feat: add --protocol-era launch flag for ad-hoc servers (#2208)

An ad-hoc --server-url / command launch always negotiated the legacy era,
because protocolEra was only settable per server in mcp.json. A wrapper
that mints a short-lived bearer token therefore had to write that token to
disk to exercise a server under auto or modern.

--protocol-era <legacy|auto|modern> now feeds settings.protocolEra in all
three clients through a shared parseProtocolEra coercer:

- CLI/TUI overlay it in loadServerEntries alongside --header, onto an
  ad-hoc target and onto a --catalog/--config entry for that run.
- Web stamps it on the in-memory ad-hoc entry, which the backend lifts
  into settings like headers; as with --header it is rejected alongside
  --catalog/--config, but unlike --header it applies to stdio too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 00:11:56 -04:00
commit 55db703196
3 changed files with 324 additions and 30 deletions

View file

@ -12,6 +12,7 @@ import { join } from "node:path";
import {
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
withDefaultCatalogPath,
resolveServerConfigs,
resolveServerSource,
@ -44,6 +45,18 @@ describe("parseKeyValuePair", () => {
});
});
describe("parseProtocolEra", () => {
it.each(["legacy", "auto", "modern"])("accepts %s", (era) => {
expect(parseProtocolEra(era)).toBe(era);
});
it("rejects an unknown era, naming the valid ones", () => {
expect(() => parseProtocolEra("Modern")).toThrow(
"Invalid protocol era: Modern. Valid eras are: legacy, auto, modern",
);
});
});
describe("parseHeaderPair", () => {
it("splits on the first colon and trims both sides", () => {
expect(parseHeaderPair("X-Test: value")).toEqual({

View file

@ -234,6 +234,74 @@ describe("loadServerEntries", () => {
).rejects.toThrow(/--catalog cannot be combined/);
});
it("overrides the disk protocolEra with --protocol-era, preserving the rest", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
web: {
type: "streamable-http",
url: "http://x/mcp",
headers: { Authorization: "Bearer disk" },
protocolEra: "auto",
requestTimeout: 9000,
},
},
}),
);
const servers = await loadServerEntries({
configPath,
protocolEra: "modern",
});
expect(servers.web?.settings?.protocolEra).toBe("modern");
expect(servers.web?.settings?.requestTimeout).toBe(9000);
expect(servers.web?.settings?.headers).toEqual([
{ key: "Authorization", value: "Bearer disk" },
]);
});
it("applies --protocol-era to a server that has no disk settings", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
web: { type: "streamable-http", url: "http://x/mcp" },
},
}),
);
const servers = await loadServerEntries({
configPath,
protocolEra: "auto",
});
expect(servers.web?.settings).toMatchObject({
protocolEra: "auto",
headers: [],
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
});
});
it("applies --protocol-era and --header together to an ad-hoc URL", async () => {
const servers = await loadServerEntries({
serverUrl: "http://x/mcp",
transport: "http",
headers: { Authorization: "Bearer t" },
protocolEra: "modern",
});
expect(servers.default?.settings).toMatchObject({
protocolEra: "modern",
headers: [{ key: "Authorization", value: "Bearer t" }],
});
});
it("gives an ad-hoc target with neither flag no settings (legacy default)", async () => {
const servers = await loadServerEntries({ target: ["my-server"] });
expect(servers.default?.settings).toBeUndefined();
});
it("builds a single ad-hoc server from a positional target", async () => {
const servers = await loadServerEntries({
target: ["my-server", "--flag"],

View file

@ -327,6 +327,83 @@ describe("runWeb", () => {
expect(startHonoServer).not.toHaveBeenCalled();
});
it("plumbs --protocol-era onto the seeded ad-hoc HTTP server", async () => {
void runWeb([
"node",
"run-web",
"--server-url",
"https://example.com/mcp",
"--transport",
"http",
"--header",
"Authorization: Bearer x",
"--protocol-era",
"modern",
]);
await expectServerStarted(startHonoServer);
expect(soleSeededEntry()).toMatchObject({
type: "streamable-http",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer x" },
protocolEra: "modern",
});
});
it("plumbs --protocol-era onto a seeded ad-hoc stdio server", async () => {
void runWeb([
"node",
"run-web",
"--protocol-era",
"auto",
"node",
"server.js",
]);
await expectServerStarted(startHonoServer);
expect(soleSeededEntry()).toMatchObject({
command: "node",
args: ["server.js"],
protocolEra: "auto",
});
});
it("omits protocolEra from the seeded entry when the flag is absent", async () => {
void runWeb([
"node",
"run-web",
"https://example.com/mcp",
"--transport",
"http",
]);
await expectServerStarted(startHonoServer);
expect(soleSeededEntry()).not.toHaveProperty("protocolEra");
});
it("rejects an unknown --protocol-era value", async () => {
await expect(
runWeb([
"node",
"run-web",
"https://example.com/mcp",
"--protocol-era",
"future",
]),
).rejects.toThrow();
expect(startHonoServer).not.toHaveBeenCalled();
});
it("rejects --protocol-era with no ad-hoc server to attach it to", async () => {
await expect(
runWeb(["node", "run-web", "--protocol-era", "modern"]),
).rejects.toThrow("process.exit:1");
expect(
errorLines.some((l) => l.includes("--protocol-era requires an ad-hoc")),
).toBe(true);
expect(startHonoServer).not.toHaveBeenCalled();
});
it("exits when an ad-hoc config resolves to no entries", async () => {
vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]);
@ -548,6 +625,16 @@ describe("runWeb", () => {
argv: ["--config", "/a.json", "--header", "Authorization: Bearer x"],
match: "--header cannot be combined with --config",
},
{
name: "--catalog + --protocol-era",
argv: ["--catalog", "/a.json", "--protocol-era", "modern"],
match: "--protocol-era cannot be combined with --catalog",
},
{
name: "--config + --protocol-era",
argv: ["--config", "/a.json", "--protocol-era", "modern"],
match: "--protocol-era cannot be combined with --config",
},
])("rejects $name", async ({ argv, match }) => {
await expect(runWeb(["node", "run-web", ...argv])).rejects.toThrow(
"process.exit:1",