The MCP resource server, and the signing key out of the database #170

Open
lz wants to merge 26 commits from feat/mcp-resource-server into main
Owner

Closes #154 and #166.

POST /api/mcp plus its discovery documents — the resource-server half of the MCP work. With #151 (the authorization server) and #152 (the CIMD allowlist) already merged, this is the point where a client can complete authorization and call a tool.

Ships two read tools (list_workspaces, list_sessions) so the shell is demonstrably working. The rest are #155–#158.

Two decisions that reversed the issue

createMcpHandler, not WebStandardStreamableHTTPServerTransport. The issue prescribed that transport in stateless mode. In @modelcontextprotocol/server@2.0.0 that is the legacy leg — taking it would have shipped a 2025-only server. createMcpHandler serves 2026-07-28 and, with its default legacy: 'stateless', answers 2025-era traffic with exactly the sessionIdGenerator: undefined wiring the issue described by hand. One factory backs both. 2026-07-28 is the revision that deprecated DCR for CIMD, which is what #152 shipped for, so serving only the older half would have been self-defeating.

Access tokens stay JWT. An earlier draft switched them to opaque so the resource server could call provider.AccessToken.findformats/jwt.js exports no getTokenId, so that lookup cannot resolve a JWT. Reversed after an honest count: jose takes issuer, audience, algorithms and typ as options, so the one check no SDK helper performs — the audience — is a parameter rather than hand-written code with tests defending it. Opaque's advantage was immediate revocation, and it is smaller than it looks: refresh tokens are always opaque, so revoking a grant already leaves a client unable to mint anything new. The residual is one in-flight access token, and accessTokenTTL drops to 300s to bound it.

#166

