DEV Community

Pin your MCP server contracts the way you pin your dependencies

You pin your npm dependencies. You have a lockfile. You review the diff when it changes. Now consider the MCP servers your agent depends on. What pins those? tools/list hands back names, descriptions, and JSON schemas, and your agent trusts all of it. The description isn't documentation - it's the instruction the model reads to decide what a tool does and when to call it. There's no version pin, no integrity check, no diff to review. The server changes, and your agent's behaviour changes with it.

Four Ways an MCP Dependency Breaks You Quietly

  1. A description is rewritten. Same tool name, same schema, different text. The model now behaves differently and nothing registers a change. This is the one that matters most, because it requires no schema change at all - which is exactly why schema-only diffing misses it.
  2. A required parameter appears. Your existing calls start failing with invalid params, and you find out from production traffic.
  3. readOnlyHint flips from true to false. A tool you allow-listed as safe to call freely can now mutate state.
  4. A tool disappears. Best case, a clean error. Worst case, your agent improvises with something else.

Prior Art, Up Front

Invariant Labs did the foundational work here. They named tool poisoning and MCP rug pulls, and their mcp-scan has detected description changes via tool hashing since April 2025. If you want to audit the MCP servers installed on your own machine, that's the tool - it scans Claude, Cursor, and Windsurf configs, detects cross-origin escalation (tool shadowing), and offers a proxy mode with live guardrails.

None of which I do. I needed something adjacent: a CI gate. Not "is my laptop safe," but "did this dependency's contract change since my last release." And it had to run against internal servers without sending tool descriptions to anyone's API. So I built mcpward.

mcpward: A CI Gate

Two commands:

npx mcpward baseline   # snapshot the server's contract into a lockfile
npx mcpward diff       # in CI: fail the build if it drifted

The baseline captures every tool's name, description hash, input and output schemas, and annotations. Then, when the server ships an update:

DRIFT (5 failed)
βœ— Tool "echo" description changed (possible rug-pull)
βœ— Tool "compute" inputSchema added required property "multiplier"
βœ— Tool "read_data" readOnlyHint changed from true to false (tool may now mutate state)
βœ— Tool "removed_tool" was removed
Summary: 2 passed | 5 failed
Exit code 1

The build fails. Four contract changes, none of which would have surfaced at runtime until something broke.

Breaking vs Non-Breaking Changes

Not every change should fail a build. Adding an optional parameter is fine. Adding a required one is not. The classification:

Change Class Fails by default
Tool removed breaking yes
Description changed rug-pull yes
Required field added, type narrowed, enum tightened breaking yes
Optional field added, type widened, constraint loosened non-breaking no
readOnlyHint true→false, destructiveHint false→true annotation change yes
Tool added info no

Getting that line right is the hard part of the whole tool. It's a pure function with an exhaustive fixture-backed test suite behind it, and it's the part I'd most like to be told I've got wrong.

Additional Checks

Since it's already speaking the protocol as a real client:

  • Protocol compliance - handshake, version negotiation, capability consistency, JSON-RPC correctness.
  • The two-layer error contract. This one is underrated. MCP distinguishes protocol errors (a JSON-RPC error object) from tool errors (a successful result carrying isError: true). A tool that fails its job - file not found, upstream 500 - should return the second, not the first. Servers get this backwards routinely, and it changes how every client must handle the failure. Nothing else checks it.
  • Tool-poisoning heuristics - injection-like phrasing in descriptions, hidden and zero-width unicode, schemas soliciting API keys or passwords, readOnlyHint that contradicts an obviously destructive tool. Output is SARIF, so findings land in the GitHub Security tab.
  • Behavioral suites - declarative YAML cases with JSONPath assertions:
    suites:
      - tool: read_file
        cases:
          - name: missing file is a tool error, not a protocol error
            args: { path: "/does-not-exist" }
            expect: { tool_is_error: true }
          - name: invalid params are a protocol error
            args: {}
            expect: { protocol_error_code: -32602 }
    
  • Latency budgets - p50/p95 per tool against a configurable threshold.

On Trusting a Testing Tool

A test harness nobody can trust is worse than none, so the testing approach is deliberately paranoid. Every check is developed against controlled fixture servers whose correctness is defined up front: a fully compliant one, a deliberately malformed one, a pair differing by exactly one change of each classification class, a slow one, and a poisoned one. Real third-party servers can't serve as ground truth, because you don't control whether they're correct. And every check has a negative test proving it can go red. A check that only ever passes is a false-confidence generator, not a feature. The clean fixture also has to stay 100% clean through every release - a security check that cries wolf trains people to ignore it, which is worse than not shipping it.

Try It

npx mcpward init
npx mcpward run

Black-box, so it works against servers you didn't write, over stdio or Streamable HTTP, in any implementation language. MIT licensed. Runs entirely on your machine: no account, no API calls, no telemetry.

https://github.com/TsvetanG2/mcpward

If you've been bitten by description drift in practice - or if you think I've drawn the breaking/non-breaking line in the wrong place - I'd like to hear it.

Comments

No comments yet. Start the discussion.