Skip to content

Commit a4dc06d

Browse files
cliffhallclaude
andcommitted
test(everything): characterize the server through the protocol, gate per-file coverage at 90
Part of #4854. Replaces the mock-based everything tests with an in-process suite that drives a real SDK Client against createServer() over InMemoryTransport (and the stdio, SSE and Streamable HTTP transports over real streams and loopback sockets), pinning current SDK 1.x behavior: every tool, resource, template, prompt and completion; logging; subscriptions and list-changed notifications; roots, sampling and elicitation answered by a test client; the experimental task tools. Known bugs are pinned, not fixed, each citing its issue (#4087, #4792, #4808, #4712, #3267). Refactors for testability, behavior-preserving: index.ts exports run() and starts only when it is the entry point; the transports export their start-up and createApp() instead of starting at import. The registrations test's dynamic imports are hoisted (cold-start flake). vitest.config.ts now enforces 90/90/90/90 per file for npm run coverage; unreachable code carries a v8 ignore with its reason. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent 13295a0 commit a4dc06d

34 files changed

Lines changed: 5092 additions & 2174 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@modelcontextprotocol/server-everything": patch
3+
---
4+
5+
Internal refactor for in-process testing, with no change in behavior: the launcher only starts a transport when run as the binary, and the stdio, SSE and Streamable HTTP transport modules export their start-up (`startStdioServer`, `startSseServer`, `startStreamableHttpServer`, and `createApp()` for the HTTP transports) instead of starting a server when imported.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Characterizes `gzip-file-as-resource` and the session resources it creates
3+
* (#4854), with its default limits: fetching a `data:` or local `http:` URL,
4+
* returning the gzipped file as a resource link or an embedded resource,
5+
* registering it for later reads, and the errors for bad protocols, oversized
6+
* or empty responses and failed fetches. It also pins #4808: two sessions that
7+
* use the same file name evict each other's resource. The configurable limits
8+
* are in `gzip-limits.test.ts`.
9+
*/
10+
import { createServer as createHttpServer, type Server } from "node:http";
11+
import { once } from "node:events";
12+
import { gunzipSync } from "node:zlib";
13+
import { afterEach, beforeAll, afterAll, describe, expect, it } from "vitest";
14+
import {
15+
connect,
16+
contentOf,
17+
contentOfType,
18+
ofMethod,
19+
textOf,
20+
type Session,
21+
} from "./harness.js";
22+
23+
const TEXT = "hello, gzip";
24+
const DATA_URI = `data:text/plain;base64,${Buffer.from(TEXT).toString("base64")}`;
25+
const TEN_MB = 10 * 1024 * 1024;
26+
27+
let http: Server;
28+
let base: string;
29+
30+
beforeAll(async () => {
31+
http = createHttpServer((req, res) => {
32+
switch (req.url) {
33+
case "/file.txt":
34+
res.end("served over http");
35+
break;
36+
case "/empty":
37+
res.writeHead(204).end();
38+
break;
39+
case "/claims-too-big":
40+
res.writeHead(200, { "content-length": String(TEN_MB + 1) });
41+
res.write("x");
42+
break;
43+
case "/streams-too-much": {
44+
res.writeHead(200, { "content-type": "application/octet-stream" });
45+
const chunk = Buffer.alloc(1024 * 1024, 120);
46+
for (let i = 0; i < 11; i++) res.write(chunk);
47+
res.end();
48+
break;
49+
}
50+
default:
51+
res.writeHead(404).end();
52+
}
53+
});
54+
http.listen(0, "127.0.0.1");
55+
await once(http, "listening");
56+
const address = http.address();
57+
if (address === null || typeof address === "string")
58+
throw new Error("no port");
59+
base = `http://127.0.0.1:${address.port}`;
60+
});
61+
62+
afterAll(async () => {
63+
http.closeAllConnections();
64+
await new Promise((resolve) => http.close(resolve));
65+
});
66+
67+
const sessions: Session[] = [];
68+
69+
afterEach(async () => {
70+
await Promise.all(sessions.splice(0).map((s) => s.close()));
71+
});
72+
73+
async function open(): Promise<Session> {
74+
const s = await connect();
75+
sessions.push(s);
76+
return s;
77+
}
78+
79+
async function gzip(s: Session, args: Record<string, unknown>) {
80+
return s.client.callTool({ name: "gzip-file-as-resource", arguments: args });
81+
}
82+
83+
async function readBlob(s: Session, uri: string): Promise<Buffer> {
84+
const { contents } = await s.client.readResource({ uri });
85+
expect(contents).toHaveLength(1);
86+
expect(contents[0]).toMatchObject({ uri, mimeType: "application/gzip" });
87+
return Buffer.from("blob" in contents[0] ? contents[0].blob : "", "base64");
88+
}
89+
90+
describe("gzip-file-as-resource", () => {
91+
it("returns a link to a session resource holding the gzipped data", async () => {
92+
const s = await open();
93+
const result = await gzip(s, { name: "hello.txt.gz", data: DATA_URI });
94+
expect(result.content).toEqual([
95+
{
96+
type: "resource_link",
97+
uri: "demo://resource/session/hello.txt.gz",
98+
name: "hello.txt.gz",
99+
mimeType: "application/gzip",
100+
},
101+
]);
102+
const blob = await readBlob(s, "demo://resource/session/hello.txt.gz");
103+
expect(gunzipSync(blob).toString()).toBe(TEXT);
104+
});
105+
106+
it("lists the session resource and announces the list change", async () => {
107+
const s = await open();
108+
await gzip(s, { name: "listed.gz", data: DATA_URI });
109+
const { resources } = await s.client.listResources();
110+
expect(resources).toContainEqual({
111+
uri: "demo://resource/session/listed.gz",
112+
name: "listed.gz",
113+
mimeType: "application/gzip",
114+
});
115+
expect(
116+
ofMethod(s.notifications, "notifications/resources/list_changed").length,
117+
).toBeGreaterThan(0);
118+
});
119+
120+
it("embeds the gzipped data when outputType is 'resource'", async () => {
121+
const s = await open();
122+
const result = await gzip(s, {
123+
name: "inline.gz",
124+
data: DATA_URI,
125+
outputType: "resource",
126+
});
127+
const { resource } = contentOfType(contentOf(result)[0], "resource");
128+
expect(resource).toMatchObject({
129+
uri: "demo://resource/session/inline.gz",
130+
mimeType: "application/gzip",
131+
});
132+
const blob = "blob" in resource ? resource.blob : "";
133+
expect(gunzipSync(Buffer.from(blob, "base64")).toString()).toBe(TEXT);
134+
});
135+
136+
it("fetches an http URL", async () => {
137+
const s = await open();
138+
await gzip(s, { name: "web.gz", data: `${base}/file.txt` });
139+
const blob = await readBlob(s, "demo://resource/session/web.gz");
140+
expect(gunzipSync(blob).toString()).toBe("served over http");
141+
});
142+
143+
it("replaces a session resource created again under the same name", async () => {
144+
const s = await open();
145+
await gzip(s, { name: "same.gz", data: DATA_URI });
146+
const second = `data:text/plain;base64,${Buffer.from("second").toString("base64")}`;
147+
const result = await gzip(s, { name: "same.gz", data: second });
148+
expect(result.isError).toBeUndefined();
149+
const blob = await readBlob(s, "demo://resource/session/same.gz");
150+
expect(gunzipSync(blob).toString()).toBe("second");
151+
});
152+
153+
it("evicts another session's resource of the same name (#4808)", async () => {
154+
// Characterization of #4808: session resources are tracked in one
155+
// module-level map keyed by URI, so the second session's registration
156+
// removes the first session's resource from the first session's server.
157+
const a = await open();
158+
const b = await open();
159+
await gzip(a, { name: "shared.gz", data: DATA_URI });
160+
await gzip(b, { name: "shared.gz", data: DATA_URI });
161+
162+
await expect(
163+
a.client.readResource({ uri: "demo://resource/session/shared.gz" }),
164+
).rejects.toThrow("Resource demo://resource/session/shared.gz not found");
165+
expect(
166+
gunzipSync(
167+
await readBlob(b, "demo://resource/session/shared.gz"),
168+
).toString(),
169+
).toBe(TEXT);
170+
});
171+
172+
it("rejects a URL that is not http, https or data", async () => {
173+
const s = await open();
174+
const result = await gzip(s, { data: "ftp://example.com/file.txt" });
175+
expect(result).toEqual({
176+
isError: true,
177+
content: [
178+
{
179+
type: "text",
180+
text: "Error processing file ftp://example.com/file.txt: Unsupported URL protocol for ftp://example.com/file.txt. Only http, https, and data URLs are supported.",
181+
},
182+
],
183+
});
184+
});
185+
186+
it("rejects something that is not a URL at input validation", async () => {
187+
const s = await open();
188+
const result = await gzip(s, { data: "not a url" });
189+
expect(result.isError).toBe(true);
190+
expect(textOf(contentOf(result)[0])).toContain("Input validation error");
191+
});
192+
193+
it("rejects a response whose Content-Length is over 10 MB", async () => {
194+
const s = await open();
195+
const url = `${base}/claims-too-big`;
196+
const result = await gzip(s, { data: url });
197+
expect(textOf(contentOf(result)[0])).toBe(
198+
`Content-Length for ${url} exceeds max of ${TEN_MB}: ${TEN_MB + 1}`,
199+
);
200+
});
201+
202+
it("rejects a response that streams more than 10 MB", async () => {
203+
const s = await open();
204+
const url = `${base}/streams-too-much`;
205+
const result = await gzip(s, { data: url });
206+
expect(textOf(contentOf(result)[0])).toBe(
207+
`Response from ${url} exceeds ${TEN_MB} bytes`,
208+
);
209+
});
210+
211+
it("rejects a response with no body", async () => {
212+
const s = await open();
213+
const result = await gzip(s, { data: `${base}/empty` });
214+
expect(textOf(contentOf(result)[0])).toBe("No response body");
215+
});
216+
217+
it("reports a fetch that fails", async () => {
218+
const s = await open();
219+
const result = await gzip(s, { data: "http://127.0.0.1:1/unreachable" });
220+
expect(result).toEqual({
221+
isError: true,
222+
content: [{ type: "text", text: "fetch failed" }],
223+
});
224+
});
225+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Characterizes the environment variables that configure
3+
* `gzip-file-as-resource` (#4854): `GZIP_MAX_FETCH_SIZE`,
4+
* `GZIP_MAX_FETCH_TIME_MILLIS` and `GZIP_ALLOWED_DOMAINS`. The tool reads them
5+
* once, when its module loads, so this file sets them before it imports the
6+
* server (vitest gives each test file its own module graph).
7+
*/
8+
import { createServer as createHttpServer, type Server } from "node:http";
9+
import { once } from "node:events";
10+
import {
11+
afterAll,
12+
afterEach,
13+
beforeAll,
14+
describe,
15+
expect,
16+
it,
17+
vi,
18+
} from "vitest";
19+
import type { Session } from "./harness.js";
20+
21+
let harness: typeof import("./harness.js");
22+
let http: Server;
23+
let port: number;
24+
25+
beforeAll(async () => {
26+
vi.stubEnv("GZIP_MAX_FETCH_SIZE", "16");
27+
vi.stubEnv("GZIP_MAX_FETCH_TIME_MILLIS", "150");
28+
vi.stubEnv("GZIP_ALLOWED_DOMAINS", " Example.COM, localhost ,,");
29+
harness = await import("./harness.js");
30+
31+
http = createHttpServer((req, res) => {
32+
if (req.url === "/hang") return; // never answers
33+
res.end("small");
34+
});
35+
http.listen(0); // every interface, so both localhost and 127.0.0.1 reach it
36+
await once(http, "listening");
37+
const address = http.address();
38+
if (address === null || typeof address === "string")
39+
throw new Error("no port");
40+
port = address.port;
41+
});
42+
43+
afterAll(async () => {
44+
vi.unstubAllEnvs();
45+
http.closeAllConnections();
46+
await new Promise((resolve) => http.close(resolve));
47+
});
48+
49+
let session: Session | undefined;
50+
51+
afterEach(async () => {
52+
await session?.close();
53+
session = undefined;
54+
});
55+
56+
async function gzip(data: string) {
57+
session ??= await harness.connect();
58+
const result = await session.client.callTool({
59+
name: "gzip-file-as-resource",
60+
arguments: { name: "limited.gz", data },
61+
});
62+
return harness.contentOf(result)[0];
63+
}
64+
65+
async function gzipText(data: string): Promise<string> {
66+
return harness.textOf(await gzip(data));
67+
}
68+
69+
describe("gzip-file-as-resource limits", () => {
70+
it("refuses a host that is not in GZIP_ALLOWED_DOMAINS", async () => {
71+
const url = `http://127.0.0.1:${port}/file`;
72+
expect(await gzipText(url)).toBe(
73+
`Error processing file ${url}: Domain 127.0.0.1 is not in the allowed domains list.`,
74+
);
75+
});
76+
77+
it("allows a listed host, case and whitespace aside", async () => {
78+
expect(await gzip(`http://localhost:${port}/file`)).toMatchObject({
79+
type: "resource_link",
80+
uri: "demo://resource/session/limited.gz",
81+
});
82+
});
83+
84+
it("refuses a host that only ends with a listed domain's name", async () => {
85+
const lookalike = "https://notexample.com/x";
86+
expect(await gzipText(lookalike)).toBe(
87+
`Error processing file ${lookalike}: Domain notexample.com is not in the allowed domains list.`,
88+
);
89+
});
90+
91+
it("does not apply the domain list to data: URLs, but does apply the size limit", async () => {
92+
const data = `data:text/plain;base64,${Buffer.from("x".repeat(17)).toString("base64")}`;
93+
expect(await gzipText(data)).toBe(`Response from ${data} exceeds 16 bytes`);
94+
});
95+
96+
it("aborts a fetch that takes longer than GZIP_MAX_FETCH_TIME_MILLIS", async () => {
97+
const url = `http://localhost:${port}/hang`;
98+
expect(await gzipText(url)).toBe(
99+
`Fetching ${url} took more than 150 ms and was aborted.`,
100+
);
101+
});
102+
});

0 commit comments

Comments
 (0)