Skip to content

Commit d82c518

Browse files
committed
fix(appkit): mcp client hardening + reload() race
Address four agentic-review findings on PR #306. All four were P1 risks under the MCP path or the markdown agent reload flow. 1. mcp callTool joined undefined text into 'undefined' literal McpToolCallResult.content[].text is optional per the spec; the old filter only matched `type === "text"`, so an entry like { type: "text" } (text undefined) flowed through and `Array.join('\n')` emitted the literal string "undefined" — visible to the agent and to the user. Tighten the filter on both the success and error paths with a type predicate that narrows on `typeof c.text === "string"`. The error path falls back to a generic "MCP tool call failed" message when no text entries survive. 2. mcp sendNotification ignored HTTP error status `notifications/initialized` and other fire-and-forget notifications completed `connect()` even when the server returned 4xx/5xx — a silent connect followed by mysterious tool-call failures. MCP spec says notifications don't acknowledge, so we keep the fire-and-forget contract but log a warning when the server clearly rejected the request. Wrap the fetch in try/catch so network failures surface in the logs without breaking the protocol. 3. mcp response body had no size cap `response.text()` and `response.json()` both buffer the entire body into memory. A misconfigured or malicious MCP server could stream unbounded data to exhaust client memory. New `readResponseTextCapped()` helper streams the body via the ReadableStream API and aborts once cumulative bytes cross MCP_RESPONSE_BODY_LIMIT_BYTES (1 MiB — far above any normal `initialize` / `tools/list` / `tools/call` JSON-RPC response). Used on both the SSE and plain-JSON branches in sendRpc. 4. agents.reload() closed the live mcpClient mid-stream Tool dispatch reads `this.mcpClient` at call time, not via capture. The old `reload()` called `await this.mcpClient.close()` and nulled the field; any in-flight stream that resolved the field afterwards crashed with "MCP client is closed" mid-conversation. Drop the synchronous close. The client owns only short-lived fetch handles, so a reload doesn't need to tear down state — new endpoints append to the existing connection map via `connectHostedTools`, stale connections from removed configs become unreachable through the new agent tool indexes (small memory cost, no correctness hazard). The shutdown path still closes — that's process teardown, where in-flight streams have already been aborted via `abortActiveOperations`. Tests: 4 new MCP cases (undefined-text filter on success + error paths, generic error fallback, 1 MiB body cap), 1 reload-doesn't-close regression, plus a connect-still-succeeds-on-4xx-notification case. 2298 tests passing across the workspace. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent 63f8cce commit d82c518

4 files changed

Lines changed: 323 additions & 21 deletions

File tree

packages/appkit/src/connectors/mcp/client.ts

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,52 @@ import type { McpEndpointConfig } from "./types";
3434

3535
const logger = createLogger("connector:mcp");
3636

37+
/**
38+
* Hard cap on the size of a single MCP response body, including SSE
39+
* frames bundled into one HTTP response. MCP `initialize` / `tools/list`
40+
* / `tools/call` responses are JSON-RPC payloads — single-digit kilobytes
41+
* in normal use. A response anywhere near this size signals either a
42+
* misbehaving server or an attempt to exhaust client memory; we'd rather
43+
* fail loudly than allocate unbounded buffers from a remote.
44+
*/
45+
const MCP_RESPONSE_BODY_LIMIT_BYTES = 1024 * 1024;
46+
47+
/**
48+
* Read a fetch Response body into a string with a hard size cap. Aborts
49+
* and throws if the cumulative bytes read cross {@link
50+
* MCP_RESPONSE_BODY_LIMIT_BYTES}, so a remote server cannot keep
51+
* streaming data past the limit. Returns the empty string when the
52+
* response has no readable body.
53+
*/
54+
async function readResponseTextCapped(
55+
response: Response,
56+
maxBytes: number,
57+
contextLabel: string,
58+
): Promise<string> {
59+
if (!response.body) return "";
60+
const reader = response.body.getReader();
61+
const decoder = new TextDecoder("utf-8");
62+
let total = 0;
63+
let out = "";
64+
try {
65+
while (true) {
66+
const { done, value } = await reader.read();
67+
if (done) break;
68+
total += value.byteLength;
69+
if (total > maxBytes) {
70+
throw new Error(
71+
`MCP ${contextLabel}: response body exceeded ${maxBytes} bytes — refusing to allocate unbounded buffer from a remote server.`,
72+
);
73+
}
74+
out += decoder.decode(value, { stream: true });
75+
}
76+
out += decoder.decode();
77+
} finally {
78+
reader.releaseLock();
79+
}
80+
return out;
81+
}
82+
3783
interface JsonRpcRequest {
3884
jsonrpc: "2.0";
3985
id: number;
@@ -261,18 +307,22 @@ export class AppKitMcpClient {
261307
);
262308
const result = rpcResult.result as McpToolCallResult;
263309

310+
// `text` is optional on `McpToolCallResult.content[]` per the MCP
311+
// spec; filtering only on `type === "text"` lets `c.text` be
312+
// `undefined`, which `Array.join` would render as the literal
313+
// string `"undefined"` and ship to the agent. Narrow on both
314+
// fields so the joined string only contains real text.
315+
const textContent = (result.content ?? []).filter(
316+
(c): c is { type: "text"; text: string } =>
317+
c.type === "text" && typeof c.text === "string",
318+
);
319+
264320
if (result.isError) {
265-
const errText = (result.content ?? [])
266-
.filter((c) => c.type === "text")
267-
.map((c) => c.text)
268-
.join("\n");
321+
const errText = textContent.map((c) => c.text).join("\n");
269322
throw new Error(errText || "MCP tool call failed");
270323
}
271324

272-
return (result.content ?? [])
273-
.filter((c) => c.type === "text")
274-
.map((c) => c.text)
275-
.join("\n");
325+
return textContent.map((c) => c.text).join("\n");
276326
}
277327

278328
async close(): Promise<void> {
@@ -334,11 +384,20 @@ export class AppKitMcpClient {
334384
}
335385

336386
const contentType = response.headers.get("content-type") ?? "";
387+
// Always read the body via the capped helper so a misconfigured or
388+
// malicious server can't exhaust client memory by streaming an
389+
// unbounded payload. Applies to both SSE (`response.text()` would
390+
// have buffered the whole stream) and plain JSON (`response.json()`
391+
// does the same internally).
392+
const bodyText = await readResponseTextCapped(
393+
response,
394+
MCP_RESPONSE_BODY_LIMIT_BYTES,
395+
method,
396+
);
337397
let json: JsonRpcResponse;
338398

339399
if (contentType.includes("text/event-stream")) {
340-
const text = await response.text();
341-
const lastData = text
400+
const lastData = bodyText
342401
.split("\n")
343402
.filter((line) => line.startsWith("data: "))
344403
.map((line) => line.slice(6))
@@ -348,7 +407,10 @@ export class AppKitMcpClient {
348407
}
349408
json = JSON.parse(lastData) as JsonRpcResponse;
350409
} else {
351-
json = (await response.json()) as JsonRpcResponse;
410+
if (bodyText.length === 0) {
411+
throw new Error(`MCP response for ${method} had an empty body`);
412+
}
413+
json = JSON.parse(bodyText) as JsonRpcResponse;
352414
}
353415

354416
if (json.error) {
@@ -380,12 +442,36 @@ export class AppKitMcpClient {
380442
}
381443

382444
const fetchImpl = this.options.fetchImpl ?? fetch;
383-
await fetchImpl(url, {
384-
method: "POST",
385-
headers,
386-
body: JSON.stringify({ jsonrpc: "2.0", method }),
387-
signal: AbortSignal.timeout(30_000),
388-
});
445+
// MCP notifications are fire-and-forget per spec — we don't throw on
446+
// failure. But silently swallowing 4xx/5xx hides server-side
447+
// rejections that would otherwise look like a successful connect()
448+
// followed by mysterious tool-call failures. Surface the bad status
449+
// via the logger so the dev sees it without breaking the protocol
450+
// contract.
451+
try {
452+
const response = await fetchImpl(url, {
453+
method: "POST",
454+
headers,
455+
body: JSON.stringify({ jsonrpc: "2.0", method }),
456+
signal: AbortSignal.timeout(30_000),
457+
});
458+
if (!response.ok) {
459+
logger.warn(
460+
"MCP notification %s to %s returned %d %s — the server may have rejected the request, but per MCP spec notifications are fire-and-forget and the connection is considered established.",
461+
method,
462+
url,
463+
response.status,
464+
response.statusText,
465+
);
466+
}
467+
} catch (err) {
468+
logger.warn(
469+
"MCP notification %s to %s failed before a response was received: %O",
470+
method,
471+
url,
472+
err,
473+
);
474+
}
389475
}
390476

391477
/**

packages/appkit/src/connectors/mcp/tests/client.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,182 @@ describe("AppKitMcpClient — caller abort signal composition", () => {
400400
expect(error.name).toBe("AbortError");
401401
});
402402
});
403+
404+
describe("AppKitMcpClient — callTool result hardening", () => {
405+
let authSpy: ReturnType<typeof vi.fn>;
406+
407+
beforeEach(() => {
408+
authSpy = vi.fn(workspaceAuth);
409+
});
410+
411+
async function connectAndCall(
412+
callResult: unknown,
413+
): Promise<{ result: string }> {
414+
const connectResponders = [
415+
() =>
416+
jsonResponse(
417+
{ jsonrpc: "2.0", id: 1, result: {} },
418+
{ "mcp-session-id": "sess-1" },
419+
),
420+
() => jsonResponse({ jsonrpc: "2.0", result: null }),
421+
() =>
422+
jsonResponse({
423+
jsonrpc: "2.0",
424+
id: 3,
425+
result: { tools: [{ name: "tool" }] },
426+
}),
427+
];
428+
const callResponder = () =>
429+
jsonResponse({ jsonrpc: "2.0", id: 4, result: callResult });
430+
431+
const { fetchImpl } = recordingFetch([...connectResponders, callResponder]);
432+
const client = new AppKitMcpClient(WORKSPACE, authSpy, workspacePolicy, {
433+
fetchImpl,
434+
dnsLookup: publicDnsLookup,
435+
});
436+
await client.connect({
437+
name: "srv",
438+
url: `${WORKSPACE}/api/2.0/mcp/genie/abc`,
439+
});
440+
const result = await client.callTool("mcp.srv.tool", {}, undefined);
441+
return { result };
442+
}
443+
444+
test("filters content entries whose text is undefined (regression: 'undefined' literal in joined output)", async () => {
445+
// McpToolCallResult.content[i].text is optional. Previously the
446+
// filter only checked `type === "text"`, so an entry like
447+
// { type: "text" } (text undefined) flowed through, and
448+
// `Array.join('\n')` emitted the literal string "undefined".
449+
const { result } = await connectAndCall({
450+
content: [
451+
{ type: "text", text: "first line" },
452+
{ type: "text" },
453+
{ type: "text", text: "second line" },
454+
{ type: "image", data: "..." },
455+
],
456+
});
457+
expect(result).toBe("first line\nsecond line");
458+
expect(result).not.toContain("undefined");
459+
});
460+
461+
test("filters undefined text on the error path too", async () => {
462+
await expect(
463+
connectAndCall({
464+
isError: true,
465+
content: [{ type: "text" }, { type: "text", text: "boom" }],
466+
}),
467+
).rejects.toThrow(/^boom$/);
468+
});
469+
470+
test("error with no text content falls back to a generic message", async () => {
471+
await expect(
472+
connectAndCall({
473+
isError: true,
474+
content: [{ type: "text" }, { type: "image", data: "..." }],
475+
}),
476+
).rejects.toThrow(/MCP tool call failed/);
477+
});
478+
});
479+
480+
describe("AppKitMcpClient — response body size cap", () => {
481+
let authSpy: ReturnType<typeof vi.fn>;
482+
483+
beforeEach(() => {
484+
authSpy = vi.fn(workspaceAuth);
485+
});
486+
487+
test("rejects an unbounded response body (1 MB cap)", async () => {
488+
// Mimic a server streaming forever: each `read()` returns another
489+
// 64 KB chunk. The capped reader must abort once it crosses the
490+
// 1 MB limit rather than buffer indefinitely.
491+
const oversizedBody = new ReadableStream<Uint8Array>({
492+
start(controller) {
493+
const chunk = new Uint8Array(64 * 1024).fill(0x41); // 'A'
494+
let pushed = 0;
495+
const maxChunks = 32; // 32 * 64KiB = 2 MiB, well above the 1 MiB cap
496+
const id = setInterval(() => {
497+
controller.enqueue(chunk);
498+
pushed++;
499+
if (pushed >= maxChunks) {
500+
clearInterval(id);
501+
controller.close();
502+
}
503+
}, 0);
504+
},
505+
});
506+
const oversizedResponse = new Response(oversizedBody, {
507+
status: 200,
508+
headers: { "content-type": "application/json" },
509+
});
510+
511+
const connectResponders = [
512+
() =>
513+
jsonResponse(
514+
{ jsonrpc: "2.0", id: 1, result: {} },
515+
{ "mcp-session-id": "sess-1" },
516+
),
517+
() => jsonResponse({ jsonrpc: "2.0", result: null }),
518+
() => oversizedResponse,
519+
];
520+
const { fetchImpl } = recordingFetch(connectResponders);
521+
const client = new AppKitMcpClient(WORKSPACE, authSpy, workspacePolicy, {
522+
fetchImpl,
523+
dnsLookup: publicDnsLookup,
524+
});
525+
526+
await expect(
527+
client.connect({
528+
name: "evil",
529+
url: `${WORKSPACE}/api/2.0/mcp/genie/abc`,
530+
}),
531+
).rejects.toThrow(/exceeded 1048576 bytes/);
532+
});
533+
});
534+
535+
describe("AppKitMcpClient — sendNotification HTTP error surfacing", () => {
536+
let authSpy: ReturnType<typeof vi.fn>;
537+
538+
beforeEach(() => {
539+
authSpy = vi.fn(workspaceAuth);
540+
});
541+
542+
test("connect succeeds even when notifications/initialized returns 4xx (fire-and-forget per spec)", async () => {
543+
// The MCP spec says notifications are fire-and-forget. We must not
544+
// throw, and connect() must return normally; the regression we're
545+
// guarding is that the failure shouldn't silently appear as a clean
546+
// connect from the dev's perspective (the warning log surfaces it).
547+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
548+
try {
549+
const { fetchImpl } = recordingFetch([
550+
() =>
551+
jsonResponse(
552+
{ jsonrpc: "2.0", id: 1, result: {} },
553+
{ "mcp-session-id": "sess-1" },
554+
),
555+
() =>
556+
new Response("bad request", {
557+
status: 400,
558+
statusText: "Bad Request",
559+
}),
560+
() =>
561+
jsonResponse({
562+
jsonrpc: "2.0",
563+
id: 3,
564+
result: { tools: [{ name: "tool" }] },
565+
}),
566+
]);
567+
const client = new AppKitMcpClient(WORKSPACE, authSpy, workspacePolicy, {
568+
fetchImpl,
569+
dnsLookup: publicDnsLookup,
570+
});
571+
await expect(
572+
client.connect({
573+
name: "srv",
574+
url: `${WORKSPACE}/api/2.0/mcp/genie/abc`,
575+
}),
576+
).resolves.not.toThrow();
577+
} finally {
578+
warnSpy.mockRestore();
579+
}
580+
});
581+
});

packages/appkit/src/plugins/agents/agents.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,21 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
259259
*/
260260
async reload(): Promise<void> {
261261
const next = await this.buildAgentRegistry();
262-
if (this.mcpClient) {
263-
await this.mcpClient.close();
264-
this.mcpClient = null;
265-
}
262+
// Deliberately NOT closing the existing mcpClient here. Tool
263+
// dispatch in `dispatchToolCall` reads `this.mcpClient` at call
264+
// time; closing it mid-stream throws "MCP client is closed" from
265+
// the next sendRpc and kills the in-flight conversation. The
266+
// client owns only short-lived `fetch` handles (no keep-alive
267+
// sockets) and the connections map persists in the live instance,
268+
// so dropping `this.mcpClient` would also strand in-flight tool
269+
// calls that resolved the field a moment earlier. Leave the live
270+
// client in place; `buildAgentRegistry` -> `connectHostedTools`
271+
// adds any new endpoints to the same instance, and stale
272+
// connections from a removed config become unreachable through
273+
// the new agent tool indexes (small memory cost, no correctness
274+
// hazard). The shutdown path still closes — that's process
275+
// teardown, where in-flight streams have already been aborted via
276+
// `abortActiveOperations`.
266277
this.agents = next.agents;
267278
this.defaultAgentName = next.defaultAgentName;
268279
}

0 commit comments

Comments
 (0)