Skip to content

fix(mcp): support both mcp 1.x and 2.0 SDKs - #1504

Merged
iamcxa merged 12 commits into
mainfrom
patch/support-mcp-package-version-2.0
Aug 12, 2026
Merged

fix(mcp): support both mcp 1.x and 2.0 SDKs#1504
iamcxa merged 12 commits into
mainfrom
patch/support-mcp-package-version-2.0

Conversation

@kentwelcome

@kentwelcome kentwelcome commented Aug 11, 2026

Copy link
Copy Markdown
Member

PR checklist

  • Ensure you have added or ran the appropriate tests for your PR.
  • DCO signed

What type of PR is this?

fix — compatibility fix for the optional mcp dependency.

What this PR does / why we need it:

recce mcp-server fails to start against mcp 2.0:

[Error] Failed to start MCP server: 'Server' object has no attribute 'list_tools'

mcp 2.0 removed the low-level Server decorators (@server.list_tools(),
@server.call_tool()). Handlers are now passed to the constructor and use a
(ctx, params) -> Result signature. This PR supports both majors rather than
cutting over, so users on mcp 1.x keep working.

  • MCP_V2 flag selects the registration path at import time.
  • _setup_handlers becomes _make_handlers, returning the same two handler
    bodies unchanged — still List[Tool] / List[TextContent], still raising on
    error. On 1.x they are registered with the old decorators; on 2.0 the thin
    _handle_list_tools / _handle_call_tool adapters wrap them into
    ListToolsResult / CallToolResult.
  • _handle_call_tool catches exceptions and returns isError=True explicitly.
    1.x did that inside the decorator; 2.0 turns a raised exception into a
    JSON-RPC protocol error instead, which an agent reads as a transport failure
    rather than a tool failure. Without this the error contract would silently
    change on 2.0.
  • Tests move to tests/mcp_compat.py helpers instead of poking
    server.server.request_handlers[...] (that dict is gone in 2.0) and instead
    of reading Tool.inputSchema (renamed input_schema in 2.0).
  • Dependency pin widened from mcp~=1.23 to mcp>=1.23,<3.

Second commit adds the regression check that was missing. Nothing in CI ever
started recce mcp-server, which is why a startup failure shipped:

  • smoke_test.sh gains an mcp-server mode. recce mcp-server speaks stdio,
    so there is no port to poll — the check feeds it initialize /
    notifications/initialized / tools/list and asserts the server names itself
    and advertises at least one tool.
  • SMOKE_SERVER selects the surface and defaults to server, so every existing
    caller behaves exactly as before. SMOKE_MCP_VERSION pins which SDK version
    to install, because mcp is an optional extra that CI's uv sync does not
    carry.
  • A new mcp-smoke-test job runs that mode against mcp 1.28.1 and 2.0.0.

Which issue(s) this PR fixes:

None.

Special notes for your reviewer:

Verified on both majors, since CI only exercises one:

mcp 1.28.1 mcp 2.0.0
tests/test_mcp_server.py + test_mcp_cloud_backend.py pass pass
Full suite (pytest tests/) 1581 passed, 5 skipped n/a — see below
Real ClientSession over memory streams 20 tools, call OK, isError=True on failure identical

The wire check ran an actual MCP client against the server on both versions to
confirm registration, a successful call, and the error path produce the same
result.

The new smoke-test function was exercised locally three ways: against a real
recce mcp-server on mcp 2.0.0 and on 1.28.1 (both reported 20 tools), and
against a stub that reproduces the original startup failure, which fails the
check with exit 1 as intended.

Two things worth knowing:

  • tests/test_mcp_e2e.py could not run in my mcp-2.0 environment for an
    unrelated reason: its duckdb rejects dbt-duckdb's python_scan_all_frames
    setting during fixture setup. It passes fully on the mcp 1.28 environment
    (60 passed), which is the version CI installs.
  • mcp 2.0 pulls in a new transitive dependency, httpx2. Worth a pip check
    in a clean environment before anyone pins forward to 2.0.

The 1.x branch in _build_server can be deleted once the floor moves to
mcp>=2.

Does this PR introduce a user-facing change?:

`recce mcp-server` now works with both mcp 1.x and mcp 2.0. Previously the
server failed to start on mcp 2.0 with "'Server' object has no attribute
'list_tools'".

mcp 2.0 removed the low-level Server decorators (@server.list_tools(),
@server.call_tool()) in favour of constructor handlers with a
(ctx, params) -> Result signature, so `recce mcp-server` failed to start
with "'Server' object has no attribute 'list_tools'".

