Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

Close #1527 acceptance gaps with automated tests (SEP-2468 iss, SEP-2207 offline_access, SEP-2351 discovery shapes, SEP-2352 AS-migration, EraNegotiationFailed→401 unwrap); lean EMA leg 2 and scope helpers on the v2 SDK; drop deprecated TS baseUrl. Reviewed LGTM.
This commit is contained in:
Bob Dickinson 2026-07-19 19:36:21 -07:00 • committed by GitHub
commit a71e6d2012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1513 additions and 852 deletions

View file

@ -29,7 +29,7 @@ const oauthTestStatePath = join(
async function completeOAuthAuthorization( async function completeOAuthAuthorization(
authorizationUrl: URL, authorizationUrl: URL,
): Promise<string> { ): Promise<{ code: string; iss?: string }> {
let response = await fetch(authorizationUrl.toString(), { let response = await fetch(authorizationUrl.toString(), {
redirect: "manual", redirect: "manual",
}); });
@ -62,19 +62,23 @@ async function completeOAuthAuthorization(
if (!code) { if (!code) {
throw new Error("Missing authorization code"); throw new Error("Missing authorization code");
} }
return code; const iss = redirect.searchParams.get("iss") ?? undefined;
return iss ? { code, iss } : { code };
} }
function createAutoCompleteNavigation( function createAutoCompleteNavigation(
redirectUrlProvider: MutableRedirectUrlProvider, redirectUrlProvider: MutableRedirectUrlProvider,
) { ) {
return new CallbackNavigation(async (url) => { return new CallbackNavigation(async (url) => {
const code = await completeOAuthAuthorization(url); const { code, iss } = await completeOAuthAuthorization(url);
const redirect = redirectUrlProvider.redirectUrl; const redirect = redirectUrlProvider.redirectUrl;
if (!redirect) { if (!redirect) {
throw new Error("redirectUrl not set"); throw new Error("redirectUrl not set");
} }
await fetch(`${redirect}?code=${encodeURIComponent(code)}`); const callback = new URL(redirect);
callback.searchParams.set("code", code);
if (iss) callback.searchParams.set("iss", iss);
await fetch(callback.href);
}); });
} }

View file

@ -2,7 +2,6 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"baseUrl": ".",
"paths": { "paths": {
"@inspector/core/*": ["../../core/*"] "@inspector/core/*": ["../../core/*"]
} }

View file

@ -4,7 +4,6 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"outDir": "./build", "outDir": "./build",
"rootDir": ".", "rootDir": ".",
"baseUrl": ".",
"paths": { "paths": {
"@inspector/core/*": ["../../core/*"] "@inspector/core/*": ["../../core/*"]
} }

View file

@ -1,14 +1,9 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { import {
exchangeIdJag, exchangeIdJag,
redeemIdJagForAccessToken, redeemIdJagForAccessToken,
} from "@inspector/core/auth/ema/wire.js"; } from "@inspector/core/auth/ema/wire.js";
import { import { GRANT_TYPE_JWT_BEARER } from "@inspector/core/auth/ema/constants.js";
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
TOKEN_TYPE_ID_JAG,
TOKEN_TYPE_ID_TOKEN,
} from "@inspector/core/auth/ema/constants.js";
import { import {
EMA_MOCK_IDP_CLIENT_ID, EMA_MOCK_IDP_CLIENT_ID,
EMA_MOCK_IDP_CLIENT_SECRET, EMA_MOCK_IDP_CLIENT_SECRET,
@ -17,8 +12,24 @@ import {
minimalOAuthAsMetadata, minimalOAuthAsMetadata,
} from "../../../integration/mcp/ema-mock-servers.js"; } from "../../../integration/mcp/ema-mock-servers.js";
const { mockDiscoverAndRequestJwtAuthGrant } = vi.hoisted(() => ({
mockDiscoverAndRequestJwtAuthGrant: vi.fn(),
}));
vi.mock("@modelcontextprotocol/client", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@modelcontextprotocol/client")>();
return {
...actual,
discoverAndRequestJwtAuthGrant: (
...args: Parameters<typeof actual.discoverAndRequestJwtAuthGrant>
) => mockDiscoverAndRequestJwtAuthGrant(...args),
};
});
const IDP_ISSUER = "https://mock-idp.test"; const IDP_ISSUER = "https://mock-idp.test";
const AS_ISSUER = "https://mock-as.test"; const AS_ISSUER = "https://mock-as.test";
const MCP_RESOURCE = "https://mcp.test/resource";
const ID_TOKEN = "header.idpayload.sig"; const ID_TOKEN = "header.idpayload.sig";
const ID_JAG = "mock-id-jag-token"; const ID_JAG = "mock-id-jag-token";
const ACCESS_TOKEN = "mock-resource-access-token"; const ACCESS_TOKEN = "mock-resource-access-token";
@ -34,34 +45,27 @@ function asMetadata() {
}; };
} }
/** Route the mock through the real SDK helper (canaries for message-map drift). */
async function useRealDiscoverAndRequestJwtAuthGrant() {
const actual = await vi.importActual<
typeof import("@modelcontextprotocol/client")
>("@modelcontextprotocol/client");
mockDiscoverAndRequestJwtAuthGrant.mockImplementation((options) =>
actual.discoverAndRequestJwtAuthGrant(options),
);
}
describe("ema wire", () => { describe("ema wire", () => {
beforeEach(() => {
mockDiscoverAndRequestJwtAuthGrant.mockReset();
});
describe("exchangeIdJag", () => { describe("exchangeIdJag", () => {
it("posts RFC 8693 token exchange and returns ID-JAG", async () => { it("delegates to the SDK ID-JAG helper and returns the grant", async () => {
const fetchFn = vi.fn( mockDiscoverAndRequestJwtAuthGrant.mockResolvedValueOnce({
async (input: RequestInfo | URL, init?: RequestInit) => { jwtAuthGrant: ID_JAG,
const url = String(input); authorizationServerUrl: IDP_ISSUER,
if (url.includes("/.well-known/oauth-authorization-server")) { });
return new Response(JSON.stringify(idpMetadata()));
}
if (url === `${IDP_ISSUER}/token`) {
const body = new URLSearchParams(init?.body as string);
expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE);
expect(body.get("subject_token")).toBe(ID_TOKEN);
expect(body.get("subject_token_type")).toBe(TOKEN_TYPE_ID_TOKEN);
expect(body.get("requested_token_type")).toBe(TOKEN_TYPE_ID_JAG);
expect(body.get("audience")).toBe(AS_ISSUER);
expect(body.get("client_id")).toBe(EMA_MOCK_IDP_CLIENT_ID);
expect(body.get("client_secret")).toBe(EMA_MOCK_IDP_CLIENT_SECRET);
return new Response(
JSON.stringify({
access_token: ID_JAG,
issued_token_type: TOKEN_TYPE_ID_JAG,
}),
);
}
throw new Error(`unexpected fetch: ${url}`);
},
);
const idJag = await exchangeIdJag({ const idJag = await exchangeIdJag({
idp: { idp: {
@ -71,17 +75,33 @@ describe("ema wire", () => {
}, },
idToken: ID_TOKEN, idToken: ID_TOKEN,
audience: AS_ISSUER, audience: AS_ISSUER,
fetchFn, resource: MCP_RESOURCE,
scope: "mcp",
}); });
expect(idJag).toBe(ID_JAG); expect(idJag).toBe(ID_JAG);
expect(mockDiscoverAndRequestJwtAuthGrant).toHaveBeenCalledWith({
idpUrl: IDP_ISSUER,
audience: AS_ISSUER,
resource: MCP_RESOURCE,
idToken: ID_TOKEN,
clientId: EMA_MOCK_IDP_CLIENT_ID,
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
scope: "mcp",
fetchFn: undefined,
});
}); });
it("throws when IdP metadata is missing a token_endpoint", async () => { // Canaries: call the real `discoverAndRequestJwtAuthGrant` so
// `wire.ts` regex remaps stay tied to actual SDK error wording. Fabricating
// those strings in mocks would hide SDK message drift (mapping would fall
// through to the generic EMA leg 2 wrapper while tests still passed).
it("maps real SDK missing-token-endpoint errors (canary)", async () => {
await useRealDiscoverAndRequestJwtAuthGrant();
const errorSpy = vi const errorSpy = vi
.spyOn(console, "error") .spyOn(console, "error")
.mockImplementation(() => undefined); .mockImplementation(() => undefined);
// All discovery endpoints 404 → SDK returns undefined metadata. // All discovery endpoints 404 → SDK throws "Failed to discover token endpoint…".
const fetchFn = vi.fn( const fetchFn = vi.fn(
async () => new Response("not found", { status: 404 }), async () => new Response("not found", { status: 404 }),
); );
@ -95,13 +115,15 @@ describe("ema wire", () => {
}, },
idToken: ID_TOKEN, idToken: ID_TOKEN,
audience: AS_ISSUER, audience: AS_ISSUER,
resource: MCP_RESOURCE,
fetchFn, fetchFn,
}), }),
).rejects.toThrow(/IdP metadata missing token_endpoint/); ).rejects.toThrow(/IdP metadata missing token_endpoint/);
errorSpy.mockRestore(); errorSpy.mockRestore();
}); });
it("sets resource and scope params and throws when no ID-JAG is returned", async () => { it("maps real SDK invalid token-exchange responses (canary)", async () => {
await useRealDiscoverAndRequestJwtAuthGrant();
const fetchFn = vi.fn( const fetchFn = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => { async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
@ -110,9 +132,9 @@ describe("ema wire", () => {
} }
if (url === `${IDP_ISSUER}/token`) { if (url === `${IDP_ISSUER}/token`) {
const body = new URLSearchParams(init?.body as string); const body = new URLSearchParams(init?.body as string);
expect(body.get("resource")).toBe("https://mcp.test/resource"); expect(body.get("resource")).toBe(MCP_RESOURCE);
expect(body.get("scope")).toBe("mcp profile"); expect(body.get("scope")).toBe("mcp profile");
// 200 OK but empty access_token → ID-JAG missing branch. // 200 OK but invalid ID-JAG shape → SDK "Invalid token exchange response…".
return new Response(JSON.stringify({ issued_token_type: "x" })); return new Response(JSON.stringify({ issued_token_type: "x" }));
} }
throw new Error(`unexpected fetch: ${url}`); throw new Error(`unexpected fetch: ${url}`);
@ -128,7 +150,7 @@ describe("ema wire", () => {
}, },
idToken: ID_TOKEN, idToken: ID_TOKEN,
audience: AS_ISSUER, audience: AS_ISSUER,
resource: "https://mcp.test/resource", resource: MCP_RESOURCE,
scope: "mcp profile", scope: "mcp profile",
fetchFn, fetchFn,
}), }),
@ -136,17 +158,61 @@ describe("ema wire", () => {
}); });
it("throws when IdP token exchange fails", async () => { it("throws when IdP token exchange fails", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => { mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce(
const url = String(input); new Error("invalid_grant"),
if (url.includes("/.well-known/oauth-authorization-server")) { );
return new Response(JSON.stringify(idpMetadata()));
} await expect(
if (url === `${IDP_ISSUER}/token`) { exchangeIdJag({
return new Response(JSON.stringify({ error: "invalid_grant" }), { idp: {
status: 400, issuer: IDP_ISSUER,
}); clientId: EMA_MOCK_IDP_CLIENT_ID,
} clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
throw new Error(`unexpected fetch: ${url}`); },
idToken: ID_TOKEN,
audience: AS_ISSUER,
resource: MCP_RESOURCE,
}),
).rejects.toThrow(/EMA leg 2/);
});
it("rejects an empty resource identifier before calling the SDK", async () => {
await expect(
exchangeIdJag({
idp: {
issuer: IDP_ISSUER,
clientId: EMA_MOCK_IDP_CLIENT_ID,
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
},
idToken: ID_TOKEN,
audience: AS_ISSUER,
resource: " ",
}),
).rejects.toThrow(/EMA leg 2 requires a resource identifier/);
expect(mockDiscoverAndRequestJwtAuthGrant).not.toHaveBeenCalled();
});
it("maps non-Error SDK failures through the generic EMA leg 2 wrapper", async () => {
mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce("boom");
await expect(
exchangeIdJag({
idp: {
issuer: IDP_ISSUER,
clientId: EMA_MOCK_IDP_CLIENT_ID,
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
},
idToken: ID_TOKEN,
audience: AS_ISSUER,
resource: MCP_RESOURCE,
}),
).rejects.toThrow(/EMA leg 2 \(IdP token exchange for ID-JAG\): boom/);
});
it("throws when the SDK returns success without a jwtAuthGrant", async () => {
mockDiscoverAndRequestJwtAuthGrant.mockResolvedValueOnce({
jwtAuthGrant: "",
authorizationServerUrl: IDP_ISSUER,
}); });
await expect( await expect(
@ -158,9 +224,9 @@ describe("ema wire", () => {
}, },
idToken: ID_TOKEN, idToken: ID_TOKEN,
audience: AS_ISSUER, audience: AS_ISSUER,
fetchFn, resource: MCP_RESOURCE,
}), }),
).rejects.toThrow(/EMA leg 2/); ).rejects.toThrow(/did not return an ID-JAG/);
}); });
}); });

