Guide

MCP transports: stdio and Streamable HTTP

ssg mcp speaks the Model Context Protocol on two bindings. Protocol semantics are identical on both — a transport defines how messages are framed and delivered, not what they mean — so every tool behaves the same whichever one a client uses.

Transport When to use it
stdio (default) The client launches ssg mcp and owns its standard streams. The right choice for an assistant on the same machine
Streamable HTTP The client dials an address. The only way to reach the server from anywhere else

Both can run at once, which is the ordinary case while developing: an editor spawns the process over stdio, and a second client reaches the same running server over HTTP.

stdio

1// the client's MCP configuration
2{ "command": "ssg", "args": ["mcp", "--role=designer"] }

Nothing to configure and nothing to secure: the client is the parent process.

Streamable HTTP

1ssg mcp --listen=7823
2#  🌐 MCP endpoint: http://127.0.0.1:7823/mcp (Streamable HTTP)

The endpoint is /mcp and accepts POST. Each JSON-RPC request is its own POST and gets its own response:

1curl -X POST http://127.0.0.1:7823/mcp \
2  -H 'Content-Type: application/json' \
3  -H 'Accept: application/json, text/event-stream' \
4  -H 'MCP-Protocol-Version: 2025-06-18' \
5  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Flag Meaning
--listen=ADDR Serve the MCP endpoint. A bare port means localhost — --listen=7823 binds 127.0.0.1:7823, not every interface
--token=SECRET Require Authorization: Bearer SECRET. Falls back to $SSG_MCP_TOKEN when the flag is absent, and is minted and printed when neither is set
--allow-origin=URL Accept one browser origin; repeatable
--no-stdio Serve only the endpoint, for a server a supervisor starts rather than a client

Security

This server writes files and runs git. An MCP endpoint a web page can reach is a remote code execution path, so the transport enforces the specification's rules rather than offering them:

  • Origin is validated. Anything not on --allow-origin gets 403. Without this, a page the operator merely visits can resolve a name to 127.0.0.1 and drive a local MCP server — DNS rebinding. A request carrying no Origin is allowed: that is a non-browser client, which cannot be a rebinding victim.

  • Localhost by default, so exposing the server is a decision rather than an accident.

  • A bearer token on every endpoint, generated if you did not supply one:

    🌐 MCP endpoint: http://[::]:7824/mcp (Streamable HTTP)
       Authorization: Bearer ff4d45eb53e6f283236ca5c2ec94660f…
       ⚠️  Listening beyond localhost. This server writes files and runs git:
          put it behind TLS and keep the token secret.
    

Put a real deployment behind TLS. The token is a shared secret, and plain HTTP carries it in the clear on every request.

Where the token comes from

Three sources, in order: --token=…, then $SSG_MCP_TOKEN, then a freshly minted one printed at startup. Every --listen endpoint ends up with a token — loopback included.

That last part changed in 1.8.47. Minting used to be conditional on the listener being off loopback, which made the deployment recommended immediately below — loopback listener, reverse proxy owning the public address — the only shape that could end up with no authentication at all. --token="$SSG_MCP_TOKEN" with the variable unset expands to an empty argument; the address is loopback; nothing was minted; and the startup line said "No token — loopback only" about a server that writes files and runs git and was, at that moment, reachable from the internet. A token on a loopback listener nobody proxies costs nothing — the client is being configured anyway — so there is no longer a case without one.

Prefer the variable to the flag. A command line is visible in ps, in the shell history, in every docker inspect, and in the supervisor's own log when it echoes what it started; ssg already takes this position for mcp.git.token, whose documentation says to always use $ENV.

   Authorization: Bearer 6f1c…            # supplied: yours, unchanged
   Minted for this run — set SSG_MCP_TOKEN to keep it stable across restarts.

A minted token is new on every start, so a client configured with one stops working when the server restarts. Set SSG_MCP_TOKEN for anything long-lived.

Serving a CMS over a network

1SSG_MCP_TOKEN=… ssg mcp --listen=127.0.0.1:7823 --no-stdio

Bind to loopback and let a reverse proxy own TLS and the public address, rather than binding the server to a routable interface directly. Then the proxy holds the certificate, the rate limiting and the access log, and the MCP server keeps one job.

The token travels in the environment, not on the command line — and if the variable is missing, the endpoint still comes up authenticated with a minted token rather than open. Read the startup line to find out which happened:

