Repository navigation
feat: ServeMCP — a solution exposes its tools as the signed-in person - #47
Merged
antoinetoussaint-byte merged 3 commits intoOct 4, 2026
Conversation
A solution exposes its experience to an agent client the way it exposes it to a browser: as the viewer, through the host's gateway, with the same authority. The solution owns its tool surface; the runtime owns serving it, exactly as it owns serving HTTP. ServeMCP mounts the official SDK's stateless Streamable HTTP handler at /mcp — /solutions/<id>/mcp through the gateway, a route it already fronts — plus the RFC 9728 Protected Resource Metadata document a client discovers how to authenticate from. Sessions are stateless because the gateway is a reverse proxy with no sticky routing: a session held in one replica's memory is unreachable from the next request. No token verification lives here. The gateway runs ext_authz on the bearer and stamps the viewer's identity, so verifying it again would be a second, divergent implementation of the host's admission rules. What is enforced is that the gateway did it: a request with no bearer, or with a bearer but no stamped identity, is answered 401 with the challenge that names the metadata document, and a credential that authenticates without a session is refused 403 by its stamped kind — an organization api_key cannot mint a Work Context at all, so it is refused at the boundary rather than once per tool call, where it would read as a broken tool. Each tool call runs as the viewer: a receiving middleware binds the stamped identity to the request's context, and ViewerFromContext hands a tool handler the same caller-bound Gateway a Handler is given, so ForModule mints the viewer's own Work Context. The identity never comes from the tool's arguments, which are written by a model. The resource identifier a client binds its token to is the public MCP URL, which no resolver here holds: the origin is the host's and the path is the host's route layout, which this runtime deliberately does not encode. It is MCP_PUBLIC_URL when set — paired with the metadata document's own URL by the same suffix swap the registration token URLs use, and refused at boot when it cannot be paired — and otherwise reconstructed from what the proxy forwarded, with a boot log line saying so. The issuer is resolved by role like every other host endpoint and is never request-derived, so a crafted Host header cannot point a client at another authorization server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three lines a solution writes, the one line a tool handler writes to get the viewer, every refusal with the reason it is that status, and the two knobs an operator has — plus what the runtime cannot answer and the host must: admitting the unauthenticated GET discovery needs, and the RFC 9728 location derived from the resource path, which sits at the host's root and not on the solution's. AGENTS.md gains the boundary rule, because it is the one an agent would otherwise undo by being helpful: the MCP surface verifies no token and must not start, the no-session refusal belongs at the boundary and not inside a tool, and no identity ever comes from a tool's arguments, which a model writes. Also guards a forwarded host that is not a host, which would otherwise be spliced into the middle of the resource identifier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the 401 Review of #47 found both halves of the same mistake: a document served with a 200 that names something no client can reach, and a README that told the reader the runtime's own 401 is what starts a client's OAuth flow. The refusal (F2). In a deployed runtime context — the same signal that already refuses a loopback SELF_UPSTREAM or PUBLIC_URL — an unset MCP_PUBLIC_URL leaves the resource identifier reconstructed from the forwarded headers, missing the route prefix the gateway strips and carrying the hop's scheme rather than the client's, and an unset HOST_ISSUER_URL publishes the in-cluster address this composition dials the host at as the authorization server. Both were served from a solution that registered and looked healthy, with one boot log line as the only signal. Now the boot is refused, naming the variable that fixes each; a local run resolves a host it can actually reach and is untouched. Pinned by a test, confirmed by deleting the refusal and watching it go red. Who sees the challenge (F1). The gateway strips identity headers and runs ext_authz before proxying, so an unauthenticated MCP request is denied there and never reaches the solution: the runtime's 401 is the direct and local caller's answer, and the shape the gateway's own challenge has to match. Emitting it there is codefly-dev/module-saas-starter#1003. The README said otherwise in the one place a reader would check, which is worse than silence — it sends whoever is debugging discovery to the wrong repository. The metadata document is still served here: it is what that challenge has to name. Four smaller ones from the same review. A forwarded proto is accepted only as http or https, so "javascript" cannot be spliced in front of "://" in a URL handed to a client (F3). The no-session 403 names the kind the gateway actually stamped instead of asserting api_key, which sent a caller of another kind looking for an API key it does not have (F4). A tool call logs its name — the one thing the accounts mint audit cannot reconstruct — and neither its arguments nor any credential (F6). And both collisions are refused at boot by name rather than panicking inside ServeMux after the listener is open, or silently keeping the last of two ServeMCP calls and serving none of the first's tools (F7). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Oct 4, 2026
AntoineToussaint
pushed a commit
that referenced
this pull request
Oct 4, 2026
The PR was unmergeable and I had not noticed: main gained ServeMCP (#47) after this branch was cut, conflicting in solution.go, go.mod, README.md and AGENTS.md. GitHub could not compute the merge, so NO CI run was created for a2993bf, 257543a or 93878d8 — three commits I reported as gated on a local run that I never said CI had confirmed, but which a reader would have assumed. Conflicts resolved keeping this branch's semantics and main's feature: - The package doc keeps "a runtime does not register itself" and gains the MCP surface; the dependency sentence names Connect and the MCP SDK. - Server gains mcp/mcpRedeclared. registrationInterval is NOT carried over: it went with the heartbeat, which is what this branch deletes. - loadConfig takes both sets of fields. ServeMCP already reads its environment inside loadConfig, so the environment boundary holds with no change. - validateMCP moves to config.validate(), validateMCPDeclaration into start(), and start() sets cfg.mcp so the MCP configuration is only load-bearing for a solution that declared the surface. - siblingURL, deployedRuntimeContext/runtimeContext and resolveFrontend are restored. They were collateral deletions, not registrations: a suffix swap, one explicit signal, and host-origin resolution by role through the SDK. ServeMCP is the only caller of each now. Then this branch's own gates refused the feature twice, which is what they are for. 1. TestNoExportedPathHandsOutAServableHandler failed on mcp.go: MCPHandler was an exported method returning http.Handler whose only callers are tests — the PassthroughHandler defect verbatim. Whoever mounted it would serve the viewer's bearer and this workload's credential over whatever they mounted it on, having skipped validate(), the mTLS boot, the caller allow-list, the ceiling and authenticated outbound; it also writes boot configuration from values its caller supplies, which loadConfig exists to be the only source of. Unexported to mcpHandler. BREAKING for #47's public API, and the owner's to confirm or override — but the rule is not conditional and the incident it comes from is in AGENTS.md. 2. The MCP middleware built its viewer gateway with newGateway, which carries the caller's headers and nothing else: no credential acquirer, so a tool call could not attest which module was asking; no attestation report; no terminal hook; and no ceilings from the published contract. A mint driven from an MCP tool call was therefore held to nothing the contract claims while the identical mint from a handler was refused. It goes through gatewayFor now, as every other viewer path does. The test for (2) is driven through the real middleware by a tool reading its own viewer with ViewerFromContext. My first version called gatewayFor directly and passed against the broken code — the mistake it exists to catch — and I found that by reverting the fix and re-running, not by reading it. TestMCPToolCallRunsAsTheViewer needed a credential source: a tool call attests which module is asking, and this runtime refuses a mint it cannot attest for. That refusal is the point, so the seam holds a stub source rather than skipping it. go mod tidy is part of this change, as the four commands require. Not verified: no real host, gateway, issuer or prober, and no MCP client other than the SDK's own in-process one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AntoineToussaint
pushed a commit
that referenced
this pull request
Oct 4, 2026
main moved while the first merge was being resolved. #49 removes the derived host issuer and makes it declared workspace configuration — the same conclusion this branch's rule 4 reaches ("never guess what it cannot resolve"), reached independently for the same reason. Resolved: - loadConfig takes the solution id again, alongside this branch's environmentLoadErr, because the derived MCP public URL needs it. The issuer and public URL move out of the struct literal into resolveMCPIssuer / resolveMCPPublicURL, as #49 has them. registrationInterval is still not carried over. - resolveFrontend stays: #49 keeps using it for the issuer's resolved default. - clearSelfEnvironment is restored into solution_test.go. It lived in deployed_registration_test.go, which went with the registrations, and #49's configuration tests need it. The reason it exists outlives what it was written for: these assertions are about what the SDK resolves, and a variable exported in the shell answering instead is a green run that proves nothing. - Both sides' new tests are kept. - AGENTS.md and README.md keep this branch's text and gain #47/#49's MCP sections, including the rule that the MCP surface verifies no token because the gateway stamps the identity, and that a composition supplies values as declared workspace configuration rather than as env overrides a render cannot set. PUBLIC_URL comes back, which contradicted two documents that said it was deleted. Both are corrected rather than left standing. It fed the manifest registration, where deriving an absolute URL from it was wrong because the route a browser reaches this solution through is the host's to resolve; the manifest is a path now. The MCP resource identifier an agent client binds its token to does need a public origin, and #49 derives from it when no mcp/public-url is declared. Whether a runtime should derive the host's /solutions/<id> route even for that is an open follow-up, not a settled answer — it is the one place this package encodes the host's route layout, and I did not change #49's semantics in a merge commit to settle it. Two lint findings in the merged MCP tests: a config assigned from a helper called for its environment side effects and immediately reloaded. main's gate does not run golangci-lint, so they arrived clean by its standard and not by this branch's. Fixed, with the side-effect call made explicit. Gate at this head: vet 0, gofmt clean, -race green, golangci-lint 0 issues uncapped, go mod tidy applied. Not verified: no real host, gateway, issuer or prober, and no MCP client other than the SDK's own in-process one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
antoinetoussaint-byte
deleted the
issue-46-servemcp-a-solution-exposes-an-mcp-server-as
branch
October 25, 2026 17:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46.
A solution exposes its experience to an agent client — Claude Code, Claude Desktop, any MCP client — the way it exposes it to a browser: as the signed-in person, through the host's gateway, with the same authority. The solution owns its tool surface; this runtime owns serving it, exactly as it owns serving HTTP. Nothing MCP-specific goes in the gateway.
The three lines a solution writes
A tool handler gets the viewer from its own context:
registerreceives the official SDK's own*mcp.Server(github.com/modelcontextprotocol/go-sdk v1.8.0, the latest stable 1.x), so tools are declared exactly as that SDK documents them and this runtime adds nothing to them.What is served
/mcp— stateless Streamable HTTP (mcp.NewStreamableHTTPHandlerwithStateless: true)./solutions/<id>/mcpthrough the gateway, a route it already fronts. Stateless because the gateway is a reverse proxy with no sticky routing: a session held in one replica's memory is unreachable from the next request, so an MCP session would break under any rollout.POSTonly;GET/DELETEare 405 and noMcp-Session-Idis ever issued./.well-known/oauth-protected-resource— the RFC 9728 document, served unauthenticated withAccess-Control-Allow-Origin: *(discovery is public, §3.1), built from the SDK's ownoauthex.ProtectedResourceMetadataso the field spellings are not typed here./mcp. The runtime's policy for a solution's own API admits any origin with an authorization header; that is the right answer there and the wrong one for the single route that acts with the viewer's full authority. The SDK's DNS-rebinding protection is left on for the same reason.Authentication: the gateway's, enforced here
No token verification, no JWKS, no audience logic. The gateway runs ext_authz and stamps the identity, so a check here would be a second, divergent copy of the host's admission rules with its own bugs. What is enforced is that the gateway did it:
authorizationWWW-Authenticate: Bearer resource_metadata="…"x-user-id/x-org-id/x-session-idstampedx-session-idstamped emptyx-credential-kindthe gateway stamped: a credential that authenticates without a session cannot mint a Work Context, which accounts roots in oneWhich caller sees those 401s — corrected since the first version of this PR, and the main thing a reader should take from it. Not an MCP client coming through the gateway: the gateway strips identity headers and runs ext_authz before proxying, so an unauthenticated request is denied there with its own
401 authentication requiredand never reaches the solution. The runtime's 401 is what a caller reaching the solution directly sees (a local run, a port-forward) and the shape the gateway's own challenge has to match; emitting it there is the host's half, codefly-dev/module-saas-starter#1003. The metadata document is served here either way, because it is what that challenge has to name.The no-session refusal is at the boundary rather than once per tool call, where it would read as a broken tool. Each tool call runs as the viewer through a receiving middleware that binds the stamped identity to the request's context — never from the tool's arguments, which a model writes — and logs the tool name, which the accounts mint audit does not carry; no arguments and no credentials.
Classification
Every change is a fix at the place that owns the behaviour. Serving a solution's surfaces, and reading the identity the gateway stamps, is what this repository owns; there is no stopgap in this PR and nothing works around a defect elsewhere.
One thing the runtime genuinely cannot resolve, handled explicitly rather than guessed: the resource identifier a client binds its token to must be the public MCP URL, whose origin is the host's and whose path is the host's route layout — which this runtime deliberately does not encode (the same reason
manifestUrlis root-relative). SoMCP_PUBLIC_URLis it when set, paired with the metadata document's URL by the same suffix swap the registration token URLs use and refused at boot when unpairable. In a deployed runtime context both it andHOST_ISSUER_URLare required: unset, the identifier is missing the prefix the gateway strips and the issuer is the in-cluster address this composition dials the host at, and both used to be served with a 200 from a solution that looked healthy. That is the same silent damagedeployedRuntimeContextalready refuses for a loopbackSELF_UPSTREAMorPUBLIC_URL, so it is refused the same way, naming the variable that fixes each. A local run resolves a host it can actually reach and is untouched; there the identifier is still reconstructed fromx-forwarded-proto/-host/-prefix, with a boot log line saying so.The fix that would retire
MCP_PUBLIC_URLis the gateway telling a solution its public route — in the registration response it already answers — which belongs to the gateway, not here. No issue filed for it: that needs the owner's go-ahead.authorization_serversis the host issuer, never request-derived, so a craftedHostheader cannot point a client at another authorization server. A forwarded proto is accepted only ashttporhttps, and a forwarded host or prefix that is not one is dropped rather than sanitized.Configuration
Resolved in
loadConfigand checked invalidate()— the one place each, asAGENTS.mdrequires;validateMCPis called fromvalidate()and runs only whenServeMCPwas declared.environment_boundary_test.gostill passes with no new exception.Tests
mcp_test.go, hermetic like the rest of the suite: an in-process gateway double (stampedTransport) stamps the identity headers as the gateway does, and the existingworkContextGatewaydouble answers the accounts mint and the module read.mcp.NewClientover Streamable HTTP doesinitialize→tools/list→tools/call, and the tool'sForModuleread succeeds only because the viewer's Work Context was minted and presented — the mint's org, session and bearer are asserted to be the viewer'sapi_key) and to carry no challengebearer_methods_supported, CORS; the issuer unchanged under a craftedHost; the configured public URL winning over any header; an unusable forwarded prefix, host or scheme dropped rather than sanitizedMcp-Session-Idissued,GETis 405; no CORS on/mcp; a tool call logged by name with neither its arguments nor the viewer's credentialServeMCP, a handler registered on a pathServeMCPserves, an unresolved issuer only when MCP is served, an unpairableMCP_PUBLIC_URL, and the deployed-context refusal of an unsetMCP_PUBLIC_URLorHOST_ISSUER_URL— that last one confirmed load-bearing by mutation (delete the refusal, the test fails)Server.MCPHandler(MCPEnvironment{...})is the test seam, mirroringPassthroughHandler: the surface exactly asServeserves it, against an environment the caller supplies, nothing registered anywhere. The wiki (obin-ai/core-solutions#343) tests its tools through it.What the independent review established by execution
An executed review of d68bc8b confirmed, beyond the findings below: go-sdk v1.8.0 with
Stateless: truebehaves as claimed (noMcp-Session-Idissued, a forged one ignored,GET/DELETE405); 40 concurrent tool calls from two viewers produced 40 mints with no cross-viewer mixing; and the SDK's localhost DNS-rebinding protection 403s any local hostname other thanlocalhost:<port>.What it found, and what was done
x-forwarded-protoused unvalidated (javascript://host/mcp)http/httpsonly, else the request's own schemeapi_keywhatever was stampedapi_keydetail only when that is the kindHandle("/mcp")+ServeMCPpanicked insideServeMux; a secondServeMCPsilently replaced the firstDocs in this PR
### Exposing an MCP server: the three lines, how a tool gets the viewer, the table of what is served, every refusal with its reason and which caller sees it, the resource identifier and the issuer (including the deployed-context requirement), connecting from Claude Code today, andMCPHandler. Two new rows in the env-override table (HOST_ISSUER_URL,MCP_PUBLIC_URL), and MCP added to what the runtime owns.mcp.goin "where the depth is".What I did not verify
httptestdouble on127.0.0.1. A green run proves the wire shapes and refusals this package produces — not that the gateway stampsx-user-id/x-credential-kindas ServeMCP: a solution exposes an MCP server as the signed-in person — Streamable HTTP, RFC 9728 metadata, tools that run under the viewer's Work Context #46 describes (if it does not, every MCP request is refused loudly, by a message naming the headers), not that accounts mints a Work Context for a tool call, and not that a real MCP client's OAuth flow completes.GETon/solutions/<id>/.well-known/oauth-protected-resource, and whether a client that only guesses RFC 9728's path-derived location (/.well-known/oauth-protected-resource/solutions/<id>/mcp, at the host's root) can discover this resource. All three are in the README and tracked by module-saas-starter#1003.Gates run locally on the head of this branch:
go build ./...,go vet ./...,go test -race ./... -count=1 -timeout=5m,go mod tidy -diff,gofmt -l .— all clean, plus CI.Dependency note: adds
github.com/modelcontextprotocol/go-sdk v1.8.0as a direct require (withgithub.com/google/jsonschema-goandsegmentio/encodingindirect, its own dependencies). It is a public API dependency — a solution imports.../go-sdk/mcpto declare tools — so a consumer pinning this runtime gets that version through MVS.🤖 Generated with Claude Code