Refactor MCP server for capability negotiation and dual-surface endpoints - #57
Conversation
Update the MCP implementation to the current protocol revision and make the served surface depend on what the connecting client actually supports. Dependencies: - agents 0.3.10 -> 0.20.1 (17 minors; adds the stateless Workers MCP handler) - @modelcontextprotocol/sdk 1.29.0 -> 1.30.0, plus the new v2 split package @modelcontextprotocol/server 2.0.0 - @modelcontextprotocol/ext-apps 1.7.1 -> 1.7.5, zod 3 -> 4 Net effect on `npm audit`: 21 vulnerabilities -> 12. Protocol: - Serve the 2026-07-28 revision via createMcpHandler from agents/mcp/server, with the built-in legacy fallback still serving 2025-era clients. - Client capabilities now arrive per-request in `_meta`, so negotiation works without a session — which a stateless Worker cannot keep anyway. Client-aware surface: - MCP Apps is genuinely negotiable: `ui://` resources and tool UI metadata are advertised only to clients declaring io.modelcontextprotocol/ui. - Resource/prompt consumption is NOT negotiable in any revision, so the tools-only surface is selected by URL (/mcp/tools-only) instead. There, resources and prompts are reprojected as tools over the same handlers, so the two surfaces cannot drift. Fixes found while migrating: - Individual memory resources were registered as the literal strings `memory://*` and `memory://*/text`. Those select the SDK's static-resource overload, matched by exact string equality, so no real memory ID ever resolved and reads failed outright. Now ResourceTemplate-based, and individual memories are enumerated in resources/list. - Resource read results were double-wrapped and JSON-stringified. - update_url_content declared `id` optional with a description promising an "update all" mode the handler rejects, and omitted the `force` param the handler reads. - /mcp/health restated the tool list by hand; it is now derived and covered by a test. Also: - Add tool titles and annotations; destructiveHint on merge_tags, delete_memory and update_memory so conformant hosts confirm before calling. - Emit structuredContent alongside the existing text blocks, replacing the JSON-sniffing the UI apps had to do. Both text blocks are unchanged. - Add protocol-level tests driving a real client and real HTTP requests; the repo previously had none. Behaviour changes for clients: - Clients must accept both application/json and text/event-stream (streamable HTTP); the old transport forced JSON. - 2026-07-28 requests must send an Mcp-Method header agreeing with the body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
memory-server | 9a19599 | Commit Preview URL Branch Preview URL |
Jul 29 2026, 11:59 AM |
|
🤖 Hi @PrashamTrivedi, I've received your request, and I'm working on it now! You can track my progress in the logs for more details. |
|
🤖 I'm sorry @PrashamTrivedi, but I was unable to process your request. Please see the logs for more details. |
CI triageThree checks are red. Two are pre-existing infrastructure failures unrelated to this diff; the third I could not diagnose without Cloudflare's build logs.
|
| Check | Result |
|---|---|
npm ci from this lockfile, clean tree |
✅ |
wrangler deploy --dry-run from that clean tree |
✅ (1.5 MB, 265 KB gzipped) |
Worker boots in real workerd (wrangler dev --local) |
✅ /health responds |
Legacy initialize (2025-06-18) in workerd |
✅ negotiates correctly |
Modern tools/list (2026-07-28 envelope) in workerd |
✅ returns all 14 tools |
tools/call dispatch in workerd |
✅ reaches handler |
| Zod input validation in workerd | ✅ rejects bad input — no eval/CSP issue |
tsc --noEmit |
✅ |
vitest run |
✅ 59/59 |
The workerd runs matter most here: a module-evaluation failure is the usual way something passes --dry-run and still fails Cloudflare's deploy-time startup check, and that is not happening. Node version is also not it — wrangler@^4.88.0 already required Node ≥22 before this branch, with no version pin, and builds were green.
Blocker: the failure detail lives only in the Cloudflare dashboard build log, and the Cloudflare MCP connector in my session is unauthenticated (this session is non-interactive, so I can't run the OAuth flow). I'm deliberately not pushing a speculative fix — pinning a Node version or shuffling dependencies without knowing the error would be guessing.
Could you paste the error from the build log?`` I'll fix it from there. Worth noting the build ran at 05:53 UTC against 691482d, which was ~2 hours before this PR was opened — so if a build has since run on `main`, comparing the two would show whether this is branch-specific at all.
Two things reviewers should know about this diff
Both are intentional and tested, but they are client-visible:
- Clients must now send
Acceptlisting bothapplication/jsonandtext/event-stream. The previous transport usedenableJsonResponse: trueand answered JSON-only clients; streamable HTTP returns406for them. - On the 2026-07-28 revision, requests must carry an
Mcp-Methodheader agreeing with the body, andtools/calladditionally needsMcp-Name(SEP-2243). Conformant client SDKs do this automatically; hand-rolled curl calls will not.
Generated by Claude Code
The 2026-07-28 revision rejects a request whose headers and body disagree (-32020, SEP-2243): every request needs `Mcp-Method`, and `tools/call` also needs `Mcp-Name`. Client SDKs set these automatically, but hand-rolled curl probes do not — found while verifying the endpoint against a local workerd instance. Adds a working curl example. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1
The repo requires Node >= 22 but never declared it, so the Cloudflare Workers build image was free to run something older. That floor rose on main, not on this branch. At the last known-green build (#56, merge 725d9b9) wrangler was ^4.61.0, which requires Node >= 20. Commit da792dc bumped it to ^4.88.0, which requires Node >= 22, with no accompanying version pin — so any build image on Node 20 would have started failing there. This branch inherited that; the packages it adds (@modelcontextprotocol/server and /client, both >= 20) sit below wrangler's floor and are not the binding constraint. Adds `.node-version` (read by Cloudflare Workers Builds to select the image) and an `engines.node` field so the requirement is declared rather than implied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1
Correction + likely fix pushed (
|
| Commit | wrangler | Node required | Workers Build |
|---|---|---|---|
725d9b9 (#56 merge, last known green) |
^4.61.0 |
≥ 20 | ✅ |
da792dc (on main, "bump deps") |
^4.88.0 |
≥ 22 | not checked |
a6fbea9 (main HEAD) |
^4.88.0 |
≥ 22 | not checked |
| this branch | ^4.88.0 |
≥ 22 | ❌ |
wrangler@4.61.0 declares engines.node >= 20.0.0; wrangler@4.88.0 declares >= 22.0.0. That bump landed on main in da792dc, before this branch existed, and it shipped without any Node version pin — no .node-version, no engines field. So a build image on Node 20 would have started failing at da792dc, and this branch simply inherited it.
The packages this PR adds (@modelcontextprotocol/server and /client, both >= 20) sit below wrangler's floor, so they are not the binding constraint.
Fix pushed: .node-version (which Cloudflare Workers Builds reads to select the image) plus an engines.node: ">=22" field, so the requirement is declared rather than implied. Locally: tsc clean, 59/59 tests, npm ci clean.
This is a hypothesis with a clear test — if the build on 41de1c9 goes green, it's confirmed. If it stays red, the cause is something else in the build log and I'll still need you to paste it, since Cloudflare exposes no error text through the GitHub check (output.text is empty) and my Cloudflare connector is unauthenticated in this session.
Worth checking independently of this PR: if main is currently red for the same reason, it will stay red after this merges unless the pin goes in there too.
The other two failures are unchanged and unrelated: claude-review fails in 623 ms at $0 cost (bad/exhausted ANTHROPIC_API_KEY; also failed on #56 in March), and review / review fails on Gemini CLI's untrusted-directory check (needs GEMINI_CLI_TRUST_WORKSPACE=true in the workflow).
Generated by Claude Code
Node pin didn't fix it — blocked, need the build log
I'm leaving Where this stands. Three commits have now failed identically: the dependency change ( Everything reproducible locally passes: Leading hypothesis now: something at the build/account level rather than in the diff — an expired or under-scoped Two things that would unblock me:
Happy to fix it the moment either of those lands. Generated by Claude Code |
The Cloudflare Workers build failure, from the build log:
Detected the following tools from environment:
nodejs@22.23.1, bun@1.2.15, npm@10.9.2
Installing project dependencies: bun install --frozen-lockfile
error: lockfile had changes, but lockfile is frozen
The repo carries both bun.lock and package-lock.json, and Cloudflare
installs with bun. The dependency bump in 691482d was made with npm, so
package-lock.json was regenerated and bun.lock was not — leaving it out of
sync with package.json and failing the frozen install.
That explains why a docs-only and a config-only commit failed identically:
the install step never got as far as the code.
Regenerated bun.lock; `bun install --frozen-lockfile` now succeeds. Verified
against the bun-resolved tree: same dependency versions as npm, tsc clean,
59/59 tests, and an unchanged 265 KB gzipped bundle.
Note the log also shows nodejs@22.23.1, confirming the Node version was
never the problem. Keeping .node-version and engines.node from 41de1c9
anyway — wrangler@4.88.0 does require Node >= 22 and the repo declared
nothing — but they are not what fixes this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1
Found it — lockfile mismatch, fixed in
|
| Check | Result |
|---|---|
bun install --frozen-lockfile (the exact CI command) |
✅ was failing, now clean |
| Dependency versions vs npm resolution | ✅ identical across agents, both MCP SDKs, zod, wrangler |
tsc --noEmit |
✅ |
vitest run |
✅ 59/59 |
wrangler deploy --dry-run |
✅ 265 KB gzipped, unchanged |
Worth fixing separately: two lockfiles for one project is the underlying trap here — npm-based tooling and Cloudflare's bun-based builds will drift again on the next dependency change. Either drop package-lock.json and standardise on bun, or add a CI step running bun install --frozen-lockfile so a mismatch fails fast in GitHub rather than only at deploy. Happy to do either; out of scope for this PR.
The two review-bot failures are unchanged and still unrelated to this branch: claude-review (bad/exhausted ANTHROPIC_API_KEY, also failing on #56 in March) and review / review (Gemini needs GEMINI_CLI_TRUST_WORKSPACE=true).
Generated by Claude Code
✅ Workers Build is green on
|
Summary
Refactored the MCP server to support per-client capability negotiation and dual-surface endpoints. The server now detects MCP Apps support via client capabilities and conditionally exposes resources/prompts, with a separate tools-only endpoint for hosts that don't consume those primitives. Migrated from the legacy
@modelcontextprotocol/ext-appshelpers to direct SDK v2 APIs.Key Changes
ClientProfileinterface andsupportsApps()detection to conditionally expose UI metadata andui://resources based on client capabilities/mcpserves the full surface (tools + resources + prompts) with MCP Apps negotiated per-client/mcp/tools-onlyserves tools only, with resources and prompts reprojected as fallback toolsmemory://*wildcard registration to properResourceTemplatewith URI parameters (memory://{id},memory://{id}/text), fixing a bug where concrete memory URIs failed to matchregisterAppTool()andregisterAppResource()helpers to directserver.registerTool()andserver.registerResource()calls with explicitinputSchemaandannotationscreateDualFormatResponse()(markdown + JSON text block +structuredContent)title,description, andannotations(readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to all tool registrationslist_memory_resources,read_memory_resource,list_workflows, andget_workflowtools for hosts that ignore native resources/promptsdescribeTools()and tool name constants (MEMORY_TOOL_NAMES,FALLBACK_TOOL_NAMES) to keep the health endpoint in sync with actual registrationsNotable Implementation Details
tools/list,resources/list,prompts/list) agree with one anotherio.modelcontextprotocol/uiextension in client capabilities; resource/prompt consumption is not (no protocol signal exists), so it's an operator choice per endpointmcpProtocol.test.ts,mcpNegotiation.test.ts) that drive a real MCP client over in-memory transport to catch registration mistakesCLAUDE.mdto explain the dual-endpoint strategy and protocol revisions supportedhttps://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1