DEV Community

lizer yang for SmartGate

Posted on Originally published at smartgate.network

Model Context Protocol (MCP) Message Format Explained

Short answer: The Model Context Protocol message format is JSON-RPC 2.0 — one request object
with jsonrpc, method, params, and an id, one result or error object back, and notifications
that carry no id at all. Over HTTP the whole conversation is POSTed to a single endpoint:
initialize, then tools/list, then tools/call, with the session optional. The parts that break
integrations are the small ones: an empty params array, an arguments field typed as a list, and a
client that calls the tool name as the method.

Key takeaways

  • Three shapes, one envelope. Requests (id + method + params), notifications (no id), and results (id + result or error) — every MCP message is one of them.
  • initialize is a negotiation, not a formality. The client proposes a protocol version, the server answers with the version it will speak plus its capabilities and instructions.
  • tools/list and tools/call are the two messages that matter for tool use. One advertises what exists (with annotations), the other names a tool and passes arguments.
  • params: [] is legal JSON and invalid for most strict parsers — Cursor sends exactly that for tools/list and notifications/initialized, which is why normalization sits in front of the parser rather than inside each tool.
  • A stateless HTTP server should not gate tools/list behind a completed handshake, because clients connect, list, and disconnect in whatever order their transport allows.
  • Read tools/list from a live server before writing a client. Annotations in that response — title and read-only hint — decide what your host will run without asking the user, so they are the contract worth testing against first.

The short version for whoever signs the invoice

"Model context protocol" carries roughly 12,100 monthly US searches, and the first page for
model context protocol message format is an AI Overview built from the specification itself
(MCP spec). That tells you two things
about this topic: demand is real, and the audience is engineers who are comparing implementations,
not shoppers. What they cannot get from the specification is what holds up in production — which
message shapes real hosts send, and where a gateway has to be lenient.

SmartGate is an MCP-native algorithm gateway for token control, traffic shaping, and agent audit. It
sits between a host (Cursor, Claude Desktop, Windsurf, OpenClaw, or your own client) and the open
web, exposing seven tools: smart_fetch, smart_search, smart_context_gate, smart_dedup,
smart_budget_guard, smart_memory, and smart_pipe. The message path described below is the layer that
makes those seven tools work across hosts that disagree on details — and the same layer is where
budget checks, rate limits, and audit rows are attached.

What the protocol actually specifies

MCP is JSON-RPC 2.0 over a transport. The specification covers two transports, stdio and Streamable
HTTP, and explicitly allows a server to be stateless — session management is optional, and a server
may answer each request independently
(MCP transports). The
server-side surface for tools is defined as two messages: tools/list to advertise tools and
tools/call to invoke one
(MCP tools).

A worked MCP JSON-RPC round trip is easier to
follow than the schema alone, because it shows the four fields and the two responses in
the order a client actually sends them.

The gap between the specification and a working integration is visible in the wild: there is a
Stack Exchange question asking precisely what the MCP message format is and how it differs from the
communication architecture, with answers pointing at the same conclusion — the format is JSON-RPC,
the architecture is transport plus session
(Stack Exchange).
Everything below is that distinction, from the gateway's side of the wire.

Which protocol versions a server will answer for, and which transport carries the messages, is the
other half of that picture — MCP Protocol Versions and Transports
covers both.

mount_mcp_routes: one endpoint, no session state

The whole HTTP surface is one mounted application on one path:

# backend/smartgate/api/mcp.py — source lines 399–406 (mount_mcp_routes)
def mount_mcp_routes(app: FastAPI) -> None:
    """Expose POST /mcp (Streamable HTTP, stateless)."""
    apply_mcp_session_compat()

    streamable_app = mcp.streamable_http_app()
    streamable_app.router.lifespan_context = _noop_starlette_lifespan(streamable_app)
    app.mount("/mcp", streamable_app)
    logger.info("MCP Streamable HTTP at POST /mcp")
Enter fullscreen mode Exit fullscreen mode

