diff --git a/src/mcp.ts b/src/mcp.ts index bfba69e..ec83eb5 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -12,6 +12,20 @@ const MCP_URL = "https://mcp2.readwise.io/mcp"; // replies. const MCP_TIMEOUT_MS = 30_000; +export function createMcpFetch(baseFetch: typeof fetch = fetch): typeof fetch { + return (url, init) => { + const method = (init?.method ?? (url instanceof Request ? url.method : "GET")).toUpperCase(); + if (method === "GET") { + // The CLI never consumes server-initiated MCP messages; opting out avoids + // Node 26/undici stalls when the SDK keeps this optional SSE stream open. + return Promise.resolve(new Response(null, { status: 405, statusText: "Method Not Allowed" })); + } + return baseFetch(url, init); + }; +} + +export const mcpFetch = createMcpFetch(); + function createTransport(token: string, authType: "oauth" | "token"): StreamableHTTPClientTransport { const authHeader = authType === "token" ? `Token ${token}` : `Bearer ${token}`; return new StreamableHTTPClientTransport(new URL(MCP_URL), { @@ -20,6 +34,7 @@ function createTransport(token: string, authType: "oauth" | "token"): Streamable Authorization: authHeader, }, }, + fetch: mcpFetch, }); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..9a60f8e --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createMcpFetch } from "../src/mcp.js"; + +test("mcp fetch opts out of the optional GET SSE stream", async () => { + let delegated = false; + const wrappedFetch = createMcpFetch(async () => { + delegated = true; + return new Response(null, { status: 500 }); + }); + + const response = await wrappedFetch("https://mcp2.readwise.io/mcp", { method: "GET" }); + + assert.equal(response.status, 405); + assert.equal(response.statusText, "Method Not Allowed"); + assert.equal(delegated, false); +}); + +test("mcp fetch delegates non-GET requests", async () => { + let seenUrl = ""; + let seenMethod = ""; + const wrappedFetch = createMcpFetch(async (url, init) => { + seenUrl = String(url); + seenMethod = init?.method ?? ""; + return new Response("{}", { status: 200 }); + }); + + const response = await wrappedFetch("https://mcp2.readwise.io/mcp", { method: "POST", body: "{}" }); + + assert.equal(response.status, 200); + assert.equal(seenUrl, "https://mcp2.readwise.io/mcp"); + assert.equal(seenMethod, "POST"); +}); + +test("mcp fetch reads the method from Request inputs", async () => { + let delegated = false; + const wrappedFetch = createMcpFetch(async () => { + delegated = true; + return new Response("{}", { status: 200 }); + }); + + const response = await wrappedFetch(new Request("https://mcp2.readwise.io/mcp", { method: "POST" })); + + assert.equal(response.status, 200); + assert.equal(delegated, true); +});