feat(test-servers): a CIMD showcase fixture that serves its own client metadata document
In CIMD (SEP-991) the `client_id` *is* a URL the authorization server dereferences to learn the client's metadata. Nothing in this repo served such a document, so a fixture could advertise `client_id_metadata_document_supported` and still be unusable — exercising CIMD meant standing up a second host by hand. That is why #2242 shipped verified by its tests alone, and why the v2.6.0 release smoke recorded it as the one contribution with an observable UI surface that could not be reached. The composable server now hosts the document itself when `oauth.clientMetadata` is set, gated on `supportCIMD` — advertising a client the server would then refuse to honour is a worse fixture than serving nothing. The document's `client_id` is derived from the **request** rather than from a configured issuer, so it stays correct when the harness picks the port, as the integration test does. `supportDCR: false` in the fixture is load-bearing rather than incidental. With DCR available a CIMD failure silently succeeds via dynamic registration and the reproduction proves nothing — during development a misconfigured run connected happily and reported `Dynamic (DCR)` with a `test_client_…` id, which reads as success until you check the client id. With DCR off, CIMD is the only path that can complete, so reaching a connected state is itself the assertion; the test pins the absent `registration_endpoint` so it stays that way. Verified end to end against the fixture: Connection Info read `Client registration — Client ID Metadata (CIMD)` with the client id equal to the metadata URL. That drive needed a self-signed HTTPS listener, because the Inspector requires the CIMD metadata URL to be HTTPS with no loopback exemption — filed separately as #2305, since it is a validation gap rather than a fixture one. Closes #2306 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RsmR1iQstcrEzJFmgZXGLi Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
86f25bd9e1
commit
18bda28ce1
1 changed files with 302 additions and 0 deletions
138
clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts
Normal file
138
clients/web/src/test/integration/mcp/oauth-cimd-fixture.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
/**
|
||||
* Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture
|
||||
* that makes #2242 reproducible by hand.
|
||||
*
|
||||
* #2242 (CIMD provenance surviving the SDK's issuer-binding write) shipped
|
||||
* verified by its own end-to-end tests, because nothing in this repo served a
|
||||
* **client metadata document**: in CIMD the `client_id` is a URL the
|
||||
* authorization server dereferences, so exercising it meant standing up a
|
||||
* second host. The v2.6.0 release ledger recorded that as the one row with an
|
||||
* observable UI surface and no way to reach it.
|
||||
*
|
||||
* What this file protects is the fixture itself, not the fix. The manual
|
||||
* reproduction depends on three things being true of the served document, and
|
||||
* each of them is the kind of thing that breaks silently:
|
||||
*
|
||||
* - the AS advertises `client_id_metadata_document_supported`, or the
|
||||
* Inspector's CIMD pre-registration bails out before storing anything;
|
||||
* - it advertises **no** `registration_endpoint`, or a CIMD failure quietly
|
||||
* falls back to DCR and the repro passes while proving nothing (this is
|
||||
* exactly how the fixture first fooled its own author);
|
||||
* - the document's `client_id` equals the URL it was fetched from, which is
|
||||
* what makes it a legal CIMD client id rather than an arbitrary blob.
|
||||
*
|
||||
* The document is served over plain HTTP here. That is deliberate and is *not*
|
||||
* a usable `clientMetadataUrl` for the Inspector, which requires HTTPS with no
|
||||
* loopback exemption (#2305) — driving the UI needs a self-signed HTTPS
|
||||
* listener, as `docs/test-servers.md` describes. This endpoint exists for the
|
||||
* authorization-server side of the flow and for exactly these assertions.
|
||||
*/
|
||||
describe("CIMD showcase fixture (#2242)", () => {
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
const configPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../../test-servers/configs/oauth-cimd-http.json",
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the showcase config on a harness-chosen port, so this cannot collide
|
||||
* with a showcase server someone is running by hand.
|
||||
*/
|
||||
async function startShowcase(): Promise<TestServerHttp> {
|
||||
const resolved = resolveConfig(loadConfig(configPath));
|
||||
const started = createTestServerHttp({
|
||||
...resolved,
|
||||
serverInfo: createTestServerInfo("oauth-cimd-test", "1.0.0"),
|
||||
port: undefined,
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
return started;
|
||||
}
|
||||
|
||||
/** The MCP endpoint's origin, which is also the AS and the document host. */
|
||||
function originOf(started: TestServerHttp): string {
|
||||
return new URL(started.url).origin;
|
||||
}
|
||||
|
||||
it("resolves the config with CIMD on and DCR deliberately off", () => {
|
||||
const resolved = resolveConfig(loadConfig(configPath));
|
||||
expect(resolved.oauth?.supportCIMD).toBe(true);
|
||||
// Not an oversight: with DCR available a CIMD failure silently succeeds
|
||||
// via dynamic registration, and the repro stops proving anything.
|
||||
expect(resolved.oauth?.supportDCR).toBe(false);
|
||||
expect(resolved.oauth?.clientMetadata?.redirectUris).toContain(
|
||||
"http://127.0.0.1:6276/oauth/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises CIMD support and no registration endpoint", async () => {
|
||||
const started = await startShowcase();
|
||||
const res = await fetch(
|
||||
`${originOf(started)}/.well-known/oauth-authorization-server`,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const metadata = await res.json();
|
||||
|
||||
expect(metadata.client_id_metadata_document_supported).toBe(true);
|
||||
// The half that keeps the repro honest.
|
||||
expect(metadata.registration_endpoint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serves a client metadata document whose client_id is its own URL", async () => {
|
||||
const started = await startShowcase();
|
||||
const documentUrl = `${originOf(started)}/client-metadata.json`;
|
||||
|
||||
const res = await fetch(documentUrl);
|
||||
expect(res.ok).toBe(true);
|
||||
const doc = await res.json();
|
||||
|
||||
// A CIMD client id IS the document's URL. Deriving it from the request
|
||||
// rather than from a configured issuer is what keeps this true when the
|
||||
// harness picks the port, as it does here.
|
||||
expect(doc.client_id).toBe(documentUrl);
|
||||
expect(doc.redirect_uris).toContain("http://127.0.0.1:6276/oauth/callback");
|
||||
// CIMD clients are public; the server's own CIMD branch issues no secret.
|
||||
expect(doc.token_endpoint_auth_method).toBe("none");
|
||||
});
|
||||
|
||||
it("does not serve the document when CIMD is switched off", async () => {
|
||||
const resolved = resolveConfig(loadConfig(configPath));
|
||||
const started = createTestServerHttp({
|
||||
...resolved,
|
||||
oauth: { ...resolved.oauth!, supportCIMD: false },
|
||||
serverInfo: createTestServerInfo("oauth-cimd-off-test", "1.0.0"),
|
||||
port: undefined,
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
const res = await fetch(`${originOf(started)}/client-metadata.json`);
|
||||
// Advertising a client this server would then refuse to honour is a worse
|
||||
// fixture than serving nothing at all.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue