Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
14 changed files with 8524 additions and 1598 deletions
|
|
@ -57,6 +57,7 @@ function createStorage(
|
|||
clearServerMetadata: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
getCodeVerifier: vi.fn(),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function createMemoryStorage(
|
|||
saveScope: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ describe("idpSession", () => {
|
|||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
|
|||
1160
clients/web/src/test/core/auth/revocation.test.ts
Normal file
1160
clients/web/src/test/core/auth/revocation.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -358,6 +358,67 @@ describe("BrowserOAuthStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2144 — the atomic take-and-clear. Split reads followed by a separate clear
|
||||
// are a check-then-act: each await yields, and an OAuth completion landing in
|
||||
// one of those gaps saves a grant the clear then destroys.
|
||||
describe("takeRevocationSnapshot", () => {
|
||||
it("returns every slot and clears the server in one step", async () => {
|
||||
await storage.saveTokens(
|
||||
testServerUrl,
|
||||
{ access_token: "a", token_type: "Bearer" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
await storage.savePreregisteredClientInformation(testServerUrl, {
|
||||
client_id: "static-cid",
|
||||
});
|
||||
await storage.saveServerMetadata(testServerUrl, {
|
||||
issuer: "https://as-a.example.com",
|
||||
authorization_endpoint: "https://as-a.example.com/authorize",
|
||||
token_endpoint: "https://as-a.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
});
|
||||
|
||||
const snapshot = await storage.takeRevocationSnapshot(testServerUrl);
|
||||
|
||||
expect(
|
||||
snapshot.byIssuer["https://as-a.example.com"]?.tokens,
|
||||
).toMatchObject({ access_token: "a" });
|
||||
expect(snapshot.preregisteredClientInformation).toMatchObject({
|
||||
client_id: "static-cid",
|
||||
});
|
||||
expect(snapshot.serverMetadata).toMatchObject({
|
||||
issuer: "https://as-a.example.com",
|
||||
});
|
||||
// Cleared in the same step.
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
expect(
|
||||
(await storage.takeRevocationSnapshot(testServerUrl)).byIssuer,
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("returns the legacy unkeyed slot too", async () => {
|
||||
await storage.saveTokens(testServerUrl, {
|
||||
access_token: "legacy",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const snapshot = await storage.takeRevocationSnapshot(testServerUrl);
|
||||
|
||||
expect(snapshot.legacyTokens).toMatchObject({ access_token: "legacy" });
|
||||
expect(snapshot.byIssuer).toEqual({});
|
||||
});
|
||||
|
||||
it("is empty for a server with no state", async () => {
|
||||
const snapshot = await storage.takeRevocationSnapshot(
|
||||
"https://unknown.example/mcp",
|
||||
);
|
||||
expect(snapshot.byIssuer).toEqual({});
|
||||
expect(snapshot.legacyTokens).toBeUndefined();
|
||||
expect(snapshot.serverMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class FakeTransport implements Transport {
|
|||
sessionId?: string;
|
||||
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
||||
setProtocolVersion?: (version: string) => void;
|
||||
// Optional too: Streamable HTTP advertises it, stdio and SSE say nothing.
|
||||
hasPerRequestStream?: boolean;
|
||||
async start(): Promise<void> {}
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.sent.push(message);
|
||||
|
|
@ -272,4 +274,19 @@ describe("MessageTrackingTransport.setProtocolVersion", () => {
|
|||
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
||||
expect(tracked.protocolVersion).toBe("2025-06-18");
|
||||
});
|
||||
|
||||
// #2140: the SDK reads `hasPerRequestStream` off the transport it was handed
|
||||
// — always this wrapper — to choose between aborting a request's own stream
|
||||
// and POSTing `notifications/cancelled`. A wrapper that answers for the base
|
||||
// transport instead of forwarding its answer breaks Cancel on every client.
|
||||
it("forwards the base transport's per-request-stream capability", () => {
|
||||
const { tracked, base } = makeTracked();
|
||||
expect(tracked.hasPerRequestStream).toBeUndefined();
|
||||
|
||||
base.hasPerRequestStream = true;
|
||||
expect(tracked.hasPerRequestStream).toBe(true);
|
||||
|
||||
base.hasPerRequestStream = false;
|
||||
expect(tracked.hasPerRequestStream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ function createMockParams(
|
|||
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
clearDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -186,17 +187,128 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
describe("clearOAuthTokens", () => {
|
||||
it("calls storage.clear(serverUrl) when storage is configured", async () => {
|
||||
/**
|
||||
* The take-and-clear is ONE atomic storage step now, so `clear` is no
|
||||
* longer called separately — these assert on `takeRevocationSnapshot`.
|
||||
*/
|
||||
function stubSnapshot(
|
||||
storage: OAuthManagerConfig["storage"],
|
||||
revocable = true,
|
||||
): void {
|
||||
vi.mocked(storage!.takeRevocationSnapshot).mockResolvedValue({
|
||||
byIssuer: revocable
|
||||
? {
|
||||
"https://as.example.com": {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("takes and clears the server's state when storage is configured", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await manager.clearOAuthTokens();
|
||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(
|
||||
params.initialConfig.storage!.takeRevocationSnapshot,
|
||||
).toHaveBeenCalledWith(SERVER_URL);
|
||||
expect(manager.getOAuthFlowState()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2144 — the ordering is the contract. The request is built from state the
|
||||
// clear destroys, so the take must come first; but the two are ONE step, so
|
||||
// nothing can save a fresh grant between them, and the network wait happens
|
||||
// after the state is already gone.
|
||||
it("clears local state before waiting on the revocation request", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
const order: string[] = [];
|
||||
vi.mocked(storage.takeRevocationSnapshot).mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
return {
|
||||
byIssuer: {
|
||||
"https://as.example.com": {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
};
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
order.push("revoke");
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
const manager = new OAuthManager({
|
||||
...params,
|
||||
effectiveAuthFetch: fetchFn,
|
||||
});
|
||||
|
||||
await expect(manager.clearOAuthTokens()).resolves.toMatchObject({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
});
|
||||
expect(order).toEqual(["clear", "revoke"]);
|
||||
});
|
||||
|
||||
it("clears local state even when the revocation request fails", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
stubSnapshot(storage);
|
||||
const manager = new OAuthManager({
|
||||
...params,
|
||||
effectiveAuthFetch: vi.fn<typeof fetch>(async () => {
|
||||
throw new Error("unreachable");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(manager.clearOAuthTokens()).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
});
|
||||
expect(storage.takeRevocationSnapshot).toHaveBeenCalledWith(SERVER_URL);
|
||||
});
|
||||
|
||||
it("skips the request when revocation is turned off", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
stubSnapshot(storage);
|
||||
const fetchFn = vi.fn<typeof fetch>();
|
||||
const manager = new OAuthManager({
|
||||
...params,
|
||||
effectiveAuthFetch: fetchFn,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.clearOAuthTokens({ revoke: false }),
|
||||
).resolves.toEqual({ status: "skipped", reason: "disabled" });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
// Still cleared — that is what the caller asked for; the request is the
|
||||
// optional part.
|
||||
expect(storage.takeRevocationSnapshot).toHaveBeenCalledWith(SERVER_URL);
|
||||
});
|
||||
|
||||
it("no-ops when storage is not configured", async () => {
|
||||
const params = createMockParams({
|
||||
initialConfig: {
|
||||
|
|
@ -207,7 +319,10 @@ describe("OAuthManager", () => {
|
|||
} as OAuthManagerConfig,
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
await manager.clearOAuthTokens();
|
||||
await expect(manager.clearOAuthTokens()).resolves.toEqual({
|
||||
status: "skipped",
|
||||
reason: "no_tokens",
|
||||
});
|
||||
expect(params.getServerUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -569,4 +569,121 @@ describe("RemoteClientTransport", () => {
|
|||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// #2140: the Cancel button's wire signal is chosen by the SDK from the
|
||||
// transport's `hasPerRequestStream`. The real upstream transport lives on the
|
||||
// backend, so this transport answers for it — and must answer per server
|
||||
// type, since only Streamable HTTP gives a request a stream of its own to
|
||||
// close.
|
||||
describe("per-request-stream cancellation (#2140)", () => {
|
||||
it("advertises hasPerRequestStream only for streamable-http", () => {
|
||||
const forType = (c: MCPServerConfig) =>
|
||||
new RemoteClientTransport(
|
||||
{ baseUrl, fetchFn: vi.fn<typeof fetch>() },
|
||||
c,
|
||||
).hasPerRequestStream;
|
||||
|
||||
expect(
|
||||
forType({ type: "streamable-http", url: "http://server.example/mcp" }),
|
||||
).toBe(true);
|
||||
// stdio and SSE multiplex every request over one shared channel, so
|
||||
// aborting anything would cancel the whole session rather than the call.
|
||||
// The SDK must keep sending `notifications/cancelled` for them.
|
||||
expect(forType(config)).toBe(false);
|
||||
expect(forType({ type: "sse", url: "http://server.example/sse" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the SDK's requestSignal to the browser-to-backend send fetch", async () => {
|
||||
const seenInits: (RequestInit | undefined)[] = [];
|
||||
let sse = createPushableSseStream();
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/mcp/connect")) {
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
sse = createPushableSseStream();
|
||||
return sse.response;
|
||||
}
|
||||
if (url.endsWith("/api/mcp/send")) {
|
||||
seenInits.push(init);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||
await transport.start();
|
||||
|
||||
const controller = new AbortController();
|
||||
await transport.send(
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
{ requestSignal: controller.signal },
|
||||
);
|
||||
expect(seenInits[0]?.signal).toBe(controller.signal);
|
||||
|
||||
// No signal supplied (stdio/SSE, where the SDK never creates one) must
|
||||
// leave the fetch unaborted rather than passing `undefined` through as a
|
||||
// deliberate value.
|
||||
await transport.send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/roots/list_changed",
|
||||
});
|
||||
expect(seenInits[1]).not.toHaveProperty("signal");
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("aborting the requestSignal rejects the in-flight send", async () => {
|
||||
let sse = createPushableSseStream();
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/mcp/connect")) {
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
sse = createPushableSseStream();
|
||||
return sse.response;
|
||||
}
|
||||
if (url.endsWith("/api/mcp/send")) {
|
||||
// The backend holds this route open for the whole call, so model it
|
||||
// as a fetch that only settles when the caller aborts.
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(new DOMException("Aborted", "AbortError")),
|
||||
);
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||
await transport.start();
|
||||
|
||||
const controller = new AbortController();
|
||||
const sent = transport.send(
|
||||
{ jsonrpc: "2.0", id: 7, method: "tools/call" },
|
||||
{ requestSignal: controller.signal },
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
// The backend never answers an aborted send, so the rejection has to come
|
||||
// from the fetch itself. `postSend` already cancels the SSE response wait
|
||||
// it registered for this id on that path (its catch does, unchanged by
|
||||
// #2140) — so the send surfaces the abort rather than the wait's timeout.
|
||||
await expect(sent).rejects.toThrow(/Aborted/);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1261,6 +1261,73 @@ describe("oauth.requestRefreshToken (#2068)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2144 — same inverted-default shape as `requestRefreshToken` above: on by
|
||||
// default, and only the opt-out is written, so an entry that never touched the
|
||||
// switch keeps a byte-stable round-trip.
|
||||
describe("oauthRevokeOnClear (RFC 7009)", () => {
|
||||
const baseSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
it("lifts an explicit false to settings.oauthRevokeOnClear", () => {
|
||||
expect(
|
||||
storedFieldsToInspectorSettings({
|
||||
oauth: { clientId: "cid", revokeOnClear: false },
|
||||
})?.oauthRevokeOnClear,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the setting unset when the field is absent (default: on)", () => {
|
||||
expect(
|
||||
storedFieldsToInspectorSettings({ oauth: { clientId: "cid" } })
|
||||
?.oauthRevokeOnClear,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the setting unset for an explicit true, which means the default", () => {
|
||||
expect(
|
||||
storedFieldsToInspectorSettings({
|
||||
oauth: { clientId: "cid", revokeOnClear: true },
|
||||
})?.oauthRevokeOnClear,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists the opt-out under oauth on disk", () => {
|
||||
expect(
|
||||
inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthRevokeOnClear: false,
|
||||
}).oauth?.revokeOnClear,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes nothing when the setting is on", () => {
|
||||
expect(
|
||||
inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthRevokeOnClear: true,
|
||||
}).oauth,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips the opt-out", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthRevokeOnClear: false,
|
||||
});
|
||||
expect(storedFieldsToInspectorSettings(stored)?.oauthRevokeOnClear).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthOnInsufficientScope (SEP-2350)", () => {
|
||||
const baseSettings = {
|
||||
headers: [],
|
||||
|
|
|
|||
272
clients/web/src/test/integration/auth/revocation-e2e.test.ts
Normal file
272
clients/web/src/test/integration/auth/revocation-e2e.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* RFC 7009 revocation against the real OAuth test server (#2144).
|
||||
*
|
||||
* The unit suite (`src/test/core/auth/revocation.test.ts`) pins what is *sent*;
|
||||
* this pins what the request actually *does*. The two halves that only a real
|
||||
* authorization server can show are both here: that the Inspector's request is
|
||||
* accepted at all (its client authentication and form encoding are right), and
|
||||
* that §2.1's grant linkage holds — one request naming the refresh token kills
|
||||
* the access token issued alongside it, which is the whole reason the Inspector
|
||||
* sends one request rather than two.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
TestServerHttp,
|
||||
createOAuthTestServerConfig,
|
||||
getDefaultServerConfig,
|
||||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
// NOT `BrowserOAuthStorage`: the `integration` project runs in the node
|
||||
// environment, where `sessionStorage` does not exist. Node 22 gates Web Storage
|
||||
// behind a flag and newer Node exposes it by default, so a browser-backed store
|
||||
// passes on a developer's machine and fails in CI — which is exactly what
|
||||
// happened here.
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
||||
|
||||
const CLIENT_ID = "test-2144-revocation";
|
||||
const CLIENT_SECRET = "test-2144-secret";
|
||||
const REDIRECT_URL = "http://localhost:3000/oauth/callback";
|
||||
/** A second registered client, used to prove tokens are not cross-revocable. */
|
||||
const OTHER_CLIENT_ID = "test-2144-other";
|
||||
const OTHER_CLIENT_SECRET = "test-2144-other-secret";
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
describe("OAuth token revocation (RFC 7009)", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
let serverUrl = "";
|
||||
let storageDir = "";
|
||||
let metadata: OAuthMetadata;
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpServer = new TestServerHttp({
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
redirectUris: [REDIRECT_URL],
|
||||
},
|
||||
{
|
||||
clientId: OTHER_CLIENT_ID,
|
||||
clientSecret: OTHER_CLIENT_SECRET,
|
||||
redirectUris: [REDIRECT_URL],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const port = await mcpServer.start();
|
||||
serverUrl = `http://localhost:${port}`;
|
||||
storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2144-"));
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
metadata = (await (
|
||||
await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
|
||||
).json()) as OAuthMetadata;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mcpServer?.stop();
|
||||
mcpServer = null;
|
||||
if (storageDir) {
|
||||
rmSync(storageDir, { recursive: true, force: true });
|
||||
storageDir = "";
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
/** Run a real authorization-code exchange and return the issued tokens. */
|
||||
async function authorize(): Promise<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}> {
|
||||
const verifier = base64Url(randomBytes(32));
|
||||
const challenge = base64Url(createHash("sha256").update(verifier).digest());
|
||||
|
||||
const authorizeResponse = await fetch(`${serverUrl}/oauth/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
redirect: "manual",
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URL,
|
||||
response_type: "code",
|
||||
scope: "mcp",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}),
|
||||
});
|
||||
const location = authorizeResponse.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
const code = new URL(location!).searchParams.get("code");
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const tokenResponse = await fetch(`${serverUrl}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code!,
|
||||
redirect_uri: REDIRECT_URL,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
return (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the MCP endpoint still accepts this bearer token. */
|
||||
async function tokenAccepted(accessToken: string): Promise<boolean> {
|
||||
const response = await fetch(`${serverUrl}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return response.status !== 401;
|
||||
}
|
||||
|
||||
async function seededStorage(tokens: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
}): Promise<NodeOAuthStorage> {
|
||||
const storage = new NodeOAuthStorage(join(storageDir, "oauth.json"));
|
||||
await storage.clear(serverUrl);
|
||||
// Issuer-bound and matching the discovered metadata: an unkeyed grant
|
||||
// records no authorization server and is deliberately refused.
|
||||
await storage.saveTokens(
|
||||
serverUrl,
|
||||
{ token_type: "Bearer", ...tokens },
|
||||
{ issuer: metadata.issuer },
|
||||
);
|
||||
await storage.saveServerMetadata(serverUrl, metadata);
|
||||
// Preconfigured, so it goes in the preregistered slot — the same one a
|
||||
// server with `oauth.clientId` uses. That is the slot the revocation path
|
||||
// must read first, or a confidential client sends no authentication at all.
|
||||
await storage.savePreregisteredClientInformation(serverUrl, {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
});
|
||||
return storage;
|
||||
}
|
||||
|
||||
// The fixture enforces RFC 7009 §2.1 client authentication, so this is what
|
||||
// makes the passing cases below mean something: a request with the wrong
|
||||
// credentials is refused, and the endpoint is not simply answering 200 to
|
||||
// anything.
|
||||
it("refuses a revocation request with the wrong client secret", async () => {
|
||||
const response = await fetch(`${serverUrl}/oauth/revoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:wrong`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({ token: "anything" }),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("refuses a revocation request naming no client at all", async () => {
|
||||
const response = await fetch(`${serverUrl}/oauth/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token: "anything" }),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// RFC 7009 §2.1: only the client a token was issued to may revoke it. The
|
||||
// response stays 200 (§2.2 — it must not tell one client whether another's
|
||||
// token exists), so the assertion is that the token still works.
|
||||
it("does not revoke a token belonging to a different client", async () => {
|
||||
const tokens = await authorize();
|
||||
const response = await fetch(`${serverUrl}/oauth/revoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${OTHER_CLIENT_ID}:${OTHER_CLIENT_SECRET}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: tokens.refresh_token,
|
||||
token_type_hint: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The caller's real sequence — take-and-clear atomically, then send — so this
|
||||
* exercises the ordering the product uses rather than a convenience wrapper.
|
||||
*/
|
||||
async function clearAndRevoke(
|
||||
storage: NodeOAuthStorage,
|
||||
): Promise<TokenRevocationOutcome> {
|
||||
const plan = await clearAndPlanRevocation({ serverUrl, storage });
|
||||
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
||||
}
|
||||
|
||||
it("advertises a revocation endpoint", () => {
|
||||
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
|
||||
});
|
||||
|
||||
// The failure this feature exists to fix: without the request, the token is
|
||||
// still accepted after the Inspector has forgotten it.
|
||||
it("revokes the whole grant from the stored refresh token", async () => {
|
||||
const tokens = await authorize();
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||
|
||||
const outcome = await clearAndRevoke(await seededStorage(tokens));
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
});
|
||||
// RFC 7009 §2.1 — the access token issued under the same grant dies too,
|
||||
// which is why one request is enough.
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(false);
|
||||
});
|
||||
|
||||
it("revokes an access token when no refresh token was issued", async () => {
|
||||
const tokens = await authorize();
|
||||
const outcome = await clearAndRevoke(
|
||||
await seededStorage({ access_token: tokens.access_token }),
|
||||
);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "access_token",
|
||||
});
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(false);
|
||||
});
|
||||
|
||||
// RFC 7009 §2.2: an unknown token is a success, so revoking a grant the
|
||||
// server has already expired must not be reported as a failure.
|
||||
it("treats an already-unknown token as revoked", async () => {
|
||||
const storage = await seededStorage({ access_token: "never-issued" });
|
||||
await expect(clearAndRevoke(storage)).resolves.toMatchObject({
|
||||
status: "revoked",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
createMrtrEdgeCaseTool,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
type ToolDefinition,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
||||
import type {
|
||||
|
|
@ -437,6 +438,146 @@ describe("modern-era negotiation (2026-07-28)", () => {
|
|||
expect(result.result!.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
// #2140: on the 2026-07-28 era, closing the request's own response stream IS
|
||||
// the cancellation signal for Streamable HTTP; `notifications/cancelled` is
|
||||
// the stdio mechanism and the spec says it is neither required nor expected
|
||||
// here. The SDK implements that fork off `transport.hasPerRequestStream` —
|
||||
// but every Inspector connection is wrapped in `MessageTrackingTransport`,
|
||||
// which was not forwarding it, so all three clients POSTed the notification
|
||||
// and a spec-compliant server acknowledged it `202` and kept running.
|
||||
//
|
||||
// This asserts at the far end, on the server's own request signal: nothing on
|
||||
// the client side distinguishes a cancel that reached the server from one
|
||||
// that was dropped, which is exactly how this shipped.
|
||||
it("cancels by aborting the request's stream, which the server observes", async () => {
|
||||
let sawAbort!: (value: boolean) => void;
|
||||
const aborted = new Promise<boolean>((resolve) => {
|
||||
sawAbort = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
|
||||
const slowServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("cancel-era-test", "1.0.0"),
|
||||
tools: [
|
||||
{
|
||||
name: "slow_task",
|
||||
description: "Runs until the client cancels it",
|
||||
handler: async (_params, _context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => sawAbort(true), {
|
||||
once: true,
|
||||
});
|
||||
started();
|
||||
// Never settles on its own, so a regression is a timeout rather
|
||||
// than a pass.
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
modern: {},
|
||||
});
|
||||
await slowServer.start();
|
||||
server = slowServer;
|
||||
|
||||
const connected = await connectWithEra(slowServer.url, "modern");
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
|
||||
// Hold the rejection immediately so it is never seen as unhandled while we
|
||||
// wait on the server side.
|
||||
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||
await running;
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
await expect(aborted).resolves.toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
// The test above builds its own never-returning tool, which is the sharpest
|
||||
// way to assert on the server's abort signal but says nothing about the
|
||||
// artifacts a human actually uses. This one drives the documented showcase
|
||||
// through the SAME path they would — the checked-in config, resolved through
|
||||
// the preset registry — so a misspelt preset name, a config naming a dead
|
||||
// preset, or a regression in the `slow_task` handler's own abort loop fails
|
||||
// here rather than only when someone runs the repro by hand.
|
||||
//
|
||||
// It observes the handler's own return value, not the client's view. Counting
|
||||
// progress notifications would be a false negative: the SDK drops a cancelled
|
||||
// request's progress handler locally, so the client stops seeing ticks the
|
||||
// moment it cancels whether or not the server ever stopped working — the very
|
||||
// silence this bug hid behind.
|
||||
it("stops the showcase tool's work on cancel (slow_task, via the showcase config)", async () => {
|
||||
const config = loadConfig(
|
||||
join(repoRoot, "test-servers/configs/cancellation-modern-http.json"),
|
||||
);
|
||||
expect(config.tools).toContainEqual({ preset: "slow_task" });
|
||||
|
||||
const resolved = resolveConfig(config);
|
||||
expect(resolved.tools?.map((tool) => tool.name)).toEqual([
|
||||
"slow_task",
|
||||
"echo",
|
||||
]);
|
||||
|
||||
// Wrap the resolved preset rather than replacing it, so what runs is the
|
||||
// real registered handler and only its outcome is observed. `tools` is a
|
||||
// union with the task-tool shape, whose handler has a different signature,
|
||||
// so narrow to the plain one this preset actually is.
|
||||
const preset = resolved.tools!.find(
|
||||
(tool): tool is ToolDefinition => tool.name === "slow_task",
|
||||
)!;
|
||||
let reportOutcome!: (text: string) => void;
|
||||
const outcome = new Promise<string>((resolve) => {
|
||||
reportOutcome = resolve;
|
||||
});
|
||||
let firstTick!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
firstTick = resolve;
|
||||
});
|
||||
|
||||
const started = createTestServerHttp({
|
||||
...resolved,
|
||||
tools: resolved.tools!.map((tool) =>
|
||||
tool.name === "slow_task"
|
||||
? {
|
||||
...preset,
|
||||
handler: async (params, context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => firstTick(), {
|
||||
once: true,
|
||||
});
|
||||
const result = await preset.handler(params, context, extra);
|
||||
reportOutcome(JSON.stringify(result));
|
||||
return result;
|
||||
},
|
||||
}
|
||||
: tool,
|
||||
),
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
const connected = await connectWithEra(started.url, "modern");
|
||||
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
// Hold the rejection immediately so it is never seen as unhandled.
|
||||
const settled = connected
|
||||
.callTool(tool!, { seconds: 30 })
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
// Let it get past its first tick, then cancel.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
await running;
|
||||
|
||||
// The handler must return promptly, saying it was cancelled — not run on to
|
||||
// its 30th second, which is what it did before the fix.
|
||||
await expect(outcome).resolves.toContain("cancelled after");
|
||||
}, 30_000);
|
||||
|
||||
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
|
||||
const started = await startMrtrServer(createMrtrTool());
|
||||
const connected = await connectWithEra(started.url, "modern");
|
||||
|
|
|
|||
|
|
@ -5447,8 +5447,12 @@ describe("InspectorClient", () => {
|
|||
expect(c.getOAuthFlowStep()).toBeUndefined();
|
||||
expect(c.getOAuthFlowState()).toBeUndefined();
|
||||
await expect(c.getOAuthState()).resolves.toBeUndefined();
|
||||
// clearOAuthTokens is a no-op when there is no manager
|
||||
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
|
||||
// clearOAuthTokens is a no-op when there is no manager; it still
|
||||
// reports an outcome so callers have one shape to read (#2144).
|
||||
await expect(c.clearOAuthTokens()).resolves.toEqual({
|
||||
status: "skipped",
|
||||
reason: "no_tokens",
|
||||
});
|
||||
});
|
||||
|
||||
it("setOAuthConfig throws when oauthManager is unset", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* #2140 — the Cancel button must send the transport's cancellation signal.
|
||||
*
|
||||
* On a 2026-07-28 Streamable HTTP connection the spec makes *closing the
|
||||
* request's SSE response stream* the cancellation signal, and says a
|
||||
* `notifications/cancelled` is neither required nor expected. The SDK
|
||||
* implements that fork itself: `Protocol.request` aborts a per-request
|
||||
* `AbortController` — handed to the transport as
|
||||
* `TransportSendOptions.requestSignal` — when the connection is modern **and**
|
||||
* the transport advertises `hasPerRequestStream`; otherwise it POSTs the
|
||||
* notification.
|
||||
*
|
||||
* The web client lost it at the proxy boundary. The real upstream transport
|
||||
* lives on the Node backend, and the browser's `RemoteClientTransport` neither
|
||||
* advertised the flag nor applied a `requestSignal` to its
|
||||
* `POST /api/mcp/send` — so every web cancel took the stdio branch, the server
|
||||
* answered the notification `202 Accepted` and dropped it, and the tool kept
|
||||
* running to completion. Only a manual disconnect actually cancelled anything.
|
||||
*
|
||||
* This drives the whole chain the browser drives — InspectorClient ->
|
||||
* RemoteClientTransport -> the Hono backend -> a real upstream Streamable HTTP
|
||||
* transport -> a modern test server — and asserts at the far end, on the
|
||||
* server's own request abort signal. Nothing shallower reaches it: each of the
|
||||
* three hops can drop the signal on its own, and a unit test of any one of them
|
||||
* passes while the chain stays broken.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
|
||||
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
createTestServerInfo,
|
||||
type TestServerHttp,
|
||||
type ToolDefinition,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool that never returns on its own. It reports when it started, and whether
|
||||
* the server-side request signal — which the SDK aborts when the client closes
|
||||
* this request's response stream — ever fired.
|
||||
*/
|
||||
function createSlowTool(): {
|
||||
tool: ToolDefinition;
|
||||
started: Promise<void>;
|
||||
aborted: Promise<boolean>;
|
||||
} {
|
||||
const started = deferred<void>();
|
||||
const aborted = deferred<boolean>();
|
||||
const tool: ToolDefinition = {
|
||||
name: "slow_task",
|
||||
description: "Runs until the client cancels it",
|
||||
handler: async (_params, _context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => aborted.resolve(true), {
|
||||
once: true,
|
||||
});
|
||||
started.resolve();
|
||||
// Never settles: this call ends by cancellation or not at all, so a
|
||||
// regression shows up as the test's own timeout rather than as a pass.
|
||||
return new Promise(() => {});
|
||||
},
|
||||
};
|
||||
return { tool, started: started.promise, aborted: aborted.promise };
|
||||
}
|
||||
|
||||
async function startRemoteBackend(): Promise<{
|
||||
baseUrl: string;
|
||||
server: ServerType;
|
||||
authToken: string;
|
||||
}> {
|
||||
const { app, authToken } = createRemoteApp({
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve(
|
||||
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
|
||||
(info) => {
|
||||
const port =
|
||||
info && typeof info === "object" && "port" in info
|
||||
? (info as { port: number }).port
|
||||
: 0;
|
||||
resolve({ baseUrl: `http://127.0.0.1:${port}`, server, authToken });
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("web-client cancellation over a per-request stream (#2140)", () => {
|
||||
let client: InspectorClient | null = null;
|
||||
let backend: ServerType | null = null;
|
||||
let upstream: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors.
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (backend) {
|
||||
const server = backend;
|
||||
backend = null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
if (upstream) {
|
||||
try {
|
||||
await upstream.stop();
|
||||
} catch {
|
||||
// Ignore stop errors.
|
||||
}
|
||||
upstream = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts the upstream request stream, which the server observes as cancellation", async () => {
|
||||
const slow = createSlowTool();
|
||||
upstream = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("cancel-test", "1.0.0"),
|
||||
tools: [slow.tool],
|
||||
modern: {},
|
||||
});
|
||||
await upstream.start();
|
||||
|
||||
const { baseUrl, server, authToken } = await startRemoteBackend();
|
||||
backend = server;
|
||||
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url: upstream.url },
|
||||
{
|
||||
environment: {
|
||||
transport: createRemoteTransport({ baseUrl, authToken }),
|
||||
},
|
||||
versionNegotiation: eraToVersionNegotiation("modern"),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
expect(connected.getProtocolEra()).toBe("modern");
|
||||
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
// Hold the rejection from the first turn so the runner never sees it as
|
||||
// unhandled while we wait on the server side.
|
||||
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||
|
||||
await slow.started;
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
|
||||
// The user-facing contract is unchanged: a deliberate cancel still surfaces
|
||||
// as ToolCallCancelledError, whichever wire signal carried it.
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
|
||||
// The assertion that matters. Before the fix the server saw only a
|
||||
// `notifications/cancelled` it was free to ignore (and did), its request
|
||||
// signal never fired, and this promise never resolved.
|
||||
await expect(slow.aborted).resolves.toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("advertises the per-request stream only for streamable-http", () => {
|
||||
// The flag is what the SDK forks on, and the factory is where a server's
|
||||
// configured type reaches the transport. stdio and SSE multiplex every
|
||||
// request over one shared channel: there is no per-request stream to close,
|
||||
// and aborting anything would take the whole session down — so they must
|
||||
// keep the `notifications/cancelled` mechanism.
|
||||
const create = createRemoteTransport({ baseUrl: "http://unused.example" });
|
||||
expect(
|
||||
create({ type: "stdio", command: "echo", args: [] }, {}).transport
|
||||
.hasPerRequestStream,
|
||||
).toBe(false);
|
||||
expect(
|
||||
create({ type: "sse", url: "http://unused.example/sse" }, {}).transport
|
||||
.hasPerRequestStream,
|
||||
).toBe(false);
|
||||
expect(
|
||||
create({ type: "streamable-http", url: "http://unused.example/mcp" }, {})
|
||||
.transport.hasPerRequestStream,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -639,6 +639,11 @@ describe("server.ts supplemental coverage", () => {
|
|||
expect((await res.json()).error).toMatch(/oauthRequestRefreshToken/);
|
||||
});
|
||||
|
||||
it("rejects a non-boolean oauthRevokeOnClear (#2144)", async () => {
|
||||
const res = await postSettings({ ...base, oauthRevokeOnClear: "no" });
|
||||
expect((await res.json()).error).toMatch(/oauthRevokeOnClear/);
|
||||
});
|
||||
|
||||
it("rejects malformed roots", async () => {
|
||||
const res = await postSettings({ ...base, roots: [{ uri: 1 }] });
|
||||
expect((await res.json()).error).toMatch(/roots/);
|
||||
|
|
@ -708,6 +713,32 @@ describe("server.ts supplemental coverage", () => {
|
|||
expect(body.mcpServers.srv?.oauth?.requestRefreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2144 — same reasoning as the refresh-token pair above: a 200 only proves
|
||||
// the payload validated, not that the field survived the write-through.
|
||||
it("persists the revoke-on-clear opt-out through a save", async () => {
|
||||
expect(
|
||||
(await postSettings({ ...base, oauthRevokeOnClear: false })).status,
|
||||
).toBe(200);
|
||||
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, { oauth?: { revokeOnClear?: boolean } }>;
|
||||
};
|
||||
expect(body.mcpServers.srv?.oauth?.revokeOnClear).toBe(false);
|
||||
});
|
||||
|
||||
it("writes no revoke-on-clear field when the setting is on", async () => {
|
||||
expect(
|
||||
(await postSettings({ ...base, oauthRevokeOnClear: true })).status,
|
||||
).toBe(200);
|
||||
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, { oauth?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(body.mcpServers.srv?.oauth?.revokeOnClear).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a fully-populated valid settings payload", async () => {
|
||||
const res = await postSettings({
|
||||
...base,
|
||||
|
|
@ -724,6 +755,7 @@ describe("server.ts supplemental coverage", () => {
|
|||
oauthScopes: "a b",
|
||||
enterpriseManaged: true,
|
||||
oauthRequestRefreshToken: false,
|
||||
oauthRevokeOnClear: false,
|
||||
roots: [{ uri: "file:///x", name: "x" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -892,6 +924,30 @@ describe("server.ts supplemental coverage", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// #2144 — same all-or-nothing rule for the revocation opt-out.
|
||||
it("drops oauth whose revokeOnClear is not a boolean", async () => {
|
||||
const h = await start({
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: {
|
||||
srv: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
oauth: { revokeOnClear: "no" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(body.mcpServers.srv).not.toHaveProperty("oauth");
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a well-formed requestRefreshToken opt-out on read (#2068)", async () => {
|
||||
const h = await start({
|
||||
seedConfig: JSON.stringify({
|
||||
|
|
|
|||
Loading…
Reference in a new issue