{"slug": "pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies", "title": "Pin your MCP server contracts the way you pin your dependencies", "summary": "A developer built mcpward, a CI gate that pins the contract of MCP (Model Context Protocol) servers the way npm lockfiles pin dependencies. The tool snapshots tool descriptions, schemas, and annotations into a lockfile, then fails the build on breaking changes like description rewrites, required parameter additions, or readOnlyHint flips. Invariant Labs previously identified tool poisoning and MCP rug pulls with their mcp-scan tool.", "body_md": "You pin your npm dependencies. You have a lockfile. You review the diff when it changes.\n\nNow consider the MCP servers your agent depends on. What pins those?\n\n`tools/list`\n\nhands 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.\n\n**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.\n\n**2. A required parameter appears.** Your existing calls start failing with invalid params, and you find out from production traffic.\n\n**3. readOnlyHint flips from true to false.** A tool you allow-listed as safe to call freely can now mutate state.\n\n**4. A tool disappears.** Best case, a clean error. Worst case, your agent improvises with something else.\n\n[Invariant Labs](https://invariantlabs.ai/) did the foundational work here. They named tool poisoning and MCP rug pulls, and their [mcp-scan](https://github.com/invariantlabs-ai/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.\n\nI 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.\n\nSo I built [mcpward](https://github.com/TsvetanG2/mcpward).\n\n```\nnpx mcpward baseline   # snapshot the server's contract into a lockfile\nnpx mcpward diff       # in CI: fail the build if it drifted\n```\n\nThe baseline captures every tool's name, description hash, input and output schemas, and annotations. Then, when the server ships an update:\n\n```\nDRIFT (5 failed)\n  ✗ Tool \"echo\" description changed (possible rug-pull)\n  ✗ Tool \"compute\" inputSchema added required property \"multiplier\"\n  ✗ Tool \"read_data\" readOnlyHint changed from true to false (tool may now mutate state)\n  ✗ Tool \"removed_tool\" was removed\n\nSummary: 2 passed | 5 failed\n```\n\nExit code `1`\n\n. The build fails. Four contract changes, none of which would have surfaced at runtime until something broke.\n\nNot every change should fail a build. Adding an optional parameter is fine. Adding a required one is not. The classification:\n\n| Change | Class | Fails by default |\n|---|---|---|\n| Tool removed | breaking | yes |\n| Description changed | rug-pull | yes |\n| Required field added, type narrowed, enum tightened | breaking | yes |\n| Optional field added, type widened, constraint loosened | non-breaking | no |\n`readOnlyHint` true→false, `destructiveHint` false→true |\nannotation change | yes |\n| Tool added | info | no |\n\nGetting 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.\n\nSince it's already speaking the protocol as a real client:\n\n**Protocol compliance** — handshake, version negotiation, capability consistency, JSON-RPC correctness.\n\n**The two-layer error contract.** This one is underrated. MCP distinguishes *protocol errors* (a JSON-RPC `error`\n\nobject) from *tool errors* (a **successful** result carrying `isError: true`\n\n). 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.\n\n**Tool-poisoning heuristics** — injection-like phrasing in descriptions, hidden and zero-width unicode, schemas soliciting API keys or passwords, `readOnlyHint`\n\nthat contradicts an obviously destructive tool. Output is SARIF, so findings land in the GitHub Security tab.\n\n**Behavioral suites** — declarative YAML cases with JSONPath assertions:\n\n```\nsuites:\n  - tool: read_file\n    cases:\n      - name: missing file is a tool error, not a protocol error\n        args: { path: \"/does-not-exist\" }\n        expect: { tool_is_error: true }\n      - name: invalid params are a protocol error\n        args: {}\n        expect: { protocol_error_code: -32602 }\n```\n\n**Latency budgets** — p50/p95 per tool against a configurable threshold.\n\nA test harness nobody can trust is worse than none, so the testing approach is deliberately paranoid.\n\nEvery 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.\n\nAnd 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.\n\n```\nnpx mcpward init\nnpx mcpward run\n```\n\nBlack-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.\n\n[https://github.com/TsvetanG2/mcpward](https://github.com/TsvetanG2/mcpward)\n\nIf 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.", "url": "https://wpnews.pro/news/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies", "canonical_source": "https://dev.to/tsvetang2/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies-43j8", "published_at": "2026-07-21 23:43:41+00:00", "updated_at": "2026-07-22 00:29:33.517379+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["Invariant Labs", "mcp-scan", "mcpward", "Claude", "Cursor", "Windsurf"], "alternates": {"html": "https://wpnews.pro/news/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies", "markdown": "https://wpnews.pro/news/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies.md", "text": "https://wpnews.pro/news/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies.txt", "jsonld": "https://wpnews.pro/news/pin-your-mcp-server-contracts-the-way-you-pin-your-dependencies.jsonld"}}