# My requirements.txt Is Pinned. My MCP Server's Actual Contract Isn't, and Nothing Would Catch It Changing.

> Source: <https://dev.to/enjoy_kumawat/my-requirementstxt-is-pinned-my-mcp-servers-actual-contract-isnt-and-nothing-would-catch-it-14ng>
> Published: 2026-07-23 03:35:07+00:00

Back on 2026-07-14 I found and fixed a real landmine in this repo: `requirements.txt`

had `mcp[cli]`

with 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`

and moved on, feeling like I'd closed the gap.

I 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.

My server (`server.py`

) is a `FastMCP`

app with plain `@mcp.tool()`

-decorated functions:

``` python
@mcp.tool()
def create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict:
    """Create a new DEV.to article. Returns id and url."""
    payload = {"article": {"title": title, "body_markdown": body_markdown, "published": published}}
    if tags:
        payload["article"]["tags"] = tags
    result = _dev("/articles", method="POST", data=payload)
    return {"id": result["id"], "url": result.get("url"), "published": result.get("published")}
```

FastMCP 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.

That's the gap. `requirements.txt`

pinning 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.

Three 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:

**Renaming or reordering a parameter.** If I rename `body_markdown`

to `body`

for readability, the generated schema's property key changes. Any agent, prompt, or cached tool description that referenced `body_markdown`

by name is now wrong — not erroring, just silently building calls against a field that no longer exists in the schema the server actually advertises.

**Widening or narrowing a type.** `tags: list[str] = None`

becoming `tags: str = None`

(say, because I decide comma-separated is easier to pass from a shell script) changes the schema's `type`

from `array`

to `string`

. 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.

**Editing a docstring for clarity.** `"Create a new DEV.to article. Returns id and url."`

is 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`

defaults to `False`

, that's not a typo fix. That's a contract change that happens to live in a comment.

None of these trip a test. `git diff`

shows 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.

I checked whether this server has *any* schema stability test:

``` bash
$ grep -rn "list_tools\|inputSchema\|get_schema" --include="*.py" .
```

Nothing. 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`

and reading the Inspector output by hand — which nobody does on every commit, only when something's already visibly broken.

Compare that to the dependency pin I fixed in July: `pip install -r requirements.txt`

with an unconstrained `mcp[cli]`

would 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`

fine. 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.

I'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:

``` python
# tools/check_schema_snapshot.py
import json, sys
from mcp.server.fastmcp import FastMCP
import server  # imports the decorated tools

def extract_shape(mcp: FastMCP) -> dict:
    return {
        name: {p: str(t) for p, t in tool.parameters.items()}
        for name, tool in mcp._tool_manager._tools.items()
    }

current = extract_shape(server.mcp)
with open("tools/schema_snapshot.json") as f:
    saved = json.load(f)

if current != saved:
    print("Tool parameter shapes changed:")
    for name in set(current) | set(saved):
        if current.get(name) != saved.get(name):
            print(f"  {name}: {saved.get(name)} -> {current.get(name)}")
    print("\nIf intentional, run with --update to accept the new contract.")
    sys.exit(1)
```

It 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."