The docstring is the specification in one line: POST /mcp, Streamable HTTP, stateless. Session
compatibility is applied before mounting, because the patches have to be in place when the first
request arrives rather than when the first session is created. The lifespan context is then replaced
with a no-op so that mounting the MCP app inside a larger FastAPI application does not run a second
startup lifecycle — a failure mode that looks like "the tools work locally but not in production".

normalize_jsonrpc_body: the leniency a real client needs

Before any parsing happens, the body is normalized. This is the function that turns a
non-conforming-but-legal message into something pydantic will accept:

# backend/smartgate/api/mcp_sse_compat.py — source lines 67–99 (normalize_jsonrpc_body)
def normalize_jsonrpc_body(body: bytes) -> bytes:
    """Coerce non-object JSON-RPC params (e.g. []) to {} for pydantic validation."""
    if not body:
        return body
    try:
        data: Any = json.loads(body)
    except (json.JSONDecodeError, UnicodeDecodeError):
        return body
    if not isinstance(data, dict):
        return body

    changed = _rewrite_direct_tool_method(data)

    params = data.get("params")
    if params is None:
        data["params"] = {}
        changed = True
    elif isinstance(params, list):
        # Cursor: tools/list, notifications/initialized with "params": []
        data["params"] = {}
        changed = True
    elif isinstance(params, dict):
        if _normalize_params_object(params):
            changed = True

    if not changed:
        return body
    logger.info(
        "Normalized JSON-RPC body: method=%s params_type=%s",
        data.get("method"),
        type(data.get("params")).__name__,
    )
    return json.dumps(data, separators=(",", ":")).encode("utf-8")
Enter fullscreen mode Exit fullscreen mode

Three cases are handled. A message with no params gets {}. A message whose params is a
list — the "params": [] that Cursor sends for tools/list and notifications/initialized
also gets {}, with the comment naming the client. A message whose params is an object is
passed to the nested-field fixer below. The rewrite is a no-op when nothing changed, and the log line
records the method and the resulting params type, which is the first thing worth grepping when a
client's handshake fails.

NormalizeJsonRpcMiddleware: where in the stack it happens

Normalization is applied as ASGI middleware, which is the only place in a Python server where you can
rewrite a request body before the framework's own parser sees it:

# backend/smartgate/api/mcp_sse_compat.py — source lines 102–138 (NormalizeJsonRpcMiddleware)
class NormalizeJsonRpcMiddleware:
    """ASGI middleware: fix params: [] before MCP sse.handle_post_message parses body."""

    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http" or scope.get("method") != "POST":
            await self.app(scope, receive, send)
            return

        path = scope.get("path", "")
        if "messages" not in path:
            await self.app(scope, receive, send)
            return

        chunks: list[bytes] = []
        while True:
            message = await receive()
            if message["type"] != "http.request":
                await self.app(scope, receive, send)
                return
            chunks.append(message.get("body", b""))
            if not message.get("more_body", False):
                break

        body = normalize_jsonrpc_body(b"".join(chunks))
        sent = False

        async def replay_receive() -> dict[str, Any]:
            nonlocal sent
            if sent:
                return {"type": "http.disconnect"}
            sent = True
            return {"type": "http.request", "body": body, "more_body": False}

        await self.app(scope, replay_receive, send)
Enter fullscreen mode Exit fullscreen mode

The guards matter as much as the fix: only POST, and only paths containing messages. Everything
else falls through untouched, so the gateway pays no cost on tool responses or on other routes. The
middleware buffers the body until more_body is false, normalizes the joined bytes, then hands the
application a receive callable that replays the rewritten body — the standard ASGI idiom for "the
body is consumed, here is what the app should see instead".

_normalize_params_object: the nested quirk

The object-level fix is deliberately tiny, because it fixes one observed shape rather than validating
arbitrary input:

# backend/smartgate/api/mcp_sse_compat.py — source lines 57–64 (_normalize_params_object)
def _normalize_params_object(params: dict[str, Any]) -> bool:
    """Fix nested params quirks from MCP hosts. Returns True if mutated."""
    changed = False
    arguments = params.get("arguments")
    if isinstance(arguments, list):
        params["arguments"] = {}
        changed = True
    return changed
