test: drop strictPort and document the port collision instead
strictPort was a new test-server config option — plus its validation and 18 tests — invented solely to keep one fixture's hard-coded issuer port honest. That is infrastructure built for a screenshot, so it goes. test-servers/src is now byte-identical to v2/main again. The fixture keeps its fixed port and docs/test-servers.md carries the caveat instead, written to be actionable rather than merely cautionary: the failure mode is confusing rather than obvious, since a relocated server leaves discovery pointing at whatever unrelated process holds 8091, so the note says to check the port before believing what the flow does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
b4b82493b1
commit
6c1b5325f0
7 changed files with 4 additions and 319 deletions
|
|
@ -1,228 +0,0 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Live coverage of `ServerConfig.strictPort` (#2280).
|
||||
*
|
||||
* Every other fixture walks to the next free port on `EADDRINUSE`, which is
|
||||
* right for them and wrong for one: `oauth-insecure-token-endpoint-http.json`
|
||||
* hard-codes its port inside an OAuth issuer string, so a relocated server would
|
||||
* announce 8092 while all its metadata still pointed at whatever unrelated
|
||||
* process holds 8091 — and would silently stop reproducing the refusal it
|
||||
* exists for. This asserts the walk still happens by default and does not
|
||||
* happen for that fixture, because "fails loudly" is only a safety property if
|
||||
* it actually fails.
|
||||
*/
|
||||
const configsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../../test-servers/configs",
|
||||
);
|
||||
|
||||
describe("strictPort (#2280)", () => {
|
||||
let squatter: Server | null = null;
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
if (squatter) {
|
||||
await new Promise<void>((resolve) => squatter!.close(() => resolve()));
|
||||
squatter = null;
|
||||
}
|
||||
});
|
||||
|
||||
/** Hold a port so the next bind has to decide whether to walk. */
|
||||
const squat = async (): Promise<number> => {
|
||||
squatter = createServer((_req, res) => res.end());
|
||||
await new Promise<void>((resolve) =>
|
||||
squatter!.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const address = squatter.address();
|
||||
if (typeof address !== "object" || address === null) {
|
||||
throw new Error("no port");
|
||||
}
|
||||
return address.port;
|
||||
};
|
||||
|
||||
it("walks to another port by default", async () => {
|
||||
const taken = await squat();
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("walks", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: taken,
|
||||
});
|
||||
const bound = await server.start();
|
||||
expect(bound).not.toBe(taken);
|
||||
});
|
||||
|
||||
it("refuses to relocate when strictPort is set", async () => {
|
||||
const taken = await squat();
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("strict", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: taken,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: "EADDRINUSE",
|
||||
});
|
||||
// Deliberately NOT nulled: `start()` installs the process-global test-server
|
||||
// control before it binds, and only `stop()` clears it. Dropping the
|
||||
// reference here would skip teardown and leave that global pointing at a
|
||||
// dead server for the rest of the worker.
|
||||
});
|
||||
|
||||
it.each([undefined, 0])(
|
||||
"refuses to start with strictPort and port %j",
|
||||
async (port) => {
|
||||
// Nothing to be strict about. Falling through to an OS-assigned port
|
||||
// would let a misconfigured fixture look strict while relocating every
|
||||
// run — the failure the flag exists to prevent, now silent.
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("misconfigured", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["false", "true", 1, null])(
|
||||
"rejects a non-boolean strictPort in a config file: %j",
|
||||
(value) => {
|
||||
// Consumed as a plain truthiness check at bind time, so the string
|
||||
// "false" would read as *enabled* and silently disable the port walk —
|
||||
// the opposite of what the author wrote.
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport: { type: "streamable-http", port: 8099, strictPort: value },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(() => loadConfig(file)).toThrow(
|
||||
/transport.strictPort must be a boolean/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
// Each of these is truthy or type-valid enough to pass a naive check, and
|
||||
// each fails SILENTLY: the fixture looks strict and relocates anyway.
|
||||
[
|
||||
{ type: "streamable-http", port: "0", strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: "8091", strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 0, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 8091.5, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 70000, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[{ type: "streamable-http", strictPort: true }, /integer in 1-65535/],
|
||||
// No listener at all, and `resolveConfig` drops the flag.
|
||||
[{ type: "stdio", strictPort: true }, /requires an HTTP transport/],
|
||||
])("rejects the unhonorable strictPort config %j", (transport, message) => {
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-combo-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(() => loadConfig(file)).toThrow(message);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts the honorable combination", () => {
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-ok-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport: { type: "streamable-http", port: 8091, strictPort: true },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(resolveConfig(loadConfig(file)).strictPort).toBe(true);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a truthy-but-unbindable port at bind time too", async () => {
|
||||
// Defense in depth for a programmatic caller that bypasses `loadConfig`.
|
||||
// A string "0" is truthy, so a bare falsiness guard would pass it through
|
||||
// and Node would coerce it to the dynamic port 0.
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("stringy", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: "0" as unknown as number,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
|
||||
// Deliberately NOT nulled, for the same reason as the EADDRINUSE case
|
||||
// above: `start()` installs the process-global test-server control before
|
||||
// it validates, so dropping the reference would skip teardown and leave
|
||||
// that global pointing at a dead server.
|
||||
});
|
||||
|
||||
it("is carried from the fixture's config file to the resolved server config", async () => {
|
||||
// The plumbing half: a flag the loader drops would leave the fixture
|
||||
// relocating again with nothing to show for it.
|
||||
const resolved = resolveConfig(
|
||||
loadConfig(
|
||||
path.join(configsDir, "oauth-insecure-token-endpoint-http.json"),
|
||||
),
|
||||
);
|
||||
expect(resolved.strictPort).toBe(true);
|
||||
expect(resolved.port).toBe(8091);
|
||||
expect(resolved.oauth?.issuerUrl?.href).toContain("localhost.:8091");
|
||||
});
|
||||
});
|
||||
|
|
@ -459,7 +459,7 @@ The value now rides the normalized `AuthChallenge` as a string — it has to be
|
|||
|
||||
`oauth-insecure-token-endpoint-http.json` is an ordinary combined AS + resource server with one thing changed: `oauth.issuerUrl` is `http://localhost.:8091`, so its advertised `token_endpoint` is `http://localhost.:8091/oauth/token`. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
|
||||
⚠️ It sets `transport.strictPort`, so it **fails to start** if 8091 is taken rather than relocating. (`strictPort` is only honorable alongside an HTTP transport and an integer `port` in 1–65535, so `loadConfig` rejects every other combination outright — each of them would otherwise leave a fixture looking strict while relocating anyway.) Every other fixture walks to the next free port on `EADDRINUSE`, which is right for them and wrong for this one: the issuer is a *fixed string* in the config, so a relocated server would announce 8092 while all its OAuth metadata still pointed at whatever unrelated process holds 8091 — and the fixture would quietly stop reproducing the refusal it exists for. A loud failure is the only honest option here.
|
||||
⚠️ **Do not run this fixture while port 8091 is already taken.** Like every fixture here it walks to the next free port on `EADDRINUSE` — but its `issuerUrl` is a *fixed string*, so a relocated server announces 8092 while all its OAuth metadata still points at whatever unrelated process holds 8091. The symptom is confusing rather than obvious: discovery reaches the wrong process, and the refusal this fixture exists to demonstrate either never fires or fires for the wrong reason. If the flow does not end at the notice described below, check that 8091 is actually this server (`lsof -nP -iTCP:8091 -sTCP:LISTEN`) before believing anything you see.
|
||||
|
||||
The trailing dot is the whole trick, and it is doing real work rather than being a curiosity. `localhost.` is the *root-anchored* spelling of `localhost`: every resolver on the machine sends it to the loopback interface, so the fixture is reachable and the flow runs for real — but the SDK's `assertSecureTokenEndpoint` exempts only the three literals `localhost`, `127.0.0.1` and `::1`, and `localhost.` is none of them. So the credential-carrying request is refused with `InsecureTokenEndpointError` while everything else about the server works. It is the same over-narrow exemption that makes `http://tenant.app.localhost:3300` fail ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591)), reproducible without a `/etc/hosts` entry or dnsmasq.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
},
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 8091,
|
||||
"strictPort": true
|
||||
"port": 8091
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,17 +456,6 @@ export interface ServerConfig {
|
|||
| undefined; // Optional callback to customize resource handler during registration
|
||||
serverType?: "sse" | "streamable-http"; // Transport type (default: "streamable-http")
|
||||
port?: number; // Port to use (optional, will find available port if not specified)
|
||||
/**
|
||||
* Refuse to relocate: bind {@link port} exactly, or fail with EADDRINUSE.
|
||||
*
|
||||
* Off by default, because walking to the next free port is what lets several
|
||||
* fixtures run side by side. It exists for a fixture whose *advertised*
|
||||
* configuration hard-codes the port — `oauth-insecure-token-endpoint-http.json`
|
||||
* puts it in an OAuth issuer — where relocating leaves the server announcing
|
||||
* one port while its metadata still points at another process entirely, and
|
||||
* the fixture silently stops reproducing what it exists to reproduce.
|
||||
*/
|
||||
strictPort?: boolean;
|
||||
/**
|
||||
* Whether to advertise listChanged capability for each list type
|
||||
* If enabled, modification tools will send list_changed notifications
|
||||
|
|
|
|||
|
|
@ -119,8 +119,6 @@ export interface ConfigFile {
|
|||
transport: {
|
||||
type: "stdio" | "streamable-http" | "sse";
|
||||
port?: number;
|
||||
/** Bind `port` exactly, or fail — see `ServerConfig.strictPort`. */
|
||||
strictPort?: boolean;
|
||||
/**
|
||||
* Serve the modern (2026-07-28) protocol era via the SDK's
|
||||
* `createMcpHandler` (only valid with `type: "streamable-http"`). `true`
|
||||
|
|
@ -198,48 +196,6 @@ function validateConfig(
|
|||
`Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`,
|
||||
);
|
||||
}
|
||||
// `strictPort` is consumed as a plain truthiness check at bind time, so a
|
||||
// string `"false"` would read as *enabled* and silently disable the port walk
|
||||
// — the opposite of what the author wrote. Validate the type here, where the
|
||||
// file is being trusted, rather than letting it through as a `ConfigFile`.
|
||||
if (
|
||||
transport.strictPort !== undefined &&
|
||||
typeof transport.strictPort !== "boolean"
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config in ${filePath}: transport.strictPort must be a boolean`,
|
||||
);
|
||||
}
|
||||
|
||||
// Beyond the type: reject every combination that cannot honor the flag's
|
||||
// contract, because each of them fails *silently* — the fixture looks strict
|
||||
// and relocates anyway, which is the failure the flag exists to prevent.
|
||||
//
|
||||
// - a string port (`"0"`, `"8091"`) is truthy, so it slips past the runtime
|
||||
// "no port to be strict about" guard, and Node then coerces `"0"` to the
|
||||
// dynamic port 0;
|
||||
// - a non-integer or out-of-range port cannot be bound as written;
|
||||
// - on `stdio` there is no listener at all, and `resolveConfig` drops the
|
||||
// flag, so it silently does nothing.
|
||||
if (transport.strictPort === true) {
|
||||
if (transportType === "stdio") {
|
||||
throw new Error(
|
||||
`Invalid config in ${filePath}: transport.strictPort requires an HTTP transport (streamable-http or sse)`,
|
||||
);
|
||||
}
|
||||
const port = transport.port;
|
||||
if (
|
||||
typeof port !== "number" ||
|
||||
!Number.isInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65535
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config in ${filePath}: transport.strictPort requires transport.port to be an integer in 1-65535 (got ${JSON.stringify(port)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only reject *enabling* modern on a non-HTTP transport; a falsy `modern`
|
||||
// (e.g. `false`) is a no-op that `resolveConfig` normalizes away.
|
||||
if (transport.modern && transportType !== "streamable-http") {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
|
|||
? (transport.type as "sse" | "streamable-http")
|
||||
: undefined,
|
||||
port: isHttp ? transport.port : undefined,
|
||||
strictPort: isHttp ? transport.strictPort : undefined,
|
||||
};
|
||||
|
||||
// Normalize the modern flag: `true` is shorthand for the default (dual-era
|
||||
|
|
|
|||
|
|
@ -400,39 +400,9 @@ export class TestServerHttp {
|
|||
const serverType = this.config.serverType ?? "streamable-http";
|
||||
const requestedPort = this.config.port;
|
||||
|
||||
// `strictPort` means "bind exactly this port, or fail". With no fixed port
|
||||
// to bind there is nothing to be strict about, and falling through to an
|
||||
// OS-assigned one would let a misconfigured fixture look strict while
|
||||
// relocating on every run — the exact failure the flag exists to prevent,
|
||||
// now silent. Reject the combination instead.
|
||||
if (
|
||||
this.config.strictPort &&
|
||||
(typeof requestedPort !== "number" ||
|
||||
!Number.isInteger(requestedPort) ||
|
||||
requestedPort < 1 ||
|
||||
requestedPort > 65535)
|
||||
) {
|
||||
// `loadConfig` rejects these for a config file; this covers a
|
||||
// programmatic caller, and specifically a value that is *truthy* but not
|
||||
// bindable as written — a string `"0"` slips past a bare falsiness check
|
||||
// and Node then coerces it to the dynamic port 0, so the fixture looks
|
||||
// strict and relocates anyway.
|
||||
throw new Error(
|
||||
`strictPort requires an explicit port as an integer in 1-65535 (got ${JSON.stringify(requestedPort)}): ` +
|
||||
"there is nothing to bind strictly otherwise.",
|
||||
);
|
||||
}
|
||||
|
||||
// If a port is explicitly requested, find an available port starting from that value
|
||||
// Otherwise, use 0 to let the OS assign an available port.
|
||||
// `strictPort` opts out of the walk: bind the requested port or fail loudly
|
||||
// (see the field's doc comment — a relocated server whose advertised config
|
||||
// hard-codes the port is worse than one that does not start).
|
||||
const port = requestedPort
|
||||
? this.config.strictPort
|
||||
? requestedPort
|
||||
: await findAvailablePort(requestedPort)
|
||||
: 0;
|
||||
// Otherwise, use 0 to let the OS assign an available port
|
||||
const port = requestedPort ? await findAvailablePort(requestedPort) : 0;
|
||||
|
||||
if (serverType === "streamable-http") {
|
||||
return this.startHttp(port);
|
||||
|
|
|
|||
Loading…
Reference in a new issue