Skip to content

Commit 2ea0fe9

Browse files
committed
Harden MCP add against abort, local shadow, and bad URLs
Live Streamable HTTP transports now pass the dispose signal through requestInit so OAuth auth() refreshes abort on quit. The add row is hidden while local mcpServers shadow global settings. HTTP URLs must be real http(s) hosts without userinfo, and persisted as href.
1 parent be89006 commit 2ea0fe9

7 files changed

Lines changed: 204 additions & 38 deletions

File tree

src/mcp/add-server.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

6-
import { createGlobalSettingsWriter, persistGlobalHTTPMCPServer } from "./add-server.js";
6+
import {
7+
createGlobalSettingsWriter,
8+
isAbsoluteHTTPURL,
9+
persistGlobalHTTPMCPServer,
10+
} from "./add-server.js";
711
import { isReadOnlyMcpTool, mcpToolName } from "./tool-name.js";
812

913
const dirs: string[] = [];
@@ -59,6 +63,17 @@ describe("persistGlobalHTTPMCPServer", () => {
5963
ok: false,
6064
reason: "invalid-url",
6165
});
66+
expect(await persistGlobalHTTPMCPServer(writer, "other", "relative/path")).toEqual({
67+
ok: false,
68+
reason: "invalid-url",
69+
});
70+
expect(await persistGlobalHTTPMCPServer(writer, "other", "https://.")).toEqual({
71+
ok: false,
72+
reason: "invalid-url",
73+
});
74+
expect(await persistGlobalHTTPMCPServer(writer, "other", "https://user:pass@host/mcp")).toEqual(
75+
{ ok: false, reason: "invalid-url" },
76+
);
6277
expect(await persistGlobalHTTPMCPServer(writer, "linear", "https://new.test/mcp")).toEqual({
6378
ok: false,
6479
reason: "duplicate",
@@ -95,6 +110,32 @@ describe("persistGlobalHTTPMCPServer", () => {
95110
expect(await readFile(path, "utf8")).toBe(original);
96111
});
97112

113+
test("rejects degenerate and credential-bearing HTTP URLs", () => {
114+
expect(isAbsoluteHTTPURL("relative/path")).toBe(false);
115+
expect(isAbsoluteHTTPURL("ftp://example.test/mcp")).toBe(false);
116+
expect(isAbsoluteHTTPURL("https://.")).toBe(false);
117+
expect(isAbsoluteHTTPURL("https:example.com")).toBe(false);
118+
expect(isAbsoluteHTTPURL("https:////evil.com")).toBe(false);
119+
expect(isAbsoluteHTTPURL("https://user:pass@host/mcp")).toBe(false);
120+
expect(isAbsoluteHTTPURL("https://mcp.linear.app/mcp")).toBe(true);
121+
});
122+
123+
test("persists the normalized href rather than the typed URL", async () => {
124+
const path = await settingsPath();
125+
await Bun.write(path, JSON.stringify({ providers: {} }));
126+
const writer = createGlobalSettingsWriter(path);
127+
128+
expect(
129+
await persistGlobalHTTPMCPServer(writer, "linear", "https://CUSTOM.example:443/mcp"),
130+
).toMatchObject({
131+
ok: true,
132+
server: { name: "linear", type: "http", url: "https://custom.example/mcp" },
133+
});
134+
expect(JSON.parse(await readFile(path, "utf8")).mcpServers).toEqual([
135+
{ name: "linear", type: "http", url: "https://custom.example/mcp" },
136+
]);
137+
});
138+
98139
test("serializes a delayed MCP add with hook and plugin mutation", async () => {
99140
const path = await settingsPath();
100141
await writeFile(path, JSON.stringify({ providers: {}, showPromptCost: true }));

src/mcp/add-server.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,29 @@ export function validateMCPServerName(value: string): string | null {
8686
return null;
8787
}
8888

89-
export function isAbsoluteHTTPURL(value: string): boolean {
89+
export function parseAbsoluteHTTPURL(value: string): URL | null {
90+
const trimmed = value.trim();
91+
const scheme = trimmed.match(/^(https?):\/\//i);
92+
if (scheme === null) return null;
93+
const afterScheme = trimmed.slice(scheme[0].length);
94+
if (afterScheme.startsWith("/")) return null;
9095
try {
91-
const url = new URL(value);
92-
return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== "null";
96+
const url = new URL(trimmed);
97+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
98+
if (url.username !== "" || url.password !== "") return null;
99+
if (url.hostname.length === 0 || url.hostname === "." || url.hostname.startsWith(".")) {
100+
return null;
101+
}
102+
return url;
93103
} catch {
94-
return false;
104+
return null;
95105
}
96106
}
97107

108+
export function isAbsoluteHTTPURL(value: string): boolean {
109+
return parseAbsoluteHTTPURL(value) !== null;
110+
}
111+
98112
export async function persistGlobalHTTPMCPServer(
99113
writer: GlobalSettingsWriter,
100114
rawName: string,
@@ -103,12 +117,12 @@ export async function persistGlobalHTTPMCPServer(
103117
isNameActive: (name: string) => boolean = () => false,
104118
): Promise<PersistMCPServerResult> {
105119
const name = rawName.trim();
106-
const url = rawURL.trim();
120+
const parsedURL = parseAbsoluteHTTPURL(rawURL);
107121
if (validateMCPServerName(name) !== null) return { ok: false, reason: "invalid-name" };
108-
if (!isAbsoluteHTTPURL(url)) return { ok: false, reason: "invalid-url" };
122+
if (parsedURL === null) return { ok: false, reason: "invalid-url" };
109123
if (source === "local") return { ok: false, reason: "local-shadow" };
110124

111-
const server: MCPServerConfig = { name, type: "http", url };
125+
const server: MCPServerConfig = { name, type: "http", url: parsedURL.href };
112126
let active = false;
113127
let duplicate = false;
114128
const settings = await writer.update((base) => {

src/mcp/client-auth-policy.test.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ let toolDiscoveryAborts = 0;
1616
let blockTokenExchange = false;
1717
let tokenExchangeSignals: (AbortSignal | null | undefined)[] = [];
1818
let tokenExchangeAborts = 0;
19+
let lastTransportAuth: (() => Promise<void>) | undefined;
20+
let tokenRefreshSignals: (AbortSignal | null | undefined)[] = [];
21+
let tokenRefreshAborts = 0;
22+
23+
function hangUntilAbort(
24+
signal: AbortSignal | null | undefined,
25+
onAbort: () => void,
26+
fallback: string,
27+
): Promise<void> {
28+
return new Promise((_resolve, reject) => {
29+
const fail = (): void => {
30+
onAbort();
31+
reject(signal?.reason ?? new Error(fallback));
32+
};
33+
if (signal?.aborted === true) fail();
34+
else signal?.addEventListener("abort", fail, { once: true });
35+
});
36+
}
1937

2038
const authProvider = { resetAuthorization: async () => undefined };
2139

@@ -45,6 +63,11 @@ await withMockedModule(
4563
}
4664
return { tools: [] };
4765
}
66+
async callTool(): Promise<{ content: [] }> {
67+
if (lastTransportAuth === undefined) throw new Error("no live HTTP transport");
68+
await lastTransportAuth();
69+
return { content: [] };
70+
}
4871
async close(): Promise<void> {
4972
clientCloses += 1;
5073
}
@@ -62,21 +85,32 @@ await withMockedModule(
6285
private readonly options?: { requestInit?: RequestInit },
6386
) {
6487
transportOptions.push(options);
88+
lastTransportAuth = () => this.auth();
6589
}
6690
async finishAuth(): Promise<void> {
6791
const signal = this.options?.requestInit?.signal;
6892
tokenExchangeSignals.push(signal);
6993
if (blockTokenExchange) {
70-
await new Promise<void>((_resolve, reject) => {
71-
const onAbort = (): void => {
94+
await hangUntilAbort(
95+
signal,
96+
() => {
7297
tokenExchangeAborts += 1;
73-
reject(signal?.reason ?? new Error("token exchange aborted"));
74-
};
75-
if (signal?.aborted === true) onAbort();
76-
else signal?.addEventListener("abort", onAbort, { once: true });
77-
});
98+
},
99+
"token exchange aborted",
100+
);
78101
}
79102
}
103+
async auth(): Promise<void> {
104+
const signal = this.options?.requestInit?.signal;
105+
tokenRefreshSignals.push(signal);
106+
await hangUntilAbort(
107+
signal,
108+
() => {
109+
tokenRefreshAborts += 1;
110+
},
111+
"token refresh aborted",
112+
);
113+
}
80114
get sessionId(): string | undefined {
81115
return undefined;
82116
}
@@ -133,6 +167,9 @@ describe("HTTP MCP auth policy", () => {
133167
blockTokenExchange = false;
134168
tokenExchangeSignals = [];
135169
tokenExchangeAborts = 0;
170+
lastTransportAuth = undefined;
171+
tokenRefreshSignals = [];
172+
tokenRefreshAborts = 0;
136173
});
137174

138175
test("built-in anonymous Exa treats 401 as a normal failure without OAuth machinery", async () => {
@@ -212,6 +249,24 @@ describe("HTTP MCP auth policy", () => {
212249
expect(callbackCloses).toBe(1);
213250
});
214251

252+
test("aborts a live-transport auth refresh that ignores the transport abort controller", async () => {
253+
const abort = new AbortController();
254+
const result = await connectMCPServer(
255+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
256+
{ signal: abort.signal },
257+
);
258+
expect(result.ok).toBe(true);
259+
if (!result.ok) return;
260+
expect(transportOptions).toEqual([{ authProvider, requestInit: { signal: abort.signal } }]);
261+
262+
const call = result.client.call("ping", {}, abort.signal);
263+
while (tokenRefreshSignals.length === 0) await Promise.resolve();
264+
expect(tokenRefreshSignals[0]).toBe(abort.signal);
265+
abort.abort(new Error("toolset disposed"));
266+
await expect(call).rejects.toThrow("toolset disposed");
267+
expect(tokenRefreshAborts).toBe(1);
268+
});
269+
215270
test("ordinary HTTP creates endpoint-scoped OAuth and passes it to transport", async () => {
216271
const result = await connectMCPServer({
217272
name: "exa",

src/mcp/client.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,25 @@ function isRecoverableAuthError(err: unknown): boolean {
7272
return err instanceof UnauthorizedError || err instanceof OAuthError;
7373
}
7474

75+
function streamableHTTPTransportOptions(
76+
authProvider: CorbitsOAuthProvider | undefined,
77+
signal: AbortSignal | undefined,
78+
) {
79+
if (authProvider === undefined && signal === undefined) return undefined;
80+
return {
81+
...(authProvider === undefined ? {} : { authProvider }),
82+
...(signal === undefined ? {} : { requestInit: { signal } }),
83+
};
84+
}
85+
7586
async function completeInteractiveAuth(context: HTTPAuthContext): Promise<void> {
7687
if (!context.interactive)
7788
throw new Error("Authorization required but no interactive handler is available.");
7889
const code = await context.callback.waitForCode(context.signal ?? new AbortController().signal);
79-
await new StreamableHTTPClientTransport(context.url, {
80-
authProvider: context.authProvider,
81-
...(context.signal === undefined ? {} : { requestInit: { signal: context.signal } }),
82-
}).finishAuth(code);
90+
await new StreamableHTTPClientTransport(
91+
context.url,
92+
streamableHTTPTransportOptions(context.authProvider, context.signal),
93+
).finishAuth(code);
8394
}
8495

8596
/**
@@ -218,7 +229,11 @@ async function connectHttp(
218229
let authContext: HTTPAuthContext | undefined;
219230
let makeTransport: () => Transport;
220231
if (config.oauth === false) {
221-
makeTransport = () => new StreamableHTTPClientTransport(url) as unknown as Transport;
232+
makeTransport = () =>
233+
new StreamableHTTPClientTransport(
234+
url,
235+
streamableHTTPTransportOptions(undefined, options.signal),
236+
) as unknown as Transport;
222237
} else {
223238
callback = await startCallbackServer(config.name);
224239
const authProvider = await createOAuthProvider({
@@ -229,7 +244,10 @@ async function connectHttp(
229244
onAuthorizationState: callback.expectState,
230245
});
231246
makeTransport = () =>
232-
new StreamableHTTPClientTransport(url, { authProvider }) as unknown as Transport;
247+
new StreamableHTTPClientTransport(
248+
url,
249+
streamableHTTPTransportOptions(authProvider, options.signal),
250+
) as unknown as Transport;
233251
authContext = {
234252
url,
235253
authProvider,

src/tui/command-surfaces.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,37 @@ describe("mcp surface", () => {
596596
});
597597
});
598598

599+
test("hides the add row while local MCP settings shadow global", async () => {
600+
await withShell((shell) => {
601+
openCommandSurface(shell, "mcp", {
602+
notify: () => {},
603+
mcp: {
604+
list: () => entries,
605+
openAuthURL: () => {},
606+
mcpServersSource: "local",
607+
addServer: async () => ({ ok: true, message: "should not run" }),
608+
},
609+
});
610+
expect(shell.overlayItems).not.toContain("Add MCP server — Alt+A");
611+
expect(shell.overlayItems.at(-1)).toBe("Close mcp");
612+
expect(runOverlayAction(shell, altKey("a"))).toBe(false);
613+
});
614+
});
615+
616+
test("empty MCP list uses a placeholder distinct from close", async () => {
617+
await withShell((shell) => {
618+
openCommandSurface(shell, "mcp", {
619+
notify: () => {},
620+
mcp: { list: () => [], openAuthURL: () => {} },
621+
});
622+
expect(shell.overlayItems).toEqual([
623+
"No MCP servers configured",
624+
"Add MCP server — Alt+A",
625+
"Close mcp",
626+
]);
627+
});
628+
});
629+
599630
test("the visible add row opens the same add-server flow", async () => {
600631
await withShell((shell) => {
601632
openCommandSurface(shell, "mcp", {

0 commit comments

Comments
 (0)