MCP Server
spall mcp <api> serves every operation in a registered OpenAPI spec as
a Model Context Protocol tool over stdio. Drop the binary
into a Claude Desktop or ChatGPT Apps config and the AI client can call
your API with no integration code.
What it does
Given an API you’ve already added with spall api add petstore <spec-url>:
spall mcp petstore # serves on stdio, default transport
Each ResolvedOperation becomes one MCP tool. The tool’s inputSchema
is generated from the operation’s parameters and request body; on
tools/call, spall dispatches through the same request pipeline used by
spall <api> <op> (auth chain, default headers, proxy, retries).
Wire it into Claude Desktop with this entry in
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"spall-petstore": {
"command": "spall",
"args": ["mcp", "petstore"]
}
}
}
Restart Claude; the tools appear in the sidebar.
Usage
spall mcp <api>
[--spall-transport stdio|http]
[--spall-port <N>] # HTTP only; default 8765
[--spall-bind <addr>] # HTTP only; default 127.0.0.1
[--spall-allowed-origin <origin>] # HTTP only; repeatable
[--spall-include <tag>] # repeatable
[--spall-exclude <tag>] # repeatable
[--spall-max-tools <N>]
[--spall-list-tags]
--spall-transportselects the wire protocol:stdio(default) for Claude Desktop / config-launched servers.httpfor Streamable HTTP per MCP spec 2025-06-18 §HTTP; see Running over HTTP below.
--spall-include <tag>keeps only operations carrying that OpenAPI tag (repeatable; union semantics).--spall-exclude <tag>removes operations carrying that tag.--spall-max-tools <N>deterministically truncates the filtered registry toNtools when the spec exceeds the cap. See Sizing your server for the ordering rule.--spall-list-tagsloads the spec, prints atag\tcount\tsample-op-idTSV to stdout, and exits without starting the server. Useful for crafting an--spall-includefilter.- Operations with no
tagsbelong to a synthetic tag nameddefault— you can include/exclude them by that name.
Tool naming
Tool names come straight from the operation’s operationId, sanitized
to fit MCP’s allowed character class ([A-Za-z0-9_./-], max 64 chars,
lowercased). For example:
operationId | tool name |
|---|---|
getPetById | getpetbyid |
create user | create-user |
Foo::Bar | foo-bar |
If two sanitized names collide (extremely rare; the resolver
deduplicates operationId collisions on load), spall appends -2,
-3, etc.
Auth
tools/call runs the standard spall auth resolution chain (env var →
hasp → OAuth2 stored token → config field). You must configure
credentials out-of-band before starting the server; MCP gives no
opportunity to prompt interactively.
Per-tool auth profiles
Some APIs mix public-read and admin-write endpoints, or carry separate
keychain entries per operation class. Two surfaces let you pin
specific tools to a non-default [profile.*] block from the API’s
config:
spall mcp github \
--spall-auth-tool delete-repo=admin \
--spall-auth-tool transfer-repo=admin
The flag is repeatable; <tool> matches either the sanitized tool
name from tools/list or the raw operationId from the spec.
Equivalently, declare the binding inline on the operation in your
spec via the extension x-mcp-auth-profile:
paths:
/repos/{id}:
delete:
operationId: delete-repo
x-mcp-auth-profile: admin
...
When both forms target the same tool, the CLI flag wins.
Profiles named via either path are validated at server start; an unknown profile name aborts startup with the list of configured profiles so typos surface immediately.
Tool annotations
Each entry in tools/list carries an annotations block with
client-confirmation hints derived from the HTTP method
(MCP spec 2025-06-18 §tools):
| Method | readOnlyHint | destructiveHint | idempotentHint |
|---|---|---|---|
| GET / HEAD / OPTIONS / TRACE | true | false | true |
| PUT / DELETE | false | true | true |
| PATCH | false | true | false |
| POST | (omitted) | (omitted) | (omitted) |
POST is intentionally hint-free — the server cannot infer intent.
Override any hint with the operation-level x-mcp-annotations
extension, which merges field-by-field over the derived defaults:
paths:
/search:
post:
operationId: search
x-mcp-annotations:
readOnlyHint: true # POST that is in fact read-only
idempotentHint: true
...
Unknown keys (e.g. openWorldHint) pass through so future MCP spec
additions don’t require a spall release.
The title annotation is auto-derived from the operation’s
summary field — MCP clients (Claude Desktop, Cursor, ChatGPT Apps)
render this in their tool pickers as a human-readable display name
in place of the sanitized tool slug. An explicit
x-mcp-annotations.title in the spec overrides the summary-derived
default; if neither is present, the field is omitted (clients fall
back to the tool name).
Each tool entry also carries _meta.spall.tags with the OpenAPI tag
list — useful for clients that surface tags in their UI.
Debugging
Pass --spall-verbose to spall mcp <api> (any transport) to dump
server-lifecycle and per-call diagnostics to stderr. Stdout
remains pure JSON-RPC; the verbose stream never crosses the protocol
channel, so it’s safe to enable while clients are connected.
Each event is one stderr line prefixed with [spall-mcp]:
[spall-mcp] kind=startup api=petstore transport=stdio tools=42 profiles=admin,readonly
[spall-mcp] kind=tools/call tool=getpetbyid profile=<default> method=GET url=/pets/{petId}
[spall-mcp] kind=http-request origin=https://app.example.com allowlist=https://app.example.com headers={...}
Profile names that appear in the startup line are the set spall
validated against your config. A profile only appears on a
tools/call line when a request actually triggered it — profile
resolution is lazy, so profiles you never invoke stay un-resolved
(and therefore can’t leak via expose_secret).
What is redacted
-
HTTP request headers (case-insensitive name match):
Authorization→ rendered asBearer [REDACTED],Basic [REDACTED], or[REDACTED]for other schemes; the auth scheme is preserved so “wrong auth kind” is still debuggable.Cookie→[REDACTED].Proxy-Authorization→ same asAuthorization.
The list is hardcoded in
spall-cli/src/mcp/verbose.rs::REDACTED_HEADER_NAMESand a unit-test drift guard asserts every entry actually triggers a redaction.
What is NOT redacted in v1
This is the honest scope statement — do not assume the verbose stream is safe to share verbatim:
- URL query parameters. The
tools/callline emits the OpenAPIpath_template(e.g./pets/{petId}), not the rendered URL with query string. Path-segment values (the{petId}substitution) are not in the verbose log because the actual rendering happens downstream of the MCP dispatcher. A future version may render + redact the URL with?api_key=[REDACTED]semantics. - Request bodies of the upstream API call.
- Response bodies and response headers of the upstream API call.
- Custom organization-specific header names outside the
hardcoded list above. If your spec uses
X-Foo-Tokenor similar for a credential, do not enable--spall-verbosein environments where stderr is captured to durable storage. - Browser CORS preflight rejections never reach the per-request log; only POST requests that pass the CORS layer are visible.
If you need to share a verbose dump, pipe through
--spall-verbose 2>&1 | tee debug.log and review debug.log
manually before sharing. The redactor closes the most common leakage
path (Authorization headers) but is not exhaustive.
--spall-verbose is also wired on the request-execution path (when
you run spall <api> <op>) for header-trace debugging; the two uses
of the flag are independent and may be combined.
Limitations
- Tools only. No MCP
resourcesorpromptssurfaces in v1. - Single request/response per tool. The HTTP transport’s SSE plumbing (content-negotiated POST + keep-alive GET channel) is in place, but every tool is one buffered round-trip — there is no long-running tool source emitting progress frames yet (#48) and no server-push source on the GET channel (#47).
oneOf/anyOf/allOfare flattened. Spall’s resolver collapses schema composition on load, so each tool’sinputSchemareflects a single resolved branch. If your spec relies heavily on polymorphism, the tool input shape may be coarser than the spec suggests.- Recursive schemas collapse. Schemas that hit the
$refcycle / depth guard emit{ "description": "cyclic schema omitted" }in place; clients see a permissive empty schema.
Running over HTTP
--spall-transport http switches the server from line-delimited
JSON-RPC over stdio to Streamable HTTP per MCP spec 2025-06-18
§HTTP. The wire shape:
- One POST endpoint at
/(the bind root). Body is one JSON-RPC 2.0 frame. A request (a frame with anid) content-negotiates its reply on theAcceptheader:- Default (
Accept: application/json, or noAcceptheader): one JSON-RPC reply object asapplication/json. The server MAY always answer JSON, per the spec’s “Sending Messages” clause 5, so this is the shape every client gets unless it opts into streaming. Accept: text/event-stream: the reply is atext/event-streambody carrying onedata:event per yielded frame, then the stream closes. spall v1 tools are all single request/response (one reply frame), so the SSE body normally carries a singledata:event; multi-frame streaming awaits a long-running tool source (see #48).- A notification or response frame carries no reply, so the server
answers
202 Acceptedwith an empty body, per the spec’s “Sending Messages” rule.
- Default (
- One GET endpoint at
/for the server→client SSE channel. With a validMcp-Session-IdandAccept: text/event-stream, the server opens a keep-alive-onlytext/event-stream— the conformant “I offer a stream but have nothing to push yet” shape. spall v1 has no server-push source, so the stream emits only keep-alive comment pings; per-session push subscription state is tracked in #47. The Origin and session-id gates apply identically to POST and DELETE. Mcp-Session-Idheader is issued oninitializeand required on every subsequent request. Sessions live for the process lifetime; restarting the server invalidates all existing sessions.- Session termination: a client ends its session with
DELETE /carrying itsMcp-Session-Id. The Origin gate applies identically and rejects (403) before the session-id is read. A valid header returns200 OKwith no body; the operation is idempotent — a secondDELETEfor the same (now-absent) id still returns200 OK, since the session no longer exists either way. A missing or emptyMcp-Session-Idheader is a malformed request and returns400 Bad Request. MCP-Protocol-Versionheader is validated on every post-initializerequest. If the header is absent, the server assumes2025-03-26(the spec’s backward-compatibility default) and proceeds. If it is present but unsupported, the request gets400 Bad Request. The supported set is2025-06-18(advertised),2025-03-26(assumed default), and2025-11-25.initializeis exempt, since the client has not yet learned a version to send.- Streaming (
text/event-stream) is wired on both the POST reply (viaAcceptcontent negotiation) and the GET channel, as described above. spall’s v1 tools are all single request/response, so an SSE-accepting POST carries a singledata:event and the GET channel is keep-alive-only — the enabling shape is in place, but there is no multi-frame / server-push source yet (#47, #48).
# Localhost by default (MCP spec recommendation; mitigates DNS rebinding).
spall mcp petstore --spall-transport http --spall-port 8765
# Bind on all interfaces — combine with a reverse proxy that adds auth + TLS.
spall mcp petstore --spall-transport http --spall-port 8765 --spall-bind 0.0.0.0
# Pass --spall-port 0 to let the kernel pick a free port. The bound
# port is logged to stderr:
spall mcp petstore --spall-transport http --spall-port 0
# [spall-mcp] listening on http://127.0.0.1:54321/
Origin allowlist (DNS rebinding mitigation)
The spec requires the server to validate the Origin header to block
DNS-rebinding attacks. spall’s policy:
-
Allowlist set (
--spall-allowed-origin <origin>, repeatable): only listed origins succeed; all others get403 Forbidden. The CORS preflight layer is configured against the same list so browsers see a coherent preflight rejection rather than a generic CORS error.spall mcp petstore --spall-transport http \ --spall-allowed-origin https://app.example.com \ --spall-allowed-origin https://staging.example.com -
Allowlist empty (default): non-browser callers (no Origin header — curl, the MCP test client) and localhost browsers (
http://localhost[:N],http://127.0.0.1[:N],http://[::1][:N], same withhttps) succeed. Browsers with a remote Origin get403. This closes the DNS-rebinding hole where an attacker- controlled DNS record atlocalhost.example.com → 127.0.0.1could otherwise drive a victim’s browser into the local server.
Request body size
Capped at 16 MiB. Larger requests get HTTP 413 Payload Too Large.
OpenAPI specs with very large multipart payloads should sit behind a
reverse proxy that handles streaming uploads, or run as stdio.
TLS, auth on the HTTP endpoint
Both are deliberately not in scope for the spall server itself. The
expected deployment is a reverse proxy (Caddy / Nginx / Cloudflare /
fly proxy / etc.) that terminates TLS and adds auth, with spall
listening on a private port behind it. This matches the
claude-desktop / chatgpt-apps deployment pattern and keeps spall’s
dep tree small.
Sizing your server
MCP clients impose practical limits on how many tools they surface from a single server. Claude Desktop in particular silently truncates near 100 tools (see modelcontextprotocol/discussions/537). Stripe / AWS / GitHub-class specs blow well past this in one server.
Spall surfaces this in three ways:
-
Startup warning. When the filtered tool count exceeds 100, the server emits a stderr warning naming the most populated tags so you can pick a filter:
spall mcp: WARNING 247 tools exceeds the ~100-tool cap most MCP clients ... spall mcp: top tags by population: users=42, orgs=38, repos=37, gists=21, billing=18 -
Discovery flag.
--spall-list-tagsdumps every tag in the filtered registry as TSV without starting the server, so you can shape your--spall-includelist ahead of time:$ spall mcp github --spall-list-tags tag count sample-op-id actions 48 actions/list-workflow-runs billing 12 billing/get-shared-storage ... -
Auto-truncation.
--spall-max-tools <N>deterministically caps the registry. The ordering rule is:- Bucket each operation by its first tag in spec order
(untagged operations land in
default). - Sort buckets alphabetically.
- Within each bucket, keep spec order.
- Take the first
N; ties on count are broken by spec order.
The selected subset is stable across runs on the same spec — useful for predictable CI behavior. An operation that’s truncated out has no way to come back without rerunning with a higher
Nor a different filter. - Bucket each operation by its first tag in spec order
(untagged operations land in
Troubleshooting
Claude Desktop only shows some of my tools
See Sizing your server. The startup warning is
your first signal; --spall-list-tags plus --spall-include or
--spall-max-tools are the levers.
“Server disconnected” / corrupted JSON-RPC stream
Stdio MCP requires that only JSON-RPC is written to stdout. Spall’s
server hot path uses eprintln! for diagnostics and never writes to
stdout outside of protocol replies. Sanity check:
echo '' | spall mcp <api>
The server should print its single-line stderr banner and exit on EOF with zero stdout output.
“Unknown argument”
Tool arguments are routed to the parameter location declared in the
spec. Pass each parameter by its spec name (not the --query /
--header flag used on the CLI). The reserved key body carries the
JSON request body when the operation declares one.