chore: merge environment serve (da920d47) into environment export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-18 21:12:19 -07:00
commit fca1a0e925
5 files changed
+118 -15

No files matched your search

+4 -3
View File
@@ -81,8 +81,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
@@ -131,7 +130,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",
+52 -8
View File
@@ -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`. */