MCP authorization: what `flow mcp` must serve to be an OAuth 2.1 protected resource, and how an agent gets a token at all
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
#549 says in one sentence what this issue is about: "flow mcp is stdio and in-process today, so it inherits whatever the parent process has; the moment it is reachable over HTTP it needs the OAuth story MCP now expects." This is that slice, worked out against the spec as it actually reads in August 2026 rather than as it read when most implementations were written — and the headline is that the spec moved twice since, and the piece flowstate would have to build is smaller than the piece it already has.
This hangs off #549 (the serving surface it needs), #557 (the principal it produces), and #548 (the vocabulary the resulting policy is written in). None of those are restated here.
What is true on main
Verified at write time, because the gap here is easy to overstate in both directions.
flow mcpserves over&mcp.StdioTransport{}only (cmd/flow/mcp.go:262). There is no HTTP listener for it, no session, and no per-caller anything. Every posture decision — egress policy, secret providers,--reveal-sensitive, plugin dir, the run-local timeout — is taken once at process start, whichrunMCPsays is deliberate: "per-call escalation is impossible" because the only caller is the process that spawned it.- The surface already documents its own identity hole, in the tool description a model reads: "Over stdio the signal is delivered as this process's own identity, not as the identity of whoever asked for it. Nothing on this transport can attest that a particular human approved anything" (
cmd/flow/mcp.go:110). That is correct today and is exactly what an HTTP transport would change. github.com/modelcontextprotocol/go-sdk v1.7.0is already a direct dependency, and it already ships the resource-server half:mcp.NewStreamableHTTPHandler(mcp/streamable.go:232),auth.RequireBearerTokenmiddleware that emitsWWW-Authenticate: Bearer resource_metadata=…, scope=…on 401/403 (auth/auth.go:97), andauth.ProtectedResourceMetadataHandleroveroauthex.ProtectedResourceMetadata(auth/auth.go:188). We are not writing RFC 9728 by hand.auth.OIDCVerifier.Verifyalready performs, per its own contract, every check OAuth 2.1 §5.2 asks a resource server for: signature against a discovered JWKS,algallowlist withnoneand HMAC refused,exp/iat/nbf, exactissmatch against a trusted issuer, andaudcontaining an audience that issuer accepts (auth/verifier.go:270-278, policyAudiencesatauth/policy.go:81-88). The audience validation the MCP spec is most insistent about is the one piece of this that is finished.serverHandleralready has the shape a.well-knowndocument needs: the default route is authenticated, and public documents are mounted explicitly beside it, only when configured (cmd/flow/routing.go:75-105, JWKS and OpenID discovery today). Adding one more unauthenticated well-known route is a two-line change to a function whose doc comment already explains why those routes sit outside the middleware.- What is genuinely absent: no
.well-known/oauth-protected-resource, no authorization endpoint, no token endpoint, no device endpoint, no PKCE, noflow logincommand at all (grepfinds noUse: "login"), andgolang.org/x/oauth2 v0.36.0is still indirect.auth/exchange_oauth.goimplements RFC 8693 token exchange outbound — flowstate's own assertion traded at somebody else's AS — withsubject_tokenand noactor_tokenanywhere in the tree.
So: flowstate can already verify an MCP-compliant token correctly. It cannot advertise that it wants one, and nobody can get one from it.
The spec as it stands, which is not the spec most implementations were built against
The current revision is 2026-07-28. Two revisions changed the shape of this since the widely-implemented 2025-06-18:
- Dynamic Client Registration is deprecated. RFC 7591 dropped from SHOULD to MAY, "retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents". The replacement is OAuth Client ID Metadata Documents (
draft-ietf-oauth-client-id-metadata-document-00): theclient_idis an HTTPS URL with a path component, the AS fetches it and MUST validate that the document'sclient_idmatches the URL exactly and that the presentedredirect_uriis in the document. Clients pick in priority order: pre-registration, then CIMD if the AS advertisesclient_id_metadata_document_supported, then DCR, then ask the user. - Authorization server metadata is now "at least one of" RFC 8414 or OpenID Connect Discovery 1.0 — but clients MUST support both. An AS publishing only
/.well-known/openid-configurationis now conformant. - RFC 9207 issuer identification landed: the AS SHOULD return
issin authorization responses (withauthorization_response_iss_parameter_supportedin metadata), and clients MUST compare it to the recorded issuer by simple string comparison, with normalization explicitly forbidden. The spec says outright that a future revision upgrades this to MUST. - Scope challenges and step-up are now normative on the resource server: a
scopeparameter SHOULD appear in the 401 challenge,403witherror="insufficient_scope"and the full required scope set SHOULD be the answer to an under-scoped token, and servers SHOULD emit all scopes for an operation in one challenge rather than incrementally.
What did not change, and is the part that binds a resource server: MCP servers MUST implement RFC 9728, MUST use WWW-Authenticate on 401 to point at the resource metadata URL, MUST validate that tokens were issued specifically for them as audience per RFC 8707 §2, MUST NOT accept or transit any other tokens, and MUST NOT pass a client's token through to an upstream API. Clients MUST send resource on both the authorization and token requests, and MUST implement PKCE (S256 where capable). And the stdio guidance is unchanged and explicit: implementations using stdio SHOULD NOT follow this specification, and instead retrieve credentials from the environment.
That last line matters more than it looks: it means today's flow mcp is not out of compliance, it is out of scope. Nothing here is a fix to an existing defect. It is the price of a new transport.
Resource server versus authorization server, and what each costs
| Obligation | Resource server (flowstate) | Authorization server |
|---|---|---|
/.well-known/oauth-protected-resource with authorization_servers, resource, scopes_supported (RFC 9728) |
MUST | — |
WWW-Authenticate on 401 pointing at that document, with scope |
MUST / SHOULD | — |
| Audience validation of every inbound token (RFC 8707 §2) | MUST — ships today | — |
403 + insufficient_scope + required scopes |
SHOULD | — |
| Never forward a client's token upstream | MUST | — |
| OAuth 2.1 for confidential and public clients | — | MUST |
| RFC 8414 or OIDC Discovery metadata | — | MUST |
| Authorization + token endpoints, PKCE S256 verification, exact redirect-URI matching | — | MUST |
| CIMD fetch, validate, cache (and SSRF-bound that fetch) | — | SHOULD |
RFC 9207 iss in authorization responses |
— | SHOULD, becoming MUST |
| Refresh-token rotation for public clients | — | MUST |
| Device authorization grant (RFC 8628, not part of OAuth 2.1 core) | — | if headless clients are supported |
Recommendation: resource-server-first, which is #549's Q2 answer restated with the bill attached. The resource-server column is roughly: one JSON document, one route in serverHandler, an adapter from auth.Verifier to the SDK's TokenVerifier, a scope vocabulary, and tests. The authorization-server column is a permanent obligation to track a spec that has changed its client-registration story twice in a year, plus persistent state for clients, codes, and refresh tokens — which #557 Q3 already identifies as the largest hidden cost in that issue, for the same reason.
What resource-server-first costs, stated plainly rather than buried:
- A deployment with no IdP cannot use HTTP MCP at all.
--insecure-no-authcovers loopback development and nothing else. This is a real regression against invariant 8 ("a first run needs nothing") for anyone whose first run is an agent on another machine, and it is the argument for eventually adding a device grant. - We do not control the token's shape, so
scopesemantics, lifetime, and anyon_behalf_ofclaim are whatever the IdP emits. Agent delegation (below) becomes claim-mapping rather than something we mint. - The 403/
insufficient_scopepath is only as good as the scopes the IdP can issue. A deployment whose IdP cannot expressflowstate:signalseparately fromflowstate:rungets coarse authorization, and the step-up flow degrades to "re-auth and get the same token back". - Confused-deputy risk moves to us anyway. flowstate's tasks make outbound HTTP. The spec's MUST NOT-pass-through rule means the inbound MCP token can never become the outbound credential — which is an argument, from a completely different direction, for #549's credential-injecting egress proxy.
PKCE everywhere, and the grants that are not on the menu
If flowstate ever does serve authorization endpoints, OAuth 2.1 is the floor and #549 is right that it is mostly a list of refusals — but the refusals should be structural, not configuration. The schema should not be able to spell an implicit grant or a password grant, so that "we do not support it" is a compile error rather than a policy check somebody can flip. PKCE is not a boolean on an authorization-code grant; a grant message with a code flow and no verifier is a message that should not exist.
The device authorization grant (RFC 8628) is the interesting one and it is deliberately not part of OAuth 2.1 core — it is a separate, additive grant. It is also the only shape that fits three of this repository's real callers: a CLI on a headless box, an agent host with no browser, and CI. It is the one AS-side capability worth building even under a resource-server-first posture, because it is the one that closes cost (1) above. The client half of it, though, is worth building first and independently: flow login --device against somebody else's AS is useful the day HTTP MCP exists, and needs no AS work from us.
How an agent gets a token
This is the part that has no good precedent, and the wrong answers are both available and tempting.
A service account is wrong because the agent's authority would be a standing grant that outlives the request and names no human — the thing #557 is explicitly trying not to build a fourth spelling of. A browser flow is wrong because there is no browser, and worse, an agent that can drive one is an agent that can be phished on a human's behalf. client_credentials is wrong for the same reason as the service account: the ext-auth SEP-1046 client-credentials handler that ships in the SDK (auth/extauth/client_credentials.go) is explicitly "for service-to-service authentication where the client has pre-registered credentials and does not require user interaction" — the correct tool for a workload, and a category error for an agent acting for a person.
The right answer already has a spelling in this repository's design, and it is #557's: PRINCIPAL_KIND_AGENT with a populated on_behalf_of. The agent's token names the agent as subject and the human as delegator, and both are visible to policy at once, which is what makes "a human may deploy; an agent acting for a human may not" expressible. Concretely, on an HTTP MCP surface:
- The human authenticates once (device grant, or their IdP's own flow) and the agent never holds the human's token.
- The agent presents its own credential, and the delegation is carried in the token — either by an IdP that can mint one, or through RFC 8693 token exchange with
actor_token, which is the standard's own name for exactly this: subject token = the human, actor token = the agent. The tree implements 8693 already, but only outbound and only withsubject_token(auth/exchange_oauth.go:150); anactor_tokenparameter and theactclaim it produces are the missing half. - The MCP surface maps the resulting claims onto one
Principalwithkindandon_behalf_of, and #548's single vocabulary means the egress rule, the secret rule and the MCP tool authorization all read the sameidentity.on_behalf_of— no fourth notion of who is calling. on_behalf_ofis never assertable by the caller, exactly asnamespaceis not today. It comes from the verified token or it is absent, and absent is refused where a rule requires it.
This also finally answers the caveat flow mcp prints today. An approval delivered over an authenticated HTTP MCP session with identity.kind == "human" and recent evidence can attest that a particular person approved something; an agent's flowstate_signal with kind == "agent" and on_behalf_of set is a materially different, and readable, event. distinct_from_starter becomes checkable against a real identity instead of a process.
What changes when flow mcp becomes reachable over HTTP
The transport change is not "add a listener". It invalidates the design premise of the whole command, which is worth stating because the current code is correct under that premise and would become wrong without changing a line.
runMCP's posture decisions are process-wide because there is exactly one caller and it is the parent process. Over HTTP there are many callers, of several kinds, in several tenants. Every one of these becomes a per-principal question:
--egress-policydecides whatflowstate_run_localmay reach. Process-wide today; per-principal over HTTP, or every caller shares the most permissive tenant's allowlist. This is #548'sidentity-keyed evaluation, and it is a hard prerequisite, not a nicety.--secret-env/--secret-dir/--auth-policydecide what a local run may resolve. Same problem, sharper: the tenancy lesson in CLAUDE.md is about exactly this, and an HTTP MCP surface with a process-wide secret store is the "A can reach A" test passing while A reaches B.--reveal-sensitiveis documented as "one deliberate, written-down decision that every call this process serves shows declared-sensitive values in the clear". Over HTTP that sentence describes a data leak to whoever authenticates.maxMCPResultBytesandrun-local-timeoutare per-process bounds on a single trusted caller; over HTTP they are per-caller resource bounds and the aggregate is unbounded. Concurrency and total in-flight local runs need their own bound, by the house rule that you bound the resource the peer controls the ratio to.flowstate_run_localandflowstate_testexecute submitted code in the server process. On stdio that is the author's own machine. Over HTTP it is remote code execution as a feature, which is #548's compute-and-storage gap arriving with a caller attached.- Session binding. The SDK's
TokenInfo.UserIDexists specifically "to prevent session hijacking by ensuring that all requests for a given session come from the same user" — a streamable-HTTP session must be pinned to the principal that created it, which needs thePrincipalto map onto that field.
My reading: HTTP MCP should be a separate command or an explicit flag with its own default-off posture, not a transport switch on flow mcp, precisely so that the process-wide posture flags do not silently change meaning. And the local-execution tools (flowstate_run_local, flowstate_test) should be independently gated on that surface — flowstate_test is defensible since a stubbed run reaches nothing by construction, and flowstate_run_local is not.
Sketches
Illustrative, not the landed shape. The configuration extends #549's AuthConfig rather than inventing a parallel one.
// Illustrative, not the landed shape. Field 4 continues #549's AuthConfig.
message AuthConfig {
bool require_dpop = 1;
bool require_mtls_binding = 2;
repeated Grant grants = 3;
// Present means this deployment serves RFC 9728 protected resource metadata
// and requires a token on the MCP surface. Absent means the MCP surface is
// not served over HTTP at all: there is no unauthenticated variant of it.
ProtectedResource protected_resource = 4;
}
// The RFC 9728 document, as configuration rather than as a hand-written JSON
// file, so a deployment cannot publish a resource identifier that disagrees
// with the audience the verifier actually checks.
message ProtectedResource {
// The canonical URI of this MCP server (RFC 8707 section 2). No fragment,
// no trailing slash. This is also the audience every inbound token must
// carry, which is why it is one field and not two.
string resource = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.uri = true,
(buf.validate.field).string.pattern = "^https://[^#]*[^/#]$"
];
// At least one, per the MCP specification's MUST. Each is an issuer
// identifier that must already appear in the trust policy: advertising an
// authorization server whose tokens the verifier would reject is a
// startup failure, not a runtime 401.
repeated string authorization_servers = 2 [
(buf.validate.field).repeated.min_items = 1,
(buf.validate.field).repeated.items.string.uri = true
];
// The minimal set for basic functionality; finer scopes are challenged for
// per operation via 403 + insufficient_scope.
repeated string scopes_supported = 3 [
(buf.validate.field).repeated.items.string.min_len = 1
];
}
The Go is mostly an adapter, because both halves already exist:
// MCPTokenVerifier adapts the Verifier that already performs OAuth 2.1 section
// 5.2 validation to the callback the MCP SDK's middleware wants. resource is
// the canonical URI from ProtectedResource; a token whose audience does not
// carry it is refused here rather than reaching a tool, which is the MCP
// specification's MUST and this repository's fail-closed default agreeing.
//
// It never returns a partially verified TokenInfo: the SDK treats a non-nil
// TokenInfo as authenticated, so the only safe error path is a nil one.
func MCPTokenVerifier(v auth.Verifier, resource string) mcpauth.TokenVerifier
// PrincipalFromTokenInfo recovers the Principal a tool handler authorizes
// against, so an MCP tool reads the same identity a Connect RPC handler does
// (#548's one vocabulary), including kind and on_behalf_of (#557).
func PrincipalFromTokenInfo(ctx context.Context) (auth.Principal, bool)
// ProtectedResourceHandler serves RFC 9728 metadata. Mounted beside the JWKS
// route in serverHandler and deliberately outside the authenticator, for the
// reason that file already gives: a client fetches this before it holds any
// credential, and a 401 here is the failure that looks like a signing bug.
func ProtectedResourceHandler(pr *v1.ProtectedResource) http.Handler
What an operator writes, continuing #549's file:
server:
public:
address: ":443"
tls:
acme:
hosts: [flowstate.example.com]
auth:
protected_resource:
resource: https://flowstate.example.com/mcp
authorization_servers:
- https://acme.okta.com
scopes_supported: [flowstate:read, flowstate:run]
and the wire, which is the whole of what a compliant client needs from us:
$ curl -i https://flowstate.example.com/mcp
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://flowstate.example.com/.well-known/oauth-protected-resource",
scope="flowstate:read"
$ curl -s https://flowstate.example.com/.well-known/oauth-protected-resource
{
"resource": "https://flowstate.example.com/mcp",
"authorization_servers": ["https://acme.okta.com"],
"scopes_supported": ["flowstate:read", "flowstate:run"],
"bearer_methods_supported": ["header"]
}
$ flow login --server https://flowstate.example.com # device grant, no browser on this box
open https://acme.okta.com/activate and enter code WDJB-MJHT
waiting…
logged in as alice@example.com (expires in 8h)
$ flow mcp --http :8443 --egress-policy ./policy.yaml
serving MCP over HTTP; every caller must present a token for
https://flowstate.example.com/mcp — local-execution tools are off
unless --allow-run-local is given
The sequence, once, because the 401-first bootstrap is the part everyone gets wrong:
sequenceDiagram
participant A as agent (MCP client)
participant F as flow mcp --http<br/>(resource server)
participant AS as authorization server<br/>(Okta / Auth0 / ...)
A->>F: initialize, no token
F-->>A: 401 + WWW-Authenticate:<br/>resource_metadata=..., scope="flowstate:read"
A->>F: GET /.well-known/oauth-protected-resource
F-->>A: resource + authorization_servers + scopes_supported
A->>AS: GET metadata (RFC 8414, else OIDC Discovery)
AS-->>A: token_endpoint, device_authorization_endpoint,<br/>client_id_metadata_document_supported
Note over A,AS: client_id is the agent's own HTTPS<br/>metadata document URL (CIMD), not DCR
A->>AS: device code + PKCE S256 + resource=https://flowstate.example.com/mcp
AS-->>A: access token (aud = that resource, act = the agent)
A->>F: MCP request + Bearer token
F->>F: verify: iss, alg, exp, aud == resource<br/>then map claims to Principal{kind, on_behalf_of}
F-->>A: tool result, authorized against one policy vocabulary (#548)
Constraints
- Fail closed on the new surfaces. No protected-resource configuration means no HTTP MCP surface, not an unauthenticated one. An
authorization_serversentry the trust policy does not already accept is a startup failure, not a per-request 401 — the compile-at-load rule #548 states. - Never pass a token through. The MCP spec's MUST NOT and this repository's own instinct point the same way: the inbound MCP token must never become an outbound credential for an
http:step. That is the strongest available argument for #549's credential-injecting proxy. - Bound every new parser, before authentication. Protected-resource and AS metadata documents, CIMD fetches (which are SSRF by design — an attacker-chosen URL the server fetches), device-code polling, and JWKS. The
plugin/transport.golesson applies verbatim: the cap belongs on theRoundTripper, below whichever library performs the fetch, because a non-200 body is the path a hostile peer takes. - Test that A cannot reach B. A token minted for another resource must be refused (audience), a token from an untrusted issuer must be refused, an agent's token whose
on_behalf_ofnames a human in another namespace must be refused, and a session created by one principal must refuse requests carrying another's token. - No credential material in history, logs, or an agent's context. A tool result is an untrusted-consumer surface —
mcp.goalready reasons this way forsensitive:— and a token, a device code, or a PKCE verifier reaching a transcript is the same leak, with the containment shapes invariant 7 requires. - Nothing changes for stdio. The spec says stdio SHOULD NOT do any of this and SHOULD take credentials from the environment, which is what
flow mcpdoes today. Whatever lands must leave the stdio path exactly as it is, including its honest identity caveat.
Questions
- Resource server only, at least for now? Recommended yes — publish RFC 9728, verify external tokens with the audience check that already ships, and build no authorization endpoints. It is the smallest thing that makes HTTP MCP real, and the AS column is a permanent obligation against a spec that has changed its registration story twice in a year. (Same answer as #549 Q2, now with the four costs above attached.)
- Is HTTP MCP a flag on
flow mcpor its own command? Recommended its own explicit surface, becauserunMCP's process-wide posture flags are correct for one trusted caller and silently wrong for many, and a flag makes that reinterpretation invisible. - Are
flowstate_run_localandflowstate_testserved over HTTP? Recommendedflowstate_testyes (a stubbed run reaches nothing by construction, already proven by test),flowstate_run_localno unless separately opted into, since it is remote code execution in the server process. - Does the device authorization grant land as client-only first? Recommended yes —
flow login --deviceagainst somebody else's AS needs no AS work from us and is the thing that makes a headless box usable on day one. Serving the device endpoint ourselves is the one AS capability worth revisiting later, because it is what closes the no-IdP gap. - Where does agent delegation come from: IdP claims, or RFC 8693
actor_token? Recommended: read it from claims first (pure mapping, no new protocol), and addactor_tokento the existing exchanger as the second step, since the exchanger already exists and only lacks that parameter. - Does
on_behalf_ofgate the approval path immediately? Recommended yes — the one thing HTTP MCP buys that stdio cannot is an attested approver, and shipping the transport without makingdistinct_from_startermean something on it would waste the entire point. - Do we implement CIMD, DCR, both, or neither on the client side? Recommended neither for now, since a resource server needs no client registration at all; revisit only alongside Q4's AS work, and prefer CIMD when we do, because DCR is deprecated.
Specs relied on: MCP Authorization 2026-07-28 and its Client Registration section, OAuth 2.1 draft-13, RFC 9728, RFC 8414, RFC 7591, RFC 8707, RFC 9207, RFC 6750, RFC 8628, RFC 8693, draft-ietf-oauth-client-id-metadata-document-00.
Related: #549 (TLS, listeners, DPoP — hard prerequisite), #557 (PrincipalKind, on_behalf_of — the identity this produces), #548 (one policy vocabulary, and the per-principal posture HTTP MCP requires).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with cmd/flow/mcp.go:262 and cmd/flow/routing.go:75-105, then inspect auth/verifier.go and the SDK's auth and StreamableHTTPHandler APIs. Define the resource-server-first boundary, including protected-resource metadata, bearer challenges, scope handling, and verifier adaptation; done means the HTTP surface has a concrete, tested OAuth resource-server plan without adding an authorization server.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, authentication, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100