Skip to content

Refactor MCP server for capability negotiation and dual-surface endpoints - #57

Merged
PrashamTrivedi merged 4 commits into
mainfrom
claude/subagents-mcp-optimization-ajiofu
Jul 29, 2026
Merged

PrashamTrivedi merged 4 commits into
mainfrom
claude/subagents-mcp-optimization-ajiofu

Conversation

@PrashamTrivedi

Copy link
Copy Markdown
Owner

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-apps helpers to direct SDK v2 APIs.

Key Changes

  • Capability negotiation: Added ClientProfile interface and supportsApps() detection to conditionally expose UI metadata and ui:// resources based on client capabilities
  • Dual endpoints:
    • /mcp serves the full surface (tools + resources + prompts) with MCP Apps negotiated per-client
    • /mcp/tools-only serves tools only, with resources and prompts reprojected as fallback tools
  • Resource template fix: Changed memory://* wildcard registration to proper ResourceTemplate with URI parameters (memory://{id}, memory://{id}/text), fixing a bug where concrete memory URIs failed to match
  • Tool registration refactor: Migrated from registerAppTool() and registerAppResource() helpers to direct server.registerTool() and server.registerResource() calls with explicit inputSchema and annotations
  • Response format: Updated to triple-format responses via createDualFormatResponse() (markdown + JSON text block + structuredContent)
  • Tool metadata: Added title, description, and annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to all tool registrations
  • Fallback tools: Implemented list_memory_resources, read_memory_resource, list_workflows, and get_workflow tools for hosts that ignore native resources/prompts
  • Health probe: Exported describeTools() and tool name constants (MEMORY_TOOL_NAMES, FALLBACK_TOOL_NAMES) to keep the health endpoint in sync with actual registrations

Notable Implementation Details

  • The server now resolves the client profile before construction so advertised capabilities (tools/list, resources/list, prompts/list) agree with one another
  • MCP Apps support is negotiable via the io.modelcontextprotocol/ui extension in client capabilities; resource/prompt consumption is not (no protocol signal exists), so it's an operator choice per endpoint
  • Fallback tools delegate to the same handlers as native resources/prompts, ensuring the two surfaces cannot drift
  • Added comprehensive protocol-level tests (mcpProtocol.test.ts, mcpNegotiation.test.ts) that drive a real MCP client over in-memory transport to catch registration mistakes
  • Updated documentation in CLAUDE.md to explain the dual-endpoint strategy and protocol revisions supported

https://claude.ai/code/session_018v7vSzCjL5bPJqUnZyNdd1

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@github-actions

Copy link
Copy Markdown
Contributor

🤖 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.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 I'm sorry @PrashamTrivedi, but I was unable to process your request. Please see the logs for more details.

Copy link
Copy Markdown
Owner Author

CI triage

Three checks are red. Two are pre-existing infrastructure failures unrelated to this diff; the third I could not diagnose without Cloudflare's build logs.

claude-review — pre-existing, not this PR

"type": "result", "is_error": true, "duration_ms": 623,
"num_turns": 1, "total_cost_usd": 0

One turn, 623 ms, $0 cost — the model call failed immediately rather than reviewing anything. This same check also failed on #56 back in March, so it predates this branch. Looks like an expired/invalid ANTHROPIC_API_KEY or an exhausted quota on the repo secret.

review / review (Gemini) — pre-existing workflow config

Gemini CLI is not running in a trusted directory. To proceed, either use
`--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment
variable, or trust this directory in interactive mode.

A configuration gap in the Gemini workflow, not a code problem. Fix is to set GEMINI_CLI_TRUST_WORKSPACE=true (or pass --skip-trust) in .github/workflows/gemini-review.yml.

Workers Builds: memory-server — could not reproduce; need the CF log

This one did pass on #56, so it is a genuine regression signal and I treated it as mine. I could not reproduce it, having checked every stage the build performs:

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:

  1. Clients must now send Accept listing both application/json and text/event-stream. The previous transport used enableJsonResponse: true and answered JSON-only clients; streamable HTTP returns 406 for them.
  2. On the 2026-07-28 revision, requests must carry an Mcp-Method header agreeing with the body, and tools/call additionally needs Mcp-Name (SEP-2243). Conformant client SDKs do this automatically; hand-rolled curl calls will not.

Generated by Claude Code

claude added 2 commits July 29, 2026 07:50
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

Copy link
Copy Markdown
Owner Author

Correction + likely fix pushed (41de1c9)

My earlier comment called the Workers Build a regression from this branch. That framing was wrong, and the evidence points at main instead.

Two new data points. First, 62a16ca — a docs-only commit — failed identically, so the failure isn't content-dependent or transient. Second, tracing the Node floor across the history:

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

Copy link
Copy Markdown
Owner Author

Node pin didn't fix it — blocked, need the build log

41de1c9 still fails, so the Node-version hypothesis from my last comment is disproven. I'm stopping speculative pushes here rather than churning the branch further.

I'm leaving .node-version / engines.node in. It's correct on its own terms — wrangler@4.88.0 genuinely requires Node ≥ 22 and the repo declared nothing — it just isn't what's breaking the build.

Where this stands. Three commits have now failed identically: the dependency change (691482d), a docs-only commit (62a16ca), and a config-only commit (41de1c9). A docs-only commit failing the same way is the important one — whatever this is, it is not sensitive to the code content of the diff.

Everything reproducible locally passes: npm ci from this exact lockfile, wrangler deploy --dry-run (265 KB gzipped), the Worker booting in real workerd and serving /health, both the 2025-era and 2026-07-28 protocol paths, tool dispatch, and Zod validation. tsc clean, 59/59 tests.

Leading hypothesis now: something at the build/account level rather than in the diff — an expired or under-scoped CLOUDFLARE_API_TOKEN on the Workers Build integration, or a binding/plan issue that only surfaces on a real deploy (the config declares ratelimits, D1, three KV namespaces, R2, and Browser Rendering; --dry-run validates the config but provisions nothing). That would fail every commit identically, which is exactly the pattern here.

Two things that would unblock me:

  1. The error text from the build log.`` Cloudflare returns an empty output.text on the GitHub check, so the dashboard is the only place it exists, and my Cloudflare connector is unauthenticated in this session.
  2. Whether main currently builds green. If it doesn't, this was never a property of the PR and merging won't change it.

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

Copy link
Copy Markdown
Owner Author

Found it — lockfile mismatch, fixed in 9a19599

Thanks for the log. The cause is not in the diff at all; the build never reached the code:

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

Cloudflare installs with bun, not npm. This repo carries both bun.lock and package-lock.json. I made the dependency bump in 691482d with npm, which regenerated package-lock.json and left bun.lock untouched — so it no longer matched package.json, and the frozen install failed before anything was built.

That also explains the thing that had me stuck: a docs-only commit and a config-only commit failing identically. They inherited the same out-of-sync bun.lock, and the failure was in the install step, which is blind to what the commit changed. My local checks all used npm ci, so they passed and never exercised the path that was actually breaking.

The log also shows nodejs@22.23.1, which independently confirms the Node hypothesis was wrong. I've kept .node-version / engines.node from 41de1c9 — wrangler@4.88.0 genuinely requires Node ≥ 22 and the repo declared nothing — but they're unrelated to this failure.

Fix: regenerated bun.lock. Verified against the bun-resolved tree, not just the npm one:

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

Copy link
Copy Markdown
Owner Author

✅ Workers Build is green on 9a19599

The bun.lock sync fixed it — deployment succeeded and Cloudflare published preview URLs.

The only remaining red check is claude-review, which is the pre-existing ANTHROPIC_API_KEY failure unrelated to this branch (it was already failing on #56 in March). Nothing in this PR affects it.

I couldn't smoke-test the preview URL myself — outbound egress from my sandbox is proxy-restricted — so if you want a quick manual check before merging, /mcp/health should now list both endpoints and their tool sets, and note that MCP clients must send Accept: application/json, text/event-stream.

Standing recommendation from the last comment: the two lockfiles will drift again on the next dependency change. Worth either dropping package-lock.json in favour of bun, or adding bun install --frozen-lockfile to CI so a mismatch fails in GitHub instead of at deploy time. Happy to do either as a follow-up.

I'll stop the periodic check-ins here.


Generated by Claude Code

@PrashamTrivedi
PrashamTrivedi merged commit b982961 into main Jul 29, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants