Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
10 changed files with 15921 additions and 1202 deletions
|
|
@ -137,9 +137,8 @@ export async function createClientMetadataServer(
|
|||
}
|
||||
|
||||
/**
|
||||
* Helper function to programmatically complete OAuth authorization
|
||||
* Makes HTTP GET request to authorization URL and extracts authorization code
|
||||
* The test server's authorization endpoint auto-approves and redirects with code
|
||||
* Programmatically complete OAuth against the local composable test AS.
|
||||
* GET shows an HTML consent page; approve via POST (same as CLI test helper).
|
||||
*
|
||||
* @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event
|
||||
* @returns Authorization code extracted from redirect URL
|
||||
|
|
@ -147,10 +146,23 @@ export async function createClientMetadataServer(
|
|||
export async function completeOAuthAuthorization(
|
||||
authorizationUrl: URL,
|
||||
): Promise<string> {
|
||||
const response = await fetch(authorizationUrl.toString(), {
|
||||
let response = await fetch(authorizationUrl.toString(), {
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const body = new URLSearchParams(authorizationUrl.searchParams);
|
||||
response = await fetch(
|
||||
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
redirect: "manual",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status !== 302 && response.status !== 301) {
|
||||
throw new Error(
|
||||
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
|
||||
|
|
@ -162,7 +174,7 @@ export async function completeOAuthAuthorization(
|
|||
throw new Error("No Location header in redirect response");
|
||||
}
|
||||
|
||||
const redirectUrlObj = new URL(redirectUrl);
|
||||
const redirectUrlObj = new URL(redirectUrl, authorizationUrl.origin);
|
||||
const code = redirectUrlObj.searchParams.get("code");
|
||||
if (!code) {
|
||||
throw new Error(`No authorization code in redirect URL: ${redirectUrl}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* Mid-session OAuth recovery over direct (TUI/CLI) transport.
|
||||
* Uses createAuthChallengeInterceptFetch + handleAuthChallenge on InspectorClient.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
TestServerHttp,
|
||||
waitForOAuthWellKnown,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
clearOAuthTestData,
|
||||
invalidateAccessToken,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
completeOAuthAuthorization,
|
||||
} from "../helpers/oauth-client-fixtures.js";
|
||||
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
const oauthTestStatePath = join(
|
||||
tmpdir(),
|
||||
`mcp-oauth-${process.pid}-direct-mid-session-e2e.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
describe("InspectorClient direct mid-session OAuth", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
const testRedirectUrl = "http://localhost:3000/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (mcpServer) {
|
||||
await mcpServer.stop();
|
||||
mcpServer = null;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(oauthTestStatePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from invalidated access token after connect via silent refresh without disconnect", async () => {
|
||||
const staticClientId = "test-direct-mid-session";
|
||||
const staticClientSecret = "test-secret-direct-mid-session";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens?.refresh_token).toBeDefined();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools.length).toBeGreaterThan(0);
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => {
|
||||
const staticClientId = "test-direct-step-up";
|
||||
const staticClientSecret = "test-secret-direct-step-up";
|
||||
|
||||
const baseConfig = getDefaultServerConfig();
|
||||
const serverConfig = {
|
||||
...baseConfig,
|
||||
serverType: "streamable-http" as const,
|
||||
tools: baseConfig.tools!.map((tool) =>
|
||||
tool.name === "get_temp"
|
||||
? { ...tool, requiredScopes: ["weather:read"] }
|
||||
: tool,
|
||||
),
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp");
|
||||
expect(getTempTool).toBeDefined();
|
||||
|
||||
let recovery: AuthRecoveryRequiredError | undefined;
|
||||
try {
|
||||
await client.callTool(getTempTool!, { city: "NYC", units: "C" });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRecoveryRequiredError) {
|
||||
recovery = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
|
||||
expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain(
|
||||
"weather:read",
|
||||
);
|
||||
|
||||
const stepUpCode = await completeOAuthAuthorization(
|
||||
recovery!.authorizationUrl,
|
||||
);
|
||||
await client.completeOAuthFlow(stepUpCode);
|
||||
|
||||
const result = await client.callTool(getTempTool!, {
|
||||
city: "NYC",
|
||||
units: "C",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* Mid-session OAuth recovery over the web remote transport.
|
||||
* Verifies inline auth_challenge on /api/mcp/send, silent refresh, auth-state push, and retry.
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import {
|
||||
TestServerHttp,
|
||||
waitForOAuthWellKnown,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
clearOAuthTestData,
|
||||
invalidateAccessToken,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
completeOAuthAuthorization,
|
||||
} from "../helpers/oauth-client-fixtures.js";
|
||||
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
const oauthTestStatePath = join(
|
||||
tmpdir(),
|
||||
`mcp-oauth-${process.pid}-remote-mid-session-e2e.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
async function startRemoteServer(port: number): 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, hostname: "127.0.0.1" },
|
||||
(info) => {
|
||||
const actualPort =
|
||||
info && typeof info === "object" && "port" in info
|
||||
? (info as { port: number }).port
|
||||
: port;
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${actualPort}`,
|
||||
server,
|
||||
authToken,
|
||||
});
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("InspectorClient remote mid-session OAuth", () => {
|
||||
let remoteServer: ServerType | null = null;
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
let remoteBaseUrl: string | undefined;
|
||||
let remoteAuthToken: string | undefined;
|
||||
const testRedirectUrl = "http://localhost:3000/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (mcpServer) {
|
||||
await mcpServer.stop();
|
||||
mcpServer = null;
|
||||
}
|
||||
if (remoteServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
remoteServer!.close(() => resolve());
|
||||
});
|
||||
remoteServer = null;
|
||||
}
|
||||
remoteBaseUrl = undefined;
|
||||
remoteAuthToken = undefined;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(oauthTestStatePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
async function setupRemoteServer(): Promise<void> {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "inspector-remote-mid-session-"));
|
||||
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||
remoteBaseUrl = baseUrl;
|
||||
remoteAuthToken = authToken;
|
||||
remoteServer = server;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
it("recovers from invalidated access token after connect via silent refresh and auth-state push", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-mid-session";
|
||||
const staticClientSecret = "test-secret-mid-session";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens?.refresh_token).toBeDefined();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools.length).toBeGreaterThan(0);
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
}, 15_000);
|
||||
|
||||
it("recovers on reconnect when stored access token was invalidated before connect", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-reconnect-mid-session";
|
||||
const staticClientSecret = "test-secret-reconnect-mid-session";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
await client.connect();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("step-up re-auth after insufficient_scope lets scoped tool succeed", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-step-up";
|
||||
const staticClientSecret = "test-secret-step-up";
|
||||
|
||||
const baseConfig = getDefaultServerConfig();
|
||||
const serverConfig = {
|
||||
...baseConfig,
|
||||
tools: baseConfig.tools!.map((tool) =>
|
||||
tool.name === "get_temp"
|
||||
? { ...tool, requiredScopes: ["weather:read"] }
|
||||
: tool,
|
||||
),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
scopesSupported: ["mcp", "tools:read", "weather:read"],
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
scope: "mcp tools:read",
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const echoTool = toolsResult.tools.find((t) => t.name === "echo");
|
||||
const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp");
|
||||
expect(echoTool).toBeDefined();
|
||||
expect(getTempTool).toBeDefined();
|
||||
|
||||
await client.callTool(echoTool!, { message: "hello" });
|
||||
|
||||
const remoteSessionId = client.getRemoteBackendSessionId();
|
||||
expect(remoteSessionId).toBeDefined();
|
||||
|
||||
let recovery: AuthRecoveryRequiredError | undefined;
|
||||
try {
|
||||
await client.callTool(getTempTool!, { city: "NYC", units: "C" });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRecoveryRequiredError) {
|
||||
recovery = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
|
||||
|
||||
const mcpServerUrl = `${serverUrl}/mcp`;
|
||||
const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl);
|
||||
expect(scopeBeforeStepUp).toContain("mcp");
|
||||
expect(scopeBeforeStepUp).not.toContain("weather:read");
|
||||
|
||||
expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain(
|
||||
"weather:read",
|
||||
);
|
||||
|
||||
const stepUpCode = await completeOAuthAuthorization(
|
||||
recovery!.authorizationUrl,
|
||||
);
|
||||
|
||||
// Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth
|
||||
// storage, reattach to the live remote backend session.
|
||||
const callbackClient = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId });
|
||||
|
||||
expect(callbackClient.getStatus()).toBe("connected");
|
||||
|
||||
const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl);
|
||||
expect(scopeAfterStepUp).toContain("weather:read");
|
||||
expect(scopeAfterStepUp).toContain("mcp");
|
||||
|
||||
const callbackTools = await callbackClient.listTools();
|
||||
const callbackGetTemp = callbackTools.tools.find(
|
||||
(t) => t.name === "get_temp",
|
||||
);
|
||||
expect(callbackGetTemp).toBeDefined();
|
||||
|
||||
const result = await callbackClient.callTool(callbackGetTemp!, {
|
||||
city: "NYC",
|
||||
units: "C",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const content = result.result?.content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content?.[0]).toHaveProperty("type", "text");
|
||||
expect("text" in content![0] && content![0].text).toContain("25");
|
||||
|
||||
await callbackClient.disconnect();
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("resumeAfterOAuth falls back to connect when remote session is dead", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-resume-fallback";
|
||||
const staticClientSecret = "test-secret-resume-fallback";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: false,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const initialCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(initialCode);
|
||||
await client.connect();
|
||||
|
||||
const mcpServerUrl = `${serverUrl}/mcp`;
|
||||
await oauthConfig.storage.clearTokens(mcpServerUrl);
|
||||
const reauthUrl = await client.authenticate();
|
||||
if (!reauthUrl) throw new Error("Expected reauth URL");
|
||||
const code = await completeOAuthAuthorization(reauthUrl);
|
||||
|
||||
const callbackClient = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
const connectSpy = vi.spyOn(callbackClient, "connect");
|
||||
await callbackClient.resumeAfterOAuth(code, {
|
||||
remoteSessionId: "dead-remote-session-id",
|
||||
});
|
||||
expect(connectSpy).toHaveBeenCalled();
|
||||
connectSpy.mockRestore();
|
||||
|
||||
expect(callbackClient.getStatus()).toBe("connected");
|
||||
const toolsResult = await callbackClient.listTools();
|
||||
expect(toolsResult.tools.length).toBeGreaterThan(0);
|
||||
|
||||
await callbackClient.disconnect();
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("dispatches authChallengeInteractive for ambient interactive recovery", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-ambient-interactive";
|
||||
const staticClientSecret = "test-secret-ambient-interactive";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: false,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
const interactive = vi.fn();
|
||||
client.addEventListener("authChallengeInteractive", interactive);
|
||||
|
||||
await client.handleAmbientAuthChallenge({ reason: "token_expired" });
|
||||
|
||||
expect(interactive).toHaveBeenCalledOnce();
|
||||
expect(interactive.mock.calls[0]![0].detail.challenge.reason).toBe(
|
||||
"token_expired",
|
||||
);
|
||||
expect(
|
||||
interactive.mock.calls[0]![0].detail.authorizationUrl,
|
||||
).toBeInstanceOf(URL);
|
||||
|
||||
await client.disconnect();
|
||||
}, 15_000);
|
||||
|
||||
it("recovers from ambient auth notification after idle token invalidation", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-remote-ambient-auth";
|
||||
const staticClientSecret = "test-secret-ambient-auth";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
fetch: createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
}),
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
invalidateAccessToken(tokens!.access_token);
|
||||
|
||||
const recovered = vi.fn();
|
||||
client.addEventListener("authChallengeRecovered", recovered);
|
||||
|
||||
await client.handleAmbientAuthChallenge({ reason: "token_expired" });
|
||||
|
||||
expect(recovered).toHaveBeenCalled();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools.length).toBeGreaterThan(0);
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
}, 15_000);
|
||||
});
|
||||
|
|
@ -341,6 +341,33 @@ describe("InspectorClient", () => {
|
|||
// (the same end state any other handshake failure would produce).
|
||||
expect(client.getStatus()).toBe("error");
|
||||
});
|
||||
|
||||
it("holds status at connecting when connect fails with a recoverable 401", async () => {
|
||||
const unauthorizedTransport = {
|
||||
start: async () => {
|
||||
const err = new Error("Unauthorized") as Error & { status?: number };
|
||||
err.status = 401;
|
||||
throw err;
|
||||
},
|
||||
send: async () => {},
|
||||
close: async () => {},
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
onmessage: undefined,
|
||||
sessionId: undefined,
|
||||
};
|
||||
const fakeFactory = () => ({
|
||||
transport:
|
||||
unauthorizedTransport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "http://localhost:8081/mcp" },
|
||||
{ environment: { transport: fakeFactory } },
|
||||
);
|
||||
|
||||
await expect(client.connect()).rejects.toMatchObject({ status: 401 });
|
||||
expect(client.getStatus()).toBe("connecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message Tracking", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Supplemental coverage for createRemoteApp's /api/mcp/* routes
|
||||
* (core/mcp/remote/node/server.ts), targeting branches the broader e2e
|
||||
* suites (transport.test.ts, connect-crash.test.ts) don't reach:
|
||||
*
|
||||
* - POST /api/mcp/connect: connect-time AuthChallengeError (401 upstream)
|
||||
* - POST /api/mcp/send: transport-dead short-circuit
|
||||
* - POST /api/mcp/disconnect: unknown sessionId (no-op, still 200)
|
||||
* - POST /api/mcp/auth-state: every guard branch (missing fields, unknown
|
||||
* session, dead transport, no OAuth provider on the session, success)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
interface Harness {
|
||||
baseUrl: string;
|
||||
server: ServerType;
|
||||
}
|
||||
|
||||
async function start(): Promise<Harness> {
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
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 });
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(h: Harness): Promise<void> {
|
||||
await new Promise<void>((resolve) => h.server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function connect(
|
||||
h: Harness,
|
||||
config: MCPServerConfig,
|
||||
authState?: { oauthTokens: { access_token: string; token_type: string } },
|
||||
): Promise<Response> {
|
||||
return fetch(`${h.baseUrl}/api/mcp/connect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config, ...(authState && { authState }) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** A raw HTTP server that returns 401 for every request (no MCP semantics). */
|
||||
async function startUnauthorizedUpstream(): Promise<{
|
||||
url: string;
|
||||
server: Server;
|
||||
}> {
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "unauthorized" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
||||
return { url: `http://127.0.0.1:${port}/mcp`, server };
|
||||
}
|
||||
|
||||
/** Connect a stdio session whose process crashes almost immediately, then
|
||||
* give the onclose handler time to mark the session's transport dead. */
|
||||
async function connectDeadSession(h: Harness): Promise<string> {
|
||||
const config: MCPServerConfig = {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stderr.write('dying\\n'); process.exit(1);"],
|
||||
};
|
||||
const res = await connect(h, config);
|
||||
expect(res.status).toBe(200);
|
||||
const { sessionId } = (await res.json()) as { sessionId: string };
|
||||
// Give the subprocess time to exit and the transport's onclose handler
|
||||
// to mark the session dead (mirrors connect-crash.test.ts's technique).
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
describe("server.ts /api/mcp/* branch coverage", () => {
|
||||
let h: Harness;
|
||||
beforeEach(async () => {
|
||||
h = await start();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stop(h);
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/connect", () => {
|
||||
it("returns 500 (not 401) when the upstream SSE connection is rejected for a non-auth reason", async () => {
|
||||
// The SDK's SSEClientTransport wraps every connection failure — including
|
||||
// a 401 — in its own SseError, so `err instanceof AuthChallengeError` at
|
||||
// the connect catch site is unreachable via the real SSE/streamable-http
|
||||
// SDK transports today (streamable-http's start() makes no network call
|
||||
// at all; SSE's start() discards the thrown error's subclass). This test
|
||||
// instead pins the *reachable* generic-failure branch: a non-401 refusal
|
||||
// still surfaces as a 500 with the wrapped message, not a misleading 401.
|
||||
const upstream = await startUnauthorizedUpstream();
|
||||
try {
|
||||
const res = await connect(h, {
|
||||
type: "sse",
|
||||
url: upstream.url,
|
||||
});
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/Failed to start transport/);
|
||||
} finally {
|
||||
await new Promise<void>((r) => upstream.server.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/send", () => {
|
||||
it("short-circuits with a transport_error when the session's transport is already dead", async () => {
|
||||
const sessionId = await connectDeadSession(h);
|
||||
const res = await fetch(`${h.baseUrl}/api/mcp/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
kind?: string;
|
||||
error?: string;
|
||||
};
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.kind).toBe("transport_error");
|
||||
expect(typeof body.error).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/disconnect", () => {
|
||||
it("returns ok:true as a no-op when the sessionId is unknown", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "does-not-exist" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mcp/auth-state", () => {
|
||||
async function postAuthState(body: unknown): Promise<Response> {
|
||||
return fetch(`${h.baseUrl}/api/mcp/auth-state`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("returns 400 when sessionId is missing", async () => {
|
||||
const res = await postAuthState({
|
||||
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(
|
||||
/Missing sessionId or authState/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when authState is missing", async () => {
|
||||
const res = await postAuthState({ sessionId: "some-session" });
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(
|
||||
/Missing sessionId or authState/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/Invalid JSON body/);
|
||||
});
|
||||
|
||||
it("returns 404 when the session is not found", async () => {
|
||||
const res = await postAuthState({
|
||||
sessionId: "unknown-session-id",
|
||||
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
expect((await res.json()).error).toMatch(/Session not found/);
|
||||
});
|
||||
|
||||
it("returns a transport_error when the session's transport is already dead", async () => {
|
||||
const sessionId = await connectDeadSession(h);
|
||||
const res = await postAuthState({
|
||||
sessionId,
|
||||
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; kind?: string };
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.kind).toBe("transport_error");
|
||||
});
|
||||
|
||||
it("returns 400 when the session has no OAuth auth provider (connected without authState)", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const res = await connect(h, { type: "stdio", command, args });
|
||||
expect(res.status).toBe(200);
|
||||
const { sessionId } = (await res.json()) as { sessionId: string };
|
||||
|
||||
const authRes = await postAuthState({
|
||||
sessionId,
|
||||
authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } },
|
||||
});
|
||||
expect(authRes.status).toBe(400);
|
||||
expect((await authRes.json()).error).toMatch(
|
||||
/Session has no OAuth auth provider/,
|
||||
);
|
||||
|
||||
await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ok:true and hot-swaps the token when the session has an OAuth auth provider", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const res = await connect(
|
||||
h,
|
||||
{ type: "stdio", command, args },
|
||||
{ oauthTokens: { access_token: "initial", token_type: "Bearer" } },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { sessionId } = (await res.json()) as { sessionId: string };
|
||||
|
||||
const authRes = await postAuthState({
|
||||
sessionId,
|
||||
authState: {
|
||||
oauthTokens: { access_token: "rotated", token_type: "Bearer" },
|
||||
},
|
||||
});
|
||||
expect(authRes.status).toBe(200);
|
||||
expect((await authRes.json()).ok).toBe(true);
|
||||
|
||||
await fetch(`${h.baseUrl}/api/mcp/disconnect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
|
||||
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
||||
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
function makeFetchEntry(
|
||||
overrides: Partial<FetchRequestEntryBase> = {},
|
||||
|
|
@ -173,4 +174,170 @@ describe("RemoteSession", () => {
|
|||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("waitForRequestResponse resolves when a matching JSON-RPC response arrives", async () => {
|
||||
const session = new RemoteSession("s-wait");
|
||||
const wait = session.waitForRequestResponse(42);
|
||||
session.onMessage({ jsonrpc: "2.0", id: 42, result: { tools: [] } });
|
||||
await expect(wait).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("endSend is a no-op when no send is active", () => {
|
||||
const session = new RemoteSession("s-endsend-noop");
|
||||
expect(session.hasActiveSend()).toBe(false);
|
||||
// No matching beginSend() — activeSendCount is already 0.
|
||||
session.endSend();
|
||||
expect(session.hasActiveSend()).toBe(false);
|
||||
});
|
||||
|
||||
it("waitForRequestResponse with timeoutMs=0 never schedules a timer and still resolves", async () => {
|
||||
const session = new RemoteSession("s-no-timeout");
|
||||
const wait = session.waitForRequestResponse(7, 0);
|
||||
session.onMessage({ jsonrpc: "2.0", id: 7, result: {} });
|
||||
await expect(wait).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancelRequestWait rejects a timeoutMs=0 wait without a timer to clear", async () => {
|
||||
const session = new RemoteSession("s-no-timeout-cancel");
|
||||
const wait = session.waitForRequestResponse(8, 0);
|
||||
const rejection = expect(wait).rejects.toThrow(/cancelled/);
|
||||
session.cancelRequestWait(8);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("handleTransportAuthError rejects active request waits during send", async () => {
|
||||
const session = new RemoteSession("s-auth");
|
||||
session.beginSend();
|
||||
const wait = session.waitForRequestResponse(1);
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
await expect(wait).rejects.toBe(err);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("handleTransportAuthError pushes ambient auth when no send is active", () => {
|
||||
const session = new RemoteSession("s-ambient");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not push SSE auth while a send is active (command path owns delivery)", () => {
|
||||
const session = new RemoteSession("s-active");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("does not duplicate on SSE until the HTTP echo suppress window expires", () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new RemoteSession("s-echo");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
session.endSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.beginSend();
|
||||
session.endSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
vi.advanceTimersByTime(RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS + 1);
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not clear HTTP auth suppression when a concurrent send starts", () => {
|
||||
const session = new RemoteSession("s-concurrent");
|
||||
const received: unknown[] = [];
|
||||
session.setEventConsumer((event) => {
|
||||
if (event.type === "auth_challenge") received.push(event.data);
|
||||
});
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
session.beginSend();
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
session.endSend();
|
||||
session.beginSend();
|
||||
expect(session.handleTransportAuthError(err)).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
session.endSend();
|
||||
});
|
||||
|
||||
it("waitForRequestResponse rejects after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new RemoteSession("s-timeout");
|
||||
const wait = session.waitForRequestResponse(99, 1000);
|
||||
const rejection = expect(wait).rejects.toThrow(/timed out/);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await rejection;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("cancelRequestWait clears the timeout for a pending wait", async () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new RemoteSession("s-cancel");
|
||||
const wait = session.waitForRequestResponse(42, 5000);
|
||||
const rejection = expect(wait).rejects.toThrow(/cancelled/);
|
||||
session.cancelRequestWait(42);
|
||||
await rejection;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("setAuthState throws when no auth provider handle is set", () => {
|
||||
const session = new RemoteSession("no-auth-provider");
|
||||
expect(() =>
|
||||
session.setAuthState({
|
||||
oauthTokens: { access_token: "x", token_type: "Bearer" },
|
||||
}),
|
||||
).toThrow(/Session has no OAuth auth provider/);
|
||||
});
|
||||
|
||||
it("cancelRequestWait is a no-op when the requestId has no pending wait", () => {
|
||||
const session = new RemoteSession("s-cancel-noop");
|
||||
// No wait was ever registered for this id — should not throw.
|
||||
expect(() => session.cancelRequestWait(123)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handleTransportAuthError returns false for a non-AuthChallengeError", () => {
|
||||
const session = new RemoteSession("s-not-auth-error");
|
||||
expect(session.handleTransportAuthError(new Error("plain error"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("setAuthState updates the session auth provider", async () => {
|
||||
const { createRemoteAuthProvider } =
|
||||
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
|
||||
const handle = createRemoteAuthProvider({
|
||||
oauthTokens: { access_token: "old", token_type: "Bearer" },
|
||||
})!;
|
||||
const session = new RemoteSession("auth-state");
|
||||
session.setAuthProviderHandle(handle);
|
||||
session.setAuthState({
|
||||
oauthTokens: { access_token: "new", token_type: "Bearer" },
|
||||
});
|
||||
await expect(handle.provider.tokens()).resolves.toEqual({
|
||||
access_token: "new",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl
|
|||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
AuthChallengeError,
|
||||
AuthRecoveryRequiredError,
|
||||
EMA_STEP_UP_PENDING_URL,
|
||||
} from "@inspector/core/auth/challenge.js";
|
||||
|
||||
const CONFIG: MCPServerConfig = {
|
||||
type: "sse",
|
||||
|
|
@ -53,10 +58,26 @@ function sseFrame(event: RemoteEvent): string {
|
|||
}
|
||||
|
||||
interface MockFetchPlan {
|
||||
connect?: () => Response | Promise<Response>;
|
||||
events?: () => Response | Promise<Response>;
|
||||
send?: () => Response | Promise<Response>;
|
||||
disconnect?: () => Response | Promise<Response>;
|
||||
connect?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
events?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
send?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
disconnect?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
authState?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,27 +85,58 @@ interface MockFetchPlan {
|
|||
* handlers, defaulting to sensible success responses when a handler is omitted.
|
||||
*/
|
||||
function mockFetch(plan: MockFetchPlan): typeof fetch {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/mcp/connect")) {
|
||||
return plan.connect
|
||||
? plan.connect()
|
||||
: jsonResponse({ sessionId: "sess-1" });
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
return plan.events ? plan.events() : sseResponse([]);
|
||||
}
|
||||
if (url.includes("/api/mcp/send")) {
|
||||
return plan.send ? plan.send() : jsonResponse({ ok: true });
|
||||
}
|
||||
if (url.includes("/api/mcp/disconnect")) {
|
||||
return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true });
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
});
|
||||
const fn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/mcp/connect")) {
|
||||
return plan.connect
|
||||
? plan.connect(input, init)
|
||||
: jsonResponse({ sessionId: "sess-1" });
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
return plan.events ? plan.events(input, init) : sseResponse([]);
|
||||
}
|
||||
if (url.includes("/api/mcp/auth-state")) {
|
||||
return plan.authState
|
||||
? plan.authState(input, init)
|
||||
: jsonResponse({ ok: true });
|
||||
}
|
||||
if (url.includes("/api/mcp/send")) {
|
||||
return plan.send ? plan.send(input, init) : jsonResponse({ ok: true });
|
||||
}
|
||||
if (url.includes("/api/mcp/disconnect")) {
|
||||
return plan.disconnect
|
||||
? plan.disconnect(input, init)
|
||||
: jsonResponse({ ok: true });
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
},
|
||||
);
|
||||
return fn as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
/** Push arbitrary RemoteEvent frames onto a controllable SSE stream. */
|
||||
function createPushableEventStream() {
|
||||
const encoder = new TextEncoder();
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
c.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
},
|
||||
});
|
||||
const push = (event: RemoteEvent) => {
|
||||
controller?.enqueue(encoder.encode(sseFrame(event)));
|
||||
};
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
push,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransport(
|
||||
plan: MockFetchPlan,
|
||||
extra: Partial<RemoteTransportOptions> = {},
|
||||
|
|
@ -103,6 +155,19 @@ function makeTransport(
|
|||
/** Wait a tick so the detached consumeEventStream loop can advance. */
|
||||
const tick = () => new Promise<void>((r) => setTimeout(r, 10));
|
||||
|
||||
/**
|
||||
* An SSE response whose stream stays open (never closes on its own) until the
|
||||
* transport cancels it. Use for tests that need the transport to remain
|
||||
* started/open past the initial start() call — the plan's default `events()`
|
||||
* handler returns an already-closed empty stream, which auto-closes the
|
||||
* transport shortly after start() resolves.
|
||||
*/
|
||||
function openEventsResponse(): Response {
|
||||
return new Response(new ReadableStream<Uint8Array>({ start() {} }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RemoteClientTransport (focused branch coverage)", () => {
|
||||
describe("start()", () => {
|
||||
it("throws Remote connect failed with status on non-OK connect", async () => {
|
||||
|
|
@ -172,7 +237,9 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
);
|
||||
await t.start();
|
||||
expect(connectBody).toMatchObject({
|
||||
oauthTokens: { access_token: "AT", refresh_token: "RT" },
|
||||
authState: {
|
||||
oauthTokens: { access_token: "AT", refresh_token: "RT" },
|
||||
},
|
||||
});
|
||||
await t.close();
|
||||
});
|
||||
|
|
@ -497,17 +564,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
|
||||
it("posts a message including relatedRequestId and succeeds", async () => {
|
||||
let sentBody: { relatedRequestId?: unknown } | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | null =
|
||||
null;
|
||||
const pushSseMessage = (message: JSONRPCMessage) => {
|
||||
const payload = JSON.stringify({ type: "message", data: message });
|
||||
sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`));
|
||||
};
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/connect")) return jsonResponse({ sessionId: "s" });
|
||||
if (url.includes("/events"))
|
||||
if (url.includes("/events")) {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({ start() {} }),
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/send")) {
|
||||
sentBody = JSON.parse(init!.body as string);
|
||||
const requestId = (
|
||||
sentBody as { message: { id?: string | number } }
|
||||
).message.id;
|
||||
pushSseMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: requestId!,
|
||||
result: {},
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
|
|
@ -517,6 +605,7 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
{
|
||||
baseUrl: "http://remote.test",
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
sseResponseTimeoutMs: 2000,
|
||||
},
|
||||
CONFIG,
|
||||
);
|
||||
|
|
@ -577,6 +666,22 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("attachToSession", () => {
|
||||
it("opens SSE for an existing session without POST /connect", async () => {
|
||||
const connect = vi.fn(() => jsonResponse({ sessionId: "new" }));
|
||||
const events = vi.fn(() => sseResponse([]));
|
||||
const t = makeTransport({ connect, events });
|
||||
await t.attachToSession("existing-sess");
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
expect(events).toHaveBeenCalledWith(
|
||||
expect.stringContaining("sessionId=existing-sess"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(t.getRemoteBackendSessionId()).toBe("existing-sess");
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionId getter", () => {
|
||||
it("always returns undefined (intentional)", () => {
|
||||
const t = makeTransport({});
|
||||
|
|
@ -608,4 +713,638 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start() connect-time auth challenge and error branches", () => {
|
||||
it("accepts the ok:true connect response shape (ok-wrapped sessionId)", async () => {
|
||||
const t = makeTransport({
|
||||
connect: () => jsonResponse({ ok: true, sessionId: "sess-ok" }),
|
||||
});
|
||||
await t.start();
|
||||
expect(t.getRemoteBackendSessionId()).toBe("sess-ok");
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws a formatted error when connect returns a transport_error", async () => {
|
||||
const t = makeTransport({
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "transport_error",
|
||||
error: "upstream unreachable",
|
||||
}),
|
||||
});
|
||||
await expect(t.start()).rejects.toThrow(
|
||||
/Remote connect failed: upstream unreachable/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AuthChallengeError immediately when connect returns an auth_challenge and no authRecovery is configured", async () => {
|
||||
const t = makeTransport({
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "token_expired" },
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await t.start();
|
||||
throw new Error("expected start() to throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(AuthChallengeError);
|
||||
expect((e as AuthChallengeError).status).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the raw httpStatus on the AuthChallengeError when no authRecovery is configured", async () => {
|
||||
const t = makeTransport({
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: {
|
||||
reason: "invalid_token",
|
||||
raw: { httpStatus: 403 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await t.start();
|
||||
throw new Error("expected start() to throw");
|
||||
} catch (e) {
|
||||
expect((e as AuthChallengeError).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AuthRecoveryRequiredError with the EMA pending URL for a step_up_confirm outcome", async () => {
|
||||
const t = makeTransport(
|
||||
{
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "insufficient_scope" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
authRecovery: {
|
||||
handleAuthChallenge: vi.fn().mockResolvedValue({
|
||||
kind: "step_up_confirm",
|
||||
challenge: { reason: "insufficient_scope" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
await t.start();
|
||||
throw new Error("expected start() to throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(AuthRecoveryRequiredError);
|
||||
const err = e as AuthRecoveryRequiredError;
|
||||
expect(err.authorizationUrl).toBe(EMA_STEP_UP_PENDING_URL);
|
||||
expect(err.emaStepUpConfirm).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AuthRecoveryRequiredError with the authorization URL for an interactive outcome", async () => {
|
||||
const authorizationUrl = new URL("https://idp.example/authorize");
|
||||
const t = makeTransport(
|
||||
{
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "unauthorized" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
authRecovery: {
|
||||
handleAuthChallenge: vi.fn().mockResolvedValue({
|
||||
kind: "interactive",
|
||||
authorizationUrl,
|
||||
challenge: { reason: "unauthorized" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
await t.start();
|
||||
throw new Error("expected start() to throw");
|
||||
} catch (e) {
|
||||
const err = e as AuthRecoveryRequiredError;
|
||||
expect(err.authorizationUrl).toBe(authorizationUrl);
|
||||
expect(err.emaStepUpConfirm).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows the recovery error for a failed outcome", async () => {
|
||||
const recoveryError = new Error("recovery failed");
|
||||
const t = makeTransport(
|
||||
{
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "unauthorized" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
authRecovery: {
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: "failed", error: recoveryError }),
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(t.start()).rejects.toBe(recoveryError);
|
||||
});
|
||||
|
||||
it("succeeds after a satisfied outcome retries connect and receives a session", async () => {
|
||||
let connectAttempt = 0;
|
||||
const t = makeTransport(
|
||||
{
|
||||
connect: () => {
|
||||
connectAttempt += 1;
|
||||
if (connectAttempt === 1) {
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "token_expired" },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ sessionId: "recovered-session" });
|
||||
},
|
||||
events: () => openEventsResponse(),
|
||||
},
|
||||
{
|
||||
authRecovery: {
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: "satisfied" }),
|
||||
},
|
||||
},
|
||||
);
|
||||
await t.start();
|
||||
expect(connectAttempt).toBe(2);
|
||||
expect(t.getRemoteBackendSessionId()).toBe("recovered-session");
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws AuthChallengeError when a retried connect still returns an auth_challenge", async () => {
|
||||
const t = makeTransport(
|
||||
{
|
||||
connect: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "token_expired" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
authRecovery: {
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: "satisfied" }),
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(t.start()).rejects.toBeInstanceOf(AuthChallengeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachToSession while closed", () => {
|
||||
it("un-closes a previously closed transport before reattaching", async () => {
|
||||
const events = vi.fn(() => openEventsResponse());
|
||||
const t = makeTransport({ events });
|
||||
await t.close(); // never started; closed=true, no sessionId so disconnect is skipped
|
||||
expect((t as unknown as { closed: boolean }).closed).toBe(true);
|
||||
await t.attachToSession("resumed-session");
|
||||
expect((t as unknown as { closed: boolean }).closed).toBe(false);
|
||||
expect(t.getRemoteBackendSessionId()).toBe("resumed-session");
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchFn default", () => {
|
||||
it("falls back to globalThis.fetch when no fetchFn option is configured", () => {
|
||||
const t = new RemoteClientTransport(
|
||||
{ baseUrl: "http://remote.test" },
|
||||
CONFIG,
|
||||
);
|
||||
expect((t as unknown as { fetchFn: typeof fetch }).fetchFn).toBe(
|
||||
globalThis.fetch,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSSE trailing buffer", () => {
|
||||
it("ignores a trailing buffer line that is neither an event: nor a data: field", async () => {
|
||||
const onmessage = vi.fn();
|
||||
const t = makeTransport({
|
||||
events: () => sseResponse([": trailing comment with no newline"]),
|
||||
});
|
||||
t.onmessage = onmessage;
|
||||
await t.start();
|
||||
await tick();
|
||||
expect(onmessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEventStream stops after transport_error closes it", () => {
|
||||
it("does not process a message frame that follows a transport_error frame in the same batch", async () => {
|
||||
const m: JSONRPCMessage = { jsonrpc: "2.0", id: 99, result: {} };
|
||||
const onmessage = vi.fn();
|
||||
const onerror = vi.fn();
|
||||
const onclose = vi.fn();
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
sseResponse([
|
||||
sseFrame({ type: "transport_error", data: { error: "died" } }),
|
||||
sseFrame({ type: "message", data: m }),
|
||||
]),
|
||||
});
|
||||
t.onmessage = onmessage;
|
||||
t.onerror = onerror;
|
||||
t.onclose = onclose;
|
||||
await t.start();
|
||||
await tick();
|
||||
expect(onerror).toHaveBeenCalledTimes(1);
|
||||
expect(onclose).toHaveBeenCalledTimes(1);
|
||||
expect(onmessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEventStream restart / re-entrancy suppression", () => {
|
||||
it("suppresses transport_error handling entirely while a reconnect is in flight", async () => {
|
||||
const sse = createPushableEventStream();
|
||||
const onerror = vi.fn();
|
||||
const onclose = vi.fn();
|
||||
const t = makeTransport({ events: () => sse.response });
|
||||
t.onerror = onerror;
|
||||
t.onclose = onclose;
|
||||
await t.start();
|
||||
await tick();
|
||||
// Simulate a reconnect (attachToSession/close) already in flight on this
|
||||
// transport when a stale transport_error frame from the old stream lands.
|
||||
(
|
||||
t as unknown as { restartingEventStream: boolean }
|
||||
).restartingEventStream = true;
|
||||
sse.push({ type: "transport_error", data: { error: "stale" } });
|
||||
await tick();
|
||||
expect(onerror).not.toHaveBeenCalled();
|
||||
expect(onclose).not.toHaveBeenCalled();
|
||||
(
|
||||
t as unknown as { restartingEventStream: boolean }
|
||||
).restartingEventStream = false;
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("does not double-fire onclose when the onerror handler synchronously closes the transport", async () => {
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
sseResponse([
|
||||
sseFrame({ type: "transport_error", data: { error: "boom" } }),
|
||||
]),
|
||||
});
|
||||
const onclose = vi.fn();
|
||||
let closePromise: Promise<void> | undefined;
|
||||
t.onerror = () => {
|
||||
closePromise = t.close();
|
||||
};
|
||||
t.onclose = onclose;
|
||||
await t.start();
|
||||
await tick();
|
||||
await closePromise;
|
||||
expect(onclose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleSseResponseWait guard", () => {
|
||||
it("ignores a message-type SSE event with no id (notification)", async () => {
|
||||
const onmessage = vi.fn();
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
sseResponse([
|
||||
sseFrame({
|
||||
type: "message",
|
||||
data: {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: {},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
t.onmessage = onmessage;
|
||||
await t.start();
|
||||
await tick();
|
||||
expect(onmessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores a message-type SSE event that has an id but neither result nor error", async () => {
|
||||
const onmessage = vi.fn();
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
sseResponse([
|
||||
sseFrame({
|
||||
type: "message",
|
||||
data: { jsonrpc: "2.0", id: 3, method: "sampling/createMessage" },
|
||||
}),
|
||||
]),
|
||||
});
|
||||
t.onmessage = onmessage;
|
||||
await t.start();
|
||||
await tick();
|
||||
expect(onmessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelSseResponseWait no-op when already settled", () => {
|
||||
it("no-ops when the SSE response for the id already settled before the auth_challenge HTTP reply arrives", async () => {
|
||||
const sse = createPushableEventStream();
|
||||
const t = makeTransport({
|
||||
events: () => sse.response,
|
||||
send: async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
message: { id?: string | number };
|
||||
};
|
||||
// Simulate the SSE response for this id landing first...
|
||||
sse.push({
|
||||
type: "message",
|
||||
data: { jsonrpc: "2.0", id: body.message.id, result: {} },
|
||||
});
|
||||
// ...and let the independent SSE consumer settle it before the
|
||||
// HTTP reply (an auth_challenge for the same id) comes back.
|
||||
await tick();
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "token_expired" },
|
||||
});
|
||||
},
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.send({ jsonrpc: "2.0", id: 11, method: "tools/list" }),
|
||||
).rejects.toBeInstanceOf(AuthChallengeError);
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("postSend catch skips cancel for requestId-less messages", () => {
|
||||
it("does not attempt to cancel an SSE wait for a notification when requestSend throws", async () => {
|
||||
const notif: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/cancelled",
|
||||
};
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
send: () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
await t.start();
|
||||
await expect(t.send(notif)).rejects.toThrow(/network down/);
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("send() generic transport_error and notification failure paths", () => {
|
||||
it("throws the raw error message when send returns a non-auth_challenge failure kind", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
send: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "transport_error",
|
||||
error: "upstream 503",
|
||||
}),
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
|
||||
).rejects.toThrow(/upstream 503/);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("skips cancelSseResponseWait for a notification whose send fails", async () => {
|
||||
const notif: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/cancelled",
|
||||
};
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
send: () =>
|
||||
jsonResponse({ ok: false, kind: "transport_error", error: "boom" }),
|
||||
});
|
||||
await t.start();
|
||||
await expect(t.send(notif)).rejects.toThrow(/boom/);
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("send() auth_challenge without recovery configured", () => {
|
||||
it("throws immediately when no authRecovery is configured", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
send: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: { reason: "token_expired" },
|
||||
}),
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
|
||||
).rejects.toBeInstanceOf(AuthChallengeError);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws immediately with the raw httpStatus when authRecovery has no pushAuthState handler", async () => {
|
||||
const t = makeTransport(
|
||||
{
|
||||
events: () => openEventsResponse(),
|
||||
send: () =>
|
||||
jsonResponse({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: {
|
||||
reason: "token_expired",
|
||||
raw: { httpStatus: 403 },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ authRecovery: { handleAuthChallenge: vi.fn() } },
|
||||
);
|
||||
await t.start();
|
||||
try {
|
||||
await t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
|
||||
throw new Error("expected send() to throw");
|
||||
} catch (e) {
|
||||
expect((e as AuthChallengeError).status).toBe(403);
|
||||
}
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushAuthState guards", () => {
|
||||
it("throws Transport not started when called before start()", async () => {
|
||||
const t = makeTransport({});
|
||||
await expect(
|
||||
t.pushAuthState({
|
||||
oauthTokens: { access_token: "a", token_type: "Bearer" },
|
||||
}),
|
||||
).rejects.toThrow(/Transport not started/);
|
||||
});
|
||||
|
||||
it("throws Transport is closed after close()", async () => {
|
||||
const t = makeTransport({});
|
||||
await t.start();
|
||||
await t.close();
|
||||
await expect(
|
||||
t.pushAuthState({
|
||||
oauthTokens: { access_token: "a", token_type: "Bearer" },
|
||||
}),
|
||||
).rejects.toThrow(/Transport is closed/);
|
||||
});
|
||||
|
||||
it("throws when the resolved auth state has neither oauthTokens nor oauthClient", async () => {
|
||||
const t = makeTransport({ events: () => openEventsResponse() });
|
||||
await t.start();
|
||||
await expect(t.pushAuthState({})).rejects.toThrow(
|
||||
/No auth state to push/,
|
||||
);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws No auth provider configured when called with no explicit state and no authProvider", async () => {
|
||||
const t = makeTransport({ events: () => openEventsResponse() });
|
||||
await t.start();
|
||||
await expect(t.pushAuthState()).rejects.toThrow(
|
||||
/No auth provider configured/,
|
||||
);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws No OAuth tokens available when the configured authProvider has no tokens", async () => {
|
||||
const authProvider = {
|
||||
tokens: async () => undefined,
|
||||
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
|
||||
const t = makeTransport(
|
||||
{ events: () => openEventsResponse() },
|
||||
{ authProvider },
|
||||
);
|
||||
await t.start();
|
||||
await expect(t.pushAuthState()).rejects.toThrow(
|
||||
/No OAuth tokens available/,
|
||||
);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws a formatted error when the auth-state POST responds non-OK", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
authState: () => new Response("nope", { status: 500 }),
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.pushAuthState({
|
||||
oauthTokens: { access_token: "a", token_type: "Bearer" },
|
||||
}),
|
||||
).rejects.toThrow(/Remote auth-state update failed \(500\)/);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws the server-provided error message when the auth-state POST reports ok:false", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
authState: () => jsonResponse({ ok: false, error: "session gone" }),
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.pushAuthState({
|
||||
oauthTokens: { access_token: "a", token_type: "Bearer" },
|
||||
}),
|
||||
).rejects.toThrow(/session gone/);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws a default error message when the auth-state POST reports ok:false with no error field", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
authState: () => jsonResponse({ ok: false }),
|
||||
});
|
||||
await t.start();
|
||||
await expect(
|
||||
t.pushAuthState({
|
||||
oauthTokens: { access_token: "a", token_type: "Bearer" },
|
||||
}),
|
||||
).rejects.toThrow(/Remote auth-state update failed/);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("accepts an explicit authState argument without consulting authProvider", async () => {
|
||||
const authProvider = {
|
||||
tokens: vi.fn(),
|
||||
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
|
||||
const authStateCalls: unknown[] = [];
|
||||
const t = makeTransport(
|
||||
{
|
||||
events: () => openEventsResponse(),
|
||||
authState: (_input, init) => {
|
||||
authStateCalls.push(JSON.parse(String(init?.body)));
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
},
|
||||
{ authProvider },
|
||||
);
|
||||
await t.start();
|
||||
await t.pushAuthState({
|
||||
oauthTokens: { access_token: "explicit", token_type: "Bearer" },
|
||||
});
|
||||
expect(authStateCalls).toHaveLength(1);
|
||||
await t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("misc coverage", () => {
|
||||
it("setOnAuthChallenge updates the ambient auth challenge handler", async () => {
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
sseResponse([
|
||||
sseFrame({
|
||||
type: "auth_challenge",
|
||||
data: { reason: "token_expired" },
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const onAuthChallenge = vi.fn();
|
||||
t.setOnAuthChallenge(onAuthChallenge);
|
||||
await t.start();
|
||||
await tick();
|
||||
expect(onAuthChallenge).toHaveBeenCalledWith({ reason: "token_expired" });
|
||||
});
|
||||
|
||||
it("is a no-op when start() is called again while already connected", async () => {
|
||||
const connect = vi.fn(() => jsonResponse({ sessionId: "s" }));
|
||||
const t = makeTransport({ connect, events: () => openEventsResponse() });
|
||||
await t.start();
|
||||
await t.start();
|
||||
expect(connect).toHaveBeenCalledTimes(1);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("rejects a pending SSE response wait when close() runs mid-send", async () => {
|
||||
const t = makeTransport({
|
||||
events: () => openEventsResponse(),
|
||||
send: () => jsonResponse({ ok: true }),
|
||||
});
|
||||
await t.start();
|
||||
const sendPromise = t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
|
||||
const rejection = expect(sendPromise).rejects.toThrow(/Transport closed/);
|
||||
await t.close();
|
||||
await rejection;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -788,4 +788,113 @@ describe("server.ts supplemental coverage", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("keychain-unavailable 503 on PUT/DELETE", () => {
|
||||
class UnavailableOnWriteStore implements SecretStore {
|
||||
async get(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
async set(): Promise<void> {
|
||||
throw new KeychainUnavailableError(new Error("libsecret missing"));
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
async deleteAllForServer(): Promise<void> {
|
||||
throw new KeychainUnavailableError(new Error("libsecret missing"));
|
||||
}
|
||||
}
|
||||
|
||||
it("PUT returns 503 when the keychain write is unavailable", async () => {
|
||||
const h = await start({
|
||||
secretStore: new UnavailableOnWriteStore(),
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: {
|
||||
srv: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
oauth: { clientId: "cid", clientSecret: "shh" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthClientSecret: "new-secret",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json()).error).toMatch(/libsecret missing/);
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
|
||||
it("DELETE returns 503 when the keychain sweep is unavailable", async () => {
|
||||
const h = await start({
|
||||
secretStore: new UnavailableOnWriteStore(),
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: { srv: { type: "stdio", command: "node" } },
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json()).error).toMatch(/libsecret missing/);
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migratePlaintextSecrets rethrows a non-keychain error", () => {
|
||||
class ThrowingOnGetStore implements SecretStore {
|
||||
async get(): Promise<string | null> {
|
||||
throw new Error("disk full");
|
||||
}
|
||||
async set(): Promise<void> {
|
||||
/* unreachable in this test */
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
async deleteAllForServer(): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
it("GET /api/servers returns 500 when migration hits a non-KeychainUnavailableError", async () => {
|
||||
const h = await start({
|
||||
secretStore: new ThrowingOnGetStore(),
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: {
|
||||
srv: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
oauth: { clientId: "cid", clientSecret: "shh" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(res.status).toBe(500);
|
||||
expect((await res.json()).error).toMatch(/Failed to read server list/);
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1340,7 +1340,7 @@ describe("Remote transport e2e", () => {
|
|||
expect((await res.json()).error).toMatch(/Failed to create transport:/);
|
||||
});
|
||||
|
||||
it("/api/mcp/send returns 500 when transport is dead", async () => {
|
||||
it("/api/mcp/send returns transport_error when transport is dead", async () => {
|
||||
mcpHttpServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
|
|
@ -1387,9 +1387,11 @@ describe("Remote transport e2e", () => {
|
|||
message: { jsonrpc: "2.0", id: 99, method: "ping" },
|
||||
}),
|
||||
});
|
||||
// Either the dead-transport short-circuit (500) or a downstream send
|
||||
// failure (500) — either way, the path is covered.
|
||||
expect(sendRes.status).toBeGreaterThanOrEqual(500);
|
||||
expect(sendRes.status).toBe(200);
|
||||
expect(await sendRes.json()).toMatchObject({
|
||||
ok: false,
|
||||
kind: "transport_error",
|
||||
});
|
||||
});
|
||||
|
||||
it("/api/log accepts non-JSON body silently via the catch fallback", async () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
loadConfig,
|
||||
resolveConfig,
|
||||
ExternalAccessTokenValidator,
|
||||
extractScopesFromJwtPayload,
|
||||
TestServerHttp,
|
||||
createExternalResourceOAuthTestServerConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
|
@ -59,6 +60,19 @@ describe("protected-resource OAuth config", () => {
|
|||
});
|
||||
|
||||
describe("ExternalAccessTokenValidator", () => {
|
||||
it("extracts scopes from JWT payload claims", () => {
|
||||
expect(extractScopesFromJwtPayload({ scope: "mcp tools:read" })).toEqual([
|
||||
"mcp",
|
||||
"tools:read",
|
||||
]);
|
||||
expect(
|
||||
extractScopesFromJwtPayload({ scp: ["tools:read", "env:read"] }),
|
||||
).toEqual(["tools:read", "env:read"]);
|
||||
expect(extractScopesFromJwtPayload({ scopes: ["tools:read"] })).toEqual([
|
||||
"tools:read",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts JWTs signed by the AS and rejects wrong issuer", async () => {
|
||||
const issuer = "https://as.example";
|
||||
const jwksUri = "https://as.example/jwks";
|
||||
|
|
|
|||
Loading…
Reference in a new issue