Enter fullscreen mode Exit fullscreen mode

MCP tool arguments belong in params.arguments as an object. Some hosts emit a list when the
tool takes no parameters, which strict validation rejects. Coercing [] to {} keeps the call
semantically identical — no arguments — while letting the same request pass validation on a server
that would otherwise return an error the client cannot interpret. The bool return value is what
makes the normalizer idempotent: it only re-serializes the body when something actually changed.

_rewrite_direct_tool_method: when the method is the tool name

The most pragmatic concession in the whole file is the direct-method rewrite, and it exists because
users write what feels obvious:

# backend/smartgate/api/mcp_sse_compat.py — source lines 27–54 (_rewrite_direct_tool_method)
def _rewrite_direct_tool_method(data: dict[str, Any]) -> bool:
    """Rewrite {method: smart_fetch, params: {url: ...}} → standard tools/call."""
    method = data.get("method")
    if not isinstance(method, str) or method not in _SMART_TOOL_METHODS:
        return False

    original = method
    params = data.get("params")
    if isinstance(params, list):
        params = {}
    elif not isinstance(params, dict):
        params = {}

    name = method
    if isinstance(params.get("name"), str):
        name = params["name"]

    if isinstance(params.get("arguments"), dict):
        arguments = params["arguments"]
    else:
        arguments = {
            k: v for k, v in params.items() if k not in ("name", "arguments", "_meta")
        }

    data["method"] = "tools/call"
    data["params"] = {"name": name, "arguments": arguments}
    logger.info("Rewrote legacy MCP tool method %s → tools/call (name=%s)", original, name)
    return True
Enter fullscreen mode Exit fullscreen mode

A standard call is {method: "tools/call", params: {name: "smart_fetch", arguments: {...}}}. But a
hand-written request often looks like {method: "smart_fetch", params: {url: "…"}} — the tool name
promoted to the method, the arguments flattened into params. Rather than rejecting that with a
protocol error, the gateway rewrites it into a conforming tools/call, extracting inline keys as
arguments and stripping name, arguments, and _meta so nothing is passed through twice. The
logger.info line records both the original method and the resolved tool name, which turns "my
client says unknown method" into a one-line log search.

replay_receive: one body, exactly once

Replaying the consumed body is small enough to hide, and wrong implementations fail in ways that look
like networking problems:

# backend/smartgate/api/mcp_sse_compat.py — source lines 131–136 (replay_receive)
async def replay_receive() -> dict[str, Any]:
            nonlocal sent
            if sent:
                return {"type": "http.disconnect"}
            sent = True
            return {"type": "http.request", "body": body, "more_body": False}
Enter fullscreen mode Exit fullscreen mode

The closure returns the rewritten body on the first call and http.disconnect on every call after
that. That is the ASGI contract for a request whose bytes have already been read: the application may
ask again, but there is no more data, so it must be told the client went away rather than being
handed an empty body. Getting this wrong produces a server that appears to hang on the second read —
and a client-side timeout that points at the wrong layer.

_stateless_server_run: why tools/list must not wait for init

Session-state handling is where stateless servers diverge from the textbook flow, and the gateway
patches the run loop rather than the tools:

# backend/smartgate/api/mcp_session_compat.py — source lines 70–86 (_stateless_server_run)
async def _stateless_server_run(
    self: lowlevel_server.Server,
    read_stream,
    write_stream,
    initialization_options,
    raise_exceptions: bool = False,
    stateless: bool = True,
):
    """SSE sessions start Initialized so tools/list is not rejected during init races."""
    return await _stateless_server_run._orig(  # type: ignore[attr-defined]
        self,
        read_stream,
        write_stream,
        initialization_options,
        raise_exceptions=raise_exceptions,
        stateless=stateless,
    )
Enter fullscreen mode Exit fullscreen mode

The docstring states the production reality: SSE sessions start already Initialized, so a
tools/list that arrives during an initialization race is answered instead of rejected. This is a
deliberate trade — a stricter server would require the handshake to complete first and would be
right by the book, while shedding exactly the clients that connect, list, and disconnect quickly.

The lifecycle a fully negotiating client walks through instead, with the actors named, is the
subject of Model Context Protocol Explained.

apply_mcp_session_compat: idempotent monkey-patching

The patches are applied in one guarded function, because applying them twice breaks the server:

# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat)
def apply_mcp_session_compat() -> None:
    """Idempotent patches applied before mounting MCP SSE."""
    global _PATCHED
    if _PATCHED:
        return

    ServerSession._received_request = _compat_received_request  # type: ignore[method-assign]
    ServerSession._received_notification = _compat_received_notification  # type: ignore[method-assign]

    if not hasattr(_stateless_server_run, "_orig"):
        _stateless_server_run._orig = lowlevel_server.Server.run  # type: ignore[attr-defined]
        lowlevel_server.Server.run = _stateless_server_run  # type: ignore[method-assign]

    _PATCHED = True
    logger.info("MCP session compat enabled (stateless SSE + relaxed init gate)")
Enter fullscreen mode Exit fullscreen mode

Two things are being replaced: the session class's request and notification handlers (both point at
the compatibility versions), and the low-level server's run method — wrapped, with the original
kept on _orig so the wrapper can delegate instead of reimplementing. The _PATCHED guard is what
makes this safe to call from anywhere: mount_mcp_routes calls it unconditionally, and a second call
is a no-op rather than a double patch. Note the honest naming — this is a compatibility layer for
real clients, not a claim that the specification is wrong.

_compat_received_request: the handshake answer

The relaxed handler is where initialize is answered, and where the version negotiation is visible:

# backend/smartgate/api/mcp_session_compat.py — source lines 24–57 (_compat_received_request)
async def _compat_received_request(
    self: ServerSession,
    responder: RequestResponder[types.ClientRequest, types.ServerResult],
) -> None:
    """Allow tools/* during Initializing; only block when session never started init."""
    match responder.request.root:
        case types.InitializeRequest(params=params):
            requested_version = params.protocolVersion
            self._initialization_state = InitializationState.Initializing
            self._client_params = params
            with responder:
                await responder.respond(
                    types.ServerResult(
                        types.InitializeResult(
                            protocolVersion=requested_version
                            if requested_version in SUPPORTED_PROTOCOL_VERSIONS
                            else types.LATEST_PROTOCOL_VERSION,
                            capabilities=self._init_options.capabilities,
                            serverInfo=types.Implementation(
                                name=self._init_options.server_name,
                                version=self._init_options.server_version,
                                websiteUrl=self._init_options.website_url,
                                icons=self._init_options.icons,
                            ),
                            instructions=self._init_options.instructions,
                        )
                    )
                )
            self._initialization_state = InitializationState.Initialized
        case types.PingRequest():
            pass
        case _:
            if self._initialization_state == InitializationState.NotInitialized:
                raise RuntimeError("Received request before initialization was complete")
Enter fullscreen mode Exit fullscreen mode

