SmartGateSmartGate

MCP Tools Reference: tools/list Schemas and Annotations

A tool in MCP is one entry in the tools/list response: a name, a JSON Schema in inputSchema describing the arguments, a human title and description, and an annotations block whose readOnlyHint tells the host whether the call can change state. Hosts read that list once and then call tools/call by name.

Short answer: A tool in MCP is one entry in the tools/list response: a name, a JSON Schema in inputSchema describing the arguments, a human title and description, and an annotations block whose readOnlyHint tells the host whether the call can change state. Hosts read that list once and then call tools/call by name. A gateway's job is to make the list honest — one schema per tool, annotations derived from what the tool actually does — and to normalize the request body before a strict parser ever rejects a legal one.

Key takeaways

  • Three lists, three audiences. MCP exposes tools (model-driven), resources (application-driven, addressed by URI), and prompts (user-driven templates). A tools reference is only the third of them, and mixing the three is the most common documentation failure.
  • inputSchema is the contract. Names and descriptions guide a model; the schema decides whether a call is valid at all, so a schema that omits a required parameter is a bug that only shows up in production traffic.
  • Annotations are hints with pessimistic defaults. readOnlyHint defaults to false and destructiveHint to true, so an unannotated tool is treated as a writer that might destroy data. Annotate explicitly or accept the cautious reading.
  • A tool definition and a tool response fail differently. Bad arguments are a schema problem the host can catch before the call; an unparseable response is a transport-shape problem the calling code has to absorb.
  • The gateway normalizes, it does not invent. Empty parameter objects, list-shaped params, and direct-method calls become conforming requests before validation, and every accepted call still produces one audit row.
  • Verify the list from a live server, not from a vendor page. One recorded tools/list response settles name, title, schema, and hint questions at once, and it is the only check that cannot drift.

What this reference covers, and what it leaves to the transport

This page is the reference half of the MCP cluster: what a host can learn from the tool surface, what a gateway normalizes in front of it, and what each returned field actually commits the server to. It deliberately does not re-argue transports or session handling — the transport layer is documented in MCP protocol versions and transports, and the JSON-RPC envelope in MCP message format explained. The measured demand here is narrower than the head term and that is expected: rag architecture carries about 1,600 monthly US searches, agent memory about 1,300, mcp proxy about 720, mcp resources about 590, and the host-specific phrases — claude desktop mcp, langchain mcp, openai mcp — about 480 each. Engineers search for the host, the concept, and the tool name separately, which is why a tools reference has to answer all three in one page instead of assuming the reader arrived with a schema question. The frame this reference sits inside — the three actors, the handshake that precedes every tools/list, and the transport underneath it — is laid out in Model Context Protocol end to end.

MCP resources, prompts, and tools are three separate lists

The first thing a tools reference has to say is which list it documents, because the three are discovered through different methods and consumed by different actors. Tools are model-driven: the model decides to call one, and tools/list plus tools/call are the whole surface. Resources are application-driven: they are addressed by URI, discovered with resources/list and resources/templates/list, and read with resources/read, and the application decides what to attach. Prompts are user-driven: they are templates with arguments, discovered with prompts/list and filled with prompts/get. Tools are also the only one of the three whose entries carry behavior hints, which is why they are the list worth a reference page.