Select the registration path at import time via MCP_V2 and keep the two
handler bodies in their 1.x shapes; on 2.0 thin adapters wrap them into
ListToolsResult / CallToolResult. The call_tool adapter returns
isError=True explicitly because 2.0 turns a raised exception into a
JSON-RPC protocol error instead of a tool error.

Tests move to tests/mcp_compat.py helpers, since 2.0 drops the
request_handlers dict and renames Tool.inputSchema to input_schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
Copilot AI lite review requested due to automatic review settings August 11, 2026 07:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates Recce’s MCP server integration to run against both mcp SDK 1.x and 2.x, preserving the existing tool result/error contract while accommodating the 2.0 handler-registration API changes.

Changes:

  • Add a version-detection flag (MCP_V2) and build the MCP Server using either 1.x decorators or 2.0 constructor handlers.
  • Introduce thin 2.0-compatible adapters (_handle_list_tools / _handle_call_tool) around the existing handler bodies.
  • Update MCP-related tests to use shared compatibility helpers, and widen the optional dependency range to allow mcp 2.x.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
recce/mcp_server.py Adds dual registration paths (1.x decorators vs 2.0 constructor handlers) and 2.0 adapter methods.
tests/mcp_compat.py New test helper module to normalize handler invocation and schema field naming across SDK majors.
tests/test_mcp_server.py Refactors tests to use compatibility helpers and normalized result accessors.
tests/test_mcp_cloud_backend.py Refactors backend delegation tests to use compatibility helpers and normalized result accessors.
pyproject.toml Widens optional mcp dependency constraint to >=1.23,<3.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread recce/mcp_server.py
Comment thread recce/mcp_server.py
`recce mcp-server` speaks stdio, so there is no port to poll: the check
feeds it initialize / notifications/initialized / tools/list and asserts
the server identifies itself and advertises at least one tool. A startup
failure like the mcp 1.x/2.0 handler-registration split shows up here.

SMOKE_SERVER selects the surface ("server" by default, so existing
callers are unchanged); SMOKE_MCP_VERSION pins the SDK version to
install, since mcp is an optional extra that CI's install does not carry.
A new matrix job runs the mcp-server mode against both majors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
Comment thread .github/workflows/integration-tests.yaml Fixed
Comment thread .github/workflows/integration-tests.yaml Outdated
Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread integration_tests/dbt/smoke_test.sh
Comment thread integration_tests/dbt/smoke_test.sh Outdated
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.33333% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
tests/mcp_compat.py 90.90% 2 Missing ⚠️
recce/mcp_server.py 97.50% 1 Missing ⚠️
Files with missing lines Coverage Δ
tests/test_mcp_cloud_backend.py 100.00% <100.00%> (ø)
tests/test_mcp_e2e.py 100.00% <100.00%> (ø)
tests/test_mcp_server.py 99.88% <100.00%> (+<0.01%) ⬆️
recce/mcp_server.py 91.03% <97.50%> (+0.18%) ⬆️
tests/mcp_compat.py 90.90% <90.90%> (ø)

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

kentwelcome and others added 3 commits August 11, 2026 15:57
- Launch `recce mcp-server` outside check_mcp_server_status, mirroring
  how check_server_status is called. The handshake is written to a file
  and fed on stdin, so the server can be backgrounded like `recce server &`
  and the check just waits on its pid.
- Take major.minor mcp versions (`1.29`, `2.0`) and install with `~=`
  instead of `==`, so a new patch release is picked up automatically.
- Add an explicit `permissions: contents: read` block to the workflow
  (CodeQL: workflow does not limit GITHUB_TOKEN permissions).
- Drop a stray tooling marker from the MCP_V2 comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
`recce mcp-server` is launched in the background as an HTTP/SSE server on
an MCP port, the way `recce server` is, and every request now goes over
that port from inside check_mcp_server_status: poll /health, open the
GET /sse response stream, POST initialize / notifications/initialized /
tools/list to the session endpoint the stream hands out, then assert the
server identifies itself and advertises tools. The function stops the
server on the way out, matching check_server_status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
`_build_server` picks its branch from MCP_V2, so whichever SDK major is
installed leaves the other branch unexecuted — the 2.0 constructor kwargs
and the 1.x decorators cannot both be reached natively. Patch the flag and
assert each path wires the handlers it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
@kentwelcome
kentwelcome requested a review from iamcxa August 11, 2026 08:46

@iamcxa iamcxa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@kentwelcome — reviewed at head c5c3395d. Event: REQUEST_CHANGES (two blocking items; everything below them is advisory).