Three behaviours are worth reading closely. initialize records the requested protocol version,
sets the session to Initializing, and answers with the requested version when it is supported,
otherwise the latest
— the standard MCP negotiation, and the reason a client speaking a newer
minor version still gets a usable session. ping is accepted silently. Any other request arriving
before initialization raises, which is the one gate the compatibility layer deliberately keeps: the
point is to allow tools/* during the handshake race, not to remove initialization as a concept.

register_mcp_tools: seven tools, one registration path

Tool advertisement is a plain function call per tool, which is what keeps tools/list and the
documentation from drifting apart:

# backend/smartgate/api/mcp.py — source lines 106–126 (register_mcp_tools)
def register_mcp_tools(server: FastMCP) -> None:
    """Register all 7 smart_* tools on a FastMCP instance."""

    @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},
        )
Enter fullscreen mode Exit fullscreen mode

Each tool is declared once, with its description read from a shared table and its annotations
derived from the tool name — so the model-facing metadata and the human-facing docs have a single
source. The two tools in this window show the pattern end to end: smart_fetch takes a URL and a
timeout, smart_search takes a query and a result cap, and both end in the same audited call path.
That shared ending is what makes tools/call predictable regardless of which of the seven tools is
invoked.

_run_with_audit: one exit path for every call

Every tool call — seven tools, any arguments — returns through the same function:

# backend/smartgate/api/mcp.py — source lines 90–103 (_run_with_audit)
async def _run_with_audit(
    tool: str,
    ctx: ToolContext,
    process_coro,
    params: Optional[Dict[str, Any]] = None,
) -> str:
    _ensure_mcp_audit_context()
    app, _registry = _app_state()
    result = await process_coro
    await app.state.audit_hook(ctx, result, tool, params or {})
    if not result.success:
        msg = result.error or f"{tool} failed"
        raise ToolError(msg)
    return json.dumps(result.data, ensure_ascii=False)
Enter fullscreen mode Exit fullscreen mode

It re-binds the audit context for in-stream calls, awaits the tool's coroutine, writes an audit row
through the application's audit hook, and only then decides how to answer. A failed tool raises a
ToolError carrying the module's own message, which reaches the client as a JSON-RPC error instead
of a success payload with an error field inside it; a successful call returns the data as compact
JSON. Because every tool goes through this path, an audit log that is missing a call means the call
never reached the gateway, not that a tool forgot to log.

tool_annotations: what tools/list says about risk

The last piece of the message format that clients actually consume is the annotation block, and it is
derived, not hand-written:

# 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,
    )
Enter fullscreen mode Exit fullscreen mode

title gives the tool a human-readable name in the host's UI, and readOnlyHint tells the client
whether invoking it can change state. Read-only hints matter more than they look: hosts use them to
decide what may run without confirmation, so a wrong hint changes the approval experience for every
user of that client. Deriving the hint from a READ_ONLY_TOOLS set keeps it consistent with what the
tool actually does.

How this differs from a local MCP server

Message path Session model What you get beyond the tools
SmartGate (hosted, stateless) JSON-RPC over Streamable HTTP at one POST endpoint (/api/mcp), normalized for host quirks Stateless; sessions optional, no session id required Free: 2M tokens/mo, all 7 tools, 120 req/min/key. Pro from $18/mo, share only after $15 saved (pricing)
Local stdio MCP server JSON-RPC over stdin/stdout, no HTTP layer Process lifetime is the session Whatever the server implements; nothing at the gateway layer
Hand-rolled JSON-RPC shim Your own parsing of the same envelopes Yours to invent Bugs proportional to how much of the protocol you re-implement
LLM proxy/router Different protocol entirely (model calls) Provider sessions Model routing, not tool governance

The honest reading: if you are writing a client or a single-purpose server, the specification is
enough — JSON-RPC is small, and the two tool messages are small. The compatibility functions above
exist because you are talking to many hosts you do not control, and because "accept the message,
then record it" is a gateway's job rather than a library's.

How to get started

  1. Look at the messages you are already sending. Point a client at https://smartgate.network/api/mcp (POST) with Authorization: Bearer <key>; the Connect page generates the exact block for Cursor, Claude Desktop, Windsurf, OpenClaw, or a generic client.
  2. Call tools/list once and read the annotations. Seven tools appear, each with a title and a read-only hint — that response is the contract your client can rely on.
  3. Then call tools/call on smart_fetch with a URL, and check that the JSON result comes back as text content rather than an error object.
  4. Watch the audit row appear in Activity Logs; if it is missing, the message never reached the gateway.

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

FAQ

Is the MCP message format just JSON-RPC?
Effectively yes: JSON-RPC 2.0 envelopes carrying MCP-defined methods. The protocol adds semantics on
top — initialize negotiation, capabilities, tool annotations — but the wire format is JSON-RPC.

Which methods does a tool-using client actually need?
Four in the common path: initialize, notifications/initialized, tools/list, and tools/call. Everything
else (resources, prompts, sampling) is optional surface.

Why would a server accept a tool name as the method?
Because hand-written clients do it, and a rewrite is friendlier than an error. The gateway converts
{method: "smart_fetch", params: {url: …}} into a conforming tools/call and logs both names.

Is "params": [] valid MCP?
It is valid JSON-RPC (params may be a structured value), and at least one widely used client sends it
for parameterless methods. Strict servers reject it; a normalizing middleware coerces it to {}.

Do I need a session id?
Not for a stateless server. Sessions are optional in the Streamable HTTP transport, and the gateway
runs without them, which is also why a config change does not require reconnecting.

What happens if a call arrives before initialization completes?
Read-only tool traffic is allowed during the handshake race, and anything else raises — a deliberate
middle ground between the strict specification and clients that connect, list, and disconnect.

Where do tool results come back?
As JSON-RPC results whose content is text: tool output is serialized to compact JSON, and a failed
tool becomes a JSON-RPC error rather than a success object with an error field.

Limitations and what this does not do

  • The compatibility layer is a compatibility layer. It exists because hosts disagree; a client that sends malformed JSON still fails, and normalization does not repair an unparseable body.
  • Statelessness costs per-connection state. A server that accepts tools/list during initialization gives up the guarantee that a session was properly established first; that is the trade, not an oversight.
  • Annotations are hints, not enforcement. readOnlyHint influences client UI; the gateway's actual controls are the rate limit, the budget cap, and the key's scope.
  • Tool-count and protocol-version details move. The messages here are stable, but supported versions and tool metadata are maintained in code and can change between releases.

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. Sections describe the gateway's message path only; no third-party client
implementation is quoted.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 mount_mcp_routes how the MCP Streamable HTTP POST route is mounted mount_mcp_routes backend/smartgate/api/mcp.py 399–406 rule A L2 → slot-proof e66f6b69a172
2 normalize_jsonrpc_body normalize the JSON-RPC body of an MCP message normalize_jsonrpc_body backend/smartgate/api/mcp_sse_compat.py 67–99 rule A L2 → slot-proof d8359617452f
3 NormalizeJsonRpcMiddleware ASGI middleware for MCP JSON-RPC parsing NormalizeJsonRpcMiddleware backend/smartgate/api/mcp_sse_compat.py 102–138 rule A L2 → slot-proof 472493b3162e
4 _normalize_params_object nested params object quirk in MCP tool calls _normalize_params_object backend/smartgate/api/mcp_sse_compat.py 57–64 rule A L2 → slot-proof 444ddb5db5f2
5 _rewrite_direct_tool_method rewrite direct tool method MCP messages _rewrite_direct_tool_method backend/smartgate/api/mcp_sse_compat.py 27–54 rule A L2 → slot-proof 2deb57048f8c
6 replay_receive replay the SSE receive stream for MCP messages replay_receive backend/smartgate/api/mcp_sse_compat.py 131–136 rule A L2 → slot-proof 74963ba50239
7 _stateless_server_run stateless MCP session run without session state _stateless_server_run backend/smartgate/api/mcp_session_compat.py 70–86 rule A L2 → slot-proof 22066742f8ed
8 apply_mcp_session_compat MCP session compatibility patch apply_mcp_session_compat backend/smartgate/api/mcp_session_compat.py 89–103 rule A L2 → slot-proof 2aec583c6238
9 _compat_received_request accept tools list during MCP initialize _compat_received_request backend/smartgate/api/mcp_session_compat.py 24–57 rule A L2 → slot-proof cc03b6abdf85
10 register_mcp_tools register all seven smart tools on the MCP server register_mcp_tools backend/smartgate/api/mcp.py 106–126 rule A L2 → slot-proof 9d4a1623b28c
11 _run_with_audit run each MCP tool call with audit context _run_with_audit backend/smartgate/api/mcp.py 90–103 rule A L2 → slot-proof 454c08008ffa
12 tool_annotations MCP tool annotations in the tools list response tool_annotations backend/smartgate/api/mcp_tool_docs.py 63–67 rule A L2 → slot-proof a822944005fe

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

This guide is republished from smartgate.network; it was drafted with AI assistance and reviewed by our team.

Top comments (0)