From 8dfadd95fa3244f14efe8180909bc9fed6b85308 Mon Sep 17 00:00:00 2001 From: chengzhang Date: Wed, 27 May 2026 16:07:17 +0800 Subject: [PATCH 1/7] refactor MCP service --- frontend/src/api/generated.ts | 127 ++++ frontend/src/api/openapi.json | 255 +++++++ .../app/capability-registry/overview.test.ts | 4 +- .../features/marketplace/api/mcp-client.ts | 5 - .../features/marketplace/api/mcp-queries.ts | 31 +- .../src/features/marketplace/api/mcp-types.ts | 3 +- .../components/MarketplaceLayout.tsx | 58 +- .../components/McpMarketplaceCard.test.tsx | 125 +++- .../components/McpMarketplaceCard.tsx | 109 ++- .../McpMarketplaceDetailView.test.tsx | 8 +- .../components/McpMarketplaceDetailView.tsx | 17 +- frontend/src/features/marketplace/i18n.ts | 40 +- frontend/src/features/marketplace/lazy.ts | 15 +- .../marketplace/model/mcp-install-action.ts | 71 +- .../model/use-mcp-marketplace-controller.ts | 32 +- .../screens/MarketplaceMcpPage.test.tsx | 2 +- .../marketplace/styles/mcp-detail.css | 52 -- frontend/src/features/mcp/api/keys.ts | 1 + .../src/features/mcp/api/management-client.ts | 7 + .../features/mcp/api/management-queries.ts | 14 +- .../src/features/mcp/api/management-types.ts | 1 + .../features/mcp/components/McpServerCard.tsx | 5 + .../components/McpServerMatrixView.test.tsx | 4 + .../detail/McpServerDetailView.test.tsx | 107 ++- .../components/detail/McpServerDetailView.tsx | 3 +- .../components/edit/McpConfigChoiceDialog.tsx | 3 +- frontend/src/features/mcp/i18n.ts | 22 + .../src/features/mcp/model/selectors.test.ts | 2 + .../model/use-mcp-management-controller.ts | 43 +- frontend/src/features/mcp/public.ts | 1 + .../mcp/screens/McpInUsePage.test.tsx | 113 +++ frontend/src/features/mcp/styles/pages.css | 25 + .../features/skills/model/use-skill-scan.ts | 145 ++-- frontend/src/features/skills/styles/scan.css | 27 + frontend/src/test/fixtures/mcp.ts | 6 + skill_manager/api/routers/mcp.py | 10 + skill_manager/api/schemas/__init__.py | 2 + skill_manager/api/schemas/mcp.py | 33 + skill_manager/application/container.py | 3 - .../application/marketplace_cache.py | 21 +- skill_manager/application/mcp/__init__.py | 4 - skill_manager/application/mcp/enrichment.py | 4 +- skill_manager/application/mcp/installers.py | 188 ----- .../application/mcp/marketplace/__init__.py | 4 +- .../application/mcp/marketplace/catalog.py | 688 ++++++++++++------ .../application/mcp/marketplace/client.py | 31 +- skill_manager/application/mcp/mutations.py | 100 +-- skill_manager/application/mcp/query.py | 135 +++- skill_manager/application/mcp/stdio.py | 2 +- tests/integration/test_mcp_routes.py | 462 ++++++++---- tests/support/app_harness.py | 27 +- tests/unit/test_mcp_installers.py | 108 --- tests/unit/test_smithery_catalog.py | 361 --------- 53 files changed, 2230 insertions(+), 1436 deletions(-) delete mode 100644 skill_manager/application/mcp/installers.py delete mode 100644 tests/unit/test_mcp_installers.py delete mode 100644 tests/unit/test_smithery_catalog.py diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 924c5b5..09e4859 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -261,6 +261,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/mcp/servers/{name}/availability/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Check Mcp Server Availability */ + post: operations["check_mcp_server_availability_api_mcp_servers__name__availability_check_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/mcp/servers/{name}/disable": { parameters: { query?: never; @@ -816,6 +833,10 @@ export interface components { schemas: { /** AddMcpServerRequest */ AddMcpServerRequest: { + /** Config */ + config?: { + [key: string]: unknown; + } | null; /** Qualifiedname */ qualifiedName: string; /** Sourceharness */ @@ -1059,6 +1080,20 @@ export interface components { /** Succeeded */ succeeded: string[]; }; + /** McpAvailabilityCheckResponse */ + McpAvailabilityCheckResponse: { + /** Availabilityreason */ + availabilityReason?: string | null; + /** + * Availabilitystatus + * @enum {string} + */ + availabilityStatus: "available" | "unavailable"; + /** Name */ + name: string; + /** Ok */ + ok: boolean; + }; /** McpBindingResponse */ McpBindingResponse: { /** Driftdetail */ @@ -1132,6 +1167,42 @@ export interface components { }; spec: components["schemas"]["McpServerSpecResponse"]; }; + /** McpInstallConfigFieldResponse */ + McpInstallConfigFieldResponse: { + /** Choices */ + choices?: string[]; + /** Default */ + default?: string | null; + /** Description */ + description: string; + /** + * Format + * @enum {string} + */ + format: "string" | "number" | "boolean" | "filepath"; + /** Label */ + label: string; + /** Name */ + name: string; + /** Placeholder */ + placeholder?: string | null; + /** Required */ + required: boolean; + /** Secret */ + secret: boolean; + /** + * Target + * @enum {string} + */ + target: "env" | "header" | "urlVariable" | "packageArgument" | "runtimeArgument"; + }; + /** McpInstallConfigResponse */ + McpInstallConfigResponse: { + /** Fields */ + fields?: components["schemas"]["McpInstallConfigFieldResponse"][]; + /** Required */ + required: boolean; + }; /** McpInstallTargetResponse */ McpInstallTargetResponse: { /** Harness */ @@ -1174,10 +1245,22 @@ export interface components { }; /** McpInventoryEntryResponse */ McpInventoryEntryResponse: { + /** Availabilityreason */ + availabilityReason?: string | null; + /** + * Availabilitystatus + * @enum {string} + */ + availabilityStatus: "available" | "unavailable"; /** Canenable */ canEnable: boolean; /** Displayname */ displayName: string; + /** + * Enabledstatus + * @enum {string} + */ + enabledStatus: "enabled" | "disabled"; /** * Kind * @enum {string} @@ -1250,6 +1333,7 @@ export interface components { externalUrl: string; /** Iconurl */ iconUrl?: string | null; + installConfig?: components["schemas"]["McpInstallConfigResponse"]; /** Isremote */ isRemote: boolean; /** Managedname */ @@ -1390,12 +1474,24 @@ export interface components { }; /** McpServerDetailResponse */ McpServerDetailResponse: { + /** Availabilityreason */ + availabilityReason?: string | null; + /** + * Availabilitystatus + * @enum {string} + */ + availabilityStatus: "available" | "unavailable"; /** Canenable */ canEnable: boolean; /** Configchoices */ configChoices?: components["schemas"]["McpConfigChoiceResponse"][]; /** Displayname */ displayName: string; + /** + * Enabledstatus + * @enum {string} + */ + enabledStatus: "enabled" | "disabled"; /** Env */ env?: components["schemas"]["McpEnvEntryResponse"][]; /** @@ -2676,6 +2772,37 @@ export interface operations { }; }; }; + check_mcp_server_availability_api_mcp_servers__name__availability_check_post: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpAvailabilityCheckResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; disable_mcp_server_api_mcp_servers__name__disable_post: { parameters: { query?: never; diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json index ca8309d..08384f2 100644 --- a/frontend/src/api/openapi.json +++ b/frontend/src/api/openapi.json @@ -3,6 +3,18 @@ "schemas": { "AddMcpServerRequest": { "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + }, "qualifiedName": { "minLength": 1, "title": "Qualifiedname", @@ -800,6 +812,44 @@ "title": "McpApplyConfigResponse", "type": "object" }, + "McpAvailabilityCheckResponse": { + "properties": { + "availabilityReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Availabilityreason" + }, + "availabilityStatus": { + "enum": [ + "available", + "unavailable" + ], + "title": "Availabilitystatus", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok", + "name", + "availabilityStatus" + ], + "title": "McpAvailabilityCheckResponse", + "type": "object" + }, "McpBindingResponse": { "properties": { "driftDetail": { @@ -1039,6 +1089,111 @@ "title": "McpIdentitySightingResponse", "type": "object" }, + "McpInstallConfigFieldResponse": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "title": "Choices", + "type": "array" + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default" + }, + "description": { + "title": "Description", + "type": "string" + }, + "format": { + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "title": "Format", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "placeholder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Placeholder" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "secret": { + "title": "Secret", + "type": "boolean" + }, + "target": { + "enum": [ + "env", + "header", + "urlVariable", + "packageArgument", + "runtimeArgument" + ], + "title": "Target", + "type": "string" + } + }, + "required": [ + "name", + "label", + "description", + "format", + "required", + "secret", + "target" + ], + "title": "McpInstallConfigFieldResponse", + "type": "object" + }, + "McpInstallConfigResponse": { + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/McpInstallConfigFieldResponse" + }, + "title": "Fields", + "type": "array" + }, + "required": { + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "required" + ], + "title": "McpInstallConfigResponse", + "type": "object" + }, "McpInstallTargetResponse": { "properties": { "harness": { @@ -1168,6 +1323,25 @@ }, "McpInventoryEntryResponse": { "properties": { + "availabilityReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Availabilityreason" + }, + "availabilityStatus": { + "enum": [ + "available", + "unavailable" + ], + "title": "Availabilitystatus", + "type": "string" + }, "canEnable": { "title": "Canenable", "type": "boolean" @@ -1176,6 +1350,14 @@ "title": "Displayname", "type": "string" }, + "enabledStatus": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Enabledstatus", + "type": "string" + }, "kind": { "enum": [ "managed", @@ -1211,6 +1393,8 @@ "displayName", "kind", "canEnable", + "enabledStatus", + "availabilityStatus", "sightings" ], "title": "McpInventoryEntryResponse", @@ -1428,6 +1612,9 @@ ], "title": "Iconurl" }, + "installConfig": { + "$ref": "#/components/schemas/McpInstallConfigResponse" + }, "isRemote": { "title": "Isremote", "type": "boolean" @@ -1895,6 +2082,25 @@ }, "McpServerDetailResponse": { "properties": { + "availabilityReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Availabilityreason" + }, + "availabilityStatus": { + "enum": [ + "available", + "unavailable" + ], + "title": "Availabilitystatus", + "type": "string" + }, "canEnable": { "title": "Canenable", "type": "boolean" @@ -1910,6 +2116,14 @@ "title": "Displayname", "type": "string" }, + "enabledStatus": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Enabledstatus", + "type": "string" + }, "env": { "items": { "$ref": "#/components/schemas/McpEnvEntryResponse" @@ -1962,6 +2176,8 @@ "displayName", "kind", "canEnable", + "enabledStatus", + "availabilityStatus", "sightings" ], "title": "McpServerDetailResponse", @@ -4873,6 +5089,45 @@ "summary": "Get Mcp Server" } }, + "/api/mcp/servers/{name}/availability/check": { + "post": { + "operationId": "check_mcp_server_availability_api_mcp_servers__name__availability_check_post", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpAvailabilityCheckResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Check Mcp Server Availability" + } + }, "/api/mcp/servers/{name}/disable": { "post": { "operationId": "disable_mcp_server_api_mcp_servers__name__disable_post", diff --git a/frontend/src/app/capability-registry/overview.test.ts b/frontend/src/app/capability-registry/overview.test.ts index d196fd2..9a4c5ec 100644 --- a/frontend/src/app/capability-registry/overview.test.ts +++ b/frontend/src/app/capability-registry/overview.test.ts @@ -43,8 +43,8 @@ describe("capability overview model", () => { { columns: [], entries: [ - { name: "exa", displayName: "Exa", kind: "managed", spec: null, canEnable: true, sightings: [] }, - { name: "firecrawl", displayName: "firecrawl", kind: "unmanaged", spec: null, canEnable: false, sightings: [] }, + { name: "exa", displayName: "Exa", kind: "managed", spec: null, canEnable: true, enabledStatus: "disabled", availabilityStatus: "unavailable", sightings: [] }, + { name: "firecrawl", displayName: "firecrawl", kind: "unmanaged", spec: null, canEnable: false, enabledStatus: "disabled", availabilityStatus: "unavailable", sightings: [] }, ], issues: [], }, diff --git a/frontend/src/features/marketplace/api/mcp-client.ts b/frontend/src/features/marketplace/api/mcp-client.ts index 0d0853d..0316d57 100644 --- a/frontend/src/features/marketplace/api/mcp-client.ts +++ b/frontend/src/features/marketplace/api/mcp-client.ts @@ -5,7 +5,6 @@ import type { AddMcpServerResponseDto, McpInstallTargetsDto, McpMarketplaceDetailDto, - McpMarketplaceFilter, McpMarketplacePageResultDto, } from "./mcp-types"; @@ -16,7 +15,6 @@ interface McpPageParams { export interface McpSearchParams extends McpPageParams { query?: string; - filter?: McpMarketplaceFilter; } export async function fetchMcpMarketplacePopular( @@ -30,15 +28,12 @@ export async function fetchMcpMarketplacePopular( export async function searchMcpMarketplace( params: McpSearchParams = {}, ): Promise { - const filter = params.filter ?? "all"; const query = (params.query ?? "").trim(); return fetchJson( withQuery("/marketplace/mcp/search", { q: query || undefined, limit: params.limit, offset: params.offset, - remote: filter === "remote" ? "true" : filter === "local" ? "false" : undefined, - verified: filter === "verified" ? "true" : undefined, }), ); } diff --git a/frontend/src/features/marketplace/api/mcp-queries.ts b/frontend/src/features/marketplace/api/mcp-queries.ts index 45dd247..776a6d3 100644 --- a/frontend/src/features/marketplace/api/mcp-queries.ts +++ b/frontend/src/features/marketplace/api/mcp-queries.ts @@ -2,7 +2,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta import { useToast } from "../../../components/Toast"; import { flattenUniquePageItems, queryPolicy } from "../../../lib/query"; -import { invalidateMcpQueries } from "../../mcp/public"; +import { checkMcpServerAvailability, invalidateMcpQueries } from "../../mcp/public"; import { useMarketplaceCopy } from "../i18n"; import { useInstallingState } from "../model/installing-context"; import { @@ -14,37 +14,35 @@ import { } from "./mcp-client"; import type { AddMcpServerResponseDto, - McpMarketplaceFilter, McpMarketplaceItemDto, McpMarketplacePageResultDto, } from "./mcp-types"; const MCP_MARKETPLACE_STALE_TIME_MS = 60_000; const MCP_MARKETPLACE_GC_TIME_MS = 15 * 60_000; -const PAGE_SIZE = 30; +const PAGE_SIZE = 20; export const mcpMarketplaceKeys = { all: ["marketplace", "mcp"] as const, - feed: (query: string, filter: McpMarketplaceFilter) => - ["marketplace", "mcp", "feed", query, filter] as const, + feed: (query: string) => + ["marketplace", "mcp", "feed", query] as const, detail: (qualifiedName: string) => ["marketplace", "mcp", "detail", qualifiedName] as const, installTargets: () => ["marketplace", "mcp", "install-targets"] as const, }; -export function useMcpMarketplaceFeedQuery(query: string, filter: McpMarketplaceFilter) { +export function useMcpMarketplaceFeedQuery(query: string) { const trimmed = query.trim(); - const usePopular = !trimmed && filter === "all"; + const usePopular = !trimmed; return useInfiniteQuery({ - queryKey: mcpMarketplaceKeys.feed(trimmed || "__popular__", filter), + queryKey: mcpMarketplaceKeys.feed(trimmed || "__popular__"), initialPageParam: 0, queryFn: ({ pageParam }) => usePopular ? fetchMcpMarketplacePopular({ limit: PAGE_SIZE, offset: pageParam }) : searchMcpMarketplace({ query: trimmed, - filter, limit: PAGE_SIZE, offset: pageParam, }), @@ -84,15 +82,26 @@ export function useAddMcpServerMutation() { return useMutation< AddMcpServerResponseDto, Error, - { qualifiedName: string; sourceHarness: string; displayName?: string } + { + qualifiedName: string; + sourceHarness: string; + displayName?: string; + config?: Record; + } >({ - mutationFn: ({ qualifiedName, sourceHarness }) => addMcpServer({ qualifiedName, sourceHarness }), + mutationFn: ({ qualifiedName, sourceHarness, config }) => + addMcpServer({ qualifiedName, sourceHarness, config }), onMutate: ({ qualifiedName }) => { begin(qualifiedName); }, onSuccess: (response, { displayName }) => { // Invalidate the central inventory so the card button flips to // "Open in MCPs" in place. User stays on the marketplace. + void checkMcpServerAvailability(response.server.name) + .catch(() => undefined) + .finally(() => { + void invalidateMcpQueries(queryClient); + }); void invalidateMcpQueries(queryClient); toast(copy.detail.installButton.addedToMcp(displayName ?? response.server.name)); }, diff --git a/frontend/src/features/marketplace/api/mcp-types.ts b/frontend/src/features/marketplace/api/mcp-types.ts index 10eed61..b781c69 100644 --- a/frontend/src/features/marketplace/api/mcp-types.ts +++ b/frontend/src/features/marketplace/api/mcp-types.ts @@ -13,6 +13,7 @@ export type McpCapabilityCountsDto = components["schemas"]["McpMarketplaceCapabi export type McpMarketplaceDetailDto = components["schemas"]["McpMarketplaceDetailResponse"]; export type McpInstallTargetDto = components["schemas"]["McpInstallTargetResponse"]; export type McpInstallTargetsDto = components["schemas"]["McpInstallTargetsResponse"]; -export type McpMarketplaceFilter = "all" | "remote" | "local" | "verified"; +export type McpInstallConfigDto = components["schemas"]["McpInstallConfigResponse"]; +export type McpInstallConfigFieldDto = components["schemas"]["McpInstallConfigFieldResponse"]; export type AddMcpServerRequestDto = components["schemas"]["AddMcpServerRequest"]; export type AddMcpServerResponseDto = components["schemas"]["McpServerMutationResponse"]; diff --git a/frontend/src/features/marketplace/components/MarketplaceLayout.tsx b/frontend/src/features/marketplace/components/MarketplaceLayout.tsx index 6a819dc..f40a579 100644 --- a/frontend/src/features/marketplace/components/MarketplaceLayout.tsx +++ b/frontend/src/features/marketplace/components/MarketplaceLayout.tsx @@ -11,7 +11,6 @@ import { PageHeader } from "../../../components/PageHeader"; import RouteLoadingPanel from "../../../components/RouteLoadingPanel"; import { useMarketplaceCopy, type MarketplaceCopy } from "../i18n"; import { marketplaceRoutes } from "../public"; -import type { McpMarketplaceFilter } from "../api/mcp-types"; import { InstallingProvider } from "../model/installing-context"; import { LazyMarketplaceMcpPage, @@ -25,14 +24,6 @@ import { prefetchMarketplacePopularFeed, } from "../lazy"; -const FILTER_VALUES: McpMarketplaceFilter[] = ["all", "remote", "local", "verified"]; - -function isFilterValue(value: string): value is McpMarketplaceFilter { - return (["all", "remote", "local", "verified"] as const).includes( - value as McpMarketplaceFilter, - ); -} - type ActiveTab = "skills" | "mcp" | "clis"; interface MarketplaceTabDefinition { @@ -62,7 +53,6 @@ export default function MarketplaceLayout() { : "skills"; const tabs = useMarketplaceTabs(copy); const activeTabDefinition = tabs.find((tab) => tab.key === activeTab) ?? tabs[0]; - const isMcp = activeTab === "mcp"; const isCli = activeTab === "clis"; const [hasVisitedSkills, setHasVisitedSkills] = useState(activeTab === "skills"); @@ -87,22 +77,13 @@ export default function MarketplaceLayout() { } }, [activeTab, previousTabRef, searchParams, setSearchParams]); - const filterParam = searchParams.get("filter") ?? "all"; - const mcpFilter: McpMarketplaceFilter = isFilterValue(filterParam) ? filterParam : "all"; - - const setMcpFilter = useCallback( - (value: McpMarketplaceFilter) => { - if (value === mcpFilter) return; + useEffect(() => { + if (searchParams.has("filter")) { const next = new URLSearchParams(searchParams); - if (value === "all") { - next.delete("filter"); - } else { - next.set("filter", value); - } - setSearchParams(next, { replace: false }); - }, - [mcpFilter, searchParams, setSearchParams], - ); + next.delete("filter"); + setSearchParams(next, { replace: true }); + } + }, [searchParams, setSearchParams]); const prefetchTab = useCallback( (tab: MarketplaceTabDefinition) => { @@ -161,26 +142,6 @@ export default function MarketplaceLayout() { ); })} -
- {FILTER_VALUES.map((value) => ( - - ))} -
} /> @@ -269,13 +230,6 @@ function useMarketplaceTabs(copy: MarketplaceCopy): readonly MarketplaceTabDefin ); } -function filterLabel(copy: MarketplaceCopy, value: McpMarketplaceFilter): string { - if (value === "all") return copy.filters.all; - if (value === "remote") return copy.filters.remote; - if (value === "local") return copy.filters.local; - return copy.filters.verified; -} - function usePrevious(value: T): T | null { const [prev, setPrev] = useState(null); useEffect(() => { diff --git a/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx b/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx index acda78b..c39c7f1 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx @@ -36,9 +36,30 @@ function createItem(overrides: Partial = {}): McpMarketpl }; } +function detailPayload(item: McpMarketplaceItemDto, overrides: Record = {}) { + return { + qualifiedName: item.qualifiedName, + managedName: "exa-mcp", + displayName: item.displayName, + description: item.description, + iconUrl: item.iconUrl, + isRemote: item.isRemote, + deploymentUrl: "https://exa.run.tools", + connections: [], + tools: [], + resources: [], + prompts: [], + capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, + externalUrl: item.externalUrl, + installConfig: { required: false, fields: [] }, + ...overrides, + }; +} + function renderCard( item: McpMarketplaceItemDto, inventoryPayload: object = { columns: [], entries: [] }, + detailOverrides: Record = {}, ) { fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); @@ -77,20 +98,14 @@ function renderCard( return okJson(inventoryPayload); } if (url.includes("/api/marketplace/mcp/items") && method === "GET") { + return okJson(detailPayload(item, detailOverrides)); + } + if (url.includes("/api/mcp/servers/exa-mcp/availability/check") && method === "POST") { return okJson({ - qualifiedName: item.qualifiedName, - managedName: "exa-mcp", - displayName: item.displayName, - description: item.description, - iconUrl: item.iconUrl, - isRemote: item.isRemote, - deploymentUrl: "https://exa.run.tools", - connections: [], - tools: [], - resources: [], - prompts: [], - capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, - externalUrl: item.externalUrl, + ok: true, + name: "exa-mcp", + availabilityStatus: "available", + availabilityReason: null, }); } if (url.includes("/api/mcp/servers") && method === "POST") { @@ -152,6 +167,16 @@ describe("McpMarketplaceCard", () => { renderCard(createItem()); expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toHaveTextContent("Add to MCPs"); + expect(screen.queryByText("Remote")).not.toBeInTheDocument(); + expect(screen.queryByText("Verified")).not.toBeInTheDocument(); + expect(screen.queryByText("1.2k")).not.toBeInTheDocument(); + }); + + it("falls back to a single initial when an item has no icon", () => { + const { container } = renderCard(createItem({ iconUrl: null, displayName: "Exa Search" })); + + expect(container.querySelector(".market-card__avatar")).toHaveTextContent("E"); + expect(container.querySelector(".market-card__avatar")).not.toHaveTextContent("EX"); }); it("does not open detail when the install button is clicked", async () => { @@ -174,6 +199,23 @@ describe("McpMarketplaceCard", () => { expect(onOpenDetail).not.toHaveBeenCalled(); }); + it("checks availability after installing from the marketplace", async () => { + renderCard(createItem()); + await waitFor(() => + expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(), + ); + + fireEvent.click(screen.getByRole("button", { name: /add exa search to mcps/i })); + fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/mcp/servers/exa-mcp/availability/check"), + expect.objectContaining({ method: "POST" }), + ); + }); + }); + it("renders an install button for local items", async () => { renderCard(createItem({ isRemote: false, isDeployed: false })); await waitFor(() => @@ -191,6 +233,63 @@ describe("McpMarketplaceCard", () => { }); }); + it("opens a config dialog for required registry install fields and submits config", async () => { + renderCard(createItem(), { columns: [], entries: [] }, { + installConfig: { + required: true, + fields: [ + { + name: "CUEAPI_API_KEY", + label: "CUEAPI_API_KEY", + description: "CueAPI API key. Generate at https://cueapi.ai or app.i18nagent.ai.", + format: "string", + required: true, + secret: true, + default: null, + placeholder: null, + choices: [], + target: "env", + }, + ], + }, + }); + await waitFor(() => + expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(), + ); + + fireEvent.click(screen.getByRole("button", { name: /add exa search to mcps/i })); + fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); + + const input = await screen.findByLabelText(/CUEAPI_API_KEY/i, { selector: "input" }); + expect(screen.getByRole("link", { name: "https://cueapi.ai" })).toHaveAttribute( + "href", + "https://cueapi.ai", + ); + expect(screen.getByRole("link", { name: "app.i18nagent.ai" })).toHaveAttribute( + "href", + "https://app.i18nagent.ai", + ); + expect(screen.getByRole("button", { name: /^install$/i })).toBeDisabled(); + fireEvent.change(input, { target: { value: "cue-key" } }); + fireEvent.click(screen.getByRole("button", { name: /^install$/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/mcp/servers"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("CUEAPI_API_KEY"), + }), + ); + }); + const postCall = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes("/api/mcp/servers") && init?.method === "POST", + ); + expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({ + config: { CUEAPI_API_KEY: "cue-key" }, + }); + }); + it("keeps undeployed remote items installable because Smithery writes the source config", async () => { renderCard(createItem({ isRemote: true, isDeployed: false })); await waitFor(() => diff --git a/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx b/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx index 221d06a..5224e8d 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx @@ -1,14 +1,12 @@ import { type KeyboardEvent, useState } from "react"; -import { Activity, CheckCircle2 } from "lucide-react"; -import { UiTooltip } from "../../../components/ui/UiTooltip"; import type { McpMarketplaceItemDto } from "../api/mcp-types"; import { useMarketplaceCopy } from "../i18n"; -import { formatMcpUseCount } from "../model/formatters"; import { summaryInstallAvailability, useMcpInstallActionState, } from "../model/mcp-install-action"; +import { McpInstallConfigDialog } from "./McpInstallConfigDialog"; import { McpInstallButton } from "./McpInstallButton"; interface McpMarketplaceCardProps { @@ -19,7 +17,7 @@ interface McpMarketplaceCardProps { function avatarFallbackLabel(item: McpMarketplaceItemDto): string { const source = item.displayName || item.qualifiedName; - return source.slice(0, 2).toUpperCase(); + return source.slice(0, 1).toUpperCase(); } export function McpMarketplaceCard({ item, onOpenDetail }: McpMarketplaceCardProps) { @@ -41,65 +39,56 @@ export function McpMarketplaceCard({ item, onOpenDetail }: McpMarketplaceCardPro } return ( -
-
-
- {avatarSrc ? ( - {copy.detail.cards.iconFor(item.displayName)} setAvatarFailed(true)} - /> - ) : ( - avatarFallbackLabel(item) - )} -
-
-

{item.displayName}

-

{item.qualifiedName}

+ <> +
+
+
+ {avatarSrc ? ( + {copy.detail.cards.iconFor(item.displayName)} setAvatarFailed(true)} + /> + ) : ( + avatarFallbackLabel(item) + )} +
+
+

{item.displayName}

+

{item.qualifiedName}

+
-
-

- {item.description || copy.detail.mcp.noDescription} -

+

+ {item.description || copy.detail.mcp.noDescription} +

-
-
- - {item.isRemote ? copy.detail.mcp.remote : copy.detail.mcp.local} - - {item.isVerified ? ( - - - ) : null} -
-
- - - - - +
+
+ +
-
-
+ + + ); } diff --git a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx index 0a10cb8..59f68f0 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx @@ -39,7 +39,7 @@ function itemFixture(): McpMarketplaceItemDto { useCount: 59087, createdAt: null, homepage: "https://exa.ai", - externalUrl: "https://smithery.ai/server/exa", + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", }; } @@ -57,7 +57,7 @@ function detailFixture(): McpMarketplaceDetailDto { resources: [], prompts: [], capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, - externalUrl: "https://smithery.ai/server/exa", + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", }; } @@ -128,9 +128,9 @@ describe("McpMarketplaceDetailView", () => { ); expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(); expect(screen.getByLabelText("Source links for Exa Search")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "View on smithery.ai" })).toHaveAttribute( + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toHaveAttribute( "href", - "https://smithery.ai/server/exa", + "https://registry.modelcontextprotocol.io/?q=exa", ); expect(document.querySelector(`.${"mcp-detail"}__external`)).not.toBeInTheDocument(); }); diff --git a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx index 76ee578..ba5c83c 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx @@ -15,6 +15,7 @@ import { detailInstallAvailability, useMcpInstallActionState, } from "../model/mcp-install-action"; +import { McpInstallConfigDialog } from "./McpInstallConfigDialog"; import { McpInstallButton } from "./McpInstallButton"; import { McpToolEntry } from "./McpToolEntry"; @@ -26,6 +27,10 @@ interface McpMarketplaceDetailViewProps { const TOOL_INITIAL_COUNT = 5; +function registrySearchUrl(qualifiedName: string): string { + return `https://registry.modelcontextprotocol.io/?${new URLSearchParams({ q: qualifiedName }).toString()}`; +} + export function McpMarketplaceDetailView({ qualifiedName, initialItem, @@ -46,7 +51,9 @@ export function McpMarketplaceDetailView({ const headerIsRemote = detail?.isRemote ?? initialItem?.isRemote ?? false; const headerIcon = detail?.iconUrl ?? initialItem?.iconUrl ?? null; const headerExternalUrl = - detail?.externalUrl ?? initialItem?.externalUrl ?? `https://smithery.ai/server/${qualifiedName}`; + detail?.externalUrl ?? + initialItem?.externalUrl ?? + registrySearchUrl(qualifiedName); const installAction = useMcpInstallActionState({ qualifiedName, displayName: headerDisplayName, @@ -166,7 +173,7 @@ export function McpMarketplaceDetailView({ links={[ { href: headerExternalUrl, - label: copy.detail.mcp.viewOnSmithery, + label: copy.detail.mcp.viewInRegistry, kind: "marketplace", }, ]} @@ -325,6 +332,12 @@ export function McpMarketplaceDetailView({ ) : null} + ); } diff --git a/frontend/src/features/marketplace/i18n.ts b/frontend/src/features/marketplace/i18n.ts index 538a969..9e37cc2 100644 --- a/frontend/src/features/marketplace/i18n.ts +++ b/frontend/src/features/marketplace/i18n.ts @@ -4,7 +4,6 @@ const englishMarketplaceCopy = { title: "Marketplace", previewOnlyNote: "Preview only · Skill Manager does not install or manage CLIs", typeAria: "Marketplace type", - mcpFilterAria: "Filter MCP servers", loading: { marketplace: "Loading marketplace", skills: "Loading marketplace", @@ -22,12 +21,6 @@ const englishMarketplaceCopy = { mcp: "MCP", clis: "CLIs", }, - filters: { - all: "All", - remote: "Remote", - local: "Local", - verified: "Verified", - }, search: { skillsPlaceholder: "Search skills.sh by name or topic", skillsLabel: "Search marketplace", @@ -76,7 +69,7 @@ const englishMarketplaceCopy = { unableDetail: "Unable to load MCP server detail.", tryReopen: "Try reopening the server from the marketplace grid.", sourceLinksAria: (name: string) => `Source links for ${name}`, - viewOnSmithery: "View on smithery.ai", + viewInRegistry: "View in MCP Registry", remote: "Remote", local: "Local", verified: "Verified", @@ -144,6 +137,17 @@ const englishMarketplaceCopy = { addedToMcp: (name: string) => `${name} added to your MCP servers`, installFailed: "Install failed", }, + installConfig: { + title: (name: string) => `Configure ${name}`, + description: (harness: string) => `Install into ${harness}. These values will be written to your local Agent MCP config.`, + requiredHint: "Complete the required fields before installing.", + optionalHint: "Optional configuration", + missingRequired: (fields: string) => `Missing required fields: ${fields}`, + install: "Install", + cancel: "Cancel", + showSecret: "Show secret", + hideSecret: "Hide secret", + }, cards: { openSkillMarketplaceDetail: (name: string) => `Open marketplace detail for ${name}`, avatarFor: (label: string) => `Avatar for ${label}`, @@ -163,7 +167,6 @@ export const marketplaceCopy = { title: "商城", previewOnlyNote: "仅预览 · Skill Manager 不会安装或管理 CLI", typeAria: "商城类型", - mcpFilterAria: "筛选 MCP 服务器", loading: { marketplace: "正在加载商城", skills: "正在加载商城", @@ -181,12 +184,6 @@ export const marketplaceCopy = { mcp: "MCP", clis: "CLI", }, - filters: { - all: "全部", - remote: "远程", - local: "本地", - verified: "已验证", - }, search: { skillsPlaceholder: "按名称或主题搜索 skills.sh", skillsLabel: "搜索 Skill 商城", @@ -235,7 +232,7 @@ export const marketplaceCopy = { unableDetail: "无法加载 MCP 服务器详情。", tryReopen: "请从商城网格中重新打开此服务器。", sourceLinksAria: (name: string) => `${name} 的来源链接`, - viewOnSmithery: "在 smithery.ai 查看", + viewInRegistry: "在 MCP Registry 查看", remote: "远程", local: "本地", verified: "已验证", @@ -302,6 +299,17 @@ export const marketplaceCopy = { addedToMcp: (name: string) => `${name} 已添加到 MCP 服务器`, installFailed: "安装失败", }, + installConfig: { + title: (name: string) => `配置 ${name}`, + description: (harness: string) => `安装到 ${harness}。这些值会写入本机 Agent MCP 配置。`, + requiredHint: "安装前请填写必填字段。", + optionalHint: "可选配置", + missingRequired: (fields: string) => `缺少必填字段:${fields}`, + install: "安装", + cancel: "取消", + showSecret: "显示密钥", + hideSecret: "隐藏密钥", + }, cards: { openSkillMarketplaceDetail: (name: string) => `打开 ${name} 的商城详情`, avatarFor: (label: string) => `${label} 的头像`, diff --git a/frontend/src/features/marketplace/lazy.ts b/frontend/src/features/marketplace/lazy.ts index 5dd8542..4eb0b2a 100644 --- a/frontend/src/features/marketplace/lazy.ts +++ b/frontend/src/features/marketplace/lazy.ts @@ -8,6 +8,14 @@ import { fetchMcpMarketplacePopular } from "./api/mcp-client"; import { mcpMarketplaceKeys } from "./api/mcp-queries"; import { marketplaceKeys } from "./api/queries"; +type MarketplacePage = { + hasMore: boolean; + nextOffset?: number | null; +}; + +const getNextMarketplacePageParam = (lastPage: MarketplacePage) => + lastPage.hasMore ? lastPage.nextOffset ?? undefined : undefined; + const marketplacePageImport = () => import("./screens/MarketplacePage"); const marketplaceMcpPageImport = () => import("./screens/MarketplaceMcpPage"); const marketplaceCliPageImport = () => import("./screens/MarketplaceCliPage"); @@ -33,14 +41,16 @@ export function prefetchMarketplacePopularFeed(queryClient: QueryClient): void { queryKey: marketplaceKeys.feed("__popular__"), queryFn: ({ pageParam }) => fetchMarketplacePopular({ limit: 20, offset: pageParam }), initialPageParam: 0, + getNextPageParam: getNextMarketplacePageParam, }); } export function prefetchMarketplaceMcpFeed(queryClient: QueryClient): void { void queryClient.prefetchInfiniteQuery({ - queryKey: mcpMarketplaceKeys.feed("__popular__", "all"), - queryFn: ({ pageParam }) => fetchMcpMarketplacePopular({ limit: 30, offset: pageParam }), + queryKey: mcpMarketplaceKeys.feed("__popular__"), + queryFn: ({ pageParam }) => fetchMcpMarketplacePopular({ limit: 20, offset: pageParam }), initialPageParam: 0, + getNextPageParam: getNextMarketplacePageParam, }); } @@ -49,5 +59,6 @@ export function prefetchMarketplaceCliFeed(queryClient: QueryClient): void { queryKey: cliMarketplaceKeys.feed("__popular__"), queryFn: ({ pageParam }) => fetchCliMarketplacePopular({ limit: 30, offset: pageParam }), initialPageParam: 0, + getNextPageParam: getNextMarketplacePageParam, }); } diff --git a/frontend/src/features/marketplace/model/mcp-install-action.ts b/frontend/src/features/marketplace/model/mcp-install-action.ts index c0daa2a..b361689 100644 --- a/frontend/src/features/marketplace/model/mcp-install-action.ts +++ b/frontend/src/features/marketplace/model/mcp-install-action.ts @@ -1,8 +1,11 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; -import { useAddMcpServerMutation, useMcpInstallTargetsQuery } from "../api/mcp-queries"; +import { fetchMcpMarketplaceDetail } from "../api/mcp-client"; +import { mcpMarketplaceKeys, useAddMcpServerMutation, useMcpInstallTargetsQuery } from "../api/mcp-queries"; import type { AddMcpServerResponseDto, + McpInstallConfigDto, McpInstallTargetDto, McpMarketplaceDetailDto, McpMarketplaceItemDto, @@ -15,6 +18,13 @@ export type McpInstallAvailability = | { kind: "unavailable"; reason: string }; export type McpSourceHarness = string; +export type McpInstallConfigValues = Record; +export interface PendingMcpInstallConfig { + qualifiedName: string; + sourceHarness: McpSourceHarness; + displayName: string; + installConfig: McpInstallConfigDto; +} export type McpInstallTargetState = | { kind: "loading" } | { kind: "error"; message: string } @@ -44,7 +54,10 @@ interface McpInstallActionState { installedState: InstalledState; installTargetState: McpInstallTargetState; installing: boolean; + pendingConfig: PendingMcpInstallConfig | null; onInstall: (sourceHarness: McpSourceHarness) => void; + onCancelConfig: () => void; + onSubmitConfig: (config: McpInstallConfigValues) => void; } export function useMcpInstallActionState({ @@ -54,19 +67,62 @@ export function useMcpInstallActionState({ }: UseMcpInstallActionStateParams): McpInstallActionState { const { lookup } = useInstalledServerLookup(); const { isInstalling } = useInstallingState(); + const queryClient = useQueryClient(); const installMutation = useAddMcpServerMutation(); const installTargetsQuery = useMcpInstallTargetsQuery(); + const [pendingConfig, setPendingConfig] = useState(null); - const onInstall = useCallback( - (sourceHarness: McpSourceHarness) => { + const submitInstall = useCallback( + (sourceHarness: McpSourceHarness, config?: McpInstallConfigValues) => { installMutation.mutate( - { qualifiedName, sourceHarness, displayName }, - { onSuccess: (response) => onInstalled?.(response) }, + { qualifiedName, sourceHarness, displayName, config }, + { + onSuccess: (response) => { + setPendingConfig(null); + onInstalled?.(response); + }, + }, ); }, [displayName, installMutation, onInstalled, qualifiedName], ); + const onInstall = useCallback( + (sourceHarness: McpSourceHarness) => { + void queryClient + .fetchQuery({ + queryKey: mcpMarketplaceKeys.detail(qualifiedName), + queryFn: () => fetchMcpMarketplaceDetail(qualifiedName), + }) + .then((detail) => { + const installConfig = detail.installConfig; + if (installConfig?.fields?.length) { + setPendingConfig({ qualifiedName, sourceHarness, displayName, installConfig }); + return; + } + submitInstall(sourceHarness); + }) + .catch(() => { + submitInstall(sourceHarness); + }); + }, + [displayName, qualifiedName, queryClient, submitInstall], + ); + + const onCancelConfig = useCallback(() => { + setPendingConfig(null); + }, []); + + const onSubmitConfig = useCallback( + (config: McpInstallConfigValues) => { + if (!pendingConfig) { + return; + } + submitInstall(pendingConfig.sourceHarness, config); + }, + [pendingConfig, submitInstall], + ); + return { installedState: lookup(qualifiedName), installTargetState: resolveInstallTargetState( @@ -75,7 +131,10 @@ export function useMcpInstallActionState({ installTargetsQuery.data?.targets, ), installing: isInstalling(qualifiedName), + pendingConfig, onInstall, + onCancelConfig, + onSubmitConfig, }; } diff --git a/frontend/src/features/marketplace/model/use-mcp-marketplace-controller.ts b/frontend/src/features/marketplace/model/use-mcp-marketplace-controller.ts index 780f9d3..915829f 100644 --- a/frontend/src/features/marketplace/model/use-mcp-marketplace-controller.ts +++ b/frontend/src/features/marketplace/model/use-mcp-marketplace-controller.ts @@ -6,25 +6,12 @@ import { useMcpMarketplaceFeedQuery, } from "../api/mcp-queries"; import type { - McpMarketplaceFilter, McpMarketplaceItemDto, } from "../api/mcp-types"; -const FILTER_VALUES: readonly McpMarketplaceFilter[] = [ - "all", - "remote", - "local", - "verified", -]; - -function isFilterValue(value: string): value is McpMarketplaceFilter { - return (FILTER_VALUES as readonly string[]).includes(value); -} - export interface McpMarketplaceController { query: string; submittedQuery: string; - filter: McpMarketplaceFilter; items: McpMarketplaceItemDto[]; feedQuery: ReturnType; status: "loading" | "ready" | "error"; @@ -34,7 +21,6 @@ export interface McpMarketplaceController { selectedName: string | null; selectedItem: McpMarketplaceItemDto | null; setQuery: (value: string) => void; - setFilter: (value: McpMarketplaceFilter) => void; openItem: (qualifiedName: string) => void; closeItem: () => void; } @@ -54,10 +40,7 @@ export function useMcpMarketplaceController( const [submittedQuery, setSubmittedQuery] = useState(""); const [errorMessage, setErrorMessage] = useState(""); - const filterParam = searchParams.get("filter") ?? "all"; - const filter: McpMarketplaceFilter = isFilterValue(filterParam) ? filterParam : "all"; - - const feedQuery = useMcpMarketplaceFeedQuery(submittedQuery, filter); + const feedQuery = useMcpMarketplaceFeedQuery(submittedQuery); const items = useMemo(() => flattenMcpMarketplaceItems(feedQuery.data), [feedQuery.data]); const status: "loading" | "ready" | "error" = feedQuery.isPending @@ -101,21 +84,9 @@ export function useMcpMarketplaceController( setSearchParams(next, { replace: false }); } - function setFilter(value: McpMarketplaceFilter): void { - if (value === filter) { - return; - } - if (value === "all") { - updateParams({ filter: null }); - } else { - updateParams({ filter: value }); - } - } - return { query, submittedQuery, - filter, items, feedQuery, status, @@ -125,7 +96,6 @@ export function useMcpMarketplaceController( selectedName, selectedItem, setQuery, - setFilter, openItem: (qualifiedName) => updateParams({ item: qualifiedName }), closeItem: () => updateParams({ item: null }), }; diff --git a/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx b/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx index c38788d..67a1a1f 100644 --- a/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx +++ b/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx @@ -80,7 +80,7 @@ describe("MarketplaceMcpPage", () => { const detail = deferred>(); fetchMock.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/api/marketplace/mcp/popular?limit=30&offset=0")) { + if (url.includes("/api/marketplace/mcp/popular?limit=20&offset=0")) { return okJson({ items: [pageItem()], nextOffset: null, hasMore: false }); } if (url.includes("/api/marketplace/mcp/install-targets")) { diff --git a/frontend/src/features/marketplace/styles/mcp-detail.css b/frontend/src/features/marketplace/styles/mcp-detail.css index bb69aaf..8a61de5 100644 --- a/frontend/src/features/marketplace/styles/mcp-detail.css +++ b/frontend/src/features/marketplace/styles/mcp-detail.css @@ -93,58 +93,6 @@ align-items: flex-end; } -/* Filter group lives in the shared marketplace layout — a single DOM node that - animates smoothly in both directions when the user toggles between Skills and MCP. - We transition max-width + opacity + transform + negative margin to collapse the - flex gap when hidden so the neighbouring tab pill doesn't jump. */ - -.mcp-filter-group { - display: inline-flex; - align-items: center; - overflow: hidden; - clip-path: inset(0); - visibility: visible; - transition: - max-width 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - margin-left 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1), - transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - visibility 0s linear 0s; -} - -.mcp-filter-group[data-state="visible"] { - /* Match the natural content width (~217px) so the transition uses its full - duration instead of stopping early when max-width exceeds content. */ - max-width: 240px; - opacity: 1; - transform: translateX(0); - visibility: visible; -} - -.mcp-filter-group[data-state="hidden"] { - max-width: 0; - margin-left: calc(-1 * var(--space-3)); - opacity: 0; - transform: translateX(4px); - pointer-events: none; - /* visibility flips to hidden AFTER the fade/slide finishes so the transition - still plays — but once fully hidden the subtree is removed from painting, - so flex children that overflow the clipped container can't ghost through. */ - visibility: hidden; - transition: - max-width 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - margin-left 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1), - transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1), - visibility 0s linear 240ms; -} - -@media (prefers-reduced-motion: reduce) { - .mcp-filter-group { - transition: none; - } -} - /* MCP detail modal */ .mcp-detail__meta-stack, diff --git a/frontend/src/features/mcp/api/keys.ts b/frontend/src/features/mcp/api/keys.ts index c83eb65..828a7d1 100644 --- a/frontend/src/features/mcp/api/keys.ts +++ b/frontend/src/features/mcp/api/keys.ts @@ -1,5 +1,6 @@ export const MCP_STALE_TIME_MS = 30_000; export const MCP_GC_TIME_MS = 5 * 60_000; +export const MCP_INVENTORY_REFETCH_INTERVAL_MS = 5_000; export const mcpManagementKeys = { all: ["mcp"] as const, diff --git a/frontend/src/features/mcp/api/management-client.ts b/frontend/src/features/mcp/api/management-client.ts index bb50da3..8bc71e1 100644 --- a/frontend/src/features/mcp/api/management-client.ts +++ b/frontend/src/features/mcp/api/management-client.ts @@ -2,6 +2,7 @@ import { deleteJson, fetchJson, postJson } from "../../../api/http"; import type { McpApplyConfigResponseDto, + McpAvailabilityCheckResponseDto, McpInventoryDto, McpServerDetailDto, McpNeedsReviewByServerDto, @@ -49,6 +50,12 @@ export async function fetchMcpServerDetail(name: string): Promise(`/mcp/servers/${encodeURIComponent(name)}`); } +export async function checkMcpServerAvailability(name: string): Promise { + return postJson( + `/mcp/servers/${encodeURIComponent(name)}/availability/check`, + ); +} + export async function reconcileMcpServer(args: { name: string; sourceKind: "managed" | "harness"; diff --git a/frontend/src/features/mcp/api/management-queries.ts b/frontend/src/features/mcp/api/management-queries.ts index 08ef2f3..3ca0c74 100644 --- a/frontend/src/features/mcp/api/management-queries.ts +++ b/frontend/src/features/mcp/api/management-queries.ts @@ -7,6 +7,7 @@ import { import { queryPolicy } from "../../../lib/query"; import { adoptMcpServer, + checkMcpServerAvailability, disableMcpServer, enableMcpServer, fetchMcpInventory, @@ -17,7 +18,7 @@ import { uninstallMcpServer, } from "./management-client"; import { invalidateMcpQueries } from "./invalidation"; -import { MCP_GC_TIME_MS, MCP_STALE_TIME_MS, mcpManagementKeys } from "./keys"; +import { MCP_GC_TIME_MS, MCP_INVENTORY_REFETCH_INTERVAL_MS, MCP_STALE_TIME_MS, mcpManagementKeys } from "./keys"; export { invalidateMcpQueries } from "./invalidation"; export { mcpManagementKeys } from "./keys"; @@ -26,6 +27,7 @@ export function useMcpInventoryQuery() { return useQuery({ queryKey: mcpManagementKeys.inventory(), queryFn: fetchMcpInventory, + refetchInterval: MCP_INVENTORY_REFETCH_INTERVAL_MS, ...queryPolicy(MCP_STALE_TIME_MS, MCP_GC_TIME_MS), }); } @@ -71,6 +73,16 @@ export function useMcpServerDetailQuery(name: string | null) { }); } +export function useCheckMcpServerAvailabilityMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: checkMcpServerAvailability, + retry: 2, + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000), + onSettled: () => invalidateMcpQueries(queryClient), + }); +} + export function useMcpNeedsReviewByServerQuery() { return useQuery({ queryKey: mcpManagementKeys.needsReviewByServer(), diff --git a/frontend/src/features/mcp/api/management-types.ts b/frontend/src/features/mcp/api/management-types.ts index 59c9dcc..60197e7 100644 --- a/frontend/src/features/mcp/api/management-types.ts +++ b/frontend/src/features/mcp/api/management-types.ts @@ -12,6 +12,7 @@ export type McpNeedsReviewHarnessDto = components["schemas"]["McpUnmanagedHarnes export type SetMcpHarnessesResponseDto = components["schemas"]["McpSetHarnessesResultResponse"]; export type UninstallMcpResponseDto = components["schemas"]["McpSetHarnessesResultResponse"]; export type McpApplyConfigResponseDto = components["schemas"]["McpApplyConfigResponse"]; +export type McpAvailabilityCheckResponseDto = components["schemas"]["McpAvailabilityCheckResponse"]; export type McpConfigChoiceDto = components["schemas"]["McpConfigChoiceResponse"]; export type McpEnvEntryDto = components["schemas"]["McpEnvEntryResponse"]; export type McpMarketplaceLinkDto = components["schemas"]["McpMarketplaceLinkResponse"]; diff --git a/frontend/src/features/mcp/components/McpServerCard.tsx b/frontend/src/features/mcp/components/McpServerCard.tsx index 3619a51..07cde4c 100644 --- a/frontend/src/features/mcp/components/McpServerCard.tsx +++ b/frontend/src/features/mcp/components/McpServerCard.tsx @@ -7,6 +7,7 @@ import { OverflowTooltipText } from "../../../components/ui/OverflowTooltipText" import type { McpInventoryColumnDto, McpInventoryEntryDto } from "../api/management-types"; import { useMcpCopy } from "../i18n"; import { isMcpHarnessAddressable } from "../model/selectors"; +import { McpAvailabilityStatusChip } from "./McpAvailabilityStatusChip"; import { McpHarnessLogoStack } from "./McpHarnessLogoStack"; interface McpServerCardProps { @@ -98,6 +99,10 @@ export function McpServerCard({ {entry.displayName} +