fix(proxy): preserve fetch semantics through the proxy adapter
Addresses review round 4 on #2099 — three posted comments and three suppressed ones, all valid. - A rebuilt Response reported `url: ""`, `redirected: false`, and `type: "default"` even after undici followed a redirect: a fetch-contract regression visible only to proxy users, and one `core/auth/endpointOverrides.ts` explicitly guards against by declining to rebuild at all. This adapter has no such choice — the response arrives from the wrong realm — so it now reinstates the three with `defineProperty`. - Normalizing a `Request` input dropped every option the Request itself carried beyond method/headers/body/signal. Most visibly `redirect`: a `new Request(url, { redirect: "manual" })` started following redirects instead of surfacing them. All of them are now carried through, with an explicit `init` still winning per the fetch contract. Both are covered by tests driving a real redirect through a real proxy, and both were confirmed to fail without the fix. - `@modelcontextprotocol/ext-apps` was added only to the CLI's `external` list. AGENTS.md requires every root-declared runtime dependency in all three, since which client reaches it is a function of what `core/` imports rather than of the client's own code. Added to the web runner and TUI lists too. - The `BUNDLED_CLIENTS` test compared the hard-coded list against a hard-coded copy of itself, so a new tsup-bundled client would have left it green while going unchecked. It now discovers clients from disk (a client is tsup-bundled iff it ships a tsup config) and compares. Verified it fails when a stub client is added. - AGENTS.md's project tree still described `core/mcp/node/` as only the stdio transport factory; it now carries `proxyFetch.ts` and its role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
dbe95e58ed
commit
96f96aad41
7 changed files with 135 additions and 17 deletions
|
|
@ -169,7 +169,11 @@ v2/main/
|
||||||
│ │ │ # server.json parser, strategy registry + well-known
|
│ │ │ # server.json parser, strategy registry + well-known
|
||||||
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
|
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
|
||||||
│ │ │ # used by the web file-upload path + /api/import-source.
|
│ │ │ # used by the web file-upload path + /api/import-source.
|
||||||
│ │ ├── node/ # Node stdio transport factory
|
│ │ ├── node/ # Node stdio transport factory, plus proxyFetch.ts
|
||||||
|
│ │ │ # (the shared HTTPS_PROXY/HTTP_PROXY/NO_PROXY
|
||||||
|
│ │ │ # fetch every Node client installs as its
|
||||||
|
│ │ │ # `environment.fetch`, and the web backend as the
|
||||||
|
│ │ │ # createTransportNode default — #2067)
|
||||||
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
|
||||||
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
|
||||||
│ │ └── state/ # InspectorClient state stores consumed by core/react/
|
│ │ └── state/ # InspectorClient state stores consumed by core/react/
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,10 @@ export default defineConfig({
|
||||||
"@modelcontextprotocol/client",
|
"@modelcontextprotocol/client",
|
||||||
"@modelcontextprotocol/core",
|
"@modelcontextprotocol/core",
|
||||||
// Root-declared and reached through `core/mcp/apps.ts`, so it must be
|
// Root-declared and reached through `core/mcp/apps.ts`, so it must be
|
||||||
// external here like every other root runtime dependency — it was being
|
// external here like every other root runtime dependency (AGENTS.md). This
|
||||||
// inlined (dragging the transitive v1 SDK and zod-to-json-schema with it),
|
// client was actually inlining it — dragging the transitive v1 SDK and
|
||||||
// which the #2067 guard surfaced. ESM, so it was not failing the way
|
// zod-to-json-schema in with it — which the #2067 guard surfaced. ESM, so it
|
||||||
// `undici` did; the rule is what it violated.
|
// was not failing the way `undici` did; the rule is what it violated.
|
||||||
"@modelcontextprotocol/ext-apps",
|
"@modelcontextprotocol/ext-apps",
|
||||||
"commander",
|
"commander",
|
||||||
"pino",
|
"pino",
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,12 @@ export default defineConfig({
|
||||||
"pino",
|
"pino",
|
||||||
"@modelcontextprotocol/client",
|
"@modelcontextprotocol/client",
|
||||||
"@modelcontextprotocol/core",
|
"@modelcontextprotocol/core",
|
||||||
|
// Root-declared and reached through `core/mcp/apps.ts`, so it must be
|
||||||
|
// external here like every other root runtime dependency — which client
|
||||||
|
// actually reaches it is a function of what `core/` imports, not of this
|
||||||
|
// client's own code, so all three lists carry it (AGENTS.md). The CLI was
|
||||||
|
// inlining it; the #2067 guard surfaced that.
|
||||||
|
"@modelcontextprotocol/ext-apps",
|
||||||
"@napi-rs/keyring",
|
"@napi-rs/keyring",
|
||||||
// Root-declared (see the repo's dependency-placement rule) and CJS, which
|
// Root-declared (see the repo's dependency-placement rule) and CJS, which
|
||||||
// is the combination that bites: tsup externalizes what the *client's*
|
// is the combination that bites: tsup externalizes what the *client's*
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,11 @@ describe("Transport", () => {
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.url === "/redirect") {
|
||||||
|
res.writeHead(302, { location: `${originUrl}/landed` });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.writeHead(200, { "content-type": "text/plain", "x-origin": "1" });
|
res.writeHead(200, { "content-type": "text/plain", "x-origin": "1" });
|
||||||
res.end("hello from origin");
|
res.end("hello from origin");
|
||||||
});
|
});
|
||||||
|
|
@ -552,6 +557,37 @@ describe("Transport", () => {
|
||||||
expect(res.body).toBeNull();
|
expect(res.body).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports url/redirected/type after following a redirect", async () => {
|
||||||
|
// A rebuilt Response cannot carry these through its constructor, so they
|
||||||
|
// are reinstated explicitly. Without that, a proxied response reports
|
||||||
|
// `url: ""` and `redirected: false` even when undici did follow a
|
||||||
|
// redirect — a fetch-contract regression only proxy users would see.
|
||||||
|
clearProxyEnv();
|
||||||
|
process.env.HTTP_PROXY = proxyUrl;
|
||||||
|
const proxyFetch = createProxyFetch();
|
||||||
|
|
||||||
|
const res = await proxyFetch!(`${originUrl}/redirect`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.url).toBe(`${originUrl}/landed`);
|
||||||
|
expect(res.redirected).toBe(true);
|
||||||
|
expect(res.type).toBe("basic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a Request's own redirect mode", async () => {
|
||||||
|
// The Request carries options the adapter must not drop on the way into
|
||||||
|
// undici. `redirect: "manual"` is the visible one: lose it and a caller
|
||||||
|
// asking to see the 302 silently gets the followed 200 instead.
|
||||||
|
clearProxyEnv();
|
||||||
|
process.env.HTTP_PROXY = proxyUrl;
|
||||||
|
const proxyFetch = createProxyFetch();
|
||||||
|
|
||||||
|
const res = await proxyFetch!(
|
||||||
|
new Request(`${originUrl}/redirect`, { redirect: "manual" }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.redirected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts a URL instance and forwards the init", async () => {
|
it("accepts a URL instance and forwards the init", async () => {
|
||||||
clearProxyEnv();
|
clearProxyEnv();
|
||||||
process.env.HTTP_PROXY = proxyUrl;
|
process.env.HTTP_PROXY = proxyUrl;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ export default defineConfig({
|
||||||
"proper-lockfile",
|
"proper-lockfile",
|
||||||
"@modelcontextprotocol/client",
|
"@modelcontextprotocol/client",
|
||||||
"@modelcontextprotocol/core",
|
"@modelcontextprotocol/core",
|
||||||
|
// Root-declared and reached through `core/mcp/apps.ts`, so it must be
|
||||||
|
// external here like every other root runtime dependency — which client
|
||||||
|
// actually reaches it is a function of what `core/` imports, not of this
|
||||||
|
// client's own code, so all three lists carry it (AGENTS.md). The CLI was
|
||||||
|
// inlining it; the #2067 guard surfaced that.
|
||||||
|
"@modelcontextprotocol/ext-apps",
|
||||||
],
|
],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,22 @@ const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([
|
||||||
*
|
*
|
||||||
* The body passes through as a `ReadableStream` rather than being buffered, so
|
* The body passes through as a `ReadableStream` rather than being buffered, so
|
||||||
* SSE responses (the streamable-HTTP transport's whole event channel) still
|
* SSE responses (the streamable-HTTP transport's whole event channel) still
|
||||||
* stream. `url` and `redirected` are not carried over — the `Response`
|
* stream.
|
||||||
* constructor cannot set them — which is safe here only because nothing in this
|
*
|
||||||
* repo or the SDK reads either.
|
* `url`, `redirected`, and `type` are restored afterwards with
|
||||||
|
* `defineProperty`, because the `Response` constructor cannot set them and a
|
||||||
|
* rebuilt response would otherwise report `url: ""` and `redirected: false`
|
||||||
|
* even after undici followed a redirect — a fetch-contract regression visible
|
||||||
|
* only to proxy users. `core/auth/endpointOverrides.ts` guards the same three
|
||||||
|
* properties by declining to rebuild at all; this adapter has no such choice,
|
||||||
|
* since the response arrives from the wrong realm, so it reinstates them
|
||||||
|
* instead. They shadow the prototype getters as own properties, which is what
|
||||||
|
* `Response` exposes them as to a consumer.
|
||||||
*/
|
*/
|
||||||
function toGlobalResponse(res: UndiciResponse): Response {
|
function toGlobalResponse(res: UndiciResponse): Response {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const [key, value] of res.headers) headers.append(key, value);
|
for (const [key, value] of res.headers) headers.append(key, value);
|
||||||
return new Response(
|
const out = new Response(
|
||||||
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
||||||
{
|
{
|
||||||
status: res.status,
|
status: res.status,
|
||||||
|
|
@ -82,6 +90,18 @@ function toGlobalResponse(res: UndiciResponse): Response {
|
||||||
headers,
|
headers,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
for (const [key, value] of [
|
||||||
|
["url", res.url],
|
||||||
|
["redirected", res.redirected],
|
||||||
|
["type", res.type],
|
||||||
|
] as const) {
|
||||||
|
Object.defineProperty(out, key, {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,6 +173,19 @@ function toUndiciRequest(
|
||||||
// the DOM `RequestInit`, so it rides along as an extra property.
|
// the DOM `RequestInit`, so it rides along as an extra property.
|
||||||
...(input.body ? { body: input.body, duplex: "half" } : {}),
|
...(input.body ? { body: input.body, duplex: "half" } : {}),
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
|
// Everything else the Request carries. Dropping these would silently
|
||||||
|
// change fetch semantics under a proxy — most visibly `redirect`, where
|
||||||
|
// a `new Request(url, { redirect: "manual" })` would start following
|
||||||
|
// redirects instead of surfacing them.
|
||||||
|
redirect: input.redirect,
|
||||||
|
cache: input.cache,
|
||||||
|
credentials: input.credentials,
|
||||||
|
integrity: input.integrity,
|
||||||
|
keepalive: input.keepalive,
|
||||||
|
mode: input.mode,
|
||||||
|
referrer: input.referrer,
|
||||||
|
referrerPolicy: input.referrerPolicy,
|
||||||
|
// An explicit `init` still wins, per the fetch contract.
|
||||||
...init,
|
...init,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@
|
||||||
|
|
||||||
import { test, describe } from "node:test";
|
import { test, describe } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BUNDLED_CLIENTS,
|
BUNDLED_CLIENTS,
|
||||||
|
|
@ -263,13 +271,38 @@ describe("evaluateClient", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BUNDLED_CLIENTS", () => {
|
describe("BUNDLED_CLIENTS", () => {
|
||||||
test("covers every tsup-bundled client", () => {
|
test("is enrolled from what is actually on disk, not from itself", () => {
|
||||||
// `clients/launcher` is plain tsc and emits no bundle, so it is deliberately
|
// Asserting the hard-coded list equals a hard-coded copy of itself proves
|
||||||
// absent. If a fourth bundled client appears, it must be enrolled here.
|
// nothing — adding `clients/foo` with a tsup build would leave it green
|
||||||
assert.deepEqual(BUNDLED_CLIENTS.map((c) => c.name).sort(), [
|
// while the new bundle went unchecked. So the expected set is DISCOVERED:
|
||||||
"cli",
|
// a client is tsup-bundled iff it ships a tsup config. `clients/launcher`
|
||||||
"tui",
|
// is plain tsc, emits no bundle, and is correctly absent.
|
||||||
"web",
|
const clientsDir = path.join(repoRoot, "clients");
|
||||||
]);
|
const discovered = readdirSync(clientsDir)
|
||||||
|
.filter((name) =>
|
||||||
|
readdirSync(path.join(clientsDir, name)).some((f) =>
|
||||||
|
/^tsup(\..+)?\.config\.ts$/.test(f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
assert.ok(discovered.length > 0, "no tsup-bundled clients discovered");
|
||||||
|
assert.deepEqual(
|
||||||
|
BUNDLED_CLIENTS.map((c) => c.name).sort(),
|
||||||
|
discovered,
|
||||||
|
"BUNDLED_CLIENTS is out of sync with the clients that ship a tsup config",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("points each client at a config and build dir that exist", () => {
|
||||||
|
for (const client of BUNDLED_CLIENTS) {
|
||||||
|
assert.ok(
|
||||||
|
existsSync(path.join(repoRoot, client.config)),
|
||||||
|
`${client.name}: missing ${client.config}`,
|
||||||
|
);
|
||||||
|
// The build dir is produced by `npm run build`; only assert the path is
|
||||||
|
// under the client, since the guard itself reports a missing build.
|
||||||
|
assert.match(client.build, new RegExp(`^clients/${client.name}/`));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue