recce-mcp-dev

Use when modifying recce/mcp_server.py, MCP tool handlers, error classification, or MCP-related tests. Also use when adding new MCP tools or changing tool response formats.

SKILL.md

GitHub

Recce MCP Server Development

Architecture

RecceMCPServer registers list_tools/call_tool handlers via MCP SDK Server. call_tool dispatches to _tool_* methods, classifies errors, logs/emits metrics, re-raises.

Entry point run_mcp_server() pops single_env before passing kwargs to load_context().

Two SDK majors are supported (mcp>=1.23,<3), and _build_server() picks the registration path from the MCP_V2 flag: decorators on 1.x, on_list_tools / on_call_tool constructor kwargs on 2.0. The handler bodies keep their 1.x shapes (List[Tool] / List[TextContent], errors raised); _handle_list_tools / _handle_call_tool adapt them for 2.0. Tests drive the _handle_* adapters on both majors via tests/mcp_compat.py — never server.server.request_handlers[...], which does not exist on 2.0.

Key Patterns

Error classification — Shared indicator lists defined in recce/tasks/rowcount.py. Priority order (PERMISSION_DENIED > TABLE_NOT_FOUND > SYNTAX_ERROR) enforced by _classify_db_error() in mcp_server.py and _query_row_count() in rowcount.py. Classified → logger.warning() + sentry_metrics.count() (when sentry_sdk available). Unclassified → logger.error() + traceback.

MCP SDK quirk — version-dependent, do not generalise. On mcp 1.x the handler must raise for the SDK to set isError=True. On 2.0 a raised exception becomes a JSON-RPC protocol error instead, which an agent reads as a transport failure rather than a tool failure — _handle_call_tool catches and returns CallToolResult(isError=True) so the response is the same on both. Inner handlers still raise; only the adapter converts.

Input validation is not free on 2.0. mcp 1.x validated tools/call arguments against the tool's inputSchema inside Server.call_tool(validate_input=True); 2.0's low-level server dropped that entirely (jsonschema survives only client-side, for output schemas). _handle_call_tool validates explicitly, with 1.x's Input validation error: ... wording. This matters because the failure is silent: "false" is a truthy string, so a boolean skip flag arrives flipped and the work it guards is quietly not done. Any new tool gets this for free — but only as far as its declared schema goes, so "type" and "required" in inputSchema are load-bearing, not documentation.

Single-env — _maybe_add_single_env_warning() adds _warning to diff results. Descriptions get conditional note.

Response Contracts

An MCP tool description is the agent contract; it MUST match the actual response format.

  • Prefer additive changes (_meta fields) over modifying existing field types.
  • Row count consumers: frontend (int), run.py (int comparison), summary.py (int arithmetic), RowCountDiffResultDiffer (3-format compat), MCP agents (description-guided).
  • summary.py row count gotcha: base/curr can be None (TABLE_NOT_FOUND, PERMISSION_DENIED). Guard with an is None check before arithmetic — dict.get(key, 0) does NOT protect when the key exists with a None value. N/A display includes the reason: "N/A (table_not_found)".
  • Format changes require both deterministic tests AND BQ/LLM eval to prove agent behaviour unchanged.

Shared tool descriptions cover both backends

list_tools is a single shared registry: the same Tool(description=...) is served in local and cloud mode, and call_tool routes to CloudBackend.call_tool whenever self.backend is set — before any local _tool_* branch is reached. Widening a tool's description therefore obliges both implementations — RecceMCPServer._tool_* and CloudBackend._tool_* — or the description must state what the other backend returns instead. A description promising a field that only one backend emits is a broken agent contract even when both implementations are individually correct.

Worked example: schema_diff's description promises a schema_coverage block. CloudBackend._tool_schema_diff satisfies it by emitting _schema_coverage(None) — status unknown, "coverage unassessed" — rather than omitting the key.

Origin: PR #1484 review (DRC-3997). Same local/cloud divergence trap as the exception-type note below, in a different guise.

Cloud vs Local Mode Exception Types

CheckDAO / RunDAO operations have cloud-mode and local-mode branches that raise DIFFERENT exception classes. RecceCloudException (defined in recce/util/recce_cloud.py) inherits from Exception, NOT from RecceException. When wrapping a DAO operation in try / except RecceException, cloud-mode failures escape the wrapper and break the consistent error contract.