The signing key cannot be sealed under the vault DEK: loadOrCreateJwks runs at boot while the DEK exists only inside an unlocked operator session, and sessions do not survive a restart (fact #25). Sealing would 503 OAuth after every restart until a human opened a browser — defeating the phone-without-the-UI goal.

So the issue's own third option ships: OAUTH_SIGNING_KEY, base64 of a JWKS. This is a trade, not a strict improvement — anyone who can docker inspect the Nexus container can read it. It removes a database-file exposure and adds an environment one. DEPLOYMENT.md says so plainly.

A malformed value throws rather than falling back to a generated key; the existing boot catch turns that into a 503 with the reason logged while the rest of Nexus starts. Setting it on an instance that already generated one does not delete the old meta row.

Verification

pnpm typecheck 5157 files 0 errors · pnpm vitest run 2160 tests / 192 files · pnpm lint clean · pnpm build clean. Baseline before the branch was 2061.

Nine checks against node build/server.js with a temp DATA_DIR, driven with node:http (fetch silently drops an explicit Host), all passing:

  1. PRM at the path-inserted location → 200, resource https://nexus.test/api/mcp
  2. PRM at the root path → byte-identical document
  3. both served with an empty cookie jar (control: /api/settings/instance 401 empty / 200 with the operator jar)
  4. unauthenticated POST /api/mcp → 401 + WWW-Authenticate naming the metadata URL — and the same POST carrying a live operator cookie also 401s, which is what admitting the route above the cookie read buys
  5. rebound Host with a valid bearer → 403
  6. GET /api/mcp → 405
  7. full authorization through the real consent screen, then tools/call list_workspaces → an actual roster row
  8. non-HTTPS public_url → 503 naming HTTPS, not a 500
  9. a real provider-issued openid-only token → 403 with the challenge; a real nexus:read nexus:write token → 200

What is NOT verified

  • Claude has not connected. No real client has driven this. The issue's "done when" is not met until one does.
  • Live verification exercised the legacy protocol leg only. Every request in it, and every test in the suite, sends claim-less POSTs. The modern 2026-07-28 leg — the one Claude will use — is covered by exactly one unit test added during review.
  • The Host allowlist is unverified against the real deployment. hostHeaderValidationResponse reads whatever proxy_set_header Host gives it; an nginx forwarding Host: 127.0.0.1:3001 would 403 every MCP call with all of the above still green. Worth one request through a.lck.sh before trusting it. Deliberately not dodged by accepting X-Forwarded-Host, which is client-settable unless nginx overwrites it.

Review found nine things the task-level passes did not

Each task was built with mutation testing and every survivor was driven to a fix. A four-agent review across the whole diff still found:

  • A nexus:write-only token was admitted and got a server with no tools capability, so tools/list answered -32601 — the exact "client reads a broken server rather than missing authorization" outcome the check existed to prevent. Both shipped tools are nexus:read, so the admission rule and the registry filter disagreed. Admission now derives from the filter itself, so drift is structurally impossible.
  • The Accept/406 rule and SSE framing belong to the legacy leg only. The modern leg checks no Accept header and returns plain JSON. Four comments said otherwise, including an AGENTS.md fact.
  • An RSA or P-384 OAUTH_SIGNING_KEY passed validation and then failed at token exchange, after consent succeeded, with nothing logged — there is no production server_error listener. Now refused at boot.
  • A throwing tool callback was invisible: the SDK converts a rejecting executor to isError: true at status 200 without calling onerror.
  • Three unpinned guards (deps.ts wiring, Host-before-Origin order, the host-less request) and two false "measured" claims.

Five of the nine were in code or comments written by the lead. Recorded as AGENTS.md facts 33–38.

Follow-ups

  • #167oauth_payloads sweep, still open.
  • #153's panel copy should say five minutes, not an hour, for residual access after revocation — and note separately that removing a CIMD entry revokes nothing, it only stops the next authorization.
  • Sender-constrained tokens (DPoP, RFC 9449) are worth investigating once we can observe whether Claude's connector supports them. oidc-provider supports it natively.
Closes #154 and #166. `POST /api/mcp` plus its discovery documents — the resource-server half of the MCP work. With #151 (the authorization server) and #152 (the CIMD allowlist) already merged, this is the point where a client can complete authorization and call a tool. Ships two read tools (`list_workspaces`, `list_sessions`) so the shell is demonstrably working. The rest are #155–#158. ## Two decisions that reversed the issue **`createMcpHandler`, not `WebStandardStreamableHTTPServerTransport`.** The issue prescribed that transport in stateless mode. In `@modelcontextprotocol/server@2.0.0` that *is* the legacy leg — taking it would have shipped a 2025-only server. `createMcpHandler` serves 2026-07-28 and, with its default `legacy: 'stateless'`, answers 2025-era traffic with exactly the `sessionIdGenerator: undefined` wiring the issue described by hand. One factory backs both. 2026-07-28 is the revision that deprecated DCR for CIMD, which is what #152 shipped for, so serving only the older half would have been self-defeating. **Access tokens stay JWT.** An earlier draft switched them to opaque so the resource server could call `provider.AccessToken.find` — `formats/jwt.js` exports no `getTokenId`, so that lookup cannot resolve a JWT. Reversed after an honest count: `jose` takes `issuer`, `audience`, `algorithms` and `typ` as options, so the one check **no SDK helper performs** — the audience — is a parameter rather than hand-written code with tests defending it. Opaque's advantage was immediate revocation, and it is smaller than it looks: refresh tokens are always opaque, so revoking a grant already leaves a client unable to mint anything new. The residual is one in-flight access token, and `accessTokenTTL` drops to 300s to bound it. ## #166 The signing key cannot be sealed under the vault DEK: `loadOrCreateJwks` runs at boot while the DEK exists only inside an unlocked operator session, and sessions do not survive a restart (fact #25). Sealing would 503 OAuth after every restart until a human opened a browser — defeating the phone-without-the-UI goal. So the issue's own third option ships: `OAUTH_SIGNING_KEY`, base64 of a JWKS. **This is a trade, not a strict improvement** — anyone who can `docker inspect` the Nexus container can read it. It removes a database-file exposure and adds an environment one. DEPLOYMENT.md says so plainly. A malformed value throws rather than falling back to a generated key; the existing boot catch turns that into a 503 with the reason logged while the rest of Nexus starts. Setting it on an instance that already generated one does **not** delete the old `meta` row. ## Verification `pnpm typecheck` 5157 files 0 errors · `pnpm vitest run` **2160 tests / 192 files** · `pnpm lint` clean · `pnpm build` clean. Baseline before the branch was 2061. Nine checks against `node build/server.js` with a temp `DATA_DIR`, driven with `node:http` (`fetch` silently drops an explicit `Host`), all passing: 1. PRM at the path-inserted location → 200, `resource` `https://nexus.test/api/mcp` 2. PRM at the root path → byte-identical document 3. both served with an empty cookie jar (control: `/api/settings/instance` 401 empty / 200 with the operator jar) 4. unauthenticated `POST /api/mcp` → 401 + `WWW-Authenticate` naming the metadata URL — **and the same POST carrying a live operator cookie also 401s**, which is what admitting the route above the cookie read buys 5. rebound `Host` with a valid bearer → 403 6. `GET /api/mcp` → 405 7. full authorization through the real consent screen, then `tools/call list_workspaces` → an actual roster row 8. non-HTTPS `public_url` → 503 naming HTTPS, not a 500 9. a real provider-issued `openid`-only token → 403 with the challenge; a real `nexus:read nexus:write` token → 200 ## What is NOT verified - **Claude has not connected.** No real client has driven this. The issue's "done when" is not met until one does. - **Live verification exercised the legacy protocol leg only.** Every request in it, and every test in the suite, sends claim-less POSTs. The modern 2026-07-28 leg — the one Claude will use — is covered by exactly one unit test added during review. - **The `Host` allowlist is unverified against the real deployment.** `hostHeaderValidationResponse` reads whatever `proxy_set_header Host` gives it; an nginx forwarding `Host: 127.0.0.1:3001` would 403 every MCP call with all of the above still green. Worth one request through `a.lck.sh` before trusting it. Deliberately not dodged by accepting `X-Forwarded-Host`, which is client-settable unless nginx overwrites it. ## Review found nine things the task-level passes did not Each task was built with mutation testing and every survivor was driven to a fix. A four-agent review across the whole diff still found: - **A `nexus:write`-only token was admitted and got a server with no `tools` capability**, so `tools/list` answered `-32601` — the exact "client reads a broken server rather than missing authorization" outcome the check existed to prevent. Both shipped tools are `nexus:read`, so the admission rule and the registry filter disagreed. Admission now derives from the filter itself, so drift is structurally impossible. - **The `Accept`/406 rule and SSE framing belong to the legacy leg only.** The modern leg checks no `Accept` header and returns plain JSON. Four comments said otherwise, including an AGENTS.md fact. - **An RSA or P-384 `OAUTH_SIGNING_KEY` passed validation** and then failed at token exchange, after consent succeeded, with nothing logged — there is no production `server_error` listener. Now refused at boot. - **A throwing tool callback was invisible**: the SDK converts a rejecting executor to `isError: true` at status 200 without calling `onerror`. - Three unpinned guards (`deps.ts` wiring, Host-before-Origin order, the host-less request) and two false "measured" claims. Five of the nine were in code or comments written by the lead. Recorded as AGENTS.md facts 33–38. ## Follow-ups - #167 — `oauth_payloads` sweep, still open. - #153's panel copy should say five minutes, not an hour, for residual access after revocation — and note separately that removing a CIMD entry revokes nothing, it only stops the next authorization. - Sender-constrained tokens (DPoP, RFC 9449) are worth investigating once we can observe whether Claude's connector supports them. `oidc-provider` supports it natively.
lz added this to the MCP support (#140) milestone 2026-09-17 05:06:47 +02:00
lz added 19 commits 2026-09-17 05:06:48 +02:00
Reverses the opaque-token decision. jose takes audience, issuer, algorithms and
typ as options, so the one check no SDK helper performs becomes a parameter
rather than hand-written code with tests defending it. Opaque's advantage was
immediate revocation, which is bounded instead by accessTokenTTL: 300 — refresh
tokens are opaque either way, so a revoked grant can already mint nothing new.

Probed, not assumed: createLocalJWKSet fails with ERR_JWKS_NO_MATCHING_KEY
against our own tokens, because jwks.ts persists a bare JWK with no kid while
oidc-provider stamps a derived kid into every header.

#166 folds in because this branch is already in the key path.
Seven blockers, all reproduced against the installed packages:

- a zero-scope token got a 200 and an empty tool list, because
  verifyBearerToken skips its scope check when requiredScopes is empty and a
  non-empty list is ANDed; serveMcp now refuses it with a 403 challenge
- every MCP request omitted Accept and would have got 406 before any handler
  ran, and successful responses are SSE-framed, not JSON
- ToolResult as an interface cannot satisfy registerTool, which needs an
  implicit index signature
- the round trip named a client the harness never registers
- the hooks and scopes tests used helpers that do not exist, and
  Object.keys() on a Map can never fail

Nine tests could not fail and are deleted or rewritten to assert tool-name
arrays. Both shipped tools are read-scoped, so the scope filter is now proved
against an injected write-scoped tool rather than one that cannot discriminate.

The per-request handler is hoisted to the process: its factory already gets
per-request authInfo, so closing it in a finally only raced the response body.
Sealing it under the vault DEK is not possible: loadOrCreateJwks runs at boot
while the DEK exists only inside an unlocked operator session, so OAuth would
503 after every restart until a human unlocked a browser. The environment
supplies the key instead, so a database copy no longer yields one that can
forge tokens indefinitely.

A malformed value throws rather than falling back to a generated key: signing
with a key the operator did not choose is worse than not signing at all. The
existing boot catch already turns that into a 503 with the reason logged. An
empty value counts as malformed, because a missing secret file yields exactly
that and it is the case that must not silently generate a key.

Also exposes publicJwkFor(), which the resource server will verify access-token
signatures against.
Audience, issuer, algorithm and typ are jose options rather than hand-written
checks — which matters because no SDK helper verifies the audience at all.
The key is imported as a single key and kid is never consulted: the persisted
JWK carries no kid while every token header does, so a JWKS resolver answers
ERR_JWKS_NO_MATCHING_KEY against our own tokens.

accessTokenTTL drops to 5 minutes, bounding the one thing revocation cannot
reach — an access token already in flight.
deriveIssuer is the single source for both the issuer and the resource, so the
document and the tokens cannot disagree about aud. A plain-HTTP LAN host is
refused with a named reason rather than left to throw inside the route.
createMcpHandler performs neither check — its documentation says to place them
in front. An instance with no usable public URL gets an empty allowlist, which
refuses everything: it cannot serve discovery either, so nothing could have
found it legitimately.

The allowlist goes through deriveIssuer rather than parsing public_url again,
so that claim stays true. A bare new URL is wider: it yields a hostname for a
non-http scheme and for a wildcard host such as 0.0.0.0, both of which
metadata.ts refuses — which would have handed an allowlist to exactly the
instances that serve no discovery document.
Scopes are enforced by registering only the permitted tools per request, which
is what createMcpHandler's per-principal factory exists for: one enforcement
point, and tools/list tells the client the truth about what it may call.

A round trip over an in-memory transport covers what registration alone
cannot: that the registered callback forwards the caller's arguments, that
the declared min(1) is enforced, and that a withheld tool is refused at
tools/call rather than merely absent from tools/list.
-32601 is the zero-tools case; a tool withheld from a credential holding some
scope is refused at tools/call with -32602. Measured while building the
registry.
createMcpHandler serves the 2026-07-28 revision and the stateless 2025 fallback
from one factory. Rebinding checks run before the token store is touched, and
the 401 is the SDK's ready-made challenge because Claude does not honour
WWW-Authenticate on a 200.
oauthMetadataResponse matches only the path-inserted location, so the root form
is built directly from the SAME options — a second options object derived from
the origin would advertise a resource the tokens are not audienced at.

Both routes export OPTIONS. SvelteKit's render_endpoint resolves mod[method] ||
mod.fallback and only ever falls back HEAD to GET, so the nested route does not
reach oauthMetadataResponse's own preflight branch by calling it from GET — an
OPTIONS would be a 405 before any of this code runs.
The route authenticates itself with a bearer access token and enforces scopes
per tool, which tokenMay cannot express on a single route id. Admission sits
above the cookie read so an operator's own session can never attach a vault key
to an MCP request.
Pins what no unit test reached: the unauthenticated 401 carries a
resource_metadata challenge, a token minted for another resource is refused
against one a real provider signed rather than one the test minted, a rebound
Host is refused while holding a valid token, and the Host header survives the
IncomingMessage to Request conversion that refusal depends on.

The cookie jar, client registration and operator gate move out of
oauth/flow.test.ts into oauth/flow-harness.ts so both suites drive the identical
flow instead of two copies that can drift.
The resource-server and MCP-server sections predate probing
@modelcontextprotocol/server@2.0.0. Corrected in place: the transport entry
(createMcpHandler, not the bare streamable-HTTP transport, which is the 2025-only
legacy leg), authorization_servers as the bare origin, bearer_methods_supported
removed from a document the helper never emits it in, the root-form
resource_metadata in the 401 example, one jose verifier in place of the
LocalVerifier/JwksVerifier pair, the bare well-known path being ours rather than
the helper's, and the four read tools split across #155-#158 rather than dropped.

AGENTS.md gains facts 33-38: the legacy-leg transport and its Accept/406 and
-32601/-32602 corollaries, the absent audience check, why AccessToken.find cannot
resolve a JWT and why createLocalJWKSet cannot verify one, the gate admitting
/api/mcp above the cookie read, SvelteKit's method-name endpoint dispatch, and why
the OAuth signing key cannot be sealed under the DEK.
deps.ts reads the public URL once and passes the resource href straight to
the verifier. The MCP tests build their JSON-RPC requests, their server and
their route modules through one helper each instead of repeating the same
four-line preambles; sseJson loses an export nothing imported.

No behaviour change: same 192 files / 2144 tests.
Both shipped tools require nexus:read, so "holds some Nexus scope" admitted a
nexus:write-only credential and handed it a server with no tools capability —
tools/list answering -32601, which is the "client reads a broken server rather
than missing authorization" outcome the admission rule exists to prevent.

scopesReachAnyTool shares one filter with registerToolsFor, so the endpoint
cannot answer a question the registry answers differently. The refusal cases
are now [] / ["openid"] / ["nexus:write"]; with only the empty set covered,
both the emptiness mutation and the vocabulary form passed every test.
Measured against @modelcontextprotocol/server 2.0.0: the 406 lives in
WebStandardStreamableHTTPServerTransport, which only the legacy stateless
fallback instantiates. A modern (2026-07-28) POST goes to
PerRequestHTTPServerTransport, which has no Accept check at all and answers
plain application/json — no Accept, both, and text/plain all return 200.

Four sites said "modern" where the truth was "legacy", AGENTS.md fact 33
included. Every test on the branch sent claim-less POSTs, so the leg the entry
was taken for had no coverage; the new test carries the params._meta envelope
and the Mcp-Method header that revision requires, and parses the body as JSON.
Dropping the envelope from it turns the request legacy and it 406s.
Two failures that produced no signal anywhere.

decodeSigningKey checked only that "keys" was non-empty and every key had a
"d", so a keyset the Provider happily constructs still broke later: more than
one key 503s /api/mcp forever through publicJwkFor while both discovery
documents keep serving 200, and a key that is not EC P-256 throws at the token
exchange — after consent has already succeeded — where nothing logs it, since
production registers no server_error listener. Both are now boot-time refusals
naming the fault.

A rejecting tool executor is converted by the SDK into isError content on a
200 and does NOT reach the handler onerror, so listWorkspaces dying on a downed
Docker proxy left the Nexus log silent. The executor now logs { err, tool } and
rethrows, keeping the SDK's wire answer.
test(mcp): pin the tool wiring, the Host/Origin order, and the host-less request
Some checks failed
ci / images (pull_request) Has been cancelled
ci / nexus (pull_request) Has been cancelled
1e5dda1117
Three assertions that could not fail, and one comment that named the wrong
mechanism.

deps.ts binds listWorkspaces and listSessions to their services and nothing
touched runtime.deps — every other suite injects its own ToolDeps — so swapping
the two bindings, or dropping the workspace argument, left the suite green.
Both the result and the arguments are now asserted, and db/docker carry tags so
two bare {} cannot compare deep-equal past a swap.

Host-before-Origin was observable only with both headers wrong, which no test
sent; swapping the operands passed everything.

And the missing-Host case claimed `new Request` always derives a Host from its
URL. Measured false — headers.get('host') is null and the header list empty —
so the test went to the SDK validator directly and stayed green with the host
leg deleted from rebindingRefusal. It now drives rebindingRefusal.

mcpRuntime's doc claimed the route and the metadata endpoints both read through
it. They do not: it has one caller, and both .well-known routes import
mcpMetadataOptions directly. The invariant holds — that shared function is what
makes it hold — so the comment now names it, and records why routing discovery
through the runtime would be a regression rather than a tidy-up.
docs: mark the MCP plan as an unreconciled record of intent
All checks were successful
ci / nexus (pull_request) Successful in 10m3s
ci / images (pull_request) Successful in 14m49s
dfdf546583
A reader who copies code out of it would reintroduce five of the nine defects the
post-implementation review found. Names the stale sections and points at the shipped
code instead of retro-editing them away.
Author
Owner

Verified against the live deployment

PR running on a.lck.sh. The body's "the Host allowlist is unverified against the real deployment" caveat is now resolved — and the answer is good.

The decisive one: an unauthenticated POST /api/mcp returns 401, not 403.

$ curl -X POST https://a.lck.sh/api/mcp -H 'content-type: application/json' \
       -H 'accept: application/json, text/event-stream' --data '{…"method":"initialize"…}'
status=401
www-authenticate: Bearer error="invalid_token", error_description="Missing Authorization header",
                  resource_metadata="https://a.lck.sh/.well-known/oauth-protected-resource/api/mcp"
{"error":"invalid_token","error_description":"Missing Authorization header"}

A 403 Invalid Host would have meant nginx forwards something the allowlist doesn't match — and every MCP call would have failed with every gate green. It reached the bearer gate, so nginx passes the client Host through and mcpAllowedHostnames matches it. The challenge also carries the correct path-inserted resource_metadata.

Both discovery documents serve, unauthenticated, with the right resource:

GET /.well-known/oauth-protected-resource/api/mcp  → 200 application/json
GET /.well-known/oauth-protected-resource          → 200, identical body
{"resource":"https://a.lck.sh/api/mcp","authorization_servers":["https://a.lck.sh"],
 "scopes_supported":["nexus:read","nexus:write"],"resource_name":"Agent Nexus"}

The rebinding defence is live, and layered more deeply than expected:

POST /api/mcp  -H 'Origin: https://evil.example'  → 403
  {"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example"},"id":null}

POST /api/mcp  -H 'Host: attacker.test'           → 404 (nginx, not Nexus)
GET  /api/mcp                                      → 405  "GET method not allowed"

The Origin refusal is Nexus's own rebindingRefusal firing through nginx, so that module demonstrably runs in production. The rebound Host never reaches Nexus at all — nginx has no server block for attacker.test and 404s it first. So in this deployment the app-level Host check is a second lock rather than the only one. That is strictly better, but it does mean production traffic cannot exercise it; the unit and live-local checks are what cover that path.

Still not verified

  • Claude has not connected. Unchanged, and it is the arc's actual goal.
  • The modern 2026-07-28 leg has not been exercised against the built server. It cannot be from here without a real access token: the bearer gate runs before the transport, so an unauthenticated modern-envelope request 401s identically to a legacy one and proves nothing about which leg would serve it. That needs an authorization completed in a browser.
## Verified against the live deployment PR running on `a.lck.sh`. **The body's "the `Host` allowlist is unverified against the real deployment" caveat is now resolved** — and the answer is good. **The decisive one: an unauthenticated `POST /api/mcp` returns 401, not 403.** ``` $ curl -X POST https://a.lck.sh/api/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' --data '{…"method":"initialize"…}' status=401 www-authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="https://a.lck.sh/.well-known/oauth-protected-resource/api/mcp" {"error":"invalid_token","error_description":"Missing Authorization header"} ``` A 403 `Invalid Host` would have meant nginx forwards something the allowlist doesn't match — and every MCP call would have failed with every gate green. It reached the bearer gate, so nginx passes the client `Host` through and `mcpAllowedHostnames` matches it. The challenge also carries the correct path-inserted `resource_metadata`. **Both discovery documents serve, unauthenticated, with the right `resource`:** ``` GET /.well-known/oauth-protected-resource/api/mcp → 200 application/json GET /.well-known/oauth-protected-resource → 200, identical body {"resource":"https://a.lck.sh/api/mcp","authorization_servers":["https://a.lck.sh"], "scopes_supported":["nexus:read","nexus:write"],"resource_name":"Agent Nexus"} ``` **The rebinding defence is live, and layered more deeply than expected:** ``` POST /api/mcp -H 'Origin: https://evil.example' → 403 {"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example"},"id":null} POST /api/mcp -H 'Host: attacker.test' → 404 (nginx, not Nexus) GET /api/mcp → 405 "GET method not allowed" ``` The Origin refusal is Nexus's own `rebindingRefusal` firing through nginx, so that module demonstrably runs in production. The rebound **Host** never reaches Nexus at all — nginx has no server block for `attacker.test` and 404s it first. So in this deployment the app-level Host check is a second lock rather than the only one. That is strictly better, but it does mean production traffic cannot exercise it; the unit and live-local checks are what cover that path. ## Still not verified - **Claude has not connected.** Unchanged, and it is the arc's actual goal. - **The modern 2026-07-28 leg has not been exercised against the built server.** It cannot be from here without a real access token: the bearer gate runs before the transport, so an unauthenticated modern-envelope request 401s identically to a legacy one and proves nothing about which leg would serve it. That needs an authorization completed in a browser.
test(mcp): pin the modern tools/call, which needs an Mcp-Name header
All checks were successful
ci / nexus (pull_request) Successful in 11m26s
ci / images (pull_request) Successful in 11m4s
e980cad301
The only modern-leg test listed tools; invoking one is the path a current client
actually uses. That revision requires a second header for three methods —
MCP_NAME_HEADER_SOURCE maps tools/call and prompts/get to name, resources/read
to uri — so a modern tools/call sending only Mcp-Method is refused -32020.

Found by driving the built server with a real token, where the failure names a
header nothing else on this branch sends.
Author
Owner

The modern 2026-07-28 leg is now verified live

The body's second caveat is resolved too. My earlier claim that this could not be tested was wrong — it needed a real token, not production access, and a local built server produces one exactly as check 7 already did.

One token, one running built server, both legs driven for comparison:

token claims: {"scope":"nexus:read nexus:write","client_id":"live-check-client",
               "iss":"https://nexus.test","aud":"https://nexus.test/api/mcp"}
# request result
1 modern, no Accept 200 application/json, plain JSON, both tools
2 modern + both Accept types 200 application/json
3 modern + Accept: text/plain 200 application/json — no Accept check exists on this leg
4 legacy, no Accept 406 — same server, same token
5 legacy + both Accept types 200 text/event-stream, SSE-framed
6 modern tools/call 200, real roster in structuredContent
7 modern without Mcp-Method 400 -32020 header/body mismatch, not a silent fall back
8 modern, no Bearer 401 — the gate runs before the transport

Checks 1 and 4 are the finding: identical credential, identical endpoint, 200 vs 406 decided purely by the envelope. That asymmetry is why a suite of claim-less POSTs looked like full coverage while testing only one leg.

A new fact, found by driving it

The first tools/call attempt was refused:

400 {"error":{"code":-32020,"message":"Bad Request: the request headers and body disagree:
     the body carries params.name=\"list_workspaces\" but the required Mcp-Name header is absent"}}

2026-07-28 requires a second header for three methods. Confirmed in the SDK rather than inferred:

const MCP_NAME_HEADER_SOURCE = { "tools/call": "name", "prompts/get": "name", "resources/read": "uri" };

With Mcp-Method: tools/call and Mcp-Name: list_workspaces, and no Accept header, it returns the real seeded roster as plain JSON.

This is a client obligation the SDK enforces, so nothing in Nexus needed changing — but the only modern-leg test on the branch listed tools rather than invoking one, so the path a client actually uses had live proof and no regression guard. e980cad adds it (asserting both the 400 without the header and the 200 with it, plus that the executor really ran), and extends AGENTS.md fact 33. Verified the test bites: neutralising the tool filter fails it.

Gates after that commit: 2161 tests / 192 files, typecheck 5157 files 0 errors, lint clean.

Remaining

Only "Claude has actually connected". Everything else on the endpoint is now verified against a built server, and the discovery, challenge, Host and Origin behaviour is verified against a.lck.sh itself.

Note OAUTH_SIGNING_KEY is not set in production, which is a supported state — the key loads from the pre-existing meta row, as GET /oauth/jwks confirms. Setting it swaps the key and invalidates issued tokens, so before a client connects is the cheapest moment; afterwards it costs one re-authorization. The old meta row is not deleted by setting it.

## The modern 2026-07-28 leg is now verified live The body's second caveat is resolved too. My earlier claim that this could not be tested was wrong — it needed a real token, not production access, and a local built server produces one exactly as check 7 already did. One token, one running built server, both legs driven for comparison: ``` token claims: {"scope":"nexus:read nexus:write","client_id":"live-check-client", "iss":"https://nexus.test","aud":"https://nexus.test/api/mcp"} ``` | # | request | result | |---|---|---| | 1 | modern, **no `Accept`** | `200 application/json`, plain JSON, both tools | | 2 | modern + both Accept types | `200 application/json` | | 3 | modern + `Accept: text/plain` | `200 application/json` — no Accept check exists on this leg | | 4 | **legacy, no `Accept`** | **`406`** — same server, same token | | 5 | legacy + both Accept types | `200 text/event-stream`, SSE-framed | | 6 | modern `tools/call` | `200`, real roster in `structuredContent` | | 7 | modern without `Mcp-Method` | `400 -32020` header/body mismatch, not a silent fall back | | 8 | modern, no Bearer | `401` — the gate runs before the transport | Checks 1 and 4 are the finding: **identical credential, identical endpoint, 200 vs 406 decided purely by the envelope.** That asymmetry is why a suite of claim-less POSTs looked like full coverage while testing only one leg. ### A new fact, found by driving it The first `tools/call` attempt was refused: ``` 400 {"error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body carries params.name=\"list_workspaces\" but the required Mcp-Name header is absent"}} ``` 2026-07-28 requires a **second** header for three methods. Confirmed in the SDK rather than inferred: ```js const MCP_NAME_HEADER_SOURCE = { "tools/call": "name", "prompts/get": "name", "resources/read": "uri" }; ``` With `Mcp-Method: tools/call` **and** `Mcp-Name: list_workspaces`, and no Accept header, it returns the real seeded roster as plain JSON. This is a client obligation the SDK enforces, so nothing in Nexus needed changing — but the only modern-leg test on the branch listed tools rather than invoking one, so the path a client actually uses had live proof and no regression guard. `e980cad` adds it (asserting both the 400 without the header and the 200 with it, plus that the executor really ran), and extends AGENTS.md fact 33. Verified the test bites: neutralising the tool filter fails it. Gates after that commit: **2161 tests / 192 files**, typecheck 5157 files 0 errors, lint clean. ### Remaining Only **"Claude has actually connected"**. Everything else on the endpoint is now verified against a built server, and the discovery, challenge, Host and Origin behaviour is verified against `a.lck.sh` itself. Note `OAUTH_SIGNING_KEY` is not set in production, which is a supported state — the key loads from the pre-existing `meta` row, as `GET /oauth/jwks` confirms. Setting it swaps the key and invalidates issued tokens, so **before** a client connects is the cheapest moment; afterwards it costs one re-authorization. The old `meta` row is not deleted by setting it.
lz added 5 commits 2026-09-17 10:53:46 +02:00
The database path goes away entirely, so "the signing key is not in nexus.db"
is true by construction rather than by an operator remembering to set a
variable AND delete a meta row. loadSigningJwks is pure and synchronous — at
most one read at boot, no db — so its tests drop their SQLite setup.

Two spellings of the same base64 blob, the docker convention:
OAUTH_SIGNING_KEY_FILE (preferred — a value in Env leaks via docker inspect
and /proc/1/environ, the rule AGENTS.md already states for workers) and
OAUTH_SIGNING_KEY (frictionless for pnpm dev and non-Docker). They share one
decode path, and a parity test compares the fault detail through both so a
second implementation cannot drift in. Setting both is refused naming both,
as the postgres and mysql entrypoints do: precedence would leave the operator
unable to say which key signs their tokens. Presence decides, not emptiness —
an empty value is itself a fault, so it cannot quietly hand over.

Neither set stays a legitimate state (null, one info line, a 503 naming both);
every other outcome throws naming the fault, now including no-such-file, a
directory (what a mistyped bind mount produces), unreadable and empty. The
meta.oauth_jwks row of an older instance is never read and never deleted — it
is their only copy of the key signing live tokens — and is named once at boot.
docker-compose.yml ships the secret form commented on both ends (compose
refuses to start when a declared secret is missing, so it cannot ship
enabled), with the inline variable named in a comment as the non-Docker
option; .gitignore covers where it puts the file. AGENTS.md fact 38 and two
env-table rows follow the code, _FILE first with the docker inspect reasoning.

DEPLOYMENT.md states the two things an upgrade turns on: this is now the only
source, so an instance that upgrades without setting either variable loses
OAuth and MCP while everything else keeps running; and the key is no longer in
a database backup, so it needs one of its own — losing it kills every issued
token. All three documented commands — the compose mint, the wiring, and the
pnpm dev one-liner — were run verbatim and their output loaded through the
real loader.
The strip is shared, so a trailing newline reaching the plain variable — pasted
out of a file, or kept by a wrapper — must be accepted the same way the file
form accepts it. Left uncommitted by the change that added the second spelling.
The sentence both 503 surfaces serve — src/server.ts's OAuth dispatcher and
/api/mcp — was hand-written byte-for-byte in each. They are in different
bundles and cannot import each other, so it now lives in oauth/jwks.ts, the
leaf module that owns both variables and that both already import.
docs: record two ways a gate run reports success it has not earned
All checks were successful
ci / nexus (pull_request) Successful in 9m34s
ci / images (pull_request) Successful in 10m8s
07ba4900e2
Chaining the gates in one shell invocation leaves vite build racing the next
one over .svelte-kit/output, which surfaces as a real-looking ENOENT. And a
pipeline exits with tail's status, so `pnpm build | tail -4; echo $?` prints 0
whatever the build did — both cost time on this branch.

Also states the mutation-gate discipline the branch ran on, including the
NOT-APPLIED outcome that looks exactly like a test gap.
Author
Owner

Signing key: no database, two spellings — and a simplify pass, verified regression-free

Since the last comment the signing key stopped coming from the database entirely, gained a Docker-secret form, and the branch had a final simplify pass. All of it re-verified against the built server.

The change

OAUTH_SIGNING_KEY (base64 inline) or OAUTH_SIGNING_KEY_FILE (a path to a file holding the same base64). The meta path is gone, so "the signing key is never in the database" is now true by construction rather than by operator discipline — previously it was closed only if you set the var and manually deleted the row.

_FILE is the recommended production form, and the docs say why rather than just recommending it: a value in Env: is readable by anyone who can docker inspect or read /proc/1/environ — the rule AGENTS.md already stated for worker containers, applied to the same container it was always true of.

Both set is a hard error, keyed on presence rather than emptiness. That is a deliberate deviation from the postgres/mysql entrypoints, which use [ "${!var:-}" ] and so read an empty value as unset. Here an empty value is already a fault — it is what a secret that failed to mount produces — so treating it as absence would silently hand over to the other spelling.

Live verification, against a rebuild of 06d8b00

The nine original checks and the seven modern-leg checks re-ran byte-identical to the earlier transcript. The four key-source states are new:

state /api/state UI /oauth/jwks /api/mcp
OAUTH_SIGNING_KEY inline 200 200 200 401 (works)
OAUTH_SIGNING_KEY_FILE 200 200 200, same kid 401 (works)
both set 200 200 503 503
neither set 200 200 503 503

Both-set and neither-set degrade MCP only/api/state, the UI and settings all answer 200, so the control plane boots normally. The refusal is explicit in the log:

level=40 err.type=InvalidSigningKey
  "both OAUTH_SIGNING_KEY and OAUTH_SIGNING_KEY_FILE are set, and they are exclusive — unset one"
503 {"error":"no OAuth signing key; set OAUTH_SIGNING_KEY_FILE or OAUTH_SIGNING_KEY (see the boot log)"}

And the database is genuinely ignored — observed, not assumed. With a valid keyset seeded into meta.oauth_jwks and no env var:

/oauth/jwks → 503   /api/mcp → 503
info: "neither OAUTH_SIGNING_KEY_FILE nor OAUTH_SIGNING_KEY is set; OAuth and MCP are disabled"
info: "a plaintext oauth_jwks row survives in `meta`; it is no longer read,
       and deleting it is safe once no client needs the old key"

The control that makes that meaningful: the same keyset handed in via OAUTH_SIGNING_KEY serves 200 and a working /oauth/jwks. So the 503 above is the row being ignored, not the key being bad.

Simplify, gated on more than green tests

A green suite proves tests pass, not that they still mean anything — so the simplify pass was gated on a 12-guard mutation harness that re-breaks each security guard and asserts the named test dies: audience, issuer and typ binding; the tool-surface admission rule; the registry scope filter; Host-before-bearer ordering; the Origin leg; the HTTPS refusal; the both-spellings exclusivity; the single-key and EC P-256 checks; and the gate placement that keeps a vault key off /api/mcp.

12/12 before, 12/12 after, no NOT-APPLIED. The harness reports NOT-APPLIED when an anchor no longer matches, because a mutation that silently fails to apply is indistinguishable from a test gap.

The pass found exactly one thing: the no-signing-key 503 sentence was hand-written byte-for-byte in two files — the only duplicated value literal in the branch. A rename of either variable would have updated one copy and left the two 503 surfaces telling the operator to set different things.

Its rejections were as useful. It declined to make importJWK eager in verifier.ts, because deps.ts builds a verifier per request — an eager import would run the key import for requests refused at the Host/Origin stage, the attacker-controlled path the ordering guard exists to keep cheap. And it kept InvalidSigningKey a class rather than a factory because pino's serializer emits the class name; the boot log above shows err.type=InvalidSigningKey, so that judgement was right.

Gates

typecheck exit 0 (5157 files, 0 errors) · vitest exit 0 (192 files, 2169 tests) · lint exit 0 · build exit 0 — each run as its own command with exit codes captured directly.

That last detail is not pedantry: pnpm build 2>&1 | tail -4; echo $? reports tail's status, so several earlier "exit 0" readings in this thread were not evidence. 07ba490 records that and the chaining race in AGENTS.md.

Still the only open item

Claude has not connected. Everything else is verified. One note on ordering: setting the key swaps it and invalidates issued tokens, so wiring the secret before a client connects costs nothing, and afterwards costs one re-authorization.

## Signing key: no database, two spellings — and a simplify pass, verified regression-free Since the last comment the signing key stopped coming from the database entirely, gained a Docker-secret form, and the branch had a final simplify pass. All of it re-verified against the built server. ### The change `OAUTH_SIGNING_KEY` (base64 inline) **or** `OAUTH_SIGNING_KEY_FILE` (a path to a file holding the same base64). The `meta` path is gone, so "the signing key is never in the database" is now true by construction rather than by operator discipline — previously it was closed only if you set the var *and* manually deleted the row. `_FILE` is the recommended production form, and the docs say why rather than just recommending it: a value in `Env:` is readable by anyone who can `docker inspect` or read `/proc/1/environ` — the rule AGENTS.md already stated for worker containers, applied to the same container it was always true of. **Both set is a hard error, keyed on presence rather than emptiness.** That is a deliberate deviation from the postgres/mysql entrypoints, which use `[ "${!var:-}" ]` and so read an empty value as unset. Here an empty value is already a fault — it is what a secret that failed to mount produces — so treating it as absence would silently hand over to the other spelling. ### Live verification, against a rebuild of `06d8b00` The nine original checks and the seven modern-leg checks re-ran **byte-identical** to the earlier transcript. The four key-source states are new: | state | `/api/state` | UI | `/oauth/jwks` | `/api/mcp` | |---|---|---|---|---| | `OAUTH_SIGNING_KEY` inline | 200 | 200 | 200 | 401 (works) | | `OAUTH_SIGNING_KEY_FILE` | 200 | 200 | 200, **same `kid`** | 401 (works) | | **both set** | 200 | 200 | 503 | 503 | | **neither set** | 200 | 200 | 503 | 503 | Both-set and neither-set degrade **MCP only** — `/api/state`, the UI and settings all answer 200, so the control plane boots normally. The refusal is explicit in the log: ``` level=40 err.type=InvalidSigningKey "both OAUTH_SIGNING_KEY and OAUTH_SIGNING_KEY_FILE are set, and they are exclusive — unset one" 503 {"error":"no OAuth signing key; set OAUTH_SIGNING_KEY_FILE or OAUTH_SIGNING_KEY (see the boot log)"} ``` **And the database is genuinely ignored** — observed, not assumed. With a valid keyset seeded into `meta.oauth_jwks` and no env var: ``` /oauth/jwks → 503 /api/mcp → 503 info: "neither OAUTH_SIGNING_KEY_FILE nor OAUTH_SIGNING_KEY is set; OAuth and MCP are disabled" info: "a plaintext oauth_jwks row survives in `meta`; it is no longer read, and deleting it is safe once no client needs the old key" ``` The control that makes that meaningful: **the same keyset** handed in via `OAUTH_SIGNING_KEY` serves 200 and a working `/oauth/jwks`. So the 503 above is the row being ignored, not the key being bad. ### Simplify, gated on more than green tests A green suite proves tests pass, not that they still mean anything — so the simplify pass was gated on a 12-guard mutation harness that re-breaks each security guard and asserts the *named* test dies: audience, issuer and `typ` binding; the tool-surface admission rule; the registry scope filter; Host-before-bearer ordering; the Origin leg; the HTTPS refusal; the both-spellings exclusivity; the single-key and EC P-256 checks; and the gate placement that keeps a vault key off `/api/mcp`. **12/12 before, 12/12 after, no `NOT-APPLIED`.** The harness reports `NOT-APPLIED` when an anchor no longer matches, because a mutation that silently fails to apply is indistinguishable from a test gap. The pass found exactly one thing: the no-signing-key 503 sentence was hand-written byte-for-byte in two files — the only duplicated value literal in the branch. A rename of either variable would have updated one copy and left the two 503 surfaces telling the operator to set different things. Its rejections were as useful. It declined to make `importJWK` eager in `verifier.ts`, because `deps.ts` builds a verifier per request — an eager import would run the key import for requests refused at the Host/Origin stage, the attacker-controlled path the ordering guard exists to keep cheap. And it kept `InvalidSigningKey` a class rather than a factory because pino's serializer emits the class name; the boot log above shows `err.type=InvalidSigningKey`, so that judgement was right. ### Gates `typecheck` exit 0 (5157 files, 0 errors) · `vitest` exit 0 (**192 files, 2169 tests**) · `lint` exit 0 · `build` exit 0 — each run as its own command with exit codes captured directly. That last detail is not pedantry: `pnpm build 2>&1 | tail -4; echo $?` reports *tail's* status, so several earlier "exit 0" readings in this thread were not evidence. `07ba490` records that and the chaining race in AGENTS.md. ### Still the only open item **Claude has not connected.** Everything else is verified. One note on ordering: setting the key swaps it and invalidates issued tokens, so wiring the secret **before** a client connects costs nothing, and afterwards costs one re-authorization.
All checks were successful
ci / nexus (pull_request) Successful in 9m34s
ci / images (pull_request) Successful in 10m8s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/mcp-resource-server:feat/mcp-resource-server
git switch feat/mcp-resource-server

Merge

Merge the changes and update on Forgejo.
git switch main
git merge --no-ff feat/mcp-resource-server
git switch feat/mcp-resource-server
git rebase main
git switch main
git merge --ff-only feat/mcp-resource-server
git switch feat/mcp-resource-server
git rebase main
git switch main
git merge --no-ff feat/mcp-resource-server
git switch main
git merge --squash feat/mcp-resource-server
git switch main
git merge --ff-only feat/mcp-resource-server
git switch main
git merge feat/mcp-resource-server
git push origin main
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!170
No description provided.