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

작성자

카테고리:

← 피드로
DEV Community · Enjoy Kumawat · 2026-07-23 개발(SW)

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.

What actually generates the contract

My server (server.py) is a FastMCP app with plain @mcp.tool()-decorated functions:

@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")}

Enter fullscreen mode Exit fullscreen mode

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.

Where this actually bites

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.

Confirming the gap is real, not hypothetical

I checked whether this server has any schema stability test:

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

Enter fullscreen mode Exit fullscreen mode

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.

What I actually shipped for this

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:

# 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)

Enter fullscreen mode Exit fullscreen mode

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

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다