Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/FastMCP.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,3 +1102,77 @@ test("custom route stream stops when a quiet client disconnects", async () => {
await server.stop();
}
});

test("custom route stream failure after headers are sent settles the response", async () => {
const port = await getRandomPort();
const server = new FastMCP({
name: "Test",
version: "1.0.0",
});
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);

const failAfterFirstChunk = (headers: Record<string, string>) => () => {
const encoder = new TextEncoder();
let pulls = 0;
return new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
if (pulls === 1) {
controller.enqueue(encoder.encode("first chunk"));
return;
}
controller.error(new Error("stream failed after first chunk"));
},
}),
{ headers },
);
};

const app = server.getApp();
app.get(
"/failing-stream",
failAfterFirstChunk({
"Content-Type": "text/plain",
}),
);
// A declared length the body never reaches: ending cleanly leaves the client
// waiting on bytes that will never arrive.
app.get(
"/failing-sized-stream",
failAfterFirstChunk({
"Content-Length": "100",
"Content-Type": "text/plain",
}),
);

await server.start({
httpStream: { port },
transportType: "httpStream",
});

try {
// The connection is dropped rather than ended, so the client sees the
// failure. Ending cleanly would look like a complete chunked body here,
// and would hang the client forever on the Content-Length route below.
await expect(
fetch(`http://localhost:${port}/failing-stream`).then((r) => r.text()),
).rejects.toThrow();

await expect(
fetch(`http://localhost:${port}/failing-sized-stream`).then((r) =>
r.text(),
),
).rejects.toThrow();

await new Promise((resolve) => setImmediate(resolve));
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
await server.stop();
}
});
17 changes: 17 additions & 0 deletions src/FastMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3819,6 +3819,23 @@ export class FastMCP<
return;
}
} catch (error) {
// A streaming custom route may fail after its first chunk has committed
// the Node response. At that point this request was matched: falling
// through to the health/OAuth/default handlers would try to write a
// second response and can throw ERR_HTTP_HEADERS_SENT. End the committed
// response and stop routing it instead.
if (res.headersSent) {
this.#logger.error("[FastMCP error] custom route stream failed", error);
// Drop the connection so the client sees the failure. Ending cleanly
// would terminate a chunked body as if it were complete, or stall a
// Content-Length response forever. end() writes nothing after
// destroy() but sets writableEnded, which mcp-proxy checks before
// running its own fallback response.
res.destroy();
res.end();
return;
}

// If Hono throws, log and continue to other endpoints
this.#logger.debug("[FastMCP debug] Hono route not matched", error);
}
Expand Down
Loading