Model Context Protocol (MCP) Message Format Explained The Model Context Protocol (MCP) message format is JSON-RPC 2.0, using request objects with jsonrpc, method, params and id fields, result or error responses, and notifications without an id, all POSTed over HTTP to a single endpoint in the sequence initialize, tools/list, tools/call. The article notes that Cursor sends params: [] for tools/list and notifications/initialized, which is legal JSON but invalid for most strict parsers, so normalization sits in front of the parser rather than inside each tool. It also states that "model context protocol" carries roughly 12,100 monthly US searches and that the first page for the query is an AI Overview built from the specification itself. 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