Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ deployed independently of the host they extend, with no build-time coupling. It
owns what every solution needs identically: configuration resolution through the
codefly SDK, self-registration with the host frontend and the gateway (the
credential exchanges and their heartbeats), CORS, Module Federation asset
serving, the capability handshake, the manifest, and the gateway client a
handler uses to read composed modules on the viewer's behalf.
serving, the capability handshake, the manifest, serving the solution's MCP
server to agent clients, and the gateway client a handler uses to read composed
modules on the viewer's behalf.

It owns none of the counterparts it talks to. The registration surfaces and
their admission rules belong to the host (`codefly-dev/module-saas-starter`) and
Expand Down Expand Up @@ -48,6 +49,13 @@ own: it builds on `Server.PassthroughHandler`, the handler `Serve` mounts.
- A registration that cannot attest which publisher it speaks for is not a
registration. There is no fallback to the shared cluster-internal token and no
downgrade path when an exchange answers `404`.
- The MCP surface verifies no token, and must not start: 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. What `requireStampedViewer`
enforces is that the gateway did it — and the refusal for a credential with no
session is at that boundary, not inside a tool, because there it reads as a
broken tool. A tool's arguments are written by a model: no identity ever comes
from them.
- Tests live beside the code in the same package. Every behaviour change brings
one, and the counterpart it exercises is an `httptest` server, never a real
host.
Expand Down Expand Up @@ -146,10 +154,12 @@ Keep this file short; add depth to the file that owns the subject.

- [README.md](README.md) — the consumer-facing contract: handler errors, the
full configuration and env-override table, self-registration and its
provisioning, consumed-module federation, and reading a
Work-Context-authenticated module.
- The package doc comment and the comments in [`solution.go`](solution.go) —
why a refusal, a backoff cap or a cache lifetime is what it is. Several
provisioning, consumed-module federation, reading a Work-Context-authenticated
module, and the MCP surface (what is served, every refusal, and why the
resource identifier cannot be resolved here).
- The package doc comment and the comments in [`solution.go`](solution.go) and
[`mcp.go`](mcp.go) — why a refusal, a backoff cap or a cache lifetime is what
it is. Several
record a failure mode that is not obvious from the code; read the one next to
what you are changing before you change it.
- `.claude/skills/` — procedures an agent repeats, loaded only when relevant.
Expand Down
169 changes: 167 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Generic Go runtime for **codefly solutions** — independently deployed modules
that plug into a host at runtime with no build-time coupling. Owns registration
(host + gateway, with heartbeat), CORS, Module Federation asset serving, the
capability handshake, the manifest, and the gateway client each handler uses
to read composed modules on the viewer's behalf.
capability handshake, the manifest, serving the solution's MCP server to agent
clients, and the gateway client each handler uses to read composed modules on
the viewer's behalf.

A solution author writes a manifest and one handler:

