Syllabus Lesson 136 of 239 · Structured Outputs & Function/Tool Calling
Structured Outputs & Function/Tool Calling

Tools over MCP (Model Context Protocol)

Once your app has more than one tool, every model vendor invents its own JSON shape for calling them, and you end up writing an adapter per provider. The Model Context Protocol (MCP) is the open standard that fixes this. It is a JSON-RPC 2.0 wire protocol for exposing tools (functions the model can call), resources (data it can read), and prompts to any model client. Write an MCP server once and any MCP-aware client (an IDE assistant, a desktop app, an agent) can use it.

Every message is JSON-RPC 2.0, which is a tiny, fixed envelope. A request always carries four keys:

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {"name": "add", "arguments": {"a": 2, "b": 3}}}

The id ties a reply back to its request. The server answers with a response that echoes the same id and carries either a result or an error object, never both:

{"jsonrpc": "2.0", "id": 1, "result": {"sum": 5}}
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}

The negative codes are standard JSON-RPC: -32601 is "method not found", -32602 is "invalid params", and so on. The first thing a client does after connecting is the handshake: it asks the server what it can do by calling the tools/list method (with empty params), and the server returns the list of tool descriptors.

A real MCP server runs over stdio or HTTP and wires this into the SDK; here we build and parse the messages with the standard-library json module so the wire format is concrete. You are writing three functions.

  • build_request(id, method, params) -> a JSON string for the envelope {"jsonrpc": "2.0", "id": id, "method": method, "params": params}. Use json.dumps.
  • parse_response(text) -> json.loads the text, require jsonrpc == "2.0" (raise ValueError otherwise). If it has a result, return {"id": ..., "ok": True, "result": ...}. If it has an error, return {"id": ..., "ok": False, "error": {"code": ..., "message": ...}}. If it has neither, raise ValueError.
  • list_tools_request(id) -> the handshake request: a build_request with method "tools/list" and empty params {}.

Because build_request returns a string, the tests parse it back with json.loads and compare the dict, so key order does not matter. Make sure a result and an error both round-trip, the handshake has the right method, and a message that is missing jsonrpc or has neither result nor error raises.

Your turn

Build and parse MCP-style JSON-RPC 2.0 messages with the json module. build_request(id, method, params) returns the JSON string for {"jsonrpc": "2.0", "id", "method", "params"}. parse_response(text) loads the text, requires jsonrpc == "2.0" (else raise ValueError), and returns {"id", "ok": True, "result"} for a result or {"id", "ok": False, "error": {"code", "message"}} for an error, raising if it has neither. list_tools_request(id) returns the tools/list handshake request with empty params.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output