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: true comes 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.

FieldFixture valueMeaning
supportedVersions["2026-07-28"]The protocol versions the server accepts
cacheTtlMs300000Sent 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
toolsbelowThe tool definitions
buildsbelowBuild records { pipeline, build, status, minutes }
flakyTestsbelowFlaky test records { pipeline, test, failures }, most failures first

The two tool definitions, in declared order:

JSON
[
    {
        "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:

pipelinebuildstatusminutes
api412failed14
api413passed11
web87passed6
mobile230failed22

flakyTests, in stored order:

pipelinetestfailures
apicheckout-timeout7
weblogin-redirect5
apirefund-webhook-retry4
apirate-card-cache2
apiinvoice-pdf-render2

Checks, in order

Apply the checks in order. The first one that fails decides the response.

StepCheckResponse when it fails
1message is an object (not null, not an array), jsonrpc is exactly the string "2.0", and method is a non-empty stringError -32600 Invalid Request, with the message's id when message is an object whose id is valid (see step 3), otherwise with id null
2message has an id memberIt is a notification: return null
3id is valid: a string, or an integerError -32600 Invalid Request with id null
4params is an object whose _meta is an object holding a string io.modelcontextprotocol/protocolVersion and an object io.modelcontextprotocol/clientCapabilitiesError -32602 Missing required _meta fields
5The protocol version is in server.supportedVersionsError -32022 Unsupported protocol version with data: { supported, requested }: a copy of supportedVersions and the version received
6method is server/discover, tools/list, or tools/callError -32601 Method not found. server/discover and tools/list return their results here
7For tools/call: params.name is a string, and params.arguments is an object whenever the member is presentError -32602 Invalid params
8A tool with exactly that name is declared in server.toolsError -32602 Unknown tool: <name>
9The arguments ({} when the member is absent) pass the tool's inputSchemaTool result with isError: true and the message of the first failure
10The tool runsA 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:

  1. If additionalProperties is false: each argument key, in the order the arguments list them, that properties does not declare gives Unexpected argument: <key>. Without additionalProperties: false, extra keys are allowed (the JSON Schema default).
  2. Each name in required, in order, that the arguments do not contain gives Missing required argument: <name>.
  3. Each declared property, in properties order, that the arguments contain: its type (Argument <name> must be a string or Argument <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 of server.supportedVersions, ttlMs from server.cacheTtlMs, and cacheScope from server.cacheScope.
  • tools/list: { resultType: "complete", tools, ttlMs, cacheScope }, where tools holds deep copies of every tool definition, exactly as declared and in declared order. There is one page: no nextCursor, and a cursor in params is ignored.
  • A tool result: { resultType: "complete", content: [{ type: "text", text }], structuredContent, isError }. A result with isError: true has no structuredContent.
  • get_build_status finds the build with the same pipeline and build. If there is none, the text is No build <build> found for pipeline <pipeline>. Otherwise structuredContent is a new object { pipeline, build, status, minutes }.
  • list_flaky_tests takes the pipeline's flaky tests in stored order, at most limit of them (3 when limit is absent). structuredContent is { pipeline, tests: [{ test, failures }, ...] }.
  • On success, text is the compact JSON of structuredContent with keys in the order shown, as JSON.stringify(structuredContent) produces it. The specification asks a tool that returns structured content to mirror it as JSON text for clients that only read content.

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

Jsonc
// 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

Jsonc
// 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

Jsonc
// 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.

RowRequest
1tools/call get_build_status with {"pipeline": "api", "build": 412}
2tools/call get_build_status with {"pipeline": "api", "build": 999}
3tools/call get_build_status with {"pipeline": "api", "build": "412"}
4tools/call get_build_status with {"pipeline": "api", "build": 412, "verbose": true}
5tools/call list_flaky_tests with {"pipeline": "mobile"}
6tools/call deploy_service with {"service": "api"}
7tools/call get_build_status with arguments set to ["api", 412]
8tools/list with protocolVersion "2025-11-25" in _meta
9initialize with params {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "old-editor", "version": "1.4.0"}} and no _meta
10tools/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.

JavaScriptPython
handleMcpRequest(message, server)handle_mcp_request(message, server)
server.supportedVersionsserver["supported_versions"]
server.cacheTtlMsserver["cache_ttl_ms"]
server.cacheScopeserver["cache_scope"]
server.tools, server.buildsserver["tools"], server["builds"]
server.flakyTestsserver["flaky_tests"]
return nullreturn 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:

  • True and False are int subclasses, so reject them as integers, both as ids and as arguments. Accept a float with no fractional part, such as 2.0, and convert it with int(...) in a new dict before the tool runs, so that 999.0 prints as 999 and 2.0 works as a slice bound. Never write the converted value back into message.
  • The default json.dumps separators add spaces, so pass separators=(",", ":").
  • Leave a key out instead of setting it to None: a tool result with isError True has no "structuredContent" key, and an error other than -32022 has no "data" key.

Constraints

  • server is 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 legacy initialize handshake.
  • 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.

MEDIUM
AI Engineering
Model Context Protocol
Tool Calling
Error Handling
Intermediate

0 views

Solution

Hints

Hint 1
Hint 2
Premium
Hint 3
Premium