Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion e2e/mcp-oauth-outcomes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
259 changes: 259 additions & 0 deletions e2e/oauth-connection-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -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: "<html>Provider</html>" }),
);
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: "<html>Provider</html>" }),
);
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();
});
}
2 changes: 1 addition & 1 deletion e2e/tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading