Skip to content

feat: ServeMCP — a solution exposes its tools as the signed-in person - #47

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
issue-46-servemcp-a-solution-exposes-an-mcp-server-as
Oct 4, 2026
Merged

antoinetoussaint-byte merged 3 commits into
mainfrom
issue-46-servemcp-a-solution-exposes-an-mcp-server-as

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

solution.New(solution.Manifest{ID: "wiki", Title: "Wiki"}).
    ServeMCP("wiki", "v1.0.0", func(srv *mcp.Server) {
        mcp.AddTool(srv, &mcp.Tool{Name: "ask_wiki", Description: "ask the wiki a question"}, askWiki)
    }).
    Serve()

A tool handler gets the viewer from its own context:

gw, err := solution.ViewerFromContext(ctx)   // the same caller-bound Gateway a Handler is given
robin, err := gw.ForModule(ctx, "robin", solution.Scope{ResourceKind: "conversations", Actions: []string{"create"}})

register receives 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.NewStreamableHTTPHandler with Stateless: true). /solutions/<id>/mcp through 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. POST only; GET/DELETE are 405 and no Mcp-Session-Id is ever issued.
  • /.well-known/oauth-protected-resource — the RFC 9728 document, served unauthenticated with Access-Control-Allow-Origin: * (discovery is public, §3.1), built from the SDK's own oauthex.ProtectedResourceMetadata so the field spellings are not typed here.
  • No CORS on /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:

The request Answer
no authorization 401 + WWW-Authenticate: Bearer resource_metadata="…"
bearer, but none of x-user-id/x-org-id/x-session-id stamped 401 + the same challenge: nothing but the gateway may reach this endpoint
x-session-id stamped empty 403 naming the x-credential-kind the gateway stamped: a credential that authenticates without a session cannot mint a Work Context, which accounts roots in one

Which 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 required and 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 manifestUrl is root-relative). So MCP_PUBLIC_URL is 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 and HOST_ISSUER_URL are 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 damage deployedRuntimeContext already refuses for a loopback SELF_UPSTREAM or PUBLIC_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 from x-forwarded-proto/-host/-prefix, with a boot log line saying so.

The fix that would retire MCP_PUBLIC_URL is 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_servers is the host issuer, never request-derived, so a crafted Host header cannot point a client at another authorization server. A forwarded proto is accepted only as http or https, and a forwarded host or prefix that is not one is dropped rather than sanitized.

Configuration

Resolved in loadConfig and checked in validate() — the one place each, as AGENTS.md requires; validateMCP is called from validate() and runs only when ServeMCP was declared. environment_boundary_test.go still 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 existing workContextGateway double answers the accounts mint and the module read.

  • a real mcp.NewClient over Streamable HTTP does initialize → tools/list → tools/call, and the tool's ForModule read 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's
  • the 401 shape and its exact challenge, for no bearer and for a bearer with nothing stamped; the 403 for a credential with no session, asserted to name the kind the gateway stamped (and, for another kind, asserted not to name api_key) and to carry no challenge
  • the metadata document: resource from the forwarded headers, the resolved issuer, bearer_methods_supported, CORS; the issuer unchanged under a crafted Host; the configured public URL winning over any header; an unusable forwarded prefix, host or scheme dropped rather than sanitized
  • stateless: no Mcp-Session-Id issued, GET is 405; no CORS on /mcp; a tool call logged by name with neither its arguments nor the viewer's credential
  • the boot refusals: a mis-declared surface, a redeclared ServeMCP, a handler registered on a path ServeMCP serves, an unresolved issuer only when MCP is served, an unpairable MCP_PUBLIC_URL, and the deployed-context refusal of an unset MCP_PUBLIC_URL or HOST_ISSUER_URL — that last one confirmed load-bearing by mutation (delete the refusal, the test fails)

Server.MCPHandler(MCPEnvironment{...}) is the test seam, mirroring PassthroughHandler: the surface exactly as Serve serves 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: true behaves as claimed (no Mcp-Session-Id issued, a forged one ignored, GET/DELETE 405); 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 than localhost:<port>.

What it found, and what was done

Finding Done
F1 the runtime's 401 challenge is unreachable through the gateway, and the README presented it as the client-facing handshake fixed in the README refusal table, the discovery paragraph, the code comments that asserted it, and above
F2 a deployed solution served a wrong resource identifier and an in-cluster issuer on a 200, with a boot log line as the only signal boot refused by name for both, pinned by a test and confirmed by mutation
F3 x-forwarded-proto used unvalidated (javascript://host/mcp) http/https only, else the request's own scheme
F4 the no-session 403 asserted api_key whatever was stamped names the stamped kind; the api_key detail only when that is the kind
F6 no per-tool-call record one log line per tool call: solution id and tool name, nothing else
F7 Handle("/mcp") + ServeMCP panicked inside ServeMux; a second ServeMCP silently replaced the first both refused at boot by name, before the listener opens

Docs in this PR

  • README.md — new ### 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, and MCPHandler. Two new rows in the env-override table (HOST_ISSUER_URL, MCP_PUBLIC_URL), and MCP added to what the runtime owns.
  • AGENTS.md — the trust boundary an agent would otherwise undo by being helpful (verify no token; refuse at the boundary; no identity from tool arguments), MCP in the ownership paragraph, and mcp.go in "where the depth is".
  • the package doc comment, which also now states what this package actually depends on.

What I did not verify

  • No real gateway, no real accounts, no composed stack. Every counterpart is an httptest double on 127.0.0.1. A green run proves the wire shapes and refusals this package produces — not that the gateway stamps x-user-id/x-credential-kind as 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.
  • Discovery through the gateway is the host's half and untested here: the gateway emitting the 401 challenge, admitting an unauthenticated GET on /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.
  • Refs in tool results are the solution's to return — the runtime encodes whatever the tool's output struct carries.
  • No tag, no release: the consumer pins a default-branch commit after this merges.

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.0 as a direct require (with github.com/google/jsonschema-go and segmentio/encoding indirect, its own dependencies). It is a public API dependency — a solution imports .../go-sdk/mcp to declare tools — so a consumer pinning this runtime gets that version through MVS.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 2 commits October 3, 2026 20:13
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>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit d727d73 into main Oct 4, 2026
1 check passed
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
antoinetoussaint-byte deleted the issue-46-servemcp-a-solution-exposes-an-mcp-server-as branch October 25, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant