Port nerve/mcp_server/ to the mcp 2.x lowlevel API - #319
Conversation
|
Retargeted from #317's branch to Consequence for reviewers: the diff now contains two commits — #317's cap Worth being explicit that #317 is still worth merging on its own: fresh installs are |
02264c9 to
07b2e1d
Compare
There was a problem hiding this comment.
Pull request overview
Ports Nerve’s external MCP server to the MCP 2.x low-level API while preserving request attribution and argument validation.
Changes:
- Migrates handler registration and request-context plumbing to MCP 2.x.
- Adds explicit JSON Schema validation for tool calls.
- Adds unit and HTTP integration coverage for the new behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pyproject.toml |
Adds MCP 2.x and JSON Schema dependencies. |
nerve/mcp_server/server.py |
Ports handlers and adds argument validation. |
nerve/mcp_server/http.py |
Threads MCP request context through attribution. |
tests/test_mcp_server.py |
Updates dispatch tests and covers validation. |
tests/test_mcp_http_integration.py |
Tests real HTTP calls and attribution. |
tests/test_mcp_session_binding.py |
Updates context-binding tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and upgraded, so a given commit produces the same dependency tree on any machine. CI installs the same way, with --locked. The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject declared ~15 dependencies with lower bounds only and no lock, so what a fresh install resolved changed silently as upstream published. CI could not see it either, because it keyed its uv cache on pyproject.toml and a restored cache carried stale index metadata — three weeks green while every fresh install was broken. `uv sync --locked` in CI is what makes a single job sufficient. It fails if uv.lock disagrees with pyproject.toml for any reason — a dependency added, removed, or merely re-bounded — so a dependency change cannot merge without a relock, and the relock moves the pins that CI then tests. Verified all three cases: adding a dependency, widening a bound, and narrowing a bound each invalidate the lock, and relocking after a narrowed bound moved the pin (anthropic 0.123.0 -> 0.119.0). An earlier revision of this work went further and added a second, unpinned workflow to catch upstream drift. That has been dropped. Its justification was that CI validated pins but was blind to bounds, and that turned out to be false — `--locked` catches bound changes too, so the pinned job already covers every dependency change. One workflow, not two. `nerve upgrade` installs from the lock as well, which matters because it was named in #316 as the reason a pin had to be reapplied after every upgrade. `_dep_install_cmd` returns `uv sync --project <root> --frozen --inexact` when uv is present and the checkout has a uv.lock. Each flag is load-bearing: * --frozen: never re-resolve or rewrite uv.lock while upgrading. * --inexact: `uv sync` is exact by default and would uninstall anything absent from the lock, silently removing optional extras the user added. Verified: without --inexact an extraneous package is removed, with it it survives. The command this replaces was purely additive, so exact syncing is a regression. * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv otherwise syncs <project>/.venv, which needn't be the same directory, and VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`. Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could remove or downgrade packages well outside Nerve's environment. Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs >=3.13. That inconsistency was already user-facing — `uv pip install -e .` on 3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13, which is what it has always effectively been (ci.yml's own matrix comment said as much), and install.sh stops accepting 3.12 and now validates the version it actually provisioned instead of only checking that some python binary exists. Note `uv pip compile` had silently papered over this by emitting memu-py with no marker; `uv lock` is stricter because it resolves across the whole declared range. Install docs updated to `uv sync`: README, docs/setup.md (quick start, server install, Docker host step, plus a table of which paths the lock covers), docs/worker-guide.md, docs/observability.md, install.sh, and the nerve-dev skill template shipped in nerve/templates/. install.sh no longer creates the venv separately, since `uv sync` manages it. Docker is deliberately NOT converted and is called out as such in the docs: the entrypoint installs with plain `pip install -e .` inside the container, which has no uv, so covering it means a Dockerfile change with its own risks. Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a fresh `git clone` plus bare `uv sync` yields a working editable install with the nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows >=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0, confirming the lock governs the documented path; a real `nerve upgrade --no-pull --no-frontend` runs `uv sync --frozen --inexact` and, with a locked package deliberately uninstalled from the target venv, restores exactly that venv at the locked version; `bash -n install.sh`. Fixes the dependency-management half of #316; the code port is #319. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and upgraded, so a given commit produces the same dependency tree on any machine. CI installs the same way, with --locked. The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject declared ~15 dependencies with lower bounds only and no lock, so what a fresh install resolved changed silently as upstream published. CI could not see it either, because it keyed its uv cache on pyproject.toml and a restored cache carried stale index metadata — three weeks green while every fresh install was broken. `uv sync --locked` in CI is what makes a single job sufficient. It fails if uv.lock disagrees with pyproject.toml for any reason — a dependency added, removed, or merely re-bounded — so a dependency change cannot merge without a relock, and the relock moves the pins that CI then tests. Verified all three cases: adding a dependency, widening a bound, and narrowing a bound each invalidate the lock, and relocking after a narrowed bound moved the pin (anthropic 0.123.0 -> 0.119.0). An earlier revision of this work went further and added a second, unpinned workflow to catch upstream drift. That has been dropped. Its justification was that CI validated pins but was blind to bounds, and that turned out to be false — `--locked` catches bound changes too, so the pinned job already covers every dependency change. One workflow, not two. `nerve upgrade` installs from the lock as well, which matters because it was named in #316 as the reason a pin had to be reapplied after every upgrade. `_dep_install_cmd` returns `uv sync --project <root> --frozen --inexact` when uv is present and the checkout has a uv.lock. Each flag is load-bearing: * --frozen: never re-resolve or rewrite uv.lock while upgrading. * --inexact: `uv sync` is exact by default and would uninstall anything absent from the lock, silently removing optional extras the user added. Verified: without --inexact an extraneous package is removed, with it it survives. The command this replaces was purely additive, so exact syncing is a regression. * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv otherwise syncs <project>/.venv, which needn't be the same directory, and VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`. Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could remove or downgrade packages well outside Nerve's environment. Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs >=3.13. That inconsistency was already user-facing — `uv pip install -e .` on 3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13, which is what it has always effectively been (ci.yml's own matrix comment said as much), and install.sh stops accepting 3.12 and now validates the version it actually provisioned instead of only checking that some python binary exists. Note `uv pip compile` had silently papered over this by emitting memu-py with no marker; `uv lock` is stricter because it resolves across the whole declared range. Install docs updated to `uv sync`: README, docs/setup.md (quick start, server install, Docker host step, plus a table of which paths the lock covers), docs/worker-guide.md, docs/observability.md, install.sh, and the nerve-dev skill template shipped in nerve/templates/. install.sh no longer creates the venv separately, since `uv sync` manages it. Docker is deliberately NOT converted and is called out as such in the docs: the entrypoint installs with plain `pip install -e .` inside the container, which has no uv, so covering it means a Dockerfile change with its own risks. Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a fresh `git clone` plus bare `uv sync` yields a working editable install with the nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows >=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0, confirming the lock governs the documented path; a real `nerve upgrade --no-pull --no-frontend` runs `uv sync --frozen --inexact` and, with a locked package deliberately uninstalled from the target venv, restores exactly that venv at the locked version; `bash -n install.sh`. Fixes the dependency-management half of #316; the code port is #319. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fresh install has been failing at startup with
ImportError: cannot import name 'request_ctx'
from 'mcp.server.lowlevel.server'
mcp was never declared in pyproject.toml — it arrived transitively via
claude-agent-sdk, whose constraint is `mcp<3.0.0,>=1.23.0`. Nothing else in
the tree capped it, so once mcp 2.0.0 landed on PyPI every fresh resolve
picked it up. mcp 2.0 is an API redesign of the lowlevel server, so this ports
the endpoint and declares `mcp>=2,<3` explicitly. Declaring it is correct
regardless: three modules under nerve/mcp_server/ import mcp directly.
What changed upstream, and what it forced here:
- Handler registration moved from `@server.list_tools()` / `@server.call_tool()`
decorators to `Server(..., on_list_tools=, on_call_tool=)` constructor
callbacks. `Server.call_tool` no longer exists.
- The request context is now an argument, not a contextvar. `request_ctx` is
gone with no replacement, so `_resolve_client_info` and
`_bound_identity_from_request` take a `ServerRequestContext` explicitly and
`build_ctx_resolver`'s closure threads it down from the call handler.
- `on_list_tools` returns `ListToolsResult` rather than `list[Tool]`, and
`on_call_tool` receives `CallToolRequestParams` rather than positional
`(name, arguments)`.
- Model attributes are snake_case in Python now (the camelCase wire aliases are
unchanged). Constructing with `isError=` still works via `populate_by_name`,
but reads had to move: `client_params.clientInfo` -> `.client_info`.
Two things worth a reviewer's attention:
mcp 1.x validated tool arguments against `inputSchema` inside its `call_tool`
decorator (`validate_input=True` by default). The 2.x callback does not, so
porting mechanically would have silently dropped argument validation on an
endpoint external clients can reach. `build_mcp_server` now validates
explicitly with jsonschema — declared as a direct dependency, since we import
it — and returns the same isError shape as the other failure paths. Tests pin
the behaviour, including that a rejected call never reaches the handler.
The `clientInfo` -> `client_info` rename was invisible to the entire existing
suite: every unit test builds a context with `session=None`, so that line never
executed, and all 3303 tests passed with it broken. Over real HTTP it raised
AttributeError inside the resolver, which `_call_tool` turns into "Context
error" — every external MCP tool call would have failed. The two new
`test_mcp_http_integration` cases drive `initialize` -> `tools/call` through the
real transport and assert satellite attribution, which is what caught it. The
module docstring had claimed a tools/call flow that was never actually there.
Verified: 3310 tests pass under mcp 2.0.0 on a fresh `--refresh` resolve;
`import nerve.gateway.server` clean; the SDK's in-process MCP bridge (which
serves every agent tool, not just /mcp/v1) round-trips a real tool call on its
mcp-2.x branch; and forcing mcp 1.29.0 fails at import, confirming the `>=2`
floor is load-bearing.
Fixes #316
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
07b2e1d to
72f8a1d
Compare
Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and upgraded, so a given commit resolves to the same versions wherever it is installed. CI installs the same way, with --locked. The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject declared ~15 dependencies with lower bounds only and no lock, so what a fresh install resolved changed silently as upstream published. CI could not see it either, because it keyed its uv cache on pyproject.toml and a restored cache carried stale index metadata — three weeks green while every fresh install was broken. `uv sync --locked` in CI is what makes a single job sufficient for dependency changes. It fails if uv.lock disagrees with pyproject.toml for any reason — a dependency added, removed, or merely re-bounded — so a change cannot merge without a relock, and CI then installs the relocked set. Note this does NOT mean a widened bound gets its new range exercised: `uv lock` is pin-preserving, so relocking after a widening keeps the old pin. Bounds still matter to consumers that resolve from them (plain pip, downstream packagers, and Docker until it is locked), which is why locking Docker is the immediate follow-up rather than carrying a permanent unpinned canary job. `nerve upgrade` installs from the lock as well, which matters because it was named in #316 as the reason a pin had to be reapplied after every upgrade. `_dep_install_cmd` returns `uv sync --project <root> --locked --inexact` when uv is present and the checkout has a uv.lock. Each flag is load-bearing: * --locked: install the lock, and refuse if it no longer matches pyproject. `--frozen` would also avoid rewriting the lock but skips that check, so a drifted checkout would be upgraded to a lock that doesn't describe it. * --inexact: `uv sync` is exact by default and would uninstall anything absent from the lock, silently removing optional extras the user added. Verified: without --inexact an extraneous package is removed, with it it survives. The command this replaces was purely additive, so exact syncing is a regression. * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv otherwise syncs <project>/.venv, which needn't be the same directory, and VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`. Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could remove or downgrade packages well outside Nerve's environment. When uv or the lock is unavailable the previous unpinned behaviour remains — installs predating the lockfile depend on it — but it now warns explicitly that the upgrade is not reproducible instead of degrading silently. Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs >=3.13. That inconsistency was already user-facing — `uv pip install -e .` on 3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13, which is what it has always effectively been (ci.yml's own matrix comment said as much), and install.sh stops accepting 3.12 and now validates the version it actually provisioned. Because install.sh now lets `uv sync --python` manage the environment rather than creating it separately, an existing 3.12 .venv is recreated at 3.13 rather than reused — verified. Two things pinned that the lock cannot pin for us: * setup-uv is pinned to a uv version. uv owns the lockfile format and is also the tool asserting the lock is current, so an unpinned uv is a moving part underneath the reproducibility this adds. * hatchling is bounded `<2`. uv.lock covers runtime dependencies, but build backends resolve fresh in an isolated build environment on every install, so an unpinned backend was a hole in the same claim. Install docs updated to `uv sync`: README, docs/setup.md, docs/worker-guide.md, docs/observability.md, install.sh, and the nerve-dev skill template shipped in nerve/templates/. `uv sync` creates .venv but does not activate it, so the examples activate (or use `uv run`) before calling `nerve`. docs/setup.md also gains a table of which paths install from the lock, an honest note that the lock is platform/marker dependent and does not vendor artifacts, and a migration section for installs predating it — the first `nerve upgrade` across this commit still runs the old in-memory updater, so it installs unpinned, and on Python 3.12 it fails after `git pull` has already advanced the checkout. Docker is deliberately NOT converted and is called out as such in the docs: the entrypoint installs with plain `pip install -e .` inside the container, which has no uv, so covering it means a Dockerfile change with its own risks. Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a fresh `git clone` plus bare `uv sync` yields a working editable install with the nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows >=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0, confirming the lock governs the documented path; a real `nerve upgrade` restores a deliberately broken target venv at the locked version; `bash -n install.sh`. Fixes the dependency-management half of #316; the code port is #319. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Good catch — fixed in The floor is now So 0.2.82 is the genuinely dangerous one: because it declares no ceiling on Confirmed it isn't theoretical — installed that pair and built the in-process server: And you're right that the blast radius is bigger than the endpoint this PR ports. |
Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and upgraded, so a given commit resolves to the same versions wherever it is installed. CI installs the same way, with --locked. The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject declared ~15 dependencies with lower bounds only and no lock, so what a fresh install resolved changed silently as upstream published. CI could not see it either, because it keyed its uv cache on pyproject.toml and a restored cache carried stale index metadata — three weeks green while every fresh install was broken. `uv sync --locked` in CI is what makes a single job sufficient for dependency changes. It fails if uv.lock disagrees with pyproject.toml for any reason — a dependency added, removed, or merely re-bounded — so a change cannot merge without a relock, and CI then installs the relocked set. Note this does NOT mean a widened bound gets its new range exercised: `uv lock` is pin-preserving, so relocking after a widening keeps the old pin. Bounds still matter to consumers that resolve from them (plain pip, downstream packagers, and Docker until it is locked), which is why locking Docker is the immediate follow-up rather than carrying a permanent unpinned canary job. `nerve upgrade` installs from the lock as well, which matters because it was named in #316 as the reason a pin had to be reapplied after every upgrade. `_dep_install_cmd` returns `uv sync --project <root> --locked --inexact` when uv is present and the checkout has a uv.lock. Each flag is load-bearing: * --locked: install the lock, and refuse if it no longer matches pyproject. `--frozen` would also avoid rewriting the lock but skips that check, so a drifted checkout would be upgraded to a lock that doesn't describe it. * --inexact: `uv sync` is exact by default and would uninstall anything absent from the lock, silently removing optional extras the user added. Verified: without --inexact an extraneous package is removed, with it it survives. The command this replaces was purely additive, so exact syncing is a regression. * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv otherwise syncs <project>/.venv, which needn't be the same directory, and VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`. Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could remove or downgrade packages well outside Nerve's environment. When uv or the lock is unavailable the previous unpinned behaviour remains — installs predating the lockfile depend on it — but it now warns explicitly that the upgrade is not reproducible instead of degrading silently. Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs >=3.13. That inconsistency was already user-facing — `uv pip install -e .` on 3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13, which is what it has always effectively been (ci.yml's own matrix comment said as much), and install.sh stops accepting 3.12 and now validates the version it actually provisioned. Because install.sh now lets `uv sync --python` manage the environment rather than creating it separately, an existing 3.12 .venv is recreated at 3.13 rather than reused — verified. Two things pinned that the lock cannot pin for us: * setup-uv is pinned to a uv version. uv owns the lockfile format and is also the tool asserting the lock is current, so an unpinned uv is a moving part underneath the reproducibility this adds. * hatchling is bounded `<2`. uv.lock covers runtime dependencies, but build backends resolve fresh in an isolated build environment on every install, so an unpinned backend was a hole in the same claim. Install docs updated to `uv sync`: README, docs/setup.md, docs/worker-guide.md, docs/observability.md, install.sh, and the nerve-dev skill template shipped in nerve/templates/. `uv sync` creates .venv but does not activate it, so the examples activate (or use `uv run`) before calling `nerve`. docs/setup.md also gains a table of which paths install from the lock, an honest note that the lock is platform/marker dependent and does not vendor artifacts, and a migration section for installs predating it — the first `nerve upgrade` across this commit still runs the old in-memory updater, so it installs unpinned, and on Python 3.12 it fails after `git pull` has already advanced the checkout. Docker installs from the lock too, so no first-class deployment path is left re-resolving. It was resolving dependencies twice, both unpinned: the image synthesised a requirements.txt from `project.dependencies` and pip-installed it, and the entrypoint then ran `pip install -e .`. Both are replaced by uv: * uv enters the image pinned to the same version CI uses. * The build layer runs `uv sync --locked --no-install-project --no-dev` from just pyproject.toml + uv.lock, so it stays cached until deps really change and the project itself can arrive later via the mount. * The entrypoint runs `uv sync --locked --inexact`, which in the normal case only installs the project. * UV_PROJECT_ENVIRONMENT=/opt/nerve-venv puts the environment OUTSIDE /nerve. That is load-bearing, not tidiness: /nerve is a bind mount at runtime, so a .venv beneath it would be shadowed by the host's checkout — and a host venv may not be Linux-compatible at all. Verified in real containers rather than by reading: the build layer installs the locked versions (mcp 2.0.0, claude-agent-sdk 0.2.140, matching uv.lock), and mounting a source tree and running the entrypoint's sync installs the project editable in ~0.6s with `nerve` resolving to /opt/nerve-venv/bin/nerve. One migration wrinkle, documented in docs/setup.md: `nerve init` does not overwrite Docker files that already exist, so pre-existing installs keep their pip-based Dockerfile and entrypoint until the user deletes those two files and regenerates. Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a fresh `git clone` plus bare `uv sync` yields a working editable install with the nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows >=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0, confirming the lock governs the documented path; a real `nerve upgrade` restores a deliberately broken target venv at the locked version; `bash -n install.sh`. Fixes the dependency-management half of #316; the code port is #319. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #316. Merge first of the three open PRs —
mainis broken for freshinstalls until this lands.
Now a single commit on
main. #317 (themcp<2stopgap) is closed, and its commithas been removed from this branch's history, so the diff is purely the port.
The bug
A fresh install fails at startup with the
ImportErrorin the issue.mcpwas neverdeclared in
pyproject.toml— it arrived transitively viaclaude-agent-sdk, whoseconstraint is
mcp<3.0.0,>=1.23.0. Nothing else in the tree capped it, so once mcp2.0.0 landed on PyPI (2026-07-28) every fresh resolve picked it up.
Declaring
mcpexplicitly is correct independent of the version question: threemodules under
nerve/mcp_server/import it directly.What changed upstream
mcp 2.0 is an API redesign of the lowlevel server, not a rename:
@server.list_tools()/@server.call_tool()decoratorsServer(..., on_list_tools=, on_call_tool=)constructor callbacksrequest_ctxcontextvarServerRequestContextpassed as the handler's first argumentlist_toolsreturnslist[Tool]ListToolsResultcall_toolreceives(name, arguments)CallToolRequestParamsserver.request_handlers[RequestClass]server.get_request_handler("tools/call")ServerResultroot unionisError,clientInfo)request_ctxhas no replacement — there is no request contextvar anywhere in mcp 2.0— so
_resolve_client_infoand_bound_identity_from_requestnow take the contextexplicitly and
build_ctx_resolverthreads it down from the call handler. Theirbodies are otherwise unchanged.
StreamableHTTPSessionManager's constructor is identical across both majors, so thetransport wiring is untouched.
Two things worth your attention
1. mcp 2.x silently drops input validation. mcp 1.x's
@server.call_tool()defaults to
validate_input=Trueand runsjsonschema.validate(arguments, inputSchema)before the handler. The 2.x callback does nothing of the kind. Sincenerve used the bare decorator, every external tool call has been schema-validated
purely by library default — with no test covering it, precisely because the library
supplied it.
Porting mechanically would have dropped that on an endpoint external MCP clients can
reach, with nothing failing to indicate it. So
build_mcp_servernow validatesexplicitly and returns the same
isErrorshape as its other failure paths.jsonschemais declared as a direct dependency (it was already an install-timetransitive of claude-agent-sdk) because we now import it. A malformed schema is
treated as our bug and refuses the call rather than skipping validation.
2. A latent break the existing suite could not see. The
clientInfo→client_inforename sits on a line guarded byif rctx.session and rctx.session.client_params:. Every unit test builds a contextwith
session=None, so that line never executed and all 3303 tests passed with itbroken. Over real HTTP it raises
AttributeErrorinside the resolver, which_call_toolconverts into"Context error"— meaning every tool call from everyexternal MCP client would have failed.
What caught it was adding the
tools/call-over-real-HTTP test thattest_mcp_http_integration's module docstring already claimed to have ("drives a fullinitialize→tools/list→tools/callflow") but which was never actuallywritten. Worth noting as a reviewing lens: unit tests that construct their own context
objects cannot validate context plumbing.
Testing
uv pip install -e ".[test]" --refreshresolveimport nerve.gateway.serverclean (thenerve startpath)claude_agent_sdk.create_sdk_mcp_server, so withmcp>=2the SDK's mcp-2.x branch becomes the only path — and it serves the whole Claude backend, not just/mcp/v1. Confirmed the SDK reportsMCP_MAJOR == 2and round-trips a realtools/list+tools/callagainst a nerve registryinitialize→tools/callover the real transport, asserting the satellite session id derives from the handshake'sclientInfo— the observable proof the context arrives populatedmcp==1.29.0fails at import (No module named 'mcp.server.context'), confirming the>=2floor is load-bearingclientInfowas the only one.isError=/inputSchema=appear solely as constructor kwargs, which still work viapopulate_by_nameFrontend untouched (Python only).
Still worth doing by hand before this leaves draft
Point a real external client (Codex / Claude Code) at
/mcp/v1. The new integrationtest covers the transport with a genuine context, which is most of what I wanted from
a manual run — but a real client exercises protocol negotiation and its own
clientInfoshape, and that's the surface where this port's remaining risk lives.🤖 Generated with Claude Code