From df9ac85f78cda9cd0b8cbf97a8f7b790aa4549da Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 18 Jul 2026 22:45:55 -0700 Subject: [PATCH 1/3] Close auth hardening CI gaps and lean EMA/scopes on the v2 SDK. Add SEP-2468/2207/2351/2352 automated coverage, unwrap EraNegotiationFailed 401s for non-legacy era, and document the SDK consolidation path. Co-authored-by: Cursor --- .../cli/__tests__/oauth-interactive.test.ts | 12 +- clients/cli/tsconfig.json | 1 - clients/tui/tsconfig.json | 1 - .../web/src/test/core/auth/ema/wire.test.ts | 185 +++++---- clients/web/src/test/core/auth/utils.test.ts | 35 ++ .../helpers/oauth-client-fixtures.ts | 14 +- ...lient-oauth-direct-mid-session-e2e.test.ts | 17 +- .../mcp/inspectorClient-oauth-e2e.test.ts | 344 +++++++++++++++-- ...lient-oauth-remote-mid-session-e2e.test.ts | 44 ++- ...torClient-oauth-remote-storage-e2e.test.ts | 15 +- .../mcp/inspectorClient-oauth.test.ts | 5 +- clients/web/src/utils/oauthFlow.test.ts | 15 + clients/web/tsconfig.app.json | 4 +- clients/web/tsconfig.node.json | 4 +- core/auth/ema/emaFlow.ts | 4 +- core/auth/ema/wire.ts | 98 ++--- core/auth/scopes.ts | 38 +- core/auth/utils.ts | 50 ++- specification/v2_auth.md | 11 +- specification/v2_auth_ema.md | 189 ++++----- specification/v2_auth_hardening.md | 365 +++++++++--------- specification/v2_auth_mid_session.md | 247 ++++++------ specification/v2_auth_sdk_consolidation.md | 136 +++++++ specification/v2_auth_smoke_testing.md | 347 +++++++++-------- specification/v2_new_spec_impact.md | 174 +++++---- test-servers/src/test-server-oauth.ts | 13 +- tsconfig.base.json | 3 +- 27 files changed, 1497 insertions(+), 874 deletions(-) create mode 100644 specification/v2_auth_sdk_consolidation.md diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts index 358caaf7a..ec5437e6e 100644 --- a/clients/cli/__tests__/oauth-interactive.test.ts +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -29,7 +29,7 @@ const oauthTestStatePath = join( async function completeOAuthAuthorization( authorizationUrl: URL, -): Promise { +): Promise<{ code: string; iss?: string }> { let response = await fetch(authorizationUrl.toString(), { redirect: "manual", }); @@ -62,19 +62,23 @@ async function completeOAuthAuthorization( if (!code) { throw new Error("Missing authorization code"); } - return code; + const iss = redirect.searchParams.get("iss") ?? undefined; + return iss ? { code, iss } : { code }; } function createAutoCompleteNavigation( redirectUrlProvider: MutableRedirectUrlProvider, ) { return new CallbackNavigation(async (url) => { - const code = await completeOAuthAuthorization(url); + const { code, iss } = await completeOAuthAuthorization(url); const redirect = redirectUrlProvider.redirectUrl; if (!redirect) { 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); }); } diff --git a/clients/cli/tsconfig.json b/clients/cli/tsconfig.json index 6c9071844..3f1d5b5e2 100644 --- a/clients/cli/tsconfig.json +++ b/clients/cli/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"] } diff --git a/clients/tui/tsconfig.json b/clients/tui/tsconfig.json index 531a0ddef..6f42313b1 100644 --- a/clients/tui/tsconfig.json +++ b/clients/tui/tsconfig.json @@ -4,7 +4,6 @@ "jsx": "react-jsx", "outDir": "./build", "rootDir": ".", - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"] } diff --git a/clients/web/src/test/core/auth/ema/wire.test.ts b/clients/web/src/test/core/auth/ema/wire.test.ts index d9c981175..e997a53fc 100644 --- a/clients/web/src/test/core/auth/ema/wire.test.ts +++ b/clients/web/src/test/core/auth/ema/wire.test.ts @@ -1,14 +1,9 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { exchangeIdJag, redeemIdJagForAccessToken, } from "@inspector/core/auth/ema/wire.js"; -import { - GRANT_TYPE_JWT_BEARER, - GRANT_TYPE_TOKEN_EXCHANGE, - TOKEN_TYPE_ID_JAG, - TOKEN_TYPE_ID_TOKEN, -} from "@inspector/core/auth/ema/constants.js"; +import { GRANT_TYPE_JWT_BEARER } from "@inspector/core/auth/ema/constants.js"; import { EMA_MOCK_IDP_CLIENT_ID, EMA_MOCK_IDP_CLIENT_SECRET, @@ -17,16 +12,28 @@ import { minimalOAuthAsMetadata, } from "../../../integration/mcp/ema-mock-servers.js"; +const { mockDiscoverAndRequestJwtAuthGrant } = vi.hoisted(() => ({ + mockDiscoverAndRequestJwtAuthGrant: vi.fn(), +})); + +vi.mock("@modelcontextprotocol/client", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + discoverAndRequestJwtAuthGrant: ( + ...args: Parameters + ) => mockDiscoverAndRequestJwtAuthGrant(...args), + }; +}); + const IDP_ISSUER = "https://mock-idp.test"; const AS_ISSUER = "https://mock-as.test"; +const MCP_RESOURCE = "https://mcp.test/resource"; const ID_TOKEN = "header.idpayload.sig"; const ID_JAG = "mock-id-jag-token"; const ACCESS_TOKEN = "mock-resource-access-token"; -function idpMetadata() { - return minimalOAuthAsMetadata(IDP_ISSUER); -} - function asMetadata() { return { ...minimalOAuthAsMetadata(AS_ISSUER), @@ -35,33 +42,16 @@ function asMetadata() { } describe("ema wire", () => { + beforeEach(() => { + mockDiscoverAndRequestJwtAuthGrant.mockReset(); + }); + describe("exchangeIdJag", () => { - it("posts RFC 8693 token exchange and returns ID-JAG", async () => { - const fetchFn = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - 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}`); - }, - ); + it("delegates to the SDK ID-JAG helper and returns the grant", async () => { + mockDiscoverAndRequestJwtAuthGrant.mockResolvedValueOnce({ + jwtAuthGrant: ID_JAG, + authorizationServerUrl: IDP_ISSUER, + }); const idJag = await exchangeIdJag({ idp: { @@ -71,19 +61,26 @@ describe("ema wire", () => { }, idToken: ID_TOKEN, audience: AS_ISSUER, - fetchFn, + resource: MCP_RESOURCE, + scope: "mcp", }); 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 () => { - const errorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - // All discovery endpoints 404 → SDK returns undefined metadata. - const fetchFn = vi.fn( - async () => new Response("not found", { status: 404 }), + mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce( + new Error("Failed to discover token endpoint"), ); await expect( @@ -95,28 +92,14 @@ describe("ema wire", () => { }, idToken: ID_TOKEN, audience: AS_ISSUER, - fetchFn, + resource: MCP_RESOURCE, }), ).rejects.toThrow(/IdP metadata missing token_endpoint/); - errorSpy.mockRestore(); }); it("sets resource and scope params and throws when no ID-JAG is returned", async () => { - const fetchFn = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - 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("resource")).toBe("https://mcp.test/resource"); - expect(body.get("scope")).toBe("mcp profile"); - // 200 OK but empty access_token → ID-JAG missing branch. - return new Response(JSON.stringify({ issued_token_type: "x" })); - } - throw new Error(`unexpected fetch: ${url}`); - }, + mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce( + new Error("Invalid token exchange response"), ); await expect( @@ -128,26 +111,23 @@ describe("ema wire", () => { }, idToken: ID_TOKEN, audience: AS_ISSUER, - resource: "https://mcp.test/resource", + resource: MCP_RESOURCE, scope: "mcp profile", - fetchFn, }), ).rejects.toThrow(/did not return an ID-JAG/); + + expect(mockDiscoverAndRequestJwtAuthGrant).toHaveBeenCalledWith( + expect.objectContaining({ + resource: MCP_RESOURCE, + scope: "mcp profile", + }), + ); }); it("throws when IdP token exchange fails", async () => { - const fetchFn = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.includes("/.well-known/oauth-authorization-server")) { - return new Response(JSON.stringify(idpMetadata())); - } - if (url === `${IDP_ISSUER}/token`) { - return new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - }); - } - throw new Error(`unexpected fetch: ${url}`); - }); + mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce( + new Error("invalid_grant"), + ); await expect( exchangeIdJag({ @@ -158,10 +138,63 @@ describe("ema wire", () => { }, idToken: ID_TOKEN, audience: AS_ISSUER, - fetchFn, + 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( + 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(/did not return an ID-JAG/); + }); }); describe("redeemIdJagForAccessToken", () => { diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index 8852a1a51..d40552b9a 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -8,6 +8,11 @@ import { formatOAuthFailureDetail, isUnauthorizedError, } from "@inspector/core/auth/utils.js"; +import { + SdkError, + SdkErrorCode, + UnauthorizedError, +} from "@modelcontextprotocol/client"; import { z, ZodError } from "zod"; describe("parseHttpUrl", () => { @@ -263,6 +268,36 @@ describe("isUnauthorizedError", () => { it("returns false for null", () => { 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", () => { diff --git a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts index f46e4780e..48213cd32 100644 --- a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts +++ b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts @@ -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. * GET shows an HTML consent page; approve via POST (same as CLI test helper). * * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event - * @returns Authorization code extracted from redirect URL + * @returns Authorization code and optional RFC 9207 `iss` from the redirect */ export async function completeOAuthAuthorization( authorizationUrl: URL, -): Promise { +): Promise { let response = await fetch(authorizationUrl.toString(), { redirect: "manual", }); @@ -180,5 +187,6 @@ export async function completeOAuthAuthorization( throw new Error(`No authorization code in redirect URL: ${redirectUrl}`); } - return code; + const iss = redirectUrlObj.searchParams.get("iss") ?? undefined; + return iss ? { code, iss } : { code }; } diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts index 605d5558e..be981518d 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts @@ -124,7 +124,10 @@ describe("InspectorClient direct mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const tokens = await client.getOAuthTokens(); @@ -205,7 +208,10 @@ describe("InspectorClient direct mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const toolsResult = await client.listTools(); @@ -227,10 +233,9 @@ describe("InspectorClient direct mid-session OAuth", () => { "weather:read", ); - const stepUpCode = await completeOAuthAuthorization( - recovery!.authorizationUrl, - ); - await client.completeOAuthFlow(stepUpCode); + const { code: stepUpCode, iss: stepUpCodeIss } = + await completeOAuthAuthorization(recovery!.authorizationUrl); + await client.completeOAuthFlow(stepUpCode, stepUpCodeIss); const result = await client.callTool(getTempTool!, { city: "NYC", diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index e062e9cbb..127b231b4 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -28,6 +28,7 @@ import { getDCRRequests, invalidateAccessToken, } from "@modelcontextprotocol/inspector-test-server"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client"; import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js"; import { createOAuthClientConfig, @@ -180,8 +181,9 @@ describe("InspectorClient OAuth E2E", () => { expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); const stateAfterComplete = client.getOAuthFlowState(); @@ -260,8 +262,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); expect(client.getStatus()).toBe("connected"); @@ -354,8 +357,9 @@ describe("InspectorClient OAuth E2E", () => { if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); // Verify tokens are stored @@ -430,8 +434,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); expect(client.getStatus()).toBe("connected"); @@ -498,8 +503,9 @@ describe("InspectorClient OAuth E2E", () => { expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); const stateAfterComplete = client.getOAuthFlowState(); @@ -567,8 +573,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); const dcr = getDCRRequests(); @@ -624,8 +631,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrlFirst = await client.authenticate(); if (!authUrlFirst) throw new Error("Expected authorization URL"); - const authCodeFirst = await completeOAuthAuthorization(authUrlFirst); - await client.completeOAuthFlow(authCodeFirst); + const { code: authCodeFirst, iss: authCodeFirstIss } = + await completeOAuthAuthorization(authUrlFirst); + await client.completeOAuthFlow(authCodeFirst, authCodeFirstIss); await client.connect(); expect(client.getStatus()).toBe("connected"); @@ -634,8 +642,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrlSecond = await client.authenticate(); if (!authUrlSecond) throw new Error("Expected authorization URL"); - const authCodeSecond = await completeOAuthAuthorization(authUrlSecond); - await client.completeOAuthFlow(authCodeSecond); + const { code: authCodeSecond, iss: authCodeSecondIss } = + await completeOAuthAuthorization(authUrlSecond); + await client.completeOAuthFlow(authCodeSecond, authCodeSecondIss); await client.connect(); expect(client.getStatus()).toBe("connected"); }); @@ -836,8 +845,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); expect(await client.isOAuthAuthorized()).toBe(true); @@ -906,8 +916,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); const tokens = await client.getOAuthTokens(); @@ -986,8 +997,9 @@ describe("InspectorClient OAuth E2E", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); const tokens = await client.getOAuthTokens(); @@ -1065,8 +1077,9 @@ describe("InspectorClient OAuth E2E", () => { try { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); expect(client.getStatus()).toBe("connected"); @@ -1181,8 +1194,9 @@ describe("InspectorClient OAuth E2E", () => { if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); expect(client.getStatus()).toBe("connected"); @@ -1196,6 +1210,19 @@ describe("InspectorClient OAuth E2E", () => { ); 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 const fetchRequests = fetchRequestLogState.getFetchRequests(); const authFetches = fetchRequests.filter((r) => r.category === "auth"); @@ -1206,4 +1233,269 @@ describe("InspectorClient OAuth E2E", () => { 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(); + } + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts index a825be954..4cde2b6bd 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts @@ -191,7 +191,10 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const tokens = await client.getOAuthTokens(); @@ -273,7 +276,10 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const tokens = await client.getOAuthTokens(); @@ -361,7 +367,10 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const toolsResult = await client.listTools(); @@ -396,9 +405,8 @@ describe("InspectorClient remote mid-session OAuth", () => { "weather:read", ); - const stepUpCode = await completeOAuthAuthorization( - recovery!.authorizationUrl, - ); + const { code: stepUpCode, iss: stepUpCodeIss } = + await completeOAuthAuthorization(recovery!.authorizationUrl); // Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth // storage, reattach to the live remote backend session. @@ -409,7 +417,10 @@ describe("InspectorClient remote mid-session OAuth", () => { } as MCPServerConfig, clientConfig, ); - await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId }); + await callbackClient.resumeAfterOAuth(stepUpCode, { + iss: stepUpCodeIss, + remoteSessionId, + }); expect(callbackClient.getStatus()).toBe("connected"); @@ -505,15 +516,17 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const initialCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(initialCode); + const { code: initialCode, iss: initialCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(initialCode, initialCodeIss); await client.connect(); const mcpServerUrl = `${serverUrl}/mcp`; await oauthConfig.storage.clearTokens(mcpServerUrl); const reauthUrl = await client.authenticate(); if (!reauthUrl) throw new Error("Expected reauth URL"); - const code = await completeOAuthAuthorization(reauthUrl); + const { code: code, iss: codeIss } = + await completeOAuthAuthorization(reauthUrl); const callbackClient = new InspectorClient( { @@ -524,6 +537,7 @@ describe("InspectorClient remote mid-session OAuth", () => { ); const connectSpy = vi.spyOn(callbackClient, "connect"); await callbackClient.resumeAfterOAuth(code, { + iss: codeIss, remoteSessionId: "dead-remote-session-id", }); expect(connectSpy).toHaveBeenCalled(); @@ -603,7 +617,10 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const tokens = await client.getOAuthTokens(); @@ -693,7 +710,10 @@ describe("InspectorClient remote mid-session OAuth", () => { const authUrl = await client.authenticate(); 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(); const tokens = await client.getOAuthTokens(); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts index f36f9651c..6cbdec260 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -248,8 +248,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); // Verify tokens are stored @@ -337,8 +338,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { const authUrl = await client1.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client1.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client1.completeOAuthFlow(authCode, authCodeIss); await client1.connect(); const tokens1 = await client1.getOAuthTokens(); @@ -504,8 +506,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode, authCodeIss); await client.connect(); // Verify tokens are stored diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts index edab9e771..e2e7e529b 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts @@ -493,8 +493,9 @@ describe("InspectorClient OAuth", () => { // connect() creates transport with authProvider; tokens are already in storage. const authorizationUrl = await testClient.authenticate(); if (!authorizationUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authorizationUrl); - await testClient.completeOAuthFlow(authCode); + const { code: authCode, iss: authCodeIss } = + await completeOAuthAuthorization(authorizationUrl); + await testClient.completeOAuthFlow(authCode, authCodeIss); await testClient.connect(); diff --git a/clients/web/src/utils/oauthFlow.test.ts b/clients/web/src/utils/oauthFlow.test.ts index 9896edd7b..bc16e65c8 100644 --- a/clients/web/src/utils/oauthFlow.test.ts +++ b/clients/web/src/utils/oauthFlow.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect } from "vitest"; +import { + SdkError, + SdkErrorCode, + UnauthorizedError, +} from "@modelcontextprotocol/client"; import { OAUTH_CALLBACK_PATH, OAUTH_PENDING_SERVER_KEY, @@ -65,4 +70,14 @@ describe("isUnauthorizedError", () => { expect(isUnauthorizedError(undefined)).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); + }); }); diff --git a/clients/web/tsconfig.app.json b/clients/web/tsconfig.app.json index 62da4bd08..df982811f 100644 --- a/clients/web/tsconfig.app.json +++ b/clients/web/tsconfig.app.json @@ -29,8 +29,10 @@ * during `tsc -b`. We point at the @types declaration packages so the * imports type-check; Vite's `dedupe` handles the runtime resolution — * see vite.config.ts. + * + * Paths are relative to this config file (no `baseUrl` — deprecated in + * TypeScript 6 / removed in 7). */ - "baseUrl": ".", "paths": { "@inspector/core/*": ["../../core/*"], "react": ["./node_modules/@types/react"], diff --git a/clients/web/tsconfig.node.json b/clients/web/tsconfig.node.json index a2ff33462..813891214 100644 --- a/clients/web/tsconfig.node.json +++ b/clients/web/tsconfig.node.json @@ -31,8 +31,10 @@ * start-vite-dev-server.ts) live in src/ and import from @inspector/core/*; * tsc follows those transitive imports while type-checking vite.config.ts, * 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": { "@inspector/core/*": ["../../core/*"], "pino": ["./node_modules/pino"], diff --git a/core/auth/ema/emaFlow.ts b/core/auth/ema/emaFlow.ts index eb3f40f0b..99f773019 100644 --- a/core/auth/ema/emaFlow.ts +++ b/core/auth/ema/emaFlow.ts @@ -67,11 +67,13 @@ export async function mintEmaResourceTokens( } 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({ idp: config.idp, idToken, audience, - resource: ctx.resourceUrl?.href, + resource, scope: ctx.scope, fetchFn: config.fetchFn, }); diff --git a/core/auth/ema/wire.ts b/core/auth/ema/wire.ts index a5f0170a8..959eaded0 100644 --- a/core/auth/ema/wire.ts +++ b/core/auth/ema/wire.ts @@ -1,16 +1,11 @@ -import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client"; +import { discoverAndRequestJwtAuthGrant } from "@modelcontextprotocol/client"; import type { OAuthClientInformation, OAuthTokens, } from "@modelcontextprotocol/client"; import { OAuthTokensSchema } from "@modelcontextprotocol/core"; import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; -import { - GRANT_TYPE_JWT_BEARER, - GRANT_TYPE_TOKEN_EXCHANGE, - TOKEN_TYPE_ID_JAG, - TOKEN_TYPE_ID_TOKEN, -} from "./constants.js"; +import { GRANT_TYPE_JWT_BEARER } from "./constants.js"; import { parseHttpUrl } from "../utils.js"; import { discoverResourceAsMetadata } from "./resourceContext.js"; import { normalizeIdpIssuer } from "./storage.js"; @@ -19,70 +14,59 @@ import { postOAuthTokenRequest, } 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: { idp: EnterpriseManagedAuthIdpConfig; idToken: string; audience: string; - resource?: string; + /** RFC 8707 resource indicator — required by the SDK Layer-2 helper. */ + resource: string; scope?: string; fetchFn?: typeof fetch; }): Promise { const issuer = normalizeIdpIssuer(params.idp.issuer); - const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); - const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, { - fetchFn: params.fetchFn, - }); - if (!idpMetadata?.token_endpoint) { - throw new Error("IdP metadata missing token_endpoint"); - } - - const clientInformation = { - client_id: params.idp.clientId, - client_secret: params.idp.clientSecret, - token_endpoint_auth_method: "client_secret_post", - } as OAuthClientInformation; - - const body = new URLSearchParams({ - grant_type: GRANT_TYPE_TOKEN_EXCHANGE, - requested_token_type: TOKEN_TYPE_ID_JAG, - subject_token: params.idToken, - subject_token_type: TOKEN_TYPE_ID_TOKEN, - audience: params.audience, - }); - if (params.resource) { - body.set("resource", params.resource); - } - if (params.scope) { - body.set("scope", params.scope); + const resource = params.resource.trim(); + if (!resource) { + throw new Error("EMA leg 2 requires a resource identifier"); } - const response = await postOAuthTokenRequest( - 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)", - ); + let result: Awaited>; + try { + result = await discoverAndRequestJwtAuthGrant({ + idpUrl: issuer, + audience: params.audience, + resource, + idToken: params.idToken, + clientId: params.idp.clientId, + clientSecret: params.idp.clientSecret, + scope: params.scope, + fetchFn: params.fetchFn, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + 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 json = (await response.json()) as { - access_token?: string; - issued_token_type?: string; - }; - const idJag = json.access_token; - if (!idJag) { + if (!result.jwtAuthGrant) { 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). */ diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts index d858bd0f2..2ffbe817a 100644 --- a/core/auth/scopes.ts +++ b/core/auth/scopes.ts @@ -1,46 +1,22 @@ /** - * SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports. - * On SDK v2 upgrade, delete this module and import `computeScopeUnion` / - * `isStrictScopeSuperset` from the client package instead. + * SEP-2350 scope helpers. Union / strict-superset algorithms come from + * `@modelcontextprotocol/client`; Inspector-only persistence helpers stay local. */ import { - parseScopeString, - unionAuthorizationScopes, -} from "./challenge.js"; + computeScopeUnion as sdkComputeScopeUnion, + isStrictScopeSuperset as sdkIsStrictScopeSuperset, +} from "@modelcontextprotocol/client"; /** Union space-delimited scope strings (order-preserving, deduped). */ -export function computeScopeUnion( - ...scopes: ReadonlyArray -): string | undefined { - let merged: string | undefined; - for (const scope of scopes) { - merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join( - " ", - ); - if (!merged) { - merged = undefined; - } - } - return merged; -} +export const computeScopeUnion = sdkComputeScopeUnion; /** * Whether `union` contains a scope token not present in `current`. * 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. */ -export function isStrictScopeSuperset( - 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; -} +export const isStrictScopeSuperset = sdkIsStrictScopeSuperset; /** * Scope to persist after a successful token grant (RFC 6749 §5.1). diff --git a/core/auth/utils.ts b/core/auth/utils.ts index 95ebccca1..fd6ea4457 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -1,3 +1,4 @@ +import { UnauthorizedError } from "@modelcontextprotocol/client"; import type { CallbackParams } from "./types.js"; import { ZodError } from "zod"; @@ -172,13 +173,50 @@ export const generateOAuthErrorDescription = ( * transport preserves the status on the error object; as a fallback, match * transport wording `"failed …(401)"` so unrelated `(401)` in messages does * 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 { - if (typeof err === "object" && err !== null) { - const status = (err as { status?: number; code?: number }).status; - const code = (err as { code?: number }).code; - if (status === 401 || code === 401) return true; + return isUnauthorizedErrorDeep(err, new Set()); +} + +function isUnauthorizedErrorDeep(err: unknown, seen: Set): boolean { + if (err == null) return false; + if (typeof err !== "object") { + return /\bfailed\b[^\n]*\(401\)/i.test(String(err)); + } + if (seen.has(err)) return false; + 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 message = err instanceof Error ? err.message : String(err); - return /\bfailed\b[^\n]*\(401\)/i.test(message); + + 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; } diff --git a/specification/v2_auth.md b/specification/v2_auth.md index 2562e8615..581e08395 100644 --- a/specification/v2_auth.md +++ b/specification/v2_auth.md @@ -1,11 +1,12 @@ # 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) -#### 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 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 @@ -15,7 +16,7 @@ Support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/e ### [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) @@ -24,3 +25,7 @@ Detecting when in-flight MCP traffic needs new or elevated credentials, respondi ### [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`. + +### [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. diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 8e46d57d4..6f314d1f7 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -1,7 +1,8 @@ # 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) -#### [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). @@ -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. -| Concern | Package / module | -| ------- | ---------------- | -| 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 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 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 | -| 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) | -| Wire constants | `core/auth/ema/constants.ts` — grant/type URNs + `IDP_OIDC_SCOPES` for leg 1 | +| Concern | Package / module | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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 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 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) | +| 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 | **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) - -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: - -| Option | When | -| ------ | ---- | -| **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 | +#### v2 `@modelcontextprotocol/client` Layer-2 EMA helpers -**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. +| 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 | -**Watch items (unchanged):** +**Watch items:** - **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 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 | -| ----- | ----- | ------------------ | -| **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) | +| 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) | +| **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) | ### 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): -| 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 | **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. -| 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`) | -| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | +| 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`) | +| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | On-disk shape (initial): @@ -183,7 +181,9 @@ const clientConfig = await loadClientConfig(); // client.json new InspectorClient(serverConfig, { ...opts, ...(getActiveEnterpriseManagedAuthIdp(clientConfig) && { - enterpriseManagedAuth: { idp: getActiveEnterpriseManagedAuthIdp(clientConfig)! }, + enterpriseManagedAuth: { + idp: getActiveEnterpriseManagedAuthIdp(clientConfig)!, + }, }), ...(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` | -| ----- | ------------------------------- | ------------------------------ | -| `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.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 | +| Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` | +| ------------------------- | --------------------------------------- | ----------------------------------------------------------------------- | +| `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.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 | 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]` 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. - 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`. **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: -| 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 | -| `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry | +| 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 | +| `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 ""`, 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. -| 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` | -| **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` | +| 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` | +| **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. @@ -373,11 +373,11 @@ When an EMA server returns **403** + `insufficient_scope`, `OAuthManager.handleA **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) | -| `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 | +| `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 | 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 -| 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) | -| 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 | +| 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) | +| 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 | ### 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] **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)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. +- [ ] _(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. ### Phase 4 — Other clients @@ -528,11 +528,11 @@ Full end-to-end EMA has been exercised manually against live **xaa.dev** (June 2 ### Topology -| Role | Service | Inspector config | -| ---- | ------- | ---------------- | -| **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 | -| **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`) | +| Role | Service | Inspector config | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **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 | +| **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. @@ -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`): -| Backend | Class | Where | -| ------- | ----- | ----- | -| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` | -| 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 | +| Backend | Class | Where | +| ----------- | --------------------- | ----------------------------------------------------------------- | +| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` | +| 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 | **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. -| Client | OAuth runtime store | -| ------ | ------------------- | -| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | -| **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` | +| Client | OAuth runtime store | +| ------------- | ----------------------------------------------------------------------------------- | +| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.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`). @@ -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` -| Case | Status | -| ---- | ------ | -| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape | -| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message | -| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint | -| `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass | -| Missing ID Token | ✅ | -| Missing resource secret | ✅ | -| `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` | -| `trySilentEmaAuth` returns false without IdP session | ✅ | +| Case | Status | +| ---------------------------------------------------- | -------------------------------------------------------------------- | +| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape | +| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message | +| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint | +| `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass | +| Missing ID Token | ✅ | +| Missing resource secret | ✅ | +| `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` | +| `trySilentEmaAuth` returns false without IdP session | ✅ | 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` -| Step | Status | -| ---- | ------ | -| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) | -| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports | -| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials | -| 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) | -| 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` | -| 6 | ✅ Second connect reuses silent path; persisted tokens tagged `enterpriseManaged: true` | +| Step | Status | +| ---- | ------------------------------------------------------------------------------------------------------------- | +| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) | +| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports | +| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials | +| 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) | +| 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` | +| 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). @@ -673,8 +673,8 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato - [x] Layer 1 tests merged - [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`) -- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant -- [ ] *(optional)* 401 re-auth stretch case +- [ ] _(optional)_ `RemoteOAuthStorage` EMA E2E variant +- [ ] _(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. All of this is implemented in: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts ### 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 Tests for body semantics: + - https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts ### 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 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/browser/mainThreadMcp.ts - https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 69503bf99..1595bc9ea 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -1,229 +1,248 @@ # Inspector V2 Auth — Authorization hardening (MCP 2026-07-28) -### [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](v2_auth_ema.md) | Hardening | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.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_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) -Align Inspector v2 with the **authorization hardening** in the upcoming MCP **`2026-07-28`** specification release — six SEPs that tighten OAuth/OIDC client behavior for MCP's single-client, many-server deployment pattern. +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | Hardening | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md) -## Summary +As-built status for aligning Inspector with the **authorization hardening** SEPs in the MCP **`2026-07-28`** release — tracked by [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527). -The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) adds six authorization SEPs. All are **OAuth client** requirements. Inspector implements OAuth through a mix of SDK delegation (connect-time standard OAuth via `auth()`) and local code (EMA wire, storage, web callback, mid-session recovery). +Inspector is on `@modelcontextprotocol/client` **2.0.0-beta.4**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). -**Strategy:** Upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) when its auth-hardening PRs land. Do **not** reimplement SDK OAuth logic in Inspector — delegate connect-time standard OAuth to the SDK and wire the gaps (callback parameters, storage shape, client type). **Mid-session authorization** is a separate, near-term track ([Mid-session auth](v2_auth_mid_session.md)); this doc covers connect-time and storage hardening that mid-session builds on. +**Policy:** SEP behavior that can be automated is covered (or should be covered) by unit/integration tests. Hosted-IdP smoke in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) is complementary for real providers — it is **not** required for every SEP once CI covers the requirement. -## Implementation status (#1625, on `@modelcontextprotocol/client` 2.0.0-beta.4) +--- -The SDK upgrade ([#1624](https://github.com/modelcontextprotocol/inspector/issues/1624)) and RFC 9207 `iss` forwarding ([#1687](https://github.com/modelcontextprotocol/inspector/pull/1687)) landed first. This section records the **as-built** behavior of the storage re-key + SEP-837/2350/2352 UX ([#1625](https://github.com/modelcontextprotocol/inspector/issues/1625)); it supersedes the forward-looking prose below where they differ. +## Status vs [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527) -- **SEP-2352 — issuer-bound credentials (done).** `ServerOAuthState` now nests issuer-bound credentials under `byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }`, with an `activeIssuer` pointer answering the transport's ctx-less per-request bearer read. The SDK v2 `auth()` drives the whole flow — it builds `infoCtx = { issuer }`, stamps `issuer` on everything it saves, and calls `discardIfIssuerMismatch` on reads — so the Inspector's job is to **honor `ctx.issuer`** and **round-trip the stamp**. A latent bug was that `OAuthStorageBase` re-parsed tokens/client-info through `OAuthTokensSchema`/`OAuthClientInformationSchema`, which **strip** the non-RFC `issuer` field; the store now re-attaches it from the `byIssuer` key. `BaseOAuthClientProvider` implements `discoveryState()`/`saveDiscoveryState()` (activating the SDK's callback-leg `AuthorizationServerMismatchError` binding check) and `invalidateCredentials(scope)`. Migration is **lazy**: a legacy pre-1625 blob's top-level `clientInformation`/`tokens` are returned as an unkeyed fallback until the first issuer-stamped save promotes them into `byIssuer` — existing web/TUI/CLI sessions survive the upgrade. -- **SEP-837 — `application_type` (done).** The shared `BaseOAuthClientProvider` declares `application_type: "native"` for **all** environments: the Inspector is a locally-hosted app reached over `localhost`, so it is a native client per RFC 8252 (the SDK also infers `"native"` from loopback `redirect_uris`). This supersedes the "web = `web`" split in the per-SEP table below. `ClientSettingsForm` renders DCR `RegistrationRejectedError` details and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. -- **SEP-2350 — step-up union (done).** The scope-union machinery (`core/auth/scopes.ts`, `handleAuthChallenge`) predates this card; #1625 adds the UX: a **per-server** `onInsufficientScope` (`reauthorize | throw`) setting (`ServerSettingsForm` → `StoredMCPServer.oauth` → `StreamableHTTPClientTransport`), and a scope-union visualization in `StepUpAuthModal` that tags each union scope `new` vs `already granted`. -- **`mcpAuth` (simplified, not deleted).** `core/auth/mcpAuth.ts` is now a thin forwarder to SDK `auth()` — the hand-rolled `authorizeWithoutRefresh` (which mishandled issuer stamping) was deleted and `forceReauthorization`/`skipIssuerMetadataValidation` are forwarded natively, so both the initial and step-up paths get SEP-2352 stamping. `core/auth/scopes.ts` is **retained** (still the union source; the SDK's `computeScopeUnion` export is a future cleanup). -- **Vocabulary.** `OAuthStep` gains `cimd_fetch` / `issuer_comparison` / `scope_step_up` (the manager records `scope_step_up` on a step-up redirect), and the Network tab labels `auth`-category requests by OAuth phase (Discovery / Registration / Authorize / Token). -- **Deferred to the era-aware card.** The `SdkError(EraNegotiationFailed) → UnauthorizedError` unwrap and the `finishAuth(URLSearchParams)` whole-params form only fire under `auto`/`pin` negotiation; the Inspector is pinned to `versionNegotiation: 'legacy'`, so this lands with the era-aware connection model. +| SEP | Topic | Status | How / where | +| -------------------------------------------------------------------------------------- | -------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)** | RFC 9207 `iss` on auth responses | **Done** | Host parses `iss` from callback (web `App.tsx`, node loopback server); `completeOAuthFlow(code, iss?)` → `mcpAuth` → SDK `auth()` validates ([#1687](https://github.com/modelcontextprotocol/inspector/pull/1687)). EMA IdP leg 1 forwards `iss` the same way. | +| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | DCR `application_type` | **Done** | `BaseOAuthClientProvider` declares `application_type: "native"` for all clients (localhost Inspector = RFC 8252 native). [#1625](https://github.com/modelcontextprotocol/inspector/issues/1625) / [#1694](https://github.com/modelcontextprotocol/inspector/pull/1694). | +| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)** | Credentials bound to AS issuer | **Done** | `OAuthStorage` `byIssuer` + `activeIssuer`; provider `invalidateCredentials` / `discoveryState`; lazy migration from legacy blobs. Same PRs as SEP-837. | +| **[SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | OIDC refresh / `offline_access` | **Done** (SDK + EMA) | Standard OAuth: SDK `auth()` / `determineScope` after v2 upgrade ([#1624](https://github.com/modelcontextprotocol/inspector/issues/1624) / [#1688](https://github.com/modelcontextprotocol/inspector/pull/1688)). EMA leg 1: `IDP_OIDC_SCOPES = "openid offline_access"`. Do not assume a refresh token is issued. | +| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | Step-up scope union | **Done** | Owned by mid-session ([#1526](https://github.com/modelcontextprotocol/inspector/issues/1526) closed; [mid-session spec](v2_auth_mid_session.md)): `handleAuthChallenge`, SDK scope helpers, web remote auth-state, TUI/CLI confirm, `onInsufficientScope` setting, step-up modal. [#1527] deferred this SEP to that track. | +| **[SEP-2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351)** | Stable `.well-known` discovery | **Done** (via SDK) | No Inspector-built discovery URLs — `discoverOAuthProtectedResourceMetadata` / `discoverAuthorizationServerMetadata` (and `auth()`) from the v2 client. | -## Normative references +**Related closed work:** SDK upgrade [#1624](https://github.com/modelcontextprotocol/inspector/issues/1624); storage/UX hardening [#1625](https://github.com/modelcontextprotocol/inspector/issues/1625); mid-session + step-up [#1526](https://github.com/modelcontextprotocol/inspector/issues/1526); shared web `RemoteOAuthStorage` [#1548](https://github.com/modelcontextprotocol/inspector/issues/1548). -- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization) -- [MCP authorization (2025-11-25 — current stable)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) -- [2026-07-28 RC announcement — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening) -- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — OAuth 2.0 Authorization Server Issuer Identification (`iss` parameter) -- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer token `insufficient_scope` -- TypeScript SDK tracking: [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) issues `#2197`–`#2201`, `#2198`, `#2256`; PRs `#2265`, `#2271`, `#2272` +--- -## Relationship to mid-session auth +## Per-SEP automated test coverage -[Mid-session auth](v2_auth_mid_session.md) ships in the **near term**. Overlap: +| SEP | Automated coverage | Gap? | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| **2350** step-up | **Covered** — mid-session remote/direct e2e + challenge/scopes/oauthManager + CLI/TUI oauth tests | No (hosted smoke is complementary, not a gap) | +| **837** `application_type` | **Covered** — `providers.test.ts` asserts `"native"`; DCR exercised in OAuth e2e | No | +| **2352** issuer binding | **Covered** — `byIssuer` persistence + migration e2e (discovery → issuer B forces re-authorize) | No | +| **2468** `iss` | **Covered** — forwarding + standard-OAuth reject e2e (mismatch / missing when required) | No | +| **2207** refresh / `offline_access` | **Covered** — refresh-after-401 e2e + authorize `offline_access` assert | No | +| **2351** discovery | **Covered** — fetchFn asserts PRM + AS well-known URL shapes | No | -| Concern | Mid-session spec | This doc | -| ------- | ---------------- | -------- | -| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })` for **web remote**, EMA, and **interactive** defer; union persisted on `completeOAuthFlow` success | **Direct streamable HTTP silent step-up:** v2 SDK transport ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)). **Do not** duplicate with client-side fetch intercept or `AuthRecoveryTransport` on TUI/CLI | -| **SEP-2207** refresh / `offline_access` | EMA legs 2–3 re-mint; standard OAuth silent refresh | Connect-time scope selection and DCR metadata | -| **SEP-2468** `iss` validation | Applies to any interactive re-auth redirect (401 mid-session) | Connect-time callback wiring is the first place to land `iss` passthrough | -| **SEP-2352** issuer-bound credentials | Re-register after AS migration may surface as mid-session failure | Storage and `invalidateCredentials` must be correct before mid-session recovery can succeed | -| **TUI/CLI mid-session** | [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation): provider + UX; silent retry via v2 SDK | Provider v2 hooks (`invalidateCredentials`, issuer-keyed storage, `application_type`) | +Hosted smoke ([v2_auth_smoke_testing.md](v2_auth_smoke_testing.md)) is complementary for real IdP quirks. It does **not** close the CI gaps below. -Implement mid-session auth now. Land auth hardening on the v2 SDK upgrade path without blocking mid-session work — use compatible storage and callback shapes so both tracks merge cleanly. +--- -## The six SEPs +## Remaining changes — explicit plan -| SEP | Requirement | Primary owner | -| --- | ------------- | ------------- | -| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)** | Validate `iss` on authorization responses per RFC 9207; reject mix-up across authorization servers | Client (+ SDK reference impl) | -| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | Declare OIDC `application_type` during Dynamic Client Registration (`native` vs `web`) | Client (+ SDK DCR body) | -| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)** | Bind persisted DCR credentials to the AS `issuer`; re-register when resource migrates between ASes | Client storage (+ SDK AS-change detection) | -| **[SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | Request refresh tokens from OIDC-style ASes (`offline_access` in scope when appropriate); do not assume refresh tokens are issued | Client (+ SDK `determineScope`) | -| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | On 403 step-up, re-authorize with **union** of previously requested scopes and challenge scopes; on 401 re-login, **replace** scope | Client (+ SDK transport retry for direct transport) | -| **[SEP-2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351)** | Stable RFC 8414 `.well-known` discovery suffix for authorization and resource metadata | Spec / discovery (SDK helpers) | +**SEP product code for the six hardening SEPs is already shipped.** Steps 1–5 below are **implemented** on branch `v2/auth-sdk-consolidation` (tests + `isUnauthorizedError` unwrap). Step 6 (close #1527) waits on PR merge. -Servers emit per-operation scopes in 403 challenges (SEP-2350 server posture). Inspector consumes them; server-side scope-challenge middleware is out of scope unless needed for tests. +Nothing below is optional: each item is either **do** (with how) or **do not** (with why). -## SDK vs Inspector responsibilities +### Decisions -Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth hardening lands on **v2 `main`** (`@modelcontextprotocol/client`). Upgrade is part of this work. +| Item | Kind | Decision | Status | +| ----------------------------------- | ---------------- | ---------- | ---------------------------------------------------------------------- | +| SEP-2468 reject-path e2e | **Test only** | **Do** | **Done** — local AS returns `iss`; e2e happy/mismatch/missing | +| SEP-2207 `offline_access` assert | **Test only** | **Do** | **Done** — authorize `scope` includes `offline_access` when advertised | +| SEP-2351 discovery URL asserts | **Test only** | **Do** | **Done** — fetchFn tracker asserts PRM + AS well-known paths | +| SEP-2352 AS-migration e2e | **Test only** | **Do** | **Done** — discovery flip to issuer B forces re-authorize URL | +| `EraNegotiationFailed` → 401 unwrap | **Product code** | **Do** | **Done** — `isUnauthorizedError` walks `cause` / `data.cause` | +| Per-SEP smoke scenarios | — | **Do not** | Unchanged — automatable → CI | -```text -Connect-time standard OAuth EMA Web remote backend -─────────────────────────── ─── ────────────────── -SDK auth() + BaseOAuthClientProvider Local wire (ema/) Frozen token stub -OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth on node -completeOAuthFlow(code, iss?) v2 CrossAppAccess optional Mid-session → browser - (evaluate vs ema/) fetch intercept (permanent) - RemoteClientTransport (permanent) - -TUI / CLI direct (not web remote): -────────────────────────────────── -StreamableHTTPClientTransport + live OAuthClientProvider in-process -v2 SDK: silent 401/403 retry + unionScopes on streamable HTTP (#2265) -Inspector: handleAuthChallenge for EMA + interactive defer; TUI/CLI UX only -Do NOT: fetch intercept or AuthRecoveryTransport on client SDK transport -``` +### Ordered work (one PR or a tight PR series) + +#### A. Tests only (steps 1–4) — no SEP feature work + +These close #1527 acceptance gaps. Fixture/harness edits under `test-servers/` and test helpers are allowed solely to make the asserts possible; they are not product features. + +##### 1. SEP-2468 — standard-OAuth `iss` reject e2e (**test**) + +**Gap:** Local AS never advertises `authorization_response_iss_parameter_supported` and never returns `iss`. `completeOAuthAuthorization` returns only `code`. EMA IdP tests already cover missing/mismatched `iss`; standard `InspectorClient.completeOAuthFlow` does not. + +**Changes (tests + fixtures only):** + +1. `test-servers/src/test-server-oauth.ts` + - AS metadata: `authorization_response_iss_parameter_supported: true` + - `completeAuthorizationRedirect`: append `iss=` (same string as metadata `issuer`, no trailing slash) +2. `clients/web/src/test/integration/helpers/oauth-client-fixtures.ts` + - Extend `completeOAuthAuthorization` to return `{ code, iss }` (or add a sibling helper). Update call sites that need the new shape. +3. `inspectorClient-oauth-e2e.test.ts` (new describe): + - **Happy:** `completeOAuthFlow(code, correctIss)` → tokens stored / connect works + - **Mismatch:** `completeOAuthFlow(code, "https://evil.example")` → rejects (`IssuerMismatchError` / message match); **no tokens stored** + - **Missing when required:** `completeOAuthFlow(code)` with no `iss` → rejects; **no tokens stored** + +**Done when:** those three cases pass under the existing streamable-HTTP (or both) transport matrix entry used by neighboring OAuth e2e. + +##### 2. SEP-2207 — `offline_access` on standard authorize (**test**) + +**Gap:** SDK `determineScope` appends `offline_access` only when AS `scopes_supported` includes it **and** client metadata advertises `refresh_token` (Inspector already does). Default test AS `scopes_supported` is `["mcp"]` only, so authorize never requests `offline_access` in CI. + +**Changes (tests + fixtures only):** + +1. OAuth e2e fixture: `scopesSupported: ["mcp", "offline_access"]` (and keep `supportRefreshTokens: true`). +2. After `authenticate()`, assert authorize URL `scope` query param includes `offline_access`. +3. Keep existing refresh-after-401 e2e as the refresh-grant proof (no change required there beyond not regressing). + +**Done when:** one e2e asserts the authorize scope string; refresh-after-401 still green. + +##### 3. SEP-2351 — discovery URL shape asserts (**test**) + +**Gap:** `fetchFn integration` only checks that some `well-known` / `oauth` URLs were hit. + +**Changes (tests only):** In that same test (or a focused sibling), after `authenticate()` (discovery complete), assert the tracker contains: -### What the v2 SDK provides (after upgrade) +- `/.well-known/oauth-protected-resource` (PRM; path-aware variant if the MCP URL has a path) +- `/.well-known/oauth-authorization-server` (and/or the SDK’s documented openid-configuration fallbacks if the AS 404s the first — our combined AS serves oauth-authorization-server, so assert that one) -| SEP | SDK status (Jun 2026) | Inspector gets | -| --- | --------------------- | -------------- | -| SEP-2207 | Implemented on v2 `main` ([#2199](https://github.com/modelcontextprotocol/typescript-sdk/issues/2199)) | `offline_access` augmentation in authorize scope for standard OAuth via `auth()` | -| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on **streamable HTTP direct transport**; SSE unchanged (401 only) | -| SEP-2352 | Open PR [#2271](https://github.com/modelcontextprotocol/typescript-sdk/pull/2271) | AS migration detection → `invalidateCredentials` → re-DCR | -| SEP-2468 | Open PR [#2272](https://github.com/modelcontextprotocol/typescript-sdk/pull/2272) | `iss` validation in code-exchange path when host passes `iss` | -| SEP-837 | Open [#2198](https://github.com/modelcontextprotocol/typescript-sdk/issues/2198) | `application_type` in DCR request body | -| SEP-2351 | Tracking [#2256](https://github.com/modelcontextprotocol/typescript-sdk/issues/2256) | Discovery URL suffix in SDK metadata helpers | +**Done when:** URL path assertions fail if Inspector/SDK ever regresses to a non-SEP-2351 discovery shape. -### What Inspector must still implement +##### 4. SEP-2352 — AS migration on connect path (**test**) -| SEP | Inspector work | Why not SDK-only | -| --- | -------------- | ---------------- | -| SEP-2468 | Parse `iss` from OAuth callback URL; extend `completeOAuthFlow(code, iss?)`; pass through to SDK `auth()` | Host owns the redirect handler — SDK cannot read the callback URL | -| SEP-837 | Ensure web vs TUI/CLI set correct client type (`web` / `native`) in provider metadata or environment | SDK may infer from redirect URI; Inspector must not mis-declare cross-environment | -| SEP-2352 | Key `OAuthStorage` credentials by AS `issuer` (not only `serverUrl`); implement `invalidateCredentials` on `BaseOAuthClientProvider`; migrate shared `oauth.json` | Storage is Inspector-owned; SDK calls provider hooks | -| SEP-2207 | EMA leg 1 already requests `openid offline_access` (`IDP_OIDC_SCOPES`); verify standard OAuth path after upgrade | EMA bypasses SDK authorize for leg 1 | -| SEP-2350 | **Web remote:** mid-session union in `handleAuthChallenge()` (bypasses SDK — proxy architecture). **TUI/CLI direct silent step-up:** v2 SDK transport after #2265. **Interactive defer** (step-up modal / Auth tab confirm): Inspector `setSuppressAuthorizationNavigation` + `mcpAuth({ forceReauthorization })` | See [Mid-session auth § Core API](v2_auth_mid_session.md#core-api--handleauthchallenge) and [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation) | -| SEP-2351 | Verify discovery after upgrade; no duplicate discovery logic in Inspector | Uses SDK `discoverOAuthProtectedResourceMetadata` today | +**Gap:** We prove `byIssuer` persistence and `invalidateCredentials` mapping. We do **not** prove that when PRM’s `authorization_servers[0]` changes, stamped credentials for the old issuer are not silently reused (#1527 AC3). -**Do not duplicate** SDK `auth.ts` logic (RFC 9207 decision table, AS migration edge cases, scope union helpers). Upgrade and wire. +**Changes (tests + fixtures only):** -## Implementation strategy +1. Complete static-client OAuth against local AS (issuer A); confirm tokens under `byIssuer[A]`. +2. On the next `authenticate()`, force discovery to resolve issuer B (recommended: `fetchFn` that rewrites PRM `authorization_servers` to a second local AS URL, **or** restart/`protected-resource` mode pointing at a different AS). Second AS can be a second `TestServerHttp` or a stub that only serves AS metadata + authorize/token. +3. Assert outcomes (all required): + - Old issuer-A tokens are **not** used to connect as authorized against B + - Client must obtain a **new** authorization URL (or fail closed for static client against unknown AS) — not a silent `AUTHORIZED` with A’s tokens + - Storage does not leave active bearer state implying B while only A’s slot has tokens -### Principles +Prefer exercising real `auth()` + provider `discardIfIssuerMismatch` / discovery-state path over a pure storage unit test — AC3 is about migration detection, not blob shape. -1. **Delegate** connect-time standard OAuth to the v2 SDK `auth()` flow. -2. **Wire** callback parameters, storage, and client-type metadata in Inspector. -3. **Extend** EMA and mid-session paths locally where the SDK has no EMA/XAA API. -4. **Upgrade** from `@modelcontextprotocol/sdk` v1 to v2 packages as a gated step — not a silent dependency bump. -5. **Do not unify transport layers** between web remote and TUI/CLI — web keeps `RemoteClientTransport` + node fetch intercept; TUI/CLI rely on v2 SDK transport for silent direct retry. +**Done when:** one integration test encodes the migration; silent reuse would fail the test. -### Transport ownership (v2 upgrade) +#### B. Product code (step 5) — only non-test work in this plan -| Path | Silent 401/403 + RPC retry | Interactive OAuth | Inspector integration | -| ---- | -------------------------- | ----------------- | --------------------- | -| **Web remote** | `RemoteClientTransport.postSend()` + `pushAuthState` (unchanged) | Browser redirect + resume snapshot | Node fetch intercept only (frozen stub) | -| **TUI/CLI direct (streamable HTTP)** | **v2 SDK transport** (`token()` + retry; #2265 union on 403) | Provider `redirectToAuthorization` + host callback | `handleAuthChallenge()` for EMA + defer-navigate; catch `UnauthorizedError` / `SdkHttpError` | -| **TUI/CLI direct (SSE)** | v2 SDK 401 retry only | Same as streamable | No 403 step-up on SSE | +##### 5. `EraNegotiationFailed` unwrap (**product code** + unit tests) -**v2 SDK hooks (direct):** `AuthProvider` with `token()` + optional `onUnauthorized()`; full `OAuthClientProvider` adapted internally. Inspector extends `BaseOAuthClientProvider` toward the v2 contract — **not** a parallel fetch intercept on the client transport. +**Gap:** Under `protocolEra: "auto" | "modern"`, a negotiation probe that hits HTTP 401 can throw `SdkError(SdkErrorCode.EraNegotiationFailed)` with the real `UnauthorizedError` at `error.cause` (or `error.data.cause` — match whatever the installed SDK beta actually sets; assert in the unit test). `isUnauthorizedError` in `core/auth/utils.ts` does not walk that chain, so App / connect OAuth UX may never start `authenticate()`. -**Delete on upgrade:** `core/auth/mcpAuth.ts` body → re-export SDK `auth`; delete `core/auth/scopes.ts` → import `unionScopes` from SDK. **Keep:** `handleAuthChallenge()`, web `RemoteClientTransport`, node `createAuthChallengeInterceptFetch`. +**Changes:** -### Order of work +1. Extend `isUnauthorizedError` to return true when the error (or a single-level / recursive `cause`) is an unauthorized 401 — including SDK `UnauthorizedError` and wrapped `EraNegotiationFailed`. +2. Unit tests in `clients/web/src/test/core/auth/utils.test.ts` (and any thin re-export tests): bare 401 still true; `SdkError(EraNegotiationFailed, { cause: UnauthorizedError })` true; unrelated `SdkError` false. +3. Prefer fixing the shared detector over scattering unwrap logic in `oauthManager` / `App.tsx`. -| Step | When | Work | -| ---- | ---- | ---- | -| 1 | **Now (parallel with [TUI/CLI mid-session](v2_auth_mid_session.md#tui-and-cli-implementation))** | Compatible prep: callback `iss`; storage schema reserves `authorizationServerIssuer`; provider v2-shaped hooks; direct mid-session e2e baseline; **no** client-side fetch intercept / `AuthRecoveryTransport` | -| 2 | **When v2 SDK auth PRs merge** | Upgrade to `@modelcontextprotocol/client`; run existing OAuth integration tests | -| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 `application_type` per environment; wire provider `onUnauthorized` → `handleAuthChallenge` where interactive defer needed | -| 4 | **TUI/CLI Phase C** | Interactive mid-session UX (Auth tab / callback server) — can overlap step 3 | -| 5 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); direct mid-session e2e green on v2 | +**Done when:** unit coverage proves wrapped negotiation 401s are recognized; no behavior change for legacy (unwrapped) 401s. -Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series. +Ship in the same PR series as steps 1–4 (or immediately adjacent). Not a #1527 SEP AC — do it anyway because era selection already exists. -### Per-SEP Inspector mapping +#### C. Issue hygiene -#### SEP-2468 — `iss` validation (RFC 9207) +##### 6. Close [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527) (**do**, after tests 1–4; product step 5 may land with them) -- **Web:** `App.tsx` OAuth callback — read `iss` from query string alongside `code`; pass to `InspectorClient.completeOAuthFlow(code, iss?)`. -- **TUI / CLI:** Node callback server — same passthrough. -- **Core:** `OAuthManager.completeOAuthFlow` forwards `iss` to SDK `auth()`. -- **EMA:** Leg 1 OIDC callback is separate (`completeEmaIdpAuthorizationAndMint`); evaluate whether IdP responses require `iss` validation on the OIDC path (may reuse SDK OIDC helpers after upgrade). +1. Land steps 1–5 (single PR preferred: “auth hardening CI gaps + era 401 unwrap”). +2. Comment on #1527 with links to the PR and this plan; mark ACs 1–5 satisfied by **existing implementation + new CI tests** (AC6 smoke-per-SEP: **waived** per policy above — state that explicitly in the closing comment). Note step 5 as a related product fix, not a SEP AC. +3. Close #1527 and move the board card to **Done**. +4. Do **not** leave #1527 open as an unbounded “nice tests” bucket. -#### SEP-837 — `application_type` in DCR +### Out of scope (hard exclusions) -- **Web (`BrowserOAuthClientProvider`):** `application_type: "web"` (https redirect). -- **TUI / CLI:** `application_type: "native"` (localhost / custom scheme redirect). -- **Pre-registered clients:** N/A — no DCR. -- **CIMD:** Portable client IDs — no `application_type` on DCR. +- Mid-session / step-up reimplementation (#1526 done) +- Web remoting redesign / deleting `mcpAuth` +- Client-credentials grant ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) +- Rewriting [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) with one scenario per SEP +- EMA leg-3 SDK swap (blocked on SDK `resource`/`scope` — see [SDK consolidation](v2_auth_sdk_consolidation.md)) -#### SEP-2352 — credentials bound to AS issuer +--- -- Extend `ServerOAuthState` / `oauth.json` with `authorizationServerIssuer` (or key storage by issuer + resource). -- Implement `OAuthClientProvider.invalidateCredentials(scope)` on `BaseOAuthClientProvider` — clear client info and/or tokens per SDK request. -- On AS migration (PRM `authorization_servers` change), SDK triggers invalidation; Inspector storage must honor it and allow re-DCR. -- **CIMD carve-out:** HTTPS `client_id` URLs remain portable across AS changes; tokens still invalidate per SDK behavior. +## Per-SEP detail (as-built) -#### SEP-2207 — OIDC refresh token guidance +### SEP-2468 — `iss` validation -- **Standard OAuth:** Inherited from v2 SDK `determineScope()` after upgrade. -- **EMA leg 1:** Already requests `openid offline_access`; IdP refresh in `refreshIdpOidcSession`. -- **Mid-session:** Silent refresh and EMA re-mint per [Mid-session auth](v2_auth_mid_session.md); do not assume refresh tokens exist. +- **SDK:** validates authorization-response `iss` against metadata when the host passes it into `auth()`. +- **Inspector:** parses `iss` from the callback query string on web and TUI/CLI loopback; forwards through `OAuthManager.completeOAuthFlow` / EMA IdP completion. +- **Tests:** forwarding covered; standard-OAuth reject-path e2e is plan step 1. -#### SEP-2350 — step-up scope accumulation +### SEP-837 — `application_type` -- **Web remote (mid-session):** `handleAuthChallenge()` union + `pushAuthState` — SDK transport not involved (proxy). -- **TUI/CLI direct (silent, streamable HTTP):** v2 SDK transport 403 retry with `unionScopes()` after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). -- **Interactive step-up (all clients):** `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })`; `setSuppressAuthorizationNavigation` until user confirms (web modal / TUI Auth tab); `saveScope` on `completeOAuthFlow` success. -- **EMA step-up:** silent legs 2–3 when IdP session valid — Inspector `handleAuthChallenge()`, not SDK transport alone. -- **401 re-login:** Replace scope set (not union) — all clients. -- **SSE:** 401 only; no 403 step-up in v1 or v2 SDK. +- **Inspector:** always `"native"` in DCR client metadata (loopback redirects). +- **UX:** `ClientSettingsForm` surfaces DCR `RegistrationRejectedError`; DCR marked deprecated-in-favor-of-CIMD (SEP-991). +- **Tests:** `providers.test.ts` asserts `application_type: "native"`. -#### SEP-2351 — discovery suffix +### SEP-2352 — issuer-bound credentials -- Rely on v2 SDK discovery helpers used by `auth()`, CIMD (`core/auth/cimd.ts`), and EMA (`core/auth/ema/resourceContext.ts`). -- No Inspector-specific discovery URL construction unless regression found in smoke tests. +- **Storage:** `byIssuer[issuer]` for client info / tokens / registration kind; `activeIssuer` for ctx-less bearer reads; re-attach `issuer` after Zod parse strips it; lazy legacy fallback until first stamped save. +- **Provider:** `discoveryState` / `saveDiscoveryState`, `invalidateCredentials(scope)`. +- **SDK:** stamps `ctx.issuer`, `discardIfIssuerMismatch` on read. +- **Tests:** storage/provider unit + e2e `byIssuer` persistence; AS-migration connect fixture is plan step 4 (required for #1527 AC3). + +### SEP-2207 — refresh tokens + +- **Standard OAuth:** SDK scope determination / refresh after v2 upgrade. +- **EMA:** IdP authorize requests `openid offline_access`; IdP session refresh in `refreshIdpOidcSession`. +- **Mid-session:** silent refresh / EMA re-mint; never assume a refresh token exists. +- **Tests:** refresh-after-401 e2e; standard-OAuth `offline_access` authorize assert is plan step 2. + +### SEP-2350 — step-up scope accumulation + +| Path | Behavior | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| Web remote | `authChallengeFetch` → `AuthChallenge` → browser `handleAuthChallenge` → optional step-up modal → `pushAuthState` | +| TUI/CLI direct | SDK silent 401 / streamable-HTTP 403 retry where `onInsufficientScope` allows; Inspector confirm UX + EMA branch | +| All | 403 unions scopes; 401 replaces; `forceReauthorization` when refresh cannot widen; scopes re-exported from SDK | + +See [v2_auth_mid_session.md](v2_auth_mid_session.md). Hosted UX: smoke §5 (complementary to CI). + +### SEP-2351 — discovery suffix + +Inspector calls SDK discovery helpers only (`core/auth/discovery.ts`, `cimd.ts`, EMA `resourceContext.ts`, and inside `auth()`). No local well-known URL builder. URL-shape asserts are plan step 3. + +--- + +## Architecture (current) + +```text +Connect-time standard OAuth EMA Web remote MCP +─────────────────────────── ─── ────────────── +SDK auth() via mcpAuth() Leg 1: idpOidc + SDK Token stub + intercept +BaseOAuthClientProvider Leg 2: SDK ID-JAG helper Browser owns OAuth +byIssuer OAuthStorage Leg 3: local until SDK auth-state hot-swap +completeOAuthFlow(code, iss?) grows resource/scope + +TUI/CLI: live provider + SDK transport silent retry; handleAuthChallenge for EMA / interactive defer +``` + +--- ## Non-goals -- Reimplementing MCP authorization-server or resource-server wire formats. +- Reimplementing authorization-server or resource-server wire formats. - v1 / v1.5 backport. -- **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track. -- Full MCP **2026-07-28 stateless transport** migration (separate from auth hardening). -- Server-side scope-challenge middleware in Inspector test servers beyond what [Mid-session auth](v2_auth_mid_session.md) requires for 403 fixtures. -- **`AuthRecoveryTransport`** or client-side fetch intercept on TUI/CLI SDK transport (defer silent retry to v2 SDK; web node intercept stays). -- Unifying web remote and TUI/CLI at the transport wrapper layer. - -## Testing - -| SEP | Test | -| --- | ---- | -| SEP-2468 | Callback with matching `iss` succeeds; mismatched `iss` rejected; AS advertising support without `iss` rejected | -| SEP-837 | DCR succeeds for web (https redirect) and TUI/CLI (localhost redirect) against OIDC AS requiring `application_type` | -| SEP-2352 | Simulated AS migration in PRM → credentials invalidated → re-DCR → connect succeeds | -| SEP-2207 | Authorize request includes `offline_access` when AS advertises it; connect succeeds without refresh token when AS omits one | -| SEP-2350 | Connect-time and mid-session 403 step-up use union scopes; 401 re-login replaces scopes | -| SEP-2351 | Discovery resolves against draft suffix paths | - -Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) after implementation. - -## File touch list (expected) - -| Area | Files | -| ---- | ----- | -| Upgrade | Root and client `package.json`; import paths v1 → `@modelcontextprotocol/client` | -| Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` | -| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` — v2 hooks: `invalidateCredentials`, issuer-keyed storage, `application_type` | -| Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/auth/oauth-persist.ts`, `core/auth/remote/storage-remote.ts`, `clients/web/src/lib/remoteOAuthStorage.ts` | -| Web remote (unchanged on v2) | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/node/authChallengeFetch.ts`, `core/mcp/remote/node/server.ts` | -| TUI/CLI | [Mid-session § TUI and CLI](v2_auth_mid_session.md#tui-and-cli-implementation): `clients/tui/src/App.tsx`; `clients/cli/src/cliOAuth.ts`; provider wiring in `core/mcp/inspectorClient.ts` | -| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) | -| Delete on upgrade | `core/auth/mcpAuth.ts` (re-export SDK), `core/auth/scopes.ts` (use SDK `unionScopes`) | -| Tests | `inspectorClient-oauth*.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (planned) | -| Smoke doc | `specification/v2_auth_smoke_testing.md` | +- Client credentials grant ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). +- Full 2026-07-28 era/`versionNegotiation` migration (era UI already exists; plan step 5 only fixes 401 detection under auto/modern — not a default-era flip). +- `AuthRecoveryTransport` or client-side fetch intercept on TUI/CLI SDK transport. +- Unifying web remote and TUI/CLI transport wrappers. +- Per-SEP smoke scenarios for behavior already covered by automated tests. + +--- + +## Normative references + +- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization) +- [MCP authorization (2025-11-25 — current stable)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [2026-07-28 RC — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — Authorization Server Issuer Identification +- Tracking issue: [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527) ## Related specs -| Doc | Relationship | -| --- | ------------ | -| [v2_auth_mid_session.md](v2_auth_mid_session.md) | Mid-session auth (near term); owns runtime SEP-2350 behavior and most recovery UX | -| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 1–3; SEP-2207 leg 1 scopes; future v2 SDK Layer-2 helpers | -| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual verification ladder | -| [v2_scope.md](v2_scope.md) | OAuth handling scope item | -| [v2_storage.md](v2_storage.md) | Shared `oauth.json` — issuer-keyed credentials affect storage schema | +| Doc | Relationship | +| ------------------------------------------------------------ | ---------------------------------------------------- | +| [v2_auth_mid_session.md](v2_auth_mid_session.md) | SEP-2350 runtime + recovery UX (done) | +| [v2_auth_sdk_consolidation.md](v2_auth_sdk_consolidation.md) | What auth code sits on the v2 SDK vs Inspector | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs; SEP-2207 IdP scopes | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Hosted-IdP manual verification (complementary to CI) | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` / issuer-keyed credentials | diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 601eac0d8..a598c7a1b 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -1,7 +1,8 @@ # Inspector V2 Auth — Mid-session authorization ### [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](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | Mid-session | [Smoke testing](v2_auth_smoke_testing.md) + +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | Mid-session | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md) Design and implementation reference for **mid-session authorization** in Inspector: detecting when MCP traffic needs new or elevated credentials, running the correct OAuth or EMA recovery flow, and restoring the session — across **web**, **TUI**, and **CLI**. @@ -15,10 +16,10 @@ Inspector already supports **connect-time** OAuth and EMA: the first `connect()` **Mid-session authorization** covers everything **after** that: a token expires, is revoked, or lacks scopes for a specific operation while the user is working. The server signals this with HTTP **401** (bad/missing token) or **403** + `insufficient_scope` (valid token, not enough scope — **step-up**). Inspector normalizes those signals into an **`AuthChallenge`**, runs **`handleAuthChallenge()`**, and either refreshes silently or starts interactive OAuth. -| Client | Transport model | Auth runs where | Recovery highlights | -| ------ | --------------- | --------------- | ------------------- | -| **Web** | Browser → Hono **remote** backend → MCP server | Browser (`OAuthManager`) | `POST /api/mcp/auth-state` hot-swap; full-page OAuth + **resume snapshot** | -| **TUI / CLI** | **Direct** SDK transport in-process | Node (`OAuthManager`) | Loopback callback on `127.0.0.1:6276`; user retries action after interactive auth | +| Client | Transport model | Auth runs where | Recovery highlights | +| ------------- | ---------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- | +| **Web** | Browser → Hono **remote** backend → MCP server | Browser (`OAuthManager`) | `POST /api/mcp/auth-state` hot-swap; full-page OAuth + **resume snapshot** | +| **TUI / CLI** | **Direct** SDK transport in-process | Node (`OAuthManager`) | Loopback callback on `127.0.0.1:6276`; user retries action after interactive auth | All three clients share **`core/auth/challenge.ts`** and **`OAuthManager.handleAuthChallenge()`**. UX and wire protocol differ by client. @@ -28,18 +29,18 @@ All three clients share **`core/auth/challenge.ts`** and **`OAuthManager.handleA ### Connect-time vs mid-session -| | Connect-time | Mid-session | -| --- | --- | --- | -| **When** | First connect with no valid tokens, or reconnect before a remote session exists | Any MCP RPC **after** credentials were supplied | -| **Typical signal** | `connect()` throws **401** | MCP HTTP **401/403** on an in-flight request | -| **Web handler** | `App.tsx` → `authenticate()` → redirect | `handleAuthChallenge()` → `auth-state` push and/or redirect | -| **Core handler** | Same OAuth primitives | `OAuthManager.handleAuthChallenge()` | +| | Connect-time | Mid-session | +| ------------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **When** | First connect with no valid tokens, or reconnect before a remote session exists | Any MCP RPC **after** credentials were supplied | +| **Typical signal** | `connect()` throws **401** | MCP HTTP **401/403** on an in-flight request | +| **Web handler** | `App.tsx` → `authenticate()` → redirect | `handleAuthChallenge()` → `auth-state` push and/or redirect | +| **Core handler** | Same OAuth primitives | `OAuthManager.handleAuthChallenge()` | Both paths use the same token storage, refresh, and authorization-code exchange; only **detection** and **session update** mechanics differ. ### Step-up authorization (SEP-2350) -**Step-up** is OAuth for “I have a token, but not permission for *this* operation.” MCP servers usually express it as: +**Step-up** is OAuth for “I have a token, but not permission for _this_ operation.” MCP servers usually express it as: ```http HTTP/1.1 403 Forbidden @@ -50,10 +51,10 @@ By contrast, **401** means the token is missing, invalid, or expired — fix by **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** (in the MCP 2026-07-28 authorization hardening) requires clients to **accumulate scopes** on step-up: -| Situation | Client scope behavior | -| --------- | --------------------- | -| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools | -| **401** re-login | **Replace** scope set (user may down-scope at the AS) | +| Situation | Client scope behavior | +| ---------------- | ---------------------------------------------------------------------------------------------------------------- | +| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools | +| **401** re-login | **Replace** scope set (user may down-scope at the AS) | Inspector persists granted scopes in `OAuthStorage.scope` (`saveScope()`): when the authorization server returns a `scope` parameter on the token response, that value is **authoritative** (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When `scope` is **omitted** on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted. @@ -61,7 +62,7 @@ For step-up, `handleAuthChallenge()` computes `authorizationScopes` as the union **UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation). -**Rationale (Inspector as a testing tool):** EMA can often satisfy scope upgrades without a visible IdP prompt when the organization session is still valid. That is convenient in production clients, but Inspector deliberately surfaces the requested scopes first so operators can see *what* permission elevation is happening while exploring MCP servers — the same visibility standard OAuth step-up already provides on web. +**Rationale (Inspector as a testing tool):** EMA can often satisfy scope upgrades without a visible IdP prompt when the organization session is still valid. That is convenient in production clients, but Inspector deliberately surfaces the requested scopes first so operators can see _what_ permission elevation is happening while exploring MCP servers — the same visibility standard OAuth step-up already provides on web. Normative background: [MCP authorization — Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow), [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors), [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). @@ -69,16 +70,16 @@ Normative background: [MCP authorization — Step-Up Authorization Flow](https:/ ## Terminology -| Term | Meaning | -| ---- | ------- | -| **App tab** | An Inspector **view** inside one browser window: Tools, Resources, Prompts, etc. (`activeTab` in `App.tsx`). Not a browser tab. | -| **Browser tab** | A top-level browsing context (window/tab) running the Inspector SPA. Each has its own `InspectorClient`, remote session, and OAuth context. | -| **Auth challenge** | Normalized `{ reason, requiredScopes?, … }` — not raw HTTP. Built by `parseAuthChallengeFromResponse()` or received on the remote wire. | +| Term | Meaning | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **App tab** | An Inspector **view** inside one browser window: Tools, Resources, Prompts, etc. (`activeTab` in `App.tsx`). Not a browser tab. | +| **Browser tab** | A top-level browsing context (window/tab) running the Inspector SPA. Each has its own `InspectorClient`, remote session, and OAuth context. | +| **Auth challenge** | Normalized `{ reason, requiredScopes?, … }` — not raw HTTP. Built by `parseAuthChallengeFromResponse()` or received on the remote wire. | | **`handleAuthChallenge()`** | Core orchestrator: silent refresh / EMA re-mint, or start interactive OAuth. Returns `satisfied`, `step_up_confirm` (EMA/web deferral), `interactive`, or `failed`. | -| **`RemoteAuthState`** | Payload for `POST /api/mcp/auth-state`: fresh `oauthTokens` (+ optional `oauthClient`) pushed to the node backend without tearing down the MCP transport. | -| **Command-scoped recovery** | Failure during an active user action (tool call, connect button). May **retry the same JSON-RPC** once after silent recovery. | -| **Ambient recovery** | Failure with no correlated in-flight command (idle SSE). Prepares the session for the **next** user action — no auto-retry of a specific RPC. | -| **OAuth resume snapshot** | `sessionStorage` blob written before a **full-page** OAuth redirect so the web app can restore server, app tab, and form state after callback. | +| **`RemoteAuthState`** | Payload for `POST /api/mcp/auth-state`: fresh `oauthTokens` (+ optional `oauthClient`) pushed to the node backend without tearing down the MCP transport. | +| **Command-scoped recovery** | Failure during an active user action (tool call, connect button). May **retry the same JSON-RPC** once after silent recovery. | +| **Ambient recovery** | Failure with no correlated in-flight command (idle SSE). Prepares the session for the **next** user action — no auto-retry of a specific RPC. | +| **OAuth resume snapshot** | `sessionStorage` blob written before a **full-page** OAuth redirect so the web app can restore server, app tab, and form state after callback. | --- @@ -108,11 +109,11 @@ TUI / CLI direct: ### Recovery shapes -| Shape | When | MCP retry | Page unload | -| ----- | ---- | --------- | ----------- | -| **Silent in-process** | Refresh, EMA re-mint after user confirmed step-up (web modal **Authorize**), TUI/CLI confirm | **Yes** (command-scoped, once) | No | -| **In-app step-up confirm** | Web EMA `insufficient_scope` before re-mint / IdP | **No** — modal only; retry after success toast | No | -| **Interactive full-page** | Standard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable) | **No** — user retries action after callback | Yes (web) | +| Shape | When | MCP retry | Page unload | +| -------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------- | +| **Silent in-process** | Refresh, EMA re-mint after user confirmed step-up (web modal **Authorize**), TUI/CLI confirm | **Yes** (command-scoped, once) | No | +| **In-app step-up confirm** | Web EMA `insufficient_scope` before re-mint / IdP | **No** — modal only; retry after success toast | No | +| **Interactive full-page** | Standard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable) | **No** — user retries action after callback | Yes (web) | ```text Browser InspectorClient Hono RemoteSession MCP server @@ -131,18 +132,18 @@ RemoteClientTransport StreamableHTTPClientTransport ### Code map -| Area | Primary files | -| ---- | ------------- | -| Challenge types & parsing | `core/auth/challenge.ts`, `core/auth/oauthUx.ts` | -| Handler & storage | `core/mcp/oauthManager.ts`, `core/auth/mcpAuth.ts`, `core/auth/scopes.ts` | -| EMA | `core/auth/ema/emaFlow.ts`, `core/auth/ema/resourceContext.ts` — see [v2_auth_ema.md](v2_auth_ema.md) | -| Web remote backend | `core/mcp/remote/node/server.ts`, `remote-session.ts`, `core/mcp/node/authChallengeFetch.ts` | -| Web remote client | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/inspectorClient.ts` | -| Web app | `clients/web/src/App.tsx`, `utils/oauthResume.ts`, `utils/pendingReauth.ts`, `utils/browserTabVisibility.ts`, `components/groups/StepUpAuthModal/` | -| TUI | `clients/tui/src/App.tsx`, `utils/tuiOAuth.ts` | -| CLI | `clients/cli/src/cliOAuth.ts` | -| Runner OAuth (TUI/CLI) | `core/auth/node/runner-interactive-oauth.ts`, `oauth-callback-server.ts` | -| Step-up test fixture | `test-servers/configs/oauth-step-up-demo.json`, `test-servers/src/test-server-oauth.ts` | +| Area | Primary files | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Challenge types & parsing | `core/auth/challenge.ts`, `core/auth/oauthUx.ts` | +| Handler & storage | `core/mcp/oauthManager.ts`, `core/auth/mcpAuth.ts`, `core/auth/scopes.ts` | +| EMA | `core/auth/ema/emaFlow.ts`, `core/auth/ema/resourceContext.ts` — see [v2_auth_ema.md](v2_auth_ema.md) | +| Web remote backend | `core/mcp/remote/node/server.ts`, `remote-session.ts`, `core/mcp/node/authChallengeFetch.ts` | +| Web remote client | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/inspectorClient.ts` | +| Web app | `clients/web/src/App.tsx`, `utils/oauthResume.ts`, `utils/pendingReauth.ts`, `utils/browserTabVisibility.ts`, `components/groups/StepUpAuthModal/` | +| TUI | `clients/tui/src/App.tsx`, `utils/tuiOAuth.ts` | +| CLI | `clients/cli/src/cliOAuth.ts` | +| Runner OAuth (TUI/CLI) | `core/auth/node/runner-interactive-oauth.ts`, `oauth-callback-server.ts` | +| Step-up test fixture | `test-servers/configs/oauth-step-up-demo.json`, `test-servers/src/test-server-oauth.ts` | --- @@ -159,8 +160,8 @@ export type AuthChallengeReason = export interface AuthChallenge { reason: AuthChallengeReason; - requiredScopes?: string[]; // From WWW-Authenticate scope= (this operation) - authorizationScopes?: string[]; // SEP-2350 union — set before re-auth, not on wire + requiredScopes?: string[]; // From WWW-Authenticate scope= (this operation) + authorizationScopes?: string[]; // SEP-2350 union — set before re-auth, not on wire resource?: string; audience?: string; message?: string; @@ -212,10 +213,10 @@ Read-only check against **current storage** (and token expiry helpers). Used bef #### Standard OAuth -| Reason | Silent | Interactive | -| ------ | ------ | ----------- | -| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) | -| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) | +| Reason | Silent | Interactive | +| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) | +| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) | Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. On success, `saveScope()` stores the AS-granted scope (explicit `scope` on the token response, or the requested union when `scope` is omitted per RFC 6749 §5.1). @@ -223,21 +224,21 @@ Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` s Per [v2_auth_ema.md](v2_auth_ema.md): **no** fallback to standard resource-OAuth redirect. -| Reason | Silent | Interactive | -| ------ | ------ | ----------- | -| Resource token expired / unauthorized | Legs 2–3 re-mint | — | -| IdP session missing | — | Leg 1 IdP OIDC redirect, then legs 2–3 | -| `insufficient_scope` | After user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session valid | IdP redirect if IdP session invalid | +| Reason | Silent | Interactive | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| Resource token expired / unauthorized | Legs 2–3 re-mint | — | +| IdP session missing | — | Leg 1 IdP OIDC redirect, then legs 2–3 | +| `insufficient_scope` | After user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session valid | IdP redirect if IdP session invalid | On **web**, `handleAuthChallenge()` returns **`step_up_confirm`** for EMA `insufficient_scope` until the user clicks **Authorize** in `StepUpAuthModal`; it does **not** call `trySilentEmaAuth()` before that. After confirm, silent re-mint runs in-process when possible; otherwise IdP redirect + callback (same as before). ### Outcomes — what callers do -| Outcome | Web remote | TUI / CLI direct | -| ------- | ---------- | ---------------- | -| **`satisfied`** | `pushAuthState()`; command-scoped: **retry send once** | Reconnect or SDK retry (v2); command wrapper may retry RPC | -| **`step_up_confirm`** | Throw `AuthRecoveryRequiredError` (`emaStepUpConfirm`) → `StepUpAuthModal` (EMA copy) | N/A (web-only deferral) | -| **`interactive`** | Throw `AuthRecoveryRequiredError` (enriched challenge) → App shows modal or redirect + snapshot | `AuthRecoveryRequiredError` or `authChallengeInteractive` → callback server flow | +| Outcome | Web remote | TUI / CLI direct | +| --------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| **`satisfied`** | `pushAuthState()`; command-scoped: **retry send once** | Reconnect or SDK retry (v2); command wrapper may retry RPC | +| **`step_up_confirm`** | Throw `AuthRecoveryRequiredError` (`emaStepUpConfirm`) → `StepUpAuthModal` (EMA copy) | N/A (web-only deferral) | +| **`interactive`** | Throw `AuthRecoveryRequiredError` (enriched challenge) → App shows modal or redirect + snapshot | `AuthRecoveryRequiredError` or `authChallengeInteractive` → callback server flow | **InspectorClient events (direct transport):** `authChallengeAmbient` (idle SSE / ambient recovery), `authChallengeCommand` (command-scoped direct recovery — no ambient toast), `authChallengeInteractive`, `authChallengeRecovered`. | **`failed`** | Toast / banner; stay connected (degraded) | Error message; stay connected (TUI) or exit non-zero (CLI one-shot) | @@ -255,10 +256,10 @@ On **web**, `handleAuthChallenge()` returns **`step_up_confirm`** for EMA `insuf **Dual delivery — one channel per incident:** -| Path | Trigger | Wire | Client behavior | -| ---- | ------- | ---- | --------------- | +| Path | Trigger | Wire | Client behavior | +| ------------------ | --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | | **Command-scoped** | Active `POST /api/mcp/send` | HTTP **200** `{ ok: false, kind: "auth_challenge", authChallenge }` | `handleAuthChallenge()` → `pushAuthState()` → **retry same JSON-RPC once** | -| **Ambient** | Transport error while idle | SSE `auth_challenge` | `handleAmbientAuthChallenge()` → push auth state; **no RPC retry** | +| **Ambient** | Transport error while idle | SSE `auth_challenge` | `handleAmbientAuthChallenge()` → push auth state; **no RPC retry** | `authReturnedViaHttp` prevents duplicating a command-scoped challenge on SSE. @@ -314,11 +315,11 @@ Full-page redirect (`window.location.href`) destroys in-memory React state. Befo interface OAuthResumeSnapshot { version: 1; serverId: string; - activeTab: string; // App tab id ("Tools", …) + activeTab: string; // App tab id ("Tools", …) authKind: "step_up" | "reauth"; - tabUi: Partial>; // lifted *UiState shells + tabUi: Partial>; // lifted *UiState shells remoteSessionId?: string; - authChallenge?: AuthChallenge; // step-up: verify scopes after callback + authChallenge?: AuthChallenge; // step-up: verify scopes after callback } ``` @@ -351,18 +352,18 @@ Future: shared `RemoteOAuthStorage` → `oauth.json` may use optional `navigator ### Web UX reference -| Situation | Behavior | -| --------- | -------- | -| Silent refresh / EMA re-mint (after confirm) | Toast “Refreshing authorization…” or EMA in-progress toast — no modal | -| **401** interactive | Toast “Session expired…” → auto-redirect; no confirm modal | -| **403** standard-OAuth step-up | Modal → optional pre-redirect toast → redirect → callback restore | -| **403** EMA step-up (web) | Modal (organization copy) → in-progress toast → silent re-mint or IdP redirect → success toast | -| EMA IdP leg 1 (401 / expired IdP) | Toast “Re-authenticating…” → auto-redirect | -| Step-up **Cancel** | Connected; failed action shows error | -| OAuth abort / callback failure | **ReAuthBanner**; Re-authenticate uses in-session `authenticate()` when already connected (no disconnect cycle) | -| Step-up callback, scopes still insufficient | Warning toast — not green success | -| `insufficient_scope` recovery failure (non-banner reasons) | Yellow toast — not ReAuthBanner | -| Concurrent step-up while modal open | Yellow toast — complete or cancel current step-up first | +| Situation | Behavior | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Silent refresh / EMA re-mint (after confirm) | Toast “Refreshing authorization…” or EMA in-progress toast — no modal | +| **401** interactive | Toast “Session expired…” → auto-redirect; no confirm modal | +| **403** standard-OAuth step-up | Modal → optional pre-redirect toast → redirect → callback restore | +| **403** EMA step-up (web) | Modal (organization copy) → in-progress toast → silent re-mint or IdP redirect → success toast | +| EMA IdP leg 1 (401 / expired IdP) | Toast “Re-authenticating…” → auto-redirect | +| Step-up **Cancel** | Connected; failed action shows error | +| OAuth abort / callback failure | **ReAuthBanner**; Re-authenticate uses in-session `authenticate()` when already connected (no disconnect cycle) | +| Step-up callback, scopes still insufficient | Warning toast — not green success | +| `insufficient_scope` recovery failure (non-banner reasons) | Yellow toast — not ReAuthBanner | +| Concurrent step-up while modal open | Yellow toast — complete or cancel current step-up first | --- @@ -381,25 +382,25 @@ CLI never spawns TUI/web for auth — completes locally or fails. ### TUI UX (`clients/tui/src/App.tsx`) -| Situation | Behavior | -| --------- | -------- | -| Silent recovery | Auth tab: “Refreshing authorization…” | -| **401** | Auto `runOAuthAuthentication()` (same as connect-time) | +| Situation | Behavior | +| ------------------------------ | ---------------------------------------------------------------------- | +| Silent recovery | Auth tab: “Refreshing authorization…” | +| **401** | Auto `runOAuthAuthentication()` (same as connect-time) | | **403** standard-OAuth step-up | Switch to Auth tab; **A** authorize / **C** cancel; browser → callback | -| **403** EMA | Silent re-mint | -| Step-up on connected server | `presentStepUpForServer()` selects server + opens Auth tab | -| Reauth for affected server | `handleAuthRecoveryRequired()` switches server when needed | -| Clear OAuth | Auth tab **S** | +| **403** EMA | Silent re-mint | +| Step-up on connected server | `presentStepUpForServer()` selects server + opens Auth tab | +| Reauth for affected server | `handleAuthRecoveryRequired()` switches server when needed | +| Clear OAuth | Auth tab **S** | ### CLI UX (`clients/cli/src/cliOAuth.ts`) -| Situation | Behavior | -| --------- | -------- | -| Connect-time 401 | `connectInspectorWithOAuth()` → authorize URL on stdout → callback | -| Mid-session interactive | `handleCliAuthRecoveryRequired()` / `withCliAuthRecoveryRetry()` (**one retry**) | -| **403** standard-OAuth step-up | stderr: scope message + `Proceed with step-up authorization? [y/N]` | -| Decline step-up | Exit non-zero | -| Insufficient scope after OAuth | stderr message from `stepUpInsufficientScopeMessage()` | +| Situation | Behavior | +| ------------------------------ | -------------------------------------------------------------------------------- | +| Connect-time 401 | `connectInspectorWithOAuth()` → authorize URL on stdout → callback | +| Mid-session interactive | `handleCliAuthRecoveryRequired()` / `withCliAuthRecoveryRetry()` (**one retry**) | +| **403** standard-OAuth step-up | stderr: scope message + `Proceed with step-up authorization? [y/N]` | +| Decline step-up | Exit non-zero | +| Insufficient scope after OAuth | stderr message from `stepUpInsufficientScopeMessage()` | ### SSE limitation @@ -409,16 +410,16 @@ Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). ## Client matrix -| Concern | Web | TUI | CLI | -| ------- | --- | --- | --- | -| Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI | -| Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` | -| OAuth storage | `RemoteOAuthStorage` → `oauth.json` (via `/api/storage/oauth`) | `NodeOAuthStorage` (file) | Same file as TUI | -| Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry | -| Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback | -| Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** | -| EMA step-up | Web modal + confirm (then silent or IdP) | Confirm on Auth tab, then silent | **y/N**, then silent | -| Multiple browser tabs | Independent sessions; background defer | N/A | N/A | +| Concern | Web | TUI | CLI | +| --------------------- | -------------------------------------------------------------- | -------------------------------- | ------------------------- | +| Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI | +| Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` | +| OAuth storage | `RemoteOAuthStorage` → `oauth.json` (via `/api/storage/oauth`) | `NodeOAuthStorage` (file) | Same file as TUI | +| Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry | +| Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback | +| Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** | +| EMA step-up | Web modal + confirm (then silent or IdP) | Confirm on Auth tab, then silent | **y/N**, then silent | +| Multiple browser tabs | Independent sessions; background defer | N/A | N/A | --- @@ -428,10 +429,10 @@ Step-up integration tests and manual smokes use the **composable OAuth server** ### How scopes work at two levels -| Level | Config field | Role | -| ----- | ------------ | ---- | -| **Authorization server** | `oauth.scopesSupported` | Advertises every scope the AS **may grant** (PRM / AS metadata). Inspector uses this for connect-time consent and step-up union. Must include all scopes referenced by `requiredScopes` below. | -| **Per capability** | `requiredScopes` on a tool, resource, or prompt preset ref | Scopes the **access token must already include** before that RPC succeeds. Missing scope → **403** + `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Omitted → only global bearer validity (401 if no/invalid token). | +| Level | Config field | Role | +| ------------------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Authorization server** | `oauth.scopesSupported` | Advertises every scope the AS **may grant** (PRM / AS metadata). Inspector uses this for connect-time consent and step-up union. Must include all scopes referenced by `requiredScopes` below. | +| **Per capability** | `requiredScopes` on a tool, resource, or prompt preset ref | Scopes the **access token must already include** before that RPC succeeds. Missing scope → **403** + `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Omitted → only global bearer validity (401 if no/invalid token). | **Connect-time vs step-up:** Inspector catalog OAuth scopes control what the user grants on **first connect**. The composable server may accept that token for some operations but reject others until step-up adds more scopes. @@ -456,7 +457,13 @@ Illustrates server-level `scopesSupported` plus tool-, resource-, and prompt-lev "enabled": true, "mode": "combined", "requireAuth": true, - "scopesSupported": ["mcp", "tools:read", "weather:read", "secrets:read", "admin:write"], + "scopesSupported": [ + "mcp", + "tools:read", + "weather:read", + "secrets:read", + "admin:write" + ], "supportDCR": true, "supportRefreshTokens": true }, @@ -472,9 +479,7 @@ Illustrates server-level `scopesSupported` plus tool-, resource-, and prompt-lev "requiredScopes": ["secrets:read"] } ], - "prompts": [ - { "preset": "simple_prompt", "requiredScopes": ["weather:read"] } - ] + "prompts": [{ "preset": "simple_prompt", "requiredScopes": ["weather:read"] }] } ``` @@ -503,11 +508,11 @@ On each MCP request, after bearer validation: 1. Parse JSON-RPC `method` and `params`. 2. Look up `requiredScopes` from the registry built at startup: -| MCP method | Registry key | -| ---------- | ------------ | -| `tools/call` | tool `name` (`params.name`) | -| `resources/read` | resource `uri` (`params.uri`) | -| `prompts/get` | prompt `name` (`params.name`) | +| MCP method | Registry key | +| -------------------------- | ---------------------------------- | +| `tools/call` | tool `name` (`params.name`) | +| `resources/read` | resource `uri` (`params.uri`) | +| `prompts/get` | prompt `name` (`params.name`) | | `resources/templates/read` | template name or URI from `params` | 3. Compare granted scopes on the access token (stored at token issue time in combined mode) against `requiredScopes`. @@ -531,12 +536,12 @@ On each MCP request, after bearer validation: ## Related specifications -| Document | Relationship | -| -------- | ------------ | -| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | -| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | -| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | -| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` on web (implemented) | +| Document | Relationship | +| ---------------------------------------------------- | --------------------------------------------------------------------------- | +| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` on web (implemented) | --- diff --git a/specification/v2_auth_sdk_consolidation.md b/specification/v2_auth_sdk_consolidation.md new file mode 100644 index 000000000..f3711bd76 --- /dev/null +++ b/specification/v2_auth_sdk_consolidation.md @@ -0,0 +1,136 @@ +# Inspector V2 Auth — SDK consolidation (as-built) + +### [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_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) + +#### [Overview](v2_auth.md) | [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 + +Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0-beta.4** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. + +Related as-built specs: [Hardening](v2_auth_hardening.md), [EMA](v2_auth_ema.md), [Mid-session](v2_auth_mid_session.md). + +--- + +## Summary + +Inspector already delegated **connect-time standard OAuth** to SDK `auth()` (via a thin `mcpAuth()` wrapper) as part of the v2 upgrade and hardening work. A follow-up pass looked for remaining protocol code that still duplicated the SDK. + +**Changed:** + +| Change | Detail | +| ----------------------- | ------------------------------------------------------------------------ | +| EMA leg 2 (ID-JAG mint) | `exchangeIdJag` → SDK `discoverAndRequestJwtAuthGrant` | +| Scope union helpers | `scopes.ts` re-exports SDK `computeScopeUnion` / `isStrictScopeSuperset` | + +**Not changed** (host integration or incomplete SDK helpers): remoting recovery, durable `OAuthStorage`, navigation/callbacks, EMA leg 1 + leg 3, `AuthChallenge` pipeline, `mcpAuth()`, step-up UX, `CrossAppAccessProvider`. + +Net effect: ~30 lines of duplicate protocol logic removed; no files deleted (`tokenEndpoint.ts` remains for leg 3). + +--- + +## Constraint: two transport models + +```text +Web: browser owns OAuth UX + storage API + Hono owns MCP transport with a frozen/mutable token stub + 401/403 → auth_challenge envelope → browser handleAuthChallenge → POST /api/mcp/auth-state + +TUI/CLI: in-process StreamableHTTP/SSE + live OAuthClientProvider + silent SDK retry where policy allows; Inspector owns interactive confirm + EMA +``` + +The SDK’s in-process 401/403 retry assumes the live provider and redirect owner share a process. On web they do not — interactive OAuth cannot run on the Hono stub, and recovery must hot-swap tokens on the existing remote session. That architecture is intentional and permanent; consolidation only targets protocol helpers that work on both paths. + +--- + +## Ownership (as-built) + +| Concern | Owner | Notes | +| ------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------- | +| Standard OAuth flow | **SDK** `auth()` | Forwarded by `core/auth/mcpAuth.ts` | +| `OAuthClientProvider` bridge | **Inspector** `BaseOAuthClientProvider` | Storage + navigation | +| Issuer-keyed `OAuthStorage` | **Inspector** | File / remote / session adapters; SEP-2352 `byIssuer` | +| Discovery / CIMD / refresh / start+exchange | **SDK** | Called from Inspector host code | +| Scope union / strict superset | **SDK** (re-export) | `core/auth/scopes.ts` | +| Grant persistence helpers | **Inspector** | `resolvePersistedScopeAfterGrant`, `resolveEffectiveGrantedScope` | +| EMA leg 1 (IdP OIDC) | **Inspector** + SDK primitives | `idpOidc.ts` | +| EMA leg 2 (ID-JAG) | **SDK** | Wrapped by `exchangeIdJag` | +| EMA leg 3 (resource token) | **Inspector** | `redeemIdJagForAccessToken` + `tokenEndpoint.ts` | +| EMA transport adapter | **Inspector** | `EmaTransportOAuthProvider` | +| Mid-session orchestration | **Inspector** | `OAuthManager.handleAuthChallenge()` | +| WWW-Authenticate → `AuthChallenge` | **Inspector** | `challenge.ts` (string + remoting wire) | +| Web remoting auth | **Inspector** | Stub provider, intercept fetch, auth-state push | +| TUI/CLI loopback callback | **Inspector** | `127.0.0.1:6276` | +| Step-up / OAuth UX copy | **Inspector** | Modal, Auth tab, CLI confirm | + +All three clients share `core/auth` + `OAuthManager`. Per-client code is navigation, storage adapter choice, and confirmation UX only. + +--- + +## What we changed + +### EMA leg 2 → SDK `discoverAndRequestJwtAuthGrant` + +`core/auth/ema/wire.ts` `exchangeIdJag` wraps the SDK helper. `emaFlow.mintEmaResourceTokens` always passes a real resource (`resourceUrl ?? resourceMetadata.resource`). SDK errors are mapped to existing Inspector prefixes for tests/UX. + +Accepted tradeoff: the SDK helper always posts `client_id` / `client_secret` in the body (`client_secret_post`). That matches our declared method and EMA mocks; a Basic-only IdP for ID-JAG would need a local fallback or an SDK `authMethod` option (see wishlist). + +### Scope helpers → SDK re-exports + +`core/auth/scopes.ts` re-exports `computeScopeUnion` and `isStrictScopeSuperset` (semantics matched existing tests). Local RFC 6749 persistence helpers stay — the SDK does not export equivalents, and `OAuthManager` depends on them. + +--- + +## What we decided not to change (and why) + +### EMA leg 3 — keep local `redeemIdJagForAccessToken` + +SDK `exchangeJwtAuthGrant` only sends `grant_type` + `assertion`. It has no `resource` or `scope` parameters. Inspector’s leg 3 (and `wire.test.ts`) set both on the JWT-bearer body. Adopting the helper today would drop wire fields. Tracked as a wishlist gap, not a permanent architectural split. + +### `CrossAppAccessProvider` — do not adopt + +In-memory tokens, IdP assertion callback only (no IdP OIDC leg 1 / session cache), no durable storage, aimed at direct `auth()` + transport. Inspector needs shared `oauth.json`, IdP redirect/loopback, remoting token stub, and step-up UX. Wrapping it would be larger than `EmaTransportOAuthProvider` without covering web remoting. **Not a wishlist item** — that would mean reshaping the SDK around Inspector’s host model. + +### WWW-Authenticate — keep Inspector `parseWwwAuthenticateBearer` + +SDK `extractWWWAuthenticateParams` takes a `Response` only. Remoting/`parseAuthChallengeFromError` work from header **strings**. The SDK parser also mishandles `Bearer` + extra whitespace and `scope=""`. Using it on the Response path only would create two parsers. Keep the local string parser + `AuthChallenge` builder. + +### Web remoting recovery — keep Inspector protocol + +Do not run interactive SDK `auth()` on Hono; do not replace `POST /api/mcp/auth-state` with in-process transport retry. See [Mid-session](v2_auth_mid_session.md). + +### `mcpAuth()` — keep the thin wrapper + +Sole production boundary over SDK `auth()` for standard OAuth. Pins `McpAuthOptions`, guards `forceReauthorization` + `authorizationCode`, provides the `vi.mock` seam for `OAuthManager` tests, and a stable `@inspector/core` import. Inlining would save almost nothing and cost that discipline. `handleAuthChallenge()` stays for the same reason: no SDK orchestrator covers EMA + remoting + step-up confirm across clients. + +### M2M / `withOAuth` — unused + +No product feature needs `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, or `withOAuth` today. + +--- + +## SDK adoption wishlist + +Only gaps where the **SDK’s own helpers are incomplete or buggy**, such that a normal fix would let Inspector call them without asking the SDK to adopt Inspector’s remoting/storage shape. + +| Gap | Fix in SDK | Then Inspector can… | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `exchangeJwtAuthGrant` omits optional `resource` / `scope` on the JWT-bearer body (leg-2 helpers and `CrossAppAccessProvider.prepareTokenRequest` already deal in resource/scope) | Add optional `resource?` / `scope?` and set them when present | Replace `redeemIdJagForAccessToken`; likely delete `tokenEndpoint.ts` | +| Leg-2 helpers hardcode body `client_secret_post` | Optional `authMethod?: ClientAuthMethod` | Pass through when an IdP is Basic-only | +| `extractWWWAuthenticateParams` is Response-only; brittle on whitespace / empty quoted `scope` | String overload (or shared header parser) with those edge cases fixed | Optionally thin-wrap in `parseWwwAuthenticateBearer` if behavior matches `challenge.test.ts` | + +**Out of wishlist** (would require the SDK to mold itself to Inspector): durable/`OAuthStorage`-backed `CrossAppAccessProvider`, remoting-aware interactive auth on a node stub, replacing auth-state hot-swap, exporting host-only scope persistence policy. + +When a wishlist row ships: bump the client package, swap behind existing tests, update this section and [v2_auth_ema.md](v2_auth_ema.md). + +--- + +## Code map + +| Role | Path | +| ---------------------------------------- | ------------------------------------------------------------------------------------------- | +| SDK `auth()` forwarder | `core/auth/mcpAuth.ts` | +| Provider / storage / challenges / scopes | `core/auth/providers.ts`, `oauth-storage.ts`, `challenge.ts`, `scopes.ts` | +| EMA | `core/auth/ema/*` (`wire.ts` leg 2 = SDK; leg 3 local) | +| Orchestration | `core/mcp/oauthManager.ts` | +| Remoting | `tokenAuthProvider.ts`, `authChallengeFetch.ts`, `remoteClientTransport.ts` | +| Client UX | web `App.tsx` / step-up modal; TUI `tuiOAuth.ts`; CLI `cliOAuth.ts`; node loopback callback | diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index b37c76922..b2621975e 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -1,7 +1,8 @@ # Inspector V2 Auth — OAuth smoke testing (real servers) ### [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](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | Smoke testing + +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | Smoke testing | [SDK consolidation](v2_auth_sdk_consolidation.md) Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers. Complements automated coverage in `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, which uses the in-repo `TestServerHttp` (`createOAuthTestServerConfig`). @@ -11,9 +12,9 @@ This document does **not** replace CI. It records known-good real endpoints, whi EMA and CIMD credentials live in **`~/.mcp-inspector/storage/client.json`** (same file the web **Client Settings** dialog writes). TUI and CLI load it automatically at startup: -| Flag / env | Default | -| ---------- | ------- | -| `--client-config ` | `~/.mcp-inspector/storage/client.json` | +| Flag / env | Default | +| ------------------------ | ---------------------------------------------- | +| `--client-config ` | `~/.mcp-inspector/storage/client.json` | | `MCP_CLIENT_CONFIG_PATH` | Same default when `--client-config` is omitted | For repo smoke fixtures, point at the checked-in template: @@ -26,21 +27,21 @@ CLI flags (`--client-metadata-url`, `--client-id`, `--client-secret`) override v OAuth callback URL (TUI/CLI only): -| Flag / env | Default | -| ---------- | ------- | -| `--callback-url ` | `http://127.0.0.1:6276/oauth/callback` | +| Flag / env | Default | +| ------------------------ | --------------------------------------------- | +| `--callback-url ` | `http://127.0.0.1:6276/oauth/callback` | | `MCP_OAUTH_CALLBACK_URL` | Same default when `--callback-url` is omitted | Web uses `http://localhost:6274/oauth/callback` on the main app server — not these runner settings. ## Terminology -| Term | Meaning in this doc | -| ---- | ------------------- | -| **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. | -| **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. | -| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. | -| **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). | +| Term | Meaning in this doc | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. | +| **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. | +| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. | +| **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). | ## Prerequisites @@ -59,21 +60,21 @@ Web uses `http://localhost:6274/oauth/callback` on the main app server — not t "mcpServers": { "mcp-example-everything": { "type": "streamable-http", - "url": "https://example-server.modelcontextprotocol.io/mcp" + "url": "https://example-server.modelcontextprotocol.io/mcp", }, "github-mcp": { "type": "streamable-http", - "url": "https://api.githubcopilot.com/mcp/" + "url": "https://api.githubcopilot.com/mcp/", }, "stytch-mcp-demo": { "type": "streamable-http", - "url": "https://stytch-as-demo.val.run/mcp" + "url": "https://stytch-as-demo.val.run/mcp", }, "stytch-mcp": { "type": "streamable-http", - "url": "https://mcp.stytch.dev/mcp" - } - } + "url": "https://mcp.stytch.dev/mcp", + }, + }, } ``` @@ -89,14 +90,14 @@ Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettin ## Smoke matrix (real servers) -| Server | URL | Mechanism to smoke | Credentials in repo? | -| ------ | --- | ------------------ | -------------------- | -| **MCP Example “Everything”** (hosted) | `https://example-server.modelcontextprotocol.io/mcp` | **DCR** | No — register at connect time | -| **GitHub MCP** (remote) | `https://api.githubcopilot.com/mcp/` | **Static OAuth App** (or PAT header bypass) | No — you create a GitHub OAuth App | -| **Stytch MCP demo** (hosted) | `https://stytch-as-demo.val.run/mcp` | **CIMD** (also DCR) | No — use [MCPJam CIMD](#cimd-credentials-for-smoke-mcpjam) (default for smoke) | -| **Stytch MCP** (management API) | `https://mcp.stytch.dev/mcp` | **CIMD** (also DCR) | No — same MCPJam CIMD URL; real Stytch login at `stytch.com` | -| **Composable test server** (local) | `http://127.0.0.1:/mcp` | Static, DCR, CIMD (all) | Fake ids in e2e tests only | -| **xaa.dev EMA** | Local resource + `auth.resource.xaa.dev` | EMA (not standard OAuth ladder) | Registered on xaa.dev — see [EMA staging](v2_auth_ema.md#staging-validation-manual--verified) | +| Server | URL | Mechanism to smoke | Credentials in repo? | +| ------------------------------------- | ---------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **MCP Example “Everything”** (hosted) | `https://example-server.modelcontextprotocol.io/mcp` | **DCR** | No — register at connect time | +| **GitHub MCP** (remote) | `https://api.githubcopilot.com/mcp/` | **Static OAuth App** (or PAT header bypass) | No — you create a GitHub OAuth App | +| **Stytch MCP demo** (hosted) | `https://stytch-as-demo.val.run/mcp` | **CIMD** (also DCR) | No — use [MCPJam CIMD](#cimd-credentials-for-smoke-mcpjam) (default for smoke) | +| **Stytch MCP** (management API) | `https://mcp.stytch.dev/mcp` | **CIMD** (also DCR) | No — same MCPJam CIMD URL; real Stytch login at `stytch.com` | +| **Composable test server** (local) | `http://127.0.0.1:/mcp` | Static, DCR, CIMD (all) | Fake ids in e2e tests only | +| **xaa.dev EMA** | Local resource + `auth.resource.xaa.dev` | EMA (not standard OAuth ladder) | Registered on xaa.dev — see [EMA staging](v2_auth_ema.md#staging-validation-manual--verified) | --- @@ -104,13 +105,13 @@ Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettin **Hosted reference server** implementing the full MCP feature surface (tools, resources, prompts, sampling, elicitation). Source: [modelcontextprotocol/example-remote-server](https://github.com/modelcontextprotocol/example-remote-server). Public deployment: -| Field | Value | -| ----- | ----- | -| MCP URL | `https://example-server.modelcontextprotocol.io/mcp` | -| Resource identifier | `https://example-server.modelcontextprotocol.io/` | -| Authorization server | Same host (combined resource + AS) | -| Protected resource metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-protected-resource` | -| AS metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-authorization-server` | +| Field | Value | +| --------------------------- | --------------------------------------------------------------------------------------- | +| MCP URL | `https://example-server.modelcontextprotocol.io/mcp` | +| Resource identifier | `https://example-server.modelcontextprotocol.io/` | +| Authorization server | Same host (combined resource + AS) | +| Protected resource metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-protected-resource` | +| AS metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-authorization-server` | **Verified AS capabilities (June 2026):** @@ -145,13 +146,13 @@ Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettin **Remote GitHub MCP** is the usual choice for **static client credential** smoke testing against a production authorization server that does **not** expose DCR to arbitrary MCP clients. -| Field | Value | -| ----- | ----- | -| MCP URL | `https://api.githubcopilot.com/mcp/` | -| Resource identifier | `https://api.githubcopilot.com/mcp` | -| Protected resource metadata | `https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp` | -| Authorization server | `https://github.com/login/oauth` | -| Upstream docs | [github/github-mcp-server](https://github.com/github/github-mcp-server) — [remote-server.md](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) | +| Field | Value | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MCP URL | `https://api.githubcopilot.com/mcp/` | +| Resource identifier | `https://api.githubcopilot.com/mcp` | +| Protected resource metadata | `https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp` | +| Authorization server | `https://github.com/login/oauth` | +| Upstream docs | [github/github-mcp-server](https://github.com/github/github-mcp-server) — [remote-server.md](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) | **Verified PRM (June 2026):** `authorization_servers: ["https://github.com/login/oauth"]`. Scopes advertised include `repo`, `read:org`, `read:user`, `user:email`, `gist`, `workflow`, etc. @@ -168,10 +169,10 @@ There are **no shared Inspector test credentials** in this repository. Create a 1. Open [GitHub Developer settings → OAuth Apps](https://github.com/settings/developers) → **New OAuth App**. 2. Suggested values for local web smoke: - | Field | Value | - | ----- | ----- | - | Application name | `MCP Inspector (local dev)` | - | Homepage URL | `http://localhost:6274` | + | Field | Value | + | -------------------------- | -------------------------------------- | + | Application name | `MCP Inspector (local dev)` | + | Homepage URL | `http://localhost:6274` | | Authorization callback URL | `http://localhost:6274/oauth/callback` | For TUI/CLI, register **`http://127.0.0.1:6276/oauth/callback`** (default) on the OAuth app, or override with `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. @@ -249,30 +250,32 @@ See [GitHub MCP README — Using a GitHub PAT](https://github.com/github/github- Stytch advertises first-class [CIMD support for MCP](https://stytch.com/blog/oauth-client-id-metadata-mcp/). Inspector smoke testing uses **two** hosted Stytch MCP targets: -| Target | When to use | -| ------ | ----------- | -| **`stytch-mcp-demo`** — `https://stytch-as-demo.val.run/mcp` | **Default for local CIMD smoke.** Test Stytch project; authorize at the demo app (`/oauth/authorize`) with email OTP / test login — not the `stytch.com` dashboard. | -| **`stytch-mcp`** — `https://mcp.stytch.dev/mcp` | **Production-style smoke.** Stytch’s hosted Management API MCP; authorize at `https://stytch.com/oauth/authorize` with a real Stytch workspace account (often social SSO). | +| Target | When to use | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`stytch-mcp-demo`** — `https://stytch-as-demo.val.run/mcp` | **Default for local CIMD smoke.** Test Stytch project; authorize at the demo app (`/oauth/authorize`) with email OTP / test login — not the `stytch.com` dashboard. | +| **`stytch-mcp`** — `https://mcp.stytch.dev/mcp` | **Production-style smoke.** Stytch’s hosted Management API MCP; authorize at `https://stytch.com/oauth/authorize` with a real Stytch workspace account (often social SSO). | ### 3a. Stytch demo MCP (preferred for dev) -| Field | Value | -| ----- | ----- | -| MCP URL | `https://stytch-as-demo.val.run/mcp` | -| Resource identifier | `https://stytch-as-demo.val.run/mcp` | -| Protected resource metadata | `https://stytch-as-demo.val.run/.well-known/oauth-protected-resource/mcp` | -| Authorization server (issuer) | `https://industrious-dress-4239.customers.stytch.dev` *(resolve from PRM — may change)* | -| AS metadata | `https://industrious-dress-4239.customers.stytch.dev/.well-known/oauth-authorization-server` | -| Token endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/token` | -| DCR registration endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` | -| Authorization UI | `https://stytch-as-demo.val.run/oauth/authorize` (demo-hosted; test login — **not** `stytch.com`) | +| Field | Value | +| ----------------------------- | ------------------------------------------------------------------------------------------------- | +| MCP URL | `https://stytch-as-demo.val.run/mcp` | +| Resource identifier | `https://stytch-as-demo.val.run/mcp` | +| Protected resource metadata | `https://stytch-as-demo.val.run/.well-known/oauth-protected-resource/mcp` | +| Authorization server (issuer) | `https://industrious-dress-4239.customers.stytch.dev` _(resolve from PRM — may change)_ | +| AS metadata | `https://industrious-dress-4239.customers.stytch.dev/.well-known/oauth-authorization-server` | +| Token endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/token` | +| DCR registration endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` | +| Authorization UI | `https://stytch-as-demo.val.run/oauth/authorize` (demo-hosted; test login — **not** `stytch.com`) | **Verified discovery (June 2026):** PRM at the URL above currently returns: ```json { "resource": "https://stytch-as-demo.val.run/mcp", - "authorization_servers": ["https://industrious-dress-4239.customers.stytch.dev"], + "authorization_servers": [ + "https://industrious-dress-4239.customers.stytch.dev" + ], "scopes_supported": ["openid", "email"] } ``` @@ -303,23 +306,30 @@ CIMD does **not** call `POST …/v1/oauth2/register` — Inspector stores the me ### 3b. Stytch Management MCP (production-style) -| Field | Value | -| ----- | ----- | -| MCP URL | `https://mcp.stytch.dev/mcp` | -| Resource identifier | `https://mcp.stytch.dev` | -| Protected resource metadata | `https://mcp.stytch.dev/.well-known/oauth-protected-resource` | -| Product docs | [Stytch MCP Server](https://stytch.com/docs/resources/workspace-management/stytch-mcp-server), [mcp.stytch.dev](https://mcp.stytch.dev/) | +| Field | Value | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| MCP URL | `https://mcp.stytch.dev/mcp` | +| Resource identifier | `https://mcp.stytch.dev` | +| Protected resource metadata | `https://mcp.stytch.dev/.well-known/oauth-protected-resource` | +| Product docs | [Stytch MCP Server](https://stytch.com/docs/resources/workspace-management/stytch-mcp-server), [mcp.stytch.dev](https://mcp.stytch.dev/) | **Verified discovery (June 2026):** PRM at the URL above currently returns: ```json { "resource": "https://mcp.stytch.dev", - "authorization_servers": ["https://rustic-kilogram-6347.customers.stytch.com"], + "authorization_servers": [ + "https://rustic-kilogram-6347.customers.stytch.com" + ], "scopes_supported": [ - "openid", "email", "profile", - "admin:projects", "manage:api_keys", "manage:api_keys:test", - "manage:project_settings", "manage:project_data" + "openid", + "email", + "profile", + "admin:projects", + "manage:api_keys", + "manage:api_keys:test", + "manage:project_settings", + "manage:project_data" ] } ``` @@ -362,7 +372,11 @@ Live document (June 2026; fetch fresh if debugging redirect mismatches): "http://localhost:6274/oauth/callback", "…" ], - "grant_types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + "grant_types": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code" + ], "response_types": ["code"], "token_endpoint_auth_method": "none", "application_type": "native" @@ -373,11 +387,11 @@ The full `redirect_uris` list also includes other localhost ports and MCPJam app #### Inspector configuration -| Surface | Setting | -| ------- | ------- | -| **Web** | **Client Settings** → **Client ID Metadata Document** → paste the MCPJam URL above → enable **Use Client ID Metadata Document** | +| Surface | Setting | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Web** | **Client Settings** → **Client ID Metadata Document** → paste the MCPJam URL above → enable **Use Client ID Metadata Document** | | **TUI** | `--client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json` (or enable CIMD in `client.json` / `--client-config`) | -| **CLI** | Same flags; reuses tokens from `~/.mcp-inspector/storage/oauth.json` when already authorized via web/TUI | +| **CLI** | Same flags; reuses tokens from `~/.mcp-inspector/storage/oauth.json` when already authorized via web/TUI | #### Why MCPJam’s document works with Inspector @@ -409,14 +423,14 @@ Hosting a dedicated `https://…/inspector/oauth-client.json` with Inspector bra Stytch **normalizes** both CIMD and DCR into internal Connected App records. The **access token JWT** looks the same either way — `client_id` in the decoded payload is Stytch’s internal id (e.g. `connected-app-test-0bb7b586-…`), **not** your metadata URL. **Do not use the JWT alone to distinguish CIMD from DCR.** -| Check | CIMD | DCR | -| ----- | ---- | --- | -| **Connection Info → Client ID** | MCPJam metadata URL | Opaque `connected-app-test-…` | -| **Connection Info → Client registration** | Client ID Metadata (CIMD) | Dynamic (DCR) | -| **`POST …/v1/oauth2/token` body → `client_id`** | Metadata URL | `connected-app-test-…` | -| **Authorize URL → `client_id` param** (browser) | Metadata URL | `connected-app-test-…` | -| **`POST …/v1/oauth2/register`** (after cleared OAuth state) | **Absent** | Present on first connect | -| **Decoded access token JWT → `client_id`** | Internal Stytch id *(same shape as DCR)* | Internal Stytch id | +| Check | CIMD | DCR | +| ----------------------------------------------------------- | ---------------------------------------- | ----------------------------- | +| **Connection Info → Client ID** | MCPJam metadata URL | Opaque `connected-app-test-…` | +| **Connection Info → Client registration** | Client ID Metadata (CIMD) | Dynamic (DCR) | +| **`POST …/v1/oauth2/token` body → `client_id`** | Metadata URL | `connected-app-test-…` | +| **Authorize URL → `client_id` param** (browser) | Metadata URL | `connected-app-test-…` | +| **`POST …/v1/oauth2/register`** (after cleared OAuth state) | **Absent** | Present on first connect | +| **Decoded access token JWT → `client_id`** | Internal Stytch id _(same shape as DCR)_ | Internal Stytch id | **Definitive smoke signal:** expand the **`POST …/v1/oauth2/token`** row in the network log (auth category). The form body must include the MCPJam metadata URL as `client_id`. Verified example against **`stytch-mcp-demo`** (June 2026): @@ -538,55 +552,58 @@ Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before le Run **required** smokes before release or after any auth UX change. **Optional** smokes extend coverage; run when time allows or when touching the listed area. -| ID | Client | When | Required? | -| -- | ------ | ---- | --------- | -| **W1** | Web | Step-up modal + OAuth resume | **Yes** | -| W2–W4 | Web | Connect-time, silent refresh, multi-tab | Optional | -| **W5–W11** | Web | P0/P1/P2 code-review UX (below) | **Recommended** after auth recovery changes | -| **T1, T2, T4** | TUI | Connect, step-up, clear OAuth | **Yes** | -| T3, T5–T6 | TUI | Modal path, tab/server selection | Optional | -| **C1, C2** | CLI | Connect + step-up **y/N** | **Yes** | -| C3 | CLI | Built launcher subprocess | Optional | +| ID | Client | When | Required? | +| -------------- | ------ | --------------------------------------- | ------------------------------------------- | +| **W1** | Web | Step-up modal + OAuth resume | **Yes** | +| W2–W4 | Web | Connect-time, silent refresh, multi-tab | Optional | +| **W5–W11** | Web | P0/P1/P2 code-review UX (below) | **Recommended** after auth recovery changes | +| **T1, T2, T4** | TUI | Connect, step-up, clear OAuth | **Yes** | +| T3, T5–T6 | TUI | Modal path, tab/server selection | Optional | +| **C1, C2** | CLI | Connect + step-up **y/N** | **Yes** | +| C3 | CLI | Built launcher subprocess | Optional | **Minimum release gate:** **W1** + **W5–W7** (web), **T1–T2** + **T4** (TUI), **C1–C2** (CLI). ### What CI covers vs what you verify manually -| Capability | Web | TUI | CLI | Automated in CI? | -| ---------- | --- | --- | --- | ---------------- | -| Core `handleAuthChallenge()` / token exchange | Remote transport | Direct transport | Direct (`cliOAuth.ts`) | Yes — `inspectorClient-oauth-*-mid-session-e2e.test.ts`, `oauth-interactive.test.ts` | -| Silent mid-session refresh (valid refresh token) | Yes | Yes (reconnect) | N/A (one-shot) | Yes — remote e2e | -| Connect-time 401 → interactive OAuth | Yes | Yes | Yes | Partial — core/CLI integration; **not** full UI/binary | -| Step-up confirm before second OAuth | Modal | Auth tab **A** / **C** | stderr **y/N** | Partial — unit/modal tests; **not** real browser + Ink/terminal | -| OAuth resume after full-page redirect | Yes (`6274`) | N/A (loopback) | N/A (loopback) | Partial — `oauthResume` unit tests; **not** browser tab restore | -| Tab + form restore after step-up | Yes | N/A | N/A | **Manual only** | -| Network log survives redirect | Yes | N/A | N/A | **Manual only** | -| Multi–browser-tab defer (background tab) | Yes | N/A | N/A | **Manual only** | -| Tool / prompt / resource modal → auth recovery | Yes | Yes | CLI one-shot RPC only | Partial — component unit tests | -| Loopback callback server (`6276`) | N/A | Yes | Yes | Partial — integration tests auto-complete authorize URL | -| Step-up **decline** (stay connected / exit cleanly) | Modal Cancel | Auth **C** | **N** | **Manual only** | -| ReAuthBanner reconnect without disconnect | Yes | N/A | N/A | **Manual only** (W7) | -| Abandoned step-up / reauth snapshot → banner | Yes | N/A | N/A | **Manual only** (W6) | -| Step-up pre-redirect toast | Yes | N/A | N/A | **Manual only** (W5) | -| Background-tab defer + resume on visibility | Yes | N/A | N/A | **Manual only** (W8) | -| Concurrent step-up → warning toast | Yes | TUI overwrite message | N/A | **Manual only** (W9 / T5) | -| Partial consent after step-up → warning toast | Yes | Yes | Yes (stderr message) | Partial — unit copy only (W10) | -| Prompt / resource / app command-scoped recovery | Yes | Yes | N/A (tools only in CLI) | Partial — no full UI e2e (W11) | -| Built launcher → CLI binary subprocess | N/A | N/A | Yes | `scripts/smoke-cli.mjs` — **no OAuth** | +| Capability | Web | TUI | CLI | Automated in CI? | +| --------------------------------------------------- | ---------------- | ---------------------- | ----------------------- | ------------------------------------------------------------------------------------ | +| Core `handleAuthChallenge()` / token exchange | Remote transport | Direct transport | Direct (`cliOAuth.ts`) | Yes — `inspectorClient-oauth-*-mid-session-e2e.test.ts`, `oauth-interactive.test.ts` | +| Silent mid-session refresh (valid refresh token) | Yes | Yes (reconnect) | N/A (one-shot) | Yes — remote e2e | +| Connect-time 401 → interactive OAuth | Yes | Yes | Yes | Partial — core/CLI integration; **not** full UI/binary | +| Step-up confirm before second OAuth | Modal | Auth tab **A** / **C** | stderr **y/N** | Partial — unit/modal tests; **not** real browser + Ink/terminal | +| OAuth resume after full-page redirect | Yes (`6274`) | N/A (loopback) | N/A (loopback) | Partial — `oauthResume` unit tests; **not** browser tab restore | +| Tab + form restore after step-up | Yes | N/A | N/A | **Manual only** | +| Network log survives redirect | Yes | N/A | N/A | **Manual only** | +| Multi–browser-tab defer (background tab) | Yes | N/A | N/A | **Manual only** | +| Tool / prompt / resource modal → auth recovery | Yes | Yes | CLI one-shot RPC only | Partial — component unit tests | +| Loopback callback server (`6276`) | N/A | Yes | Yes | Partial — integration tests auto-complete authorize URL | +| Step-up **decline** (stay connected / exit cleanly) | Modal Cancel | Auth **C** | **N** | **Manual only** | +| ReAuthBanner reconnect without disconnect | Yes | N/A | N/A | **Manual only** (W7) | +| Abandoned step-up / reauth snapshot → banner | Yes | N/A | N/A | **Manual only** (W6) | +| Step-up pre-redirect toast | Yes | N/A | N/A | **Manual only** (W5) | +| Background-tab defer + resume on visibility | Yes | N/A | N/A | **Manual only** (W8) | +| Concurrent step-up → warning toast | Yes | TUI overwrite message | N/A | **Manual only** (W9 / T5) | +| Partial consent after step-up → warning toast | Yes | Yes | Yes (stderr message) | Partial — unit copy only (W10) | +| Prompt / resource / app command-scoped recovery | Yes | Yes | N/A (tools only in CLI) | Partial — no full UI e2e (W11) | +| Built launcher → CLI binary subprocess | N/A | N/A | Yes | `scripts/smoke-cli.mjs` — **no OAuth** | Run this section when touching mid-session auth UX. CI proves protocol/core paths; **you** prove each client’s interactive UX with a real browser (and terminal for CLI **y/N**). ### Shared setup 1. **Composable OAuth server** (terminal A): + ```bash cd clients/web && npm run test-servers:build node ../../test-servers/build/server-composable.js \ --config ../../test-servers/configs/oauth-step-up-demo.json ``` + Confirm stderr: `Composable server listening at http://127.0.0.1:8081/mcp`. 2. **Catalog entry** — add to `~/.mcp-inspector/mcp.json` (all three clients): + ```json "oauth-step-up-demo": { "type": "streamable-http", @@ -665,7 +682,7 @@ Primary path; CI does **not** exercise modal UX or post-redirect tab restore. #### W8 — Background-tab defer + resume (**recommended**, MR-104) 1. Connect; open step-up modal on **get_temp** (W1 steps 1–3) but **do not** click Authorize yet. -2. Switch away from the inspector tab (another browser tab or app) so the page is **hidden** (`document.visibilityState === "hidden"` — DevTools → **Rendering** → *Emulate page visibility hidden* also works). +2. Switch away from the inspector tab (another browser tab or app) so the page is **hidden** (`document.visibilityState === "hidden"` — DevTools → **Rendering** → _Emulate page visibility hidden_ also works). 3. Return to the inspector tab (or disable emulation). 4. **Expect:** deferred recovery runs — step-up modal or OAuth flow resumes without losing the connected session. 5. Complete step-up; run **get_temp** successfully. @@ -783,73 +800,73 @@ Session from C1 must have **`mcp tools:read` only**. If step-up does not prompt, ### Manual sign-off checklist -| # | Check | Web | TUI | CLI | -| - | ----- | --- | --- | --- | -| 1 | Connect-time OAuth with empty storage | W1 prep / W2 | T1 | C1 | -| 2 | Unscoped tool works after connect | W1 step 2 | T1 | C1 output | -| 3 | Step-up prompt before second OAuth | W1 step 4 | T2 step 2 | C2 step 2 | -| 4 | Decline step-up, stay usable | W1 step 5 | T2 step 3 | C2 step 3 | -| 5 | Accept step-up + complete browser OAuth | W1 step 6–8 | T2 step 4–6 | C2 step 4–5 | -| 6 | Clear OAuth state | Settings | T4 | delete `oauth.json` / fresh HOME | -| 7 | Step-up pre-redirect toast | W5 | — | — | -| 8 | Abandoned step-up → banner | W6 | — | — | -| 9 | ReAuthBanner reconnect (no disconnect) | W7 | — | — | -| 10 | Background-tab defer + resume | W8 | — | — | -| 11 | Concurrent step-up warning | W9 | T6 | — | -| 12 | Partial consent warning toast | W10 | T2 (if AS allows) | C2 (if AS allows) | -| 13 | Prompt / resource / app recovery | W11 | T3 | — | +| # | Check | Web | TUI | CLI | +| --- | --------------------------------------- | ------------ | ----------------- | -------------------------------- | +| 1 | Connect-time OAuth with empty storage | W1 prep / W2 | T1 | C1 | +| 2 | Unscoped tool works after connect | W1 step 2 | T1 | C1 output | +| 3 | Step-up prompt before second OAuth | W1 step 4 | T2 step 2 | C2 step 2 | +| 4 | Decline step-up, stay usable | W1 step 5 | T2 step 3 | C2 step 3 | +| 5 | Accept step-up + complete browser OAuth | W1 step 6–8 | T2 step 4–6 | C2 step 4–5 | +| 6 | Clear OAuth state | Settings | T4 | delete `oauth.json` / fresh HOME | +| 7 | Step-up pre-redirect toast | W5 | — | — | +| 8 | Abandoned step-up → banner | W6 | — | — | +| 9 | ReAuthBanner reconnect (no disconnect) | W7 | — | — | +| 10 | Background-tab defer + resume | W8 | — | — | +| 11 | Concurrent step-up warning | W9 | T6 | — | +| 12 | Partial consent warning toast | W10 | T2 (if AS allows) | C2 (if AS allows) | +| 13 | Prompt / resource / app recovery | W11 | T3 | — | ### Troubleshooting -| Symptom | Likely cause | -| ------- | ------------- | -| Redirect with no modal / no step-up prompt | Scopes already include `weather:read`; clear OAuth state and ensure catalog has `"scopes": "mcp tools:read"` only | -| Web callback “could not be matched” | Missing `mcp-inspector:oauth-resume` in `sessionStorage` — use modal **Authorize**, not manual URL | -| TUI/CLI `EADDRINUSE` on 6276 | Another TUI/CLI (or stale process) holds callback port | -| CLI step-up never prompts | Connect-time recovery unioned scopes — clear tokens and retry C1 | -| `get_temp` fails after web step-up without re-run | Expected — click **Run** again (no auto-replay after full-page OAuth) | -| ReAuthBanner **Re-authenticate** disconnects session | Should **not** happen when already connected — see W7 | -| Step-up shows toast instead of modal | `insufficient_scope` recovery failures use a toast, not ReAuthBanner (MR-206) | -| No pre-redirect toast on step-up Authorize | See W5 — blue toast expected before redirect (MR-219) | -| Port conflict on 8081 | Change `transport.port` in config and catalog URL | +| Symptom | Likely cause | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Redirect with no modal / no step-up prompt | Scopes already include `weather:read`; clear OAuth state and ensure catalog has `"scopes": "mcp tools:read"` only | +| Web callback “could not be matched” | Missing `mcp-inspector:oauth-resume` in `sessionStorage` — use modal **Authorize**, not manual URL | +| TUI/CLI `EADDRINUSE` on 6276 | Another TUI/CLI (or stale process) holds callback port | +| CLI step-up never prompts | Connect-time recovery unioned scopes — clear tokens and retry C1 | +| `get_temp` fails after web step-up without re-run | Expected — click **Run** again (no auto-replay after full-page OAuth) | +| ReAuthBanner **Re-authenticate** disconnects session | Should **not** happen when already connected — see W7 | +| Step-up shows toast instead of modal | `insufficient_scope` recovery failures use a toast, not ReAuthBanner (MR-206) | +| No pre-redirect toast on step-up Authorize | See W5 — blue toast expected before redirect (MR-219) | +| Port conflict on 8081 | Change `transport.port` in config and catalog URL | --- ## What to verify (all smokes) -| Check | Where | -| ----- | ----- | -| **Negative control** (mechanism off → fail or wrong client id source) | Connection Info / error toast | -| **Clear OAuth state** between smoke phases | **Web:** Server Settings → OAuth → **Clear stored OAuth state**; or Connection Info → **Clear and disconnect** (standard) / **Clear OAuth state** (EMA). **TUI:** Auth tab → **S** (**Clear OAuth State**; disconnects when connected) | -| **Positive path** (mechanism on → authorized session) | Connect + `tools/list` | -| 401 on first connect without tokens | Network / Requests tab | -| OAuth discovery + correct mechanism (DCR vs static vs CIMD) | Network log (auth category) | -| **CIMD:** token exchange `client_id` = metadata URL | `POST …/v1/oauth2/token` request body | -| **CIMD:** no DCR register call (after cleared state) | Absence of `POST …/v1/oauth2/register` | -| **Stytch:** JWT `client_id` is internal id (not metadata URL) | Decode access token — informational only | -| Authorization redirect opens | Browser | -| Callback completes (`/oauth/callback`) | Web URL or TUI callback server | -| Second connect uses stored access token | Connect without re-login (until expiry) | -| `tools/list` JSON | Tools tab / CLI `--method tools/list` | -| Connection Info: protocol, authorized, client id, registration kind | Connection Info OAuth section | +| Check | Where | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Negative control** (mechanism off → fail or wrong client id source) | Connection Info / error toast | +| **Clear OAuth state** between smoke phases | **Web:** Server Settings → OAuth → **Clear stored OAuth state**; or Connection Info → **Clear and disconnect** (standard) / **Clear OAuth state** (EMA). **TUI:** Auth tab → **S** (**Clear OAuth State**; disconnects when connected) | +| **Positive path** (mechanism on → authorized session) | Connect + `tools/list` | +| 401 on first connect without tokens | Network / Requests tab | +| OAuth discovery + correct mechanism (DCR vs static vs CIMD) | Network log (auth category) | +| **CIMD:** token exchange `client_id` = metadata URL | `POST …/v1/oauth2/token` request body | +| **CIMD:** no DCR register call (after cleared state) | Absence of `POST …/v1/oauth2/register` | +| **Stytch:** JWT `client_id` is internal id (not metadata URL) | Decode access token — informational only | +| Authorization redirect opens | Browser | +| Callback completes (`/oauth/callback`) | Web URL or TUI callback server | +| Second connect uses stored access token | Connect without re-login (until expiry) | +| `tools/list` JSON | Tools tab / CLI `--method tools/list` | +| Connection Info: protocol, authorized, client id, registration kind | Connection Info OAuth section | ## Automated parity -| Mode | Real server (this doc) | CI (in-repo) | -| ---- | ---------------------- | ------------ | -| DCR | example-server.modelcontextprotocol.io (or Stytch MCP) | `inspectorClient-oauth-e2e.test.ts` | -| Static | GitHub MCP + your OAuth App | `test-static-client` / `test-static-secret` on TestServerHttp | -| CIMD | **Stytch demo MCP** (`stytch-as-demo.val.run`) + [MCPJam metadata URL](#cimd-credentials-for-smoke-mcpjam) (or local composable) | `createClientMetadataServer()` in e2e | -| EMA | xaa.dev staging | `inspectorClient-ema-e2e.test.ts` + mocks | -| Mid-session / step-up (web remote) | §5 **W1** + **W5–W7** (required gate); W2–W4, W8–W11 optional | `inspectorClient-oauth-remote-mid-session-e2e.test.ts`; web unit (`oauthResume`, `StepUpAuthModal`) | -| Mid-session / step-up (TUI direct) | §5 **T1–T2, T4** (required); T3, T5–T6 optional | `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core); `App.test.tsx`, `tuiOAuth.test.ts` (no Ink+browser) | -| Mid-session / step-up (CLI direct) | §5 **C1–C2** (required) | `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process; not subprocess) | +| Mode | Real server (this doc) | CI (in-repo) | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| DCR | example-server.modelcontextprotocol.io (or Stytch MCP) | `inspectorClient-oauth-e2e.test.ts` | +| Static | GitHub MCP + your OAuth App | `test-static-client` / `test-static-secret` on TestServerHttp | +| CIMD | **Stytch demo MCP** (`stytch-as-demo.val.run`) + [MCPJam metadata URL](#cimd-credentials-for-smoke-mcpjam) (or local composable) | `createClientMetadataServer()` in e2e | +| EMA | xaa.dev staging | `inspectorClient-ema-e2e.test.ts` + mocks | +| Mid-session / step-up (web remote) | §5 **W1** + **W5–W7** (required gate); W2–W4, W8–W11 optional | `inspectorClient-oauth-remote-mid-session-e2e.test.ts`; web unit (`oauthResume`, `StepUpAuthModal`) | +| Mid-session / step-up (TUI direct) | §5 **T1–T2, T4** (required); T3, T5–T6 optional | `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core); `App.test.tsx`, `tuiOAuth.test.ts` (no Ink+browser) | +| Mid-session / step-up (CLI direct) | §5 **C1–C2** (required) | `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process; not subprocess) | ## Known gaps (Inspector) **Mid-session auth** is implemented for web (remote transport), TUI, and CLI — see [Mid-session authorization](v2_auth_mid_session.md). **Remaining:** optional idle SSE E2E, v2 SDK transport upgrade for direct silent retry. -See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for connect-time OAuth hardening (SEP-2468, SEP-837, SEP-2352, SEP-2207, SEP-2350, SEP-2351) and the v2 SDK upgrade strategy. +See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for per-SEP as-built status and automated coverage. Hosted smoke here is complementary for real IdPs — SEPs already covered in CI do not need dedicated smoke scenarios. - **Mid-session auth:** see [§5 manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CI covers core protocol; **W1 + W5–W7 / T1–T2 + T4 / C1–C2** are the required manual gate per client; **W8–W11, T3, T5–T6, C3** extend P0/P1/P2 UX coverage. - **Client credentials grant:** not implemented ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). diff --git a/specification/v2_new_spec_impact.md b/specification/v2_new_spec_impact.md index 5910255e4..e8c73df42 100644 --- a/specification/v2_new_spec_impact.md +++ b/specification/v2_new_spec_impact.md @@ -2,46 +2,47 @@ ### [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_auth.md) | V2 New Spec Impact - -* [1. Executive summary](#1-executive-summary) -* [2. Scope note: milestone vs. release](#2-scope-note-milestone-vs-release) -* [3. High-level overview of the milestone SEPs](#3-high-level-overview-of-the-milestone-seps) -* [4. Functional area: transport, HTTP, and observability](#4-functional-area-transport-http-and-observability) - * [4.1 SEP-2243 — HTTP Header Standardization (final title: "HTTP Header Standardization for Streamable HTTP Transport")](#41-sep-2243--http-header-standardization-final-title-http-header-standardization-for-streamable-http-transport) - * [4.2 SEP-2260 — Server requests must be associated with a client request](#42-sep-2260--server-requests-must-be-associated-with-a-client-request) - * [4.3 SEP-414 — OpenTelemetry trace context in `_meta`](#43-sep-414--opentelemetry-trace-context-in-_meta) -* [5. Functional area: authorization](#5-functional-area-authorization) - * [5.1 SEP-837 — `application_type` in Dynamic Client Registration](#51-sep-837--application_type-in-dynamic-client-registration) - * [5.2 SEP-2350 — Client-side scope accumulation in step-up authorization](#52-sep-2350--client-side-scope-accumulation-in-step-up-authorization) - * [5.3 SEP-2351 — RFC 8414 well-known suffix declaration](#53-sep-2351--rfc-8414-well-known-suffix-declaration) - * [5.4 SEP-2352 — Authorization server binding and migration](#54-sep-2352--authorization-server-binding-and-migration) -* [6. Functional area: extensibility, UI, and governance](#6-functional-area-extensibility-ui-and-governance) - * [6.1 SEP-2133 — Extensions framework](#61-sep-2133--extensions-framework) - * [6.2 SEP-1865 — MCP Apps (`io.modelcontextprotocol/ui`)](#62-sep-1865--mcp-apps-iomodelcontextprotocolui) - * [6.3 SEP-1730 — SDK tiers](#63-sep-1730--sdk-tiers) -* [7. How the over-the-wire conversation changes](#7-how-the-over-the-wire-conversation-changes) - * [7.1 Connection establishment](#71-connection-establishment) - * [7.2 Responses and request-scoped streaming](#72-responses-and-request-scoped-streaming) - * [7.3 Server→client interactions: MRTR replaces server-initiated requests](#73-serverclient-interactions-mrtr-replaces-server-initiated-requests) - * [7.4 Push notifications: `subscriptions/listen` replaces the GET stream](#74-push-notifications-subscriptionslisten-replaces-the-get-stream) - * [7.5 Cancellation, resumability, keepalive](#75-cancellation-resumability-keepalive) - * [7.6 Logging](#76-logging) - * [7.7 Errors and version negotiation](#77-errors-and-version-negotiation) - * [7.8 Backward compatibility: the "era" model](#78-backward-compatibility-the-era-model) -* [8. TypeScript SDK v2: what it handles vs. what bubbles up](#8-typescript-sdk-v2-what-it-handles-vs-what-bubbles-up) - * [8.1 Structural/API migration (independent of the new spec)](#81-structuralapi-migration-independent-of-the-new-spec) - * [8.2 Handled automatically by SDK v2](#82-handled-automatically-by-sdk-v2) - * [8.3 Bubbles up to the application](#83-bubbles-up-to-the-application) -* [9. Impact on MCP Inspector V2 (`v2/main`)](#9-impact-on-mcp-inspector-v2-v2main) - * [9.1 Connection model and state management](#91-connection-model-and-state-management) - * [9.2 History and Network tabs (the Inspector's core value)](#92-history-and-network-tabs-the-inspectors-core-value) - * [9.3 Feature tabs](#93-feature-tabs) - * [9.4 Auth and the OAuth store](#94-auth-and-the-oauth-store) - * [9.5 Suggested sequencing](#95-suggested-sequencing) -* [10. Reference: new error codes](#10-reference-new-error-codes) -* [11. Sources](#11-sources) + +- [1. Executive summary](#1-executive-summary) +- [2. Scope note: milestone vs. release](#2-scope-note-milestone-vs-release) +- [3. High-level overview of the milestone SEPs](#3-high-level-overview-of-the-milestone-seps) +- [4. Functional area: transport, HTTP, and observability](#4-functional-area-transport-http-and-observability) + - [4.1 SEP-2243 — HTTP Header Standardization (final title: "HTTP Header Standardization for Streamable HTTP Transport")](#41-sep-2243--http-header-standardization-final-title-http-header-standardization-for-streamable-http-transport) + - [4.2 SEP-2260 — Server requests must be associated with a client request](#42-sep-2260--server-requests-must-be-associated-with-a-client-request) + - [4.3 SEP-414 — OpenTelemetry trace context in `_meta`](#43-sep-414--opentelemetry-trace-context-in-_meta) +- [5. Functional area: authorization](#5-functional-area-authorization) + - [5.1 SEP-837 — `application_type` in Dynamic Client Registration](#51-sep-837--application_type-in-dynamic-client-registration) + - [5.2 SEP-2350 — Client-side scope accumulation in step-up authorization](#52-sep-2350--client-side-scope-accumulation-in-step-up-authorization) + - [5.3 SEP-2351 — RFC 8414 well-known suffix declaration](#53-sep-2351--rfc-8414-well-known-suffix-declaration) + - [5.4 SEP-2352 — Authorization server binding and migration](#54-sep-2352--authorization-server-binding-and-migration) +- [6. Functional area: extensibility, UI, and governance](#6-functional-area-extensibility-ui-and-governance) + - [6.1 SEP-2133 — Extensions framework](#61-sep-2133--extensions-framework) + - [6.2 SEP-1865 — MCP Apps (`io.modelcontextprotocol/ui`)](#62-sep-1865--mcp-apps-iomodelcontextprotocolui) + - [6.3 SEP-1730 — SDK tiers](#63-sep-1730--sdk-tiers) +- [7. How the over-the-wire conversation changes](#7-how-the-over-the-wire-conversation-changes) + - [7.1 Connection establishment](#71-connection-establishment) + - [7.2 Responses and request-scoped streaming](#72-responses-and-request-scoped-streaming) + - [7.3 Server→client interactions: MRTR replaces server-initiated requests](#73-serverclient-interactions-mrtr-replaces-server-initiated-requests) + - [7.4 Push notifications: `subscriptions/listen` replaces the GET stream](#74-push-notifications-subscriptionslisten-replaces-the-get-stream) + - [7.5 Cancellation, resumability, keepalive](#75-cancellation-resumability-keepalive) + - [7.6 Logging](#76-logging) + - [7.7 Errors and version negotiation](#77-errors-and-version-negotiation) + - [7.8 Backward compatibility: the "era" model](#78-backward-compatibility-the-era-model) +- [8. TypeScript SDK v2: what it handles vs. what bubbles up](#8-typescript-sdk-v2-what-it-handles-vs-what-bubbles-up) + - [8.1 Structural/API migration (independent of the new spec)](#81-structuralapi-migration-independent-of-the-new-spec) + - [8.2 Handled automatically by SDK v2](#82-handled-automatically-by-sdk-v2) + - [8.3 Bubbles up to the application](#83-bubbles-up-to-the-application) +- [9. Impact on MCP Inspector V2 (`v2/main`)](#9-impact-on-mcp-inspector-v2-v2main) + - [9.1 Connection model and state management](#91-connection-model-and-state-management) + - [9.2 History and Network tabs (the Inspector's core value)](#92-history-and-network-tabs-the-inspectors-core-value) + - [9.3 Feature tabs](#93-feature-tabs) + - [9.4 Auth and the OAuth store](#94-auth-and-the-oauth-store) + - [9.5 Suggested sequencing](#95-suggested-sequencing) +- [10. Reference: new error codes](#10-reference-new-error-codes) +- [11. Sources](#11-sources) + --- ## 1. Executive summary @@ -51,7 +52,7 @@ The upcoming release (protocol version **`2026-07-28`**, successor to `2025-11-2 The headline for the Inspector: 1. **The HTTP transport is redesigned.** POST-only, no `initialize` handshake, no sessions, no GET stream, no SSE resumability. Every request is self-describing via `_meta`; server→client requests (sampling/elicitation/roots) are replaced by an in-band `input_required` retry pattern (MRTR); push notifications move to a `subscriptions/listen` stream; new `Mcp-Method`/`Mcp-Name` headers are mandatory (SEP-2243). -2. **SDK v2 absorbs most of the mechanics** — era negotiation, `_meta` envelopes, header mirroring, MRTR auto-fulfilment, listen streams — behind the existing handler APIs. But it defaults to **legacy** behavior, and its docs explicitly warn that *debugging tools should not default to auto-negotiation*. Era selection, per-request log levels, response caching, pagination, tasks, and a new error taxonomy all bubble up to Inspector UX and state. +2. **SDK v2 absorbs most of the mechanics** — era negotiation, `_meta` envelopes, header mirroring, MRTR auto-fulfilment, listen streams — behind the existing handler APIs. But it defaults to **legacy** behavior, and its docs explicitly warn that _debugging tools should not default to auto-negotiation_. Era selection, per-request log levels, response caching, pagination, tasks, and a new error taxonomy all bubble up to Inspector UX and state. 3. **Auth changes are storage-schema changes.** Credentials must be keyed by `(server, issuer)`, DCR must send `application_type`, scope step-up accumulation is a client responsibility. SDK v2 implements the flows but the Inspector's persisted OAuth store and Network-tab visualizations must follow. 4. **Extensions become a first-class concept** (`capabilities.extensions` on both sides), MCP Apps is the flagship extension (already implemented in Inspector v2 via `ext-apps`), and **tasks moves out of core into an extension with a redesigned polling model** — and SDK v2 removed its built-in tasks support entirely. @@ -59,7 +60,7 @@ The headline for the Inspector: ## 2. Scope note: milestone vs. release -The milestone was 41% complete at review time (10 closed, 14 open). The 10 closed items are covered in §3–§6. However, the published **draft spec changelog** already lists as *major changes* several SEPs merged outside this closed list — SEP-2567 (remove sessions), SEP-2575 (stateless + `server/discover` + `subscriptions/listen`), SEP-2322 (MRTR + `resultType`), and SEP-2663 (tasks extension). Since the Inspector must implement the *composite* protocol, §7 (wire changes) and §9 (Inspector impact) describe the full 2026-07-28 picture. Caveat: items could still shift before the July 28 release; the RC window is the time to re-verify. +The milestone was 41% complete at review time (10 closed, 14 open). The 10 closed items are covered in §3–§6. However, the published **draft spec changelog** already lists as _major changes_ several SEPs merged outside this closed list — SEP-2567 (remove sessions), SEP-2575 (stateless + `server/discover` + `subscriptions/listen`), SEP-2322 (MRTR + `resultType`), and SEP-2663 (tasks extension). Since the Inspector must implement the _composite_ protocol, §7 (wire changes) and §9 (Inspector impact) describe the full 2026-07-28 picture. Caveat: items could still shift before the July 28 release; the RC window is the time to re-verify. A reading caveat that applies throughout: **the merged SEP documents themselves contain stale examples** (e.g., SEP-2243's doc still shows `Mcp-Session-Id` and `initialize`, and error code `-32001` before renumbering to `-32020`). The authoritative integration is the draft spec pages at `modelcontextprotocol.io/specification/draft`, not the SEP files. @@ -67,18 +68,18 @@ A reading caveat that applies throughout: **the merged SEP documents themselves ## 3. High-level overview of the milestone SEPs -| SEP | Title | Area | One-liner | -|---|---|---|---| -| [2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) | HTTP Header Standardization | Transport | Mandatory `Mcp-Method`/`Mcp-Name` headers mirroring the JSON-RPC body; opt-in `x-mcp-header` param mirroring; `-32020 HeaderMismatch` | -| [2260](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260) | Server requests tied to client requests | Transport | Sampling/elicitation/roots MUST be associated with an in-flight client request; no standalone server→client requests. Doctrinal precursor to MRTR | -| [414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) | OTel trace context conventions | Observability | `traceparent`/`tracestate`/`baggage` reserved as bare `_meta` keys (W3C formats) | -| [837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837) | Client type in DCR | Auth | Clients MUST send `application_type` (`"native"`/`"web"`) during Dynamic Client Registration | -| [2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) | Scope accumulation in step-up auth | Auth | Servers challenge per-operation; **clients** union previously requested scopes with challenged scopes before re-authorizing | -| [2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351) | RFC 8414 well-known suffix | Auth | MCP formally declares the default `oauth-authorization-server` suffix; no MCP-specific suffix. Confirmatory, no wire change | -| [2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352) | AS binding and migration | Auth | Credentials MUST be keyed by AS `issuer`; on AS change, re-register (DCR) or error (pre-registered); CIMD IDs are portable | -| [2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) | Extensions framework | Extensibility | `capabilities.extensions` map on both sides; governance for official (`ext-*`) / experimental / third-party extensions | -| [1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) | MCP Apps | Extensibility/UI | Extension `io.modelcontextprotocol/ui`: predeclared `ui://` HTML resources, sandboxed-iframe rendering, JSON-RPC-over-postMessage host bridge | -| [1730](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1777) | SDK tiers | Governance | Three-tier SDK classification driven by conformance testing and issue-triage SLAs. No wire impact | +| SEP | Title | Area | One-liner | +| ------------------------------------------------------------------------------ | --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| [2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) | HTTP Header Standardization | Transport | Mandatory `Mcp-Method`/`Mcp-Name` headers mirroring the JSON-RPC body; opt-in `x-mcp-header` param mirroring; `-32020 HeaderMismatch` | +| [2260](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260) | Server requests tied to client requests | Transport | Sampling/elicitation/roots MUST be associated with an in-flight client request; no standalone server→client requests. Doctrinal precursor to MRTR | +| [414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) | OTel trace context conventions | Observability | `traceparent`/`tracestate`/`baggage` reserved as bare `_meta` keys (W3C formats) | +| [837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837) | Client type in DCR | Auth | Clients MUST send `application_type` (`"native"`/`"web"`) during Dynamic Client Registration | +| [2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) | Scope accumulation in step-up auth | Auth | Servers challenge per-operation; **clients** union previously requested scopes with challenged scopes before re-authorizing | +| [2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351) | RFC 8414 well-known suffix | Auth | MCP formally declares the default `oauth-authorization-server` suffix; no MCP-specific suffix. Confirmatory, no wire change | +| [2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352) | AS binding and migration | Auth | Credentials MUST be keyed by AS `issuer`; on AS change, re-register (DCR) or error (pre-registered); CIMD IDs are portable | +| [2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) | Extensions framework | Extensibility | `capabilities.extensions` map on both sides; governance for official (`ext-*`) / experimental / third-party extensions | +| [1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) | MCP Apps | Extensibility/UI | Extension `io.modelcontextprotocol/ui`: predeclared `ui://` HTML resources, sandboxed-iframe rendering, JSON-RPC-over-postMessage host bridge | +| [1730](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1777) | SDK tiers | Governance | Three-tier SDK classification driven by conformance testing and issue-triage SLAs. No wire impact | Also landed in the draft (not in the closed milestone list, but essential context): **SEP-2567** (sessionless), **SEP-2575** (stateless, `server/discover`, `subscriptions/listen`, removal of `ping`/`logging/setLevel`/`notifications/roots/list_changed`), **SEP-2322** (MRTR, required `resultType`), **SEP-2663** (tasks redesigned as extension `io.modelcontextprotocol/tasks`), **SEP-2549** (list-response caching hints), **SEP-2577** (deprecation annotations for sampling/roots/logging), **SEP-991** (CIMD; DCR deprecated in its favor), **SEP-2596** (feature lifecycle; HTTP+SSE formally Deprecated). @@ -115,9 +116,18 @@ One operational consequence survives into both eras: transports must tolerate ** A small, documentation-only standardization: the bare keys `traceparent`, `tracestate`, and `baggage` are **reserved** in `_meta` (an explicit exception to the reverse-DNS prefix rule) and, when present, MUST follow W3C Trace Context / Baggage formats. This codifies what instrumented SDKs already emit and keeps interop with the OTel semantic conventions for MCP. There is no requirement for clients to send or servers to process them. ```json -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ - "name":"get_weather","arguments":{"location":"NYC"}, - "_meta":{"traceparent":"00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"}}} +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { "location": "NYC" }, + "_meta": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" + } + } +} ``` The carrier is the JSON-RPC body (transport-independent — survives stdio). Note for a tool that displays `_meta` verbatim: `baggage` can carry arbitrary correlation key-values; treat as potentially sensitive. @@ -130,14 +140,14 @@ All four auth SEPs are clarifications/hardening of the OAuth 2.1 framework — n ### 5.1 SEP-837 — `application_type` in Dynamic Client Registration -OIDC-flavored authorization servers default omitted `application_type` to `"web"`, which forbids loopback redirect URIs — breaking DCR for desktop/CLI/localhost clients. Now: clients **MUST** send an appropriate `application_type` in the DCR request. Native apps (desktop, mobile, CLI, *locally hosted web apps accessed via localhost* — i.e., the Inspector) SHOULD use `"native"`; remote browser-based apps SHOULD use `"web"`. Clients MUST handle registration rejections caused by redirect-URI constraints, SHOULD surface a meaningful error, and MAY retry with adjusted parameters. +OIDC-flavored authorization servers default omitted `application_type` to `"web"`, which forbids loopback redirect URIs — breaking DCR for desktop/CLI/localhost clients. Now: clients **MUST** send an appropriate `application_type` in the DCR request. Native apps (desktop, mobile, CLI, _locally hosted web apps accessed via localhost_ — i.e., the Inspector) SHOULD use `"native"`; remote browser-based apps SHOULD use `"web"`. Clients MUST handle registration rejections caused by redirect-URI constraints, SHOULD surface a meaningful error, and MAY retry with adjusted parameters. ### 5.2 SEP-2350 — Client-side scope accumulation in step-up authorization -Resolves an ambiguity: the old spec's `insufficient_scope` example implied servers echo accumulated scopes; RFC 6750 says the `scope` attribute describes the *current* resource. The resolution: **servers stay stateless and challenge per-operation; clients own accumulation.** +Resolves an ambiguity: the old spec's `insufficient_scope` example implied servers echo accumulated scopes; RFC 6750 says the `scope` attribute describes the _current_ resource. The resolution: **servers stay stateless and challenge per-operation; clients own accumulation.** - The 403 challenge now carries only the scopes for the failing operation (spec example changed from `scope="files:read files:write user:profile"` to `scope="files:write"`). -- On step-up, the client **computes the union** of its previously requested scope set and the challenged scopes, and re-authorizes with that union. A naive client that requests only the challenged scope will receive a token that silently *lost* its earlier grants. +- On step-up, the client **computes the union** of its previously requested scope set and the challenged scopes, and re-authorizes with that union. A naive client that requests only the challenged scope will receive a token that silently _lost_ its earlier grants. - Clients MUST NOT assume any set relationship between challenged scopes and `scopes_supported`, and need not deduplicate hierarchical scopes. Practical requirement: persist the requested scope set per `(server, AS)` pair and union on every challenge. @@ -180,7 +190,7 @@ The rest is governance: official extensions live in `ext-*` repos with delegated The flagship extension: servers deliver interactive HTML UIs that hosts render inline. Mechanics: -- **Declaration:** UI templates are *predeclared resources* under a `ui://` URI scheme with MIME type `text/html;profile=mcp-app`, fetched via ordinary `resources/read`. Tools link to them via `_meta.ui.resourceUri`; resources can carry `_meta.ui.csp` (origin allowlist over a deny-by-default CSP) and `_meta.ui.permissions` (camera/mic etc.). Predeclaration enables prefetching at `tools/list` time and security review. +- **Declaration:** UI templates are _predeclared resources_ under a `ui://` URI scheme with MIME type `text/html;profile=mcp-app`, fetched via ordinary `resources/read`. Tools link to them via `_meta.ui.resourceUri`; resources can carry `_meta.ui.csp` (origin allowlist over a deny-by-default CSP) and `_meta.ui.permissions` (camera/mic etc.). Predeclaration enables prefetching at `tools/list` time and security review. - **Negotiation:** via the SEP-2133 `extensions` map, with the client declaring supported `mimeTypes`. Servers SHOULD fall back to text-only tools for non-supporting clients. - **Runtime:** mandatory sandboxed iframe; iframe↔host communication is a JSON-RPC dialect of MCP over `postMessage`: `ui/initialize` handshake, host→app notifications (`ui/notifications/tool-input`, `tool-input-partial`, `tool-result`, `tool-cancelled`, `host-context-changed`, `size-changed`, `resource-teardown`), and app-initiated `tools/call` proxied — and policed/consent-gated — by the host, plus `ui/message`, `ui/open-link`, `ui/request-display-mode`. - **No new methods on the client↔server channel.** All new methods live on the postMessage bridge. The normative spec versions independently in `modelcontextprotocol/ext-apps` (`2026-01-26`); `@modelcontextprotocol/ext-apps` ships the host-side `AppBridge`. @@ -224,13 +234,13 @@ GET and DELETE are gone (modern servers return 405). `Mcp-Session-Id` is gone (m ### 7.2 Responses and request-scoped streaming -Unchanged in shape: the server answers each request with either `application/json` (single object) or a `text/event-stream` **scoped to that request** — but the stream may now carry only *notifications related to that request* (`notifications/progress`, `notifications/message`) followed by the final response. Servers MUST NOT send JSON-RPC requests on any stream. +Unchanged in shape: the server answers each request with either `application/json` (single object) or a `text/event-stream` **scoped to that request** — but the stream may now carry only _notifications related to that request_ (`notifications/progress`, `notifications/message`) followed by the final response. Servers MUST NOT send JSON-RPC requests on any stream. ### 7.3 Server→client interactions: MRTR replaces server-initiated requests -**Before:** the server sent `elicitation/create` / `sampling/createMessage` / `roots/list` as JSON-RPC *requests* (on the POST's SSE stream, or pre-SEP-2260 even on the GET stream); the client POSTed the response back, correlated by JSON-RPC id. +**Before:** the server sent `elicitation/create` / `sampling/createMessage` / `roots/list` as JSON-RPC _requests_ (on the POST's SSE stream, or pre-SEP-2260 even on the GET stream); the client POSTed the response back, correlated by JSON-RPC id. -**After:** all results carry a required **`resultType`** (`"complete"` | `"input_required"`; results from older servers lacking it are treated as complete). When a server needs input, it *returns*: +**After:** all results carry a required **`resultType`** (`"complete"` | `"input_required"`; results from older servers lacking it are treated as complete). When a server needs input, it _returns_: ```json {"jsonrpc":"2.0","id":1,"result":{ @@ -272,7 +282,7 @@ The Inspector v2 currently depends on `@modelcontextprotocol/sdk@1.29.0`. SDK v2 - **Package split:** `@modelcontextprotocol/sdk` → `@modelcontextprotocol/client` (Client, transports, OAuth), `@modelcontextprotocol/core` (public Zod schemas), plus server packages. stdio transport moves to subpath `@modelcontextprotocol/client/stdio` (root barrel is browser-safe — convenient for the Inspector's web/Node split). `WebSocketClientTransport` removed. A codemod exists: `npx @modelcontextprotocol/codemod@beta v1-to-v2 .` - **Handlers take method strings, not Zod schemas:** `client.setRequestHandler('elicitation/create', ...)`. Result schemas are dropped from `request()`/`callTool()` for spec methods (SDK enforces them); custom/raw passthrough requests need an explicit schema — an Inspector-style "send raw request" feature should pass `ResultSchema` from core. - **Zod 4 required** (`^4.2.0`); zod 3 unsupported. -- **Error taxonomy rename:** `McpError` → `ProtocolError`; new `SdkError` with string codes (`RequestTimeout`, `ConnectionClosed`, `EraNegotiationFailed`, `MethodNotSupportedByProtocolVersion`, `InputRequiredRoundsExceeded`, ...); `StreamableHTTPError` → `SdkHttpError` (status on `.status`). Unknown-tool calls now *reject* with `-32602` instead of resolving `isError: true`. +- **Error taxonomy rename:** `McpError` → `ProtocolError`; new `SdkError` with string codes (`RequestTimeout`, `ConnectionClosed`, `EraNegotiationFailed`, `MethodNotSupportedByProtocolVersion`, `InputRequiredRoundsExceeded`, ...); `StreamableHTTPError` → `SdkHttpError` (status on `.status`). Unknown-tool calls now _reject_ with `-32602` instead of resolving `isError: true`. - **Elicitation `mode` discriminant:** branch on `params.mode === 'url'` vs. everything-else-is-form; `ElicitResult.content` values strictly `string | number | boolean | string[]`. - `Protocol` base class no longer exported; `client.fallbackRequestHandler` is the hook for catching arbitrary inbound requests (relevant for a debugging UI). @@ -281,22 +291,22 @@ The Inspector v2 currently depends on `@modelcontextprotocol/sdk@1.29.0`. SDK v2 - **Era/version negotiation:** `versionNegotiation: { mode: 'auto' }` probes `server/discover` and falls back to `initialize`; `{ pin: '2026-07-28' }` forces modern. **Default is `'legacy'`** — nothing 2026 goes on the wire unless opted in. State accessors: `getProtocolEra()`, `getNegotiatedProtocolVersion()`, `getDiscoverResult()` (persistable; feed back as `connect(transport, { prior })` for a zero-round-trip reconnect). - **`_meta` envelope** attached to every modern request; `resultType` consumed internally. - **SEP-2243 headers:** `Mcp-Method`/`Mcp-Name`/`MCP-Protocol-Version` emitted; `x-mcp-header` args auto-mirrored into `Mcp-Param-*` (skipped in browsers due to CORS — note for the Inspector's browser-side transport: header mirroring only happens on the Node/proxy path); `-32020` recovery retry built in. -- **MRTR auto-fulfilment:** on modern connections, `input_required` results are fulfilled through the *same* `setRequestHandler` handlers for elicitation/sampling/roots, and the original request is retried automatically (default 10 rounds; opt out with `inputRequired: { autoFulfill: false }` or drive manually with `withInputRequired()`). Existing sampling/elicitation UI handlers work on both eras unchanged. +- **MRTR auto-fulfilment:** on modern connections, `input_required` results are fulfilled through the _same_ `setRequestHandler` handlers for elicitation/sampling/roots, and the original request is retried automatically (default 10 rounds; opt out with `inputRequired: { autoFulfill: false }` or drive manually with `withInputRequired()`). Existing sampling/elicitation UI handlers work on both eras unchanged. - **Notifications:** `listChanged` options transparently open/manage a `subscriptions/listen` stream on modern; explicit `client.listen(filter)` returns an `McpSubscription`. - **Cancellation** switches to stream-abort on modern; calling code unchanged. -- **Auth:** RFC 9207 `iss` validation in `finishAuth(URLSearchParams)`, issuer stamping (SEP-2352), HTTPS token-endpoint enforcement, scope step-up union (SEP-2350, `onInsufficientScope: 'reauthorize' | 'throw'`), DCR `application_type` defaults (SEP-837), CIMD support (DCR marked deprecated). +- **Auth:** RFC 9207 `iss` validation (SDK `auth()` / callback `iss` — Inspector path is `completeOAuthFlow` → `mcpAuth`, not transport `finishAuth`), issuer stamping (SEP-2352), HTTPS token-endpoint enforcement, scope step-up union (SEP-2350, `onInsufficientScope: 'reauthorize' | 'throw'`), DCR `application_type` defaults (SEP-837), CIMD support (DCR marked deprecated). ### 8.3 Bubbles up to the application - **Negotiation mode is a product decision.** The SDK docs explicitly warn that **debugging tools should not default to `'auto'`** (the probe stalls on silent stdio legacy servers and pollutes recorded transcripts with an extra round trip). The Inspector should expose era/version selection as per-server configuration. - **List verbs auto-aggregate all pages** (up to `listMaxPages`, default 64) and return `nextCursor: undefined`. **A pagination-debugging UI must drop to raw `client.request({method:'tools/list', ...})` to observe individual pages.** - **List verbs no longer throw on missing capability** (return empty instead) unless `enforceStrictCapabilities: true` — an Inspector probably wants strictness on, or should annotate the difference. -- **Response caching (SEP-2549):** on 2026 connections, list verbs honor server `ttlMs`/`cacheScope` and may answer *without a round trip*. A debugging tool should default to `cacheMode: 'bypass'`/`'refresh'` or display cache provenance. +- **Response caching (SEP-2549):** on 2026 connections, list verbs honor server `ttlMs`/`cacheScope` and may answer _without a round trip_. A debugging tool should default to `cacheMode: 'bypass'`/`'refresh'` or display cache provenance. - **Per-request log level:** the SDK does **not** auto-attach `logLevel`; server logs are silently absent until the client stamps the `_meta` key per request. An Inspector that displays server logs must manage this itself on modern connections. - **Tasks: removed from the SDK.** The experimental tasks layer (polling helpers, `callToolStream`, `TaskStore`) is deleted. Task wire types remain importable as deprecated for 2025-11-25 interop, but `tasks/*` calls require the explicit-schema raw-request form. On a 2026 connection, tasks live in the redesigned `io.modelcontextprotocol/tasks` extension (SEP-2663: polling via `tasks/get`, new `tasks/update`, `tasks/list` removed, unsolicited task handles allowed) — with **no SDK support**; the Inspector implements it or waits for an ext package. - **Extensions:** capability plumbing only — declare via constructor capabilities, read via `getServerCapabilities()?.extensions`; extension methods are custom methods with your own schemas. **MCP Apps has no support in this SDK** (it stays in `@modelcontextprotocol/ext-apps`, which Inspector v2 already uses). -- **OAuth UX details:** under probing negotiation, a connect-time 401 surfaces wrapped as `SdkError(EraNegotiationFailed)` with the `UnauthorizedError` at `error.data.cause` — the Inspector's 401→authorize→`finishAuth`→reconnect flow must unwrap it. `finishAuth` now takes the full callback `URLSearchParams`. -- **Transcript-classification caveat:** the modern probe itself carries the `_meta` envelope *before* era is known — "saw an envelope" ≠ "modern negotiated" when labeling captured traffic in History. +- **OAuth UX details:** under probing negotiation, a connect-time 401 surfaces wrapped as `SdkError(EraNegotiationFailed)` with the `UnauthorizedError` at `error.cause` (or `error.data.cause`) — Inspector's 401→`authenticate`→`completeOAuthFlow`→reconnect path must unwrap it via `isUnauthorizedError` (see [v2_auth_hardening.md](v2_auth_hardening.md) plan step 5). (SDK transports also expose `finishAuth`; Inspector does not use that entrypoint.) +- **Transcript-classification caveat:** the modern probe itself carries the `_meta` envelope _before_ era is known — "saw an envelope" ≠ "modern negotiated" when labeling captured traffic in History. --- @@ -308,7 +318,7 @@ Grounded in the current branch architecture: `InspectorClient` (core/mcp/inspect This is the deepest change. Today `InspectorClient` and the UI assume: connect ⇒ `initialize` exchange ⇒ negotiated capabilities + optional session id ⇒ session-scoped everything. On modern servers none of that exists. -- **Era becomes first-class connection state.** Add per-server config (auto / legacy / pin-2026, defaulting *not* to auto per SDK guidance) and expose `getProtocolEra()` / `getNegotiatedProtocolVersion()` through the event target and stores. Most downstream UI (which tabs show, which affordances render, how messages are interpreted) should gate on era, not just capabilities. +- **Era becomes first-class connection state.** Add per-server config (auto / legacy / pin-2026, defaulting _not_ to auto per SDK guidance) and expose `getProtocolEra()` / `getNegotiatedProtocolVersion()` through the event target and stores. Most downstream UI (which tabs show, which affordances render, how messages are interpreted) should gate on era, not just capabilities. - **`ConnectionInfoModal` needs an era-aware redesign:** for modern servers there is no initialize result or session id to display — show the `server/discover` result, the pinned/negotiated version, and "sessionless" explicitly. Consider persisting `getDiscoverResult()` in the server catalog and passing it as `prior` for instant reconnects. - **`getServerType` (`core/mcp/config.ts`) hard-codes `stdio | sse | streamable-http`.** Transport type and protocol era are now orthogonal dimensions; the config model and the remote-transport protocol (`/api/mcp/connect`) must carry both. The backend's `RemoteSession` also currently assumes a persistent transport per session — still true mechanically, but "connected" no longer implies any server-side session, which affects reconnect/disconnect semantics and what `/api/mcp/disconnect` means for a modern server (nothing to DELETE). - **Capability gating changes source:** tabs are currently gated on the `initialize` result. On modern connections capabilities come from `server/discover` (or lazily from error responses). Also new gates: `-32021 MissingRequiredClientCapability` handling, and the `extensions` maps on both sides. @@ -316,25 +326,25 @@ This is the deepest change. Today `InspectorClient` and the UI assume: connect ### 9.2 History and Network tabs (the Inspector's core value) - **New message vocabulary to render and correlate:** `_meta` envelopes on every request, `resultType` on every result, `input_required` results, retried requests with **new ids** linked by `requestState`, `server/discover`, `subscriptions/listen` + `notifications/subscriptions/acknowledged` + `subscriptionId` tagging. The History view's request/response pairing logic must learn that one logical operation can span multiple JSON-RPC ids (MRTR retries) — probably the single most valuable new visualization: group an MRTR conversation (original call → input requests → user answers → retry → final result) as one expandable unit. -- **If MRTR auto-fulfilment is left on**, the SDK hides the retry loop; the Inspector should either drive MRTR manually (`autoFulfill: false` + `withInputRequired()`) to keep its pending-request UX and full visibility, or capture the auto-fulfilled rounds via the transport-level message log. The existing `PendingClientRequests`/inline panels survive either way since handlers are era-agnostic, but the *semantics* shown to the user ("server sent a request" vs. "server returned input_required; response goes back as a retry") should be accurate per era. +- **If MRTR auto-fulfilment is left on**, the SDK hides the retry loop; the Inspector should either drive MRTR manually (`autoFulfill: false` + `withInputRequired()`) to keep its pending-request UX and full visibility, or capture the auto-fulfilled rounds via the transport-level message log. The existing `PendingClientRequests`/inline panels survive either way since handlers are era-agnostic, but the _semantics_ shown to the user ("server sent a request" vs. "server returned input_required; response goes back as a retry") should be accurate per era. - **Network tab:** display and validate the new headers (`Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, sentinel-encoded values), show `-32020/-32021/-32022` failures distinctly, and reflect that browser-path requests won't carry `Mcp-Param-*` (mirroring happens on the Node side — since Inspector's web client proxies through the backend, mirroring should work, but verify in the remote-transport layer). Cancellation now appears as connection abort, not a `notifications/cancelled` frame. - **Raw-request passthrough** must supply explicit result schemas (`ResultSchema`) under SDK v2, and is also the only way to exercise page-by-page pagination and cache-bypass behaviors worth surfacing as Inspector features ("fetch single page", "bypass cache", "refresh"). ### 9.3 Feature tabs -- **Tools:** parse/validate `x-mcp-header` annotations and *visibly flag tools excluded for invalid annotations* (the spec makes exclusion mandatory — a debugging tool should show *why* a tool vanished). Show which args will be mirrored to headers. Unknown-tool calls now reject with `-32602` rather than returning `isError` results — adjust result rendering. +- **Tools:** parse/validate `x-mcp-header` annotations and _visibly flag tools excluded for invalid annotations_ (the spec makes exclusion mandatory — a debugging tool should show _why_ a tool vanished). Show which args will be mirrored to headers. Unknown-tool calls now reject with `-32602` rather than returning `isError` results — adjust result rendering. - **Tasks tab: substantial rework.** The current implementation targets the 2025-11-25 experimental core tasks (including receiver tasks and `tasks/list`). Under SEP-2663 tasks are an extension (`io.modelcontextprotocol/tasks`) with polling `tasks/get`, new `tasks/update`, no `tasks/list`, no blocking `tasks/result`, and unsolicited task handles. SDK v2 gives no help. Plan: keep the current tab for legacy-era servers, build an extension-aware implementation (raw requests + explicit schemas) for modern ones, and gate on the extension being negotiated. - **Resources:** `resources/subscribe`/`unsubscribe` and `ResourceSubscriptionsState` become legacy-only; on modern, subscriptions are entries in the `subscriptions/listen` filter — the state store should model "one listen stream + filter set + acknowledged state + reconnect-by-re-listen" (no resumability). - **Logs:** `logging/setLevel` and the `LoggingScreen`'s level selector are legacy-only. On modern, add a per-request (or global stamp-every-request) log-level control and make clear that logs arrive on the originating request's stream. `ping` UI, if any, is legacy-only. - **Apps:** already ahead of the curve via `ext-apps`. Work is alignment: advertise `io.modelcontextprotocol/ui` with `mimeTypes` through the now-formalized `capabilities.extensions` (the EMA advertisement in `inspectorClient.ts` is the existing precedent to generalize), and track the ext-apps `2026-01-26` spec/`AppBridge` version. -- **Extensions UI (new, small but high-leverage):** display the server's `extensions` map in Connection Info, and let users toggle which extensions the Inspector *advertises* — servers legitimately change tool registration based on client-declared extensions, so this is a real debugging knob. +- **Extensions UI (new, small but high-leverage):** display the server's `extensions` map in Connection Info, and let users toggle which extensions the Inspector _advertises_ — servers legitimately change tool registration based on client-declared extensions, so this is a real debugging knob. ### 9.4 Auth and the OAuth store - **Re-key OAuth storage** (`core/auth/store.ts` / `OAuthStorage`) from per-server to per-`(server, issuer)`: stamp `issuer` on stored `clientInformation`/`tokens`, detect issuer changes against freshly fetched PRM/AS metadata on every flow, invalidate + re-register (DCR) / error (static) / continue (CIMD) accordingly. SDK v2 handles the flow logic but expects providers to round-trip issuer-stamped objects and optionally implement `discoveryState()`/`saveDiscoveryState()`. - **Persist the requested-scope set** per (server, issuer) and show the step-up union computation in the auth visualizations — this is exactly the kind of thing Inspector users will want to see when debugging 403 loops. Surface the `onInsufficientScope` policy as a setting. - **DCR panel:** show the `application_type` sent (Inspector = `"native"`), render registration rejections meaningfully, and reflect DCR's deprecated-in-favor-of-CIMD status (Inspector v2 already supports CIMD registration kind — good position). -- **401 handling under negotiation:** unwrap `EraNegotiationFailed` → `UnauthorizedError` in `oauthManager.ts`; switch to `finishAuth(callbackParams)` whole-params form (RFC 9207 iss validation happens inside; mismatches throw `IssuerMismatchError` — a new error state to display). +- **401 handling under negotiation:** unwrap `EraNegotiationFailed` → `UnauthorizedError` in `isUnauthorizedError` / connect error handling so OAuth recovery still runs under `protocolEra: auto|modern`. RFC 9207 `iss` is already validated on Inspector's `mcpAuth` path when the host forwards callback `iss`. - **Network tab auth capture** continues to work (discovery/DCR/token traffic through `/api/fetch`), but the masked-secret-field list and the flow-step model (`OAuthStep`) should gain the new steps/errors (issuer comparison, step-up union, CIMD fetch). ### 9.5 Suggested sequencing @@ -342,20 +352,20 @@ This is the deepest change. Today `InspectorClient` and the UI assume: connect 1. **SDK v2 migration mechanics** (packages, codemod, Zod 4, handler signatures, error taxonomy) with `versionNegotiation: 'legacy'` — behavior-neutral, unblocks everything else. Evaluate the `v1.x-2026-07-28` branch as a hedge if v2-stable slips past your release window. 2. **Auth store re-keying + SEP-837/2350/2352 UX** — independent of transport work, applies to both eras, and the SDK betas already enforce the flows. 3. **Era-aware connection model** (config, remote-transport protocol, Connection Info, capability gating) with explicit era selection UI. -4. **History/Network upgrades** for the new vocabulary (MRTR grouping, listen streams, Mcp-* headers, new error codes). +4. **History/Network upgrades** for the new vocabulary (MRTR grouping, listen streams, Mcp-\* headers, new error codes). 5. **Tab-by-tab era forks:** logging, resources subscriptions, tasks-as-extension, x-mcp-header tooling, extensions toggle UI. --- ## 10. Reference: new error codes -| Code | Name | HTTP | Source | -|---|---|---|---| -| -32020 | HeaderMismatch | 400 | SEP-2243 (renumbered from draft -32001) | -| -32021 | MissingRequiredClientCapability | 400 | SEP-2575 | -| -32022 | UnsupportedProtocolVersion (body lists `supported`) | 400 | SEP-2575 | -| -32601 | Method not found (era/method mismatch) | **404** | draft transport spec | -| -32602 | Invalid params — incl. orphaned server request (legacy), unknown tool (SDK v2) | — | SEP-2260 / SDK v2 | +| Code | Name | HTTP | Source | +| ------ | ------------------------------------------------------------------------------ | ------- | --------------------------------------- | +| -32020 | HeaderMismatch | 400 | SEP-2243 (renumbered from draft -32001) | +| -32021 | MissingRequiredClientCapability | 400 | SEP-2575 | +| -32022 | UnsupportedProtocolVersion (body lists `supported`) | 400 | SEP-2575 | +| -32601 | Method not found (era/method mismatch) | **404** | draft transport spec | +| -32602 | Invalid params — incl. orphaned server request (legacy), unknown tool (SDK v2) | — | SEP-2260 / SDK v2 | `-32020`–`-32099` is now reserved for spec-defined errors; `-32000`–`-32019` remains implementation-defined. @@ -372,4 +382,4 @@ This is the deepest change. Today `InspectorClient` and the UI assume: connect - Inspector `v2/main` (cloned at commit `066676a`): `AGENTS.md`, `core/mcp/inspectorClient.ts`, `core/mcp/remote/`, `core/auth/`, `clients/web/src/` - Release blog posts: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/, `/posts/sdk-betas-2026-07-28/` -*Verification note: transport and protocol claims were cross-checked three ways — the draft spec pages, the draft changelog, and the SDK v2 source/docs (independently cloned). SEP-level normative wording was diffed against the 2025-11-25 spec where relevant. GitHub API access was intermittently unavailable during research, so PR-level diffs for the four auth SEPs were reconstructed from the published draft vs. 2025-11-25 text; attribution of individual sentences to specific PRs is high-confidence but not diff-verified. Re-verify against the final spec when it ships July 28.* +_Verification note: transport and protocol claims were cross-checked three ways — the draft spec pages, the draft changelog, and the SDK v2 source/docs (independently cloned). SEP-level normative wording was diffed against the 2025-11-25 spec where relevant. GitHub API access was intermittently unavailable during research, so PR-level diffs for the four auth SEPs were reconstructed from the published draft vs. 2025-11-25 text; attribution of individual sentences to specific PRs is high-confidence but not diff-verified. Re-verify against the final spec when it ships July 28._ diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index c8c9bc20e..4566f5f11 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -167,6 +167,9 @@ function setupMetadataEndpoints( "client_secret_basic", "none", ], + // RFC 9207 / SEP-2468: advertise iss on authorization responses so + // clients must validate (and our e2e can exercise reject paths). + authorization_response_iss_parameter_supported: true, ...(config.supportDCR && { registration_endpoint: new URL("/oauth/register", actualIssuerUrl) .href, @@ -235,7 +238,12 @@ function setupAuthorizationEndpoint( return; } - completeAuthorizationRedirect(res, parsed.value); + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + const issuer = (config.issuerUrl ?? new URL(requestBaseUrl)).href.replace( + /\/$/, + "", + ); + completeAuthorizationRedirect(res, parsed.value, issuer); }, ); } @@ -340,6 +348,7 @@ async function parseAuthorizationRequest( function completeAuthorizationRedirect( res: Response, params: AuthorizationRequestParams, + issuer: string, ): void { const authCode = generateAuthorizationCode(); storeAuthorizationCode(authCode, { @@ -354,6 +363,8 @@ function completeAuthorizationRedirect( if (params.state) { redirectUrl.searchParams.set("state", params.state); } + // RFC 9207: iss must match metadata `issuer` (no trailing slash). + redirectUrl.searchParams.set("iss", issuer); res.redirect(redirectUrl.href); } diff --git a/tsconfig.base.json b/tsconfig.base.json index a3718b946..e91751f60 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,9 +9,8 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "noUncheckedIndexedAccess": true, - "baseUrl": ".", "paths": { - "@inspector/core/*": ["core/*"] + "@inspector/core/*": ["./core/*"] } } } From 9f528fa0b82d6208fffc5a1484ad166099c04cc4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 18 Jul 2026 23:20:38 -0700 Subject: [PATCH 2/3] test(auth): add real-SDK canaries for EMA ID-JAG error remaps Keep message-regex remaps honest when SDK wording drifts; document the coupling in wire.ts and the canary tests. --- .../web/src/test/core/auth/ema/wire.test.ts | 59 +++++++++++++++---- core/auth/ema/wire.ts | 5 ++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/core/auth/ema/wire.test.ts b/clients/web/src/test/core/auth/ema/wire.test.ts index e997a53fc..938abd434 100644 --- a/clients/web/src/test/core/auth/ema/wire.test.ts +++ b/clients/web/src/test/core/auth/ema/wire.test.ts @@ -34,6 +34,10 @@ const ID_TOKEN = "header.idpayload.sig"; const ID_JAG = "mock-id-jag-token"; const ACCESS_TOKEN = "mock-resource-access-token"; +function idpMetadata() { + return minimalOAuthAsMetadata(IDP_ISSUER); +} + function asMetadata() { return { ...minimalOAuthAsMetadata(AS_ISSUER), @@ -41,6 +45,16 @@ 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", () => { beforeEach(() => { mockDiscoverAndRequestJwtAuthGrant.mockReset(); @@ -78,9 +92,18 @@ describe("ema wire", () => { }); }); - it("throws when IdP metadata is missing a token_endpoint", async () => { - mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce( - new Error("Failed to discover token endpoint"), + // 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 + .spyOn(console, "error") + .mockImplementation(() => undefined); + // All discovery endpoints 404 → SDK throws "Failed to discover token endpoint…". + const fetchFn = vi.fn( + async () => new Response("not found", { status: 404 }), ); await expect( @@ -93,13 +116,29 @@ describe("ema wire", () => { idToken: ID_TOKEN, audience: AS_ISSUER, resource: MCP_RESOURCE, + fetchFn, }), ).rejects.toThrow(/IdP metadata missing token_endpoint/); + errorSpy.mockRestore(); }); - it("sets resource and scope params and throws when no ID-JAG is returned", async () => { - mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce( - new Error("Invalid token exchange response"), + it("maps real SDK invalid token-exchange responses (canary)", async () => { + await useRealDiscoverAndRequestJwtAuthGrant(); + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + 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("resource")).toBe(MCP_RESOURCE); + expect(body.get("scope")).toBe("mcp profile"); + // 200 OK but invalid ID-JAG shape → SDK "Invalid token exchange response…". + return new Response(JSON.stringify({ issued_token_type: "x" })); + } + throw new Error(`unexpected fetch: ${url}`); + }, ); await expect( @@ -113,15 +152,9 @@ describe("ema wire", () => { audience: AS_ISSUER, resource: MCP_RESOURCE, scope: "mcp profile", + fetchFn, }), ).rejects.toThrow(/did not return an ID-JAG/); - - expect(mockDiscoverAndRequestJwtAuthGrant).toHaveBeenCalledWith( - expect.objectContaining({ - resource: MCP_RESOURCE, - scope: "mcp profile", - }), - ); }); it("throws when IdP token exchange fails", async () => { diff --git a/core/auth/ema/wire.ts b/core/auth/ema/wire.ts index 959eaded0..33b7f61d7 100644 --- a/core/auth/ema/wire.ts +++ b/core/auth/ema/wire.ts @@ -50,6 +50,11 @@ export async function exchangeIdJag(params: { }); } catch (err) { const message = err instanceof Error ? err.message : String(err); + // Drift-sensitive: `@modelcontextprotocol/client` currently throws plain + // `Error`s (not typed SdkError codes) for these failure modes. Remap to + // stable Inspector UX strings by matching SDK wording. Prefer typed errors + // 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 }); } From 3ea1b21263cf446f4f6398bfd51ad501448f405f Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 18 Jul 2026 23:37:57 -0700 Subject: [PATCH 3/3] Removed invalid scope from smoke test EMA server config (used to be allowed, now SDK code rejects unsupported scopes on leg 2). --- test-servers/configs/xaa-ema-http.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-servers/configs/xaa-ema-http.json b/test-servers/configs/xaa-ema-http.json index 7e51e0c1b..c2905a941 100644 --- a/test-servers/configs/xaa-ema-http.json +++ b/test-servers/configs/xaa-ema-http.json @@ -16,7 +16,7 @@ "mode": "protected-resource", "authorizationServers": ["https://auth.resource.xaa.dev"], "requireAuth": true, - "scopesSupported": ["mcp", "tools:read", "env:read"], + "scopesSupported": ["tools:read", "env:read"], "accessTokenIssuers": ["https://auth.resource.xaa.dev"], "jwksUri": "https://auth.resource.xaa.dev/jwks", "resource": "http://localhost:8080/",