# backend/smartgate/api/mcp.py — source lines 254–299 (register_mcp_tools)
    @server.tool(
        name="smart_memory",
        description=TOOL_DESCRIPTIONS["smart_memory"],
        annotations=tool_annotations("smart_memory"),
    )
    async def smart_memory(
        action: str = Field(
            description="One of: add, search, get, delete (team-scoped memory).",
        ),
        query: str = Field(default="", description="Search query when action=search."),
        text: str = Field(default="", description="Content when action=add."),
        messages: str | list[dict[str, Any]] | None = Field(
            default=None,
            description="Optional messages payload for add.",
        ),
        user_id: str = Field(default="", description="User scope for memory operations."),
        memory_id: str = Field(default="", description="Memory id for get/delete."),
        top_k: int = Field(default=10, description="Max results for search."),
        threshold: float = Field(
            default=0.1,
            description="Similarity threshold for search.",
        ),
        metadata: dict[str, Any] | None = Field(
            default=None,
            description="Optional metadata for add.",
        ),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("memory")
        ctx = _tool_ctx()
        params = _non_empty(
            action=action,
            query=query or None,
            text=text or None,
            messages=messages,
            user_id=user_id or None,
            memory_id=memory_id or None,
            top_k=top_k,
            threshold=threshold,
            metadata=metadata,
        )
        return await _run_with_audit(
            f"memory_{action}",
            ctx,
            module.process(ctx, **params),
            {"action": action},
# backend/smartgate/api/mcp.py — source lines 302–309 (register_mcp_tools)
    @server.tool(
        name="smart_pipe",
        description=TOOL_DESCRIPTIONS["smart_pipe"],
        annotations=ToolAnnotations(
            title="Pipeline orchestrator",
            readOnlyHint=False,
        ),
    )

What the window shows is the registration discipline: one decorator per tool, the description read from a shared table, annotations derived from the tool name, and parameters declared with their defaults and descriptions inline. Two consequences follow. The model-facing metadata and the human-facing docs cannot drift, because both read the same table. And the schema is generated from the declaration rather than hand-written, so a parameter that exists in code cannot be missing from the advertised tool. The last entries in the list are the proof that this is not purely mechanical: the pipeline orchestrator's annotation block is written by hand, with readOnlyHint set to false, because it composes other tools and no naming convention could work that out. That is the honest use of annotations — derived where the rule is real, explicit where the exception is.

Annotations: read-only hints, and why prompts behave differently

Annotations are the smallest part of the tool list and the part hosts act on most visibly, so they are worth deriving rather than hand-maintaining:

# backend/smartgate/api/mcp_tool_docs.py — source lines 63–67 (tool_annotations)
def tool_annotations(name: str) -> ToolAnnotations:
    return ToolAnnotations(
        title=TOOL_TITLES.get(name),
        readOnlyHint=name in READ_ONLY_TOOLS,
    )

Three fields do the work: a human-readable title for the host's UI, a readOnlyHint that says whether invoking the tool can change state, and — in the wider schema — destructiveHint, idempotentHint, and openWorldHint for the cases a host needs to distinguish a safe retry from a risky one. The defaults are deliberately cautious: an unannotated tool is read as a writer that might destroy data, is not safe to retry, and may reach outside a closed domain. Deriving readOnlyHint from a set of read-only tool names keeps the hint consistent with behavior in one place, and it is the difference between a host that runs a fetch silently and a host that stops to ask about every call. Note also what is not annotated: prompts and resources carry no equivalent risk hints, because the client chooses when to use them and a prompt template has no side effects to declare. Annotations are advisory in any case — a server can lie — so a gateway still needs its own budget, rate, and scope controls rather than trusting the list it publishes.

A tool call end to end, from the client's side

The client path is small enough to read in one function, and reading it is the fastest way to see where retries and sessions belong:

# backend/tools/cursor_mcp_deep_test.py — source lines 57–73 (mcp_tool_call)
def mcp_tool_call(mcp_url: str, api_key: str, tool: str, args: dict[str, Any]) -> dict[str, Any]:
    """One SSE session per tool — avoids session expiry on long runs."""
    last_exc: Exception | None = None
    for attempt in range(3):
        client = McpSseClient(mcp_url, api_key)
        try:
            client.connect()
            time.sleep(0.3 * (attempt + 1))
            client.initialize()
            return client.call_tool(tool, args)
        except Exception as exc:
            last_exc = exc
            if "404" not in str(exc) or attempt == 2:
                raise
        finally:
            client.close()
    raise last_exc or RuntimeError("mcp_tool_call failed")

The docstring states the session policy: one session per tool, so a long run cannot fail because an unrelated call expired the shared stream. Around that policy sit the retry rules — three attempts, a small backoff, and a re-raise unless the failure looks like a transient not-found, which is the one error a fresh session can legitimately fix. Everything else is timeouts and authentication, and a client that retries those is just paying twice. The sequencing is also the part an integration gets wrong: connect, initialize, call. A tools/call sent before the handshake completes is the single most common "the server is broken" report, and it is a client ordering bug — which is exactly why a stateless gateway is relaxed about accepting tool traffic during that race instead of refusing it.

Tool schema: what inputSchema really is

The schema is where a tool reference becomes checkable rather than descriptive, and it is also where the server's own parsing starts:

# lib/api/parse-json-body.ts — source lines 3–15 (parseJsonBody)
async function parseJsonBody(
  request: Request,
): Promise<Record<string, unknown>> {
  try {
    const raw: unknown = await request.json();
    if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
      return {};
    }
    return raw as Record<string, unknown>;
  } catch {
    return {};
  }
}

Two decisions are visible. The parser is total: not-JSON, null, an array, or a non-object scalar all collapse to an empty object instead of raising, which is what keeps a malformed request from producing a stack trace in place of a protocol error the client can act on. And the failure is typed at the edge: the caller receives a plain record, so the code that validates against inputSchema decides what is wrong and reports it in one place. For a tools reference, the practical checklist is the field set rather than the parser: every tool advertises a name, a title for display, a description the model reads, an inputSchema that lists required parameters with their types and defaults, and an annotations block. Optional fields are the trap — a parameter with a default must not be marked required, and a parameter that is required in code must not be advertised as optional, because the host trusts the schema and will not re-derive it from prose. The conventions for writing that surface down in a form generators and agents can consume — coverage tests, frontmatter, and the published schema — are in API documentation best practices.

Tool definition vs tool response: two parsers, two failure modes

Definitions and responses get confused in review notes constantly, and the code that handles them is deliberately asymmetric:

# lib/api/parse-json-response.ts — source lines 3–5 (parseJsonResponse)
async function parseJsonResponse<T>(res: Response): Promise<T> {
  return (await res.json()) as T;
}

A tool definition is what the server advertises; a tool response is what comes back from a call, and the types do not meet. The definition is validated hard, because a bad definition misleads every client that will ever connect. The response is absorbed softly, because the client's only job is to hand the host a typed value and let the host decide what the content means. Reading a response as if it were a definition — checking for inputSchema in a result, or expecting annotations on a call — is the signature of an integration built from a blog post rather than from a recorded exchange. The rule worth keeping: pin the definition in tests with a real tools/list fixture, and treat the response envelope as something to pass through untouched.

The RAG architecture behind the memory and dedup tools

Tool lists are easy to read and expensive to run, and the cost is not in the tool definitions — it is in what those tools load. The embedding layer is the clearest example:

# backend/smartgate/core/resources.py — source lines 19–51 (EmbeddingPool)
class EmbeddingPool:
    """Embedding 模型池 — 懒加载 + 缓存。"""

    MODELS = {
        "fast": "minishlab/potion-base-8M",
        "balanced": "BAAI/bge-small-zh-v1.5",
        "accurate": "text-embedding-3-small",
    }

    def __init__(self):
        self._instances: Dict[str, Any] = {}

    async def get(self, name: str = "fast"):
        if name not in self._instances:
            model_name = self.MODELS.get(name, self.MODELS["fast"])
            if "minishlab" in model_name:
                from model2vec import StaticModel
                local = _baked_model_dir(model_name)
                load_id = local or model_name
                logger.info("Loading embedding model: %s (local=%s)", load_id, bool(local))
                self._instances[name] = StaticModel.from_pretrained(load_id)
            elif "text-embedding" in model_name:
                from sentence_transformers import SentenceTransformer
                logger.info(f"Loading embedding model: {model_name}")
                self._instances[name] = SentenceTransformer(model_name)
            else:
                from sentence_transformers import SentenceTransformer
                logger.info(f"Loading embedding model: {model_name}")
                self._instances[name] = SentenceTransformer(model_name)
        return self._instances[name]

    async def shutdown(self):
        self._instances.clear()

The lazy-loading shape is the message: the pool holds no model until a tool needs one, keeps the loaded instances, and clears them on shutdown. There is an explicit model tier map — a small static model for speed, a mid-size one for balance, a hosted embedding model for accuracy — selected by name, which means an operator can trade retrieval quality against latency without touching tool code. For a RAG architecture this is the layer where a tool reference stops being documentation and becomes capacity planning: dedup, memory search, and context gating all draw on these embeddings, so the tier choice decides the latency of every tool that touches text. The honest reading is that a bigger embedding model does not fix a badly chunked corpus; it makes each badly chunked call slower and more expensive.

Agent memory: the vector store a memory tool writes to

A memory tool is a promise about persistence, and the promise is only as good as the store behind it:

# backend/smartgate/core/resources.py — source lines 97–105 (VectorStoreHub)
class VectorStoreHub:
    """统一向量数据库接入 — Qdrant 默认。"""

    def __init__(self):
        self._client = None
        self._url: str = ""

    async def initialize(self, config) -> None:
        vs_cfg = config.get("vector_store", {}) if hasattr(config, 'get') else config
# backend/smartgate/core/resources.py — source lines 107–153 (VectorStoreHub)
        from qdrant_client import AsyncQdrantClient
        self._client = AsyncQdrantClient(url=self._url)

    @property
    def client(self):
        return self._client

    async def ensure_collection(self, name: str, dim: int = 768) -> None:
        from qdrant_client.models import Distance, VectorParams
        collections = await self._client.get_collections()
        existing = [c.name for c in collections.collections]
        if name not in existing:
            await self._client.create_collection(
                collection_name=name,
                vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
            )
            logger.info(f"Created Qdrant collection: {name} (dim={dim})")

    async def store_vectors(self, collection: str, vectors, payloads, ids=None) -> int:
        from qdrant_client.models import PointStruct
        import uuid
        points = []
        for i, (vec, pay) in enumerate(zip(vectors, payloads)):
            points.append(PointStruct(
                id=ids[i] if ids else str(uuid.uuid4()),
                vector=vec,
                payload=pay,
            ))
        result = await self._client.upsert(
            collection_name=collection,
            points=points,
        )
        return len(points)

    async def search_vectors(self, collection: str, query_vector, top_k: int = 10, filters=None):
        from qdrant_client.models import Filter as QFilter
        result = await self._client.search(
            collection_name=collection,
            query_vector=query_vector,
            limit=top_k,
            query_filter=filters,
        )
        return result

    async def shutdown(self):
        if self._client:
            await self._client.close()

One window is deliberately missing between those two excerpts: the line that sets the default store URL is omitted, because it is a development default rather than a deployment decision — the real address comes from configuration. What remains is the persistence contract worth documenting. Initialization is explicit, so a memory call that arrives before the store is ready fails loudly rather than silently writing nowhere. Collection creation is idempotent and dimensioned, so embeddings of the wrong width cannot be written into an existing collection by accident. Writes take ids when the caller has them and generate them otherwise, which is what makes an upsert safe to replay. And search takes a top-k and an optional filter, so tenant or user scoping belongs to the query rather than to post-processing. Every one of those is a tool-visible guarantee: smart_memory behaves the way it does because this layer behaves that way.

MCP proxy paths: where a gateway forwards

A hosted gateway rarely owns the capability a tool exposes; it forwards. That forwarding path — the MCP proxy layer — is where an otherwise-correct tool list starts to lie, so it deserves its own rules even though no single symbol pins it. Three properties have to survive the hop. Identity: the key that authorized the call must also be the tenant whose budget is charged and whose rows are searched, or a multi-tenant memory tool quietly reads another team's data. Schema fidelity: the upstream service's parameter names must not be rewritten in transit, because the schema the host validated against is the schema the upstream will reject. Failure shape: an upstream timeout has to come back as a tool error rather than an empty success, since a host that cannot tell "nothing found" from "the proxy gave up" will happily treat a failure as a fact. That is the whole case for putting the proxy behind a schema registry instead of in front of each tool ad hoc.

Host by host: Claude Desktop and the tools it shows

The host decides how much of the list a human actually sees, and the tool definition is the only input it gets:

# backend/smartgate/api/mcp.py — source lines 109–126 (smart_fetch)
@server.tool(
        name="smart_fetch",
        description=TOOL_DESCRIPTIONS["smart_fetch"],
        annotations=tool_annotations("smart_fetch"),
    )
    async def smart_fetch(
        url: str = Field(description="Full HTTP or HTTPS URL to fetch."),
        timeout: int = Field(default=30, description="HTTP timeout in seconds."),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("fetch")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "fetch",
            ctx,
            module.process(ctx, url=url, timeout=timeout),
            {"url": url},
        )

Everything a desktop host can display comes from that declaration: the name it groups by, the title it shows, the description it summarizes, the parameter schema it turns into a form, and the annotation that decides whether the call runs without a confirmation step. For Claude Desktop the practical consequence is that a vague description costs the user a confirmation dialog on every call, while a precise one with a read-only hint lets routine reads pass unnoticed. The same definition also drives the failure experience: because the tool returns a string payload, the host renders text rather than a structured object, so a tool that hides an error inside its success payload looks like a working call to the person reading the transcript.

LangChain clients, retention windows, and the audit trail

Tool calls are cheap to make and hard to reconstruct later, so the retention policy is part of the reference rather than an operator footnote:

# lib/jobs/audit-retention.ts — source lines 6–24 (runAuditRetention)
async function runAuditRetention() {
  const teams = await prisma.team.findMany({
    select: { id: true, plan: true, features: true },
  });
  let deleted = 0;
  for (const team of teams) {
    const days = planEntitlements(
      team.plan as Plan,
      team.features as Record<string, unknown> | null,
    ).activity_log_retention_days;
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    const result = await prisma.auditLog.deleteMany({
      where: { teamId: team.id, timestamp: { lt: cutoff } },
    });
    deleted += result.count;
  }
  return { teams: teams.length, deleted };
}

The job walks every team, reads the retention window that plan entitlements grant, and deletes audit rows older than the cutoff — which makes the retention window a property of the plan, visible in the same place as the rate limits, and not a global setting an operator has to remember. For a LangChain-style client the interesting part is the asymmetry: the client keeps its own conversation state, while the gateway keeps the call record, and the two have different lifetimes on purpose. A tool reference that does not state how long the call record survives is incomplete, because the first question after an incident is whether the evidence still exists. Read the window from your plan before you need it.

MCP vs A2A, and the one flag that changes the surface

MCP's tool surface is deliberately narrow, and the surfaces around it are opt-in:

# backend/smartgate/core/openapi_env.py — source lines 8–10 (openapi_enabled)
def openapi_enabled() -> bool:
    raw = os.environ.get("SMARTGATE_OPENAPI_ENABLED", "false").strip().lower()
    return raw in ("1", "true", "yes", "on")

The flag is a single environment read, and its default is off — understated, and correct. A schema endpoint is a convenience for integrators, not a second protocol, and exposing it can widen the attack surface and freeze an API shape you may still want to change. The comparison that matters here is not "which protocol wins" — MCP exposes capabilities to models and A2A delegates tasks between agents — but which surface you are documenting. A tools reference describes tools/list and tools/call; anything served over REST belongs in the API docs, and keeping the two separate is why the flag exists at all.

OpenAI-style clients and where shared state lives

Serving several host families from one deployment pushes session and cache state into a shared backend, and that backend has more modes than most deployment docs admit:

# lib/redis/config.ts — source lines 38–42 (resolveRedisMode)
function resolveRedisMode(): RedisMode {
  if (isUpstashRestConfigured()) return "upstash";
  if (isTcpRedisConfigured()) return "tcp";
  return "none";
}

The resolution order is the useful part: a REST-configured store wins, then a TCP-configured one, and otherwise there is no shared state at all — which means the deployment still runs, with per-instance memory and weaker guarantees, rather than failing closed. That is the right default for a tool gateway, because availability of the tool list should not depend on a cache being reachable. For an OpenAI-style client, which may open many short-lived connections, the practical effects are the ones to document: rate-limit counters are per key and shared, budget accounting is eventually consistent, and a restart can reset anything held in instance memory. None of that changes the tool list — but it changes what a caller may conclude from two identical calls.

How SmartGate's tool surface compares

What it exposes Schema Annotations Normalization
SmartGate (hosted) Seven smart_* tools over Streamable HTTP at one endpoint Declared once per tool in code and advertised in tools/list Derived from a read-only set, with the pipeline orchestrator annotated explicitly Body normalization in ASGI middleware, before validation (tools reference)
Single-purpose local server The tools that server implements Whatever you wrote Often omitted, so hosts assume writes and destructive changes None; the process boundary is the contract
Framework adapter (LangChain, SDK wrappers) The framework's own function registry, projected into MCP Generated from type hints where the adapter supports it Usually absent Framework-level, and different per adapter
Raw JSON-RPC shim Your own list, your own names Hand-maintained Yours to invent You own every lenient case, including the ones you have not met yet

The point of the comparison is not features. It is that three of the four options have no normalization layer, so every host-specific quirk becomes your bug, and the annotations that decide a confirmation prompt are usually missing — which is why an unannotated tool list produces the friction users blame on the model. SmartGate runs on Free for 2M tokens/month with all seven tools at 120 requests/min per key; paid plans start at $18/month, and the savings share only begins after $15 saved (pricing).

For a worked example of those tools inside a research session — search, dedup, memory, and a saved pipeline — see SmartGate MCP for Research and Decisions.

How to get started

Before you attach a server, the MCP server list works through the checks that come first: the tool surface it advertises, how its key is issued, and the readiness check to ask for.

  1. Record your own tools/list. Point a client at the hosted endpoint using the block on the Connect page, then read the response as a document: names, titles, descriptions, schemas, annotations. Seven tools come back.
  2. Call one read-only tool and one writer. Fetch a page, then run a memory write, and compare what your host asked you before each. The difference is the annotation layer working.
  3. Check the log for both calls. Every call produces an audit row; the retention window for it comes from your plan, and the logs documentation explains what is recorded.
  4. Then read the neighbours. MCP resources, prompts, and sampling covers the two lists this page deliberately leaves out, and what an MCP gateway does covers the policy layer around the tools.

Start on Free — 2M tokens/month, all seven tools, 120 MCP requests/min per key: start free, then compare limits and log retention on the pricing page.

Frequently Asked Questions

Limitations and what this does not do

  • The annotation layer is advisory. Read-only hints shape a host's confirmation policy; they are not a security boundary, and a compromised or careless server can declare them wrongly.
  • A schema is not a type system. inputSchema describes arguments in JSON Schema, which cannot express every constraint your handler enforces, so some invalid calls will still reach your code.
  • Normalization hides client bugs. Coercing an empty parameter list to an empty object keeps a call working, which also means the client's own defect stays unreported until something stricter talks to it.
  • Per-instance fallback weakens guarantees. With no shared store configured, rate and budget state is local to the instance; that is a deliberate availability trade, not an equivalent deployment.
  • Tool counts and names change. The surface is maintained in code and can move between releases, so pin a recorded tools/list response in your tests rather than trusting a copied list.
  • This is not an authorization guide. Key scoping, tenant separation, and OAuth are covered by the auth and OAuth page, not here.

Sources

Method note

The code in this article is not transcribed. Each block was cut directly out of the slice body returned by the SmartGate slice API and then re-asserted byte-for-byte as a substring of that body before publication; the first line inside every fence records the file and the exact source lines. Symbols were pinned with whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint. One section (mcp proxy) matched no unique symbol and is written from the forwarding rules with no code, as the abstain rule requires; one window inside the vector-store excerpt omits a single line that names a development-default address, which is stated in the section itself. Sections describe the gateway's tool surface only; no third-party client implementation is quoted.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 mcp resources register_mcp_tools backend/smartgate/api/mcp.py 254–299, 302–309 rule A L2 → slot-proof 9d4a1623b28c
2 mcp prompts tool_annotations backend/smartgate/api/mcp_tool_docs.py 63–67 rule A L2 → slot-proof a822944005fe
3 mcp tool call mcp_tool_call backend/tools/cursor_mcp_deep_test.py 57–73 rule A L2 → slot-proof 4a485dbffc6f
4 mcp tool schema parseJsonBody lib/api/parse-json-body.ts 3–15 rule A L2 → slot-proof 0c37ab71f2cd
5 mcp tool definition parseJsonResponse lib/api/parse-json-response.ts 3–5 rule A L2 → slot-proof ce9219102cfa
6 rag architecture EmbeddingPool backend/smartgate/core/resources.py 19–51 rule A L2 → slot-proof e3c5e219a719
7 agent memory VectorStoreHub backend/smartgate/core/resources.py 97–105, 107–153 rule A L2 → slot-proof 33c8bca3c4d4
8 claude desktop mcp smart_fetch backend/smartgate/api/mcp.py 109–126 rule A L2 → slot-proof fdc9a4259783
9 langchain mcp runAuditRetention lib/jobs/audit-retention.ts 6–24 rule A L2 → slot-proof 9e549a91c008
10 mcp vs a2a openapi_enabled backend/smartgate/core/openapi_env.py 8–10 rule A L2 → slot-proof 7683748ab469
11 openai mcp resolveRedisMode lib/redis/config.ts 38–42 rule A L2 → slot-proof 3a9dd8e9daf4

Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 11 of 12 sections pinned, 0 abstentions, 1 misses.