diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 7ee171f9d5..a2e0a4d161 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -80,8 +80,7 @@ text with `truncated: true`. `body_recorded` is false when the capture kept the response's status and content type but no body; `body` is then `null`, which is not a recorded JSON `null`. `body_encoding` is `base64` when the capture retained response bytes that are not valid UTF-8; `body` is then exactly those -bytes in base64, never a lossy decoding. A retained base64 body is decoded only -as far as the 256 KiB a reply can carry, and a leading byte-order mark is kept. Request bodies passed to `POST`, `PUT` and `PATCH` +bytes in base64, never a lossy decoding. A retained base64 body is decoded only as far as the 256 KiB a reply can carry, and a leading byte-order mark is kept. For a `base64` body the limit applies to the recorded bytes, not their longer base64 spelling, and a cut keeps whole base64 groups. Request bodies passed to `POST`, `PUT` and `PATCH` tools are accepted and ignored: nothing is written, and no call changes what a later call returns. Arguments the tool's input schema does not name are refused by the MCP server as invalid arguments, so a misspelled parameter never @@ -130,7 +129,9 @@ operations in this release; the error names the requested method so a caller can see that the method, not the transport, is unsupported. Its request body is read within the same 4 MiB bound the MCP endpoint applies: a larger body is answered `413` with a JSON-RPC `-32000` error before any of it is parsed, and a -body that is not JSON is answered `400` with `-32700`. +body that is not JSON is answered `400` with `-32700`. A notification (no `id`) +gets `202` and no body, a batch gets one reply per request in it, an empty batch +or a message that is not a JSON-RPC request gets `-32600 invalid request`. ## The environment card diff --git a/src/application/environment/environmentReplay.test.ts b/src/application/environment/environmentReplay.test.ts index 9b7728c001..54fac0ac4a 100644 --- a/src/application/environment/environmentReplay.test.ts +++ b/src/application/environment/environmentReplay.test.ts @@ -344,9 +344,20 @@ describe("replayEnvironmentTool bodies", () => { body_encoding: encoding, }); expect(typeof result.body).toBe("string"); + const body = result.body as string; expect( - Buffer.byteLength(result.body as string, "utf8"), + encoding === "base64" + ? Buffer.from(body, "base64").length + : Buffer.byteLength(body, "utf8"), ).toBeLessThanOrEqual(REPLAY_BODY_LIMIT_BYTES); } + // Under the limit in recorded bytes, even though its base64 spelling is longer. + const small = Buffer.alloc(200 * 1024, 0xff); + expect( + replay("get_users_by_id", { id: "2" }, base64Source(small)), + ).toMatchObject({ + body: small.toString("base64"), + replay: { truncated: false, body_encoding: "base64" }, + }); }); }); diff --git a/src/application/environment/environmentReplay.ts b/src/application/environment/environmentReplay.ts index 2f3ca76061..7117c78b2a 100644 --- a/src/application/environment/environmentReplay.ts +++ b/src/application/environment/environmentReplay.ts @@ -121,17 +121,34 @@ const utf8Prefix = (text: string, maxBytes: number): string => { return text.slice(0, read); }; +/** Bytes a padded base64 string decodes to. */ +const decodedBase64Bytes = (encoded: string): number => + Math.floor((encoded.length * 3) / 4) - + (encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0); + +/** Base64 characters, in whole groups, whose bytes fit the replay limit. */ +const BASE64_LIMIT_CHARS = Math.floor(REPLAY_BODY_LIMIT_BYTES / 3) * 4; + +/** The limit applies to the recorded bytes, not to their base64 spelling. */ +const replayBase64Body = ( + encoded: string, + cut: boolean, +): { readonly body: JsonValue; readonly truncated: boolean } => + !cut && decodedBase64Bytes(encoded) <= REPLAY_BODY_LIMIT_BYTES + ? { body: encoded, truncated: false } + : { body: encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true }; + /** Parse a retained body as JSON when its content type is JSON-like; otherwise return the text. */ const replayBody = ( exchange: RecordedExchange, ): { readonly body: JsonValue; readonly truncated: boolean } => { const text = exchange.bodyText; if (text === null) return { body: null, truncated: false }; + if (exchange.bodyEncoding === "base64") + return replayBase64Body(text, exchange.bodyCut === true); const prefix = utf8Prefix(text, REPLAY_BODY_LIMIT_BYTES); if (prefix.length < text.length || exchange.bodyCut === true) return { body: prefix, truncated: true }; - if (exchange.bodyEncoding === "base64") - return { body: text, truncated: false }; if ( exchange.contentType !== null && isJsonLike(mediaTypeEssence(exchange.contentType)) diff --git a/src/server/environment/serveEnvironment.test.ts b/src/server/environment/serveEnvironment.test.ts index 3c4f5190a4..f05d159873 100644 --- a/src/server/environment/serveEnvironment.test.ts +++ b/src/server/environment/serveEnvironment.test.ts @@ -128,10 +128,40 @@ describe("environment routes", () => { body: JSON.stringify({ jsonrpc: "2.0", id: "a" }), }), ); + expect(noMethod.status).toBe(400); await expect(noMethod.json()).resolves.toMatchObject({ id: "a", - error: { code: -32601, message: "method not found: " }, + error: { code: -32600, message: "invalid request" }, }); + const post = (body: unknown) => + rpc.handle( + new Request(`http://127.0.0.1${ENVIRONMENT_RPC_PATH}`, { + method: "POST", + body: JSON.stringify(body), + }), + ); + // A notification gets no reply; a batch is answered per request, skipping notifications. + expect((await post({ jsonrpc: "2.0", method: "data/reset" })).status).toBe( + 202, + ); + const batch = await post([ + { jsonrpc: "2.0", id: 7, method: "data/reset" }, + { jsonrpc: "2.0", method: "data/notify" }, + { id: 8 }, + ]); + await expect(batch.json()).resolves.toEqual([ + { + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: "method not found: data/reset" }, + }, + { + jsonrpc: "2.0", + id: 8, + error: { code: -32600, message: "invalid request" }, + }, + ]); + expect((await post([])).status).toBe(400); const malformed = await rpc.handle( new Request(`http://127.0.0.1${ENVIRONMENT_RPC_PATH}`, { method: "POST", diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index 0e93179d53..a2d931c5c6 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -40,6 +40,7 @@ export const ENVIRONMENT_CARD_PATH = "/.well-known/agent-env.json"; export const ENVIRONMENT_RPC_PATH = "/agentenv"; const METHOD_NOT_FOUND = -32601; +const INVALID_REQUEST = -32600; const PARSE_ERROR = -32700; /** The SDK's code for a body over its bound, so `/agentenv` and `/mcp` refuse alike. */ const PAYLOAD_TOO_LARGE = -32000; @@ -101,6 +102,40 @@ export const createEnvironmentMcpServer = ( return server; }; +interface RpcErrorReply { + readonly jsonrpc: "2.0"; + readonly id: string | number | null; + readonly error: { readonly code: number; readonly message: string }; +} + +/** The reply to one JSON-RPC message: invalid request, method not found, or none for a notification. */ +const rpcReply = (message: unknown): RpcErrorReply | undefined => { + const id = + isRecord(message) && + (typeof message.id === "string" || typeof message.id === "number") + ? message.id + : null; + if ( + !isRecord(message) || + message.jsonrpc !== "2.0" || + typeof message.method !== "string" + ) + return { + jsonrpc: "2.0", + id, + error: { code: INVALID_REQUEST, message: "invalid request" }, + }; + if (!("id" in message)) return undefined; + return { + jsonrpc: "2.0", + id, + error: { + code: METHOD_NOT_FOUND, + message: `method not found: ${message.method}`, + }, + }; +}; + /** Answer the data plane with a JSON-RPC method-not-found error for every request within the body bound. */ const answerRpc = async (request: Request): Promise => { const read = await readRequestBody(request, DEFAULT_MAX_REQUEST_BODY_SIZE); @@ -112,14 +147,23 @@ const answerRpc = async (request: Request): Promise => { ); const parsed = safeParseJson(read.text); if (!parsed.ok) return jsonRpcErrorResponse(400, PARSE_ERROR, "parse error"); - const body = isRecord(parsed.value) ? parsed.value : {}; - const method = typeof body.method === "string" ? body.method : ""; - const id = body.id; - return Response.json({ - jsonrpc: "2.0", - id: typeof id === "string" || typeof id === "number" ? id : null, - error: { code: METHOD_NOT_FOUND, message: `method not found: ${method}` }, - }); + // A batch is answered message by message; notifications get no reply at all. + if (Array.isArray(parsed.value)) { + if (parsed.value.length === 0) + return jsonRpcErrorResponse(400, INVALID_REQUEST, "invalid request"); + const replies = parsed.value + .map(rpcReply) + .filter((reply) => reply !== undefined); + return replies.length === 0 + ? new Response(null, { status: 202 }) + : Response.json(replies); + } + const reply = rpcReply(parsed.value); + return reply === undefined + ? new Response(null, { status: 202 }) + : Response.json(reply, { + status: reply.error.code === INVALID_REQUEST ? 400 : 200, + }); }; /** The plain routes an agent environment serves beside `/mcp`. */