DEV Community

Casey Li
Casey Li

Posted on

When Free Inference Should Not Back an MCP Tool

Free inference is a scratch pad. It is not a backend for an MCP tool that other agents, editors, or CI jobs will call by name. The instant a team publishes a tool over the Model Context Protocol, callers freeze the name, the argument schema, and the implied side effects into their own loops. A free backend that can stall, drop context, or change shape without a ticket then stops being a convenience and starts being a shared outage.

MCP makes that freeze easy to miss. A local function wrapped as a tool looks like a polite helper in a sidebar. After a second client generates a stub against the schema, the helper is an unofficial API. Free inference behind that API inherits none of the properties those clients quietly assume: bounded latency, a stable output shape, a durable session, or an operator who can be paged.

Think of MCP as a hallway directory. The directory does not merely suggest a room. It tells every visitor which door to knock on, and visitors start leaving notes on that door. If the room is a borrowed lounge that closes without warning, the notes become a pile of incident tickets. The directory was honest. The room was never owned.

That failure mode is not the same as hosting an agent on free inference, and it is not the same as letting an agent hold tools. Here the free model is the tool. Other programs treat its JSON as ground truth. That reversal is the red flag that should stop registration before a socket opens.

Writing is the first eject condition. A tool that creates branches, opens issues, patches files, or talks to a webhook is exercising authority. Authority needs an owner and a rollback path. Free inference supplies neither. A hallway that offers a “fix it” button will be pressed by loops that do not read the fine print.

Persistence is the second. If the returned text will be committed, cached, embedded, or used as an identity, the backend has become a source of record. Free inference is a poor source of record. Cache keys derived from model prose will thrash. Identifiers derived from model prose will collide. Release notes derived from model prose will leak into customer channels with no one sure who approved the wording.

Hot paths are the third. A tool invoked on every keystroke, every pull request, or every deploy turns tail latency into a build outage. Callers then add retries. Retries amplify load on a free backend and turn a slow answer into a stampedes of duplicate side effects. The directory still points at the same door. The lounge is now full of the same visitor knocking twice.

Trust boundaries are the fourth. A tool shared with another team, another tenant, or an unattended bot is no longer a scratch pad. Arguments start to contain repository fragments, log lines, and the occasional secret that someone pasted “just this once.” Cross-session confusion is not theoretical once two clients share a name. The safe assumption is that a free backend does not isolate callers the way a product contract would.

Schema freeze is the fifth. Yesterday’s tool returned a string. Today’s free model returns a prose apology wrapped in Markdown. Typed clients break. Onboarding docs lie. Compatibility is not a styling issue. Free inference has no built-in compatibility window, so a published schema is a promise the backend cannot keep.

The artifact below is a registration gate, not a server. It scores a proposed tool against those conditions and refuses to bind a listener when the score says eject. The code is a labeled proposal. It is meant to be read and tested locally, not dropped onto a public port.

# tool_proposal.yaml — declarations the gate trusts more than model prose
name: repo_summarize
mutates: false
output_is_persisted: true
hot_path: false
crosses_trust_boundary: true
callers_auto_retry: true
schema_in_docs: true
arguments_may_contain_secrets: true
binds_shared_socket: true
uses_free_inference: true
Enter fullscreen mode Exit fullscreen mode
# proposal: mcp_free_gate.py — refuse MCP registration, do not implement a server
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import List
import json
import sys


class Verdict(str, Enum):
    PASS = "pass"
    EJECT = "eject"


@dataclass(frozen=True)
class ToolProposal:
    name: str
    mutates: bool
    output_is_persisted: bool
    hot_path: bool
    crosses_trust_boundary: bool
    callers_auto_retry: bool
    schema_in_docs: bool
    arguments_may_contain_secrets: bool
    binds_shared_socket: bool
    uses_free_inference: bool

    @classmethod
    def from_mapping(cls, data: dict) -> "ToolProposal":
        required = cls.__dataclass_fields__.keys()
        missing = [k for k in required if k not in data]
        if missing:
            raise ValueError(f"proposal missing fields: {missing}")
        return cls(**{k: data[k] for k in required})


RED_FLAGS = (
    "mutates",
    "output_is_persisted",
    "hot_path",
    "crosses_trust_boundary",
    "callers_auto_retry",
    "schema_in_docs",
    "arguments_may_contain_secrets",
    "binds_shared_socket",
)


def score(proposal: ToolProposal) -> tuple[Verdict, List[str]]:
    reasons: List[str] = []
    if not proposal.uses_free_inference:
        return Verdict.PASS, ["pinned or local backend; gate does not apply"]

    for flag in RED_FLAGS:
        if getattr(proposal, flag):
            reasons.append(flag)

    if reasons:
        return Verdict.EJECT, reasons
    return Verdict.PASS, ["free inference stays private; no shared MCP surface"]


def refuse_bind(proposal: ToolProposal) -> None:
    verdict, reasons = score(proposal)
    report = {"tool": proposal.name, "verdict": verdict.value, "reasons": reasons}
    print(json.dumps(report, indent=2))
    if verdict is Verdict.EJECT:
        raise SystemExit(
            f"refusing to register {proposal.name!r} as an MCP tool on free inference"
        )


def main(argv: List[str]) -> None:
    if len(argv) != 2:
        raise SystemExit("usage: python mcp_free_gate.py path/to/tool_proposal.yaml")
    raw = Path(argv[1]).read_text(encoding="utf-8")
    try:
        import yaml  # type: ignore
        data = yaml.safe_load(raw)
    except ImportError:
        data = json.loads(raw)
    refuse_bind(ToolProposal.from_mapping(data))


if __name__ == "__main__":
    main(sys.argv)
Enter fullscreen mode Exit fullscreen mode

A dry run should fail closed on the sample proposal. The command below must print eject and a non-zero status. Anything else means the gate was edited into a courtesy warning, which callers will ignore.

python mcp_free_gate.py tool_proposal.yaml; echo "exit: $?"
# expected: JSON with verdict eject, then a non-zero exit
Enter fullscreen mode Exit fullscreen mode
# proposal: test_mcp_free_gate.py — unexecuted until the operator runs pytest
from mcp_free_gate import ToolProposal, Verdict, score


def _base(**overrides) -> ToolProposal:
    payload = dict(
        name="scratch_echo",
        mutates=False,
        output_is_persisted=False,
        hot_path=False,
        crosses_trust_boundary=False,
        callers_auto_retry=False,
        schema_in_docs=False,
        arguments_may_contain_secrets=False,
        binds_shared_socket=False,
        uses_free_inference=True,
    )
    payload.update(overrides)
    return ToolProposal(**payload)


def test_private_scratch_may_pass():
    verdict, reasons = score(_base())
    assert verdict is Verdict.PASS
    assert "shared MCP surface" in reasons[0]


def test_any_red_flag_ejects_free_backend():
    verdict, reasons = score(_base(binds_shared_socket=True, schema_in_docs=True))
    assert verdict is Verdict.EJECT
    assert "binds_shared_socket" in reasons
    assert "schema_in_docs" in reasons


def test_pinned_backend_skips_gate():
    verdict, _ = score(_base(uses_free_inference=False, mutates=True))
    assert verdict is Verdict.PASS
Enter fullscreen mode Exit fullscreen mode

Better alternatives follow the grain of any other interface. Deterministic local functions belong on MCP: path existence, grep over a working tree, a calculator, a linter already installed on the machine. A pinned, billed endpoint with an owner belongs on MCP if the tool must generate language. A human approval step belongs in front of anything that mutates. Free inference belongs in a private scratch session that never receives a public tool name, never binds to a shared port, and never accepts arguments from another process.

Binding advice is similarly dull, and that is the point. Local rehearsal, if it happens at all, should listen on a loopback address and die when the terminal closes. Advertising 0.0.0.0 or a container port “so the team can try it” is how a scratch pad becomes a service. The gate treats binds_shared_socket as an eject on purpose.

# refuse the shared-socket habit; loopback only, and only after score() returns pass
# nc -l 127.0.0.1 8765
# do not: python -m http.server 0.0.0.0:8765
Enter fullscreen mode Exit fullscreen mode

Exit criteria should be mechanical so a tired on-call does not have to invent policy at 2 a.m. Unregister on the first page about latency or content. Unregister when a caller commits model output. Unregister when two clients disagree about the schema. Unregister when the tool name appears in a runbook or an onboarding doc. Unregister when a secret, a production URL, or a customer identifier shows up in a trace. After eject, leave the name retired. Reusing a burned tool name teaches callers that the hallway directory lies.

Who should ignore this gate? Teams that already treat MCP tools as versioned APIs with pinned inference and an on-call owner do not need a free-backend veto; they need change control. Researchers probing a single local client, with no shared socket and no secrets in the prompt, can skip the ceremony. People who want a creative partner in an editor sidebar are not the audience. The audience is the engineer about to add a tools/list entry that some other loop will trust.

Limitations are blunt. The gate reads declarations, not runtime behavior. A tool marked read-only can still wrap a prompt that asks a model to summarize a private repository. A classifier that used free inference to score the proposal would reintroduce the same drift the constants were written to avoid, which is why the red flags are local booleans. MCP client behavior varies. This article does not claim a particular SDK, quota, hardware shape, or uptime story.

A team can still use free inference to rehearse the scoring step, as long as rehearsal never becomes registration. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option are enough to drive a throwaway client that prints PASS or EJECT from the proposal file, provided that client never registers the scored tool on an interface other processes can reach.

The directory should only point at rooms the team actually owns. Free inference can help draft the sign. It should not live behind the door.

Top comments (0)