View file

@ -8,6 +8,11 @@ import {
formatOAuthFailureDetail, formatOAuthFailureDetail,
isUnauthorizedError, isUnauthorizedError,
} from "@inspector/core/auth/utils.js"; } from "@inspector/core/auth/utils.js";
import {
SdkError,
SdkErrorCode,
UnauthorizedError,
} from "@modelcontextprotocol/client";
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
describe("parseHttpUrl", () => { describe("parseHttpUrl", () => {
@ -263,6 +268,36 @@ describe("isUnauthorizedError", () => {
it("returns false for null", () => { it("returns false for null", () => {
expect(isUnauthorizedError(null)).toBe(false); expect(isUnauthorizedError(null)).toBe(false);
}); });
it("returns true for SDK UnauthorizedError", () => {
expect(isUnauthorizedError(new UnauthorizedError("Unauthorized"))).toBe(
true,
);
});
it("returns true when EraNegotiationFailed wraps UnauthorizedError in data.cause", () => {
const wrapped = new SdkError(
SdkErrorCode.EraNegotiationFailed,
"Version negotiation probe failed",
{ cause: new UnauthorizedError("Unauthorized") },
);
expect(isUnauthorizedError(wrapped)).toBe(true);
});
it("returns true when UnauthorizedError is on native Error.cause", () => {
const wrapped = new Error("probe failed", {
cause: new UnauthorizedError("Unauthorized"),
});
expect(isUnauthorizedError(wrapped)).toBe(true);
});
it("returns false for unrelated SdkError", () => {
expect(
isUnauthorizedError(
new SdkError(SdkErrorCode.RequestTimeout, "timed out"),
),
).toBe(false);
});
}); });
describe("generateOAuthErrorDescription without description/uri", () => { describe("generateOAuthErrorDescription without description/uri", () => {

View file

@ -136,16 +136,23 @@ export async function createClientMetadataServer(
}); });
} }
/** Result of approving consent on the local composable test AS. */
export interface CompletedOAuthAuthorization {
code: string;
/** RFC 9207 `iss` from the redirect when the AS returns it. */
iss?: string;
}
/** /**
* Programmatically complete OAuth against the local composable test AS. * Programmatically complete OAuth against the local composable test AS.
* GET shows an HTML consent page; approve via POST (same as CLI test helper). * GET shows an HTML consent page; approve via POST (same as CLI test helper).
* *
* @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event
* @returns Authorization code extracted from redirect URL * @returns Authorization code and optional RFC 9207 `iss` from the redirect
*/ */
export async function completeOAuthAuthorization( export async function completeOAuthAuthorization(
authorizationUrl: URL, authorizationUrl: URL,
): Promise<string> { ): Promise<CompletedOAuthAuthorization> {
let response = await fetch(authorizationUrl.toString(), { let response = await fetch(authorizationUrl.toString(), {
redirect: "manual", redirect: "manual",
}); });
@ -180,5 +187,6 @@ export async function completeOAuthAuthorization(
throw new Error(`No authorization code in redirect URL: ${redirectUrl}`); throw new Error(`No authorization code in redirect URL: ${redirectUrl}`);
} }
return code; const iss = redirectUrlObj.searchParams.get("iss") ?? undefined;
return iss ? { code, iss } : { code };
} }

View file

