From 54a2182e291db68f41d8d740740b230312b12889 Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Tue, 3 Nov 2026 11:52:15 +0000 Subject: [PATCH] feat: validate OAuth MCP connections after authorization Signed-off-by: Vishu Bhatnagar --- .secrets.baseline | 10 + README.md | 2 +- e2e/mcp-oauth-outcomes.spec.ts | 4 +- e2e/oauth-connection-validation.spec.ts | 259 +++++ e2e/tools.spec.ts | 2 +- openapi.json | 922 +++++++++++++++++- server/test/oauth-callback.test.ts | 20 + src/api/oauth.ts | 9 +- src/api/servers.test.ts | 64 +- src/api/servers.ts | 28 +- .../server-catalog/CatalogResults.tsx | 55 +- .../servers/MCPServerDetailsPanel.tsx | 3 + .../OAuthConnectionValidationPanel.test.tsx | 102 ++ .../OAuthConnectionValidationPanel.tsx | 106 ++ src/components/servers/ServersTable.tsx | 171 ++-- .../useOAuthConnectionValidation.test.ts | 174 ++++ src/hooks/useOAuthConnectionValidation.ts | 105 ++ src/i18n/locales/en-US/mcpServer.json | 49 +- src/i18n/locales/es-ES/mcpServer.json | 49 +- src/i18n/locales/pt-BR/mcpServer.json | 49 +- src/lib/oauthConnectionValidation.ts | 77 ++ src/pages/ServerCatalog.test.tsx | 75 +- src/pages/ServerCatalog.tsx | 237 +++-- src/pages/Servers.test.tsx | 77 +- src/pages/Servers.tsx | 87 +- tsconfig.e2e.json | 2 +- 26 files changed, 2534 insertions(+), 204 deletions(-) create mode 100644 e2e/oauth-connection-validation.spec.ts create mode 100644 src/components/servers/OAuthConnectionValidationPanel.test.tsx create mode 100644 src/components/servers/OAuthConnectionValidationPanel.tsx create mode 100644 src/hooks/useOAuthConnectionValidation.test.ts create mode 100644 src/hooks/useOAuthConnectionValidation.ts create mode 100644 src/lib/oauthConnectionValidation.ts diff --git a/.secrets.baseline b/.secrets.baseline index fd3445fd..b68bd6a7 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -182,6 +182,16 @@ "type": "Secret Keyword", "verified_result": null } + ], + "src/pages/ServerCatalog.tsx": [ + { + "hashed_secret": "d9b1022fb31deaf437aef6eac8b6dd7f0aa4c019", + "is_secret": false, + "is_verified": false, + "line_number": 1377, + "type": "Secret Keyword", + "verified_result": null + } ] }, "version": "0.13.1+ibm.64.dss", diff --git a/README.md b/README.md index bad7d313..5479a3af 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ npm run preview ## API Types -TypeScript types and fetch clients under `src/generated/` come from [`openapi.json`](./openapi.json) via [Orval](./orval.config.ts). That file is committed and pinned to API v1.1.0, not re-fetched at build time. +TypeScript types and fetch clients under `src/generated/` come from [`openapi.json`](./openapi.json) via [Orval](./orval.config.ts). That file is committed and pinned to API v1.1.0 at [backend revision d2da38b](https://github.com/IBM/mcp-context-forge/commit/d2da38b56be5e8e4bdf51db9b2c43d4c96036b62), which includes the post-authorization validation contract from [PR #7343](https://github.com/IBM/mcp-context-forge/pull/7343). It is not re-fetched at build time. ```bash npm run api:generate # regenerate src/generated/ from ./openapi.json diff --git a/e2e/mcp-oauth-outcomes.spec.ts b/e2e/mcp-oauth-outcomes.spec.ts index 6e4def46..0c2cf18d 100644 --- a/e2e/mcp-oauth-outcomes.spec.ts +++ b/e2e/mcp-oauth-outcomes.spec.ts @@ -132,7 +132,9 @@ for (const fromDetails of [false, true]) { const dialog = page.getByRole("dialog", { name: "OAuth authorization failed for OAuth outcomes server", }); - await expect(dialog.getByText("Provider denied the requested scope")).toBeVisible(); + await expect( + dialog.getByText("The provider denied authorization. Authorize this server again."), + ).toBeVisible(); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); if (fromDetails) await expect(panel).toBeVisible(); diff --git a/e2e/oauth-connection-validation.spec.ts b/e2e/oauth-connection-validation.spec.ts new file mode 100644 index 00000000..754d62bf --- /dev/null +++ b/e2e/oauth-connection-validation.spec.ts @@ -0,0 +1,259 @@ +import { test, expect } from "./fixtures/auth"; +import { APP } from "./utils/paths"; + +for (const catalog of [false, true]) { + const surface = catalog ? "Catalog" : "Servers"; + test.describe(`${surface} post-authorization connection validation`, () => { + test("authorization exposes a manual test, scope failure can be reauthorized and retested", async ({ + page, + context, + apiMock, + }) => { + await apiMock.mockPermissions(); + let authorized = false; + let scopeFailure = true; + let tests = 0; + let imports = 0; + const gateway = { + id: "oauth-validation", + name: "Validation server", + url: "https://saved.example/mcp", + transport: "STREAMABLEHTTP", + enabled: true, + reachable: true, + visibility: "public", + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + createdAt: "2026-11-02T00:00:00Z", + updatedAt: "2026-11-02T00:00:00Z", + }; + await page.route("**/v1/mcp-servers?*", (route) => + route.fulfill({ json: { gateways: [gateway], nextCursor: null } }), + ); + await page.route(`**/v1/mcp-servers/${gateway.id}`, (route) => + route.fulfill({ json: gateway }), + ); + await page.route("**/v1/catalog?*", (route) => + route.fulfill({ + json: { + servers: [ + { + id: "catalog-validation", + name: gateway.name, + url: "https://template.example/mcp", + description: "OAuth test server", + auth_type: "OAuth", + category: "Development", + provider: "Example", + is_registered: true, + gateway_id: gateway.id, + requires_oauth_config: false, + }, + ], + total: 1, + }, + }), + ); + await page.route("**/oauth/status?*", (route) => + route.fulfill({ + json: { + [gateway.id]: { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: authorized ? "valid" : "missing", authorized }, + }, + }, + }), + ); + await page.route("**/oauth/authorize-nonce", (route) => + route.fulfill({ json: { nonce: "validation-nonce" } }), + ); + await context.route("**/oauth/authorize/**", (route) => + route.fulfill({ contentType: "text/html", body: "Provider" }), + ); + await page.route(`**/oauth/fetch-tools/${gateway.id}`, (route) => { + imports++; + return route.fulfill({ json: { success: true } }); + }); + await page.route(`**/v1/mcp-servers/${gateway.id}/state?*`, (route) => + route.fulfill({ json: { status: "success" } }), + ); + await page.route("**/v1/mcp-servers/test-handshake", (route) => { + tests++; + expect(route.request().postDataJSON()).toEqual({ + baseUrl: gateway.url, + gatewayId: gateway.id, + validationMode: "post_authorization", + }); + return route.fulfill({ + json: { + success: !scopeFailure, + latencyMs: 3, + stages: { token: "ok", negotiation: "ok", tools_list: scopeFailure ? "failed" : "ok" }, + errorCode: scopeFailure ? "insufficient_scope" : null, + reauthorizationRequired: scopeFailure, + componentCounts: scopeFailure ? null : { tools: 0 }, + }, + }); + }); + await page.goto(catalog ? APP.SERVER_CATALOG : APP.SERVERS); + const popupPromise = context.waitForEvent("page"); + if (catalog) { + await page.getByRole("button", { name: "Actions for Validation server" }).click(); + await page.getByRole("menuitem", { name: "Authorize", exact: true }).click(); + } + if (!catalog) await page.getByRole("button", { name: "Authorize Validation server" }).click(); + const popup = await popupPromise; + await popup.waitForURL(/\/oauth\/authorize\//); + authorized = true; + await popup.evaluate(() => { + window.opener.postMessage({ type: "oauth_callback", status: "success" }, "*"); + window.close(); + }); + const button = page.getByRole("button", { name: "Test connection", exact: true }); + await expect(button).toBeVisible(); + expect(tests).toBe(0); + await expect.poll(() => imports).toBe(1); + await button.click(); + await expect( + page.getByText( + "The stored token lacks the required scope. Review the scopes and authorize again.", + ), + ).toBeVisible(); + expect(imports).toBe(1); + const retryPopupPromise = context.waitForEvent("page"); + await page.getByRole("button", { name: "Authorize again" }).click(); + const retryPopup = await retryPopupPromise; + await retryPopup.waitForURL(/\/oauth\/authorize\//); + await expect(page.getByText("Connection validation failed")).not.toBeVisible(); + await retryPopup.evaluate(() => { + window.opener.postMessage({ type: "oauth_callback", status: "success" }, "*"); + window.close(); + }); + await expect(button).toBeVisible(); + scopeFailure = false; + await button.click(); + await expect(page.getByText("Stored authorization verified")).toBeVisible(); + await expect(page.getByText("Tools access: Passed")).toBeVisible(); + await expect(page.getByRole("status").getByText("0 tools", { exact: true })).toBeVisible(); + expect(tests).toBe(2); + }); + + test("read-only users cannot run stored-token validation", async ({ page, apiMock }) => { + await apiMock.mockPermissions({ permissions: ["gateways.read"] }); + const gateway = { + id: "read-only-oauth", + name: "Read only OAuth", + url: "https://saved.example/mcp", + enabled: true, + reachable: true, + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + visibility: "public", + }; + await page.route("**/v1/mcp-servers?*", (route) => + route.fulfill({ json: { gateways: [gateway] } }), + ); + await page.route(`**/v1/mcp-servers/${gateway.id}`, (route) => + route.fulfill({ json: gateway }), + ); + await page.route("**/v1/catalog?*", (route) => + route.fulfill({ + json: { + servers: [ + { + id: "read-only", + name: gateway.name, + url: gateway.url, + auth_type: "OAuth", + is_registered: true, + gateway_id: gateway.id, + }, + ], + total: 1, + }, + }), + ); + await page.route("**/oauth/status?*", (route) => + route.fulfill({ + json: { + [gateway.id]: { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: "valid", authorized: true }, + }, + }, + }), + ); + await page.goto(catalog ? APP.SERVER_CATALOG : APP.SERVERS); + await expect(page.getByText(gateway.name, { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Test connection", exact: true }), + ).not.toBeVisible(); + if (catalog) { + await page.getByRole("button", { name: `Actions for ${gateway.name}` }).click(); + await expect(page.getByRole("menuitem", { name: "Test connection" })).not.toBeVisible(); + } + }); + }); +} + +for (const [code, guidance] of [ + [ + "redirect_uri_mismatch", + "The provider rejected the redirect URI. Check the registered callback URL.", + ], + ["token_url_unreachable", "The token endpoint is unreachable. Check its URL and network access."], +]) { + test(`callback surfaces ${code} before a token exists`, async ({ page, context, apiMock }) => { + await apiMock.mockPermissions(); + const gateway = { + id: "callback-error", + name: "Callback server", + url: "https://saved.example/mcp", + enabled: true, + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + }; + await page.route("**/v1/mcp-servers?*", (route) => + route.fulfill({ json: { gateways: [gateway] } }), + ); + await page.route("**/oauth/status?*", (route) => + route.fulfill({ + json: { + [gateway.id]: { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: "missing", authorized: false }, + }, + }, + }), + ); + await page.route("**/oauth/authorize-nonce", (route) => + route.fulfill({ json: { nonce: "callback-nonce" } }), + ); + await context.route("**/oauth/authorize/**", (route) => + route.fulfill({ contentType: "text/html", body: "Provider" }), + ); + await page.goto(APP.SERVERS); + const popupPromise = context.waitForEvent("page"); + await page.getByRole("button", { name: "Authorize Callback server" }).click(); + const popup = await popupPromise; + await popup.waitForURL(/\/oauth\/authorize\//); + await popup.evaluate((errorCode) => { + window.opener.postMessage( + { + type: "oauth_callback", + status: "error", + error: "oauth_error", + errorCode, + failedStage: "token_exchange", + reauthorizationRequired: true, + }, + "*", + ); + window.close(); + }, code); + await expect(page.getByRole("dialog").getByText(guidance)).toBeVisible(); + }); +} diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts index 7e72dd9a..61f9a49c 100644 --- a/e2e/tools.spec.ts +++ b/e2e/tools.spec.ts @@ -460,7 +460,7 @@ test.describe("Tools page", () => { arguments: { query: "cloudflare", limit: 5 }, }, }); - expect(rpcBody?.params).not.toHaveProperty("server_id"); + expect(rpcBody).not.toHaveProperty("params.server_id"); expect(rpcHeaders["x-tenant-id"]).toBe("team-a"); // Real mode gets a real, randomly-generated token from the real login. expect(rpcHeaders["x-csrf-token"]).toBe((await apiMock.getRealCsrfToken()) ?? MOCK_CSRF_TOKEN); diff --git a/openapi.json b/openapi.json index fcfad167..aecfe0c6 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "ContextForge", "description": "ContextForge AI Gateway — an AI gateway, registry, and proxy for MCP, A2A, and REST/gRPC APIs. Exposes a unified control plane with centralized governance, discovery, and observability. Optimizes agent and tool calling, and supports plugins.", - "version": "1.1.0+663938" + "version": "1.1.0+d2da38" }, "paths": { "/metrics/prometheus": { @@ -8872,6 +8872,70 @@ } } }, + "/v1/a2a/{agent_id}/card": { + "get": { + "tags": [ + "A2A Agents" + ], + "summary": "Get A2A Agent Card", + "description": "Retrieve the A2A AgentCard for an agent or agent-exposing virtual server.\n\nResolves ``agent_id`` against standalone A2A agents first, then against\nvirtual servers with an enabled A2A interface, mirroring the internal\n``/_internal/a2a/agents/{agent_name}/card`` lookup used by the Rust\nsidecar. Layer-1 visibility is enforced inside the service calls.\n\nArgs:\n agent_id (str): The ID of the agent or agent-exposing virtual server.\n request (Request): The FastAPI request object for team_id retrieval.\n response (Response): The response object used to set cache headers.\n db (Session): The database session used to interact with the data store.\n user (str): The authenticated user making the request.\n\nReturns:\n Dict[str, Any]: The AgentCard for the resolved agent.\n\nRaises:\n HTTPException: If the agent is not found or the caller lacks access.", + "operationId": "get_a2a_agent_card_v1_a2a__agent_id__card_get", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentCard" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/a2a/{agent_id}/state": { "post": { "tags": [ @@ -8928,7 +8992,502 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/A2AAgentRead" + "$ref": "#/components/schemas/A2AAgentRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/a2a/{agent_id}/toggle": { + "post": { + "tags": [ + "A2A Agents" + ], + "summary": "Toggle A2A Agent Status", + "description": "DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.\n\nSets the status of an A2A agent (activate or deactivate).\n\nArgs:\n agent_id: The A2A agent ID.\n activate: Whether to activate (True) or deactivate (False) the agent.\n db: Database session.\n user: Authenticated user context.\n\nReturns:\n The updated A2A agent.", + "operationId": "toggle_a2a_agent_status_v1_a2a__agent_id__toggle_post", + "deprecated": true, + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "activate", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Activate" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/A2AAgentRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/a2a/{agent_id}/tasks": { + "get": { + "tags": [ + "A2A Agents" + ], + "summary": "List A2A Tasks", + "description": "List task summaries for an A2A agent with ``pageToken`` pagination.\n\nSummaries omit ``history`` and ``artifacts``; fetch a single task for those.\n\nArgs:\n agent_id: Owning agent ID.\n request: The FastAPI request object.\n state: Optional task state filter.\n page_size: Maximum tasks per page.\n page_token: Opaque token returned as ``nextPageToken`` by the previous page.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n Dict with ``tasks`` and ``nextPageToken`` (empty string on the last page).", + "operationId": "list_a2a_tasks_v1_a2a__agent_id__tasks_get", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/A2ATaskState" + }, + { + "type": "null" + } + ], + "description": "Filter by task state", + "title": "State" + }, + "description": "Filter by task state" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 50, + "title": "Pagesize" + } + }, + { + "name": "pageToken", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pagetoken" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List A2A Tasks V1 A2A Agent Id Tasks Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/a2a/{agent_id}/tasks/{task_id}": { + "get": { + "tags": [ + "A2A Agents" + ], + "summary": "Get A2A Task", + "description": "Get an A2A task.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n request: The FastAPI request object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n The task in A2A wire format.\n\nRaises:\n HTTPException: 404 if the task does not exist or is not visible.", + "operationId": "get_a2a_task_v1_a2a__agent_id__tasks__task_id__get", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get A2A Task V1 A2A Agent Id Tasks Task Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/a2a/{agent_id}/tasks/{task_id}/cancel": { + "post": { + "tags": [ + "A2A Agents" + ], + "summary": "Cancel A2A Task", + "description": "Cancel an A2A task in the gateway's task store.\n\nThe route changes local task state only and does not signal the remote agent.\nTasks already in a terminal state are returned unchanged.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n request: The FastAPI request object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n The task in A2A wire format.\n\nRaises:\n HTTPException: 404 if the task does not exist or is not visible.", + "operationId": "cancel_a2a_task_v1_a2a__agent_id__tasks__task_id__cancel_post", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Cancel A2A Task V1 A2A Agent Id Tasks Task Id Cancel Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/a2a/{agent_id}/tasks/{task_id}/pushNotificationConfigs": { + "post": { + "tags": [ + "A2A Agents" + ], + "summary": "Create A2A Push Config", + "description": "Create or update a push notification config for a task.\n\nReturns 201 for a new config and 200 when the ``(agent, task, webhook_url)`` config already exists and is updated.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n body: Webhook configuration.\n request: The FastAPI request object.\n response: The FastAPI response object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n The stored config. ``auth_token`` is never returned.", + "operationId": "create_a2a_push_config_v1_a2a__agent_id__tasks__task_id__pushNotificationConfigs_post", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/A2APushNotificationConfigRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Create A2A Push Config V1 A2A Agent Id Tasks Task Id Pushnotificationconfigs Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "A2A Agents" + ], + "summary": "List A2A Push Configs", + "description": "List push notification configs for a task.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n request: The FastAPI request object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n Dict with ``configs``.", + "operationId": "list_a2a_push_configs_v1_a2a__agent_id__tasks__task_id__pushNotificationConfigs_get", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List A2A Push Configs V1 A2A Agent Id Tasks Task Id Pushnotificationconfigs Get" } } } @@ -8946,15 +9505,14 @@ } } }, - "/v1/a2a/{agent_id}/toggle": { - "post": { + "/v1/a2a/{agent_id}/tasks/{task_id}/pushNotificationConfigs/{config_id}": { + "get": { "tags": [ "A2A Agents" ], - "summary": "Toggle A2A Agent Status", - "description": "DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.\n\nSets the status of an A2A agent (activate or deactivate).\n\nArgs:\n agent_id: The A2A agent ID.\n activate: Whether to activate (True) or deactivate (False) the agent.\n db: Database session.\n user: Authenticated user context.\n\nReturns:\n The updated A2A agent.", - "operationId": "toggle_a2a_agent_status_v1_a2a__agent_id__toggle_post", - "deprecated": true, + "summary": "Get A2A Push Config", + "description": "Get one push notification config.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n config_id: Push config ID.\n request: The FastAPI request object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n The stored config.\n\nRaises:\n HTTPException: 404 if the config does not exist or is not visible.", + "operationId": "get_a2a_push_config_v1_a2a__agent_id__tasks__task_id__pushNotificationConfigs__config_id__get", "security": [ { "ConfigurableHTTPBearer": [] @@ -8971,13 +9529,103 @@ } }, { - "name": "activate", - "in": "query", + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "config_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Config Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", "required": false, "schema": { - "type": "boolean", - "default": true, - "title": "Activate" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get A2A Push Config V1 A2A Agent Id Tasks Task Id Pushnotificationconfigs Config Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "A2A Agents" + ], + "summary": "Delete A2A Push Config", + "description": "Delete a push notification config.\n\nArgs:\n agent_id: Owning agent ID.\n task_id: Agent-side task ID.\n config_id: Push config ID.\n request: The FastAPI request object.\n db: Database session.\n user: Authenticated user.\n\nReturns:\n ``{\"deleted\": True}``.\n\nRaises:\n HTTPException: 404 if the config does not exist or is not visible.", + "operationId": "delete_a2a_push_config_v1_a2a__agent_id__tasks__task_id__pushNotificationConfigs__config_id__delete", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "config_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Config Id" } }, { @@ -9003,7 +9651,9 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/A2AAgentRead" + "type": "object", + "additionalProperties": true, + "title": "Response Delete A2A Push Config V1 A2A Agent Id Tasks Task Id Pushnotificationconfigs Config Id Delete" } } } @@ -37937,6 +38587,9 @@ } } }, + "400": { + "description": "Safe OAuth failure HTML. Popup postMessage retains the type, status, error, and errorDescription fields, and adds errorCode, failedStage, and reauthorizationRequired. Recognized provider codes are preserved; descriptions use fixed safe guidance. Unknown provider codes become oauth_error. Codes include redirect_uri_mismatch, invalid_scope, insufficient_scope, invalid_client, invalid_request, unauthorized_client, unsupported_response_type, server_error, temporarily_unavailable, token_url_unreachable, and token_exchange_failed." + }, "422": { "description": "Validation Error", "content": { @@ -38929,7 +39582,7 @@ }, "responses": { "200": { - "description": "Successful Response", + "description": "Handshake or validation outcome. Upstream failures return success=false with safe stage results.", "content": { "application/json": { "schema": { @@ -38938,15 +39591,17 @@ } } }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Gateway read permission required; post_authorization also requires gateway update permission." + }, "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } + "description": "Invalid input, missing exact gateway ID, or incompatible post-authorization header overrides." + }, + "429": { + "description": "Per-user connection-test rate limit exceeded." } } } @@ -41472,6 +42127,65 @@ "title": "A2AJsonRpcResponse", "description": "Schema for ``POST /v1/a2a/{agent_name}/jsonrpc``.\n\nThis passthrough always answers with a JSON-RPC envelope: an agent document that\nalready carries ``jsonrpc`` is returned unchanged, otherwise the agent's response is\nwrapped as ``{\"jsonrpc\": \"2.0\", \"result\": ...}``. ``id`` is only present when the\nrequest had one. The gateway's own JSON-RPC error replies are returned as explicit\nresponses with a non-2xx status, so they bypass this model.\n\nIts fields deliberately match :class:`A2AInvokeResponse`; the separate class\nname provides a distinct OpenAPI component for this route." }, + "A2APushNotificationConfigRequest": { + "properties": { + "webhook_url": { + "type": "string", + "title": "Webhook Url" + }, + "auth_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Auth Token" + }, + "events": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Events" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "required": [ + "webhook_url" + ], + "title": "A2APushNotificationConfigRequest", + "description": "Schema for the client-facing push notification config body.\n\nThe agent and task identifiers come from the URL path, not the body." + }, + "A2ATaskState": { + "type": "string", + "enum": [ + "submitted", + "working", + "input-required", + "auth-required", + "completed", + "canceled", + "failed", + "rejected" + ], + "title": "A2ATaskState", + "description": "Finite state machine for A2A task lifecycle per the A2A v1 spec.\n\nTerminal states (``completed``, ``canceled``, ``failed``, ``rejected``)\nare the only states for which ``completed_at`` should be set; the\nmodel-validators on :class:`A2ATaskCreate` and :class:`A2ATaskUpdate`\nenforce this invariant." + }, "ActivityItem": { "properties": { "id": { @@ -41694,6 +42408,95 @@ "title": "AdminUserUpdateRequest", "description": "Request schema for admin user updates.\n\nAttributes:\n full_name: User's full name\n is_admin: Whether user has admin privileges\n is_active: Whether account is active\n password_change_required: Whether user must change password on next login\n password: New password (admin can reset without old password)\n\nExamples:\n >>> request = AdminUserUpdateRequest(\n ... full_name=\"Updated Name\",\n ... is_admin=True,\n ... is_active=True\n ... )\n >>> request.full_name\n 'Updated Name'\n >>> request.is_admin\n True" }, + "AgentCard": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Agent or virtual server name" + }, + "description": { + "type": "string", + "title": "Description", + "description": "Agent description", + "default": "" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url", + "description": "Endpoint URL of the agent" + }, + "version": { + "type": "string", + "title": "Version", + "description": "Agent version" + }, + "protocolVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Protocolversion", + "description": "A2A protocol version" + }, + "defaultInputModes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Defaultinputmodes", + "description": "Default input modes" + }, + "defaultOutputModes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Defaultoutputmodes", + "description": "Default output modes" + }, + "capabilities": { + "additionalProperties": true, + "type": "object", + "title": "Capabilities", + "description": "Declared agent capabilities" + }, + "skills": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Skills", + "description": "Skills the agent advertises" + }, + "supportsAuthenticatedExtendedCard": { + "type": "boolean", + "title": "Supportsauthenticatedextendedcard", + "description": "Whether an extended card exists", + "default": true + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "name", + "version" + ], + "title": "AgentCard", + "description": "Schema for ``GET /v1/a2a/{agent_id}/card``.\n\nDescribes an A2A v1 AgentCard built from a standalone agent or from a\nvirtual server that exposes an A2A interface. Extra keys stay on the wire\nso the card can grow without a schema change." + }, "Annotations": { "properties": { "audience": { @@ -44778,6 +45581,16 @@ "title": "Gatewayid", "description": "Exact registered gateway to use for transport, TLS, and stored-credential settings; combined with credential_mode to control whether stored credentials are applied. Also accepted as mcp_server_id" }, + "validationMode": { + "type": "string", + "enum": [ + "handshake", + "post_authorization" + ], + "title": "Validationmode", + "description": "'handshake' preserves existing negotiation success semantics. 'post_authorization' requires an exact enabled OAuth authorization_code gateway, gateways.read and gateways.update, and the caller's stored token without refresh. A live tools/list must succeed within ten seconds. No tokens or registered components are changed. Legacy Vault records without an exact gateway binding require authorization again.", + "default": "handshake" + }, "credentialMode": { "type": "string", "enum": [ @@ -44939,6 +45752,69 @@ ], "title": "Rawpreview", "description": "Size-capped JSON preview of the final handshake payload" + }, + "stages": { + "anyOf": [ + { + "additionalProperties": { + "type": "string", + "enum": [ + "ok", + "failed", + "skipped", + "unsupported" + ] + }, + "propertyNames": { + "enum": [ + "token", + "negotiation", + "tools_list" + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Stages", + "description": "Post-authorization stage outcomes. Skipped or unsupported listing never proves live tools access." + }, + "failedStage": { + "anyOf": [ + { + "type": "string", + "enum": [ + "gateway", + "token", + "negotiation", + "tools_list" + ] + }, + { + "type": "null" + } + ], + "title": "Failedstage" + }, + "errorCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Errorcode", + "description": "Stable post-authorization codes: gateway_unavailable, permission_denied, unsupported_auth, authorization_missing, token_expired, invalid_token, token_storage_unavailable, insufficient_scope, tools_unsupported, tools_list_failed, mcp_handshake_failed, timeout, blocked, validation_failed. Callback codes also include redirect_uri_mismatch, token_exchange_failed, token_url_unreachable, invalid_client, oauth_error." + }, + "reauthorizationRequired": { + "type": "boolean", + "title": "Reauthorizationrequired", + "description": "Whether the caller must authorize this gateway again", + "default": false } }, "type": "object", diff --git a/server/test/oauth-callback.test.ts b/server/test/oauth-callback.test.ts index ca75adc6..8355b9b9 100644 --- a/server/test/oauth-callback.test.ts +++ b/server/test/oauth-callback.test.ts @@ -39,6 +39,14 @@ beforeAll(async () => { return; } + if (req.url?.startsWith("/oauth/callback?structured-error")) { + res.writeHead(400, { "content-type": "text/html" }); + res.end( + '', + ); + return; + } + // Mirrors mcpgateway's oauth_callback popup branch: an HTML page whose // inline script posts the result to window.opener and closes itself. res.writeHead(200, { @@ -65,6 +73,18 @@ async function buildApp() { } describe("GET /oauth/callback", () => { + it("preserves safe structured backend callback errors", async () => { + const app = await buildApp(); + const response = await app.fastify.inject({ + method: "GET", + url: "/oauth/callback?structured-error=1&state=popup.xyz", + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('"errorCode":"token_url_unreachable"'); + expect(response.body).toContain('"failedStage":"token_exchange"'); + expect(response.body).toContain('"reauthorizationRequired":false'); + }); + it("proxies with no session cookie required", async () => { const app = await buildApp(); diff --git a/src/api/oauth.ts b/src/api/oauth.ts index 375b3373..edca0e67 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -106,4 +106,11 @@ export async function getOAuthStatuses( export type OAuthAttempt = | { state: "pending" } | { state: "succeeded" } - | { state: "failed"; reason: string; dialogOpen: boolean }; + | { + state: "failed"; + reason: string; + dialogOpen: boolean; + errorCode?: string; + failedStage?: string; + reauthorizationRequired?: boolean; + }; diff --git a/src/api/servers.test.ts b/src/api/servers.test.ts index cfbb5688..d7c4e40d 100644 --- a/src/api/servers.test.ts +++ b/src/api/servers.test.ts @@ -1,6 +1,6 @@ import { waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { serversApi } from "./servers"; +import { OAuthAuthorizationError, serversApi } from "./servers"; import { setCsrfToken } from "./client"; import type { GatewayTestRequest, GatewayHandshakeRequest } from "@/generated/types"; @@ -33,6 +33,68 @@ describe("serversApi", () => { vi.restoreAllMocks(); }); + describe("post-authorization connection validation", () => { + it("sends only the saved gateway identity, URL and strict mode", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ success: false, latencyMs: 1, errorCode: "insufficient_scope" }), + { status: 200 }, + ), + ); + const controller = new AbortController(); + const result = await serversApi.testOAuthConnection( + { gatewayId: "gw-1", baseUrl: "https://saved.example/mcp" }, + controller.signal, + ); + expect(result?.errorCode).toBe("insufficient_scope"); + expect(mockFetch).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/v1/mcp-servers/test-handshake"), + expect.objectContaining({ + method: "POST", + signal: controller.signal, + body: JSON.stringify({ + baseUrl: "https://saved.example/mcp", + gatewayId: "gw-1", + validationMode: "post_authorization", + }), + }), + ); + }); + + it("retains structured callback fields and remains compatible with legacy errors", async () => { + const authWindow = mockAuthPopup(); + vi.spyOn(window, "open").mockReturnValue(authWindow); + mockFetch.mockResolvedValueOnce(nonceResponse()); + const promise = serversApi.triggerOAuthAuthorization("gw-1"); + const failure = expect(promise).rejects.toMatchObject({ + code: "oauth_error", + errorCode: "token_url_unreachable", + failedStage: "token_exchange", + reauthorizationRequired: false, + }); + window.dispatchEvent( + new MessageEvent("message", { + source: authWindow, + data: { + type: "oauth_callback", + status: "error", + error: "oauth_error", + errorCode: "token_url_unreachable", + failedStage: "token_exchange", + reauthorizationRequired: false, + }, + }), + ); + await failure; + expect(new OAuthAuthorizationError("access_denied").errorCode).toBe("access_denied"); + expect( + new OAuthAuthorizationError("unknown", "text", { + reauthorizationRequired: "yes" as unknown as boolean, + }).reauthorizationRequired, + ).toBe(false); + }); + }); + describe("toggleEnabled", () => { it("should activate a server with CSRF token", async () => { const mockResponse = new Response( diff --git a/src/api/servers.ts b/src/api/servers.ts index 912cf4cb..05136c71 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -33,14 +33,20 @@ function providerErrorText(value: unknown): string | undefined { export class OAuthAuthorizationError extends Error { readonly code?: string; readonly description?: string; + readonly errorCode?: string; + readonly failedStage?: string; + readonly reauthorizationRequired: boolean; - constructor(code?: unknown, description?: unknown) { + constructor(code?: unknown, description?: unknown, details?: Partial) { const safeCode = providerErrorText(code); const safeDescription = providerErrorText(description); super(safeDescription || safeCode || "OAuth authorization failed"); this.name = "OAuthAuthorizationError"; this.code = safeCode; this.description = safeDescription; + this.errorCode = providerErrorText(details?.errorCode) ?? safeCode; + this.failedStage = providerErrorText(details?.failedStage); + this.reauthorizationRequired = details?.reauthorizationRequired === true; } } @@ -198,6 +204,21 @@ export const serversApi = { return api.post("/v1/mcp-servers/test-handshake", request, { signal }); }, + testOAuthConnection: ( + { gatewayId, baseUrl }: { gatewayId: string; baseUrl: string }, + signal?: AbortSignal, + ): Promise => { + return api.post( + "/v1/mcp-servers/test-handshake", + { + baseUrl, + gatewayId: validateServerId(gatewayId), + validationMode: "post_authorization", + } satisfies NonNullable, + { signal }, + ); + }, + /** * Toggle the enabled state of an MCP server (activate/deactivate) */ @@ -291,7 +312,7 @@ export const serversApi = { if (data.status === "success") { resolve(data); } else { - reject(new OAuthAuthorizationError(data.error, data.errorDescription)); + reject(new OAuthAuthorizationError(data.error, data.errorDescription, data)); } }; @@ -337,4 +358,7 @@ export interface OAuthCallbackResult { gatewayName?: string; error?: string; errorDescription?: string; + errorCode?: string; + failedStage?: string; + reauthorizationRequired?: boolean; } diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx index d272d164..cedb305e 100644 --- a/src/components/server-catalog/CatalogResults.tsx +++ b/src/components/server-catalog/CatalogResults.tsx @@ -118,6 +118,9 @@ function CatalogCard({ onView, onAdd, onTest, + canTestOAuthServer, + canTestOAuth = true, + renderOAuthValidation, onAuthorize, onDisconnect, isAdding, @@ -134,6 +137,9 @@ function CatalogCard({ onView: (trigger: HTMLElement) => void; onAdd: () => void; onTest: () => void; + canTestOAuthServer?: (server: CatalogServer) => boolean; + canTestOAuth?: boolean; + renderOAuthValidation?: (server: CatalogServer) => ReactNode; onAuthorize: () => void; onDisconnect: () => void; isAdding: boolean; @@ -279,19 +285,32 @@ function CatalogCard({ > {intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })} - {canTest && ( - - {intl.formatMessage({ id: "mcpServer.catalog.test" })} - - )} + {canTest && + (!isCatalogOAuthServer(server) || + oauthStatuses?.[server.gateway_id ?? ""]?.state === "not_applicable" || + canTestOAuth) && ( + + {intl.formatMessage({ id: "mcpServer.catalog.test" })} + + )} {oauthState.canAuthorize && server.gateway_id && ( )} + {renderOAuthValidation?.(server)} @@ -481,6 +501,9 @@ export function CatalogResults({ onAdd, addingServerIds, onTest, + canTestOAuthServer, + canTestOAuth = true, + renderOAuthValidation, onAuthorize, onDisconnect, testingServerIds = EMPTY_PENDING_IDS, @@ -498,6 +521,9 @@ export function CatalogResults({ onAdd: (server: CatalogServer) => void; addingServerIds: ReadonlySet; onTest: (server: CatalogServer) => void; + canTestOAuthServer?: (server: CatalogServer) => boolean; + canTestOAuth?: boolean; + renderOAuthValidation?: (server: CatalogServer) => ReactNode; onAuthorize: (server: CatalogServer) => void; onDisconnect: (server: CatalogServer) => void; testingServerIds?: ReadonlySet; @@ -529,6 +555,9 @@ export function CatalogResults({ onView={(trigger) => onView(server, trigger)} onAdd={() => onAdd(server)} onTest={() => onTest(server)} + canTestOAuthServer={canTestOAuthServer} + canTestOAuth={canTestOAuth} + renderOAuthValidation={renderOAuthValidation} onAuthorize={() => onAuthorize(server)} onDisconnect={() => onDisconnect(server)} isAdding={addingServerIds.has(server.id)} diff --git a/src/components/servers/MCPServerDetailsPanel.tsx b/src/components/servers/MCPServerDetailsPanel.tsx index 6ea21c38..8acbdbb0 100644 --- a/src/components/servers/MCPServerDetailsPanel.tsx +++ b/src/components/servers/MCPServerDetailsPanel.tsx @@ -142,6 +142,7 @@ export function MCPServerDetailsPanel({ onAuthorize, onRetryOAuthStatus, oauthAttempt, + oauthValidation, componentRefreshRevision = 0, componentRefreshError, isRefreshing = false, @@ -164,6 +165,7 @@ export function MCPServerDetailsPanel({ onAuthorize?: () => Promise; onRetryOAuthStatus?: () => void; oauthAttempt?: OAuthAttempt; + oauthValidation?: React.ReactNode; componentRefreshRevision?: number; componentRefreshError?: { reason?: string }; isRefreshing?: boolean; @@ -635,6 +637,7 @@ export function MCPServerDetailsPanel({ + {oauthValidation &&
{oauthValidation}
}
diff --git a/src/components/servers/OAuthConnectionValidationPanel.test.tsx b/src/components/servers/OAuthConnectionValidationPanel.test.tsx new file mode 100644 index 00000000..91f3be4c --- /dev/null +++ b/src/components/servers/OAuthConnectionValidationPanel.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render } from "@/test/test-utils"; +import { OAuthConnectionValidationPanel } from "./OAuthConnectionValidationPanel"; +import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation"; + +const props = { name: "Example", onTest: vi.fn(), onCancel: vi.fn(), onAuthorize: vi.fn() }; +const success: OAuthConnectionValidationState = { + state: "success", + testedAt: Date.now(), + result: { + success: true, + latencyMs: 2, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, + }, +}; + +describe("OAuthConnectionValidationPanel", () => { + it("waits for a click before testing", async () => { + render(); + expect(screen.queryByRole("status", { busy: true })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Test connection" })); + expect(props.onTest).toHaveBeenCalledOnce(); + }); + + it("renders zero tools, stage text, test time and Retest", () => { + render(); + expect(screen.getByText("0 tools")).toBeVisible(); + expect(screen.getByText("Token: Passed")).toBeVisible(); + expect(screen.getByText("MCP negotiation: Passed")).toBeVisible(); + expect(screen.getByText("Tools access: Passed")).toBeVisible(); + expect(screen.getByText(/Tested at/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Retest" })).toBeEnabled(); + }); + + it("preserves first-page lower bounds", () => { + render( + , + ); + expect(screen.getByText("2+ tools")).toBeVisible(); + }); + + it("announces a cancellable running test", async () => { + render(); + expect(screen.getByRole("status", { busy: true })).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(props.onCancel).toHaveBeenCalled(); + }); + + it.each(["token", "negotiation", "tools_list"] as const)( + "shows a failed %s stage and reauthorization", + async (stage) => { + render( + , + ); + expect(screen.getByText(/stored token lacks the required scope/)).toBeVisible(); + expect(screen.getByText(/: Failed$/)).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Authorize again" })); + expect(props.onAuthorize).toHaveBeenCalled(); + }, + ); + + it("uses generic guidance for unknown errors without displaying upstream secrets", () => { + render( + , + ); + expect(screen.getByText(/Check the configuration and try again/)).toBeVisible(); + expect(screen.queryByText(/unsafe token|secret-provider-code/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/servers/OAuthConnectionValidationPanel.tsx b/src/components/servers/OAuthConnectionValidationPanel.tsx new file mode 100644 index 00000000..b265d68f --- /dev/null +++ b/src/components/servers/OAuthConnectionValidationPanel.tsx @@ -0,0 +1,106 @@ +import { CheckCircle2, CircleMinus, CircleX, Loader2 } from "lucide-react"; +import { useIntl } from "react-intl"; +import { Button } from "@/components/ui/button"; +import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation"; +import { oauthErrorMessage, VALIDATION_STAGES } from "@/lib/oauthConnectionValidation"; + +export interface OAuthConnectionValidationPanelProps { + name: string; + entry?: OAuthConnectionValidationState; + onTest: () => void; + onCancel: () => void; + onAuthorize?: () => void; + actionsOnly?: boolean; + resultOnly?: boolean; +} + +export function OAuthConnectionValidationPanel({ + name, + entry, + onTest, + onCancel, + onAuthorize, + actionsOnly = false, + resultOnly = false, +}: OAuthConnectionValidationPanelProps) { + const intl = useIntl(); + const message = (key: string) => intl.formatMessage({ id: `mcpServer.oauthValidation.${key}` }); + const running = entry?.state === "running"; + const result = entry?.result; + return ( +
+ {!resultOnly && ( +
+ + {running && ( + + )} + {entry?.state === "failure" && result?.reauthorizationRequired && onAuthorize && ( + + )} +
+ )} + {!actionsOnly && entry && ( +
+

+ {message(running ? "testing" : entry.state === "success" ? "success" : "failure")} +

+ {result?.stages && ( +
    + {VALIDATION_STAGES.map((stage) => { + const status = result.stages?.[stage] ?? "skipped"; + const Icon = + status === "ok" ? CheckCircle2 : status === "failed" ? CircleX : CircleMinus; + return ( +
  • +
  • + ); + })} +
+ )} + {entry.state === "failure" &&

{oauthErrorMessage(intl, entry.errorCode)}

} + {entry.state === "success" && result?.componentCounts?.tools !== undefined && ( +

+ {intl.formatMessage( + { + id: `mcpServer.testConnection.${result.countsPartial ? "countsPartial" : "counts"}.tools`, + }, + { count: result.componentCounts.tools }, + )} +

+ )} + {entry.testedAt && ( +

+ {intl.formatMessage( + { id: "mcpServer.oauthValidation.testedAt" }, + { time: intl.formatTime(entry.testedAt) }, + )} +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/servers/ServersTable.tsx b/src/components/servers/ServersTable.tsx index af2bb999..de74e440 100644 --- a/src/components/servers/ServersTable.tsx +++ b/src/components/servers/ServersTable.tsx @@ -1,3 +1,4 @@ +import { Fragment, type ReactNode } from "react"; import { Building2, Lock, Users } from "lucide-react"; import { Table, @@ -47,6 +48,7 @@ function getVisibilityConfig(visibility: MCPServer["visibility"]) { } interface ServersTableProps { + renderOAuthValidation?: (server: MCPServer, variant: "actions" | "result") => ReactNode; servers: MCPServer[]; isLoading: boolean; onEdit?: (id: string) => void; @@ -63,6 +65,7 @@ interface ServersTableProps { export function ServersTable({ servers, + renderOAuthValidation, isLoading, onEdit, onDelete, @@ -132,84 +135,98 @@ export function ServersTable({ const VisibilityIcon = visibility.Icon; return ( - - -
- - {server.name} -
-
- -
- - {intl.formatMessage( - { id: "mcpServer.table.components.tools" }, - { count: toolCount }, - )} - - • - - {intl.formatMessage( - { id: "mcpServer.table.components.resources" }, - { count: resourcesCount }, - )} - - • - - {intl.formatMessage( - { id: "mcpServer.table.components.prompts" }, - { count: promptsCount }, - )} - -
-
- - {formatLocalDateTime(lastSeen, intl.formatMessage({ id: "mcpServer.neverUsed" }))} - - -
- {server.id} - + + +
+ + {server.name} +
+
+ +
+ + {intl.formatMessage( + { id: "mcpServer.table.components.tools" }, + { count: toolCount }, + )} + + • + + {intl.formatMessage( + { id: "mcpServer.table.components.resources" }, + { count: resourcesCount }, + )} + + • + + {intl.formatMessage( + { id: "mcpServer.table.components.prompts" }, + { count: promptsCount }, + )} + +
+
+ + {formatLocalDateTime( + lastSeen, + intl.formatMessage({ id: "mcpServer.neverUsed" }), + )} + + +
+ {server.id} + +
+
+ +
+
+
+ + onAuthorize(server.id))} + onRetry={onRetryOAuthStatus && (() => onRetryOAuthStatus(server.id))} + compact /> -
-
- -
-
-
- - onAuthorize(server.id))} - onRetry={onRetryOAuthStatus && (() => onRetryOAuthStatus(server.id))} - compact - /> - - - - -
+ {renderOAuthValidation?.(server, "actions")} + + + + + + {renderOAuthValidation?.(server, "result") && ( + + + {renderOAuthValidation(server, "result")} + + + )} + ); })} diff --git a/src/hooks/useOAuthConnectionValidation.test.ts b/src/hooks/useOAuthConnectionValidation.test.ts new file mode 100644 index 00000000..11ebe420 --- /dev/null +++ b/src/hooks/useOAuthConnectionValidation.test.ts @@ -0,0 +1,174 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; +import type { GatewayHandshakeResponse } from "@/generated/types"; +import { useOAuthConnectionValidation } from "./useOAuthConnectionValidation"; + +vi.mock("@/api/servers", () => ({ serversApi: { testOAuthConnection: vi.fn() } })); +const success: GatewayHandshakeResponse = { + success: true, + latencyMs: 5, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, +}; +const target = { gatewayId: "gw-1", baseUrl: "https://saved.example/mcp" }; +const mockTest = vi.mocked(serversApi.testOAuthConnection); + +beforeEach(() => { + mockTest.mockReset(); +}); + +describe("useOAuthConnectionValidation", () => { + it("only runs on demand and accepts zero tools without any mutation calls", async () => { + mockTest.mockResolvedValue(success); + const { result } = renderHook(() => useOAuthConnectionValidation()); + expect(mockTest).not.toHaveBeenCalled(); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledExactlyOnceWith(target, expect.any(AbortSignal)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "success", + result: success, + testedAt: expect.any(Number), + }); + }); + + it.each([ + { success: true, latencyMs: 1 }, + { ...success, stages: { token: "ok", negotiation: "ok" } }, + null, + ] as GatewayHandshakeResponse[])( + "rejects responses without complete stage evidence: %j", + async (response) => { + mockTest.mockResolvedValue(response); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + errorCode: "incompatible", + }); + }, + ); + + it.each(["failed", "skipped", "unsupported"] as const)( + "does not report success for a %s listing", + async (status) => { + mockTest.mockResolvedValue({ + ...success!, + stages: { token: "ok", negotiation: "ok", tools_list: status }, + }); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"].state).toBe("failure"); + }, + ); + + it("preserves HTTP-200 validation failures and reauthorization guidance", async () => { + const response: GatewayHandshakeResponse = { + ...success!, + success: false, + stages: { token: "ok", negotiation: "ok", tools_list: "failed" }, + errorCode: "insufficient_scope", + failedStage: "tools_list", + reauthorizationRequired: true, + }; + mockTest.mockResolvedValue(response); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + result: response, + errorCode: "insufficient_scope", + }); + }); + + it.each([ + [403, "permission_denied"], + [422, "request_invalid"], + [429, "rate_limited"], + [500, "generic"], + ])("maps HTTP %s without automatic retry", async (status, code) => { + mockTest.mockRejectedValue(new ApiError(status as number, null, "unsafe upstream body")); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ state: "failure", errorCode: code }); + expect(mockTest).toHaveBeenCalledTimes(1); + }); + + it("deduplicates, cancels and ignores late responses after a replacement test", async () => { + let finish!: (response: GatewayHandshakeResponse) => void; + mockTest.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useOAuthConnectionValidation()); + let first!: Promise; + act(() => { + first = result.current.test("gw-1", target); + }); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledTimes(1); + const signal = mockTest.mock.calls[0][1]!; + act(() => result.current.reset("gw-1")); + expect(signal.aborted).toBe(true); + mockTest.mockResolvedValue(success); + await act(() => result.current.test("gw-1", target)); + await act(async () => { + finish(null); + await first; + }); + expect(result.current.entries["gw-1"].state).toBe("success"); + }); + + it("resets on configuration change and aborts on permission loss and unmount", async () => { + mockTest.mockImplementation(() => new Promise(() => {})); + const { result, rerender, unmount } = renderHook( + ({ enabled, key }) => useOAuthConnectionValidation(enabled, { "gw-1": key }), + { initialProps: { enabled: true, key: "old" } }, + ); + act(() => { + void result.current.test("gw-1", target); + }); + rerender({ enabled: true, key: "new" }); + expect(mockTest.mock.calls[0][1]?.aborted).toBe(true); + expect(result.current.entries).toEqual({}); + act(() => { + void result.current.test("gw-1", target); + }); + rerender({ enabled: false, key: "new" }); + expect(mockTest.mock.calls[1][1]?.aborted).toBe(true); + expect(result.current.entries).toEqual({}); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledTimes(2); + rerender({ enabled: true, key: "new" }); + act(() => { + void result.current.test("gw-1", target); + }); + unmount(); + expect(mockTest.mock.calls[2][1]?.aborted).toBe(true); + }); + + it("cancels while resolving the saved gateway before any handshake request", async () => { + let finish!: (value: typeof target) => void; + const resolver = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useOAuthConnectionValidation()); + let request!: Promise; + act(() => { + request = result.current.test("gw-1", resolver); + }); + act(() => result.current.reset("gw-1")); + await act(async () => { + finish(target); + await request; + }); + expect(mockTest).not.toHaveBeenCalled(); + expect(result.current.entries).toEqual({}); + }); +}); diff --git a/src/hooks/useOAuthConnectionValidation.ts b/src/hooks/useOAuthConnectionValidation.ts new file mode 100644 index 00000000..f230ec1b --- /dev/null +++ b/src/hooks/useOAuthConnectionValidation.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; +import type { GatewayHandshakeResponse } from "@/generated/types"; +import { hasValidationStages, validationSucceeded } from "@/lib/oauthConnectionValidation"; + +export interface OAuthConnectionValidationState { + state: "running" | "success" | "failure"; + result?: GatewayHandshakeResponse; + errorCode?: string; + testedAt?: number; +} + +type Target = { gatewayId: string; baseUrl: string }; + +export function useOAuthConnectionValidation(enabled = true, targets?: Record) { + const [entries, setEntries] = useState>({}); + const requests = useRef(new Map()); + + const reset = useCallback((id: string) => { + requests.current.get(id)?.abort(); + requests.current.delete(id); + setEntries((previous) => { + if (!previous[id]) return previous; + const next = { ...previous }; + delete next[id]; + return next; + }); + }, []); + + const previousTargets = useRef>({}); + const targetsKey = JSON.stringify(targets); + useEffect(() => { + if (!targetsKey) return; + const current = JSON.parse(targetsKey) as Record; + Object.entries(previousTargets.current).forEach(([id, key]) => { + if (current[id] !== key) reset(id); + }); + previousTargets.current = current; + }, [targetsKey, reset]); + + const abortAll = useCallback(() => { + requests.current.forEach((controller) => controller.abort()); + requests.current.clear(); + }, []); + + useEffect(() => abortAll, [abortAll]); + useEffect(() => { + if (!enabled) { + abortAll(); + setEntries({}); + } + }, [enabled, abortAll]); + + const test = useCallback( + async (id: string, target: Target | ((signal: AbortSignal) => Promise)) => { + if (!enabled || requests.current.has(id)) return; + const controller = new AbortController(); + requests.current.set(id, controller); + const isCurrent = () => !controller.signal.aborted && requests.current.get(id) === controller; + setEntries((previous) => ({ ...previous, [id]: { state: "running" } })); + try { + const resolved = typeof target === "function" ? await target(controller.signal) : target; + if (!isCurrent()) return; + if (resolved.gatewayId !== id) throw new Error("Gateway mismatch"); + const result = await serversApi.testOAuthConnection(resolved, controller.signal); + if (!isCurrent()) return; + setEntries((previous) => ({ + ...previous, + [id]: { + state: validationSucceeded(result) ? "success" : "failure", + result, + errorCode: hasValidationStages(result) + ? (result?.errorCode ?? undefined) + : "incompatible", + testedAt: Date.now(), + }, + })); + } catch (error) { + if (!isCurrent()) return; + const errorCode = + error instanceof ApiError + ? (( + { + 401: "unauthenticated", + 403: "permission_denied", + 422: "request_invalid", + 429: "rate_limited", + 404: "gateway_unavailable", + } as Record + )[error.status] ?? "generic") + : "network"; + setEntries((previous) => ({ + ...previous, + [id]: { state: "failure", errorCode, testedAt: Date.now() }, + })); + } finally { + if (requests.current.get(id) === controller) requests.current.delete(id); + } + }, + [enabled], + ); + + return { entries, test, reset }; +} diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 917146c3..772fba95 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -389,5 +389,52 @@ "mcpServer.components.refreshing": "Refreshing components…", "mcpServer.components.discoveryError": "Components could not be refreshed.", "mcpServer.components.retryRefresh": "Retry refresh", - "mcpServer.components.readError": "Tools, resources, or prompts could not be loaded." + "mcpServer.components.readError": "Tools, resources, or prompts could not be loaded.", + "mcpServer.oauthValidation.label": "OAuth connection test for {name}", + "mcpServer.oauthValidation.test": "Test connection", + "mcpServer.oauthValidation.retest": "Retest", + "mcpServer.oauthValidation.cancel": "Cancel", + "mcpServer.oauthValidation.authorize": "Authorize again", + "mcpServer.oauthValidation.testing": "Testing stored authorization…", + "mcpServer.oauthValidation.success": "Stored authorization verified", + "mcpServer.oauthValidation.failure": "Connection validation failed", + "mcpServer.oauthValidation.testedAt": "Tested at {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "MCP negotiation", + "mcpServer.oauthValidation.stage.tools_list": "Tools access", + "mcpServer.oauthValidation.outcome.ok": "Passed", + "mcpServer.oauthValidation.outcome.failed": "Failed", + "mcpServer.oauthValidation.outcome.skipped": "Skipped", + "mcpServer.oauthValidation.outcome.unsupported": "Unsupported", + "mcpServer.oauthValidation.error.generic": "OAuth connection failed. Check the configuration and try again.", + "mcpServer.oauthValidation.error.invalid_scope": "The provider rejected the requested scopes. Review the scopes and authorize again.", + "mcpServer.oauthValidation.error.insufficient_scope": "The stored token lacks the required scope. Review the scopes and authorize again.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "The provider rejected the redirect URI. Check the registered callback URL.", + "mcpServer.oauthValidation.error.token_url_unreachable": "The token endpoint is unreachable. Check its URL and network access.", + "mcpServer.oauthValidation.error.authorization_missing": "Authorize this server before testing the connection.", + "mcpServer.oauthValidation.error.token_expired": "The stored token has expired. Authorize this server again.", + "mcpServer.oauthValidation.error.invalid_token": "The stored token is invalid. Authorize this server again.", + "mcpServer.oauthValidation.error.invalid_client": "The provider rejected the OAuth client. Check the client configuration.", + "mcpServer.oauthValidation.error.invalid_request": "The provider rejected the authorization request. Check the OAuth configuration.", + "mcpServer.oauthValidation.error.unauthorized_client": "This client cannot use the requested authorization method. Check the provider configuration.", + "mcpServer.oauthValidation.error.unsupported_response_type": "The provider does not support this response type. Check the OAuth configuration.", + "mcpServer.oauthValidation.error.server_error": "The authorization server encountered an error. Try again later.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "The authorization server is temporarily unavailable. Try again later.", + "mcpServer.oauthValidation.error.token_exchange_failed": "OAuth token exchange failed. Check the configuration and authorize again.", + "mcpServer.oauthValidation.error.access_denied": "The provider denied authorization. Authorize this server again.", + "mcpServer.oauthValidation.error.issuer_mismatch": "The authorization response issuer does not match. Check the issuer configuration.", + "mcpServer.oauthValidation.error.gateway_unavailable": "The saved server is unavailable. Reload the page and check its status.", + "mcpServer.oauthValidation.error.permission_denied": "You need server read and update permissions to validate this connection.", + "mcpServer.oauthValidation.error.unsupported_auth": "This test requires an OAuth authorization-code server.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "Stored authorization cannot be read. Try again later.", + "mcpServer.oauthValidation.error.tools_unsupported": "This server does not advertise tools, so tools access cannot be validated.", + "mcpServer.oauthValidation.error.tools_list_failed": "The tools request failed. Check upstream access and protocol support.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "MCP negotiation failed. Check the server transport and configuration.", + "mcpServer.oauthValidation.error.timeout": "Validation exceeded its ten-second time limit. Try again.", + "mcpServer.oauthValidation.error.blocked": "The target is blocked by the connection-test policy. Check the server URL.", + "mcpServer.oauthValidation.error.incompatible": "This backend did not return post-authorization validation stages. Update the backend before testing again.", + "mcpServer.oauthValidation.error.request_invalid": "The saved server is not eligible for this test or the request is invalid. Reload and check its configuration.", + "mcpServer.oauthValidation.error.rate_limited": "Too many connection tests. Wait before testing again.", + "mcpServer.oauthValidation.error.network": "The connection test could not reach the backend. Check your connection and try again.", + "mcpServer.oauthValidation.error.unauthenticated": "Your session has expired. Sign in again." } diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 7fe16912..6a3fe0ee 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -389,5 +389,52 @@ "mcpServer.components.refreshing": "Actualizando componentes…", "mcpServer.components.discoveryError": "No se pudieron actualizar los componentes.", "mcpServer.components.retryRefresh": "Reintentar actualización", - "mcpServer.components.readError": "No se pudieron cargar las herramientas, los recursos o los prompts." + "mcpServer.components.readError": "No se pudieron cargar las herramientas, los recursos o los prompts.", + "mcpServer.oauthValidation.label": "Prueba de conexión OAuth para {name}", + "mcpServer.oauthValidation.test": "Probar conexión", + "mcpServer.oauthValidation.retest": "Volver a probar", + "mcpServer.oauthValidation.cancel": "Cancelar", + "mcpServer.oauthValidation.authorize": "Autorizar de nuevo", + "mcpServer.oauthValidation.testing": "Probando la autorización guardada…", + "mcpServer.oauthValidation.success": "Autorización guardada verificada", + "mcpServer.oauthValidation.failure": "Falló la validación de conexión", + "mcpServer.oauthValidation.testedAt": "Probado a las {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "Negociación MCP", + "mcpServer.oauthValidation.stage.tools_list": "Acceso a herramientas", + "mcpServer.oauthValidation.outcome.ok": "Correcto", + "mcpServer.oauthValidation.outcome.failed": "Falló", + "mcpServer.oauthValidation.outcome.skipped": "Omitido", + "mcpServer.oauthValidation.outcome.unsupported": "No compatible", + "mcpServer.oauthValidation.error.generic": "Falló la conexión OAuth. Revisa la configuración e inténtalo de nuevo.", + "mcpServer.oauthValidation.error.invalid_scope": "El proveedor rechazó los permisos solicitados. Revísalos y autoriza de nuevo.", + "mcpServer.oauthValidation.error.insufficient_scope": "El token guardado no tiene los permisos necesarios. Revísalos y autoriza de nuevo.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "El proveedor rechazó la URI de redirección. Revisa la URL de retorno registrada.", + "mcpServer.oauthValidation.error.token_url_unreachable": "No se puede acceder al endpoint de tokens. Revisa su URL y el acceso a la red.", + "mcpServer.oauthValidation.error.authorization_missing": "Autoriza este servidor antes de probar la conexión.", + "mcpServer.oauthValidation.error.token_expired": "El token guardado ha caducado. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.invalid_token": "El token guardado no es válido. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.invalid_client": "El proveedor rechazó el cliente OAuth. Revisa su configuración.", + "mcpServer.oauthValidation.error.invalid_request": "El proveedor rechazó la solicitud de autorización. Revisa la configuración OAuth.", + "mcpServer.oauthValidation.error.unauthorized_client": "Este cliente no puede usar el método de autorización solicitado. Revisa la configuración del proveedor.", + "mcpServer.oauthValidation.error.unsupported_response_type": "El proveedor no admite este tipo de respuesta. Revisa la configuración OAuth.", + "mcpServer.oauthValidation.error.server_error": "El servidor de autorización encontró un error. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "El servidor de autorización no está disponible temporalmente. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.token_exchange_failed": "Falló el intercambio de tokens OAuth. Revisa la configuración y autoriza de nuevo.", + "mcpServer.oauthValidation.error.access_denied": "El proveedor denegó la autorización. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.issuer_mismatch": "El emisor de la respuesta de autorización no coincide. Revisa su configuración.", + "mcpServer.oauthValidation.error.gateway_unavailable": "El servidor guardado no está disponible. Recarga la página y revisa su estado.", + "mcpServer.oauthValidation.error.permission_denied": "Necesitas permisos de lectura y actualización del servidor para validar esta conexión.", + "mcpServer.oauthValidation.error.unsupported_auth": "Esta prueba requiere un servidor OAuth con código de autorización.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "No se puede leer la autorización guardada. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.tools_unsupported": "Este servidor no anuncia herramientas, por lo que no se puede validar su acceso.", + "mcpServer.oauthValidation.error.tools_list_failed": "Falló la solicitud de herramientas. Revisa el acceso al servidor y la compatibilidad del protocolo.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "Falló la negociación MCP. Revisa el transporte y la configuración del servidor.", + "mcpServer.oauthValidation.error.timeout": "La validación superó el límite de diez segundos. Inténtalo de nuevo.", + "mcpServer.oauthValidation.error.blocked": "La política de pruebas de conexión bloquea el destino. Revisa la URL del servidor.", + "mcpServer.oauthValidation.error.incompatible": "Este backend no devolvió las etapas de validación posteriores a la autorización. Actualízalo antes de volver a probar.", + "mcpServer.oauthValidation.error.request_invalid": "El servidor guardado no es apto para esta prueba o la solicitud no es válida. Recarga y revisa su configuración.", + "mcpServer.oauthValidation.error.rate_limited": "Demasiadas pruebas de conexión. Espera antes de volver a probar.", + "mcpServer.oauthValidation.error.network": "La prueba no pudo acceder al backend. Revisa tu conexión e inténtalo de nuevo.", + "mcpServer.oauthValidation.error.unauthenticated": "Tu sesión ha caducado. Inicia sesión de nuevo." } diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index aa8e5e26..f9c79a32 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -389,5 +389,52 @@ "mcpServer.components.refreshing": "Atualizando componentes…", "mcpServer.components.discoveryError": "Não foi possível atualizar os componentes.", "mcpServer.components.retryRefresh": "Tentar atualizar novamente", - "mcpServer.components.readError": "Não foi possível carregar as ferramentas, os recursos ou os prompts." + "mcpServer.components.readError": "Não foi possível carregar as ferramentas, os recursos ou os prompts.", + "mcpServer.oauthValidation.label": "Teste de conexão OAuth para {name}", + "mcpServer.oauthValidation.test": "Testar conexão", + "mcpServer.oauthValidation.retest": "Testar novamente", + "mcpServer.oauthValidation.cancel": "Cancelar", + "mcpServer.oauthValidation.authorize": "Autorizar novamente", + "mcpServer.oauthValidation.testing": "Testando a autorização armazenada…", + "mcpServer.oauthValidation.success": "Autorização armazenada verificada", + "mcpServer.oauthValidation.failure": "Falha na validação da conexão", + "mcpServer.oauthValidation.testedAt": "Testado às {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "Negociação MCP", + "mcpServer.oauthValidation.stage.tools_list": "Acesso às ferramentas", + "mcpServer.oauthValidation.outcome.ok": "Aprovado", + "mcpServer.oauthValidation.outcome.failed": "Falhou", + "mcpServer.oauthValidation.outcome.skipped": "Ignorado", + "mcpServer.oauthValidation.outcome.unsupported": "Não compatível", + "mcpServer.oauthValidation.error.generic": "Falha na conexão OAuth. Verifique a configuração e tente novamente.", + "mcpServer.oauthValidation.error.invalid_scope": "O provedor rejeitou os escopos solicitados. Revise os escopos e autorize novamente.", + "mcpServer.oauthValidation.error.insufficient_scope": "O token armazenado não tem o escopo necessário. Revise os escopos e autorize novamente.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "O provedor rejeitou a URI de redirecionamento. Verifique a URL de retorno registrada.", + "mcpServer.oauthValidation.error.token_url_unreachable": "O endpoint de tokens está inacessível. Verifique a URL e o acesso à rede.", + "mcpServer.oauthValidation.error.authorization_missing": "Autorize este servidor antes de testar a conexão.", + "mcpServer.oauthValidation.error.token_expired": "O token armazenado expirou. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.invalid_token": "O token armazenado é inválido. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.invalid_client": "O provedor rejeitou o cliente OAuth. Verifique a configuração do cliente.", + "mcpServer.oauthValidation.error.invalid_request": "O provedor rejeitou a solicitação de autorização. Verifique a configuração OAuth.", + "mcpServer.oauthValidation.error.unauthorized_client": "Este cliente não pode usar o método de autorização solicitado. Verifique a configuração do provedor.", + "mcpServer.oauthValidation.error.unsupported_response_type": "O provedor não oferece suporte a este tipo de resposta. Verifique a configuração OAuth.", + "mcpServer.oauthValidation.error.server_error": "O servidor de autorização encontrou um erro. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "O servidor de autorização está temporariamente indisponível. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.token_exchange_failed": "Falha na troca de tokens OAuth. Verifique a configuração e autorize novamente.", + "mcpServer.oauthValidation.error.access_denied": "O provedor negou a autorização. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.issuer_mismatch": "O emissor da resposta de autorização não corresponde. Verifique a configuração do emissor.", + "mcpServer.oauthValidation.error.gateway_unavailable": "O servidor salvo está indisponível. Recarregue a página e verifique o status.", + "mcpServer.oauthValidation.error.permission_denied": "Você precisa de permissões de leitura e atualização do servidor para validar esta conexão.", + "mcpServer.oauthValidation.error.unsupported_auth": "Este teste requer um servidor OAuth com código de autorização.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "Não foi possível ler a autorização armazenada. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.tools_unsupported": "Este servidor não anuncia ferramentas, portanto o acesso não pode ser validado.", + "mcpServer.oauthValidation.error.tools_list_failed": "Falha na solicitação de ferramentas. Verifique o acesso ao servidor e o suporte ao protocolo.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "Falha na negociação MCP. Verifique o transporte e a configuração do servidor.", + "mcpServer.oauthValidation.error.timeout": "A validação excedeu o limite de dez segundos. Tente novamente.", + "mcpServer.oauthValidation.error.blocked": "O destino está bloqueado pela política de testes de conexão. Verifique a URL do servidor.", + "mcpServer.oauthValidation.error.incompatible": "Este backend não retornou as etapas de validação após a autorização. Atualize o backend antes de testar novamente.", + "mcpServer.oauthValidation.error.request_invalid": "O servidor salvo não é elegível para este teste ou a solicitação é inválida. Recarregue e verifique a configuração.", + "mcpServer.oauthValidation.error.rate_limited": "Muitos testes de conexão. Aguarde antes de testar novamente.", + "mcpServer.oauthValidation.error.network": "O teste não conseguiu acessar o backend. Verifique sua conexão e tente novamente.", + "mcpServer.oauthValidation.error.unauthenticated": "Sua sessão expirou. Entre novamente." } diff --git a/src/lib/oauthConnectionValidation.ts b/src/lib/oauthConnectionValidation.ts new file mode 100644 index 00000000..25a87a06 --- /dev/null +++ b/src/lib/oauthConnectionValidation.ts @@ -0,0 +1,77 @@ +import type { IntlShape } from "react-intl"; +import type { OAuthStatusEntry } from "@/api/oauth"; +import type { MCPServer } from "@/types/server"; +import type { GatewayHandshakeResponse } from "@/generated/types"; + +export const VALIDATION_STAGES = ["token", "negotiation", "tools_list"] as const; + +const ERROR_CODES = new Set([ + "invalid_scope", + "insufficient_scope", + "redirect_uri_mismatch", + "token_url_unreachable", + "authorization_missing", + "token_expired", + "invalid_token", + "invalid_client", + "invalid_request", + "unauthorized_client", + "unsupported_response_type", + "server_error", + "temporarily_unavailable", + "token_exchange_failed", + "access_denied", + "issuer_mismatch", + "gateway_unavailable", + "permission_denied", + "unsupported_auth", + "token_storage_unavailable", + "tools_unsupported", + "tools_list_failed", + "mcp_handshake_failed", + "timeout", + "blocked", + "incompatible", + "request_invalid", + "rate_limited", + "network", + "unauthenticated", +]); + +export function oauthErrorMessage(intl: IntlShape, code?: string): string { + return intl.formatMessage({ + id: `mcpServer.oauthValidation.error.${code && ERROR_CODES.has(code) ? code : "generic"}`, + }); +} + +export function hasValidationStages(result: GatewayHandshakeResponse): boolean { + return VALIDATION_STAGES.every((stage) => + ["ok", "failed", "skipped", "unsupported"].includes(result?.stages?.[stage] ?? ""), + ); +} + +export function validationSucceeded(result: GatewayHandshakeResponse): boolean { + return ( + result?.success === true && VALIDATION_STAGES.every((stage) => result.stages?.[stage] === "ok") + ); +} + +export function canValidateOAuthConnection(server: MCPServer, status?: OAuthStatusEntry): boolean { + return ( + server.enabled && + server.authType?.toLowerCase() === "oauth" && + server.oauthConfig?.grant_type === "authorization_code" && + status?.state === "ready" && + (status.tokenStatus === "valid" || status.tokenStatus === "near_expiry") + ); +} + +export function oauthConfigurationKey(server: MCPServer): string { + return JSON.stringify([ + server.url, + server.transport, + server.enabled, + server.authType, + server.oauthConfig, + ]); +} diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx index 17eafa84..aec75d69 100644 --- a/src/pages/ServerCatalog.test.tsx +++ b/src/pages/ServerCatalog.test.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -9,7 +9,7 @@ import { registerCatalogServer, testCatalogServer, } from "@/api/catalog"; -import { ApiError } from "@/api/client"; +import { api, ApiError } from "@/api/client"; import { getOAuthStatuses } from "@/api/oauth"; import { OAuthCancelledError, serversApi } from "@/api/servers"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -53,7 +53,8 @@ vi.mock("@/api/oauth", async (importOriginal) => ({ ...(await importOriginal()), getOAuthStatuses: vi.fn(), })); -vi.mock("@/api/servers", () => ({ +vi.mock("@/api/servers", async (importOriginal) => ({ + ...(await importOriginal()), // Real error classes so `instanceof` checks in the page keep working // against rejections created from the mocked module surface. OAuthCancelledError: class OAuthCancelledError extends Error { @@ -63,6 +64,7 @@ vi.mock("@/api/servers", () => ({ } }, serversApi: { + testOAuthConnection: vi.fn(), openOAuthAuthorizationPopup: vi.fn(), triggerOAuthAuthorization: vi.fn(), toggleEnabled: vi.fn(), @@ -196,6 +198,7 @@ function renderWithRouter(ui: ReactElement, path = "/app/server-catalog") { } describe("ServerCatalog", () => { + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { window.history.replaceState({}, "", "/app/"); mockUseQuery.mockImplementation((path) => @@ -235,6 +238,72 @@ describe("ServerCatalog", () => { mockFetchToolsAfterOAuth.mockResolvedValue({ success: true, message: "Tools fetched" }); }); + it("validates the current saved OAuth gateway URL on explicit Test only", async () => { + const user = userEvent.setup(); + const catalogServer = { + ...oauthServer, + is_registered: true, + gateway_id: "gateway-github", + requires_oauth_config: false, + }; + mockUseQuery.mockReturnValue( + queryResult({ data: { ...response, servers: [catalogServer], total: 1 } }), + ); + mockGetOAuthStatuses.mockResolvedValue({ + statuses: { + "gateway-github": { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: "valid", authorized: true }, + }, + }, + failures: {}, + }); + const gateway = { + id: "gateway-github", + name: "GitHub", + url: "https://saved.example/mcp", + enabled: true, + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + }; + const get = vi.spyOn(api, "get").mockResolvedValue(gateway); + const testConnection = vi.spyOn(serversApi, "testOAuthConnection"); + testConnection.mockResolvedValue({ + success: true, + latencyMs: 1, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, + }); + renderWithRouter(); + const button = await screen.findByRole("button", { name: "Test connection" }); + expect(testConnection).not.toHaveBeenCalled(); + await user.click(button); + expect(await screen.findByText("Stored authorization verified")).toBeVisible(); + expect(get).toHaveBeenCalledTimes(2); + expect(testConnection).toHaveBeenCalledExactlyOnceWith( + { gatewayId: "gateway-github", baseUrl: gateway.url }, + expect.any(AbortSignal), + ); + expect(mockFetchToolsAfterOAuth).not.toHaveBeenCalled(); + expect(mockToggleEnabled).not.toHaveBeenCalled(); + expect(mockRegisterCatalogServer).not.toHaveBeenCalled(); + expect(mockTestCatalogServer).not.toHaveBeenCalled(); + }); + + it("does not expose OAuth Test to read-only callers", async () => { + const user = userEvent.setup(); + const catalogServer = { ...oauthServer, is_registered: true, gateway_id: "gateway-github" }; + mockUseQuery.mockReturnValue( + queryResult({ data: { ...response, servers: [catalogServer], total: 1 } }), + ); + authState.hasPermission.mockImplementation((permission) => permission === "gateways.read"); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Actions for GitHub" })); + expect(screen.queryByRole("menuitem", { name: "Test connection" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Test connection" })).not.toBeInTheDocument(); + }); + it("uses the catalog GET endpoint and shared loader", () => { mockUseQuery.mockReturnValue(queryResult({ data: undefined, isLoading: true })); diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx index 6e8c4a22..4e4c92d2 100644 --- a/src/pages/ServerCatalog.tsx +++ b/src/pages/ServerCatalog.tsx @@ -9,8 +9,8 @@ import { testCatalogServer, type GatewayImpactPreview, } from "@/api/catalog"; -import { ApiError } from "@/api/client"; -import { OAuthCancelledError, serversApi } from "@/api/servers"; +import { api, ApiError } from "@/api/client"; +import { OAuthAuthorizationError, OAuthCancelledError, serversApi } from "@/api/servers"; import { useAuth } from "@/auth/useAuth"; import { CatalogApiKeyDialog } from "@/components/server-catalog/CatalogApiKeyDialog"; import { CatalogOAuthForm } from "@/components/server-catalog/CatalogOAuthForm"; @@ -33,6 +33,14 @@ import type { CatalogServerRegisterBody, } from "@/generated/types"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import type { MCPServer } from "@/types/server"; +import { useOAuthConnectionValidation } from "@/hooks/useOAuthConnectionValidation"; +import { OAuthConnectionValidationPanel } from "@/components/servers/OAuthConnectionValidationPanel"; +import { + canValidateOAuthConnection, + oauthConfigurationKey, + oauthErrorMessage, +} from "@/lib/oauthConnectionValidation"; import { useOAuthStatuses } from "@/hooks/useOAuthStatuses"; import { useQuery } from "@/hooks/useQuery"; import { useRouter } from "@/router"; @@ -419,6 +427,49 @@ export function ServerCatalog() { reload: reloadOAuthStatuses, retry: retryOAuthStatus, } = useOAuthStatuses(oauthGatewayIds, { enabled: canReadOAuthStatuses }); + const [savedOAuthGateways, setSavedOAuthGateways] = useState>({}); + const oauthGatewayIdsKey = JSON.stringify(oauthGatewayIds); + useEffect(() => { + if (!canReadOAuthStatuses) { + setSavedOAuthGateways({}); + return; + } + const controller = new AbortController(); + const ids = JSON.parse(oauthGatewayIdsKey) as string[]; + setSavedOAuthGateways((previous) => + Object.fromEntries(Object.entries(previous).filter(([id]) => ids.includes(id))), + ); + ids.forEach((id) => { + void api + .get(`/v1/mcp-servers/${encodeURIComponent(id)}`, undefined, controller.signal) + .then((gateway) => { + if (!controller.signal.aborted) + setSavedOAuthGateways((previous) => ({ ...previous, [id]: gateway })); + }) + .catch(() => undefined); + }); + return () => controller.abort(); + }, [oauthGatewayIdsKey, canReadOAuthStatuses]); + const validationTargets = Object.fromEntries( + oauthGatewayIds.map((id) => [ + id, + savedOAuthGateways[id] ? oauthConfigurationKey(savedOAuthGateways[id]) : id, + ]), + ); + const { + entries: validationEntries, + test: testOAuthConnection, + reset: resetValidation, + } = useOAuthConnectionValidation(canReadOAuthStatuses && canUpdateServer, validationTargets); + const canTestOAuthServer = (server: CatalogServer) => { + const gateway = server.gateway_id ? savedOAuthGateways[server.gateway_id] : undefined; + return ( + canReadOAuthStatuses && + canUpdateServer && + !!gateway && + canValidateOAuthConnection(gateway, oauthStatuses[gateway.id]) + ); + }; const canTest = canReadOAuthStatuses; const canDisconnect = !permissionsLoading && hasPermission("gateways.delete"); const { filters, updateQuery, toggleFilterOption, clearFilterSection, clearAllFilters } = @@ -505,12 +556,18 @@ export function ServerCatalog() { const refreshGatewayData = useCallback( async (gatewayId: string, server: Pick) => { + const gatewayRefresh = api + .get(`/v1/mcp-servers/${encodeURIComponent(gatewayId)}`) + .then((gateway) => + setSavedOAuthGateways((previous) => ({ ...previous, [gatewayId]: gateway })), + ); const componentRefreshPromise = canUpdateServer ? serversApi.fetchToolsAfterOAuth(gatewayId) : Promise.resolve(undefined); const [, componentRefresh] = await Promise.allSettled([ refreshOAuthStatus(gatewayId), componentRefreshPromise, + gatewayRefresh, ]); if (componentRefresh.status === "rejected") { showRegistrationNotification({ @@ -729,9 +786,11 @@ export function ServerCatalog() { id: `oauth:${oauthServer.id}`, type: "info", message: - error instanceof Error - ? error.message - : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), + error instanceof OAuthAuthorizationError + ? oauthErrorMessage(intl, error.errorCode) + : error instanceof Error + ? error.message + : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), }); return false; } @@ -756,6 +815,7 @@ export function ServerCatalog() { setOAuthAuthorizing(true); setOAuthFormNotification(undefined); try { + resetValidation(gatewayId); await serversApi.triggerOAuthAuthorization(gatewayId, authWindow); if (canUpdateServer) await serversApi.toggleEnabled(gatewayId, true); await refreshGatewayData(gatewayId, oauthServer); @@ -773,9 +833,11 @@ export function ServerCatalog() { id: `oauth:${oauthServer.id}`, type: "info", message: - error instanceof Error - ? error.message - : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), + error instanceof OAuthAuthorizationError + ? oauthErrorMessage(intl, error.errorCode) + : error instanceof Error + ? error.message + : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), }); return false; } finally { @@ -787,6 +849,7 @@ export function ServerCatalog() { canUpdateServer, oauthServer, pendingOAuthGatewayId, + resetValidation, refreshGatewayData, refreshCatalogSilently, registerServer, @@ -799,6 +862,7 @@ export function ServerCatalog() { if (!server.gateway_id || !beginAdding(server.id)) return; dismissRegistrationNotification(`oauth:${server.id}`); try { + resetValidation(server.gateway_id); await serversApi.triggerOAuthAuthorization(server.gateway_id); if (canUpdateServer) await serversApi.toggleEnabled(server.gateway_id, true); await refreshGatewayData(server.gateway_id, server); @@ -814,9 +878,11 @@ export function ServerCatalog() { id: `oauth:${server.id}`, type: "info", message: - error instanceof Error - ? error.message - : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), + error instanceof OAuthAuthorizationError + ? oauthErrorMessage(intl, error.errorCode) + : error instanceof Error + ? error.message + : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), }); } finally { endAdding(server.id); @@ -824,6 +890,7 @@ export function ServerCatalog() { }, [ beginAdding, + resetValidation, canUpdateServer, dismissRegistrationNotification, endAdding, @@ -835,52 +902,50 @@ export function ServerCatalog() { ], ); - const handleTest = useCallback( - async (server: CatalogServer) => { - if ( - server.requires_oauth_config || - isAdding(server.id) || - isDisconnecting(server.id) || - !beginTesting(server.id) - ) { - return; - } + const handleTest = async (server: CatalogServer) => { + if ( + !canTest || + server.requires_oauth_config || + isAdding(server.id) || + isDisconnecting(server.id) || + !beginTesting(server.id) + ) { + return; + } - dismissRegistrationNotification(`test:${server.id}`); + dismissRegistrationNotification(`test:${server.id}`); - try { - const result = await testCatalogServer(server.url); - if (!result) { - showRegistrationNotification( - { - id: `test:${server.id}`, - type: "error", - message: intl.formatMessage( - { id: "mcpServer.catalog.testError" }, - { name: server.name }, - ), - }, - true, - ); - return; + if ( + isCatalogOAuthServer(server) && + oauthStatuses[server.gateway_id ?? ""]?.state !== "not_applicable" + ) { + if (!server.gateway_id || !canTestOAuthServer(server)) { + endTesting(server.id); + return; + } + const id = server.gateway_id; + await testOAuthConnection(id, async (signal) => { + const gateway = await api.get( + `/v1/mcp-servers/${encodeURIComponent(id)}`, + undefined, + signal, + ); + if ( + !gateway.enabled || + gateway.authType !== "oauth" || + gateway.oauthConfig?.grant_type !== "authorization_code" + ) { + throw new ApiError(422, null, "Gateway is not eligible for OAuth validation"); } + return { gatewayId: id, baseUrl: gateway.url }; + }); + endTesting(server.id); + return; + } - const statusCode = result.statusCode ?? 0; - const succeeded = statusCode >= 200 && statusCode < 300; - showRegistrationNotification( - { - id: `test:${server.id}`, - type: succeeded ? "success" : "error", - message: intl.formatMessage( - { - id: succeeded ? "mcpServer.catalog.testSuccess" : "mcpServer.catalog.testFailure", - }, - { name: server.name, statusCode, latencyMs: result.latencyMs ?? 0 }, - ), - }, - true, - ); - } catch { + try { + const result = await testCatalogServer(server.url); + if (!result) { showRegistrationNotification( { id: `test:${server.id}`, @@ -892,20 +957,37 @@ export function ServerCatalog() { }, true, ); - } finally { - endTesting(server.id); + return; } - }, - [ - beginTesting, - dismissRegistrationNotification, - endTesting, - intl, - isAdding, - isDisconnecting, - showRegistrationNotification, - ], - ); + + const statusCode = result.statusCode ?? 0; + const succeeded = statusCode >= 200 && statusCode < 300; + showRegistrationNotification( + { + id: `test:${server.id}`, + type: succeeded ? "success" : "error", + message: intl.formatMessage( + { + id: succeeded ? "mcpServer.catalog.testSuccess" : "mcpServer.catalog.testFailure", + }, + { name: server.name, statusCode, latencyMs: result.latencyMs ?? 0 }, + ), + }, + true, + ); + } catch { + showRegistrationNotification( + { + id: `test:${server.id}`, + type: "error", + message: intl.formatMessage({ id: "mcpServer.catalog.testError" }, { name: server.name }), + }, + true, + ); + } finally { + endTesting(server.id); + } + }; const handleDisconnect = useCallback( (server: CatalogServer) => { @@ -1056,6 +1138,7 @@ export function ServerCatalog() { let pollController: AbortController | null = null; try { + resetValidation(gatewayId); const response = await disconnectCatalogGateway(gatewayId); if (response.status === 202) { setDisconnectServer(null); @@ -1129,6 +1212,7 @@ export function ServerCatalog() { } }, [ beginDisconnecting, + resetValidation, disconnectServer, endDisconnecting, intl, @@ -1289,6 +1373,27 @@ export function ServerCatalog() { onAdd={handleAdd} addingServerIds={addingServerIds} onTest={(server) => void handleTest(server)} + canTestOAuthServer={canTestOAuthServer} + canTestOAuth={canReadOAuthStatuses && canUpdateServer} + renderOAuthValidation={(server) => { + if ( + !server.gateway_id || + !canTestOAuthServer(server) || + isAdding(server.id) || + isDisconnecting(server.id) + ) + return null; + const id = server.gateway_id; + return ( + void handleTest(server)} + onCancel={() => resetValidation(id)} + onAuthorize={() => void handleAuthorize(server)} + /> + ); + }} onAuthorize={(server) => void handleAuthorize(server)} onDisconnect={handleDisconnect} testingServerIds={testingServerIds} diff --git a/src/pages/Servers.test.tsx b/src/pages/Servers.test.tsx index ad2f0448..d17725bd 100644 --- a/src/pages/Servers.test.tsx +++ b/src/pages/Servers.test.tsx @@ -1553,6 +1553,61 @@ describe("Servers", () => { }; } + it("offers an explicit test after authorization and shares its result with details", async () => { + const user = userEvent.setup(); + setupOAuth(); + const testConnection = vi.spyOn(serversApi, "testOAuthConnection"); + testConnection.mockResolvedValue({ + success: true, + latencyMs: 5, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, + }); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + const row = screen.getByRole("row", { name: /Test Server 0/ }); + const button = await within(row).findByRole("button", { name: "Test connection" }); + expect(testConnection).not.toHaveBeenCalled(); + await user.click(button); + expect(await screen.findByText("Stored authorization verified")).toBeVisible(); + expect(testConnection).toHaveBeenCalledExactlyOnceWith( + { gatewayId: "server-0", baseUrl: mockServerDetails.url }, + expect.any(AbortSignal), + ); + await user.click(within(row).getByRole("button", { name: "Actions for Test Server 0" })); + await user.click(await screen.findByRole("menuitem", { name: "View details" })); + const panel = screen.getByRole("region", { name: /MCP Server details: Test Server 0/ }); + expect(within(panel).getByText("Stored authorization verified")).toBeVisible(); + expect(within(panel).getByText("0 tools")).toBeVisible(); + }); + + it("does not offer strict validation to a read-only caller", async () => { + setupOAuth({ token: "valid" }); + mockHasPermission.mockImplementation((permission) => permission === "gateways.read"); + renderWithRouter(); + await screen.findByText("Test Server 0"); + expect(screen.queryByRole("button", { name: "Test connection" })).not.toBeInTheDocument(); + }); + + it("clears a failed validation when reauthorization starts", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth({ token: "valid" }); + vi.spyOn(serversApi, "testOAuthConnection").mockResolvedValue({ + success: false, + latencyMs: 5, + stages: { token: "ok", negotiation: "ok", tools_list: "failed" }, + errorCode: "insufficient_scope", + reauthorizationRequired: true, + }); + renderWithRouter(); + const row = await screen.findByRole("row", { name: /Test Server 0/ }); + await user.click(await within(row).findByRole("button", { name: "Test connection" })); + expect(await screen.findByText(/stored token lacks the required scope/)).toBeVisible(); + authorize.mockImplementationOnce(() => new Promise(() => {})); + await user.click(within(row).getByRole("button", { name: "Authorize again" })); + expect(screen.queryByText("Connection validation failed")).not.toBeInTheDocument(); + }); + it("authorizes from the row without opening details or reloading the table", async () => { const user = userEvent.setup(); const { discover } = setupOAuth(); @@ -1586,7 +1641,10 @@ describe("Servers", () => { const user = userEvent.setup(); const { authorize } = setupOAuth(); authorize.mockRejectedValueOnce( - new OAuthAuthorizationError("access_denied", "Provider denied the requested scope"), + new OAuthAuthorizationError( + "access_denied", + "The provider denied authorization. Authorize this server again.", + ), ); renderWithRouter(, "/app/servers?selected=server-0"); const details = await screen.findByRole("region", { name: /Test Server 0/i }); @@ -1597,7 +1655,9 @@ describe("Servers", () => { const dialog = await screen.findByRole("dialog", { name: "OAuth authorization failed for Test Server 0", }); - expect(within(dialog).getByText("Provider denied the requested scope")).toBeVisible(); + expect( + within(dialog).getByText("The provider denied authorization. Authorize this server again."), + ).toBeVisible(); await user.click(within(dialog).getByRole("button", { name: "Dismiss" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const panel = screen.getByRole("region", { name: /Test Server 0/i }); @@ -1773,7 +1833,12 @@ describe("Servers", () => { () => new Promise((_, reject) => { fail = () => - reject(new OAuthAuthorizationError("access_denied", "Failure from server 0")); + reject( + new OAuthAuthorizationError( + "access_denied", + "The provider denied authorization. Authorize this server again.", + ), + ); }), ); renderWithRouter(); @@ -1785,7 +1850,11 @@ describe("Servers", () => { const failure = await screen.findByRole("dialog", { name: "OAuth authorization failed for Test Server 0", }); - expect(within(failure).getByText("Failure from server 0")).toBeVisible(); + expect( + within(failure).getByText( + "The provider denied authorization. Authorize this server again.", + ), + ).toBeVisible(); await user.click(within(failure).getByRole("button", { name: "Dismiss" })); expect(screen.getByRole("region", { name: /Test Server 1/i })).toBeVisible(); }); diff --git a/src/pages/Servers.tsx b/src/pages/Servers.tsx index 0ef48525..e94dce89 100644 --- a/src/pages/Servers.tsx +++ b/src/pages/Servers.tsx @@ -21,15 +21,18 @@ import { InlineNotification } from "@/components/ui/inline-notification"; import { useIntl } from "react-intl"; import type { OAuthAttempt } from "@/api/oauth"; import { useOAuthStatuses } from "@/hooks/useOAuthStatuses"; +import { useOAuthConnectionValidation } from "@/hooks/useOAuthConnectionValidation"; +import { OAuthConnectionValidationPanel } from "@/components/servers/OAuthConnectionValidationPanel"; +import { + canValidateOAuthConnection, + oauthConfigurationKey, + oauthErrorMessage, +} from "@/lib/oauthConnectionValidation"; import { isOAuthServer } from "@/lib/serverStatus"; // Pagination constants const DEFAULT_PAGE_SIZE = 10; -function oauthFailureReason(error: unknown): string { - return error instanceof OAuthAuthorizationError ? error.message : sanitizeError(error); -} - export function Servers() { const intl = useIntl(); const { path } = useRouter(); @@ -98,6 +101,7 @@ export function Servers() { data: detailsServer, error: detailsQueryError, setData: setDetailsServer, + refetch: refetchDetails, } = useQuery(detailsQueryPath, { enabled: Boolean(selectedServerIdForDetails), }); @@ -139,6 +143,18 @@ export function Servers() { retry: retryOAuthStatus, } = useOAuthStatuses(oauthServerIds, { enabled: canReadServers }); + const validationTargets = Object.fromEntries( + [...servers, ...(panelServer ? [panelServer] : [])].map((server) => [ + server.id, + oauthConfigurationKey(server), + ]), + ); + const { + entries: validationEntries, + test: testOAuthConnection, + reset: resetValidation, + } = useOAuthConnectionValidation(canReadServers && canUpdateServer, validationTargets); + const getServerText = useCallback( (server: MCPServer) => `${server.name} ${server.description ?? ""} ${server.id}`, [], @@ -155,6 +171,7 @@ export function Servers() { const patchServer = useCallback( (updated: MCPServer) => { setAllServers((prev) => prev.map((s) => (s.id === updated.id ? updated : s))); + setDetailsServer((previous) => (previous?.id === updated.id ? updated : previous)); // Keep the query cache aligned so the refetch useEffect doesn't restore // the pre-mutation row. setQueryData((prev) => @@ -163,7 +180,7 @@ export function Servers() { : prev, ); }, - [setQueryData], + [setQueryData, setDetailsServer], ); const handleEdit = (id: string) => { @@ -180,6 +197,7 @@ export function Servers() { if (!selectedServerId) return; const idToDelete = selectedServerId; + resetValidation(idToDelete); const previousServers = allServers; const serverToDelete = allServers.find((s) => s.id === idToDelete); @@ -227,6 +245,7 @@ export function Servers() { } }, [ allServers, + resetValidation, selectedServerId, selectedServerIdForDetails, isDetailsDrawerOpen, @@ -237,6 +256,7 @@ export function Servers() { const handleToggleEnabled = useCallback( async (id: string, enabled: boolean) => { + resetValidation(id); setToggleError(null); try { await serversApi.toggleEnabled(id, enabled); @@ -255,7 +275,7 @@ export function Servers() { console.error("Failed to toggle server state:", errorMsg); } }, - [patchServer], + [patchServer, resetValidation], ); const discoverOAuthComponents = useCallback( @@ -286,6 +306,7 @@ export function Servers() { const handleAuthorize = useCallback( async (id: string) => { if (!canReadServers || pendingAuthorizationIds.current.has(id)) return; + resetValidation(id); pendingAuthorizationIds.current.add(id); if ( !document.activeElement?.closest('[role="dialog"]') || @@ -306,7 +327,22 @@ export function Servers() { setOAuthAttempts((prev) => { const next = { ...prev }; if (err instanceof OAuthCancelledError) delete next[id]; - else next[id] = { state: "failed", reason: oauthFailureReason(err), dialogOpen: true }; + else + next[id] = { + state: "failed", + dialogOpen: true, + reason: + err instanceof OAuthAuthorizationError + ? oauthErrorMessage(intl, err.errorCode) + : sanitizeError(err), + ...(err instanceof OAuthAuthorizationError + ? { + errorCode: err.errorCode, + failedStage: err.failedStage, + reauthorizationRequired: err.reauthorizationRequired, + } + : {}), + }; return next; }); return; @@ -326,6 +362,8 @@ export function Servers() { }, [ canReadServers, + intl, + resetValidation, reloadOAuthStatuses, discoverOAuthComponents, allServers, @@ -335,6 +373,34 @@ export function Servers() { ], ); + const renderOAuthValidation = ( + server: MCPServer, + variant: "actions" | "result" | "full" = "full", + ) => { + if ( + !canReadServers || + !canUpdateServer || + pendingAuthorizationIds.current.has(server.id) || + !canValidateOAuthConnection(server, oauthStatuses[server.id]) + ) + return null; + const entry = validationEntries[server.id]; + if (variant === "result" && !entry) return null; + return ( + + void testOAuthConnection(server.id, { gatewayId: server.id, baseUrl: server.url }) + } + onCancel={() => resetValidation(server.id)} + onAuthorize={() => void handleAuthorize(server.id)} + /> + ); + }; + const dismissOAuthFailure = () => { if (!oauthDialogServerId) return; const id = oauthDialogServerId; @@ -542,6 +608,11 @@ export function Servers() { }} serverId={updateServerId || undefined} onSuccess={() => { + if (updateServerId) { + resetValidation(updateServerId); + serversApi.invalidateGetCache(updateServerId); + if (selectedServerIdForDetails === updateServerId) void refetchDetails(); + } setIsFormOpen(false); setUpdateServerId(null); refetch(); @@ -615,6 +686,7 @@ export function Servers() {