Handle MCP Tool Requests with Schema Validation
Handle MCP Tool Requests with Schema Validation
The platform team at a fictional freight-booking company is building ci-insights, a small server that lets engineers' coding assistants read CI results through the Model Context Protocol (MCP). Each assistant launches the server as a local subprocess with a read-only CI token, and the server exposes two read-only tools. It implements only the 2026-07-28 revision of the MCP specification.
That revision is stateless. There is no initialize handshake: every request carries its protocol version and the client's capabilities in params._meta, and a server must not rely on earlier requests. So the whole request handler can be a pure function of one message and the server's configuration.
Implement handleMcpRequest(message, server). message is one already-parsed JSON value of any type: an object, array, string, number, boolean, or null. Return one JSON-RPC response object, or null when nothing may be sent. The function never throws, and it never changes message or server.
MCP reports failures in two channels, and putting each failure in the right one is the point of this exercise:
- A JSON-RPC error,
{ jsonrpc: "2.0", id, error: { code, message } }, is a protocol failure: the server could not route the request to a declared tool. - A tool result with
isError: truecomes from a declared tool. Its text reaches the model, which can read it and act on it, for example by fixing an argument and calling again.
The server
server holds the configuration and the data the tools read. Tests also pass variants (other cache values, other supported versions, the tools in another order, a schema without additionalProperties), so read every value from server.
| Field | Fixture value | Meaning |
|---|---|---|
supportedVersions | ["2026-07-28"] | The protocol versions the server accepts |
cacheTtlMs | 300000 | Sent as ttlMs: how long a client may treat a listing as fresh (5 minutes) |
cacheScope | "public" | Sent as cacheScope: "public" lets shared caches reuse a listing for any caller, "private" only within one authorization context |
tools | below | The tool definitions |
builds | below | Build records { pipeline, build, status, minutes } |
flakyTests | below | Flaky test records { pipeline, test, failures }, most failures first |
The two tool definitions, in declared order:
[
{
"name": "get_build_status",
"description": "Get the result and duration of one CI build. Read-only.",
"inputSchema": {
"type": "object",
"properties": {
"pipeline": { "type": "string", "enum": ["web", "api", "mobile"] },
"build": { "type": "integer", "minimum": 1 }
},
"required": ["pipeline", "build"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": true, "openWorldHint": false }
},
{
"name": "list_flaky_tests",
"description": "List the flakiest tests of a CI pipeline, most failures first. Read-only.",
"inputSchema": {
"type": "object",
"properties": {
"pipeline": { "type": "string", "enum": ["web", "api", "mobile"] },
"limit": { "type": "integer", "minimum": 1, "maximum": 20, "default": 3 }
},
"required": ["pipeline"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": true, "openWorldHint": false }
}
]builds, in stored order:
| pipeline | build | status | minutes |
|---|---|---|---|
| api | 412 | failed | 14 |
| api | 413 | passed | 11 |
| web | 87 | passed | 6 |
| mobile | 230 | failed | 22 |
flakyTests, in stored order:
| pipeline | test | failures |
|---|---|---|
| api | checkout-timeout | 7 |
| web | login-redirect | 5 |
| api | refund-webhook-retry | 4 |
| api | rate-card-cache | 2 |
| api | invoice-pdf-render | 2 |
Checks, in order
Apply the checks in order. The first one that fails decides the response.
| Step | Check | Response when it fails |
|---|---|---|
| 1 | message is an object (not null, not an array), jsonrpc is exactly the string "2.0", and method is a non-empty string | Error -32600 Invalid Request, with the message's id when message is an object whose id is valid (see step 3), otherwise with id null |
| 2 | message has an id member | It is a notification: return null |
| 3 | id is valid: a string, or an integer | Error -32600 Invalid Request with id null |
| 4 | params is an object whose _meta is an object holding a string io.modelcontextprotocol/protocolVersion and an object io.modelcontextprotocol/clientCapabilities | Error -32602 Missing required _meta fields |
| 5 | The protocol version is in server.supportedVersions | Error -32022 Unsupported protocol version with data: { supported, requested }: a copy of supportedVersions and the version received |
| 6 | method is server/discover, tools/list, or tools/call | Error -32601 Method not found. server/discover and tools/list return their results here |
| 7 | For tools/call: params.name is a string, and params.arguments is an object whenever the member is present | Error -32602 Invalid params |
| 8 | A tool with exactly that name is declared in server.tools | Error -32602 Unknown tool: <name> |
| 9 | The arguments ({} when the member is absent) pass the tool's inputSchema | Tool result with isError: true and the message of the first failure |
| 10 | The tool runs | A business failure is a tool result with isError: true; a success is a tool result with isError: false |
Every error response is { jsonrpc: "2.0", id, error: { code, message } }, and only -32022 adds data. Every other response is { jsonrpc: "2.0", id, result }. The messages are exact, and none ends with a period.
Argument validation
inputSchema is JSON Schema 2020-12, but this server reads only a subset of it: additionalProperties: false, required, and, for each property, type ("string" or "integer"), enum, minimum, and maximum. Other keywords, such as default, are annotations and change nothing. Drive the checks from the declared schema, not from code written for one tool, and report only the first failure, in this order:
- If
additionalPropertiesisfalse: each argument key, in the order the arguments list them, thatpropertiesdoes not declare givesUnexpected argument: <key>. WithoutadditionalProperties: false, extra keys are allowed (the JSON Schema default). - Each name in
required, in order, that the arguments do not contain givesMissing required argument: <name>. - Each declared property, in
propertiesorder, that the arguments contain: its type (Argument <name> must be a stringorArgument <name> must be an integer), then its enum (Argument <name> must be one of: <values joined by ", ">), then its minimum (Argument <name> must be at least <minimum>), then its maximum (Argument <name> must be at most <maximum>).
An integer is a number with no fractional part, as in JSON Schema 2020-12: 412 and 2.0 are integers, while 412.5, true, and "412" are not. The same rule decides whether an id is an integer.
Results
server/discover:{ resultType: "complete", supportedVersions, capabilities: { tools: {} }, ttlMs, cacheScope }, with a copy ofserver.supportedVersions,ttlMsfromserver.cacheTtlMs, andcacheScopefromserver.cacheScope.tools/list:{ resultType: "complete", tools, ttlMs, cacheScope }, wheretoolsholds deep copies of every tool definition, exactly as declared and in declared order. There is one page: nonextCursor, and acursorinparamsis ignored.- A tool result:
{ resultType: "complete", content: [{ type: "text", text }], structuredContent, isError }. A result withisError: truehas nostructuredContent. get_build_statusfinds the build with the samepipelineandbuild. If there is none, the text isNo build <build> found for pipeline <pipeline>. OtherwisestructuredContentis a new object{ pipeline, build, status, minutes }.list_flaky_teststakes the pipeline's flaky tests in stored order, at mostlimitof them (3 whenlimitis absent).structuredContentis{ pipeline, tests: [{ test, failures }, ...] }.- On success,
textis the compact JSON ofstructuredContentwith keys in the order shown, asJSON.stringify(structuredContent)produces it. The specification asks a tool that returns structured content to mirror it as JSON text for clients that only readcontent.
Never return an object stored in server: a caller that edits a response must not change the server.
Examples
In every example request, params._meta holds "io.modelcontextprotocol/protocolVersion": "2026-07-28" and "io.modelcontextprotocol/clientCapabilities": {}.
Example 1: a successful call
// Request
{
"jsonrpc": "2.0",
"id": "w-1",
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "example-editor", "version": "2.0.0" }
},
"name": "list_flaky_tests",
"arguments": { "pipeline": "web" }
}
}
// Response
{
"jsonrpc": "2.0",
"id": "w-1",
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "{\"pipeline\":\"web\",\"tests\":[{\"test\":\"login-redirect\",\"failures\":5}]}" }],
"structuredContent": { "pipeline": "web", "tests": [{ "test": "login-redirect", "failures": 5 }] },
"isError": false
}
}Explanation: the arguments pass the schema, the web pipeline has one flaky test, and the default limit of 3 keeps it. The optional clientInfo entry in _meta changes nothing.
Example 2: an argument the schema rejects
// Request
{
"jsonrpc": "2.0",
"id": "w-2",
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"name": "get_build_status",
"arguments": { "pipeline": "staging", "build": 3 }
}
}
// Response
{
"jsonrpc": "2.0",
"id": "w-2",
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Argument pipeline must be one of: web, api, mobile" }],
"isError": true
}
}Explanation: "staging" is a string, so the type check passes and the enum check fails. The model reads the message and can retry with a listed pipeline.
Example 3: a method this server does not serve
// Request
{
"jsonrpc": "2.0",
"id": "w-3",
"method": "prompts/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
// Response
{ "jsonrpc": "2.0", "id": "w-3", "error": { "code": -32601, "message": "Method not found" } }Explanation: the envelope, _meta, and version are fine, but this server answers only server/discover, tools/list, and tools/call.
Fixture
The first test sends these ten requests to the fixture server and compares all ten responses in one assertion. Unless a row says otherwise, a request has "jsonrpc": "2.0", an integer id equal to the row number, and the _meta from the examples. Work out each response from the rules before you run the tests.
| Row | Request |
|---|---|
| 1 | tools/call get_build_status with {"pipeline": "api", "build": 412} |
| 2 | tools/call get_build_status with {"pipeline": "api", "build": 999} |
| 3 | tools/call get_build_status with {"pipeline": "api", "build": "412"} |
| 4 | tools/call get_build_status with {"pipeline": "api", "build": 412, "verbose": true} |
| 5 | tools/call list_flaky_tests with {"pipeline": "mobile"} |
| 6 | tools/call deploy_service with {"service": "api"} |
| 7 | tools/call get_build_status with arguments set to ["api", 412] |
| 8 | tools/list with protocolVersion "2025-11-25" in _meta |
| 9 | initialize with params {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "old-editor", "version": "1.4.0"}} and no _meta |
| 10 | tools/call get_build_status with {"pipeline": "api", "build": 413} and no id member |
Python
Implement handle_mcp_request(message, server). JSON objects are dicts and arrays are lists. The server's own keys are snake_case; every protocol field keeps its spelling in both languages.
| JavaScript | Python |
|---|---|
handleMcpRequest(message, server) | handle_mcp_request(message, server) |
server.supportedVersions | server["supported_versions"] |
server.cacheTtlMs | server["cache_ttl_ms"] |
server.cacheScope | server["cache_scope"] |
server.tools, server.builds | server["tools"], server["builds"] |
server.flakyTests | server["flaky_tests"] |
return null | return None |
JSON.stringify(value) | json.dumps(value, separators=(",", ":")) |
Unchanged in both languages: every request and response field (jsonrpc, id, method, params, _meta and its two keys, name, arguments, cursor, result, resultType, tools, ttlMs, cacheScope, supportedVersions, capabilities, content, type, text, structuredContent, isError, error, code, message, data, supported, requested), the tool definition fields (name, description, inputSchema, annotations, readOnlyHint, openWorldHint), the record fields, and the argument names.
Three Python details:
TrueandFalseareintsubclasses, so reject them as integers, both asids and as arguments. Accept a float with no fractional part, such as2.0, and convert it withint(...)in a new dict before the tool runs, so that999.0prints as999and2.0works as a slice bound. Never write the converted value back intomessage.- The default
json.dumpsseparators add spaces, so passseparators=(",", ":"). - Leave a key out instead of setting it to
None: a tool result withisErrorTruehas no"structuredContent"key, and an error other than-32022has no"data"key.
Constraints
serveris always well-formed and is not validated. Tool names are unique, and every string is ASCII.- Parse errors (
-32700) happen before this handler and are out of scope, as are pagination, list-changed notifications, and the legacyinitializehandshake. - No key anywhere looks like an integer, so object keys keep insertion order in both languages.
Expected Complexity
O(T + R) time per request, where T is the total size of the tool definitions (searched for the lookup, or copied for tools/list) and R is the number of stored records the tool scans. Extra space is proportional to the response.
0 views
Solution
Hints
