Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/475-mcpl-request-ids.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- MCPL tells a server's request from its notification by whether the message has an `id` member, as JSON-RPC 2.0 and MCPL §4.1 define them (#475). So a request with `"id": null` is now dispatched and answered, with `id: null`, where it was taken for a notification and never answered. That includes a ping, on a connected transport and during the initialize handshake.
- **A message with an `id` and no `method` is a response,** whatever the id's value, and is never answered. So is an error with no `id`, which MCP lets a server send for a request it couldn't read, in a message whose `jsonrpc` is `"2.0"`; it was ignored silently before. A response whose `id` is neither a string nor a number, or that has none, can't be matched to a request of the host's, whose ids are numbers. So it's logged, with its error member if it has one, and it's no longer surfaced as an orphaned response. A server sends an error with `id: null`, or under MCP with no `id`, for a request it couldn't read. Once connected, the request it was meant for gets nothing from it, ending as any unanswered request does, by its timeout where it has one. During the initialize handshake, `initialize` is the only request the host has out, so such an error, in a message whose `jsonrpc` is `"2.0"`, is its answer: the connect fails at once, with the server's error, even if the server answers `initialize` after it. Before, it waited out the 30 s timeout, or connected on that later answer. Any other response the handshake can't match is logged, as on a connected transport, a server's own JSON log line with an `id` and an `error` key included. Any other object with neither a `method` nor an `id` isn't JSON-RPC, and is ignored, as before.
- **An invalid request is answered once with `-32600` "Invalid Request",** saying what was wrong, and isn't dispatched. That's a message whose `id` isn't a string, a number or null, which was echoed back in the answer, or whose `method` isn't a string, which got -32601 "Method not found" with an `id` and no answer without one. One with no `id` is answered too, since it isn't a valid request, and so isn't a notification either, as JSON-RPC 2.0's own example shows. The answer carries the request's `id` where that's a string or a number, and `null` otherwise. During the initialize handshake, which answers only pings, it's ignored, as other requests are. A ping with an invalid `id` was answered there before, with that `id`.
- **A streaming `inference/request` with `id: null`** is answered with its whole result, since its chunks name the request by its id.
- The MCP server, `McpServer`, which the `agent-framework-mcp` command runs and the package root exports, reads a message's envelope by the same rule (#475).
- **An invalid request now gets one `-32600` "Invalid Request",** saying what was wrong, and isn't handled. Before, a request whose `id` isn't a string, a number or null had that `id` echoed back in the answer, one whose `method` isn't a string got -32601 "Method not found", and one of those with no `id` got no answer.
- **A message with an `id` and no `method` is a response,** and so is a JSON-RPC 2.0 error with no `id`. This server sends no requests, so it logs a response and never answers it. Before, one with an `id` was answered -32601, and an error with no `id` was ignored.
- **A JSON value that isn't an object gets `-32600` with `id: null`,** and a batch's answer says this server doesn't accept batches. Before, a number, a string or `null` got -32700 "Parse error", and an array got no answer.
- **A request named `initialized` gets -32601 "Method not found"**, as any unknown method does, where it got no answer. MCP's acknowledgment is the `notifications/initialized` notification, which needs none.
68 changes: 51 additions & 17 deletions src/api/mcp-server-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@
import WebSocket from 'ws';
import { createInterface } from 'node:readline';
import type { ApiRequest, ApiResponse, ApiEvent } from './types.js';
import { jsonKind, readEnvelope } from '../jsonrpc-envelope.js';
import { logValue } from '../mcpl/log-value.js';

// ============================================================================
// MCP Protocol Types
// ============================================================================

interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
/** null is allowed, though JSON-RPC 2.0 discourages it. */
id: string | number | null;
method: string;
params?: unknown;
}
Expand Down Expand Up @@ -188,20 +191,55 @@ export class McpServer {
}