@ -124,7 +124,10 @@ describe("InspectorClient direct mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -205,7 +208,10 @@ describe("InspectorClient direct mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const toolsResult = await client.listTools(); const toolsResult = await client.listTools();
@ -227,10 +233,9 @@ describe("InspectorClient direct mid-session OAuth", () => {
"weather:read", "weather:read",
); );
const stepUpCode = await completeOAuthAuthorization( const { code: stepUpCode, iss: stepUpCodeIss } =
recovery!.authorizationUrl, await completeOAuthAuthorization(recovery!.authorizationUrl);
); await client.completeOAuthFlow(stepUpCode, stepUpCodeIss);
await client.completeOAuthFlow(stepUpCode);
const result = await client.callTool(getTempTool!, { const result = await client.callTool(getTempTool!, {
city: "NYC", city: "NYC",

View file

@ -28,6 +28,7 @@ import {
getDCRRequests, getDCRRequests,
invalidateAccessToken, invalidateAccessToken,
} from "@modelcontextprotocol/inspector-test-server"; } from "@modelcontextprotocol/inspector-test-server";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js"; import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js";
import { import {
createOAuthClientConfig, createOAuthClientConfig,
@ -180,8 +181,9 @@ describe("InspectorClient OAuth E2E", () => {
expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId); expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId);
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
const stateAfterComplete = client.getOAuthFlowState(); const stateAfterComplete = client.getOAuthFlowState();
@ -260,8 +262,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize"); expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
@ -354,8 +357,9 @@ describe("InspectorClient OAuth E2E", () => {
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize"); expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
// Verify tokens are stored // Verify tokens are stored
@ -430,8 +434,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
@ -498,8 +503,9 @@ describe("InspectorClient OAuth E2E", () => {
expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined();
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
const stateAfterComplete = client.getOAuthFlowState(); const stateAfterComplete = client.getOAuthFlowState();
@ -567,8 +573,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
const dcr = getDCRRequests(); const dcr = getDCRRequests();
@ -624,8 +631,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrlFirst = await client.authenticate(); const authUrlFirst = await client.authenticate();
if (!authUrlFirst) throw new Error("Expected authorization URL"); if (!authUrlFirst) throw new Error("Expected authorization URL");
const authCodeFirst = await completeOAuthAuthorization(authUrlFirst); const { code: authCodeFirst, iss: authCodeFirstIss } =
await client.completeOAuthFlow(authCodeFirst); await completeOAuthAuthorization(authUrlFirst);
await client.completeOAuthFlow(authCodeFirst, authCodeFirstIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
@ -634,8 +642,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrlSecond = await client.authenticate(); const authUrlSecond = await client.authenticate();
if (!authUrlSecond) throw new Error("Expected authorization URL"); if (!authUrlSecond) throw new Error("Expected authorization URL");
const authCodeSecond = await completeOAuthAuthorization(authUrlSecond); const { code: authCodeSecond, iss: authCodeSecondIss } =
await client.completeOAuthFlow(authCodeSecond); await completeOAuthAuthorization(authUrlSecond);
await client.completeOAuthFlow(authCodeSecond, authCodeSecondIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
}); });
@ -836,8 +845,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
expect(await client.isOAuthAuthorized()).toBe(true); expect(await client.isOAuthAuthorized()).toBe(true);
@ -906,8 +916,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -986,8 +997,9 @@ describe("InspectorClient OAuth E2E", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -1065,8 +1077,9 @@ describe("InspectorClient OAuth E2E", () => {
try { try {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
@ -1181,8 +1194,9 @@ describe("InspectorClient OAuth E2E", () => {
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize"); expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
@ -1196,6 +1210,19 @@ describe("InspectorClient OAuth E2E", () => {
); );
expect(oauthUrls.length).toBeGreaterThan(0); expect(oauthUrls.length).toBeGreaterThan(0);
// SEP-2351: discovery uses stable well-known suffixes (not only "some oauth URL").
const trackedUrls = tracker.map((c) => c.url);
expect(
trackedUrls.some((u) =>
u.includes("/.well-known/oauth-protected-resource"),
),
).toBe(true);
expect(
trackedUrls.some((u) =>
u.includes("/.well-known/oauth-authorization-server"),
),
).toBe(true);
// Verify fetch tracking categories: auth vs transport // Verify fetch tracking categories: auth vs transport
const fetchRequests = fetchRequestLogState.getFetchRequests(); const fetchRequests = fetchRequestLogState.getFetchRequests();
const authFetches = fetchRequests.filter((r) => r.category === "auth"); const authFetches = fetchRequests.filter((r) => r.category === "auth");
@ -1206,4 +1233,269 @@ describe("InspectorClient OAuth E2E", () => {
expect(transportFetches.length).toBeGreaterThan(0); expect(transportFetches.length).toBeGreaterThan(0);
}); });
}); });
describe("SEP-2468 iss validation (Streamable HTTP)", () => {
const transport = transports[1]!;
async function startStaticClient() {
const staticClientId = "test-iss-client";
const staticClientSecret = "test-iss-secret";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
return { serverUrl, oauthConfig };
}
it("accepts completeOAuthFlow with matching callback iss", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code, iss } = await completeOAuthAuthorization(authUrl);
expect(iss).toBeDefined();
await client.completeOAuthFlow(code, iss);
await client.connect();
expect(client.getStatus()).toBe("connected");
expect(await client.getOAuthTokens()).toBeDefined();
});
it("rejects mismatched callback iss before storing tokens", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code } = await completeOAuthAuthorization(authUrl);
await expect(
client.completeOAuthFlow(code, "https://evil.example"),
).rejects.toThrow(/Issuer mismatch|issuer/i);
expect(await client.getOAuthTokens()).toBeUndefined();
});
it("rejects missing iss when AS requires authorization_response iss", async () => {
await startStaticClient();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const { code } = await completeOAuthAuthorization(authUrl);
await expect(client.completeOAuthFlow(code)).rejects.toThrow(
/Issuer mismatch|issuer/i,
);
expect(await client.getOAuthTokens()).toBeUndefined();
});
});
describe("SEP-2207 offline_access on authorize (Streamable HTTP)", () => {
it("requests offline_access when AS scopes_supported includes it", async () => {
const transport = transports[1]!;
const staticClientId = "test-offline-access-client";
const staticClientSecret = "test-offline-access-secret";
const serverConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
scopesSupported: ["mcp", "offline_access"],
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverConfig);
const port = await server.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
scope: "mcp",
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const scope = authUrl.searchParams.get("scope") ?? "";
expect(scope.split(/\s+/)).toContain("offline_access");
});
});
describe("SEP-2352 AS migration (Streamable HTTP)", () => {
it("does not silently reuse issuer-A tokens when discovery resolves issuer B", async () => {
const transport = transports[1]!;
const staticClientId = "test-as-migrate-client";
const staticClientSecret = "test-as-migrate-secret";
let asB: TestServerHttp | undefined;
try {
const serverAConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
server = new TestServerHttp(serverAConfig);
const portA = await server.start();
const serverAUrl = `http://localhost:${portA}`;
await waitForOAuthWellKnown(serverAUrl);
const serverBConfig = {
...getDefaultServerConfig(),
serverType: transport.serverType,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUris: [testRedirectUrl],
},
],
}),
};
asB = new TestServerHttp(serverBConfig);
const portB = await asB.start();
const serverBUrl = `http://localhost:${portB}`;
await waitForOAuthWellKnown(serverBUrl);
const issuerB = serverBUrl.replace(/\/$/, "");
const oauthConfig = createTestOAuthConfig({
mode: "static",
clientId: staticClientId,
clientSecret: staticClientSecret,
redirectUrl: testRedirectUrl,
});
client = new InspectorClient(
{
type: transport.clientType,
url: `${serverAUrl}${transport.endpoint}`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: oauthConfig.storage,
navigation: oauthConfig.navigation,
redirectUrlProvider: oauthConfig.redirectUrlProvider,
},
},
oauth: {
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
scope: oauthConfig.scope,
},
},
);
const mcpUrl = `${serverAUrl}${transport.endpoint}`;
const authUrlA = await client.authenticate();
if (!authUrlA) throw new Error("Expected authorization URL for A");
expect(authUrlA.origin).toBe(new URL(serverAUrl).origin);
const { code, iss } = await completeOAuthAuthorization(authUrlA);
await client.completeOAuthFlow(code, iss);
await client.connect();
expect(client.getStatus()).toBe("connected");
expect(await client.getOAuthTokens()).toBeDefined();
await client.disconnect();
// SDK auth() reuses cached authorizationServerUrl and will not pick up a
// PRM flip until discovery is replaced. Simulate AS migration by pointing
// persisted discovery at issuer B while leaving issuer-A tokens in place.
const asBMetadata = await discoverAuthorizationServerMetadata(issuerB);
await oauthConfig.storage.saveDiscoveryState(mcpUrl, {
authorizationServerUrl: issuerB,
authorizationServerMetadata: asBMetadata,
resourceMetadata: {
resource: `${serverAUrl}/`,
authorization_servers: [issuerB],
},
});
// Shared in-memory AS token store would otherwise let B redeem A's
// refresh_token; clear AS-side grants so reuse cannot succeed via refresh.
clearOAuthTestData();
const authUrlB = await client.authenticate();
expect(authUrlB).toBeDefined();
expect(authUrlB!.origin).toBe(new URL(serverBUrl).origin);
} finally {
if (asB) await asB.stop();
}
});
});
}); });

View file

@ -191,7 +191,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -273,7 +276,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -361,7 +367,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const toolsResult = await client.listTools(); const toolsResult = await client.listTools();
@ -396,9 +405,8 @@ describe("InspectorClient remote mid-session OAuth", () => {
"weather:read", "weather:read",
); );
const stepUpCode = await completeOAuthAuthorization( const { code: stepUpCode, iss: stepUpCodeIss } =
recovery!.authorizationUrl, await completeOAuthAuthorization(recovery!.authorizationUrl);
);
// Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth // Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth
// storage, reattach to the live remote backend session. // storage, reattach to the live remote backend session.
@ -409,7 +417,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
} as MCPServerConfig, } as MCPServerConfig,
clientConfig, clientConfig,
); );
await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId }); await callbackClient.resumeAfterOAuth(stepUpCode, {
iss: stepUpCodeIss,
remoteSessionId,
});
expect(callbackClient.getStatus()).toBe("connected"); expect(callbackClient.getStatus()).toBe("connected");
@ -505,15 +516,17 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const initialCode = await completeOAuthAuthorization(authUrl); const { code: initialCode, iss: initialCodeIss } =
await client.completeOAuthFlow(initialCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(initialCode, initialCodeIss);
await client.connect(); await client.connect();
const mcpServerUrl = `${serverUrl}/mcp`; const mcpServerUrl = `${serverUrl}/mcp`;
await oauthConfig.storage.clearTokens(mcpServerUrl); await oauthConfig.storage.clearTokens(mcpServerUrl);
const reauthUrl = await client.authenticate(); const reauthUrl = await client.authenticate();
if (!reauthUrl) throw new Error("Expected reauth URL"); if (!reauthUrl) throw new Error("Expected reauth URL");
const code = await completeOAuthAuthorization(reauthUrl); const { code: code, iss: codeIss } =
await completeOAuthAuthorization(reauthUrl);
const callbackClient = new InspectorClient( const callbackClient = new InspectorClient(
{ {
@ -524,6 +537,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
); );
const connectSpy = vi.spyOn(callbackClient, "connect"); const connectSpy = vi.spyOn(callbackClient, "connect");
await callbackClient.resumeAfterOAuth(code, { await callbackClient.resumeAfterOAuth(code, {
iss: codeIss,
remoteSessionId: "dead-remote-session-id", remoteSessionId: "dead-remote-session-id",
}); });
expect(connectSpy).toHaveBeenCalled(); expect(connectSpy).toHaveBeenCalled();
@ -603,7 +617,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();
@ -693,7 +710,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
const authUrl = await client.authenticate(); const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); {
const { code, iss } = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(code, iss);
}
await client.connect(); await client.connect();
const tokens = await client.getOAuthTokens(); const tokens = await client.getOAuthTokens();

View file

@ -248,8 +248,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize"); expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
// Verify tokens are stored // Verify tokens are stored
@ -337,8 +338,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
const authUrl = await client1.authenticate(); const authUrl = await client1.authenticate();
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client1.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client1.completeOAuthFlow(authCode, authCodeIss);
await client1.connect(); await client1.connect();
const tokens1 = await client1.getOAuthTokens(); const tokens1 = await client1.getOAuthTokens();
@ -504,8 +506,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
if (!authUrl) throw new Error("Expected authorization URL"); if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize"); expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl); const { code: authCode, iss: authCodeIss } =
await client.completeOAuthFlow(authCode); await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode, authCodeIss);
await client.connect(); await client.connect();
// Verify tokens are stored // Verify tokens are stored

View file

@ -493,8 +493,9 @@ describe("InspectorClient OAuth", () => {
// connect() creates transport with authProvider; tokens are already in storage. // connect() creates transport with authProvider; tokens are already in storage.
const authorizationUrl = await testClient.authenticate(); const authorizationUrl = await testClient.authenticate();
if (!authorizationUrl) throw new Error("Expected authorization URL"); if (!authorizationUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authorizationUrl); const { code: authCode, iss: authCodeIss } =
await testClient.completeOAuthFlow(authCode); await completeOAuthAuthorization(authorizationUrl);
await testClient.completeOAuthFlow(authCode, authCodeIss);
await testClient.connect(); await testClient.connect();

View file

@ -1,4 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import {
SdkError,
SdkErrorCode,
UnauthorizedError,
} from "@modelcontextprotocol/client";
import { import {
OAUTH_CALLBACK_PATH, OAUTH_CALLBACK_PATH,
OAUTH_PENDING_SERVER_KEY, OAUTH_PENDING_SERVER_KEY,
@ -65,4 +70,14 @@ describe("isUnauthorizedError", () => {
expect(isUnauthorizedError(undefined)).toBe(false); expect(isUnauthorizedError(undefined)).toBe(false);
expect(isUnauthorizedError(42)).toBe(false); expect(isUnauthorizedError(42)).toBe(false);
}); });
it("unwraps EraNegotiationFailed → UnauthorizedError (data.cause)", () => {
expect(
isUnauthorizedError(
new SdkError(SdkErrorCode.EraNegotiationFailed, "probe failed", {
cause: new UnauthorizedError("Unauthorized"),
}),
),
).toBe(true);
});
}); });

View file

@ -29,8 +29,10 @@
* during `tsc -b`. We point at the @types declaration packages so the * during `tsc -b`. We point at the @types declaration packages so the
* imports type-check; Vite's `dedupe` handles the runtime resolution — * imports type-check; Vite's `dedupe` handles the runtime resolution —
* see vite.config.ts. * see vite.config.ts.
*
* Paths are relative to this config file (no `baseUrl` — deprecated in
* TypeScript 6 / removed in 7).
*/ */
"baseUrl": ".",
"paths": { "paths": {
"@inspector/core/*": ["../../core/*"], "@inspector/core/*": ["../../core/*"],
"react": ["./node_modules/@types/react"], "react": ["./node_modules/@types/react"],

View file

@ -31,8 +31,10 @@
* start-vite-dev-server.ts) live in src/ and import from @inspector/core/*; * start-vite-dev-server.ts) live in src/ and import from @inspector/core/*;
* tsc follows those transitive imports while type-checking vite.config.ts, * tsc follows those transitive imports while type-checking vite.config.ts,
* so this tsconfig needs the same alias tsconfig.app.json has. * so this tsconfig needs the same alias tsconfig.app.json has.
*
* Paths are relative to this config file (no `baseUrl` — deprecated in
* TypeScript 6 / removed in 7).
*/ */
"baseUrl": ".",
"paths": { "paths": {
"@inspector/core/*": ["../../core/*"], "@inspector/core/*": ["../../core/*"],
"pino": ["./node_modules/pino"], "pino": ["./node_modules/pino"],

View file

@ -67,11 +67,13 @@ export async function mintEmaResourceTokens(
} }
const audience = ctx.resourceAsUrl.href.replace(/\/$/, ""); const audience = ctx.resourceAsUrl.href.replace(/\/$/, "");
// SDK leg-2 helper requires `resource` on the wire; fall back to PRM `resource`.
const resource = ctx.resourceUrl?.href ?? ctx.resourceMetadata.resource;
const idJag = await exchangeIdJag({ const idJag = await exchangeIdJag({
idp: config.idp, idp: config.idp,
idToken, idToken,
audience, audience,
resource: ctx.resourceUrl?.href, resource,
scope: ctx.scope, scope: ctx.scope,
fetchFn: config.fetchFn, fetchFn: config.fetchFn,
}); });

View file

@ -1,16 +1,11 @@
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client"; import { discoverAndRequestJwtAuthGrant } from "@modelcontextprotocol/client";
import type { import type {
OAuthClientInformation, OAuthClientInformation,
OAuthTokens, OAuthTokens,
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import { OAuthTokensSchema } from "@modelcontextprotocol/core"; import { OAuthTokensSchema } from "@modelcontextprotocol/core";
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
import { import { GRANT_TYPE_JWT_BEARER } from "./constants.js";
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
TOKEN_TYPE_ID_JAG,
TOKEN_TYPE_ID_TOKEN,
} from "./constants.js";
import { parseHttpUrl } from "../utils.js"; import { parseHttpUrl } from "../utils.js";
import { discoverResourceAsMetadata } from "./resourceContext.js"; import { discoverResourceAsMetadata } from "./resourceContext.js";
import { normalizeIdpIssuer } from "./storage.js"; import { normalizeIdpIssuer } from "./storage.js";
@ -19,70 +14,64 @@ import {
postOAuthTokenRequest, postOAuthTokenRequest,
} from "./tokenEndpoint.js"; } from "./tokenEndpoint.js";
/** Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693). */ /**
* Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693).
*
* Delegates to SDK `discoverAndRequestJwtAuthGrant`. `resource` is required
* (the SDK always sends it on the wire). Callers should pass
* `resourceUrl ?? resourceMetadata.resource` from EMA resource context discovery.
*/
export async function exchangeIdJag(params: { export async function exchangeIdJag(params: {
idp: EnterpriseManagedAuthIdpConfig; idp: EnterpriseManagedAuthIdpConfig;
idToken: string; idToken: string;
audience: string; audience: string;
resource?: string; /** RFC 8707 resource indicator — required by the SDK Layer-2 helper. */
resource: string;
scope?: string; scope?: string;
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
}): Promise<string> { }): Promise<string> {
const issuer = normalizeIdpIssuer(params.idp.issuer); const issuer = normalizeIdpIssuer(params.idp.issuer);
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); const resource = params.resource.trim();
const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, { if (!resource) {
fetchFn: params.fetchFn, throw new Error("EMA leg 2 requires a resource identifier");
});
if (!idpMetadata?.token_endpoint) {
throw new Error("IdP metadata missing token_endpoint");
} }
const clientInformation = { let result: Awaited<ReturnType<typeof discoverAndRequestJwtAuthGrant>>;
client_id: params.idp.clientId, try {
client_secret: params.idp.clientSecret, result = await discoverAndRequestJwtAuthGrant({
token_endpoint_auth_method: "client_secret_post", idpUrl: issuer,
} as OAuthClientInformation; audience: params.audience,
resource,
const body = new URLSearchParams({ idToken: params.idToken,
grant_type: GRANT_TYPE_TOKEN_EXCHANGE, clientId: params.idp.clientId,
requested_token_type: TOKEN_TYPE_ID_JAG, clientSecret: params.idp.clientSecret,
subject_token: params.idToken, scope: params.scope,
subject_token_type: TOKEN_TYPE_ID_TOKEN, fetchFn: params.fetchFn,
audience: params.audience, });
}); } catch (err) {
if (params.resource) { const message = err instanceof Error ? err.message : String(err);
body.set("resource", params.resource); // Drift-sensitive: `@modelcontextprotocol/client` currently throws plain
} // `Error`s (not typed SdkError codes) for these failure modes. Remap to
if (params.scope) { // stable Inspector UX strings by matching SDK wording. Prefer typed errors
body.set("scope", params.scope); // when the SDK grows them. Canaries in `wire.test.ts` call the real helper
// so a wording change fails CI instead of silently falling through.
if (/Failed to discover token endpoint/i.test(message)) {
throw new Error("IdP metadata missing token_endpoint", { cause: err });
}
if (/Invalid token exchange response/i.test(message)) {
throw new Error("IdP token exchange did not return an ID-JAG", {
cause: err,
});
}
throw new Error(`EMA leg 2 (IdP token exchange for ID-JAG): ${message}`, {
cause: err,
});
} }
const response = await postOAuthTokenRequest( if (!result.jwtAuthGrant) {
parseHttpUrl(
idpMetadata.token_endpoint,
"IdP token_endpoint (from OIDC discovery)",
),
body,
idpMetadata,
clientInformation,
params.fetchFn,
);
if (!response.ok) {
throw await parseOAuthTokenErrorResponse(
response,
"EMA leg 2 (IdP token exchange for ID-JAG)",
);
}
const json = (await response.json()) as {
access_token?: string;
issued_token_type?: string;
};
const idJag = json.access_token;
if (!idJag) {
throw new Error("IdP token exchange did not return an ID-JAG"); throw new Error("IdP token exchange did not return an ID-JAG");
} }
return idJag; return result.jwtAuthGrant;
} }
/** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */ /** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */

View file

@ -1,46 +1,22 @@
/** /**
* SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports. * SEP-2350 scope helpers. Union / strict-superset algorithms come from
* On SDK v2 upgrade, delete this module and import `computeScopeUnion` / * `@modelcontextprotocol/client`; Inspector-only persistence helpers stay local.
* `isStrictScopeSuperset` from the client package instead.
*/ */
import { import {
parseScopeString, computeScopeUnion as sdkComputeScopeUnion,
unionAuthorizationScopes, isStrictScopeSuperset as sdkIsStrictScopeSuperset,
} from "./challenge.js"; } from "@modelcontextprotocol/client";
/** Union space-delimited scope strings (order-preserving, deduped). */ /** Union space-delimited scope strings (order-preserving, deduped). */
export function computeScopeUnion( export const computeScopeUnion = sdkComputeScopeUnion;
...scopes: ReadonlyArray<string | undefined>
): string | undefined {
let merged: string | undefined;
for (const scope of scopes) {
merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join(
" ",
);
if (!merged) {
merged = undefined;
}
}
return merged;
}
/** /**
* Whether `union` contains a scope token not present in `current`. * Whether `union` contains a scope token not present in `current`.
* When the AS omits `scope` on the token response, `current` is empty and any * When the AS omits `scope` on the token response, `current` is empty and any
* non-empty union is a strict superset — step-up must re-authorize, not refresh. * non-empty union is a strict superset — step-up must re-authorize, not refresh.
*/ */
export function isStrictScopeSuperset( export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
union: string | undefined,
current: string | undefined,
): boolean {
if (!union) return false;
const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean));
for (const token of union.split(/\s+/)) {
if (token && !currentSet.has(token)) return true;
}
return false;
}
/** /**
* Scope to persist after a successful token grant (RFC 6749 §5.1). * Scope to persist after a successful token grant (RFC 6749 §5.1).

View file

@ -1,3 +1,4 @@
import { UnauthorizedError } from "@modelcontextprotocol/client";
import type { CallbackParams } from "./types.js"; import type { CallbackParams } from "./types.js";
import { ZodError } from "zod"; import { ZodError } from "zod";
@ -172,13 +173,50 @@ export const generateOAuthErrorDescription = (
* transport preserves the status on the error object; as a fallback, match * transport preserves the status on the error object; as a fallback, match
* transport wording `"failed …(401)"` so unrelated `(401)` in messages does * transport wording `"failed …(401)"` so unrelated `(401)` in messages does
* not trigger OAuth. * not trigger OAuth.
*
* Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as
* `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at
* `error.data.cause` (and sometimes native `error.cause`). Walk that chain so
* OAuth recovery still starts.
*/ */
export function isUnauthorizedError(err: unknown): boolean { export function isUnauthorizedError(err: unknown): boolean {
if (typeof err === "object" && err !== null) { return isUnauthorizedErrorDeep(err, new Set());
const status = (err as { status?: number; code?: number }).status; }
const code = (err as { code?: number }).code;
if (status === 401 || code === 401) return true; function isUnauthorizedErrorDeep(err: unknown, seen: Set<unknown>): boolean {
if (err == null) return false;
if (typeof err !== "object") {
return /\bfailed\b[^\n]*\(401\)/i.test(String(err));
} }
const message = err instanceof Error ? err.message : String(err); if (seen.has(err)) return false;
return /\bfailed\b[^\n]*\(401\)/i.test(message); seen.add(err);
if (UnauthorizedError.isInstance(err)) return true;
const status = (err as { status?: number }).status;
const code = (err as { code?: unknown }).code;
if (status === 401 || code === 401) return true;
if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) {
return true;
}
if (
"cause" in err &&
isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen)
) {
return true;
}
const data = (err as { data?: unknown }).data;
if (
data !== null &&
typeof data === "object" &&
"cause" in data &&
isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen)
) {
return true;
}
return false;
} }

View file

@ -1,11 +1,12 @@
# Inspector V2 Authorization - Specification # Inspector V2 Authorization - Specification
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) ### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
#### Overview | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
#### Overview | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
Inspector v2's authorization support spans OAuth 2.1 connect-time flows, Enterprise-Managed Authorization (EMA / XAA), mid-session credential recovery, and the hardening required by the MCP `2026-07-28` specification. This page is the landing point for that work; each area has its own spec below. Inspector v2's authorization support spans OAuth 2.1 connect-time flows, Enterprise-Managed Authorization (EMA / XAA), mid-session credential recovery, and the hardening required by the MCP `2026-07-28` specification. This page is the landing point for that work; each area has its own spec below.
Inspector already ships connect-time OAuth and EMA infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). The specs here cover extending that foundation across web, TUI, and CLI. Inspector already ships connect-time OAuth and EMA infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). The specs here cover extending that foundation across web, TUI, and CLI. How that stack maps onto `@modelcontextprotocol/client` after the v2 upgrade is recorded in [SDK consolidation](v2_auth_sdk_consolidation.md).
## Authorization specs ## Authorization specs
@ -15,7 +16,7 @@ Support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/e
### [Hardening](v2_auth_hardening.md) ### [Hardening](v2_auth_hardening.md)
Aligns Inspector with the six authorization SEPs in the MCP `2026-07-28` release candidate — all OAuth client requirements (`iss` validation per RFC 9207, `application_type` in DCR, credentials bound to AS issuer, and more). Strategy: upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) and wire the gaps rather than reimplementing SDK OAuth logic. Covers connect-time and storage hardening that mid-session builds on. As-built status for the six MCP `2026-07-28` authorization SEPs ([#1527](https://github.com/modelcontextprotocol/inspector/issues/1527)): what is done (and how), per-SEP automated coverage, and remaining test/era-aware follow-ups.
### [Mid-session](v2_auth_mid_session.md) ### [Mid-session](v2_auth_mid_session.md)
@ -24,3 +25,7 @@ Detecting when in-flight MCP traffic needs new or elevated credentials, respondi
### [Smoke testing](v2_auth_smoke_testing.md) ### [Smoke testing](v2_auth_smoke_testing.md)
Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers, complementing the automated integration coverage. Records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI) via install-level `client.json`. Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers, complementing the automated integration coverage. Records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI) via install-level `client.json`.
### [SDK consolidation](v2_auth_sdk_consolidation.md)
As-built: what auth logic moved onto the v2 SDK, what stayed Inspector-owned (and why), and which small SDK helper gaps would unlock further deletions.

