mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 11:56:12 +00:00
fix(environment): honour the JSON-RPC envelope on the data plane and limit base64 bodies by their bytes
- The data plane treated a batch or a notification as one call and replied to everything. Notifications now get 202 with no body, a batch gets one reply per request in it, and an empty batch or a message that is not a JSON-RPC request gets -32600 invalid request. - The replay limit applied to a base64 body's spelling, so a binary body under 256 KiB was truncated because its base64 is a third longer. The limit now applies to the recorded bytes, and a cut keeps whole base64 groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9196cf7302
commit
da920d4726
5 files changed
+118
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Response> => {
|
||||
const read = await readRequestBody(request, DEFAULT_MAX_REQUEST_BODY_SIZE);
|
||||
@@ -112,14 +147,23 @@ const answerRpc = async (request: Request): Promise<Response> => {
|
||||
);
|
||||
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`. */
|
||||
|
||||
Reference in new issue
Block a user