private handleInput(line: string): void {
let message: unknown;
try {
const message = JSON.parse(line);

// Check if this is a notification (no id) or a request (has id)
if (!('id' in message) || message.id === undefined) {
// This is a notification - handle silently without response
// Common notifications: notifications/initialized, notifications/cancelled
return;
}

// This is a request - process and respond
this.handleRequest(message as JsonRpcRequest);
message = JSON.parse(line);
} catch {
this.sendError(null, -32700, 'Parse error');
return;
}
// A request is an object (JSON-RPC 2.0 §4), so any other JSON value is an
// invalid request, answered with id null (§5). An array is JSON-RPC's
// batch, which this server doesn't take.
if (Array.isArray(message)) {
this.sendError(null, -32600, "Invalid Request: this server doesn't accept batches, so send each message on a line of its own");
return;
}
if (message === null || typeof message !== 'object') {
this.sendError(null, -32600, `Invalid Request: a message must be an object, not ${jsonKind(message)}`);
return;
}

// The envelope is read once, here (src/jsonrpc-envelope.ts says how).
const envelope = readEnvelope(message as Record<string, unknown>);
switch (envelope.kind) {
case 'request':
void this.handleRequest(message as JsonRpcRequest);
return;
case 'invalid':
this.sendError(envelope.id, envelope.error.code, envelope.error.message);
return;
case 'response': {
// This server sends its client no requests, so a response can't be
// matched to one, and it's never answered. A client that couldn't
// read a line this server sent, whatever it was, may answer it with
// id null, or under MCP with no id. The id and the error member, if
// it has one, are printed as sent, through logValue, since they're
// the client's text.
const { id, error } = message as { id?: unknown; error?: unknown };
const which = !Object.hasOwn(message as object, 'id') ? 'with no id'
: `whose id is ${typeof id === 'string' || typeof id === 'number' ? logValue(id) : jsonKind(id)}`;
console.error(`[agent-framework-mcp] ignored a response ${which}, since this server sends no requests` +
(error !== undefined ? `; its error: ${logValue(error)}` : ''));
return;
}
case 'notification':
// notifications/initialized, notifications/cancelled and the like
// need no answer.
return;
case 'neither':
// Not JSON-RPC, and nothing to answer.
return;
}
}

Expand All @@ -221,10 +259,6 @@ export class McpServer {
});
break;

case 'initialized':
// Client acknowledgment, no response needed
break;

case 'tools/list':
this.sendResult(request.id, { tools: this.getTools() });
break;
Expand Down Expand Up @@ -639,7 +673,7 @@ export class McpServer {
});
}