Rule: For DAO operations that may run in cloud mode, either:

  • Use except (RecceException, RecceCloudException) to catch both, OR
  • Move the DAO call OUTSIDE the typed-exception wrapper (mirrors _tool_create_check's structure, which keeps update_check_by_id unguarded so cloud failures propagate as expected).

Where this matters: any new code in mcp_server.py or *_api.py that adds a DAO write inside an existing try / except RecceException block. Origin: PR #1342 review (DRC-3307).

Testing (Three Layers)

Layer File Data Source Runs In Purpose
Unit tests/test_mcp_server.py Mock RecceContext CI (pytest) Logic correctness — tool handlers, error classification, response format
Integration tests/test_mcp_e2e.py DbtTestHelper + DuckDB (fixed data) CI (pytest) MCP protocol works end-to-end via anyio memory streams
Smoke (E2E) /recce-mcp-e2e skill User's real dbt project + real database Manual The 8 tools that harness covers return valid results against real data

Which mcp version each layer runs against is itself a coverage question. The tox envs resolve one mcp release, so on their own they leave the other major untested — that is how a module failing 5/60 on mcp 2.0 once shipped CI-green. The mcp-smoke-test job in .github/workflows/integration-tests.yaml installs each major in turn and runs both the shell smoke check and the three MCP pytest modules. Anything that touches the MCP_V2 branches, the _handle_* adapters, or tests/mcp_compat.py has to be checked there, not only in the default pytest run.

Tool count — mode-dependent, verify per mode. list_tools registers at most 20 tools, and how many it actually returns depends on the server mode:

Gate Count Tools
always 7 lineage_diff, schema_diff, get_model, get_cll, get_server_info, set_backend, select_nodes
self.backend is None (local only) 1 analyze_model
self.mode == RecceServerMode.server 12 row_count_diff, query, query_diff, profile_diff, value_diff, value_diff_detail, top_k_diff, histogram_diff, list_checks, run_check, create_check, impact_analysis

So preview and read-only mode expose 8; cloud mode never exceeds 19. grep -c 'Tool(' recce/mcp_server.py counts code sites, not any single session's surface — read the if guards around the analyze_model and server-mode blocks before quoting a number.

The /recce-mcp-e2e harness exercises 8 tools (2 always-on + 6 server-mode diff tools), so a green E2E run is not full-surface coverage. Not covered by that harness: analyze_model, create_check, get_cll, get_model, get_server_info, histogram_diff, impact_analysis, select_nodes, set_backend, top_k_diff, value_diff, value_diff_detail.

Each new MCP feature or behavior change should be covered at all three layers.

Test Coverage Gap Analysis

After completing a round of MCP changes (see E2E Gate below for definition), proactively scan for missing test coverage across the three layers before asking about E2E verification.

How to check:

  1. Identify what changed — new tool handler? new error path? new response field?
  2. For each change, verify coverage exists at each layer:
    • Unit: Does tests/test_mcp_server.py have a test case for the new behavior? (happy path + error path)
    • Integration: Does tests/test_mcp_e2e.py exercise the new tool/feature via MCP protocol?
    • Smoke: Will /recce-mcp-e2e template cover the new tool? (If a new tool was added, the template may need updating)

If gaps are found, report them to the user before the E2E gate prompt:

Test coverage gaps found:

  • Unit: missing test for _tool_foo error path when table not found
  • Integration: test_mcp_e2e.py does not exercise foo tool
  • Smoke: /recce-mcp-e2e template does not include foo tool

Want to fill these gaps before running E2E?

Do NOT scan after: test-only changes, comment/doc edits, import reordering.

E2E Verification Gate

After each meaningful round of MCP changes, you MUST ask the user:

MCP changes complete for this round. Run /recce-mcp-e2e to verify?

If the user says yes, invoke /recce-mcp-e2e. If a dbt project path was used earlier in this session, reuse it automatically; otherwise ask.

What counts as "a round":

  • A tool handler added or modified + its unit tests pass
  • Error classification logic changed + tests pass
  • Single-env or response format changed + tests pass

Do NOT ask after: test-only changes, comment/doc edits, import reordering.

This is separate from tests/test_mcp_e2e.py — that file tests with DbtTestHelper + DuckDB in CI. /recce-mcp-e2e verifies the 8 tools that harness covers (see the tool-count note above) against a real dbt project with a real database.

Pitfalls

  • sentry_sdk import: # pragma: no cover on except (CI always has it)
  • Typing: pyproject.toml sets requires-python = ">=3.10" (classifiers 3.10–3.13), so X | Y unions are fine. A previous version of this pitfall claimed a Python 3.9 floor requiring Union[X, Y]; that is obsolete.
  • Pre-commit: black/isort may reformat — re-stage and commit
  • run.py schema_diff_should_be_approved() try/except is intentional (ensures check creation)

File Map

recce/mcp_server.py (server + handlers), recce/tasks/rowcount.py (error indicators, RowCountStatus), recce/run.py (CLI preset), recce/summary.py (display logic), recce/event/__init__.py (Sentry)