The compat rewrite is the right shape and it genuinely boots on both majors — I verified
that independently, not just from the green CI. Two things do not survive the widened pin:
mcp 2.0's low-level server dropped input-schema validation that 1.x performs by default,
so tools/call behaviour is not actually identical across the two; and
tests/test_mcp_e2e.py was never migrated to the new compat helpers, so it fails on
mcp 2.0
— invisibly, because no CI job runs the Python tests against 2.x.

PR intent

Goal (PR body / release note) recce mcp-server starts and serves tools on both mcp 1.x and mcp 2.0
Verdict Achieved for start-up and tools/list — verified on both majors
Gap The stronger claim — "keep the response identical across both versions" — does not hold for tools/call with invalid arguments (finding 1)
Gap The sub-claim "Tests move to tests/mcp_compat.py helpers" is incomplete: tests/test_mcp_e2e.py still reads result.isError (finding 2)

Verification performed

Independent of CI, in clean venvs (Python 3.12; one with mcp==2.0.0, one with mcp==1.29.0,
recce installed from this head):

Check Result
pytest tests/test_mcp_server.py tests/test_mcp_cloud_backend.py on mcp 2.0.0 194 passed
pytest tests/test_mcp_e2e.py on mcp 2.0.0 5 failed, 55 passed → finding 2
same file with only .isError.is_error 60 passed — the production code is fine; the defect is test-side
pip check in the clean mcp-2.0 env (you asked for this) clean, 110 packages compatible
uv lock --check at this head fails — "The lockfile at uv.lock needs to be updated" → finding 3
CallToolResult(isError=True) on 2.0 correct — field is_error, alias isError, validate_by_alias=True. Confirms your reply to Copilot; not re-raising it
Server.__init__ on 2.0 accepts on_list_tools / on_call_tool; hasattr(Server, "list_tools") is False, so MCP_V2 detection is right
Tool(inputSchema=...) on 2.0 still valid via the alias — the 20 unchanged construction sites are safe
stdio_server, SseServerTransport, Server.run(...), create_initialization_options() on 2.0 all present
Stale refs to _setup_handlers / request_handlers none remain anywhere
flake8 / isort on changed files clean (black deferred to the pinned pre-commit 26.3.1; my local 25.9.0 only wants to reformat pre-existing textwrap.dedent blocks this PR does not touch)
permissions: contents: read addresses the CodeQL finding

