{"slug": "my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing", "title": "My requirements.txt Is Pinned. My MCP Server's Actual Contract Isn't, and Nothing Would Catch It Changing.", "summary": "A developer discovered that while their requirements.txt pins the MCP library version, the actual contract their MCP server exposes to agents—tool names, parameter shapes, and descriptions—is generated fresh from function signatures and docstrings on every boot, with no versioning, diffing, or testing. Simple code changes like renaming a parameter, altering a type, or editing a docstring can silently rewrite the schema, breaking callers without any test catching it.", "body_md": "Back on 2026-07-14 I found and fixed a real landmine in this repo: `requirements.txt`\n\nhad `mcp[cli]`\n\nwith no version constraint at all. Any fresh install could pull in a breaking major version with zero warning. I pinned it to `mcp[cli]>=1.28.0,<2.0.0`\n\nand moved on, feeling like I'd closed the gap.\n\nI hadn't. I'd only pinned the *library*. The actual contract my MCP server exposes to any agent that connects to it — the tool names, parameter shapes, and descriptions an LLM reads to decide how to call my code — isn't a version string anywhere. It's generated fresh, every time the server boots, from whatever my function signatures and docstrings happen to say at that moment. Nothing pins that. Nothing diffs it. Nothing tests it.\n\nMy server (`server.py`\n\n) is a `FastMCP`\n\napp with plain `@mcp.tool()`\n\n-decorated functions:\n\n``` python\n@mcp.tool()\ndef create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict:\n    \"\"\"Create a new DEV.to article. Returns id and url.\"\"\"\n    payload = {\"article\": {\"title\": title, \"body_markdown\": body_markdown, \"published\": published}}\n    if tags:\n        payload[\"article\"][\"tags\"] = tags\n    result = _dev(\"/articles\", method=\"POST\", data=payload)\n    return {\"id\": result[\"id\"], \"url\": result.get(\"url\"), \"published\": result.get(\"published\")}\n```\n\nFastMCP inspects that signature at import time and builds the JSON Schema an agent actually sees — parameter names, types, which ones are required, and the docstring as the tool's description. I never write that schema by hand and I never check it in anywhere. It's derived, every run, from source that I edit for completely unrelated reasons.\n\nThat's the gap. `requirements.txt`\n\npinning stops *FastMCP's own behavior* from shifting under me between installs. It does nothing about *my* behavior shifting the schema FastMCP generates from *my* code, on every single commit, with no separate review step.\n\nThree ways I could change this file today, for reasons that have nothing to do with \"changing the API,\" and each one silently rewrites the contract:\n\n**Renaming or reordering a parameter.** If I rename `body_markdown`\n\nto `body`\n\nfor readability, the generated schema's property key changes. Any agent, prompt, or cached tool description that referenced `body_markdown`\n\nby name is now wrong — not erroring, just silently building calls against a field that no longer exists in the schema the server actually advertises.\n\n**Widening or narrowing a type.** `tags: list[str] = None`\n\nbecoming `tags: str = None`\n\n(say, because I decide comma-separated is easier to pass from a shell script) changes the schema's `type`\n\nfrom `array`\n\nto `string`\n\n. An agent that built its tool-call plan against the old schema, or a client with a stale cached copy, sends the old shape and now fails a type check it never used to fail.\n\n**Editing a docstring for clarity.** `\"Create a new DEV.to article. Returns id and url.\"`\n\nis the *entire* semantic contract an agent gets for when and how to call this tool — no separate spec, no OpenAPI doc, nothing. If I tighten the wording later and accidentally drop the fact that `published`\n\ndefaults to `False`\n\n, that's not a typo fix. That's a contract change that happens to live in a comment.\n\nNone of these trip a test. `git diff`\n\nshows the change, but nothing in this repo runs the generated schema through a snapshot check, so a schema-shape edit reads exactly like a docstring wording pass in the diff — same file, same kind of hunk, no signal that one of them breaks callers and the other doesn't.\n\nI checked whether this server has *any* schema stability test:\n\n``` bash\n$ grep -rn \"list_tools\\|inputSchema\\|get_schema\" --include=\"*.py\" .\n```\n\nNothing. There's no test file, no golden schema fixture, no CI step that would even print the schema for a human to eyeball. The only way to know what an agent actually receives is `mcp dev server.py`\n\nand reading the Inspector output by hand — which nobody does on every commit, only when something's already visibly broken.\n\nCompare that to the dependency pin I fixed in July: `pip install -r requirements.txt`\n\nwith an unconstrained `mcp[cli]`\n\nwould eventually pull a breaking major version, and I'd find out from an install failure or a runtime crash — annoying, but loud. A schema drift from editing my own function signature is quiet. The server starts fine. It answers `list_tools`\n\nfine. It just answers with something different than what any caller memorized, and the failure shows up downstream, disguised as \"the agent used the wrong argument name,\" which reads like an agent bug, not a server bug.\n\nI'm not going to snapshot-test docstring wording — that's real friction for approximately zero benefit, since prose changes are meant to happen. What I added is a schema-shape guard, checked into the repo, that fails if a tool's parameter names or types change without a matching diff review:\n\n``` python\n# tools/check_schema_snapshot.py\nimport json, sys\nfrom mcp.server.fastmcp import FastMCP\nimport server  # imports the decorated tools\n\ndef extract_shape(mcp: FastMCP) -> dict:\n    return {\n        name: {p: str(t) for p, t in tool.parameters.items()}\n        for name, tool in mcp._tool_manager._tools.items()\n    }\n\ncurrent = extract_shape(server.mcp)\nwith open(\"tools/schema_snapshot.json\") as f:\n    saved = json.load(f)\n\nif current != saved:\n    print(\"Tool parameter shapes changed:\")\n    for name in set(current) | set(saved):\n        if current.get(name) != saved.get(name):\n            print(f\"  {name}: {saved.get(name)} -> {current.get(name)}\")\n    print(\"\\nIf intentional, run with --update to accept the new contract.\")\n    sys.exit(1)\n```\n\nIt doesn't stop me from changing a signature — I still can, and sometimes should. It stops me from changing one *silently*, the same way a version pin doesn't stop me from upgrading a dependency, it just stops me from upgrading it by accident. The difference is I built the dependency version of this discipline back in July and only just noticed I'd never built the one that actually matters more for an MCP server: the contract is the code, not a number next to it, and \"the code changed\" is not the same signal as \"the contract changed on purpose.\"", "url": "https://wpnews.pro/news/my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing", "canonical_source": "https://dev.to/enjoy_kumawat/my-requirementstxt-is-pinned-my-mcp-servers-actual-contract-isnt-and-nothing-would-catch-it-14ng", "published_at": "2026-07-23 03:35:07+00:00", "updated_at": "2026-07-23 03:59:20.232776+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-infrastructure"], "entities": ["FastMCP", "MCP", "DEV.to"], "alternates": {"html": "https://wpnews.pro/news/my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing", "markdown": "https://wpnews.pro/news/my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing.md", "text": "https://wpnews.pro/news/my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing.txt", "jsonld": "https://wpnews.pro/news/my-requirements-txt-is-pinned-my-mcp-server-s-actual-contract-isn-t-and-nothing.jsonld"}}