🌐 MCP endpoint: http://127.0.0.1:7823/mcp (Streamable HTTP)
   Authorization: Bearer …
   Minted for this run — set SSG_MCP_TOKEN to keep it stable across restarts.

That second line is the one to alert on in a supervised deployment: it means the secret you thought you passed did not arrive.

GET and DELETE answer 405 by design: they belonged to the standalone-stream and session mechanics of earlier protocol revisions, which this transport does not implement.

OAuth 2.1

A client that only offers OAuth sign-in for a remote server cannot use a static token. With mcp.oauth set, the endpoint is an OAuth 2.1 resource server, as the MCP authorization specification describes. Tokens come from an authorization server you already run (Keycloak, Auth0, Cloudflare Access, …). ssg never issues them.

1mcp:
2  oauth:
3    issuer: https://auth.example.com/realms/site
4    resource: https://mcp.example.com/mcp     # the address clients use
5    scopes: [ssg.mcp]
6    # jwks_url: https://auth.example.com/realms/site/protocol/openid-connect/certs

What the endpoint then does:

  • Publishes Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource/mcp and at /.well-known/oauth-protected-resource, naming the issuer and the scopes.
  • Answers 401 with a challenge that points at that metadata, so the client finds where to sign in: WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="ssg.mcp".
  • Checks every access token. The signature must come from the issuer's keys, and only asymmetric algorithms are accepted (RS, PS, ES, EdDSA). iss must equal issuer. exp must be present and in the future, with a minute of leeway. aud must name resource (RFC 8707), so a token the same issuer minted for another service does not work here.
  • Answers 403 insufficient_scope for a valid token without one of scopes. Scopes are read from scope (space-separated) or scp (a list or a string).

When jwks_url is empty, the keys are found through the issuer's RFC 8414 metadata, then its OpenID Connect discovery document. That document must name the same issuer. The key set is cached for an hour. A token with an unknown key ID triggers a refetch at most every 30 seconds, so made-up key IDs cannot hammer the issuer.

resource must be https unless it points at this machine. ssg refuses to start otherwise. Behind a reverse proxy, resource is the proxy's public address and the proxy must forward /.well-known/oauth-protected-resource* as well as /mcp.

With OAuth on, no token is minted. A static token from --token or $SSG_MCP_TOKEN is still accepted beside OAuth, for scripts. Leave both unset to accept OAuth only. Without mcp.oauth nothing changes, except that a 401 now carries WWW-Authenticate: Bearer, as RFC 6750 asks.

Protocol version

ssg mcp speaks both eras, and says so:

1curl -sX POST http://127.0.0.1:7823/mcp -H 'Mcp-Method: server/discover' \
2  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover"}'
3# {"resultType":"complete","protocolVersions":["2026-07-28","2025-06-18"], …}

2026-07-28 is the stateless shape: no initialize, every request carrying its own protocol version and client identity in _meta, server/discover mandatory, a resultType on every result, and ttlMs/cacheScope on list results so a client caches instead of polling.

1curl -sX POST http://127.0.0.1:7823/mcp \
2  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
3  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list",
4       "params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'

2025-06-18 — the initialize handshake — is answered exactly as it always was. A request that declares no version in _meta is an older client, and it gets the older shape unchanged: that revision changed the protocol rather than extending it, and adopting it by abandoning the older era would strand every client that has not moved.

A version this server does not implement is refused with UnsupportedProtocolVersionError (-32022) listing what it does, so a client can retry rather than guess.

Header validation

From 2026-07-28, a POST mirrors Mcp-Method and Mcp-Name into headers so gateways can route without parsing the body — and the server checks they agree with it. An intermediary routing on the header while the server executes on the body is how a request ends up somewhere it was not authorised for, so a mismatch is HeaderMismatch (-32020):

{"code":-32020,"message":"header mismatch: Mcp-Name header value 'wrong' does not match body value 'help'"}

A name that cannot be written as plain ASCII travels as =?base64?…?= and is decoded before the comparison, so a non-ASCII tool name is not mistaken for an attack. Only modern requests are held to this: demanding the headers of an older client would reject everything it has ever sent.

The HTTP+SSE transport from 2024-11-05 is deprecated in the specification and is not implemented here; Streamable HTTP replaced it.