Expand Down Expand Up @@ -62,6 +63,8 @@ the SDK-resolved value is the default.
| Self upstream | the reachable self endpoint Codefly injects as `CODEFLY__SELF_ENDPOINT__<MODULE>__<SERVICE>__HTTP__HTTP` (core ≥ v0.5.6); without it, the listen address `http://localhost:<port>` | `SELF_UPSTREAM` |
| Deployed or local | `CODEFLY__RUNTIME_CONTEXT`, injected by Codefly: `native`/`nix`/`container`/`free` (or unset) is a local run, anything else (a GitOps render's `kubernetes`, core ≥ v0.5.6) a deployment | — |
| MF assets | `Manifest.Assets` when set (see below), else the `../fe-remote/dist` directory | `ASSETS_DIR` (directory only) |
| Host issuer (MCP) | the resolved host frontend origin — the host is the authorization server an MCP client authenticates against (see [Exposing an MCP server](#exposing-an-mcp-server)); checked at boot only when `ServeMCP` is declared | `HOST_ISSUER_URL` |
| Public MCP URL | none — the resource identifier is then reconstructed per request from `x-forwarded-proto` / `x-forwarded-host` / `x-forwarded-prefix` | `MCP_PUBLIC_URL` (must end in `/mcp`) |

The host it plugs into is named by Codefly-convention **service roles**, not by
its workspace module name: the runtime discovers the single module that owns each
Expand Down Expand Up @@ -546,6 +549,168 @@ cancellation. It never proves that a real host admits the solution, that
accounts grants the scopes, or that the real module answers its binding the way
the test's stand-in does.

### Exposing an MCP server

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. These are the three lines:

```go
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()
```

`register` receives the official SDK's own `*mcp.Server`
(`github.com/modelcontextprotocol/go-sdk`, pinned in this module's `go.mod`), so
tools, prompts and resources are declared exactly as that SDK documents them and
this runtime adds nothing to them. It is called once, when the endpoint is
mounted.

A tool call runs as the viewer who made the request. `ViewerFromContext` returns
the same caller-bound `Gateway` a `Handler` is given, so a tool reads a composed
module through `ForModule` under the viewer's own Work Context — the identical
call the page makes, with the identical typed refusals:

```go
func askWiki(ctx context.Context, _ *mcp.CallToolRequest, in askIn) (*mcp.CallToolResult, askOut, error) {
gw, err := solution.ViewerFromContext(ctx)
if err != nil {
return nil, askOut{}, err
}
robin, err := gw.ForModule(ctx, "robin", solution.Scope{ResourceKind: "conversations", Actions: []string{"create"}})
if err != nil {
return nil, askOut{}, err
}
...
}
```

The viewer comes from the identity headers the gateway stamped, never from the
tool's arguments: those are written by a model, and a session id taken from them
would be a client asking for another viewer's authority. `ViewerFromContext`
errors only for a handler invoked outside a request this runtime served — a
harness of its own, or an `*mcp.Server` mounted without `ServeMCP`.

#### What is served, and what is refused

| Path | What |
| --- | --- |
| `/mcp` (`solution.MCPPath`) | Stateless Streamable HTTP. `/solutions/<id>/mcp` through the gateway, a route it already fronts. `POST` only: `GET` and `DELETE` are `405` and no `Mcp-Session-Id` is ever issued, 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. The SDK's DNS-rebinding protection is left on, so a request reaching a loopback listener with a non-loopback `Host` header is refused `403`: on a developer's machine that protection is the one that still applies, since a rebound page is same-origin and can forge identity headers that a cross-origin page cannot. |
| `/.well-known/oauth-protected-resource` (`solution.ProtectedResourceMetadataPath`) | The OAuth 2.0 Protected Resource Metadata document (RFC 9728): the `resource` a client binds its token to, the host issuer in `authorization_servers`, and `bearer_methods_supported: ["header"]`. Served unauthenticated, with `Access-Control-Allow-Origin: *`, because discovery is public (RFC 9728 §3.1). |

The MCP endpoint itself carries **no** CORS. The runtime's policy for a
solution's own API admits any origin with an authorization header, which is the
right answer there and the wrong one for the single route that acts with the
viewer's full authority; an MCP client is not a browser page.

The runtime verifies no token: the gateway strips caller identity headers, runs
ext_authz on the bearer and stamps what it resolved, so verifying it again here
would be a second, divergent implementation of the host's admission rules, with
its own JWKS fetch, its own audience logic and its own bugs. What is enforced is
that the gateway did it:

| The request | Answer |
| --- | --- |
| No `authorization` | `401` with `WWW-Authenticate: Bearer resource_metadata="…"`. |
| A bearer, but none of `x-user-id`, `x-org-id`, `x-session-id` stamped | `401` with the same challenge: nothing but the gateway may reach this endpoint, so either the request did not come through it or it did not authenticate the bearer. |
| `x-session-id` stamped empty | `403`, naming the `x-credential-kind` the gateway stamped. A credential that authenticates without a session cannot act for a viewer, and every tool call that does mints a Work Context rooted in one — so this is refused at the boundary rather than once per tool call, where the same refusal would read as the tool being broken. Another token cannot fix it, so it carries no challenge. |

**Which caller sees those two 401s.** Not an MCP client coming through the
gateway: the gateway strips the caller's identity headers and runs ext_authz on
the bearer *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 this solution directly sees — a local
run, a port-forward, a composition dialling the backend — and it is the shape
the gateway's own challenge has to match for discovery to work at all. Emitting
it at the gateway is the host's half
([codefly-dev/module-saas-starter#1003](https://github.com/codefly-dev/module-saas-starter/issues/1003));
the document that challenge must point at is served here either way.

#### The resource identifier and the issuer

The `resource` in the metadata document is what a client asks the authorization
server for a token for (RFC 8707) and what its token is audience-bound to, so it
must be the **public** MCP URL — not this process's listen address, not the
in-cluster address the gateway dials. The runtime cannot resolve it: the origin
is the host's, and the path is the host's route layout, which this runtime
deliberately does not encode (see
[Where the host reaches the solution](#where-the-host-reaches-the-solution)).

So `MCP_PUBLIC_URL` is it when set, and must end in `/mcp` — the metadata
document's own URL is derived from it by swapping that suffix, the same pairing
the registration token URLs use, refused at boot for the same reason when it
cannot be made. Unset, the identifier is reconstructed per request from
`x-forwarded-proto`, `x-forwarded-host` and `x-forwarded-prefix`, and `ServeMCP`
logs at boot that it is doing so: a proxy that forwards no prefix yields an
identifier missing the path it stripped, which a conforming client rejects and a
tolerant one binds to the wrong resource. Set it in any deployment.

`authorization_servers` is the host issuer resolved at boot — by role, like every
other host endpoint — and is never derived from a request, so a crafted `Host`
header cannot point a client at an authorization server of someone's choosing.
The resolved value is the address this composition reaches the host at: right for
a local run, and in a deployment an in-cluster address no public client could
reach, which is what `HOST_ISSUER_URL` is for. A solution that declares
`ServeMCP` and resolves no issuer is refused at boot; one that declares no MCP
server is unaffected.

Three things about discovery belong to the host, not to this runtime, all of them
tracked by
[module-saas-starter#1003](https://github.com/codefly-dev/module-saas-starter/issues/1003).
The gateway emits the 401 challenge, as above. It must admit an unauthenticated
`GET` on `/solutions/<id>/.well-known/oauth-protected-resource`, or no client can
read the document that challenge points at. And that is where the document is —
on the solution's own path — whereas RFC 9728 §3.1 also defines a location
derived from the resource's path,
`/.well-known/oauth-protected-resource/solutions/<id>/mcp`, at the **host's**
root. A client that follows `resource_metadata` reaches the document either way;
one that only guesses the derived location needs the host to serve it there.

#### Connecting

Until the host is an MCP-conformant OAuth 2.1 authorization server, a client
presents a token it already has, which the gateway already accepts:

```sh
claude mcp add --transport http wiki https://<host>/solutions/<id>/mcp \
--header "Authorization: Bearer <access token>"
```

Once the host supports it, the gateway's 401 challenge and the metadata document
above are the whole of what a client needs to authenticate on its own.

#### Testing the tools: `MCPHandler`

`Serve` resolves the gateway and the issuer from the composition and registers
with the host, so a solution cannot drive its own tools in a test through it.
`Server.MCPHandler` serves the MCP surface exactly as `Serve` serves it — the
same endpoint, the same metadata document, the same refusals, mounted by the same
code — against an environment the test supplies. Nothing else of the solution is
served and nothing registers anywhere:

```go
handler, err := s.MCPHandler(solution.MCPEnvironment{
GatewayURL: fakeHost.URL, // answers the accounts StartTask mint and /v1/<as>/*
IssuerURL: "https://host.test",
})
server := httptest.NewServer(handler)
session, err := mcp.NewClient(&mcp.Implementation{Name: "agent", Version: "v0"}, nil).
Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: server.URL + solution.MCPPath,
HTTPClient: &http.Client{Transport: stampsTheViewersIdentity{}},
}, nil)
```

What a green test there proves is the solution's side: the tools it offers, the
authority each one mints, and the refusals its callers get. It never proves that
a real gateway stamps what the test's `RoundTripper` stamps, that accounts mints
a Work Context, or that a real MCP client's OAuth flow completes against the
host.

### Generated messages in a response

A handler's value is encoded with `encoding/json`, which reads a generated
Expand Down
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/codefly-dev/core v0.3.41
github.com/codefly-dev/sdk-go v0.1.65
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.8.0
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -36,19 +37,26 @@ require (
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.83.2 // indirect
)
Loading
Loading