api-spec-cli
v0.6.2
Published
Agent-friendly CLI and SDK for exploring and calling OpenAPI, GraphQL, and MCP APIs
Maintainers
Readme
api-spec-cli
CLI and TypeScript SDK for AI agents to explore and call OpenAPI, GraphQL, and MCP APIs. CLI output is TOON by default — compact, parseable, token-efficient.
MCP gateway
Run spec mcp to serve the registry to MCP clients. The gateway uses the official MCP SDK and the same execution, credentials, caches and policies as CLI calls.
spec mcp --specs github,petstore # stdio, meta mode
spec mcp --mode tools --tools github.list_issues # stdio, direct tools
spec mcp --labels orc:project=alpha # stdio, specs carrying a label
spec mcp --transport http --port 3000 --auth env:SPEC_GATEWAY_TOKEN --specs github,petstore
spec mcp --config gateway.jsonFor HTTP, configure the MCP client with one of these URLs and an Authorization: Bearer ... header when authentication is enabled:
http://127.0.0.1:3000/mcp
http://127.0.0.1:3000/mcp?mode=meta&specs=github
http://127.0.0.1:3000/mcp?mode=tools
http://127.0.0.1:3000/mcp?mode=tools&specs=github,petstore
http://127.0.0.1:3000/mcp?mode=tools&tools=github.list_issues,petstore.getPet| URL parameter | Behavior |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| mode=meta | Default. Five fixed tools: list_specs, list_tools, search_tools, get_tool, call_tool. |
| mode=tools | Directly expose permitted upstream operations with their argument schemas. |
| specs=a,b | Exact registered spec names; omitted means all specs permitted by server configuration. |
| tools=a.tool1,b.tool2 | Exact qualified IDs (spec.operation, or spec.resource:<template>); omitted means all tools. |
| labels=a,b | Only specs carrying any of these labels; specs outside the selection are never loaded. |
All filters intersect. URL filters can only narrow operator configuration, enabled registry entries and existing allow/disable rules. Deny rules win. The same checks apply to search, schema lookup and direct calls, including cached catalogs. Unknown modes, unknown parameters, repeated parameters and empty filters are rejected. Unknown or forbidden tool IDs do not grant access. Selections are evaluated independently on every stateless HTTP request; clients retain their configured URL.
Example gateway.json (all fields optional):
{
"transport": "http",
"host": "127.0.0.1",
"port": 3000,
"auth": "env:SPEC_GATEWAY_TOKEN",
"specs": ["github", "petstore"],
"tools": ["github.list_issues", "petstore.getPet"],
"labels": ["orc"],
"disabledTools": ["github.delete_issue"],
"allowedOrigins": []
}CLI options override the file. Without exposure lists, all enabled registered specs/tools are server-permitted; use explicit lists to fix the exposure boundary. Empty server lists expose nothing. mode selects stdio mode; HTTP mode comes from the URL and defaults to meta. auth accepts existing env:NAME, ${NAME} and file:/path secret references; gateway authentication is independent of upstream credentials and is never forwarded upstream. Stdio uses local process access rather than HTTP bearer authentication. This is a shared bearer gate, not an OAuth login server or per-user authorization system.
HTTP binds to loopback by default. Non-loopback binding requires auth and an explicit allowedHosts array of hostnames (without ports). Use HTTPS termination for remote deployment. Browser origins are denied unless listed in allowedOrigins. Request bodies are limited to 100 KiB, traffic to 300 requests/minute per IP, and simultaneous MCP requests to 100. A reverse proxy does not automatically become trusted for client-IP headers. Lifecycle logs go to ~/.spec-cli/logs/mcp.log, or logFile from the config; use external log rotation. Stdio stdout contains only MCP protocol messages.
Meta discovery returns at most 20 summaries by default; offset and limit (1–100) page results. get_tool({"tool":"github.list_issues"}) returns its schema. call_tool({"tool":"github.list_issues","arguments":{...}}) executes it. MCP arguments retain their original schema; OpenAPI uses an envelope with declared path, query and JSON body; GraphQL accepts declared field arguments and generates its query. Caller-supplied endpoints, auth overrides, shell commands and local file reads are not exposed. GraphQL subscriptions, external OpenAPI schema references, multipart uploads and per-call header/cookie parameters are not supported by this gateway. Existing automatic GraphQL scalar-field selection is retained. Direct MCP names include a readable prefix and stable hash suffix to handle invalid characters and naming collisions; descriptions include the qualified ID used in URL filters. Upstream tool content and structured results are preserved; OpenAPI response headers and local spill-file paths are not exposed. If a selected upstream catalog fails, discovery fails rather than silently publishing a partial catalog.
Both modes relay upstream MCP resources and prompts through the standard resources and prompts capabilities. Resource URIs and templates are namespaced as spec://<spec>/<original-uri> (for example spec://docs/docs://pages/{slug}), and prompt names as <spec>.<prompt>; URIs inside read results and prompt messages are rewritten the same way. Reads and prompt calls go through the same spec filters and policies as the CLI (resource:<uri> and prompt:<name>, with encoded and .. forms normalized). Reads take concrete URIs only: resource names and template literals are rejected, and any other URI is checked against filters and policies in all its normalized forms. In tools URL filters and disabledTools, address them as docs.resource:docs://readme, docs.resource:docs://pages/{slug} (covers every expansion) or docs.prompt:summarize; a tools allow-list that names only tools hides every resource and prompt. These IDs share the tool namespace, so an upstream tool literally named resource:... or prompt:... is matched by the same rules. Listings load every selected MCP server (clients often list on connect, also in meta mode), are paged 100 at a time and, like tool discovery, fail rather than publish a partial list when a selected server is unavailable; narrow with specs or labels to keep connects fast.
OpenAPI query arrays use repeated keys by default (tag=a&tag=b), respecting form explode: false, space-delimited and pipe-delimited array settings. Object query serialization is unsupported.
Install
npm install -g api-spec-cliWorks with Node.js 20.19+, 22.12+, or 23+. No other dependencies.
# Or run without installing
npx api-spec-cli <command>SDK
The package also exports a typed, side-effect-free library (lib/index.js + .d.ts). The CLI is a thin layer over it, so registry, credentials, caches, usage and policy behave the same in both.
import { createSpecClient, memoryRegistryStore } from "api-spec-cli";
const client = createSpecClient(); // ~/.spec-cli/registry.json
const scoped = createSpecClient({ registry: memoryRegistryStore() }); // isolated, in-memory
client.specs.add("petstore", {
type: "openapi",
source: "https://petstore3.swagger.io/api/v3/openapi.json",
});
client.specs.add("github", { type: "http", url: "https://api.githubcopilot.com/mcp/" });
await client.operations.list("petstore");
await client.operations.search("create an issue", { spec: ["github", "petstore"], limit: 10 });
// { query, total, showing, limit, results: [{ rank: 1, spec: "github", type, id, method, path }, ...] }
await client.operations.call("petstore", "getPetById", { vars: { petId: "1" } });
await client.resources.list("docs", { filter: "page", limit: 10 }); // { total, showing, offset, resources, templates }
await client.resources.read("docs", "page", { vars: { slug: "intro" } }); // { uri, contents }
await client.prompts.list("docs"); // { total, showing, offset, prompts: [{ name, description, arguments }] }
await client.prompts.get("docs", "summarize", { arguments: { topic: "billing" } }); // { messages, ... }
await client.close();client.tools is the progressive-discovery catalog the MCP gateway is built on — list specs, search, describe one tool's JSON schema, then call it with validated arguments. Tool IDs are spec.operation; results are MCP CallToolResult objects.
const { tools } = createSpecClient({ scopes: [{ specs: ["github", "petstore"] }] });
tools.specs(); // permitted registry entries
await tools.search("pet by id"); // ranked: [{ id: "petstore.getPetById", ... }, ...]
await tools.search("pet by id", "petstore"); // one spec, or an array for a set
await tools.describe("petstore.getPetById"); // { id, spec, description, inputSchema }
await tools.call("petstore.getPetById", { path: { petId: 1 } });Search has one implementation used everywhere — tools.search, client.operations.search (same results as flat rank-numbered hits with offset/limit paging), the gateway search_tools meta-tool and spec search. It only returns tools the catalog permits (enabled specs, allow/disable filters, scopes). Every entry point searches all permitted specs, one spec, or a set (spec: "a" / ["a", "b"]; gateway spec / specs; CLI --spec a,b). A spec that fails to load (upstream down, auth expired) is skipped and listed under unavailable instead of failing the whole search; the gateway lists only names, the SDK and CLI include the error, and searching only broken specs throws. A query containing * or ? is a glob match on IDs, paths and descriptions instead of a ranked search.
search is full-text (BM25 via MiniSearch) over tool name, path, summary, description and tags. Names are split on camelCase and punctuation; stop words, plurals and common API verbs (remove → delete, fetch → get, sign in → login, repository → repo, pull request → pull) are normalized; prefix and typo matching are on. The index is built per catalog scope and reused until the permitted tool set changes. Queries are capped at 256 characters.
| Export | Purpose |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createSpecClient(options) | specs (list/get/add/remove/enable/disable/refresh/authorize/startAuthorization/finishAuthorization/revoke), operations (load/list/show/types/type/search/call), resources (list/read), prompts (list/get), tools, close |
| createToolCatalog({ entries, load?, execute?, scopes?, session? }) | Catalog over any entry source; scopes intersect and use exact tool IDs |
| createGatewayServer, startGatewayHttp, metaTools, handleMetaTool, parseSelection | Embed the MCP gateway or its five meta-tools in another MCP server |
| listOperations, describeOperation, grepOperations, executeOperation, loadSpec | Pure building blocks used by the CLI commands |
| listResources, readResource, listPrompts, getPrompt, resolveResourceUri | MCP resources and prompts building blocks behind client.resources / client.prompts and spec resources / spec prompts |
| createMcpSession({ interactiveAuth? }) | Scope MCP connections: run(fn) tracks clients opened inside fn, close() closes only those |
| memoryRegistryStore, fileRegistryStore | Registry backends; any { read, write } object works |
| setSpecHome, setCacheDir, setUsageDir, setTokenDir, setPolicyDir, setResultsDir | Redirect storage per process (defaults to ~/.spec-cli) |
| setProjectDir(dir \| null) | Where project config (.spec-cli/config.json) is read from; defaults to cwd, null disables it |
Options accept loadSpec and execute overrides for tests or custom transports. Inline sources ({ type: "openapi", source }, { type: "stdio", command, args }, …) can be passed anywhere a registered name is accepted by operations.*.
Embedding in a service
- No browser auth. SDK clients, catalogs and
startGatewayHttpnever open a browser or callback server. An MCP server that needs a new OAuth grant fails fast withMcpAuthRequiredError; runspec auth <name>once (stored and refreshed tokens are used), configureclient_credentials, or pass anAuthorizationheader. Opt back in withcreateSpecClient({ interactiveAuth: true }). - Redirect sign-in for web hosts. A server or web app that receives the OAuth callback itself calls
specs.startAuthorization(name, { redirectUrl }), sends the user to the returnedauthorizationUrl, then passes the callback'scodeandstatetospecs.finishAuthorization(name, { code, state }). PKCE and dynamic client registration are handled for you, existing tokens are kept until the exchange succeeds, and an unfinished sign-in expires after 10 minutes. - Lifecycle.
client.close()/catalog.close()close only the MCP connections that client opened, so several clients can share a process. - Isolation. Pass a
memoryRegistryStore()per tenant; the spec cache is keyed by name plus source fingerprint, so equal names in different registries never share documents. CallsetProjectDir(null)andsetLocalPolicyFile(<path>)so the host's working directory cannot inject auth, base URLs or policies. - Trusted sources only. Headers, auth and stdio env expand
${VAR},env:NAMEandfile:/pathfrom the host. Only register specs that come from operators, not from end users. - Errors. Library calls throw
Errors;tools.callreturnsisErrorresults for invalid arguments and error responses (HTTP ≥ 400, GraphQLerrors, MCPisError), and throws for unknown tools, policy blocks and transport failures (the gateway converts those to a generic MCP error).
How It Works
Every command is stateless — you specify the spec source on each call. Two paths:
| Path | When to use |
| --------------- | ------------------------------------------------------ |
| --spec <name> | Registered spec — auto-fetches and caches on first use |
| Inline flags | Ad-hoc — no registration, fetched each call |
Register once, use everywhere
spec add petstore --openapi https://petstore3.swagger.io/api/v3/openapi.json \
--description "Petstore example"
spec add hashnode --graphql https://gql.hashnode.com --auth YOUR_TOKEN
spec add agno --mcp-http https://docs.agno.com/mcp --description "Agno docs"
spec add fs --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
spec import ~/.cursor/mcp.json
spec import ~/Library/Application\ Support/Claude/claude_desktop_config.jsonRegistration is instant — does not connect. Connection happens on first list/show/call. Spec/tool catalogs are cached 30 minutes; identical call results (same path + params) are cached 1 minute. Files live in ~/.spec-cli/cache/.
Or use inline (no registration)
spec list --openapi https://petstore3.swagger.io/api/v3/openapi.json
spec list --graphql https://gql.hashnode.com
spec list --mcp-http https://docs.agno.com/mcp
spec list --mcp-sse http://localhost:3000/sse
spec list --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"Inline specs use the same file cache (keyed from URL/command), so listTools is not repeated on every call within TTL.
Discovery
Search across specs
search ranks operations/tools across all enabled specs (full-text over names, paths, summaries, descriptions and tags; see SDK for how terms are normalized). Results are one flat list, best match first, each with its rank; ranking is across all searched specs. Every hit has the same help-style shape for every spec type — rank, spec, type, id, description, where description is a one-line summary (≤120 chars); run spec show for the full description and inputs, like cli command --help. The gateway search_tools/list_tools items use the same summary. --limit (default 20, 0 = all) and --offset page that list.
spec search pet by id # Ranked: getPetById first
spec search "remove a collaborator" # Synonyms: remove → delete
spec search "get*" # Glob: IDs, paths or descriptions starting with "get"
spec search "*list*" # Glob: anything containing "list"
spec search docs --spec agno # Limit to one spec
spec search delete --spec github,petstore # Limit to a set of specs
spec search delete --limit 10 --offset 10 # Ranks 11-20Case-insensitive. Plain text = ranked search, */? = glob. Disabled specs and disabled tools are excluded. spec grep is kept as an alias.
List all specs in the registry
spec specs # Compact: name, type, enabled
spec specs --compact false # Full: includes source, configList operations / tools
list is compact (IDs only) and returns the first 20 tools/operations by default. total is the full catalog size. Use --filter, spec search, --limit, and --offset on large MCP servers instead of dumping hundreds of tools. --limit 0 prints the whole catalog.
spec list --spec agno # Registered spec (uses cache)
spec list --spec petstore --filter pet # Search by keyword
spec list --spec petstore --tag pets # OpenAPI: filter by tag
spec list --spec hashnode --tag mutation # GraphQL: filter by kind
spec list --spec petstore --limit 10 # First 10 only
spec list --spec petstore --limit 10 --offset 10 # Next 10
spec list --spec datadog --limit 0 # All tools (prefer search on large servers)
spec list --mcp-http https://docs.agno.com/mcp # Inline: no registration needed
spec specs --filter datadog # Find a registered server by name
spec search metric --spec datadog # Search 300+ MCP tools without dumping them
spec search metric --spec datadog --limit 10 --offset 10Compact output (--format json shown for readability):
{
"type": "mcp",
"total": 1,
"showing": 1,
"operations": [{ "id": "search_agno", "description": "Search across the Agno knowledge base..." }]
}Use --compact false for full details including inputSchema for MCP tools.
Inspect one operation or tool
show gives you everything to make a call — params, body schema, response schemas, related types — in one call.
spec show --spec petstore getPetById # OpenAPI: by operationId
spec show --spec petstore /pet/{petId} # OpenAPI: by path
spec show --spec petstore "GET /pet/{petId}" # OpenAPI: by method + path
spec show --spec hashnode publishPost # GraphQL: by operation name
spec show --spec agno search_agno # MCP: by tool nameMCP output includes the full inputSchema so you know exactly what arguments to pass.
Drill into types (OpenAPI/GraphQL only)
spec types --spec petstore # List all schema names
spec types --spec petstore Pet # Inspect one schema
spec types --spec hashnode PublishPostInput # GraphQL input typeMCP resources and prompts
MCP servers that advertise the resources or prompts capability expose them alongside their tools. Same shape as types: no argument lists, one argument reads or gets.
spec resources --spec docs # Resources + resource templates (first 20)
spec resources --spec docs --filter page --limit 10 # Filter and page like spec list
spec resources --spec docs readme # Read by name
spec resources --spec docs file:///README.md # Read by URI
spec resources --spec docs page --var slug=intro # Expand a resource template (by name or URI template)
spec prompts --spec docs # Prompts and their arguments
spec prompts --spec docs summarize --var topic=billing # Rendered prompt messages
spec prompts --spec docs summarize --data '{"topic":"billing"}'spec list on an MCP spec reports resources and prompts counts when the server advertises them, so agents know they exist. Missing template variables and required prompt arguments are reported before the server is called. Policy rules apply with a kind prefix so tool rules don't catch them: resources match as resource:<uri> (spec policy add --tool "resource:file:///etc/*") and prompts as prompt:<name>. The same names apply to --allow-tool / --disable-tool: an entry registered with --allow-tool get_* hides its resources and prompts unless you also allow, for example, --allow-tool "resource:docs://*". Policy and tool-filter checks see the URI as given, normalized (dot segments, unreserved escapes) and percent-decoded, so an encoded or .. variant can't slip past a rule, and a resource: pattern containing a template expression (resource:docs://pages/{slug}) matches every URI the template matches, in filters, policies and gateway scopes alike. {slug} stops at /, so a template allow-list hides URIs with an encoded slash in the variable; use {+slug} to admit them. Allow-lists must match every form of the URI; a deny on any form blocks it. Resource URIs over 8192 characters are rejected. The server receives the URI unchanged. If listing resources or prompts fails, tools keep working and spec resources / spec prompts report the error. Reads share the call cache with tool calls.
Calling APIs
# OpenAPI
spec call --spec petstore getPetById --var petId=1
spec call --spec petstore findPetsByStatus --query status=available
spec call --spec petstore addPet --data '{"name":"Rex","photoUrls":[]}'
# GraphQL (auto-generates query from schema)
spec call --spec hashnode me
spec call --spec hashnode publication --var host=blog.hashnode.dev
# MCP
spec call --spec agno search_agno --var query="how to create an agent"
spec call --spec agno search_agno --data '{"query":"agents"}'
# Read body from stdin (explicit --data -)
echo '{"query":"agents"}' | spec call --spec agno search_agno --data -
cat body.json | spec call --spec petstore addPet --data -
# Inline (no registration)
spec call --openapi https://petstore3.swagger.io/api/v3/openapi.json getPetById --var petId=1Relative OpenAPI servers[0].url values (e.g. /api/v3) resolve against the spec URL. Use --base-url to override, or when the spec is a local file.
Per-call overrides
Flags passed at call time win over registry entry config, which wins over .spec-cli/config.json.
spec call --spec agno search_agno --var query="foo" --header X-Tenant=acme
spec call --spec petstore getPetById --var petId=1 --auth staging-token
spec list --spec petstore --base-url https://staging.api.example.comRegistry Management
spec remove <name> # Delete entry and remove cache
spec enable <name> # Re-enable a disabled spec
spec disable <name> # Disable without removing
spec refresh <name> # Force re-fetch and update cachespec add is an upsert — re-adding an existing name overwrites the entry (and clears its stale cache), since there's no separate update command. The response includes "overwritten": true when an existing entry was replaced.
Labels
Tag specs to group and scope them. A label is 1-64 characters of a-z 0-9 : = . _ - (no uppercase, no silent lowercasing); a spec holds at most 32, stored deduplicated and sorted in the registry's optional labels field.
spec add petstore --openapi <url> --label orc --label orc:project=alpha # repeatable
spec label add petstore team:billing env=prod
spec label remove petstore env=prod
spec specs --label orc --label team:billing # any match
spec specs --label orc --label team:billing --all-labels # every label
spec search "delete a pet" --label orc:project=alpha # combine with --spec: both must passSDK: client.specs.list({ labels, labelMatch: "any" | "all" }), client.specs.addLabels(name, labels), client.specs.removeLabels(name, labels), client.operations.search(query, { labels, labelMatch }), and { labels, labelMatch } on a ToolScope so a catalog only loads and searches matching specs (an empty labels array in a scope matches nothing). The gateway accepts ?labels=a,b, --labels a,b or "labels" in its config (any match). normalizeLabels and matchesLabels are exported.
Usage Ranking
spec records which operations/tools you call so agents can surface the ones that matter:
spec list --spec petstore --top 5 # the 5 most-called operations first
spec usage # recorded usage across all specs
spec usage petstore # ranked operations for one specCounts are stored locally in ~/.spec-cli/usage.json. --top applies after --filter/--tag and overrides --limit. Set SPEC_NO_USAGE=1 to disable tracking.
Policy
Deny rules run on spec call only. Any matching block wins. Missing files = allow. SPEC_NO_POLICY=1 skips checks.
| File | When to use |
| ---------------------------------- | ---------------------------------------------------------------- |
| ~/.spec-cli/policies/<spec>.json | Common path — rules for one registered server |
| ~/.spec-cli/policy.json | Rules that apply across servers (globs, regex, no --spec name) |
| .spec-cli/policy.json | Project-local (--local) |
--policies-path overrides the per-spec folder (default ~/.spec-cli/policies). A plain --spec k8s writes policies/k8s.json. A glob like --spec k8s* stays in the global file.
spec policy
spec policy add --id no-prod-restart --spec k8s --tool restart \
--when pod_name.prefix=prod --message "cannot restart prod pods"
spec policy add --data '{"id":"no-deletes","tool":{"regex":"^delete_.*"},"message":"destructive tools blocked"}'
spec policy show no-prod-restart
spec policy remove no-prod-restart --spec k8s
spec policy add --local --id no-etc --tool write_* --when path.glob=/etc/* --message "no writes under /etc"--when is key=glob or key.op=value. Operators: eq, prefix, suffix, glob, regex, in, exists. Dotted keys walk nested args (metadata.namespace.prefix=prod). Tool/spec strings use the same glob rules as --allow-tool (exact or *).
A blocked call exits non-zero:
error: blocked by policy: cannot restart prod pods
rule: no-prod-restartRules with spec only apply when the call uses --spec <name>. Inline --openapi / --mcp-http / --graphql calls have no spec name, so match on tool / when instead.
Secrets & Environment Overrides
Stored values can reference secrets without putting them in argv or the registry. All of these expand at call time:
${VAR}from the environmentenv:NAME— same as mcp2cli, the whole value is an env varfile:/path/to/secret— file contents (trailing newline stripped)
spec add gh --mcp-http https://api.example.com/mcp --header "Authorization=Bearer ${GH_TOKEN}"
spec add gh --mcp-http https://api.example.com/mcp --auth env:GH_TOKEN
spec add gh --mcp-http https://api.example.com/mcp --oauth-client-secret file:/run/secrets/oauth
spec config set auth '${API_TOKEN}'A .env file in the working directory is auto-loaded on startup (real environment variables take precedence; set SPEC_NO_DOTENV=1 to disable).
Two environment variables override a registered spec's connection per call — useful in CI:
SPEC_URL=https://staging.example.com/mcp spec call --spec gh some_tool # override MCP/GraphQL endpoint
SPEC_HEADER_X_TENANT=acme spec list --spec gh # add/override a headerSPEC_HEADER_<NAME> maps underscores to dashes (SPEC_HEADER_X_TENANT → X-Tenant). Precedence: call-time flags > env override > registry entry > project config.
Agent Skill
Install a ready-made skill that teaches an agent the explore-then-call workflow:
spec skill install # copy SKILL.md into ~/.claude/skills/spec-cli/
spec skill path # print the bundled SKILL.md locationspec add options
spec add <name> --openapi <url-or-file> [--base-url <url>] [--auth <token>] [--header k=v]
spec add <name> --graphql <url> [--auth <token>] [--header k=v]
spec add <name> --mcp-http <url> [--auth <token>] [--header k=v]
spec add <name> --mcp-sse <url> [--auth <token>] [--header k=v]
spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL] [--cwd <path>]
[--description <text>] (all types)All options are repeatable where it makes sense (--header, --env). --auth adds Authorization: Bearer <token> unless the header is already set.
Operation filtering works for all spec types — MCP, OpenAPI, and GraphQL:
# MCP: allow only read/list tools
spec add <name> --mcp-http <url> \
--allow-tool "read_*" --allow-tool "list_*" \
--disable-tool "delete_*"
# OpenAPI: allow only GET operations (by operationId)
spec add <name> --openapi <url> \
--allow-tool "get*" --allow-tool "find*"
# GraphQL: allow specific operations by exact name
spec add <name> --graphql <url> \
--allow-tool "me" --allow-tool "publication"--allow-tool keeps only matching operations. --disable-tool removes matching operations (applied after allow). Both are repeatable.
Matching rules:
- Plain text → exact match (case-insensitive):
"me"matches onlyme - Glob patterns → anchored match:
"get*"matchesgetPetById,"*post*"matchescreatePost
Use search to find tools (ranked) — --allow-tool / --disable-tool for precise whitelists (exact or glob).
OAuth / Authentication
MCP HTTP and SSE servers that require OAuth 2.1 are handled automatically. spec-cli detects the 401 on spec add and runs the flow before returning.
Two modes depending on whether the server supports Dynamic Client Registration (DCR):
- DCR-enabled servers (e.g. self-hosted with Cloudflare workers-oauth-provider, Stytch, Curity) — no flags needed, browser opens automatically
- Pre-registered-only servers (e.g. GitHub) — pass
--oauth-client-idwith your app's client ID
Interactive (browser) — default
# DCR-enabled server: fully automatic
spec add myserver --mcp-http https://...
# GitHub (no DCR) — create an OAuth App at github.com/settings/developers first
# Set callback URL to http://127.0.0.1:8090/callback
spec add github --mcp-http https://api.githubcopilot.com/mcp/ \
--oauth-client-id <your-github-app-client-id> \
--oauth-callback-port 8090Headless / device flow
spec add myserver --mcp-http https://... --oauth-flow device
# Prints a URL to stderr — open in any browser to authorizeMachine / CI (client credentials)
spec add myserver --mcp-http https://... \
--oauth-flow client_credentials \
--oauth-client-id <id> --oauth-client-secret <secret>Re-authenticate
spec auth myserver # Re-run the OAuth flow
spec auth myserver --revoke # Clear stored token onlyTokens are stored in ~/.spec-cli/tokens/<name>.json — separate from the cache, not touched by spec refresh. Access tokens are refreshed automatically: when a stored token expires, the refresh token is used and the rotated tokens are persisted, so the next command picks them up without re-authenticating.
OAuth flags
| Flag | Description |
| --------------------------------- | ---------------------------------------------------------------------------------- |
| --oauth-client-id <id> | Skip DCR — use a pre-registered OAuth app client ID |
| --oauth-client-secret <secret> | Use client credentials flow (machine/CI) |
| --oauth-callback-port <port> | Fixed callback port (required for apps with exact redirect URL match, e.g. GitHub) |
| --oauth-flow device | Force device authorization flow (headless/SSH) |
| --oauth-flow client_credentials | Machine/CI flow (requires client id + secret) |
Reuse a stored MCP OAuth access token on an OpenAPI or GraphQL call (only if the token audience matches):
spec call --spec petstore getPet --auth-from github-mcpConfig
Persistent config stored in .spec-cli/config.json (lowest priority — overridden by registry entry config and call-time flags).
spec config set baseUrl https://api.example.com
spec config set auth my-token # Auto-adds "Bearer " prefix
spec config set auth "Basic dXNlcjpwYXNz" # Or explicit scheme
spec config set headers.X-API-Key abc123 # Custom header (dot notation)
spec config get
spec config unset authValidate
spec validate https://api.example.com/openapi.json
spec validate ./openapi.yamlReports broken $ref references, missing required fields, duplicate operationIds, invalid schema types, and more.
Output Format
TOON by default. Errors go to stderr as plain text (error: ...) with a non-zero exit code.
spec list --spec petstore # TOON (default)
spec list --spec petstore --format json # pretty-printed JSON
spec show --spec petstore getPetById --format yaml
spec list --spec petstore --format texttoon (Token-Oriented Object Notation) is the most token-efficient format for tabular/list output — the best choice when feeding results back into a model. Pass --format json for pretty-printed JSON. Help and errors are always plain text.
If formatted stdout is over 30000 characters (same ballpark as agent harness tool-result limits), spec writes the full payload as JSON to ~/.spec-cli/results/ and prints a stub with path so you can rg / read chunks instead of swallowing the blob. Override with --max-bytes or SPEC_MAX_STDOUT. --max-bytes 0 always prints.
cached: true
path: C:\Users\you\.spec-cli\results\2026-08-30T22-16-03-123.json
bytes: 184320
hint: rg <pattern> <path>Token Efficiency
listreturns only IDs by default — no schemasshowresolves$refcompactly — nested refs show as names, not explosionstypeslets you inspect one schema at a time--limit/--offsetpaginate large APIs (list/search default to 20)- Oversized
call/show/listoutput spills to~/.spec-cli/results/instead of flooding context --filterand--tagnarrow results before output
MCP Options
# Retry on connection failure (useful for stdio servers that take time to start)
MCP_MAX_RETRIES=3 # Attempts (default: 3)
MCP_RETRY_DELAY=1000 # Base delay in ms, doubles each attempt, capped at 5s (default: 1000)
# HTTP timeout for OpenAPI/GraphQL calls
SPEC_HTTP_TIMEOUT=30000 # ms (default: 30000)
# Storage root for registry, cache, tokens, policies, results (default ~/.spec-cli)
SPEC_HOME=/path/to/home
# File cache (global, ~/.spec-cli/cache/)
SPEC_NO_CACHE=1 # Disable meta + call cache
SPEC_META_CACHE_MS=1800000 # Tools/schema TTL (default 30m)
SPEC_CALL_CACHE_MS=60000 # Call result TTL (default 1m)Stdio env vars support ${VAR} expansion from the host environment:
spec add fs --mcp-stdio "npx -y server /tmp" --env "TOKEN=${MY_SECRET}"Storage
| Path | Purpose |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| ~/.spec-cli/registry.json | Global named registry |
| ~/.spec-cli/cache/ | File cache: meta-* (30m tools/schema) and call-* (1m results). Expired files are deleted on the next command. |
| ~/.spec-cli/tokens/<name>.json | OAuth tokens per MCP entry |
| ~/.spec-cli/usage.json | Operation/tool call counts for --top and spec usage |
| ~/.spec-cli/results/ | Full JSON for stdout that exceeded SPEC_MAX_STDOUT |
| ~/.spec-cli/policy.json | Global call-blocking policy (rules across all servers) |
| ~/.spec-cli/policies/<name>.json | Per-spec policy file |
| .spec-cli/config.json | Project-local config (baseUrl, auth, headers) |
| .spec-cli/policy.json | Project-local policy rules (merged with global; any block wins) |
All global CLI data lives in ~/.spec-cli/. On startup, an existing
~/spec-cli-config/ is renamed to ~/.spec-cli/ if the destination does not
already exist. If both directories exist, neither is merged or overwritten.
Migration failures produce a warning and leave the old directory in place;
commands continue using ~/.spec-cli/. Project-local .spec-cli/ stays separate.
Planned
- MCP resources and prompts (
spec resources/spec prompts) - OAuth for OpenAPI and GraphQL (not just MCP)
