chore: merge environment serve (4216aa89) 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 20:28:04 -07:00
commit 6ec7ff3870
9 files changed
+192 -34

No files matched your search

+8 -2
View File
@@ -63,6 +63,7 @@ where the answer came from:
"exact_match": true,
"recorded_path": "/api/users/2",
"recorded_query": null,
"body_recorded": true,
"truncated": false
}
}
@@ -75,7 +76,9 @@ record and reports `exact_match: false`. `body` is parsed JSON when the
recorded content type is JSON-like (`application/json`, `application/*+json`,
`text/plain`, `text/markdown`) and the text parses; otherwise it is the
recorded text. Bodies over 256 KiB are cut at a UTF-8 boundary and returned as
text with `truncated: true`. Request bodies passed to `POST`, `PUT` and `PATCH`
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`. 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
@@ -164,7 +167,10 @@ connecting to `/mcp`:
`tools/list` advertises over MCP. Path parameters are required strings whose
description names the segment index, the templating rule and the observed
values; query parameter names are optional strings; write methods accept an
optional `body` object described by the recorded top-level key types. Every
optional `body` object described by the recorded top-level key types. Each
input keeps its own argument: a query name equal to a path parameter's name is
advertised as `query_<name>`, and the write body as `request_body` when a query
parameter is named `body`. Every
schema closes with `additionalProperties: false`, a key the echo example's
schema does not carry.
`extensions`, `operations` and `children_environments` are fixed to `null`,
@@ -27,7 +27,9 @@ later increment.
- Build an `McpServer` from the manifest alone: each tool's input schema lists
every path parameter as a required string described by its segment index,
rule and observed values, every observed query name as an optional string,
and an optional `body` object for `POST`, `PUT` and `PATCH`, closed with
and an optional `body` object for `POST`, `PUT` and `PATCH` (a query name
that collides with a path parameter becomes `query_<name>`, and the body
becomes `request_body` when a query parameter is named `body`), closed with
`additionalProperties: false` so unknown arguments are refused; the
description reads `METHOD template; replays retained records [ordinals];
evidence ev_…`. The served catalog stays separate from REA's own tool
@@ -38,7 +40,9 @@ later increment.
`content_type`, `body` (parsed JSON for JSON-like content types that parse,
else text; bodies over 256 KiB cut at a UTF-8 boundary with
`truncated: true`) and `replay` with `record_ordinal`, `evidence_id`,
`exact_match`, `recorded_path`, `recorded_query` and `truncated`, as both
`exact_match`, `recorded_path`, `recorded_query`, `body_recorded` (false
when the capture kept no response body, so a `null` body is not mistaken for
a recorded one) and `truncated`, as both
structured content and text, with REA's existing target-content provenance
marker in `_meta`.
- Serve the card with exactly the echo example's keys and nesting: `name`
@@ -68,6 +68,7 @@ describe("replayEnvironmentTool selection", () => {
exact_match: true,
recorded_path: "/api/users/2",
recorded_query: null,
body_recorded: true,
truncated: false,
},
});
@@ -163,7 +164,56 @@ describe("replayEnvironmentTool selection", () => {
});
});
describe("replayEnvironmentTool argument names", () => {
it("matches a query value whose name collides with a path parameter under its renamed argument", () => {
const users = tool("get_users_by_id");
const exchange = (ordinal: number, query: string): RecordedExchange => ({
ordinal,
path: "/api/users/2",
query,
status: 200,
contentType: "application/json",
bodyText: JSON.stringify({ query }),
});
const colliding = {
...users,
query_parameter_names: ["id"],
record_ordinals: [901, 902],
};
const from: EnvironmentSource = {
...source,
exchanges: new Map([
[901, exchange(901, "id=3")],
[902, exchange(902, "id=4")],
]),
};
const result = replayEnvironmentTool(from, colliding, {
id: "2",
query_id: "4",
});
if (!result.ok) throw result.error;
expect(result.value.replay).toMatchObject({
record_ordinal: 902,
recorded_query: "id=4",
exact_match: true,
});
});
});
describe("replayEnvironmentTool bodies", () => {
it("reports a response whose body the capture did not retain instead of passing off null as recorded", () => {
const result = replay(
"get_users_by_id",
{ id: "2" },
singleExchangeSource(null, "application/json"),
);
expect(result.body).toBeNull();
expect(result.replay).toMatchObject({
body_recorded: false,
truncated: false,
});
});
it("returns text for non-JSON content types and for JSON-like content that does not parse", () => {
expect(
replay(
@@ -5,6 +5,7 @@ import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { safeParseJson } from "../../domain/safeJson.js";
import { environmentToolArguments } from "./environmentTools.js";
import {
ENVIRONMENT_SERVE_OPERATION,
type EnvironmentManifestTool,
@@ -29,6 +30,7 @@ export interface ReplayResult {
readonly exact_match: boolean;
readonly recorded_path: string;
readonly recorded_query: string | null;
readonly body_recorded: boolean;
readonly truncated: boolean;
};
}
@@ -50,6 +52,7 @@ const decoded = (segment: string): string => {
/** Count how many supplied path and query values the recorded request line satisfies. */
const scoreExchange = (
tool: EnvironmentManifestTool,
queryArguments: ReadonlyMap<string, string>,
exchange: RecordedExchange,
args: ReplayArguments,
): MatchScore => {
@@ -70,8 +73,8 @@ const scoreExchange = (
const query = new URLSearchParams(exchange.query ?? "");
let queryProvided = 0;
let queryMatches = 0;
for (const name of tool.query_parameter_names) {
const value = args[name];
for (const [name, argument] of queryArguments) {
const value = args[argument];
if (typeof value !== "string") continue;
queryProvided += 1;
if (query.getAll(name).includes(value)) queryMatches += 1;
@@ -92,10 +95,11 @@ export const selectReplayExchange = (
| { readonly exchange: RecordedExchange; readonly exact: boolean }
| undefined => {
let best: { exchange: RecordedExchange; score: MatchScore } | undefined;
const queryArguments = environmentToolArguments(tool).query;
for (const ordinal of tool.record_ordinals) {
const exchange = exchanges.get(ordinal);
if (exchange === undefined) continue;
const score = scoreExchange(tool, exchange, args);
const score = scoreExchange(tool, queryArguments, exchange, args);
if (
best === undefined ||
score.pathMatches > best.score.pathMatches ||
@@ -109,12 +113,11 @@ export const selectReplayExchange = (
: { exchange: best.exchange, exact: best.score.exact };
};
/** Cut text at the last UTF-8 character boundary within the byte limit. */
const truncateUtf8 = (text: string, maxBytes: number): string => {
const bytes = Buffer.from(text, "utf8");
let end = maxBytes;
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end -= 1;
return bytes.toString("utf8", 0, end);
/** The longest prefix of text whose UTF-8 encoding fits the byte limit, encoding only that prefix. */
const utf8Prefix = (text: string, maxBytes: number): string => {
// encodeInto stops before the first character that does not fit, so `read` ends on a boundary.
const { read } = new TextEncoder().encodeInto(text, new Uint8Array(maxBytes));
return text.slice(0, read);
};
/** Parse a retained body as JSON when its content type is JSON-like; otherwise return the text. */
@@ -123,11 +126,8 @@ const replayBody = (
): { readonly body: JsonValue; readonly truncated: boolean } => {
const text = exchange.bodyText;
if (text === null) return { body: null, truncated: false };
if (Buffer.byteLength(text, "utf8") > REPLAY_BODY_LIMIT_BYTES)
return {
body: truncateUtf8(text, REPLAY_BODY_LIMIT_BYTES),
truncated: true,
};
const prefix = utf8Prefix(text, REPLAY_BODY_LIMIT_BYTES);
if (prefix.length < text.length) return { body: prefix, truncated: true };
if (
exchange.contentType !== null &&
isJsonLike(mediaTypeEssence(exchange.contentType))
@@ -170,6 +170,7 @@ export const replayEnvironmentTool = (
exact_match: exact,
recorded_path: exchange.path,
recorded_query: exchange.query,
body_recorded: exchange.bodyText !== null,
truncated,
},
});
@@ -92,6 +92,30 @@ describe("environmentToolDefinitions", () => {
});
});
describe("environmentToolInputSchema argument names", () => {
it("keeps every input addressable when query names collide with a path parameter, the body or inherited names", () => {
const schema = environmentToolInputSchema({
...tool("post_folders"),
params: tool("get_users_by_id").params,
query_parameter_names: ["id", "body", "constructor"],
});
expect(Object.keys(schema.properties)).toEqual([
"id",
"query_id",
"body",
"constructor",
"request_body",
]);
expect(Object.hasOwn(schema.properties, "constructor")).toBe(true);
expect(schema.properties.query_id?.description).toBe(
"Query parameter id; optional, matched against the recorded query on replay.",
);
expect(schema.properties.body?.type).toBe("string");
expect(schema.properties.request_body?.type).toBe("object");
expect(schema.required).toEqual(["id"]);
});
});
describe("environmentCard", () => {
it("shapes the card with fixed protocol fields, the tools and null extension slots in contract key order", () => {
const card = environmentCard("grafana-example", definitions);
+55 -12
View File
@@ -40,27 +40,70 @@ const bodyDescription = (tool: EnvironmentManifestTool): string =>
.map(([key, type]) => `${key} (${type})`)
.join(", ")}.`;
/** Where each input of one tool lives in the flat argument object the schema advertises. */
export interface EnvironmentToolArguments {
/** Query parameter name to argument name. */
readonly query: ReadonlyMap<string, string>;
/** Argument name of the write body, for write methods. */
readonly body: string | undefined;
}
const freeName = (
taken: ReadonlySet<string>,
preferred: string,
prefix: string,
): string => {
if (!taken.has(preferred)) return preferred;
let candidate = `${prefix}${preferred}`;
for (let suffix = 2; taken.has(candidate); suffix += 1)
candidate = `${prefix}${preferred}_${String(suffix)}`;
return candidate;
};
/** Path parameters keep their names; a query name or the write body that would collide is renamed, so every input stays addressable. */
export const environmentToolArguments = (
tool: EnvironmentManifestTool,
): EnvironmentToolArguments => {
const taken = new Set(tool.params.map((param) => param.name));
const query = new Map<string, string>();
for (const name of tool.query_parameter_names) {
const argument = freeName(taken, name, "query_");
taken.add(argument);
query.set(name, argument);
}
return {
query,
body: WRITE_METHODS.has(tool.method)
? freeName(taken, "body", "request_")
: undefined,
};
};
/** Map one manifest tool to its advertised MCP input schema. */
export const environmentToolInputSchema = (
tool: EnvironmentManifestTool,
): EnvironmentToolInputSchema => {
const properties: Record<string, EnvironmentToolProperty> = {};
// A Map, not an object literal, so names such as `constructor` are never mistaken for taken.
const properties = new Map<string, EnvironmentToolProperty>();
for (const param of tool.params)
properties[param.name] = {
properties.set(param.name, {
type: "string",
description: `Path segment ${String(param.index)} (${param.rule}); observed: ${observed(param.observed_values)}`,
};
for (const name of tool.query_parameter_names)
if (!(name in properties))
properties[name] = {
type: "string",
description: `Query parameter ${name}; optional, matched against the recorded query on replay.`,
};
if (WRITE_METHODS.has(tool.method) && !("body" in properties))
properties.body = { type: "object", description: bodyDescription(tool) };
});
const args = environmentToolArguments(tool);
for (const [name, argument] of args.query)
properties.set(argument, {
type: "string",
description: `Query parameter ${name}; optional, matched against the recorded query on replay.`,
});
if (args.body !== undefined)
properties.set(args.body, {
type: "object",
description: bodyDescription(tool),
});
return {
type: "object",
properties,
properties: Object.fromEntries(properties),
required: tool.params.map((param) => param.name),
additionalProperties: false,
};
@@ -519,6 +519,30 @@ it("requires exact Accept media ranges before a sessionless POST may replace the
await client.close();
});
it("keeps the live session when the new client left before its initialize was probed", async () => {
const slot = new McpSessionSlot(
createEchoServer(() => undefined),
undefined,
);
const close = vi.spyOn(
WebStandardStreamableHTTPServerTransport.prototype,
"close",
);
try {
expect((await slot.handle(initializeRequest())).status).toBe(200);
const left = new AbortController();
left.abort();
const abandoned = new Request(initializeRequest(), { signal: left.signal });
expect((await slot.handle(abandoned)).status).toBe(400);
expect(close).not.toHaveBeenCalled();
expect((await slot.handle(initializeRequest())).status).toBe(200);
expect(close).toHaveBeenCalledTimes(1);
} finally {
close.mockRestore();
await slot.close();
}
});
it("keeps a session whose transport shutdown failed so the next close retries it", async () => {
let shutdowns = 0;
const slot = new McpSessionSlot(
+3 -1
View File
@@ -238,7 +238,9 @@ export class McpSessionSlot {
const open = this.#live;
if (
open?.transport.sessionId !== undefined &&
(await isInitializePost(request))
(await isInitializePost(request)) &&
// A client that left while its initialize was probed would evict the session for nobody.
!request.signal.aborted
) {
if (this.#closed) return SHUTTING_DOWN();
this.logger?.info("MCP HTTP session replaced by a new client");
@@ -10,9 +10,13 @@ import { processTest } from "../../support/process/processFixture.js";
const SHUTDOWN_TIMEOUT_MS = 90_000;
// Windows has no SIGTERM handler to run: Node's kill("SIGTERM") ends the child outright there.
const [SIGNAL, STATUS]: readonly [NodeJS.Signals, number] =
process.platform === "win32" ? ["SIGINT", 130] : ["SIGTERM", 143];
describe("rea environment-serve shutdown", () => {
processTest(
"closes the listener on SIGTERM, prints one stopped document and exits 143",
`closes the listener on ${SIGNAL}, prints one stopped document and exits ${String(STATUS)}`,
async ({ processes }) => {
const root = await createTestTempDirectory("rea-environment-serve-stop-");
const files = await writeEnvironmentServeFiles(root);
@@ -33,9 +37,9 @@ describe("rea environment-serve shutdown", () => {
new URL("/.well-known/agent-env.json", started.url.origin),
);
expect(card.status).toBe(200);
started.child.kill("SIGTERM");
started.child.kill(SIGNAL);
const [code, signalCode] = await once(started.child, "close");
expect(code).toBe(143);
expect(code).toBe(STATUS);
expect(signalCode).toBeNull();
expect(JSON.parse(stdout)).toMatchObject({
status: "stopped",