View file

@ -1,7 +1,8 @@
# Inspector V2 Auth — Enterprise-Managed Authorization (EMA / XAA) # Inspector V2 Auth — Enterprise-Managed Authorization (EMA / XAA)
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) ### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
#### [Overview](v2_auth.md) | EMA / XAA | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
#### [Overview](v2_auth.md) | EMA / XAA | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509).
@ -47,43 +48,40 @@ _Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol
Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today. Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today.
| Concern | Package / module | | Concern | Package / module |
| ------- | ---------------- | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Standard OAuth | v1 `@modelcontextprotocol/sdk/client/auth.js` via `BaseOAuthClientProvider` (`core/auth/providers.ts`) | | Standard OAuth | v1 `@modelcontextprotocol/sdk/client/auth.js` via `BaseOAuthClientProvider` (`core/auth/providers.ts`) |
| EMA leg 1 (IdP OIDC) | v1 SDK: `startAuthorization`, `exchangeAuthorization`, `discoverAuthorizationServerMetadata` — `core/auth/ema/idpOidc.ts` | | EMA leg 1 (IdP OIDC) | v1 SDK: `startAuthorization`, `exchangeAuthorization`, `discoverAuthorizationServerMetadata` — `core/auth/ema/idpOidc.ts` |
| EMA legs 2–3 (ID-JAG + resource token) | **Local wire** in `core/auth/ema/wire.ts` (RFC 8693 token exchange + RFC 7523 JWT bearer); reuses v1 SDK for AS discovery, `selectClientAuthMethod`, `OAuthTokensSchema` | | EMA legs 2–3 (ID-JAG + resource token) | **Leg 2:** SDK `discoverAndRequestJwtAuthGrant` via `exchangeIdJag` in `core/auth/ema/wire.ts`. **Leg 3:** still local JWT bearer in `wire.ts` + `tokenEndpoint.ts` (SDK `exchangeJwtAuthGrant` lacks `resource`/`scope` — see [SDK consolidation](v2_auth_sdk_consolidation.md#sdk-adoption-wishlist)) |
| EMA orchestration | `core/auth/ema/emaFlow.ts` — `mintEmaResourceTokens`, silent connect, connect/callback completion | | EMA orchestration | `core/auth/ema/emaFlow.ts` — `mintEmaResourceTokens`, silent connect, connect/callback completion |
| EMA 401 / transport | `core/auth/ema/transportProvider.ts` — `EmaTransportOAuthProvider` wraps v1 `BaseOAuthClientProvider`; re-runs legs 2–3 on expiry, IdP redirect on missing ID Token | | EMA 401 / transport | `core/auth/ema/transportProvider.ts` — `EmaTransportOAuthProvider` wraps v1 `BaseOAuthClientProvider`; re-runs legs 2–3 on expiry, IdP redirect on missing ID Token |
| JWT expiry helpers | `core/auth/ema/jwt.ts` — `jwtExpiresAtMs`, `isJwtExpired` (exp claim only; no signature verification) | | JWT expiry helpers | `core/auth/ema/jwt.ts` — `jwtExpiresAtMs`, `isJwtExpired` (exp claim only; no signature verification) |
| Leg 1 in-flight PKCE key | `core/auth/ema/storage.ts` — `idpOAuthStorageKey(issuer)` → `ema-idp:{issuer}` in per-server `servers` map (storage key only; not an OAuth `state` param prefix) | | Leg 1 in-flight PKCE key | `core/auth/ema/storage.ts` — `idpOAuthStorageKey(issuer)` → `ema-idp:{issuer}` in per-server `servers` map (storage key only; not an OAuth `state` param prefix) |
| Wire constants | `core/auth/ema/constants.ts` — grant/type URNs + `IDP_OIDC_SCOPES` for leg 1 | | Wire constants | `core/auth/ema/constants.ts` — grant/type URNs + `IDP_OIDC_SCOPES` for leg 1 |
**EMA is not in the v1 SDK** as a named feature (`CrossAppAccess`, ID-JAG helpers, etc.). Inspector implements the protocol in `core/auth/ema/` instead. **EMA is not in the v1 SDK** as a named feature (`CrossAppAccess`, ID-JAG helpers, etc.). Inspector implements the protocol in `core/auth/ema/` instead.
#### v2 `@modelcontextprotocol/client` (not used; future option) #### v2 `@modelcontextprotocol/client` Layer-2 EMA helpers
The v2 client package (`CrossAppAccessProvider`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) implements the same legs 2–3 wire format internally. It is **not** imported anywhere today. Possible future paths: | Leg | Status |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Leg 2** (`discoverAndRequestJwtAuthGrant`) | **Adopted** — `exchangeIdJag` wraps the SDK helper |
| **Leg 3** (`exchangeJwtAuthGrant`) | **Not adopted** — helper omits `resource`/`scope` on the JWT-bearer body; keep local `redeemIdJagForAccessToken` until the [SDK wishlist](v2_auth_sdk_consolidation.md#sdk-adoption-wishlist) gap is fixed |
| **`CrossAppAccessProvider`** | **Not adopted** — in-memory tokens; no fit for remoting / durable `OAuthStorage` / IdP leg 1 |
| Option | When | **Watch items:**
| ------ | ---- |
| **Swap legs 2–3 to v2 Layer-2 helpers** | Replace `wire.ts` calls with `discoverAndRequestJwtAuthGrant` / `exchangeJwtAuthGrant`; keep v1 transport + `EmaTransportOAuthProvider` |
| **Full v2 transport (Path B)** | v2 `StreamableHTTPClientTransport` + `CrossAppAccessProvider` for EMA servers only — requires v2 client stack; does **not** drop into v1 transport today |
**Why v1 + local wire for the first slice:** single SDK surface, custom `fetchFn` threading, inspector-owned scope/audience resolution (`resourceContext.ts`), and v1 `OAuthClientProvider` adapter for 401 re-auth — without pulling in alpha v2 client bundle surface.
**Watch items (unchanged):**
- **401 re-auth** — EMA connections re-run legs 2–3 (leg 1 only if ID Token missing/expired); do not fall back to standard resource OAuth redirect. - **401 re-auth** — EMA connections re-run legs 2–3 (leg 1 only if ID Token missing/expired); do not fall back to standard resource OAuth redirect.
- **Future v2 adoption** — when migrating to v2 wholesale, prefer SDK Layer-2 helpers or `CrossAppAccessProvider` over maintaining duplicate wire in `wire.ts`. - **Leg 3 SDK adoption** — when `exchangeJwtAuthGrant` grows `resource`/`scope`, swap local wire per the consolidation wishlist.
## Configuration ## Configuration
Config shape matches VS Code (verified against [`mcpConfiguration.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts)). **Two credential planes** — do not merge them: Config shape matches VS Code (verified against [`mcpConfiguration.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts)). **Two credential planes** — do not merge them:
| Plane | Scope | What it identifies | | Plane | Scope | What it identifies |
| ----- | ----- | ------------------ | | ---------------------------- | --------------------------------------------- | -------------------------------------------------------- |
| **IdP (client / tenant)** | Global — one per inspector install or session | Inspector's OIDC client at the enterprise IdP (legs 1–2) | | **IdP (client / tenant)** | Global — one per inspector install or session | Inspector's OIDC client at the enterprise IdP (legs 1–2) |
| **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) | | **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) |
### 1. Client / tenant IdP credentials (global) ### 1. Client / tenant IdP credentials (global)
@ -103,8 +101,8 @@ All three fields are the **IdP OIDC client** credentials used in leg 2 (ID-JAG m
Until **client profiles** (see below) land, install-level client config — starting with `enterpriseManagedAuth` — is stored in **`~/.mcp-inspector/storage/client.json`** (same generic storage API as other install-level blobs): Until **client profiles** (see below) land, install-level client config — starting with `enterpriseManagedAuth` — is stored in **`~/.mcp-inspector/storage/client.json`** (same generic storage API as other install-level blobs):
| Store | API (web) | On disk | Purpose | | Store | API (web) | On disk | Purpose |
| ----- | --------- | ------- | ------- | | ------------ | ------------------------------------- | -------------------------------------- | -------------------------------------------------------- |
| **`client`** | `GET/POST/DELETE /api/storage/client` | `~/.mcp-inspector/storage/client.json` | **Config** — IdP credentials, later client identity/caps | | **`client`** | `GET/POST/DELETE /api/storage/client` | `~/.mcp-inspector/storage/client.json` | **Config** — IdP credentials, later client identity/caps |
**Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file. **Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file.
@ -113,10 +111,10 @@ Until **client profiles** (see below) land, install-level client config — star
Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once. Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once.
| Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) | | Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) |
| ------ | ---------------------- | ----------------------------------- | | ------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Web** | `RemoteStorage` adapter → `/api/storage/client` | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` (via `getWebRemoteOAuthStorage()` in `clients/web/src/lib/remoteOAuthStorage.ts`) | | **Web** | `RemoteStorage` adapter → `/api/storage/client` | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` (via `getWebRemoteOAuthStorage()` in `clients/web/src/lib/remoteOAuthStorage.ts`) |
| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | | **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` |
On-disk shape (initial): On-disk shape (initial):
@ -183,7 +181,9 @@ const clientConfig = await loadClientConfig(); // client.json
new InspectorClient(serverConfig, { new InspectorClient(serverConfig, {
...opts, ...opts,
...(getActiveEnterpriseManagedAuthIdp(clientConfig) && { ...(getActiveEnterpriseManagedAuthIdp(clientConfig) && {
enterpriseManagedAuth: { idp: getActiveEnterpriseManagedAuthIdp(clientConfig)! }, enterpriseManagedAuth: {
idp: getActiveEnterpriseManagedAuthIdp(clientConfig)!,
},
}), }),
...(clientConfig.enterpriseManagedAuth && { ...(clientConfig.enterpriseManagedAuth && {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
@ -248,12 +248,12 @@ Extend the existing per-server `oauth` block already used for standard OAuth ([S
} }
``` ```
| Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` | | Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` |
| ----- | ------------------------------- | ------------------------------ | | ------------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
| `oauth.clientId` | OAuth client id (existing behavior) | **Resource AS** client id trusted by the protected resource | | `oauth.clientId` | OAuth client id (existing behavior) | **Resource AS** client id trusted by the protected resource |
| `oauth.clientSecret` | OAuth client secret (existing behavior) | **Resource AS** client secret (keychain-backed, same as today) | | `oauth.clientSecret` | OAuth client secret (existing behavior) | **Resource AS** client secret (keychain-backed, same as today) |
| `oauth.scopes` | OAuth scopes (existing behavior) | Scopes for the resource token request | | `oauth.scopes` | OAuth scopes (existing behavior) | Scopes for the resource token request |
| `oauth.enterpriseManaged` | omitted / `false` | `true` — route to EMA flow instead of standard authorization-code OAuth | | `oauth.enterpriseManaged` | omitted / `false` | `true` — route to EMA flow instead of standard authorization-code OAuth |
Types to extend in `core/mcp/types.ts`: Types to extend in `core/mcp/types.ts`:
@ -297,7 +297,7 @@ Install-level IdP credentials are edited in **Client Settings**, separate from p
1. Clears `idpSessions[issuer]` 1. Clears `idpSessions[issuer]`
2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]` 2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]`
3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag. 3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag.
The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`. The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`.
- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory `OAuthStorageBase` view as the active `InspectorClient`. - **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory `OAuthStorageBase` view as the active `InspectorClient`.
**Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP. **Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP.
@ -317,10 +317,10 @@ CLI/TUI do not have a dedicated Client Settings dialog. They **load** `client.js
When the user connects to a server with `oauth.enterpriseManaged: true` but install-level IdP is not active, `OAuthManager.getEmaFlowConfig()` throws `EmaClientNotConfiguredError` (`core/auth/ema/clientConfigError.ts`) with a reason-specific message: When the user connects to a server with `oauth.enterpriseManaged: true` but install-level IdP is not active, `OAuthManager.getEmaFlowConfig()` throws `EmaClientNotConfiguredError` (`core/auth/ema/clientConfigError.ts`) with a reason-specific message:
| Reason | When | User-facing guidance | | Reason | When | User-facing guidance |
| ------ | ---- | -------------------- | | ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `not_configured` | No IdP block, or issuer/client id missing | Open Client Settings, enable Enterprise IdP, set issuer, client ID, and secret | | `not_configured` | No IdP block, or issuer/client id missing | Open Client Settings, enable Enterprise IdP, set issuer, client ID, and secret |
| `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry | | `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry |
`installEnterpriseManagedAuth` on `InspectorClientOptions` supplies the full install config so the error can distinguish disabled from never configured. Web surfaces this in connect, OAuth auth, and post-callback reconnect paths (`App.tsx`): toast title `Cannot connect to "<server name>"`, message from the error, `autoClose: false` (stays until dismissed). `installEnterpriseManagedAuth` on `InspectorClientOptions` supplies the full install config so the error can distinguish disabled from never configured. Web surfaces this in connect, OAuth auth, and post-callback reconnect paths (`App.tsx`): toast title `Cannot connect to "<server name>"`, message from the error, `autoClose: false` (stays until dismissed).
@ -344,10 +344,10 @@ Leg 1 is **OIDC authorization-code login** against the enterprise IdP using cred
**Reuse existing OAuth client infrastructure** — leg 1 is not a new auth stack. It uses the same per-client mechanisms already wired for standard MCP resource OAuth (`OAuthManager`, redirect/callback, PKCE, token exchange). Only the target changes: IdP endpoints and `client.json` credentials instead of the resource authorization server and per-server `oauth.*` fields. **Reuse existing OAuth client infrastructure** — leg 1 is not a new auth stack. It uses the same per-client mechanisms already wired for standard MCP resource OAuth (`OAuthManager`, redirect/callback, PKCE, token exchange). Only the target changes: IdP endpoints and `client.json` credentials instead of the resource authorization server and per-server `oauth.*` fields.
| Client | Leg 1 mechanism (same stack as standard OAuth in that client) | | Client | Leg 1 mechanism (same stack as standard OAuth in that client) |
| ------ | ------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `RemoteOAuthStorage` → shared `oauth.json` | | **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `RemoteOAuthStorage` → shared `oauth.json` |
| **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` | | **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` |
**Silent connect:** if a valid cached ID Token exists for the configured issuer, skip the interactive redirect and proceed to leg 2. **Silent connect:** if a valid cached ID Token exists for the configured issuer, skip the interactive redirect and proceed to leg 2.
@ -373,11 +373,11 @@ When an EMA server returns **403** + `insufficient_scope`, `OAuthManager.handleA
**Web UX after Authorize:** **Web UX after Authorize:**
| Outcome | User sees | | Outcome | User sees |
| ------- | --------- | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `satisfied` (silent re-mint) | Blue in-progress toast → green “Organization permissions were updated” (+ “Retry your action” when step-up was triggered by a tool/prompt/resource/app) | | `satisfied` (silent re-mint) | Blue in-progress toast → green “Organization permissions were updated” (+ “Retry your action” when step-up was triggered by a tool/prompt/resource/app) |
| `interactive` (IdP redirect) | Pre-redirect toast → full-page IdP callback → same resume snapshot behavior as other EMA flows | | `interactive` (IdP redirect) | Pre-redirect toast → full-page IdP callback → same resume snapshot behavior as other EMA flows |
| `failed` | Red toast with error detail; triggering panel shows failure | | `failed` | Red toast with error detail; triggering panel shows failure |
TUI/CLI still use their existing confirm prompts (Auth tab **A**/**C**, CLI **y/N**) before silent re-mint — no web modal. TUI/CLI still use their existing confirm prompts (Auth tab **A**/**C**, CLI **y/N**) before silent re-mint — no web modal.
@ -428,11 +428,11 @@ Inspector touchpoints to extend:
### Clients ### Clients
| Client | Notes | | Client | Notes |
| ------ | ----- | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); connect via `authenticate()` on 401 or explicit auth; leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) | | Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); connect via `authenticate()` on 401 or explicit auth; leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) |
| TUI | Follow web; extend existing OAuth auth tab (`clients/tui/src/App.tsx`, `AuthTab`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow | | TUI | Follow web; extend existing OAuth auth tab (`clients/tui/src/App.tsx`, `AuthTab`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow |
| CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required | | CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required |
### Implementation order (settled) ### Implementation order (settled)
@ -503,8 +503,8 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che
- [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. - [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI.
- [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI. - [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI.
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional). - [ ] _(optional)_ `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional).
- [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. - [ ] _(optional)_ 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run.
### Phase 4 — Other clients ### Phase 4 — Other clients
@ -528,11 +528,11 @@ Full end-to-end EMA has been exercised manually against live **xaa.dev** (June 2
### Topology ### Topology
| Role | Service | Inspector config | | Role | Service | Inspector config |
| ---- | ------- | ---------------- | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Enterprise IdP** (legs 1–2) | `https://idp.xaa.dev` | **Client Settings** — issuer, requesting-app client id/secret | | **Enterprise IdP** (legs 1–2) | `https://idp.xaa.dev` | **Client Settings** — issuer, requesting-app client id/secret |
| **Resource authorization server** (leg 3) | `https://auth.resource.xaa.dev` (xaa.dev resource AS) | Per-server **Server Settings** — resource test client id/secret, EMA enabled, scopes | | **Resource authorization server** (leg 3) | `https://auth.resource.xaa.dev` (xaa.dev resource AS) | Per-server **Server Settings** — resource test client id/secret, EMA enabled, scopes |
| **Protected MCP resource** | Composable test server (`test-servers/configs/xaa-ema-http.json`) — `protected-resource` mode, local streamable-http | Server catalog entry URL must match registered resource identifier (`http://localhost:8080/` — use `localhost`, not `127.0.0.1`) | | **Protected MCP resource** | Composable test server (`test-servers/configs/xaa-ema-http.json`) — `protected-resource` mode, local streamable-http | Server catalog entry URL must match registered resource identifier (`http://localhost:8080/` — use `localhost`, not `127.0.0.1`) |
Registration on xaa.dev: composable test server registered as a **resource server**; its AS points at xaa.dev (`authorizationServers` / JWKS in `xaa-ema-http.json`). IdP and resource AS are distinct hosts on xaa.dev. Registration on xaa.dev: composable test server registered as a **resource server**; its AS points at xaa.dev (`authorizationServers` / JWKS in `xaa-ema-http.json`). IdP and resource AS are distinct hosts on xaa.dev.
@ -557,11 +557,11 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi
`OAuthStorage` is implemented by **`OAuthStorageBase`** (`core/auth/oauth-storage.ts`) backed by **`OAuthMemoryStore`** (`core/auth/store.ts`) and an explicit **`OAuthPersistBackend`** (`core/auth/oauth-persist.ts`): `OAuthStorage` is implemented by **`OAuthStorageBase`** (`core/auth/oauth-storage.ts`) backed by **`OAuthMemoryStore`** (`core/auth/store.ts`) and an explicit **`OAuthPersistBackend`** (`core/auth/oauth-persist.ts`):
| Backend | Class | Where | | Backend | Class | Where |
| ------- | ----- | ----- | | ----------- | --------------------- | ----------------------------------------------------------------- |
| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` | | File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` |
| Remote HTTP | `RemoteOAuthStorage` | Web → `GET/POST/DELETE /api/storage/oauth` on the Hono backend | | Remote HTTP | `RemoteOAuthStorage` | Web → `GET/POST/DELETE /api/storage/oauth` on the Hono backend |
| Session | `BrowserOAuthStorage` | Reference/tests only; v2 web uses shared file-backed remote store | | Session | `BrowserOAuthStorage` | Reference/tests only; v2 web uses shared file-backed remote store |
**On disk / wire:** writes plain JSON `{ servers, idpSessions }`. **Reads** still accept legacy blobs wrapped as `{ state: { servers, idpSessions }, version }` (produced by the old Zustand `persist` middleware) and promote the inner payload — migrate-on-write on the next save. **On disk / wire:** writes plain JSON `{ servers, idpSessions }`. **Reads** still accept legacy blobs wrapped as `{ state: { servers, idpSessions }, version }` (produced by the old Zustand `persist` middleware) and promote the inner payload — migrate-on-write on the next save.
@ -573,10 +573,10 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi
**Status (July 2026):** Web is wired through `RemoteOAuthStorage` (`environmentFactory.ts`, `App.tsx`, `getWebRemoteOAuthStorage()`). CLI/TUI continue to use `NodeOAuthStorage` on the same on-disk file when using the default local backend. **Status (July 2026):** Web is wired through `RemoteOAuthStorage` (`environmentFactory.ts`, `App.tsx`, `getWebRemoteOAuthStorage()`). CLI/TUI continue to use `NodeOAuthStorage` on the same on-disk file when using the default local backend.
| Client | OAuth runtime store | | Client | OAuth runtime store |
| ------ | ------------------- | | ------------- | ----------------------------------------------------------------------------------- |
| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | | **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` |
| **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` | | **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` |
`RemoteOAuthStorage` (`core/auth/remote/storage-remote.ts`) talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`). `RemoteOAuthStorage` (`core/auth/remote/storage-remote.ts`) talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`).
@ -607,16 +607,16 @@ Goal: CI tests that prove EMA legs 1–3 through `InspectorClient` / `OAuthManag
**Files:** `clients/web/src/test/core/auth/ema/wire.test.ts`, `emaFlow.test.ts` **Files:** `clients/web/src/test/core/auth/ema/wire.test.ts`, `emaFlow.test.ts`
| Case | Status | | Case | Status |
| ---- | ------ | | ---------------------------------------------------- | -------------------------------------------------------------------- |
| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape | | `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape |
| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message | | `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message |
| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint | | `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint |
| `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass | | `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass |
| Missing ID Token | ✅ | | Missing ID Token | ✅ |
| Missing resource secret | ✅ | | Missing resource secret | ✅ |
| `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` | | `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` |
| `trySilentEmaAuth` returns false without IdP session | ✅ | | `trySilentEmaAuth` returns false without IdP session | ✅ |
Uses `vi.fn()` fetch — no listening ports. Runs in `unit` project. Uses `vi.fn()` fetch — no listening ports. Runs in `unit` project.
@ -635,14 +635,14 @@ Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `c
**File:** `clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts` **File:** `clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts`
| Step | Status | | Step | Status |
| ---- | ------ | | ---- | ------------------------------------------------------------------------------------------------------------- |
| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) | | 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) |
| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports | | 2 | ✅ Mock IdP + mock resource AS on ephemeral ports |
| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials | | 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials |
| 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) | | 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) |
| 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` | | 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` |
| 6 | ✅ Second connect reuses silent path; persisted tokens tagged `enterpriseManaged: true` | | 6 | ✅ Second connect reuses silent path; persisted tokens tagged `enterpriseManaged: true` |
Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only). Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only).
@ -673,8 +673,8 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato
- [x] Layer 1 tests merged - [x] Layer 1 tests merged
- [x] Layer 3 happy-path integration test green in CI - [x] Layer 3 happy-path integration test green in CI
- [x] Document in test file how mock topology maps to xaa.dev staging (comment block + link to `xaa-ema-http.json`) - [x] Document in test file how mock topology maps to xaa.dev staging (comment block + link to `xaa-ema-http.json`)
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant - [ ] _(optional)_ `RemoteOAuthStorage` EMA E2E variant
- [ ] *(optional)* 401 re-auth stretch case - [ ] _(optional)_ 401 re-auth stretch case
--- ---
@ -724,6 +724,7 @@ In `mainThreadMcp`, enterprise-managed path does these implementation-specific c
7. Call session acquisition with `audience` and `resource` in options. 7. Call session acquisition with `audience` and `resource` in options.
All of this is implemented in: All of this is implemented in:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts
### A.4 Provider lifecycle and host plumbing ### A.4 Provider lifecycle and host plumbing
@ -794,6 +795,7 @@ Functions to copy conceptually:
3. JWT claims parsing helpers used to infer `client_id` from assertion 3. JWT claims parsing helpers used to infer `client_id` from assertion
Tests for body semantics: Tests for body semantics:
- https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts - https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts
### A.7 Secret storage model in this implementation ### A.7 Secret storage model in this implementation
@ -877,6 +879,7 @@ If you need a frozen reference (won't drift as `main` changes), use commit-pinne
- https://github.com/microsoft/vscode/tree/2d95154af0f50db2a41ceee431acfc6d79638aab - https://github.com/microsoft/vscode/tree/2d95154af0f50db2a41ceee431acfc6d79638aab
For example: For example:
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/common/extHostXaaAuthProvider.ts - https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/common/extHostXaaAuthProvider.ts
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadMcp.ts - https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadMcp.ts
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts - https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts

Some files were not shown because too many files have changed in this diff Show more