Merge pull request #2036 from modelcontextprotocol/v2/fix/2006-crlf-sse-frames
fix(web): accept CRLF-delimited SSE frames on the servers events stream
This commit is contained in:
commit
7a0ef43e67
1 changed files with 177 additions and 7 deletions
|
|
@ -969,6 +969,146 @@ describe("useServers", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("parses CRLF-delimited SSE frames as change notifications (#2006)", async () => {
|
||||
// SSE permits CRLF line endings, and this hook is a hand-rolled parser
|
||||
// (EventSource can't send `x-mcp-remote-auth`). Matching only `\n\n` left
|
||||
// a CRLF frame buffered forever, so an external mcp.json edit never
|
||||
// reached the list.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { seed: { type: "stdio", command: "s" } },
|
||||
}),
|
||||
);
|
||||
|
||||
// The frame is held back until the test has mutated the file, so the
|
||||
// resulting list can only come from a refresh this frame triggered.
|
||||
let releaseFrame: (() => void) | undefined;
|
||||
const framed = new Promise<void>((r) => {
|
||||
releaseFrame = r;
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
// Referentially stable: an inline arrow re-subscribes on every render, and
|
||||
// the re-subscription's own mount refresh would pick the edit up on its
|
||||
// own — passing whether or not the frame was ever parsed.
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/api/servers/events")) {
|
||||
// A real ReadableStream in a real Response — `pull` is only invoked
|
||||
// when the hook's reader asks for a chunk, so awaiting inside it
|
||||
// gates delivery exactly the way a hand-rolled reader double would,
|
||||
// while keeping the mock type-checked against the Response API.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
await framed;
|
||||
controller.enqueue(encoder.encode("event: change\r\n\r\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
return h.fetchFn(url, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]);
|
||||
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { aftercrlf: { type: "stdio", command: "x" } },
|
||||
}),
|
||||
);
|
||||
releaseFrame?.();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["aftercrlf"]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not split a CRLF frame separator straddling two chunks (#2006)", async () => {
|
||||
// The bare-CR terminator is deliberately not accepted: a chunk ending in
|
||||
// `…\r\n\r` would otherwise read as a completed frame, and the `\n`
|
||||
// arriving next would start a bogus one. Deliver exactly that split and
|
||||
// assert the frame is recognized once — only after both halves land.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { seed: { type: "stdio", command: "s" } },
|
||||
}),
|
||||
);
|
||||
|
||||
let releaseFirstHalf: (() => void) | undefined;
|
||||
const firstHalf = new Promise<void>((r) => {
|
||||
releaseFirstHalf = r;
|
||||
});
|
||||
let releaseSecondHalf: (() => void) | undefined;
|
||||
const secondHalf = new Promise<void>((r) => {
|
||||
releaseSecondHalf = r;
|
||||
});
|
||||
let listGets = 0;
|
||||
let chunks = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/api/servers/events")) {
|
||||
// `pull` runs once per chunk the hook's reader consumes, so `chunks`
|
||||
// reaching 2 proves the half-separator chunk was read and parsed.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
chunks += 1;
|
||||
if (chunks === 1) {
|
||||
await firstHalf;
|
||||
controller.enqueue(encoder.encode("event: change\r\n\r"));
|
||||
return;
|
||||
}
|
||||
if (chunks === 2) {
|
||||
await secondHalf;
|
||||
controller.enqueue(encoder.encode("\n"));
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/api/servers")) listGets += 1;
|
||||
return h.fetchFn(url, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listGets).toBe(1);
|
||||
|
||||
// Half a separator is not a frame: no refresh yet.
|
||||
releaseFirstHalf?.();
|
||||
await waitFor(() => expect(chunks).toBeGreaterThanOrEqual(2));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(listGets).toBe(1);
|
||||
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { aftersplit: { type: "stdio", command: "x" } },
|
||||
}),
|
||||
);
|
||||
releaseSecondHalf?.();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["aftersplit"]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(listGets).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores the backend's inert priming comment frame (#1858)", async () => {
|
||||
// The backend opens every SSE stream with a `:` comment so a streaming
|
||||
// fetch() resolves on Firefox at all. That frame carries no event/data
|
||||
|
|
|
|||
Loading…
Reference in a new issue