Model Context Protocol (MCP) Message Format Explained
Short answer: The Model Context Protocol message format is JSON-RPC 2.0 — one request object jsonrpc, method, params, and an id, one result or error object back, and notifications id at all. Over HTTP the whole conversation is POSTed to a single endpoint: initialize, then tools/list, then tools/call

Short answer: The Model Context Protocol message format is JSON-RPC 2.0 — one request object jsonrpc, method, params, and an id, one result or error object back, and notifications 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 params array, an arguments field typed as a list, and a 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. "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 holds up in production — which SmartGate is an MCP-native algorithm gateway for token control, traffic shaping, and agent audit. It MCP is JSON-RPC 2.0 over a transport. The specification covers two transports, stdio and Streamable MCP transports). The tools/list to advertise tools and tools/call to invoke one MCP tools). A worked MCP JSON-RPC round trip is easier to The gap between the specification and a working integration is visible in the wild: there is a message format is and how it differs from the Stack Exchange). Which protocol versions a server will answer for, and which transport carries the messages, is the MCP Protocol Versions and Transports 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") The docstring is the specification in one line: POST /mcp, Streamable HTTP, stateless. Session before mounting, because the patches have to be in place when the first Before any parsing happens, the body is normalized. This is the function that turns a # 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") 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 — {}, with the comment naming the client. A message whose params is an object is params type, which is the first thing worth grepping when a Normalization is applied as ASGI middleware, which is the only place in a Python server where you can # 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) The guards matter as much as the fix: only POST, and only paths containing messages. Everything more_body is false, normalizes the joined bytes, then hands the receive callable that replays the rewritten body — the standard ASGI idiom for "the The object-level fix is deliberately tiny, because it fixes one observed shape rather than validating # 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 MCP tool arguments belong in params.arguments as an object. Some hosts emit a list when the [] to {} keeps the call bool return value is what The most pragmatic concession in the whole file is the direct-method rewrite, and it exists because # 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 A standard call is {method: "tools/call", params: {name: "smart_fetch", arguments: {...}}}. But a {method: "smart_fetch", params: {url: "…"}} — the tool name params. Rather than rejecting that with a tools/call, extracting inline keys as 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 Replaying the consumed body is small enough to hide, and wrong implementations fail in ways that look # 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} The closure returns the rewritten body on the first call and http.disconnect on every call after Session-state handling is where stateless servers diverge from the textbook flow, and the gateway # 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, ) 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 The lifecycle a fully negotiating client walks through instead, with the actors named, is the Model Context Protocol Explained. 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)") Two things are being replaced: the session class's request and notification handlers (both point at run method — wrapped, with the original _orig so the wrapper can delegate instead of reimplementing. The _PATCHED guard is what mount_mcp_routes calls it unconditionally, and a second call 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") Three behaviours are worth reading closely. initialize records the requested protocol version, Initializing, and answers with the requested version when it is supported, — the standard MCP negotiation, and the reason a client speaking a newer ping is accepted silently. Any other request arriving tools/* during the handshake race, not to remove initialization as a concept. Tool advertisement is a plain function call per tool, which is what keeps tools/list and the # 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}, ) Each tool is declared once, with its description read from a shared table and its annotations smart_fetch takes a URL and a smart_search takes a query and a result cap, and both end in the same audited call path. tools/call predictable regardless of which of the seven tools is 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) It re-binds the audit context for in-stream calls, awaits the tool's coroutine, writes an audit row ToolError carrying the module's own message, which reaches the client as a JSON-RPC error instead tools/list says about risk The last piece of the message format that clients actually consume is the annotation block, and it is # 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, ) title gives the tool a human-readable name in the host's UI, and readOnlyHint tells the client READ_ONLY_TOOLS set keeps it consistent with what the 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 many hosts you do not control, and because "accept the message, 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. 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. 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. 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, pricing page. Is the MCP message format just JSON-RPC? initialize negotiation, capabilities, tool annotations — but the wire format is JSON-RPC. Which methods does a tool-using client actually need? Why would a server accept a tool name as the method? {method: "smart_fetch", params: {url: …}} into a conforming tools/call and logs both names. Is "params": [] valid MCP? {}. Do I need a session id? What happens if a call arrives before initialization completes? Where do tool results come back? 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. Model Context Protocol — specification (2026-07-28): https://modelcontextprotocol.io/specification/2026-07-28 Model Context Protocol — transports, including stateless Streamable HTTP: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports Model Context Protocol — server tools (tools/list, tools/call): https://modelcontextprotocol.io/specification/2025-06-18/server/tools Anthropic — introducing the Model Context Protocol: https://www.anthropic.com/news/model-context-protocol Stack Exchange — clarification on the MCP message format vs. communication architecture: https://softwareengineering.stackexchange.com/questions/458556/clarification-on-model-context-protocol-message-format-vs-communication-archit SmartGate — product site and pricing: https://smartgate.network · https://smartgate.network/pricing Method note The code in this article is not transcribed. Each block was cut directly out of the slice body # 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 This guide is republished from smartgate.network; it was drafted with AI assistance and reviewed by our team.
Key Takeaways
- •Short answer: The Model Context Protocol message format is JSON-RPC 2.0 — one request object jsonrpc, method, params, and an id, one result or error object back, and notifications id at all
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


