Files
Edgar Arakelyanandgensokyo-dev 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

70 lines
1.9 KiB
TypeScript

import { request as httpRequest, type IncomingHttpHeaders } from "node:http";
import { expect } from "vitest";
/** One buffered HTTP response. */
export interface RawHttpResponse {
readonly status: number;
readonly headers: IncomingHttpHeaders;
readonly body: string;
}
/** Send one request with caller-controlled headers, including Host, which fetch overwrites. */
export const rawHttpRequest = (
url: URL,
options: {
readonly method?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly body?: string;
} = {},
): Promise<RawHttpResponse> =>
new Promise((resolve, reject) => {
// A fresh connection per request keeps a closed listener observable as ECONNREFUSED.
const request = httpRequest(
url,
{
method: options.method ?? "GET",
headers: { ...options.headers },
agent: false,
},
(response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk: string) => {
body += chunk;
});
response.on("end", () =>
resolve({
status: response.statusCode ?? 0,
headers: response.headers,
body,
}),
);
response.on("error", reject);
},
);
request.on("error", reject);
request.end(options.body);
});
/** Assert the MCP endpoint refuses a Host header outside the loopback allowlist. */
export const expectForeignHostRefused = async (
url: URL,
): Promise<RawHttpResponse> => {
const refused = await rawHttpRequest(url, {
method: "POST",
headers: {
host: "rea.invalid",
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: "{}",
});
expect(refused.status).toBe(403);
expect(JSON.parse(refused.body)).toMatchObject({
jsonrpc: "2.0",
error: { code: -32000 },
});
return refused;
};