Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
123716a
feat: add a shared MCP server status indicator and detail dialog
a-effort Sep 15, 2026
6a5ad5d
feat: explain source availability in the create virtual server picker
a-effort Sep 16, 2026
9780328
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 16, 2026
8c1b8e4
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 16, 2026
200885f
fix: give the zero-component empty state its own strings
a-effort Sep 16, 2026
7bc7e95
fix: put the empty state messages in the active voice
a-effort Sep 16, 2026
f2a7eed
feat: explain server status in a popover, hand auth to the OAuth flow
a-effort Sep 16, 2026
1289dec
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 16, 2026
a5ec1c5
fix: rework the MCP server status popover copy and alignment
a-effort Sep 17, 2026
092cdde
fix: hold the authorize button's width while the OAuth flow is open
a-effort Sep 17, 2026
c1f3397
fix: rework the MCP server status popover copy and alignment
a-effort Sep 17, 2026
56f409d
fix: hold the authorize button's width while the OAuth flow is open
a-effort Sep 17, 2026
a31308b
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 17, 2026
f22fad0
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 17, 2026
00c7da9
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 17, 2026
9af64f3
fix: Batch OAuth status ids and keep status reloading after authorize
a-effort Sep 17, 2026
aaf20e2
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 18, 2026
730f2f2
fix: Let only the latest OAuth status lookup publish state
a-effort Sep 17, 2026
a970840
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 18, 2026
a06dae1
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 18, 2026
e61bdf4
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 21, 2026
1a7c7c8
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 21, 2026
71f55c1
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 22, 2026
1cca82f
fix: withhold last error from disabled servers by checking enabled
a-effort Sep 23, 2026
87d666b
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 23, 2026
289666f
Merge branch 'main' into 6404-shared-server-status-badge
a-effort Sep 23, 2026
b0a9a01
fix: name every failing source and keep names of dropped sources
a-effort Sep 23, 2026
db6ed07
Merge branch '6404-shared-server-status-badge' into 6404-source-picke…
a-effort Sep 23, 2026
6e4d473
Merge branch 'main' into 6404-source-picker-status
a-effort Sep 28, 2026
234eace
test: expect the source-attributed component error in e2e
a-effort Sep 29, 2026
1f374ad
Merge remote-tracking branch 'origin/main' into 6404-source-picker-st…
a-effort Sep 29, 2026
f35c1e4
Merge branch 'main' into 6404-source-picker-status
a-effort Sep 30, 2026
1dcaa48
Merge branch 'main' into 6404-source-picker-status
a-effort Oct 1, 2026
626e336
fix(i18n): reword the authorization-unavailable empty state
a-effort Oct 1, 2026
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
2 changes: 1 addition & 1 deletion e2e/virtual-servers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ test.describe("Virtual Servers page", () => {
await page.getByRole("checkbox", { name: "Select github-mcp" }).check();
await page.getByRole("button", { name: "Submit" }).click();

await expect(page.getByRole("alert")).toHaveText("Unable to load tools");
await expect(page.getByRole("alert")).toHaveText("github-mcp: Unable to load tools");
await expect(page).toHaveURL(/\/app\/gateways\/create-server$/);
});

Expand Down
195 changes: 195 additions & 0 deletions src/components/gateways/SourceSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,4 +366,199 @@ describe("SourceSelection", () => {
expect(grpcAction).not.toHaveBeenCalled();
expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(1);
});
it("keeps every source selectable and explains its state", async () => {
const user = userEvent.setup();
server.use(
http.get("*/v1/mcp-servers", () =>
HttpResponse.json({
gateways: [
{
id: "s-off",
name: "offline-src",
enabled: true,
reachable: false,
lastSeen: "2026-01-01T00:00:00Z",
visibility: "public",
tool_count: 4,
},
{
id: "s-draft",
name: "draft-src",
enabled: false,
reachable: false,
visibility: "public",
tool_count: 0,
},
],
}),
),
http.get("*/oauth/status", () => HttpResponse.json({})),
);

renderWithProviders(
<SourceSelection
actionCards={actionCards}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

await user.click(
screen.getByRole("button", {
name: "Add tools, resources, and prompts from connected sources",
}),
);

expect(await screen.findByText("offline-src")).toBeInTheDocument();
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText("Inactive")).toBeInTheDocument();

// An unavailable source stays selectable: its components remain in the catalog.
for (const name of ["Select offline-src", "Select draft-src"]) {
expect(screen.getByRole("checkbox", { name })).toBeEnabled();
}
});

it("warns at submit only about selected sources with nothing to add", async () => {
const user = userEvent.setup();
server.use(
http.get("*/v1/mcp-servers", () =>
HttpResponse.json({
gateways: [
{
id: "s-full",
name: "full-src",
enabled: true,
reachable: true,
visibility: "public",
tool_count: 3,
},
{
id: "s-empty",
name: "empty-src",
enabled: true,
reachable: true,
visibility: "public",
tool_count: 0,
resource_count: 0,
prompt_count: 0,
},
],
}),
),
http.get("*/oauth/status", () => HttpResponse.json({})),
);

renderWithProviders(
<SourceSelection
actionCards={actionCards}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

await user.click(
screen.getByRole("button", {
name: "Add tools, resources, and prompts from connected sources",
}),
);
await screen.findByText("full-src");

await user.click(screen.getByRole("checkbox", { name: "Select full-src" }));
expect(screen.queryByText(/no components to add yet/)).not.toBeInTheDocument();

await user.click(screen.getByRole("checkbox", { name: "Select empty-src" }));
expect(screen.getByText(/empty-src has no components to add yet/)).toBeInTheDocument();

// Non-blocking: submitting stays available.
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
});

it("reports the selected source names to the caller", async () => {
const user = userEvent.setup();
const onSelectSources = vi.fn();
server.use(
http.get("*/v1/mcp-servers", () =>
HttpResponse.json({
gateways: [
{
id: "s-1",
name: "alpha",
enabled: true,
reachable: true,
visibility: "public",
tool_count: 1,
},
],
}),
),
http.get("*/oauth/status", () => HttpResponse.json({})),
);

renderWithProviders(
<SourceSelection
actionCards={actionCards}
onSelectSources={onSelectSources}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

await user.click(
screen.getByRole("button", {
name: "Add tools, resources, and prompts from connected sources",
}),
);
await screen.findByText("alpha");
await user.click(screen.getByRole("checkbox", { name: "Select alpha" }));

expect(onSelectSources).toHaveBeenCalledWith(["s-1"], { "s-1": "alpha" });
});

it("keeps the name of a selected source that has dropped out of the list", async () => {
const user = userEvent.setup();
const onSelectSources = vi.fn();
server.use(
http.get("*/v1/mcp-servers", () =>
HttpResponse.json({
gateways: [
{ id: "s-1", name: "alpha", enabled: true, reachable: true, tool_count: 1 },
{ id: "s-2", name: "beta", enabled: true, reachable: true, tool_count: 1 },
],
}),
),
http.get("*/oauth/status", () => HttpResponse.json({})),
);

const { rerender } = renderWithProviders(
<SourceSelection
actionCards={actionCards}
onSelectSources={onSelectSources}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

await user.click(
screen.getByRole("button", {
name: "Add tools, resources, and prompts from connected sources",
}),
);
await screen.findByText("beta");
await user.click(screen.getByRole("checkbox", { name: "Select beta" }));

// beta leaves the available list while it is still selected.
rerender(
<SourceSelection
actionCards={actionCards}
associatedMCPServerIds={["s-2"]}
onSelectSources={onSelectSources}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);
expect(screen.queryByText("beta")).not.toBeInTheDocument();

await user.click(screen.getByRole("checkbox", { name: "Select alpha" }));

expect(onSelectSources).toHaveBeenLastCalledWith(["s-2", "s-1"], {
"s-1": "alpha",
"s-2": "beta",
});
});
});
42 changes: 39 additions & 3 deletions src/components/gateways/SourceSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useIntl } from "react-intl";
import {
ArrowLeft,
Expand Down Expand Up @@ -61,6 +61,10 @@ function getPromptCount(server: ListedMCPServer) {
return server.promptCount ?? server.prompt_count ?? 0;
}

function getComponentTotal(server: ListedMCPServer) {
return getToolCount(server) + getResourceCount(server) + getPromptCount(server);
}

function getVisibilityConfig(visibility: ListedMCPServer["visibility"]) {
switch (visibility) {
case "private":
Expand All @@ -80,7 +84,7 @@ export function SourceSelection({
}: {
actionCards: ActionCard[];
associatedMCPServerIds?: string[];
onSelectSources?: (selectedIds: string[]) => void;
onSelectSources?: (selectedIds: string[], namesById: Record<string, string>) => void;
createServerActions?: {
onBack: () => void;
onSkip: () => void;
Expand All @@ -98,6 +102,7 @@ export function SourceSelection({
const [isComponentsPanelOpen, setIsComponentsPanelOpen] = useState(false);
const [hasRequestedMCPServers, setHasRequestedMCPServers] = useState(false);
const [selectedMCPServerIds, setSelectedMCPServerIds] = useState<Set<string>>(new Set());
const selectedNamesRef = useRef<Record<string, string>>({});
const {
data: mcpServersData,
error: mcpServersError,
Expand Down Expand Up @@ -127,6 +132,16 @@ export function SourceSelection({
const hasSelectedMCPServers = selectedMCPServerIds.size > 0;
const panelId = "connected-sources-panel";

// Selecting an offline source still works: its components stay in the catalog.
// Only a source with nothing to contribute leaves the virtual server empty.
const emptySelectedSources = useMemo(
() =>
availableMCPServers.filter(
(server) => selectedMCPServerIds.has(server.id) && getComponentTotal(server) === 0,
),
[availableMCPServers, selectedMCPServerIds],
);

const handleToggleComponentsPanel = () => {
setIsComponentsPanelOpen((open) => !open);
setHasRequestedMCPServers(true);
Expand All @@ -137,7 +152,16 @@ export function SourceSelection({
if (checked) next.add(serverId);
else next.delete(serverId);
setSelectedMCPServerIds(next);
onSelectSources?.(Array.from(next));

// Kept from when each source was picked, so a refetch that drops one does not lose its name.
const names = selectedNamesRef.current;
if (checked) {
const selected = availableMCPServers.find((server) => server.id === serverId);
if (selected) names[serverId] = selected.name;
} else {
delete names[serverId];
}
onSelectSources?.(Array.from(next), { ...names });
};

return (
Expand Down Expand Up @@ -419,6 +443,18 @@ export function SourceSelection({
</section>
)}

{emptySelectedSources.length > 0 && (
<p role="status" className="text-sm text-muted-foreground">
{intl.formatMessage(
{ id: "gateways.source.emptySelectionWarning" },
{
count: emptySelectedSources.length,
names: emptySelectedSources.map((server) => server.name).join(", "),
},
)}
</p>
)}

<div className="flex justify-end">
<Button
type="button"
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en-US/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"gateways.source.selectSource": "Select {name}",
"gateways.source.skipForNow": "Skip for now",
"gateways.source.submit": "Submit",
"gateways.source.emptySelectionWarning": "{count, plural, one {{names} has no components to add yet. You can continue and add them later.} other {{names} have no components to add yet. You can continue and add them later.}}",
"gateways.createServer.card.title": "Create server",
"gateways.createServer.card.description": "Make external sources available through a virtual server endpoint. Sources can be running MCP servers, REST APIs, gRPC services, or A2A agents.",
"gateways.createServer.title": "Create server",
Expand Down Expand Up @@ -122,6 +123,7 @@
"gateways.details.component.copyName.tools": "Copy tool name for {name}",
"gateways.details.component.copyName.resources": "Copy URI for {name}",
"gateways.details.component.copyName.prompts": "Copy prompt name for {name}",
"gateways.details.componentsLoadFailed": "Could not load {kind}: {detail}",
"gateways.details.noComponentsFound": "No components found.",
"gateways.details.noFilteredComponentsFound": "No {filter} found.",
"gateways.details.close": "Close virtual server details",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/en-US/mcpServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@
"mcpServer.refresh.conflict": "A refresh is already running for {name}. Try again shortly.",
"mcpServer.refresh.validationWarning": "{count, plural, one {# tool was skipped due to a validation error:} other {# tools were skipped due to validation errors:}}",
"mcpServer.status.active": "Active",
"mcpServer.status.warning": "Warning",
"mcpServer.status.offline": "Offline",
"mcpServer.status.draft": "Draft",
"mcpServer.status.auth.short": "Auth",
Expand Down Expand Up @@ -116,6 +115,13 @@
"mcpServer.status.detail.unreachable": "This server is offline. Calls to tools, resources, and prompts fail until the server responds again.",
"mcpServer.status.detail.checking": "This server and its components are not active yet.",
"mcpServer.status.detail.inactive": "This server and its tools, resources, and prompts are inactive.",
"mcpServer.status.empty.authRequired": "Authorize this server to see its tools, resources, and prompts.",
"mcpServer.status.empty.authExpired": "Your authorization has expired. Authorize this server again to see its tools, resources, and prompts.",
"mcpServer.status.empty.authChecking": "Checking your authorization, so there is nothing to show yet.",
"mcpServer.status.empty.authUnavailable": "This server has nothing to show. Authorization may be the cause, since the check on your authorization status did not complete.",
"mcpServer.status.empty.unreachable": "This server is offline, so it has nothing to show until it responds again.",
"mcpServer.status.empty.checking": "This server is not active yet, so it has nothing to show.",
"mcpServer.status.empty.inactive": "This server is inactive, so it has nothing to show.",
"mcpServer.status.detail.lastSeen": "Last response: {timestamp}",
"mcpServer.status.detail.lastError": "Last error: {error}",
"mcpServer.list.showing": "{count, plural, one {Showing # server} other {Showing # servers}}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es-ES/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"gateways.source.selectSource": "Seleccionar {name}",
"gateways.source.skipForNow": "Omitir por ahora",
"gateways.source.submit": "Enviar",
"gateways.source.emptySelectionWarning": "{count, plural, one {{names} todavía no tiene componentes que añadir. Puedes continuar y añadirlos más tarde.} other {{names} todavía no tienen componentes que añadir. Puedes continuar y añadirlos más tarde.}}",
"gateways.createServer.card.title": "Crear servidor",
"gateways.createServer.card.description": "Haga que las fuentes externas estén disponibles mediante un endpoint de servidor virtual. Las fuentes pueden ser servidores MCP, API REST, servicios gRPC o agentes A2A en ejecución.",
"gateways.createServer.title": "Crear servidor",
Expand Down Expand Up @@ -122,6 +123,7 @@
"gateways.details.component.copyName.tools": "Copiar nombre de la herramienta de {name}",
"gateways.details.component.copyName.resources": "Copiar URI de {name}",
"gateways.details.component.copyName.prompts": "Copiar nombre del prompt de {name}",
"gateways.details.componentsLoadFailed": "No se pudieron cargar {kind}: {detail}",
"gateways.details.noComponentsFound": "No se encontraron componentes.",
"gateways.details.noFilteredComponentsFound": "No se encontraron {filter}.",
"gateways.details.close": "Cerrar detalles del servidor virtual",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/es-ES/mcpServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@
"mcpServer.refresh.conflict": "Ya hay una actualización en curso para {name}. Inténtalo de nuevo en unos momentos.",
"mcpServer.refresh.validationWarning": "{count, plural, one {Se omitió # herramienta por un error de validación:} other {Se omitieron # herramientas por errores de validación:}}",
"mcpServer.status.active": "Activo",
"mcpServer.status.warning": "Advertencia",
"mcpServer.status.offline": "Sin conexión",
"mcpServer.status.draft": "Borrador",
"mcpServer.status.auth.short": "Autorizar",
Expand Down Expand Up @@ -116,6 +115,13 @@
"mcpServer.status.detail.unreachable": "Este servidor está sin conexión. Las llamadas a herramientas, recursos y prompts fallan hasta que el servidor vuelva a responder.",
"mcpServer.status.detail.checking": "Este servidor y sus componentes aún no están activos.",
"mcpServer.status.detail.inactive": "Este servidor y sus herramientas, recursos y prompts están inactivos.",
"mcpServer.status.empty.authRequired": "Autoriza este servidor para ver sus herramientas, recursos y prompts.",
"mcpServer.status.empty.authExpired": "Tu autorización ha caducado. Autoriza este servidor de nuevo para ver sus herramientas, recursos y prompts.",
"mcpServer.status.empty.authChecking": "Comprobando tu autorización, así que todavía no hay nada que mostrar.",
"mcpServer.status.empty.authUnavailable": "Este servidor no tiene nada que mostrar. La autorización puede ser la causa, ya que la comprobación de tu estado de autorización no se completó.",
"mcpServer.status.empty.unreachable": "Este servidor está sin conexión, así que no tiene nada que mostrar hasta que vuelva a responder.",
"mcpServer.status.empty.checking": "Este servidor aún no está activo, así que no tiene nada que mostrar.",
"mcpServer.status.empty.inactive": "Este servidor está inactivo, así que no tiene nada que mostrar.",
"mcpServer.status.detail.lastSeen": "Última respuesta: {timestamp}",
"mcpServer.status.detail.lastError": "Último error: {error}",
"mcpServer.list.showing": "{count, plural, one {Se muestra # servidor} other {Se muestran # servidores}}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/pt-BR/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"gateways.source.selectSource": "Selecionar {name}",
"gateways.source.skipForNow": "Pular por enquanto",
"gateways.source.submit": "Enviar",
"gateways.source.emptySelectionWarning": "{count, plural, one {{names} ainda não tem componentes para adicionar. Você pode continuar e adicioná-los depois.} other {{names} ainda não têm componentes para adicionar. Você pode continuar e adicioná-los depois.}}",
"gateways.createServer.card.title": "Criar servidor",
"gateways.createServer.card.description": "Disponibilize fontes externas por meio de um endpoint de servidor virtual. As fontes podem ser servidores MCP, APIs REST, serviços gRPC ou agentes A2A em execução.",
"gateways.createServer.title": "Criar servidor",
Expand Down Expand Up @@ -122,6 +123,7 @@
"gateways.details.component.copyName.tools": "Copiar nome da ferramenta de {name}",
"gateways.details.component.copyName.resources": "Copiar URI de {name}",
"gateways.details.component.copyName.prompts": "Copiar nome do prompt de {name}",
"gateways.details.componentsLoadFailed": "Não foi possível carregar {kind}: {detail}",
"gateways.details.noComponentsFound": "Nenhum componente encontrado.",
"gateways.details.noFilteredComponentsFound": "Nenhum {filter} encontrado.",
"gateways.details.close": "Fechar detalhes do servidor virtual",
Expand Down
Loading
Loading