fix(contracts): publish portable browser request patterns (#7185)

Publish portable browser request-path and HTTP-token patterns at their canonical owner, preserving input spelling and existing policy. Independently reproduced and verified through actual full-profile stdio MCP catalog and domain/MCP regressions on latest main. Closes #7168.
This commit is contained in:
Nianze Wu authored and GitHub committed 2026-10-26 20:52:19 +08:00
1 parent 1a91630b88
commit 63417bf3f0
3 files changed
+144 -3

No files matched your search

+59
View File
@@ -1,6 +1,65 @@
import { expect, it } from "vitest";
import { browserScenarioSchema } from "./browserScenario.js";
import { browserScenarioActionSchema } from "./browserScenarioValues.js";
const requestAction = (path: string, name = "X-Trace") => ({
step_id: "request",
action: "request",
method: "GET",
path,
headers: [{ name, value: { source: "literal", value: "selected" } }],
});
it("preserves same-origin request path spelling and rejects network-path prefixes", () => {
for (const path of ["/", "/api?x=a%2Fb", "/api//item", "/a\\b"]) {
expect(
browserScenarioActionSchema.parse(requestAction(path)),
).toMatchObject({
path,
});
}
for (const path of [
"",
"relative",
"//other.test",
"/\\other.test",
"https://other.test/",
])
expect(
browserScenarioActionSchema.safeParse(requestAction(path)).success,
path,
).toBe(false);
});
it("keeps HTTP token characters and forbidden-header policy distinct", () => {
const punctuation = new Set("!#$%&'*+-.^_`|~");
for (let code = 0; code < 128; code += 1) {
const name = String.fromCharCode(code);
const isToken =
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
punctuation.has(name);
expect(
browserScenarioActionSchema.safeParse(requestAction("/api", name))
.success,
JSON.stringify(name),
).toBe(isToken);
}
const name = "X-Trace|Id-";
expect(
browserScenarioActionSchema.parse(requestAction("/api", name)),
).toMatchObject({
headers: [{ name, value: { source: "literal", value: "selected" } }],
});
for (const name of ["", "é", "Host", "cookie", "Proxy-Trace", "Sec-Trace"])
expect(
browserScenarioActionSchema.safeParse(requestAction("/api", name))
.success,
name,
).toBe(false);
});
it("accepts large action, secret, and storage selections", () => {
const actions = Array.from({ length: 129 }, (_, index) => ({
+4 -2
View File
@@ -141,7 +141,8 @@ const browserScenarioPageValueSchemas = [
const requestHeaderSchema = z.strictObject({
name: z
.string()
.regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u)
// Hex escapes retain HTTP token characters in Unicode-set client regexes.
.regex(/^[!#$%&'*+.^_`\x7C~0-9A-Za-z\x2D]+$/u)
.refine(settableRequestHeader, {
message: "A page's fetch cannot set this header",
}),
@@ -319,7 +320,8 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS),
path: z
.string()
.regex(/^\/(?![/\\])/u)
// Preserve the prefix grammar without look-around or reserved class syntax.
.regex(/^\x2F(?:[^\x2F\x5C]|$)/u)
.describe(
"Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.",
),
+81 -1
View File
@@ -1,5 +1,5 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, onTestFinished, test } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
import { z } from "zod";
@@ -178,6 +178,86 @@ const scenario = (origin = "https://app.example.test") => ({
],
});
test.each(["full", "compact"] as const)(
"advertises portable browser action patterns (%s)",
async (profile) => {
const provider = new FakeBrowserScenarioProvider();
const session = createTestBinarySession(() => {
throw new Error("Schema discovery must not start a deep provider");
});
const server = createServer(
{ kind: "session", session },
{
browserScenarioCapture: provider,
environment: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
},
);
const client = new Client({
name: "browser-action-patterns",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
onTestFinished(async () => {
await Promise.all([client.close(), server.close(), session.close()]);
});
await server.connect(serverTransport);
await client.connect(clientTransport);
const tool = (await client.listTools()).tools.find(
({ name }) => name === "capture_browser_scenario",
);
if (tool === undefined) throw new Error("Missing browser scenario tool");
const properties = z
.record(z.string(), z.unknown())
.parse(tool.inputSchema.properties);
const pending = [{ value: properties.actions, where: "actions" }];
const patterns: Array<{ where: string; pattern: string }> = [];
while (pending.length > 0) {
const selected = pending.pop();
if (
selected === undefined ||
selected.value === null ||
typeof selected.value !== "object"
)
continue;
for (const [key, value] of Object.entries(selected.value)) {
const where = `${selected.where}/${key}`;
if (key === "pattern" && typeof value === "string")
patterns.push({ where, pattern: value });
pending.push({ value, where });
}
}
if (profile === "full") {
expect(
patterns.some(({ where }) => where.endsWith("/path/pattern")),
).toBe(true);
expect(
patterns.some(
({ where }) =>
where.includes("/headers/") && where.endsWith("/name/pattern"),
),
).toBe(true);
}
const failures = patterns.flatMap(({ where, pattern }) =>
["", "u", "v"].flatMap((flags) => {
try {
new RegExp(pattern, flags);
return [];
} catch (cause: unknown) {
return [`${where} /${pattern}/${flags}: ${String(cause)}`];
}
}),
);
expect(failures).toEqual([]);
expect(
patterns.filter(({ pattern }) =>
/\(\?<?[=!]|\\[1-9]|\\k</u.test(pattern),
),
).toEqual([]);
expect(provider.scenarios).toEqual([]);
},
);
describe("browser scenario MCP tool", () => {
const resources: Array<{ close(): Promise<unknown> }> = [];