diff --git a/docs/pages/mcp-authentication.md b/docs/pages/mcp-authentication.md index 966c8f8e73..c91984c86b 100644 --- a/docs/pages/mcp-authentication.md +++ b/docs/pages/mcp-authentication.md @@ -38,7 +38,9 @@ For direct API integrations, clients can authenticate using a static Bearer toke Identity Providers (IdPs) configured in Archestra can also be used to authenticate external MCP clients. When an IdP is linked to an Archestra MCP Gateway, the gateway validates incoming JWT bearer tokens against the IdP's JWKS (JSON Web Key Set) endpoint and matches the caller to an Archestra user account. The same team-based access control that applies to Bearer tokens and OAuth also applies here — the JWT's email claim must correspond to an Archestra user who has permission to access the gateway. -After authentication, the gateway propagates the original JWT to upstream MCP servers as an `Authorization: Bearer` header. This enables end-to-end identity propagation — upstream servers can validate the same JWT against the IdP's JWKS and extract user identity from the claims without any Archestra-specific integration. See [End-to-End JWKS](#end-to-end-jwks-without-gateway) below for how to build servers that consume these tokens. +After authentication, the gateway resolves credentials for the upstream MCP server. If the upstream server has its own credentials configured (e.g., a GitHub PAT or OAuth token), those are used. If no upstream credentials are configured, the gateway propagates the original JWT as an `Authorization: Bearer` header, enabling end-to-end identity propagation where the upstream server validates the same JWT against the IdP's JWKS. See [End-to-End JWKS](#end-to-end-jwks-without-gateway) below for how to build servers that consume propagated JWTs. + +This credential resolution enables a powerful workflow: an admin installs upstream MCP servers (GitHub, Jira, etc.) with service credentials once, and any user who authenticates via their org's IdP can access those tools seamlessly — the gateway resolves the appropriate upstream token automatically. Both [static and per-user credentials](#upstream-credentials) work with JWKS authentication. #### How It Works @@ -49,7 +51,7 @@ After authentication, the gateway propagates the original JWT to upstream MCP se 5. Gateway discovers the JWKS URL from the IdP's OIDC discovery endpoint 6. Gateway validates the JWT signature, issuer, audience (IdP's client ID), and expiration 7. Gateway extracts the `email` claim from the JWT and matches it to an Archestra user account -8. Gateway propagates the original JWT to upstream MCP servers +8. Gateway resolves upstream credentials: if the server has its own credentials, those are used; otherwise the original JWT is propagated #### Requirements diff --git a/platform/backend/src/clients/mcp-client.test.ts b/platform/backend/src/clients/mcp-client.test.ts index 07ab24e6e7..5bd1fb6c9b 100644 --- a/platform/backend/src/clients/mcp-client.test.ts +++ b/platform/backend/src/clients/mcp-client.test.ts @@ -1833,5 +1833,386 @@ describe("McpClient", () => { }); }); }); + + describe("Credential resolution priority (JWKS auth)", () => { + test("JWKS auth with upstream credentials uses upstream token, not JWT (remote server)", async () => { + // The existing setup creates a remote server with access_token: "test-github-token-123" + const tool = await ToolModel.createToolIfNotExists({ + name: "github-mcp-server__jwks_cred_test", + description: "Test JWKS credential priority", + parameters: {}, + catalogId, + }); + + await AgentToolModel.create(agentId, tool.id, { + credentialSourceMcpServerId: mcpServerId, + }); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "GitHub response" }], + isError: false, + }); + + const toolCall = { + id: "call_jwks_cred", + name: "github-mcp-server__jwks_cred_test", + arguments: {}, + }; + + // Call with JWKS tokenAuth — the gateway has both the JWT and upstream credentials + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "ext-token", + teamId: null, + isOrganizationToken: false, + isExternalIdp: true, + rawToken: "keycloak-jwt-should-not-be-forwarded", + userId: "ext-user-123", + }); + + // Verify the transport was created with the upstream GitHub token, NOT the Keycloak JWT + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Bearer test-github-token-123", + ); + }); + + test("JWKS auth without upstream credentials falls back to JWT propagation (remote server)", async () => { + // Create a remote server WITHOUT credentials + const noCredCatalog = await InternalMcpCatalogModel.create({ + name: "jwks-echo-server", + serverType: "remote", + serverUrl: "https://jwks-echo.example.com/mcp", + }); + + const noCredServer = await McpServerModel.create({ + name: "jwks-echo-server", + catalogId: noCredCatalog.id, + serverType: "remote", + // No secretId — this server has no upstream credentials + }); + + const tool = await ToolModel.createToolIfNotExists({ + name: "jwks-echo-server__get_info", + description: "Get info with JWT passthrough", + parameters: {}, + catalogId: noCredCatalog.id, + }); + + await AgentToolModel.create(agentId, tool.id, { + credentialSourceMcpServerId: noCredServer.id, + }); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "JWT validated" }], + isError: false, + }); + + const toolCall = { + id: "call_jwks_passthrough", + name: "jwks-echo-server__get_info", + arguments: {}, + }; + + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "ext-token", + teamId: null, + isOrganizationToken: false, + isExternalIdp: true, + rawToken: "keycloak-jwt-for-passthrough", + userId: "ext-user-456", + }); + + // Verify the transport was created with the Keycloak JWT (fallback) + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Bearer keycloak-jwt-for-passthrough", + ); + }); + + test("JWKS auth with raw_access_token uses raw token (remote server)", async () => { + // Create a server with raw_access_token instead of access_token + const rawTokenCatalog = await InternalMcpCatalogModel.create({ + name: "raw-token-server", + serverType: "remote", + serverUrl: "https://raw-token.example.com/mcp", + }); + + const rawTokenSecret = await secretManager().createSecret( + { raw_access_token: "Token github_pat_raw_abc123" }, + "raw-token-secret", + ); + + const rawTokenServer = await McpServerModel.create({ + name: "raw-token-server", + secretId: rawTokenSecret.id, + catalogId: rawTokenCatalog.id, + serverType: "remote", + }); + + const tool = await ToolModel.createToolIfNotExists({ + name: "raw-token-server__list_items", + description: "List items with raw token", + parameters: {}, + catalogId: rawTokenCatalog.id, + }); + + await AgentToolModel.create(agentId, tool.id, { + credentialSourceMcpServerId: rawTokenServer.id, + }); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "Raw token response" }], + isError: false, + }); + + const toolCall = { + id: "call_jwks_raw", + name: "raw-token-server__list_items", + arguments: {}, + }; + + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "ext-token", + teamId: null, + isOrganizationToken: false, + isExternalIdp: true, + rawToken: "keycloak-jwt-should-not-be-used", + userId: "ext-user-789", + }); + + // Verify raw_access_token was used (not the JWT) + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Token github_pat_raw_abc123", + ); + }); + + test("non-JWKS auth (OAuth/Bearer) still uses upstream credentials", async () => { + const tool = await ToolModel.createToolIfNotExists({ + name: "github-mcp-server__oauth_cred_test", + description: "Test OAuth credential behavior", + parameters: {}, + catalogId, + }); + + await AgentToolModel.create(agentId, tool.id, { + credentialSourceMcpServerId: mcpServerId, + }); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "OAuth response" }], + isError: false, + }); + + const toolCall = { + id: "call_oauth_cred", + name: "github-mcp-server__oauth_cred_test", + arguments: {}, + }; + + // Call with standard (non-JWKS) tokenAuth — isExternalIdp is false + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "user-token", + teamId: null, + isOrganizationToken: false, + isUserToken: true, + userId: "user-123", + }); + + // Verify upstream credentials are used (unchanged behavior) + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Bearer test-github-token-123", + ); + }); + + test("JWKS auth with dynamic credentials resolves server and uses its credentials", async ({ + makeUser, + }) => { + const testUser = await makeUser({ + email: "jwks-dynamic@example.com", + }); + + // Create a catalog with dynamic credentials enabled + const dynCatalog = await InternalMcpCatalogModel.create({ + name: "github-dynamic", + serverType: "remote", + serverUrl: "https://api.github.com/mcp", + }); + + // Create a server owned by the test user with credentials + const dynSecret = await secretManager().createSecret( + { access_token: "ghp_dynamic_user_token" }, + "github-dynamic-secret", + ); + + await McpServerModel.create({ + name: "github-dynamic", + catalogId: dynCatalog.id, + secretId: dynSecret.id, + serverType: "remote", + ownerId: testUser.id, + }); + + const tool = await ToolModel.createToolIfNotExists({ + name: "github-dynamic__list_repos", + description: "List repos", + parameters: {}, + catalogId: dynCatalog.id, + }); + + // Enable dynamic credential resolution + await AgentToolModel.createOrUpdateCredentials( + agentId, + tool.id, + null, + null, + true, // useDynamicTeamCredential + ); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "Dynamic response" }], + isError: false, + }); + + const toolCall = { + id: "call_jwks_dynamic", + name: "github-dynamic__list_repos", + arguments: {}, + }; + + // Call with JWKS tokenAuth, userId matching the server owner + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "ext-dynamic-token", + teamId: null, + isOrganizationToken: false, + isExternalIdp: true, + rawToken: "keycloak-jwt-not-for-github", + userId: testUser.id, + }); + + // Verify the dynamically resolved server credentials were used + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Bearer ghp_dynamic_user_token", + ); + }); + + test("JWKS auth with local streamable-http server uses upstream credentials over JWT", async ({ + makeUser, + }) => { + const testUser = await makeUser({ + email: "jwks-local@example.com", + }); + + // Create local server with credentials + const localCatalog = await InternalMcpCatalogModel.create({ + name: "local-github-jwks", + serverType: "local", + localConfig: { + command: "npx", + arguments: ["github-mcp-server"], + transportType: "streamable-http", + httpPort: 3001, + httpPath: "/mcp", + }, + }); + + const localSecret = await secretManager().createSecret( + { access_token: "ghp_local_server_token" }, + "local-github-secret", + ); + + const localServer = await McpServerModel.create({ + name: "local-github-jwks", + catalogId: localCatalog.id, + secretId: localSecret.id, + serverType: "local", + userId: testUser.id, + }); + + const tool = await ToolModel.createToolIfNotExists({ + name: "local-github-jwks__get_repos", + description: "Get repos", + parameters: {}, + catalogId: localCatalog.id, + }); + + await AgentToolModel.create(agentId, tool.id, { + executionSourceMcpServerId: localServer.id, + }); + + mockUsesStreamableHttp.mockResolvedValue(true); + mockGetHttpEndpointUrl.mockReturnValue("http://localhost:30456/mcp"); + + mockCallTool.mockResolvedValueOnce({ + content: [{ type: "text", text: "Local GitHub response" }], + isError: false, + }); + + const toolCall = { + id: "call_jwks_local", + name: "local-github-jwks__get_repos", + arguments: {}, + }; + + await mcpClient.executeToolCall(toolCall, agentId, { + tokenId: "ext-local-token", + teamId: null, + isOrganizationToken: false, + isExternalIdp: true, + rawToken: "keycloak-jwt-not-for-local", + userId: "ext-user-local", + }); + + // Verify local server used upstream credentials, not JWT + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + const transportCalls = vi.mocked(StreamableHTTPClientTransport).mock + .calls; + expect(transportCalls.length).toBeGreaterThan(0); + const lastCall = transportCalls[transportCalls.length - 1]; + const headers = lastCall[1]?.requestInit?.headers as Headers; + expect(headers.get("authorization")).toBe( + "Bearer ghp_local_server_token", + ); + }); + }); }); }); diff --git a/platform/backend/src/clients/mcp-client.ts b/platform/backend/src/clients/mcp-client.ts index 8a7a9a187d..c4d63182ea 100644 --- a/platform/backend/src/clients/mcp-client.ts +++ b/platform/backend/src/clients/mcp-client.ts @@ -1076,12 +1076,17 @@ class McpClient { } const localHeaders: Record = {}; - if (tokenAuth?.isExternalIdp && tokenAuth.rawToken) { - localHeaders.Authorization = `Bearer ${tokenAuth.rawToken}`; - } else if (secrets.access_token) { + if (secrets.access_token) { + // Prefer upstream server credentials when available (e.g. GitHub PAT, OAuth token). + // This enables JWKS-authenticated users to access servers with their own credentials + // rather than propagating the IdP JWT which the upstream server wouldn't understand. localHeaders.Authorization = `Bearer ${secrets.access_token}`; } else if (secrets.raw_access_token) { localHeaders.Authorization = String(secrets.raw_access_token); + } else if (tokenAuth?.isExternalIdp && tokenAuth.rawToken) { + // Fallback: propagate external IdP JWT for end-to-end JWKS pattern + // (upstream server validates the same JWT against the IdP's JWKS) + localHeaders.Authorization = `Bearer ${tokenAuth.rawToken}`; } return new StreamableHTTPClientTransport(new URL(endpointUrl), { @@ -1096,13 +1101,17 @@ class McpClient { } const headers: Record = {}; - if (tokenAuth?.isExternalIdp && tokenAuth.rawToken) { - // Propagate external IdP JWT to the underlying MCP server - headers.Authorization = `Bearer ${tokenAuth.rawToken}`; - } else if (secrets.access_token) { + if (secrets.access_token) { + // Prefer upstream server credentials when available (e.g. GitHub PAT, OAuth token). + // This enables JWKS-authenticated users to access servers with their own credentials + // rather than propagating the IdP JWT which the upstream server wouldn't understand. headers.Authorization = `Bearer ${secrets.access_token}`; } else if (secrets.raw_access_token) { headers.Authorization = String(secrets.raw_access_token); + } else if (tokenAuth?.isExternalIdp && tokenAuth.rawToken) { + // Fallback: propagate external IdP JWT for end-to-end JWKS pattern + // (upstream server validates the same JWT against the IdP's JWKS) + headers.Authorization = `Bearer ${tokenAuth.rawToken}`; } return new StreamableHTTPClientTransport( diff --git a/platform/e2e-tests/tests/api/mcp-gateway-jwks-credential-priority.ee.spec.ts b/platform/e2e-tests/tests/api/mcp-gateway-jwks-credential-priority.ee.spec.ts new file mode 100644 index 0000000000..f270ce6623 --- /dev/null +++ b/platform/e2e-tests/tests/api/mcp-gateway-jwks-credential-priority.ee.spec.ts @@ -0,0 +1,278 @@ +/** + * E2E tests for MCP Gateway JWKS credential resolution priority. + * + * Verifies the credential resolution behavior when a user authenticates + * via an external IdP (JWKS) and the upstream MCP server has its own + * credentials configured: + * + * 1. Upstream credentials should take priority over the caller's JWT + * 2. When no upstream credentials exist, the JWT should be propagated as fallback + * + * Uses WireMock stubs (helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-*.json) + * that echo back the Authorization header received by the upstream server, + * allowing us to verify exactly which token was sent. + * + * Prerequisites: + * - Keycloak running (deployed via e2e Helm chart) + * - WireMock running with jwks-cred-priority-e2e stubs loaded + */ +import { MCP_SERVER_TOOL_NAME_SEPARATOR, WIREMOCK_INTERNAL_URL } from "../../consts"; +import { getKeycloakJwt } from "../../utils"; +import { expect, test } from "./fixtures"; +import { + callMcpTool, + initializeMcpSession, + makeApiRequest, +} from "./mcp-gateway-utils"; + +const WIREMOCK_MCP_URL = `${WIREMOCK_INTERNAL_URL}/mcp/jwks-cred-priority-e2e`; +const STATIC_TOKEN = "static-test-token-for-cred-priority-e2e"; + +test.describe("MCP Gateway - JWKS Credential Resolution Priority", () => { + test("should prefer upstream server credentials over JWT propagation", async ({ + request, + createAgent, + deleteAgent, + createIdentityProvider, + deleteIdentityProvider, + createMcpCatalogItem, + deleteMcpCatalogItem, + installMcpServer, + uninstallMcpServer, + waitForAgentTool, + }) => { + test.slow(); + + // STEP 1: Get a JWT from Keycloak + const jwt = await getKeycloakJwt(); + expect(jwt).toBeTruthy(); + expect(jwt.split(".")).toHaveLength(3); + + // STEP 2: Create identity provider with Keycloak OIDC config + const providerName = `JwksCredPriority${Date.now()}`; + const identityProviderId = await createIdentityProvider( + request, + providerName, + ); + + let profileId: string | undefined; + let catalogId: string | undefined; + let serverId: string | undefined; + const catalogName = `jwks-cred-priority-${Date.now()}`; + const echoAuthToolName = `${catalogName}${MCP_SERVER_TOOL_NAME_SEPARATOR}echo_auth`; + + try { + // STEP 3: Create an MCP Gateway profile linked to the IdP + const agentResponse = await createAgent( + request, + `JWKS Cred Priority E2E ${Date.now()}`, + "personal", + ); + const agent = await agentResponse.json(); + profileId = agent.id; + const pid = profileId as string; + + await makeApiRequest({ + request, + method: "put", + urlSuffix: `/api/agents/${pid}`, + data: { + agentType: "mcp_gateway", + identityProviderId, + }, + }); + + // STEP 4: Create remote catalog item pointing to WireMock + const catalogResponse = await createMcpCatalogItem(request, { + name: catalogName, + description: + "E2E test: JWKS credential resolution priority — upstream creds preferred", + serverType: "remote", + serverUrl: WIREMOCK_MCP_URL, + }); + const catalogItem = await catalogResponse.json(); + catalogId = catalogItem.id; + + // STEP 5: Install server WITH a static token (stored as upstream credential) + const installResponse = await installMcpServer(request, { + name: catalogName, + catalogId, + accessToken: STATIC_TOKEN, + agentIds: [pid], + }); + const mcpServer = await installResponse.json(); + serverId = mcpServer.id; + + // STEP 6: Wait for tool discovery + const agentTool = await waitForAgentTool( + request, + pid, + echoAuthToolName, + { maxAttempts: 30, delayMs: 2000 }, + ); + expect(agentTool).toBeDefined(); + + // STEP 7: Initialize MCP session with the external JWT + await initializeMcpSession(request, { + profileId: pid, + token: jwt, + }); + + // STEP 8: Call echo_auth tool via MCP Gateway + // The upstream WireMock server echoes back the Authorization header it received. + // After the credential resolution priority fix, the upstream should receive + // the static token (stored credential), NOT the Keycloak JWT. + const result = await callMcpTool(request, { + profileId: pid, + token: jwt, + toolName: echoAuthToolName, + timeoutMs: 30000, + }); + + expect(result.content).toBeDefined(); + expect(result.content.length).toBeGreaterThan(0); + + const responseText = result.content[0].text; + expect(responseText).toBeDefined(); + + // Verify the upstream server received the STATIC token, not the JWT + expect(responseText).toContain(`Bearer ${STATIC_TOKEN}`); + expect(responseText).not.toContain(jwt); + } finally { + if (profileId) { + await deleteAgent(request, profileId); + } + if (serverId) { + await uninstallMcpServer(request, serverId); + } + if (catalogId) { + await deleteMcpCatalogItem(request, catalogId); + } + await deleteIdentityProvider(request, identityProviderId); + } + }); + + test("should propagate JWT as fallback when no upstream credentials exist", async ({ + request, + createAgent, + deleteAgent, + createIdentityProvider, + deleteIdentityProvider, + createMcpCatalogItem, + deleteMcpCatalogItem, + installMcpServer, + uninstallMcpServer, + waitForAgentTool, + }) => { + test.slow(); + + // STEP 1: Get a JWT from Keycloak + const jwt = await getKeycloakJwt(); + expect(jwt).toBeTruthy(); + expect(jwt.split(".")).toHaveLength(3); + + // STEP 2: Create identity provider with Keycloak OIDC config + const providerName = `JwksJwtFallback${Date.now()}`; + const identityProviderId = await createIdentityProvider( + request, + providerName, + ); + + let profileId: string | undefined; + let catalogId: string | undefined; + let serverId: string | undefined; + const catalogName = `jwks-jwt-fallback-${Date.now()}`; + const echoAuthToolName = `${catalogName}${MCP_SERVER_TOOL_NAME_SEPARATOR}echo_auth`; + + try { + // STEP 3: Create an MCP Gateway profile linked to the IdP + const agentResponse = await createAgent( + request, + `JWKS JWT Fallback E2E ${Date.now()}`, + "personal", + ); + const agent = await agentResponse.json(); + profileId = agent.id; + const pid = profileId as string; + + await makeApiRequest({ + request, + method: "put", + urlSuffix: `/api/agents/${pid}`, + data: { + agentType: "mcp_gateway", + identityProviderId, + }, + }); + + // STEP 4: Create remote catalog item pointing to WireMock + const catalogResponse = await createMcpCatalogItem(request, { + name: catalogName, + description: + "E2E test: JWKS JWT propagation fallback — no upstream credentials", + serverType: "remote", + serverUrl: WIREMOCK_MCP_URL, + }); + const catalogItem = await catalogResponse.json(); + catalogId = catalogItem.id; + + // STEP 5: Install server WITHOUT credentials (no accessToken) + // WireMock stubs don't require auth, so tool discovery works without credentials + const installResponse = await installMcpServer(request, { + name: catalogName, + catalogId, + agentIds: [pid], + }); + const mcpServer = await installResponse.json(); + serverId = mcpServer.id; + + // STEP 6: Wait for tool discovery + const agentTool = await waitForAgentTool( + request, + pid, + echoAuthToolName, + { maxAttempts: 30, delayMs: 2000 }, + ); + expect(agentTool).toBeDefined(); + + // STEP 7: Initialize MCP session with the external JWT + await initializeMcpSession(request, { + profileId: pid, + token: jwt, + }); + + // STEP 8: Call echo_auth tool via MCP Gateway + // Without upstream credentials, the gateway should propagate the Keycloak JWT + // to the upstream server as a fallback (end-to-end JWKS pattern). + const result = await callMcpTool(request, { + profileId: pid, + token: jwt, + toolName: echoAuthToolName, + timeoutMs: 30000, + }); + + expect(result.content).toBeDefined(); + expect(result.content.length).toBeGreaterThan(0); + + const responseText = result.content[0].text; + expect(responseText).toBeDefined(); + + // Verify the upstream server received a JWT (three dot-separated base64url segments) + expect(responseText).toContain("RECEIVED_AUTH=Bearer "); + const receivedToken = responseText + ?.replace("RECEIVED_AUTH=Bearer ", ""); + expect(receivedToken?.split(".")).toHaveLength(3); + } finally { + if (profileId) { + await deleteAgent(request, profileId); + } + if (serverId) { + await uninstallMcpServer(request, serverId); + } + if (catalogId) { + await deleteMcpCatalogItem(request, catalogId); + } + await deleteIdentityProvider(request, identityProviderId); + } + }); +}); diff --git a/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-catch-all.json b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-catch-all.json new file mode 100644 index 0000000000..ebd5cda73e --- /dev/null +++ b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-catch-all.json @@ -0,0 +1,12 @@ +{ + "priority": 10, + "request": { + "method": "POST", + "urlPath": "/mcp/jwks-cred-priority-e2e" + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "body": "" + } +} diff --git a/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-initialize.json b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-initialize.json new file mode 100644 index 0000000000..3b99693225 --- /dev/null +++ b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-initialize.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "POST", + "urlPath": "/mcp/jwks-cred-priority-e2e", + "bodyPatterns": [ + { "matchesJsonPath": "$[?(@.method == 'initialize')]" } + ] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":{{jsonPath request.body '$.id'}},\"result\":{\"protocolVersion\":\"2024-11-05\",\"serverInfo\":{\"name\":\"jwks-cred-priority-e2e\",\"version\":\"1.0.0\"},\"capabilities\":{\"tools\":{\"listChanged\":false}}}}" + } +} diff --git a/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-call.json b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-call.json new file mode 100644 index 0000000000..6c0cf05183 --- /dev/null +++ b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-call.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "POST", + "urlPath": "/mcp/jwks-cred-priority-e2e", + "bodyPatterns": [ + { "matchesJsonPath": "$[?(@.method == 'tools/call')]" } + ] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":{{jsonPath request.body '$.id'}},\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"RECEIVED_AUTH={{request.headers.Authorization}}\"}]}}" + } +} diff --git a/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-list.json b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-list.json new file mode 100644 index 0000000000..0178712f5d --- /dev/null +++ b/platform/helm/e2e-tests/mappings/mcp-jwks-cred-priority-e2e-tools-list.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "POST", + "urlPath": "/mcp/jwks-cred-priority-e2e", + "bodyPatterns": [ + { "matchesJsonPath": "$[?(@.method == 'tools/list')]" } + ] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":{{jsonPath request.body '$.id'}},\"result\":{\"tools\":[{\"name\":\"echo_auth\",\"description\":\"Echoes back the Authorization header received by the upstream server\",\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}]}}" + } +}