httpx2 2.10.0 (mcp 2.0's new transitive dep) declares github.com/pydantic/httpx2 as its
home. That is package metadata, not an independent provenance audit — worth a human look
before anyone raises the floor to mcp>=2.

Break-point coverage

  • Break point: recce/mcp_server.py:670 (self.server = self._build_server()) — the original
    'Server' object has no attribute 'list_tools' crash site.
  • Failure chain: recce mcp-serverRecceMCPServer.__init__ → handler registration →
    AttributeError on 2.0 → server never starts.
  • Verified at runtime: mcp-smoke-test (1.29) and (2.0) are both green on this head — a real
    recce mcp-server --sse, a real handshake, a non-empty tool list. Plus a real ClientSession
    round-trip on 2.0 locally.
  • Residual — stdio transport: the smoke test drives only --sse. The default
    recce mcp-server (stdio) is exercised on neither major. The crash site is shared, so the
    reported regression is covered; a future stdio-specific divergence would not be.
  • Residual — tools/call: not exercised over the wire on either major (finding 4).

Blocking

1. HIGH — mcp 2.0 does no input-schema validation; 1.x does, by default

_handle_call_tool forwards params.arguments or {} straight to the handler. On mcp 1.x the
SDK validated first — mcp/server/lowlevel/server.py in 1.29.0:

def call_tool(self, *, validate_input: bool = True):
    ...
    # input validation
    if validate_input and tool:
        try:
            jsonschema.validate(instance=arguments, schema=tool.inputSchema)
        except jsonschema.ValidationError as e:
            return self._make_error_result(f"Input validation error: {e.message}")

The 1.x branch of _build_server registers with that default, so it keeps validating. In mcp
2.0 the low-level server imports jsonschema nowhere at all (it survives only in
mcp/client/session.py, for client-side output schemas). Nothing replaces it here.

That is a silent divergence, not a louder error. Concrete case using this repo's own schema —
impact_analysis declares "skip_value_diff": {"type": "boolean"}
(recce/mcp_server.py:1533) and the handler reads it at recce/mcp_server.py:2009 /
:2161:

skip_value_diff = arguments.get("skip_value_diff", False)
...
if not skip_value_diff:

An agent sending {"skip_value_diff": "false"} gets, on 1.x, a clean
isError=True, "Input validation error: 'false' is not of type 'boolean'". On 2.0 the
string is truthy, the value comparison is silently skipped, and the agent receives a
result that looks complete. Same class applies to missing required fields and to
array-typed params receiving a bare string.

Validating in the adapter restores parity, and adds no dependency — jsonschema is already
required by mcp on both majors (and by dbt-core):

async def _handle_call_tool(self, ctx, params) -> CallToolResult:
    arguments = params.arguments or {}
    tool = next((t for t in await self._list_tools() if t.name == params.name), None)
    if tool is not None:
        # `Tool.inputSchema` on 1.x, `Tool.input_schema` on 2.0 — this adapter is
        # also driven by the tests on 1.x, so read it version-agnostically.
        schema = getattr(tool, "input_schema", None)
        if schema is None:
            schema = tool.inputSchema
        try:
            jsonschema.validate(instance=arguments, schema=schema)
        except jsonschema.ValidationError as e:
            return CallToolResult(
                content=[TextContent(type="text", text=f"Input validation error: {e.message}")],
                isError=True,
            )
    try:
        content = await self._call_tool(params.name, arguments)
    ...

1.x caches the tool definitions (_tool_cache) rather than rebuilding them per call; worth
mirroring if _list_tools() turns out to be hot, but correctness first.

(Raised by a cross-model reviewer; I confirmed it against the source of both SDKs, quoted above.)

2. HIGH — tests/test_mcp_e2e.py was not migrated; 5 tests fail on mcp 2.0

The PR adds tests/mcp_compat.py and routes test_mcp_server.py /
test_mcp_cloud_backend.py through it, but tests/test_mcp_e2e.py still reads
result.isError off a real SDK CallToolResult at lines 1265, 1273, 1283, 1293, 1303, 1317,
1326. Reproduced against this head with mcp==2.0.0:

AttributeError: 'CallToolResult' object has no attribute 'isError'. Did you mean: 'is_error'?
5 failed, 55 passed

All five failures share that one cause. Substituting .isError.is_error in a scratch copy
gives 60 passed — which incidentally confirms the server-side 2.0 error contract works end
to end through a real ClientSession.

The environment problem you hit (duckdb rejecting python_scan_all_frames during fixture
setup) was real, and it masked this: the fixtures died before any assertion ran, so the file
looked merely un-runnable rather than incompatible. Once fixtures can run, it fails for a
related reason.

A bare .is_error would break 1.x, so the fix belongs in the compat module — the same
normalisation invoke_call_tool already performs:

def is_error(result) -> bool:
    """Read a ``CallToolResult`` error flag regardless of SDK field naming."""
    value = getattr(result, "is_error", None)
    if value is None:
        value = getattr(result, "isError", None)
    return bool(value)

Non-blocking

3. MEDIUM — the widened pin is not propagated to the lockfile or to the test matrix

Two consequences of mcp = ["mcp>=1.23,<3"] that did not follow it:

a. uv.lock is stale. It still records specifier = "~=1.23" for the extra (uv.lock:2561)
and pins mcp 1.28.1 (uv.lock:1251). uv lock --check at this head reports "The lockfile at
uv.lock needs to be updated"
. CI stays green only because no workflow uses --locked /
--frozen, so every uv sync silently re-resolves and rewrites the lock in the working tree.
uv lock + commit.

b. The MCP test suite never runs on 2.x. tests-python.yamlTest Python Versions uses the
tox [testenv:{3.10,3.11,3.12,3.13}] env, which carries no mcp dep, so the three MCP modules
are skipped at collection (collected 1334 items / 3 skipped). Only [testenv:dbtlatest] has
mcp, pinned mcp>=1.0.0 — below this project's own floor, with no upper bound — and on this
run it resolved to mcp==1.27.1 (visible in the Test DBT Versions log). So the whole MCP
suite runs exactly once in CI, on 1.27.1. That is why finding 2 is invisible.

Using tox's native extras key inherits the declared window instead of drifting from it:

[testenv:dbt{1.6,1.7,1.8,1.9,latest}]
extras =
    dbtlatest: mcp

An explicit 2.x leg that runs tests/test_mcp*.py is what actually closes the gap.

4. MEDIUM — the smoke test never calls a tool

The handshake covers initialize + tools/list. tools/call is never sent, so
_handle_call_tool — the adapter this PR adds specifically to preserve the error contract —
has no automated coverage on either major. Note this is also now the only end-to-end path
on 1.x: the migrated unit tests call _handle_call_tool directly, which on 1.x is not the
registered handler, so nothing exercises the decorator path any more.

Two more POSTs in the same loop close it on both majors over the real wire: one cheap
successful call (get_server_info), and one deliberately failing call (a nonexistent tool name)
asserting "isError":true in the SSE response. That would also have caught finding 1, if the
failing call used a schema-invalid argument instead.

5. LOW — failure paths leave the server and the SSE stream running

Every exit 1 inside check_mcp_server_status (lines 148, 157, 168, 174, 184) fires after
recce mcp-server is already backgrounded, and after line 153 also leaves curl -sN alive.
Both inherit the CI step's stdout, so a failed smoke test can keep the step open past the point
where it has anything left to say. check_server_status has the same shape, so this is not a
regression — but the new function orphans two processes rather than one, and a
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT covers both cheaply.

6. LOW — tool_count -lt 1 is a weaker gate than the risk it guards

The failure mode this job exists to catch is registration, and in server mode list_tools
advertises 20 tools. >= 1 still passes if 19 of them silently stop registering. Asserting a
known name is one more jq line and does not get brittle as tools are added:

jq -e 'select(.id == 2) | [.result.tools[].name] | index("lineage_diff")' <<< "$responses" > /dev/null

7. NIT — the ~= comment describes patch releases; PEP 440 gives minor too

mcp~=1.29 expands to >= 1.29, == 1.*, i.e. >=1.29,<2.0 — a future 1.30 is picked up, not
just patches. Your own reply on the earlier thread states this correctly ("a new patch or minor
release is picked up automatically"); only the in-code comment is off.

8. NIT — input_schema() should test for None, not falsiness

getattr(tool, "input_schema", None) or tool.inputSchema falls through to tool.inputSchema
when the schema is an empty dict, which raises AttributeError on 2.0 (no such attribute
there). Unreachable today — every tool declares a non-empty schema — but it is the same
is None guard this codebase already documents elsewhere.


Cross-model reconciliation

A second reviewer on a different model ran against the same diff.

Bucket Count Items
Agreement (both) 1 no real tools/call coverage on either registration path (finding 4)
Claude-only 5 findings 2, 3b, 6, 7, 8
Cross-model-only 3 findings 1, 3a, 5
Contradictions 0

No arbitration was needed: each cross-model-only finding was verified directly against primary
sources (SDK source for finding 1, uv lock --check for 3a, the script's own control flow for
5) rather than adjudicated between models. Note that the highest-severity item in this review
came from the lane the primary agents did not cover — worth weighing against any instinct that
model agreement is what makes a finding real. The human with domain context is still the decider.

Advisory (not posted as inline comments)

  • PR body is stale against the final implementation. It still describes the stdio design
    ("speaks stdio, so there is no port to poll — the check feeds it initialize / ...") that
    bad52311 replaced with HTTP/SSE, and cites "mcp 1.28.1 and 2.0.0" where the matrix is
    ["1.29", "2.0"]. The body is what the release note and future archaeology inherit.
  • .claude/skills/recce-mcp-dev/SKILL.md:18"MCP SDK quirk — Handler must raise for
    SDK to set isError=True"
    is now version-dependent: true on 1.x, false on 2.0 where
    _handle_call_tool converts explicitly. Worth updating in the same PR, along with a note about
    input validation if finding 1 is addressed.
  • mcp-smoke-test runs the full recce run / recce summary assertion block before it
    reaches the MCP server. The dbt artifacts are genuinely needed; those assertions are not. As
    written, a summary-rendering regression turns the MCP job red for an unrelated reason.

Comment thread recce/mcp_server.py Outdated
Comment thread tests/mcp_compat.py Outdated
Comment thread pyproject.toml
Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread integration_tests/dbt/smoke_test.sh
Comment thread integration_tests/dbt/smoke_test.sh
Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread tests/mcp_compat.py Outdated
iamcxa and others added 6 commits August 11, 2026 18:08
mcp 1.x validates `tools/call` arguments against the tool's `inputSchema`
inside `Server.call_tool(validate_input=True)` and returns
`Input validation error: ...`. mcp 2.0's low-level server dropped that
entirely -- `jsonschema` survives only in the client, for output schemas --
so the 2.0 adapter forwarded raw arguments straight to the tool.

That fails silently rather than loudly. `impact_analysis` declares
`skip_value_diff` as a boolean; the string `"false"` is truthy, so on 2.0
the value comparison is skipped and the agent gets a result that looks
complete. Missing required fields and array params given a bare string
diverge the same way.

`_handle_call_tool` now validates first, with the same message 1.x emits.
Schemas are cached and refreshed on a miss, mirroring the SDK's own
`_tool_cache`: `set_backend` can change the advertised surface at runtime,
and rebuilding the list per call would forge a `Returning N tools` log line
on every `tools/call`.

Verified on mcp 1.29.0 and 2.0.0 -- 259 passed on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
`tests/test_mcp_e2e.py` was the one test module left reading `result.isError`
off a real SDK `CallToolResult`. That attribute exists only on mcp 1.x, so
the module failed 5/60 on 2.0 with

    AttributeError: 'CallToolResult' object has no attribute 'isError'

No CI job could see it: the MCP suite runs exactly once, in the tox
`dbtlatest` env, whose `mcp>=1.0.0` resolves to a 1.x release.

Reading `.is_error` instead would just move the breakage to 1.x, so the
normalisation `invoke_call_tool` already did is lifted into a free
`is_error()` -- it also has to take results built by a real `ClientSession`,
not only ones this module builds.

`input_schema()` now tests for `None` rather than falsiness, so an empty
schema cannot fall through to the attribute the other major lacks.

Verified on mcp 1.29.0 and 2.0.0 -- 259 passed on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The check stopped at `initialize` + `tools/list`, which is the part the two
SDK majors agree on. What actually differs is the error contract, and
nothing exercised it:

- id 3 calls `get_server_info` and asserts the call succeeds.
- id 4 sends `{"select":123}` to `lineage_diff`, whose schema says string,
  and asserts `isError` plus `Input validation error`. 1.x rejects that in
  the SDK; on 2.0 the adapter has to.

Verified against a real `recce mcp-server --sse` on mcp 1.29.0 and 2.0.0
(20 tools, both assertions pass), and with the argument made valid, where
the id 4 assertion correctly fails — so it is not vacuous.

Two smaller things in the same check:

- `tool_count -lt 1` passes with 19 of 20 tools silently unregistered,
  which is the exact failure this job exists to catch. Assert `lineage_diff`
  is named.
- Every `exit 1` fired with the server, and later the SSE reader, still
  running and holding the CI step's stdout. An EXIT trap covers both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The lock still recorded the extra as `~=1.23`, so it disagreed with
pyproject the moment the pin moved and `uv lock --check` failed. Nothing in
CI uses `--locked` or `--frozen`, which is why it stayed green: every
`uv sync` silently re-resolved and rewrote the lock in the working tree.

Regenerating only rewrites the recorded specifier (plus two marker
refinements from a newer resolver); the resolved mcp version is unchanged,
since 1.28.1 still satisfies the new window. Moving that is a separate
decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The MCP suite ran exactly once in CI. `Test Python Versions` uses tox envs
with no mcp dep at all, so all three MCP modules were skipped at collection
(`collected 1334 items / 3 skipped`); only `dbtlatest` carried mcp, pinned
`mcp>=1.0.0` -- under the project's own floor, unbounded above -- which
resolved to 1.27.1. Nothing ever ran pytest against 2.x, which is how a
module that fails 5/60 on mcp 2.0 shipped green.

`mcp-smoke-test` already installs both majors, so it is the cheap place to
close this: the smoke step proves the server boots, and the new step proves
the handlers behave. tox now takes mcp through the extra, so that env
inherits the window pyproject declares instead of restating it wrongly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The skill still read `Handler must raise for SDK to set isError=True` as a
flat rule. That is true on mcp 1.x and false on 2.0, where a raised
exception becomes a protocol error and the adapter has to convert it -- so
following the rule as written on 2.0 changes what an agent sees.

Also records the two things this PR made load-bearing and neither obvious
nor greppable: `inputSchema` is enforced (2.0 does no validation of its
own, so `type` and `required` stop being documentation), and which CI job
covers which mcp version, since the tox envs resolve only one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
@iamcxa

iamcxa commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Pushed fixes for all 8 review threads directly to this branch (c5c3395d..6bec82b4, 6 commits) — details are in each thread reply. Flagging that here because it is your branch and it moved under you; happy to drop the commits if you would rather take them yourself.

c7f7987b fix(mcp) — validate tool arguments on mcp 2.0
c0c0302d test(mcp) — route the e2e protocol tests through the compat helpers
122ffde4 test(smoke) — call a tool, and clean up when the check fails
74e33136 chore(deps) — sync uv.lock with the widened mcp window
23258ffe ci(mcp) — run the MCP tests against both SDK majors
6bec82b4 docs(mcp) — record what differs between the two SDK majors

Verification, on both majors rather than only the one CI resolves:

Check mcp 1.29.0 mcp 2.0.0
test_mcp_server + test_mcp_cloud_backend + test_mcp_e2e 259 passed 259 passed
Full suite (pytest tests/) 1583 passed, 5 skipped 1583 passed, 5 skipped
Smoke check against a real recce mcp-server --sse 20 tools, both tool-call assertions pass identical

The 5 skips are the usual mcp-gated ones. The only failures anywhere were the 5 test_server.py SPA routes that need a built frontend — they fail identically on c5c3395d, so they are environmental, not from these commits.

The new smoke assertion was checked for vacuity as well: with the deliberately-invalid argument made valid, it fails with exit 1 rather than passing quietly.

One thing left for you, since it is your text: the PR body still describes the stdio design that bad52311 replaced with HTTP/SSE, and cites mcp 1.28.1 / 2.0.0 where the matrix is ["1.29", "2.0"]. That body is what the release note inherits.

I am not clearing my own REQUEST_CHANGES — the fixes are mine, so self-approving them would not mean anything. Over to you.

@kentwelcome

@iamcxa

iamcxa commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@kentwelcome — ready for your look at the 6 commits on 6bec82b4. Details are in the individual thread replies; nothing new to add here.

CI is green on the new head, including the coverage that was missing before:

Job mcp version Result
mcp-smoke-test (1.29) mcp==1.29.0 20 tools, tool-call assertions pass, 259 passed
mcp-smoke-test (2.0) mcp==2.0.0 20 tools, tool-call assertions pass, 259 passed

That second row is the point — before this, no CI job ran pytest against mcp 2.x at all.

Copilot has been re-requested on the new commits. My REQUEST_CHANGES is deliberately still standing: the fixes are mine, so clearing it myself would not mean anything. Your call whether to take the commits, amend them, or ask me to drop them and hand the findings back.

One item is still yours, since it is your text: the PR body describes the stdio design that bad52311 replaced with HTTP/SSE, and cites mcp 1.28.1 / 2.0.0 where the matrix is ["1.29", "2.0"]. The release note inherits that body.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

@kentwelcome kentwelcome added the reviewing code-review-loop: review in progress label Aug 11, 2026
@kentwelcome

Copy link
Copy Markdown
Member Author

Code Review: PR #1504

SHA 6bec82b4 · Verdict NO-GO · Incremental (c5c3395d..6bec82b4; the earlier commits carry review from Copilot, @kentwelcome and @iamcxa)

Issues

  1. integration_tests/dbt/smoke_test.sh:202 — the id 3 assertion passes when the response has no result at all, so it is a false green for the one regression this PR exists to catch.
    Evidence: echo '{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"boom"}}' | jq -e 'select(.id == 3) | .result.isError != true' prints truenull.isError is null, and null != true. An exception escaping as a JSON-RPC protocol error on 2.0 (exactly what _handle_call_tool is written to prevent) satisfies it. The id 4 assertion is immune because it requires .result.isError == true.
    Pass E.

  2. recce/mcp_server.py:737 — an unknown tool name rebuilds the whole tool list on every call, which re-announces tools/list to the console and to the persistent MCPLogger record. The docstring and tests/test_mcp_server.py:3542 both assert this cannot happen.
    Evidence: instrumented _list_tools at this head — known tool ×2 → 1 rebuild, then 0; unknown tool ×3 → 3 rebuilds, each emitting [MCP] Returning 20 tools: … and one log_list_tools entry. test_schema_lookup_does_not_re_announce_the_tool_list only calls a name that is already cached, so it passes while the invariant it documents is broken on the path test_unknown_tool_is_not_reported_as_a_validation_error proves is live. A hallucinated tool name is the normal way an agent hits it.
    Pass A.

Notes

  1. recce/mcp_server.py:756 — the validation block sits outside the try, so jsonschema.SchemaError leaves _handle_call_tool as an exception, which on 2.0 becomes the transport error the docstring says must not happen. Reachable by shipping one malformed schema ("required": "select" instead of ["select"]) — it then fires on every call to that tool, not just malformed ones.
    Evidence: patching _get_tool_input_schema to return {"type": "object", "required": "select"} gives RAISED out of _handle_call_tool: jsonschema.exceptions.SchemaError.
    Pass D.

  2. recce/mcp_server.py:19import jsonschema is a direct import of a package the project does not declare; it resolves only because every mcp release in the >=1.23,<3 window happens to pull jsonschema>=4.20.0 (checked 1.23.0, 1.25.0, 1.28.1, 2.0.0 on PyPI). Adding it to the mcp extra removes the coupling.
    Pass C.

Suggested fixes

Issue 1 — require the member to exist:

if ! jq -e 'select(.id == 3) | .result != null and .result.isError != true' > /dev/null <<< "$responses"; then

Issue 2 — build once, invalidate where the surface actually changes. _tool_set_backend is the only place self.backend / self.context / self.single_env move after __init__ (lines 2589, 2618–2620, 2628, 2633), so a miss can stop meaning "re-read the list":

if not self._tool_schema_cache:
    self._tool_schema_cache = {tool.name: _tool_input_schema(tool) for tool in await self._list_tools()}
return self._tool_schema_cache.get(name)

plus self._tool_schema_cache = {} in _tool_set_backend. That also closes the stale-schema case the refresh-on-miss was reaching for: a name present both before and after a swap never refreshes today.

Extending the existing test to a second unknown-name call would have caught it.

Verified

  • tests/test_mcp_server.py + test_mcp_cloud_backend.py: 199 passed on mcp 1.29.0 and on mcp 2.0.0.
  • tests/test_mcp_e2e.py: 60 passed (mcp 1.28.1). No .isError reads left on real SDK objects — the remaining hits are mcp_compat.ToolResult's own field.
  • black --check, isort --check-only, flake8: clean. shellcheck: 2 × SC2046, both the pre-existing kill $(jobs -p) idiom this file already uses.
  • uv lock --check: clean at this head.
  • tox config -e dbtlatestextras = mcp; -e dbt1.9 → empty. The factor-conditional extras resolves as intended.
  • Coverage on recce/mcp_server.py reaches every new validation line.
  • Base freshness: branch contains origin/main (9f367a84), MERGEABLE.

@kentwelcome kentwelcome left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

NO-GO at 6bec82b4 — 2 issues, 2 notes. Full review: #1504 (comment) (--request-changes is not available on one's own PR.)

Comment thread integration_tests/dbt/smoke_test.sh Outdated
Comment thread recce/mcp_server.py Outdated
Two review findings on the validation work:

- `_get_tool_input_schema` refreshed on a miss, and an unknown tool name
  misses every time — so every bad call rebuilt all 20 tool definitions
  and forged a `Returning N tools` line plus an MCPLogger `list_tools`
  entry. Measured 3 rebuilds for 3 unknown calls. Build once instead and
  have `_tool_set_backend` clear the cache, since it is the only thing
  that moves the advertised surface after `__init__`. The existing test
  passed because it only called a name already in the cache; it now calls
  an unknown one twice, and fails against the old code.

- The smoke test's id-3 assertion read `.result.isError != true`, which is
  true when there is no `result` at all — a JSON-RPC error response, i.e.
  exactly the escaped-exception regression that call exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
@kentwelcome

Copy link
Copy Markdown
Member Author

Review outcome — 0350e2a7

Both issues from the review are fixed and pushed. Re-verdict: GO.

Finding Status Evidence
smoke_test.sh:202 — id-3 assertion false green Fixed Rejects a JSON-RPC error response, still passes a real success, still fails on isError: true
mcp_server.py:737 — unknown name re-announces the tool list Fixed Unknown name ×3 → 0 rebuilds (was 3); the extended test fails against the pre-fix code

Both notes are left open deliberately, as non-blocking author calls:

  • mcp_server.py:756jsonschema.SchemaError still escapes as a transport error if a tool ships a malformed schema.
  • mcp_server.py:19jsonschema stays an undeclared direct import, satisfied by mcp's own dependency across the whole >=1.23,<3 window.

Verified at this head: 200 tests on mcp 1.29.0 and on mcp 2.0.0, 60 e2e on 1.28.1, black / isort / flake8 clean, and the real recce mcp-server --sse smoke check green on both majors (20 tools, success and invalid-input assertions).

GitHub does not allow --approve on one's own PR, so the review state cannot be flipped from here; the verdict above is the record.

@kentwelcome kentwelcome removed the reviewing code-review-loop: review in progress label Aug 11, 2026
@kentwelcome
kentwelcome requested a review from iamcxa August 12, 2026 00:19

@iamcxa iamcxa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@iamcxa
iamcxa merged commit eceea09 into main Aug 12, 2026
24 checks passed
@iamcxa
iamcxa deleted the patch/support-mcp-package-version-2.0 branch August 12, 2026 01:39
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.

4 participants