Skip to content
Merged
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
55 changes: 55 additions & 0 deletions src/api/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,61 @@ describe("serversApi", () => {
});
});

describe("getOAuthStatus", () => {
const statusResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});

const idsFrom = (call: unknown[]) =>
new URL(String(call[0]), "http://localhost").searchParams.getAll("gateway_ids");

it("sends one request and returns its statuses", async () => {
const body = { "srv-1": { user_token_status: { status: "missing" } } };
mockFetch.mockResolvedValueOnce(statusResponse(body));

const result = await serversApi.getOAuthStatus(["srv-1"]);

expect(result).toEqual(body);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(idsFrom(mockFetch.mock.calls[0])).toEqual(["srv-1"]);
});

it("splits over 100 ids across requests and merges the responses", async () => {
const ids = Array.from({ length: 101 }, (_, index) => `srv-${index}`);
mockFetch
.mockResolvedValueOnce(
statusResponse({ "srv-0": { user_token_status: { status: "valid" } } }),
)
.mockResolvedValueOnce(
statusResponse({ "srv-100": { user_token_status: { status: "expired" } } }),
);

const result = await serversApi.getOAuthStatus(ids);

expect(mockFetch).toHaveBeenCalledTimes(2);
expect(idsFrom(mockFetch.mock.calls[0])).toHaveLength(100);
expect(idsFrom(mockFetch.mock.calls[1])).toEqual(["srv-100"]);
expect(result).toEqual({
"srv-0": { user_token_status: { status: "valid" } },
"srv-100": { user_token_status: { status: "expired" } },
});
});

it("makes no request for an empty id list", async () => {
await expect(serversApi.getOAuthStatus([])).resolves.toEqual({});
expect(mockFetch).not.toHaveBeenCalled();
});

it("rejects a malformed id before any request", async () => {
await expect(serversApi.getOAuthStatus(["../etc"])).rejects.toThrow(
"Invalid server ID format",
);
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe("testConnection", () => {
it("POSTs /v1/mcp-servers/:id/test and returns the result", async () => {
mockFetch.mockResolvedValueOnce(
Expand Down
40 changes: 38 additions & 2 deletions src/api/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { api } from "./client";
import type { ServersResponse, MCPServer } from "../types/server";
import type { ServersResponse, MCPServer, GatewayOAuthStatus } from "../types/server";
import type {
GatewayHandshakeRequest,
GatewayHandshakeResponse,
Expand All @@ -17,6 +17,17 @@ import type {

const serverByIdRequestCache = new Map<string, Promise<MCPServer>>();

/** Mirrors OAUTH_STATUS_BATCH_MAX_IDS on the backend's /oauth/status route. */
const OAUTH_STATUS_MAX_IDS = 100;

/** The user closed the OAuth popup. Typed so callers can stay quiet about it. */
export class OAuthCancelledError extends Error {
constructor() {
super("OAuth authorization was cancelled");
this.name = "OAuthCancelledError";
}
}

/**
* Validates server ID to prevent path traversal and injection attacks
* @param id - The server ID to validate
Expand Down Expand Up @@ -203,6 +214,31 @@ export const serversApi = {
return api.post(`/oauth/fetch-tools/${validId}`);
},

/**
* The caller's own OAuth state for each gateway, batched.
*
* Keys stay snake_case, unlike the gateway endpoints. Ids that are missing or
* not visible to the caller are omitted. A paged-through list can exceed the
* backend's id cap, so requests are split and the responses merged.
*/
getOAuthStatus: async (ids: string[]): Promise<Record<string, GatewayOAuthStatus>> => {
const validIds = ids.map(validateServerId);
const batches: string[][] = [];
for (let start = 0; start < validIds.length; start += OAUTH_STATUS_MAX_IDS) {
batches.push(validIds.slice(start, start + OAUTH_STATUS_MAX_IDS));
}

const responses = await Promise.all(
batches.map((batch) => {
const params = new URLSearchParams();
batch.forEach((id) => params.append("gateway_ids", id));
return api.get<Record<string, GatewayOAuthStatus>>(`/oauth/status?${params.toString()}`);
}),
);

return Object.assign({}, ...responses) as Record<string, GatewayOAuthStatus>;
},

/**
* Open blank OAuth popup during an active user gesture.
*
Expand Down Expand Up @@ -278,7 +314,7 @@ export const serversApi = {
if (!settled) {
settled = true;
cleanup();
reject(new Error("OAuth authorization was cancelled"));
reject(new OAuthCancelledError());
}
}
}, 1000);
Expand Down
50 changes: 0 additions & 50 deletions src/components/servers/ServerStatusBadge.test.tsx

This file was deleted.

85 changes: 0 additions & 85 deletions src/components/servers/ServerStatusBadge.tsx

This file was deleted.

53 changes: 53 additions & 0 deletions src/components/servers/ServerStatusDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useIntl } from "react-intl";

import { getAvailabilityPresentation, type ServerAvailability } from "@/lib/serverStatus";
import { formatLocalDateTime } from "@/utils/formatDate";

interface ServerStatusDetailProps {
availability: ServerAvailability;
enabled: boolean;
lastSeen?: string | null;
lastError?: string | null;
}

/**
* What a status means, plus the last response and last error where the server
* has them. This is the only place either value is surfaced in the UI.
*
* Disabled servers withhold the error: the health loop clears `last_error` only
* on enabled servers, so theirs is left over from an outage before they were
* turned off and reads as a current failure. The test is `enabled` rather than
* the `inactive` state, because `auth` outranks `inactive`, so a disabled
* server whose token has also expired is classified `auth` and would otherwise
* slip past the guard.
*/
export function ServerStatusDetail({
availability,
enabled,
lastSeen,
lastError,
}: ServerStatusDetailProps) {
const intl = useIntl();
const showLastError = Boolean(lastError) && enabled;

return (
<div className="space-y-2 text-sm">
<p className="text-foreground">
{intl.formatMessage({ id: getAvailabilityPresentation(availability).detailId })}
</p>
{lastSeen && (
<p className="text-muted-foreground">
{intl.formatMessage(
{ id: "mcpServer.status.detail.lastSeen" },
{ timestamp: formatLocalDateTime(lastSeen, "") },
)}
</p>
)}
{showLastError && (
<p className="break-words text-muted-foreground">
{intl.formatMessage({ id: "mcpServer.status.detail.lastError" }, { error: lastError })}
</p>
)}
</div>
);
}
Loading
Loading