Skip to content

Port nerve/mcp_server/ to the mcp 2.x lowlevel API - #319

Merged
pufit merged 1 commit into
mainfrom
alex-clickhouse/port-mcp-2x
Aug 19, 2026
Merged

Port nerve/mcp_server/ to the mcp 2.x lowlevel API#319
pufit merged 1 commit into
mainfrom
alex-clickhouse/port-mcp-2x

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes #316. Merge first of the three open PRs — main is broken for fresh
installs until this lands.

Now a single commit on main. #317 (the mcp<2 stopgap) is closed, and its commit
has been removed from this branch's history, so the diff is purely the port.

The bug

A fresh install fails at startup with the ImportError in the issue. 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 (2026-07-28) every fresh resolve picked it up.

Declaring mcp explicitly is correct independent of the version question: three
modules under nerve/mcp_server/ import it directly.

What changed upstream

mcp 2.0 is an API redesign of the lowlevel server, not a rename:

mcp 1.x mcp 2.x
Handler registration @server.list_tools() / @server.call_tool() decorators Server(..., on_list_tools=, on_call_tool=) constructor callbacks
Request context request_ctx contextvar ServerRequestContext passed as the handler's first argument
list_tools returns list[Tool] ListToolsResult
call_tool receives (name, arguments) CallToolRequestParams
Dispatch table server.request_handlers[RequestClass] server.get_request_handler("tools/call")
Handler result wrapped in the ServerResult root union returned as-is
Model attributes camelCase (isError, clientInfo) snake_case; camelCase retained as wire aliases

request_ctx has no replacement — there is no request contextvar anywhere in mcp 2.0
— so _resolve_client_info and _bound_identity_from_request now take the context
explicitly and build_ctx_resolver threads it down from the call handler. Their
bodies are otherwise unchanged.

StreamableHTTPSessionManager's constructor is identical across both majors, so the
transport 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=True and runs jsonschema.validate(arguments, inputSchema) before the handler. The 2.x callback does nothing of the kind. Since
nerve 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_server now validates
explicitly and returns the same isError shape as its other failure paths.
jsonschema is declared as a direct dependency (it was already an install-time
transitive 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
clientInfoclient_info rename sits on a line guarded by
if rctx.session and rctx.session.client_params:. 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 raises AttributeError inside the resolver, which
_call_tool converts into "Context error" — meaning every tool call from every
external MCP client would have failed.

What caught it was adding the tools/call-over-real-HTTP test that
test_mcp_http_integration's module docstring already claimed to have ("drives a full
initializetools/listtools/call flow") but which was never actually
written. Worth noting as a reviewing lens: unit tests that construct their own context
objects cannot validate context plumbing.

Testing

  • 3310 pass under mcp 2.0.0 (3303 baseline + 5 validation + 2 HTTP integration), on a fresh uv pip install -e ".[test]" --refresh resolve
  • import nerve.gateway.server clean (the nerve start path)
  • Agent-backend bridge, the cut-over's real risk: nerve serves every agent tool through claude_agent_sdk.create_sdk_mcp_server, so with mcp>=2 the SDK's mcp-2.x branch becomes the only path — and it serves the whole Claude backend, not just /mcp/v1. Confirmed the SDK reports MCP_MAJOR == 2 and round-trips a real tools/list + tools/call against a nerve registry
  • New: initializetools/call over the real transport, asserting the satellite session id derives from the handshake's clientInfo — the observable proof the context arrives populated
  • New: invalid arguments rejected over real HTTP, handler never entered
  • Negative control: forcing mcp==1.29.0 fails at import (No module named 'mcp.server.context'), confirming the >=2 floor is load-bearing
  • Audited for sibling camelCase reads on mcp models; clientInfo was the only one. isError=/inputSchema= appear solely as constructor kwargs, which still work via populate_by_name

Frontend 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 integration
test 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
clientInfo shape, and that's the surface where this port's remaining risk lives.

🤖 Generated with Claude Code

@alex-clickhouse
alex-clickhouse changed the base branch from alex-clickhouse/pin-mcp-below-2 to main August 19, 2026 13:39
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Retargeted from #317's branch to main. Reason: ci.yml triggers on
pull_request: branches: [main], so while this was stacked it received no CI
checks at all
— and of the three PRs in this chain, the port is the one that most
needs them. Stacking was costing exactly the signal it was meant to organise.

Consequence for reviewers: the diff now contains two commits#317's cap
(0b90148) and the port itself (02264c9). Read 02264c9 alone for the port. If
#317 merges first this collapses to just the port; if it doesn't, this PR delivers
both, which is coherent either way since the port supersedes the cap.

Worth being explicit that #317 is still worth merging on its own: fresh installs are
broken right now, that's a one-line change that can go in immediately, and this
port needs real review plus a live-client check first.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pyproject.toml Outdated
alex-clickhouse added a commit that referenced this pull request Aug 19, 2026
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>
alex-clickhouse added a commit that referenced this pull request Aug 19, 2026
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>
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/port-mcp-2x branch from 07b2e1d to 72f8a1d Compare August 19, 2026 15:51
alex-clickhouse added a commit that referenced this pull request Aug 19, 2026
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>
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in 72f8a1d, and it's worse than described, so thank you for it.

The floor is now claude-agent-sdk>=0.2.140. I checked every release's own mcp
constraint to find the right number:

0.2.82            mcp>=1.23.0            ← no upper bound at all
0.2.100 … 0.2.139 mcp<2.0.0,>=1.23.0     ← explicitly incompatible
0.2.140           mcp<3.0.0,>=1.23.0     ← first release allowing 2.x

So 0.2.82 is the genuinely dangerous one: because it declares no ceiling on mcp,
0.2.82 + mcp 2.x is a resolvable combination rather than one the resolver would
reject. 0.2.100–0.2.139 at least fail loudly.

Confirmed it isn't theoretical — installed that pair and built the in-process server:

$ uv pip install 'claude-agent-sdk==0.2.82' 'mcp==2.0.0'    # installs fine
$ python -c "from claude_agent_sdk import create_sdk_mcp_server, tool; ..."
AttributeError: 'Server' object has no attribute 'list_tools'

And you're right that the blast radius is bigger than the endpoint this PR ports.
nerve/agent/backends/claude.py builds the "nerve" MCP server through
create_sdk_mcp_server for every agent tool, so an SDK without 2.x support takes
down the whole Claude backend, not just /mcp/v1. The comment on the floor now records
that reasoning so nobody lowers it back without understanding the coupling.

alex-clickhouse added a commit that referenced this pull request Aug 19, 2026
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>
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 19, 2026 16:08
@pufit
pufit merged commit 99560d7 into main Aug 19, 2026
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.

Fresh install broken by mcp 2.0.0 — ImportError: cannot import name 'request_ctx' from 'mcp.server.lowlevel.server'

3 participants