private sendResult(id: string | number, result: unknown): void {
private sendResult(id: string | number | null, result: unknown): void {
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id,
Expand Down
5 changes: 3 additions & 2 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,8 @@ const bySeq = (a: { seq: number }, b: { seq: number }): number => a.seq - b.seq;

/** The reply helpers an MCPL connection hands an inbound request's handler. */
type InboundResponder = {
id: string | number;
/** The request's id: null for a request that the server sent with id null. */
id: string | number | null;
respond: (result: unknown) => void;
respondError: (code: number, message: string, data?: unknown) => void;
};
Expand Down Expand Up @@ -22576,7 +22577,7 @@ export class AgentFramework {
// Handle server-initiated inference requests (Step 6)
onRequest('inference-request', async (
params: McplInferenceRequestParams,
responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
responder?: { id: string | number | null; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
) => {
// RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
if (this.pushCoalescer?.isRendering(connection.id)) {
Expand Down
77 changes: 77 additions & 0 deletions src/jsonrpc-envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* A JSON-RPC 2.0 message's envelope, read once where a message arrives, by
* each of the framework's endpoints that answers requests: the MCPL
* connection, for its servers' requests, and the MCP server
* (src/api/mcp-server-core.ts), for its client's.
*
* A message with an id member and no method is a response, whatever the
* id's value: a peer answers a request it couldn't read with id null (§5),
* and taking that answer for a request would answer it in turn. So is an
* error with no id, which MCP lets a peer send for a request it couldn't
* read (MCP 2025-11-25, Basic, Error Responses), but only from a message
* whose jsonrpc member is "2.0": a peer's own JSON log lines, which an MCPL
* server's stdout can carry, can have an error key. Any other object with
* neither a method nor an id isn't JSON-RPC. A message with a method is
* a request when it has an id member, whatever its value, and a notification
* when it has none (§4.1), so `"id": null` is a request. A request's method
* must be a string and its id a string, a number or null (§4). A message
* that breaks either isn't a Request object, and so isn't a notification
* either, with or without an id: the endpoints answer it once with -32600,
* carrying its id where that's a string or a number and null otherwise (§5,
* and the spec's own example of an invalid Request object, which has no id),
* and don't dispatch it. The MCPL handshake, which answers only pings,
* ignores it, as it ignores every other request then. Apart from the error
* with no id, the `jsonrpc` member isn't checked: a peer that leaves it out
* is served, as before.
*/

/** A JSON value's kind, as a log line or an error names what a peer sent
* without repeating it: "null", "an array", "an object", "a number". */
export function jsonKind(value: unknown): string {
if (value === null) return 'null';
const kind = Array.isArray(value) ? 'array' : typeof value;
return `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind}`;
}

export type JsonRpcEnvelope =
/** An id member and no method, or a JSON-RPC 2.0 error with neither: an
* answer, which the endpoint matches to one of its own requests by the
* id where it can. */
| { kind: 'response' }
/** Neither a method nor an id member, and not such an error: not a
* JSON-RPC message. */
| { kind: 'neither' }
| { kind: 'request'; method: string; id: string | number | null }
| { kind: 'notification'; method: string }
/** Not a Request object, with or without an id: answered with `error`,
* carrying `id`, and not dispatched. `method` is its method where that's
* a string, for a log line. */
| { kind: 'invalid'; id: string | number | null; error: { code: -32600; message: string }; method?: string; problems: string };

/** Read a parsed message's envelope. What to do with a value that isn't an
* object is the endpoint's own call. */
export function readEnvelope(msg: Record<string, unknown>): JsonRpcEnvelope {
if (!Object.hasOwn(msg, 'method')) {
const answers = Object.hasOwn(msg, 'id') || (msg.jsonrpc === '2.0' && Object.hasOwn(msg, 'error'));
return { kind: answers ? 'response' : 'neither' };
}
const isRequest = Object.hasOwn(msg, 'id');
const { method, id } = msg;
const problems = [
...(typeof method === 'string' ? [] : [`the method must be a string, not ${jsonKind(method)}`]),
...(!isRequest || typeof id === 'string' || typeof id === 'number' || id === null
? [] : [`the id must be a string, a number or null, not ${jsonKind(id)}`]),
].join('; ');
if (problems) {
return {
kind: 'invalid',
id: typeof id === 'string' || typeof id === 'number' ? id : null,
error: { code: -32600, message: `Invalid Request: ${problems}` },
...(typeof method === 'string' ? { method } : {}),
problems,
};
}
return isRequest
? { kind: 'request', method: method as string, id: id as string | number | null }
: { kind: 'notification', method: method as string };
}
6 changes: 4 additions & 2 deletions src/mcpl/inference-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ import type { HookOrchestrator } from './hook-orchestrator.js';
interface Responder {
respond(result: McplInferenceRequestResult): void;
respondError(code: number, message: string, data?: unknown): void;
/** The JSON-RPC request ID, used for streaming chunk correlation. */
requestId?: string | number;
/** The JSON-RPC request ID, used for streaming chunk correlation: a
* request sent with id null is answered whole, since its chunks couldn't
* name it. */
requestId?: string | number | null;
}

// ============================================================================
Expand Down
Loading
Loading