diff --git a/README.md b/README.md index b114271..ddeb90f 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Actions that can change local state include: - removing or deleting a skill - creating, updating, validating, activating, or deleting an LLM scan configuration - running a Skill scan, which sends selected Skill context to the configured LLM provider -- installing an MCP server into a source harness +- installing an MCP server into a selected harness config - adopting an existing MCP config - enabling, disabling, resolving, or uninstalling an MCP server - creating, updating, syncing, importing, or deleting a slash command diff --git a/assets/skill-manager-mcp-translation.svg b/assets/skill-manager-mcp-translation.svg index ba9f13a..35a1685 100644 --- a/assets/skill-manager-mcp-translation.svg +++ b/assets/skill-manager-mcp-translation.svg @@ -1,6 +1,6 @@ How Skill Manager translates MCP server configs across harnesses -Skill Manager imports a valid config from the source harness, normalizes it into a single record with named fields, then projects that record into each harness's own config format using verified codecs. OpenClaw is capability-gated and may be skipped if the local client doesn't support the required config surface. +Skill Manager imports a valid config from the selected harness config, normalizes it into a single record with named fields, then projects that record into each harness's own config format using verified codecs. OpenClaw is capability-gated and may be skipped if the local client doesn't support the required config surface. @@ -11,7 +11,7 @@ -SOURCE HARNESS +SELECTED CONFIG Where you installed the MCP server diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 924c5b5..2d1fb76 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -123,23 +123,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/marketplace/mcp/install-targets": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Mcp Install Targets */ - get: operations["get_mcp_install_targets_api_marketplace_mcp_install_targets_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/marketplace/mcp/items/{qualified_name}": { parameters: { query?: never; @@ -261,6 +244,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; @@ -818,8 +818,6 @@ export interface components { AddMcpServerRequest: { /** Qualifiedname */ qualifiedName: string; - /** Sourceharness */ - sourceHarness: string; }; /** AdoptMcpRequest */ AdoptMcpRequest: { @@ -827,8 +825,8 @@ export interface components { harnesses?: string[] | null; /** Name */ name: string; - /** Sourceharness */ - sourceHarness?: string | null; + /** Observed harness */ + observedHarness?: string | null; }; /** BulkManageFailureResponse */ BulkManageFailureResponse: { @@ -968,6 +966,10 @@ export interface components { }; /** EnableMcpServerRequest */ EnableMcpServerRequest: { + /** Config */ + config?: { + [key: string]: unknown; + } | null; /** * Harness * @description Harness identifier @@ -1059,6 +1061,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 */ @@ -1081,12 +1097,12 @@ export interface components { label: string; /** Logokey */ logoKey?: string | null; + /** Observed harness */ + observedHarness?: string | null; /** Payloadpreview */ payloadPreview: { [key: string]: unknown; }; - /** Sourceharness */ - sourceHarness?: string | null; /** * Sourcekind * @enum {string} @@ -1132,25 +1148,50 @@ export interface components { }; spec: components["schemas"]["McpServerSpecResponse"]; }; - /** McpInstallTargetResponse */ - McpInstallTargetResponse: { - /** Harness */ - harness: string; + /** McpInstallConfigFieldResponse */ + McpInstallConfigFieldResponse: { + /** Choices */ + choices?: string[]; + /** Default */ + default?: string | null; + /** Description */ + description: string; + /** + * Format + * @enum {string} + */ + format: "string" | "number" | "boolean" | "filepath"; /** Label */ label: string; - /** Logokey */ - logoKey?: string | null; - /** Reason */ - reason?: string | null; - /** Smitheryclient */ - smitheryClient?: string | null; - /** Supported */ - supported: boolean; + /** Name */ + name: string; + /** Placeholder */ + placeholder?: string | null; + /** Required */ + required: boolean; + /** Secret */ + secret: boolean; + /** + * Target + * @enum {string} + */ + target: "env" | "header" | "urlVariable" | "packageArgument" | "runtimeArgument"; }; - /** McpInstallTargetsResponse */ - McpInstallTargetsResponse: { - /** Targets */ - targets: components["schemas"]["McpInstallTargetResponse"][]; + /** McpInstallConfigResponse */ + McpInstallConfigResponse: { + /** Fields */ + fields?: components["schemas"]["McpInstallConfigFieldResponse"][]; + /** Required */ + required: boolean; + }; + /** McpInstallConfigStatusResponse */ + McpInstallConfigStatusResponse: { + /** Configured */ + configured: boolean; + /** Hasfields */ + hasFields: boolean; + /** Missingrequired */ + missingRequired: string[]; }; /** McpInventoryColumnResponse */ McpInventoryColumnResponse: { @@ -1174,15 +1215,29 @@ 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"; + installConfigStatus: components["schemas"]["McpInstallConfigStatusResponse"]; /** * Kind * @enum {string} */ kind: "managed" | "unmanaged"; + mcpStatus: components["schemas"]["McpStatusResponse"]; /** Name */ name: string; /** Sightings */ @@ -1248,8 +1303,11 @@ export interface components { displayName: string; /** Externalurl */ externalUrl: string; + /** Githuburl */ + githubUrl?: string | null; /** Iconurl */ iconUrl?: string | null; + installConfig?: components["schemas"]["McpInstallConfigResponse"]; /** Isremote */ isRemote: boolean; /** Managedname */ @@ -1262,6 +1320,8 @@ export interface components { resources: components["schemas"]["McpMarketplaceResourceResponse"][]; /** Tools */ tools: components["schemas"]["McpMarketplaceToolResponse"][]; + /** Websiteurl */ + websiteUrl?: string | null; }; /** McpMarketplaceItemResponse */ McpMarketplaceItemResponse: { @@ -1273,6 +1333,8 @@ export interface components { displayName: string; /** Externalurl */ externalUrl: string; + /** Githuburl */ + githubUrl?: string | null; /** Homepage */ homepage?: string | null; /** Iconurl */ @@ -1289,6 +1351,8 @@ export interface components { qualifiedName: string; /** Usecount */ useCount: number; + /** Websiteurl */ + websiteUrl?: string | null; }; /** McpMarketplaceLinkResponse */ McpMarketplaceLinkResponse: { @@ -1298,6 +1362,8 @@ export interface components { displayName: string; /** Externalurl */ externalUrl: string; + /** Githuburl */ + githubUrl?: string | null; /** Iconurl */ iconUrl?: string | null; /** Isremote */ @@ -1306,6 +1372,8 @@ export interface components { isVerified: boolean; /** Qualifiedname */ qualifiedName: string; + /** Websiteurl */ + websiteUrl?: string | null; }; /** McpMarketplacePageResponse */ McpMarketplacePageResponse: { @@ -1390,20 +1458,34 @@ 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"][]; + installConfigStatus: components["schemas"]["McpInstallConfigStatusResponse"]; /** * Kind * @enum {string} */ kind: "managed" | "unmanaged"; marketplaceLink?: components["schemas"]["McpMarketplaceLinkResponse"] | null; + mcpStatus: components["schemas"]["McpStatusResponse"]; /** Name */ name: string; /** Sightings */ @@ -1466,6 +1548,16 @@ export interface components { /** Locator */ locator: string; }; + /** McpStatusResponse */ + McpStatusResponse: { + /** + * Kind + * @enum {string} + */ + kind: "available" | "needs_config" | "connection_issue" | "unchecked"; + /** Reason */ + reason?: string | null; + }; /** McpUnmanagedByServerResponse */ McpUnmanagedByServerResponse: { /** Harnesses */ @@ -1506,8 +1598,8 @@ export interface components { ReconcileMcpServerRequest: { /** Harnesses */ harnesses?: string[] | null; - /** Sourceharness */ - sourceHarness?: string | null; + /** Observed harness */ + observedHarness?: string | null; /** * Sourcekind * @enum {string} @@ -1764,6 +1856,10 @@ export interface components { }; /** SetMcpServerHarnessesRequest */ SetMcpServerHarnessesRequest: { + /** Config */ + config?: { + [key: string]: unknown; + } | null; /** * Target * @enum {string} @@ -2374,26 +2470,6 @@ export interface operations { }; }; }; - get_mcp_install_targets_api_marketplace_mcp_install_targets_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["McpInstallTargetsResponse"]; - }; - }; - }; - }; get_mcp_marketplace_detail_api_marketplace_mcp_items__qualified_name__get: { parameters: { query?: never; @@ -2676,6 +2752,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..9743c5e 100644 --- a/frontend/src/api/openapi.json +++ b/frontend/src/api/openapi.json @@ -2,21 +2,16 @@ "components": { "schemas": { "AddMcpServerRequest": { + "additionalProperties": false, "properties": { "qualifiedName": { "minLength": 1, "title": "Qualifiedname", "type": "string" - }, - "sourceHarness": { - "minLength": 1, - "title": "Sourceharness", - "type": "string" } }, "required": [ - "qualifiedName", - "sourceHarness" + "qualifiedName" ], "title": "AddMcpServerRequest", "type": "object" @@ -43,7 +38,7 @@ "title": "Name", "type": "string" }, - "sourceHarness": { + "observedHarness": { "anyOf": [ { "type": "string" @@ -52,7 +47,7 @@ "type": "null" } ], - "title": "Sourceharness" + "title": "Observed harness" } }, "required": [ @@ -528,6 +523,18 @@ }, "EnableMcpServerRequest": { "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + }, "harness": { "description": "Harness identifier", "minLength": 1, @@ -800,6 +807,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": { @@ -870,12 +915,7 @@ ], "title": "Logokey" }, - "payloadPreview": { - "additionalProperties": true, - "title": "Payloadpreview", - "type": "object" - }, - "sourceHarness": { + "observedHarness": { "anyOf": [ { "type": "string" @@ -884,7 +924,12 @@ "type": "null" } ], - "title": "Sourceharness" + "title": "Observed harness" + }, + "payloadPreview": { + "additionalProperties": true, + "title": "Payloadpreview", + "type": "object" }, "sourceKind": { "enum": [ @@ -1039,17 +1084,16 @@ "title": "McpIdentitySightingResponse", "type": "object" }, - "McpInstallTargetResponse": { + "McpInstallConfigFieldResponse": { "properties": { - "harness": { - "title": "Harness", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" + "choices": { + "items": { + "type": "string" + }, + "title": "Choices", + "type": "array" }, - "logoKey": { + "default": { "anyOf": [ { "type": "string" @@ -1058,20 +1102,31 @@ "type": "null" } ], - "title": "Logokey" + "title": "Default" }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "description": { + "title": "Description", + "type": "string" + }, + "format": { + "enum": [ + "string", + "number", + "boolean", + "filepath" ], - "title": "Reason" + "title": "Format", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" }, - "smitheryClient": { + "placeholder": { "anyOf": [ { "type": "string" @@ -1080,35 +1135,84 @@ "type": "null" } ], - "title": "Smitheryclient" + "title": "Placeholder" + }, + "required": { + "title": "Required", + "type": "boolean" }, - "supported": { - "title": "Supported", + "secret": { + "title": "Secret", "type": "boolean" + }, + "target": { + "enum": [ + "env", + "header", + "urlVariable", + "packageArgument", + "runtimeArgument" + ], + "title": "Target", + "type": "string" } }, "required": [ - "harness", + "name", "label", - "supported" + "description", + "format", + "required", + "secret", + "target" ], - "title": "McpInstallTargetResponse", + "title": "McpInstallConfigFieldResponse", "type": "object" }, - "McpInstallTargetsResponse": { + "McpInstallConfigResponse": { "properties": { - "targets": { + "fields": { "items": { - "$ref": "#/components/schemas/McpInstallTargetResponse" + "$ref": "#/components/schemas/McpInstallConfigFieldResponse" }, - "title": "Targets", + "title": "Fields", + "type": "array" + }, + "required": { + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "required" + ], + "title": "McpInstallConfigResponse", + "type": "object" + }, + "McpInstallConfigStatusResponse": { + "properties": { + "configured": { + "title": "Configured", + "type": "boolean" + }, + "hasFields": { + "title": "Hasfields", + "type": "boolean" + }, + "missingRequired": { + "items": { + "type": "string" + }, + "title": "Missingrequired", "type": "array" } }, "required": [ - "targets" + "hasFields", + "missingRequired", + "configured" ], - "title": "McpInstallTargetsResponse", + "title": "McpInstallConfigStatusResponse", "type": "object" }, "McpInventoryColumnResponse": { @@ -1168,6 +1272,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 +1299,17 @@ "title": "Displayname", "type": "string" }, + "enabledStatus": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Enabledstatus", + "type": "string" + }, + "installConfigStatus": { + "$ref": "#/components/schemas/McpInstallConfigStatusResponse" + }, "kind": { "enum": [ "managed", @@ -1184,6 +1318,9 @@ "title": "Kind", "type": "string" }, + "mcpStatus": { + "$ref": "#/components/schemas/McpStatusResponse" + }, "name": { "title": "Name", "type": "string" @@ -1211,6 +1348,10 @@ "displayName", "kind", "canEnable", + "enabledStatus", + "availabilityStatus", + "mcpStatus", + "installConfigStatus", "sightings" ], "title": "McpInventoryEntryResponse", @@ -1417,6 +1558,17 @@ "title": "Externalurl", "type": "string" }, + "githubUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Githuburl" + }, "iconUrl": { "anyOf": [ { @@ -1428,6 +1580,9 @@ ], "title": "Iconurl" }, + "installConfig": { + "$ref": "#/components/schemas/McpInstallConfigResponse" + }, "isRemote": { "title": "Isremote", "type": "boolean" @@ -1460,6 +1615,17 @@ }, "title": "Tools", "type": "array" + }, + "websiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Websiteurl" } }, "required": [ @@ -1503,6 +1669,17 @@ "title": "Externalurl", "type": "string" }, + "githubUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Githuburl" + }, "homepage": { "anyOf": [ { @@ -1548,6 +1725,17 @@ "useCount": { "title": "Usecount", "type": "integer" + }, + "websiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Websiteurl" } }, "required": [ @@ -1578,6 +1766,17 @@ "title": "Externalurl", "type": "string" }, + "githubUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Githuburl" + }, "iconUrl": { "anyOf": [ { @@ -1600,6 +1799,17 @@ "qualifiedName": { "title": "Qualifiedname", "type": "string" + }, + "websiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Websiteurl" } }, "required": [ @@ -1895,6 +2105,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 +2139,14 @@ "title": "Displayname", "type": "string" }, + "enabledStatus": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Enabledstatus", + "type": "string" + }, "env": { "items": { "$ref": "#/components/schemas/McpEnvEntryResponse" @@ -1917,6 +2154,9 @@ "title": "Env", "type": "array" }, + "installConfigStatus": { + "$ref": "#/components/schemas/McpInstallConfigStatusResponse" + }, "kind": { "enum": [ "managed", @@ -1935,6 +2175,9 @@ } ] }, + "mcpStatus": { + "$ref": "#/components/schemas/McpStatusResponse" + }, "name": { "title": "Name", "type": "string" @@ -1962,6 +2205,10 @@ "displayName", "kind", "canEnable", + "enabledStatus", + "availabilityStatus", + "mcpStatus", + "installConfigStatus", "sightings" ], "title": "McpServerDetailResponse", @@ -2142,6 +2389,36 @@ "title": "McpSourceResponse", "type": "object" }, + "McpStatusResponse": { + "properties": { + "kind": { + "enum": [ + "available", + "needs_config", + "connection_issue", + "unchecked" + ], + "title": "Kind", + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "required": [ + "kind" + ], + "title": "McpStatusResponse", + "type": "object" + }, "McpUnmanagedByServerResponse": { "properties": { "harnesses": { @@ -2269,7 +2546,7 @@ ], "title": "Harnesses" }, - "sourceHarness": { + "observedHarness": { "anyOf": [ { "type": "string" @@ -2278,7 +2555,7 @@ "type": "null" } ], - "title": "Sourceharness" + "title": "Observed harness" }, "sourceKind": { "enum": [ @@ -2904,6 +3181,18 @@ }, "SetMcpServerHarnessesRequest": { "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + }, "target": { "enum": [ "enabled", @@ -4407,24 +4696,6 @@ "summary": "Get Marketplace Document" } }, - "/api/marketplace/mcp/install-targets": { - "get": { - "operationId": "get_mcp_install_targets_api_marketplace_mcp_install_targets_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpInstallTargetsResponse" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "Get Mcp Install Targets" - } - }, "/api/marketplace/mcp/items/{qualified_name}": { "get": { "operationId": "get_mcp_marketplace_detail_api_marketplace_mcp_items__qualified_name__get", @@ -4873,6 +5144,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..c08d0ce 100644 --- a/frontend/src/app/capability-registry/overview.test.ts +++ b/frontend/src/app/capability-registry/overview.test.ts @@ -43,8 +43,33 @@ 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", + mcpStatus: { kind: "unchecked", reason: null }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, + sightings: [], + }, + { + name: "firecrawl", + displayName: "firecrawl", + kind: "unmanaged", + spec: null, + canEnable: false, + enabledStatus: "disabled", + availabilityStatus: "unavailable", + mcpStatus: { + kind: "connection_issue", + reason: "Skill Manager does not have a valid MCP spec for this server.", + }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, + sightings: [], + }, ], issues: [], }, diff --git a/frontend/src/app/capability-registry/overview.ts b/frontend/src/app/capability-registry/overview.ts index c51dba0..bbbd895 100644 --- a/frontend/src/app/capability-registry/overview.ts +++ b/frontend/src/app/capability-registry/overview.ts @@ -321,7 +321,7 @@ function buildMarketplaceEntries(copy: OverviewCopy): OverviewMarketplaceEntry[] key: "mcp", label: copy.marketplace.mcp, iconKey: "mcp", - sourceLabel: "smithery.ai", + sourceLabel: "MCP Registry", action: { label: copy.marketplace.browse, to: marketplaceRoutes.mcp, primary: true }, }, { diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx index d5f12e9..018fc75 100644 --- a/frontend/src/components/FilterBar.tsx +++ b/frontend/src/components/FilterBar.tsx @@ -1,5 +1,5 @@ -import { Search } from "lucide-react"; -import type { ReactNode } from "react"; +import { Search, X } from "lucide-react"; +import { useRef, type ReactNode } from "react"; import { useCommonCopy } from "../i18n"; @@ -31,20 +31,37 @@ export function FilterBar({ trailing, }: FilterBarProps) { const common = useCommonCopy(); + const inputRef = useRef(null); const renderedSearchPlaceholder = searchPlaceholder ?? common.search.placeholder; const renderedSearchLabel = searchLabel ?? common.search.label; + const clearSearch = () => { + onSearchChange(""); + inputRef.current?.focus(); + }; return (
-
{pills && pills.length > 0 ? ( diff --git a/frontend/src/components/detail/DetailSourceLinks.tsx b/frontend/src/components/detail/DetailSourceLinks.tsx index 8e29aa1..3ef7efe 100644 --- a/frontend/src/components/detail/DetailSourceLinks.tsx +++ b/frontend/src/components/detail/DetailSourceLinks.tsx @@ -1,11 +1,15 @@ -import { ExternalLink, FolderGit2 } from "lucide-react"; +import { ExternalLink, FolderGit2, GitBranch, Globe2 } from "lucide-react"; + +import { UiTooltipTriggerBoundary } from "../ui/UiTooltipTriggerBoundary"; export type DetailSourceLinkKind = "repo" | "folder" | "marketplace" | "external" | "website"; export interface DetailSourceLink { - href: string; + href?: string | null; label: string; kind?: DetailSourceLinkKind; + disabledReason?: string; + disabledAriaLabel?: string; } interface DetailSourceLinksProps { @@ -30,19 +34,53 @@ export function DetailSourceLinks({ {label}
- {links.map((link) => ( - - {link.label} - - ))} + {links.map((link) => { + const kind = link.kind ?? "external"; + const className = `action-pill detail-source-link detail-source-link--${kind}`; + const Icon = iconForKind(kind); + const key = `${kind}:${link.href ?? link.label}`; + if (!link.href) { + const button = ( + + ); + return ( + + {button} + + ); + } + return ( + + + ); + })}
); } + +function iconForKind(kind: DetailSourceLinkKind) { + if (kind === "repo") { + return GitBranch; + } + if (kind === "website") { + return Globe2; + } + return ExternalLink; +} diff --git a/frontend/src/components/detail/index.css b/frontend/src/components/detail/index.css index c1ed17d..56466c1 100644 --- a/frontend/src/components/detail/index.css +++ b/frontend/src/components/detail/index.css @@ -53,23 +53,11 @@ } .detail-source-link { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 0 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 999px; - background: rgba(18, 22, 30, 0.62); - color: var(--color-text-muted); - transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease; + text-decoration: none; } -.detail-source-link:hover { - border-color: rgba(240, 163, 107, 0.28); - background: rgba(240, 163, 107, 0.09); - color: var(--color-text); - transform: translateY(-1px); +.detail-source-link:disabled { + pointer-events: none; } .detail-source-link--repo { diff --git a/frontend/src/features/marketplace/api/mcp-client.ts b/frontend/src/features/marketplace/api/mcp-client.ts index 0d0853d..1355985 100644 --- a/frontend/src/features/marketplace/api/mcp-client.ts +++ b/frontend/src/features/marketplace/api/mcp-client.ts @@ -1,14 +1,15 @@ import { fetchJson, postJson } from "../../../api/http"; import type { - AddMcpServerRequestDto, AddMcpServerResponseDto, - McpInstallTargetsDto, McpMarketplaceDetailDto, - McpMarketplaceFilter, McpMarketplacePageResultDto, } from "./mcp-types"; +interface AddMcpServerRequestBody { + qualifiedName: string; +} + interface McpPageParams { limit?: number; offset?: number; @@ -16,7 +17,6 @@ interface McpPageParams { export interface McpSearchParams extends McpPageParams { query?: string; - filter?: McpMarketplaceFilter; } export async function fetchMcpMarketplacePopular( @@ -30,15 +30,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, }), ); } @@ -50,12 +47,8 @@ export async function fetchMcpMarketplaceDetail( return fetchJson(`/marketplace/mcp/items/${encoded}`); } -export async function fetchMcpInstallTargets(): Promise { - return fetchJson("/marketplace/mcp/install-targets"); -} - export async function addMcpServer( - body: AddMcpServerRequestDto, + body: AddMcpServerRequestBody, ): Promise { return postJson("/mcp/servers", body); } diff --git a/frontend/src/features/marketplace/api/mcp-queries.ts b/frontend/src/features/marketplace/api/mcp-queries.ts index 45dd247..1062b83 100644 --- a/frontend/src/features/marketplace/api/mcp-queries.ts +++ b/frontend/src/features/marketplace/api/mcp-queries.ts @@ -6,7 +6,6 @@ import { invalidateMcpQueries } from "../../mcp/public"; import { useMarketplaceCopy } from "../i18n"; import { useInstallingState } from "../model/installing-context"; import { - fetchMcpInstallTargets, fetchMcpMarketplaceDetail, fetchMcpMarketplacePopular, addMcpServer, @@ -14,37 +13,34 @@ 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, }), @@ -63,14 +59,6 @@ export function useMcpMarketplaceDetailQuery(qualifiedName: string | null) { }); } -export function useMcpInstallTargetsQuery() { - return useQuery({ - queryKey: mcpMarketplaceKeys.installTargets(), - queryFn: fetchMcpInstallTargets, - ...queryPolicy(MCP_MARKETPLACE_GC_TIME_MS, MCP_MARKETPLACE_GC_TIME_MS), - }); -} - /** * Shared marketplace install mutation used by the detail view. * Handles: pending-state publication, inventory invalidation, and success/error toasts. @@ -84,9 +72,13 @@ export function useAddMcpServerMutation() { return useMutation< AddMcpServerResponseDto, Error, - { qualifiedName: string; sourceHarness: string; displayName?: string } + { + qualifiedName: string; + displayName?: string; + } >({ - mutationFn: ({ qualifiedName, sourceHarness }) => addMcpServer({ qualifiedName, sourceHarness }), + mutationFn: ({ qualifiedName }) => + addMcpServer({ qualifiedName }), onMutate: ({ qualifiedName }) => { begin(qualifiedName); }, diff --git a/frontend/src/features/marketplace/api/mcp-types.ts b/frontend/src/features/marketplace/api/mcp-types.ts index 10eed61..ae9bdf4 100644 --- a/frontend/src/features/marketplace/api/mcp-types.ts +++ b/frontend/src/features/marketplace/api/mcp-types.ts @@ -11,8 +11,4 @@ export type McpPromptArgumentDto = components["schemas"]["McpMarketplacePromptAr export type McpPromptDto = components["schemas"]["McpMarketplacePromptResponse"]; export type McpCapabilityCountsDto = components["schemas"]["McpMarketplaceCapabilityCountsResponse"]; 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 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/McpInstallButton.test.tsx b/frontend/src/features/marketplace/components/McpInstallButton.test.tsx index 55b8a03..72b29fd 100644 --- a/frontend/src/features/marketplace/components/McpInstallButton.test.tsx +++ b/frontend/src/features/marketplace/components/McpInstallButton.test.tsx @@ -1,45 +1,10 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UiTooltipProvider } from "../../../components/ui/UiTooltipProvider"; import { McpInstallButton } from "./McpInstallButton"; -const installTargets = [ - { - harness: "cursor", - label: "Cursor", - logoKey: "cursor", - smitheryClient: "cursor", - supported: true, - reason: null, - }, - { - harness: "codex", - label: "Codex", - logoKey: "codex", - smitheryClient: "codex", - supported: true, - reason: null, - }, - { - harness: "claude", - label: "Claude", - logoKey: "claude", - smitheryClient: "claude-code", - supported: true, - reason: null, - }, - { - harness: "openclaw", - label: "OpenClaw", - logoKey: "openclaw", - smitheryClient: null, - supported: false, - reason: "Smithery does not provide an OpenClaw MCP installer target", - }, -]; - function renderButton(props: Partial[0]> = {}) { const onInstall = vi.fn(); const utils = render( @@ -47,9 +12,7 @@ function renderButton(props: Partial[0]> = { { vi.unstubAllGlobals(); }); - it("renders 'Add to MCPs' when available and installs through the selected source harness", async () => { + it("renders 'Install' when available and installs directly without choosing an Agent", () => { const { onInstall } = renderButton(); - const button = screen.getByRole("button", { name: /add exa search to mcps/i }); + const button = screen.getByRole("button", { name: /install exa search/i }); expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("Install"); fireEvent.click(button); - expect(await screen.findByRole("button", { name: /claude/i })).toHaveTextContent("claude-code"); - expect(screen.queryByRole("button", { name: /openclaw/i })).not.toBeInTheDocument(); - fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); expect(onInstall).toHaveBeenCalledTimes(1); - expect(onInstall).toHaveBeenCalledWith("cursor"); - }); - - it("renders disabled 'Add to MCPs' with tooltip when unavailable", async () => { - const { onInstall } = renderButton({ - availability: { - kind: "unavailable", - reason: "Unavailable reason", - }, - }); - const button = screen.getByRole("button", { name: /add exa search to mcps \(unavailable\)/i }); - expect(button).toBeDisabled(); - - const trigger = button.closest(".ui-tooltip-trigger"); - expect(trigger).not.toBeNull(); - fireEvent.focus(trigger!); - - await waitFor(() => { - const bubble = document.querySelector(".ui-popup--tooltip"); - expect(bubble).not.toBeNull(); - expect(bubble).toHaveTextContent("Unavailable reason"); - }); - - fireEvent.click(button); - expect(onInstall).not.toHaveBeenCalled(); - }); - - it("renders loading state while source harness installers load", async () => { - const { onInstall } = renderButton({ - installTargetState: { kind: "loading" }, - }); - - await expectDisabledTooltip("Loading source harness installers"); - expect(onInstall).not.toHaveBeenCalled(); - }); - - it("renders install-target API failures as load errors", async () => { - renderButton({ - installTargetState: { - kind: "error", - message: "Unable to load source harness installers: unknown api path", - }, - }); - - await expectDisabledTooltip( - "Unable to load source harness installers: unknown api path", - ); - expect(screen.queryByText(/no compatible/i)).not.toBeInTheDocument(); - }); - - it("renders empty successful target responses as unsupported", async () => { - renderButton({ - installTargetState: { - kind: "ready", - targets: [ - { - harness: "openclaw", - label: "OpenClaw", - logoKey: "openclaw", - smitheryClient: null, - supported: false, - reason: "Smithery does not provide an OpenClaw MCP installer target", - }, - ], - }, - }); - - await expectDisabledTooltip("No supported Smithery source harness installers are available"); + expect(onInstall).toHaveBeenCalledWith(); + expect(screen.queryByRole("button", { name: /cursor/i })).not.toBeInTheDocument(); }); it("renders 'Open in MCPs' when already installed", () => { @@ -171,18 +66,3 @@ describe("McpInstallButton", () => { expect(button).toHaveTextContent(/installing/i); }); }); - -async function expectDisabledTooltip(message: string): Promise { - const button = screen.getByRole("button", { name: /add exa search to mcps \(unavailable\)/i }); - expect(button).toBeDisabled(); - - const trigger = button.closest(".ui-tooltip-trigger"); - expect(trigger).not.toBeNull(); - fireEvent.focus(trigger!); - - await waitFor(() => { - const bubble = document.querySelector(".ui-popup--tooltip"); - expect(bubble).not.toBeNull(); - expect(bubble).toHaveTextContent(message); - }); -} diff --git a/frontend/src/features/marketplace/components/McpInstallButton.tsx b/frontend/src/features/marketplace/components/McpInstallButton.tsx index 5cb0705..c7703dc 100644 --- a/frontend/src/features/marketplace/components/McpInstallButton.tsx +++ b/frontend/src/features/marketplace/components/McpInstallButton.tsx @@ -1,54 +1,32 @@ import { type MouseEvent } from "react"; import { Link } from "react-router-dom"; -import * as Popover from "@radix-ui/react-popover"; import { ArrowUpRight, Loader2, Plus } from "lucide-react"; import { UiTooltip } from "../../../components/ui/UiTooltip"; -import { UiTooltipTriggerBoundary } from "../../../components/ui/UiTooltipTriggerBoundary"; -import type { McpInstallTargetDto } from "../api/mcp-types"; import { useMarketplaceCopy } from "../i18n"; import type { InstalledState } from "../model/installed-lookup"; -import type { - McpInstallAvailability, - McpInstallTargetState, - McpSourceHarness, -} from "../model/mcp-install-action"; - -type SupportedMcpInstallTarget = McpInstallTargetDto & { smitheryClient: string }; interface McpInstallButtonProps { displayName: string; - availability: McpInstallAvailability; installedState: InstalledState; - installTargetState: McpInstallTargetState; installing: boolean; - onInstall: (sourceHarness: McpSourceHarness) => void; + onInstall: () => void; } /** * Three-state install affordance for a marketplace server. * - * unavailable → disabled pill with an explanatory tooltip * installing → disabled pill with spinner + "Installing" * installed → link to /mcp/use?server= with "Open in MCPs" - * default → normal Add to MCPs pill that triggers onInstall + * default → normal Install pill that triggers onInstall */ export function McpInstallButton({ displayName, - availability, installedState, - installTargetState, installing, onInstall, }: McpInstallButtonProps) { const copy = useMarketplaceCopy(); - const sourceOptions = - installTargetState.kind === "ready" - ? installTargetState.targets.filter( - (target): target is SupportedMcpInstallTarget => - target.supported && Boolean(target.smitheryClient), - ) - : []; if (installing) { return ( @@ -82,94 +60,19 @@ export function McpInstallButton({ ); } - if (availability.kind === "unavailable") { - const button = ( - - ); - - return ( - {button} - ); - } - - if (installTargetState.kind !== "ready" || sourceOptions.length === 0) { - const reason = - installTargetState.kind === "loading" - ? copy.detail.installButton.loadingSourceHarnessInstallers - : installTargetState.kind === "error" - ? installTargetState.message - : copy.detail.installButton.noSupportedInstallers; - const button = ( - - ); - - return {button}; - } - return ( - - - - - - event.stopPropagation()} - > -
{copy.detail.installButton.installIntoSourceHarness}
-
    - {sourceOptions.map((option) => ( -
  • - - - -
  • - ))} -
-
-
-
+ ); } diff --git a/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx b/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx index acda78b..3399db3 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceCard.test.tsx @@ -32,66 +32,43 @@ function createItem(overrides: Partial = {}): McpMarketpl useCount: overrides.useCount ?? 1200, createdAt: overrides.createdAt ?? null, homepage: overrides.homepage ?? null, - externalUrl: overrides.externalUrl ?? "https://smithery.ai/server/exa", + externalUrl: overrides.externalUrl ?? "https://registry.modelcontextprotocol.io/?q=exa", + }; +} + +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(); const method = init?.method ?? "GET"; - if (url.includes("/api/marketplace/mcp/install-targets") && method === "GET") { - return okJson({ - targets: [ - { - harness: "cursor", - label: "Cursor", - logoKey: "cursor", - smitheryClient: "cursor", - supported: true, - reason: null, - }, - { - harness: "claude", - label: "Claude", - logoKey: "claude", - smitheryClient: "claude-code", - supported: true, - reason: null, - }, - { - harness: "openclaw", - label: "OpenClaw", - logoKey: "openclaw", - smitheryClient: null, - supported: false, - reason: "Smithery does not provide an OpenClaw MCP installer target", - }, - ], - }); - } if (url.includes("/api/mcp/servers") && method === "GET") { return okJson(inventoryPayload); } if (url.includes("/api/marketplace/mcp/items") && method === "GET") { - 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, - }); + return okJson(detailPayload(item, detailOverrides)); } if (url.includes("/api/mcp/servers") && method === "POST") { return okJson({ @@ -150,20 +127,45 @@ describe("McpMarketplaceCard", () => { it("renders an install button for remote deployed items", () => { 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.getByRole("button", { name: /install exa search/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /install exa search/i })).toHaveTextContent("Install"); + 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("keeps full long text available while using marketplace card text slots", () => { + const longName = "Very Long MCP Server Display Name That Should Truncate Like Skill Marketplace Cards"; + const longQualifiedName = "@very-long-namespace/very-long-mcp-server-name-that-should-ellipsize"; + const longDescription = + "This MCP server description is intentionally long so the card should clamp it instead of stretching the marketplace grid layout."; + const { container } = renderCard( + createItem({ + displayName: longName, + qualifiedName: longQualifiedName, + description: longDescription, + }), + ); + + expect(container.querySelector(".market-card__title")).toHaveAttribute("title", longName); + expect(container.querySelector(".market-card__repo")).toHaveAttribute("title", longQualifiedName); + expect(container.querySelector(".market-card__body")).toHaveAttribute("title", longDescription); }); it("does not open detail when the install button is clicked", async () => { const { onOpenDetail } = renderCard(createItem()); await waitFor(() => - expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(), + expect(screen.getByRole("button", { name: /install exa search/i })).toBeEnabled(), ); - const button = screen.getByRole("button", { name: /add exa search to mcps/i }); + const button = screen.getByRole("button", { name: /install exa search/i }); fireEvent.click(button); - expect(await screen.findByRole("button", { name: /claude/i })).toHaveTextContent("claude-code"); - expect(screen.queryByRole("button", { name: /openclaw/i })).not.toBeInTheDocument(); - fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( @@ -171,18 +173,24 @@ describe("McpMarketplaceCard", () => { expect.objectContaining({ method: "POST" }), ); }); + const postCall = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes("/api/mcp/servers") && init?.method === "POST", + ); + expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ + qualifiedName: "@exa/exa-mcp", + }); + expect(screen.queryByRole("button", { name: /cursor/i })).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("renders an install button for local items", async () => { renderCard(createItem({ isRemote: false, isDeployed: false })); await waitFor(() => - expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(), + expect(screen.getByRole("button", { name: /install exa search/i })).toBeEnabled(), ); - const button = screen.getByRole("button", { name: /add exa search to mcps/i }); + const button = screen.getByRole("button", { name: /install exa search/i }); fireEvent.click(button); - fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/mcp/servers"), @@ -191,14 +199,54 @@ describe("McpMarketplaceCard", () => { }); }); - it("keeps undeployed remote items installable because Smithery writes the source config", async () => { + it("installs directly when registry install fields are required", 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: /install exa search/i })).toBeEnabled(), + ); + + fireEvent.click(screen.getByRole("button", { name: /install exa search/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/mcp/servers"), + expect.objectContaining({ + method: "POST", + }), + ); + }); + const postCall = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes("/api/mcp/servers") && init?.method === "POST", + ); + expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ qualifiedName: "@exa/exa-mcp" }); + expect(screen.queryByLabelText(/CUEAPI_API_KEY/i, { selector: "input" })).not.toBeInTheDocument(); + }); + + it("keeps undeployed remote items installable as registry MCP installs", async () => { renderCard(createItem({ isRemote: true, isDeployed: false })); await waitFor(() => - expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(), + expect(screen.getByRole("button", { name: /install exa search/i })).toBeEnabled(), ); - const button = screen.getByRole("button", { name: /add exa search to mcps/i }); + const button = screen.getByRole("button", { name: /install exa search/i }); fireEvent.click(button); - fireEvent.click(await screen.findByRole("button", { name: /cursor/i })); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/mcp/servers"), diff --git a/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx b/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx index 221d06a..05794c9 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceCard.tsx @@ -1,14 +1,8 @@ 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 { useMcpInstallActionState } from "../model/mcp-install-action"; import { McpInstallButton } from "./McpInstallButton"; interface McpMarketplaceCardProps { @@ -19,7 +13,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) { @@ -30,7 +24,6 @@ export function McpMarketplaceCard({ item, onOpenDetail }: McpMarketplaceCardPro qualifiedName: item.qualifiedName, displayName: item.displayName, }); - const availability = summaryInstallAvailability(item); function handleKeyDown(event: KeyboardEvent): void { if (event.key !== "Enter" && event.key !== " ") { @@ -41,65 +34,55 @@ 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..b041a87 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -18,7 +18,7 @@ function okJson(payload: object) { }; } -function deferred() { +function controlledPromise() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; @@ -26,7 +26,7 @@ function deferred() { return { promise, resolve }; } -function itemFixture(): McpMarketplaceItemDto { +function itemFixture(overrides: Partial = {}): McpMarketplaceItemDto { return { qualifiedName: "exa", namespace: "exa", @@ -39,11 +39,14 @@ function itemFixture(): McpMarketplaceItemDto { useCount: 59087, createdAt: null, homepage: "https://exa.ai", - externalUrl: "https://smithery.ai/server/exa", + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + githubUrl: "https://github.com/exa-labs/exa-mcp-server", + websiteUrl: "https://exa.ai", + ...overrides, }; } -function detailFixture(): McpMarketplaceDetailDto { +function detailFixture(overrides: Partial = {}): McpMarketplaceDetailDto { return { qualifiedName: "exa", managedName: "exa", @@ -57,11 +60,14 @@ 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", + githubUrl: "https://github.com/exa-labs/exa-mcp-server", + websiteUrl: "https://exa.ai", + ...overrides, }; } -function renderView() { +function renderView(initialItem: McpMarketplaceItemDto | null = itemFixture()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -69,7 +75,7 @@ function renderView() { undefined} /> @@ -81,6 +87,14 @@ function renderView() { describe("McpMarketplaceDetailView", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); }); afterEach(() => { @@ -89,26 +103,12 @@ describe("McpMarketplaceDetailView", () => { }); it("transitions from loading to loaded without changing hook order", async () => { - const detail = deferred>(); + const detail = controlledPromise>(); fetchMock.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/api/marketplace/mcp/items/exa")) { return detail.promise; } - if (url.includes("/api/marketplace/mcp/install-targets")) { - return okJson({ - targets: [ - { - harness: "cursor", - label: "Cursor", - logoKey: "cursor", - smitheryClient: "cursor", - supported: true, - reason: null, - }, - ], - }); - } if (url.includes("/api/mcp/servers")) { return okJson({ columns: [], entries: [], issues: [] }); } @@ -126,12 +126,57 @@ describe("McpMarketplaceDetailView", () => { await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument(), ); - expect(screen.getByRole("button", { name: /add exa search to mcps/i })).toBeEnabled(); + expect(screen.getByRole("button", { name: /install exa search/i })).toBeEnabled(); expect(screen.getByLabelText("Source links for Exa Search")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "View on smithery.ai" })).toHaveAttribute( + expect(screen.queryByText("Remote")).not.toBeInTheDocument(); + expect(screen.queryByText("Verified")).not.toBeInTheDocument(); + expect(screen.queryByText("59.1k")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toHaveAttribute( + "href", + "https://registry.modelcontextprotocol.io/?q=exa", + ); + expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute( "href", - "https://smithery.ai/server/exa", + "https://github.com/exa-labs/exa-mcp-server", + ); + expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute( + "href", + "https://exa.ai", ); expect(document.querySelector(`.${"mcp-detail"}__external`)).not.toBeInTheDocument(); }); + + it("shows disabled source buttons when GitHub and Website are unavailable", async () => { + const detail = controlledPromise>(); + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/marketplace/mcp/items/exa")) { + return detail.promise; + } + if (url.includes("/api/mcp/servers")) { + return okJson({ columns: [], entries: [], issues: [] }); + } + throw new Error(`Unhandled URL ${url}`); + }); + + renderView(itemFixture({ githubUrl: null, websiteUrl: null, homepage: null })); + + await act(async () => { + detail.resolve(okJson(detailFixture({ githubUrl: null, websiteUrl: null }))); + }); + + await waitFor(() => + expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument(), + ); + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toBeInTheDocument(); + const githubButton = screen.getByRole("button", { name: "GitHub unavailable" }); + expect(githubButton).toBeDisabled(); + expect(screen.getByRole("button", { name: "Website unavailable" })).toBeDisabled(); + fireEvent.focus(githubButton.closest(".ui-tooltip-trigger")!); + await waitFor(() => { + expect(document.querySelector(".ui-popup--tooltip")).toHaveTextContent( + "No GitHub repository is listed for this MCP server.", + ); + }); + }); }); diff --git a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx index 76ee578..4b53704 100644 --- a/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx +++ b/frontend/src/features/marketplace/components/McpMarketplaceDetailView.tsx @@ -1,20 +1,18 @@ import { type ReactNode, useId, useState } from "react"; -import { Activity, CheckCircle2, Copy } from "lucide-react"; +import { Copy } from "lucide-react"; import { DetailHeader } from "../../../components/detail/DetailHeader"; -import { DetailSourceLinks } from "../../../components/detail/DetailSourceLinks"; +import { + DetailSourceLinks, + type DetailSourceLink, +} from "../../../components/detail/DetailSourceLinks"; import { ErrorBanner } from "../../../components/ErrorBanner"; import { LoadingSpinner } from "../../../components/LoadingSpinner"; import { useToast } from "../../../components/Toast"; -import { UiTooltip } from "../../../components/ui/UiTooltip"; import { useMcpMarketplaceDetailQuery } from "../api/mcp-queries"; import type { McpMarketplaceItemDto } from "../api/mcp-types"; import { useMarketplaceCopy, type MarketplaceCopy } from "../i18n"; -import { formatMcpUseCount } from "../model/formatters"; -import { - detailInstallAvailability, - useMcpInstallActionState, -} from "../model/mcp-install-action"; +import { useMcpInstallActionState } from "../model/mcp-install-action"; import { McpInstallButton } from "./McpInstallButton"; import { McpToolEntry } from "./McpToolEntry"; @@ -26,6 +24,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, @@ -40,13 +42,15 @@ export function McpMarketplaceDetailView({ const { toast } = useToast(); const [showAllTools, setShowAllTools] = useState(false); - const fallbackUseCount = initialItem?.useCount ?? 0; - const fallbackVerified = initialItem?.isVerified ?? false; const headerDisplayName = detail?.displayName ?? initialItem?.displayName ?? qualifiedName; - 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 headerGithubUrl = detail?.githubUrl ?? initialItem?.githubUrl ?? null; + const headerWebsiteUrl = + detail?.websiteUrl ?? initialItem?.websiteUrl ?? initialItem?.homepage ?? null; const installAction = useMcpInstallActionState({ qualifiedName, displayName: headerDisplayName, @@ -98,8 +102,6 @@ export function McpMarketplaceDetailView({ const localConnection = detail.connections.find( (connection) => connection.kind === "stdio", ); - const installAvailability = detailInstallAvailability(detail); - function handleCopy(value: string, label: string): void { if (!navigator.clipboard?.writeText) { toast(copy.detail.mcp.copied(label)); @@ -112,12 +114,10 @@ export function McpMarketplaceDetailView({ } const installButton = ( - ); @@ -142,34 +142,15 @@ export function McpMarketplaceDetailView({ /> ) : null} {qualifiedName} - -
- - {headerIsRemote ? copy.detail.mcp.remote : copy.detail.mcp.local} - - {fallbackVerified ? ( - - - ) : null} - - - - -
} @@ -249,7 +230,7 @@ export function McpMarketplaceDetailView({ ) : (

- {copy.detail.mcp.sourceInstallerWillWrite} + {copy.detail.mcp.connectionMetadataUnavailable}

)} @@ -329,6 +310,40 @@ export function McpMarketplaceDetailView({ ); } +function mcpSourceLinks({ + registryUrl, + githubUrl, + websiteUrl, + copy, +}: { + registryUrl: string; + githubUrl: string | null; + websiteUrl: string | null; + copy: MarketplaceCopy; +}): DetailSourceLink[] { + return [ + { + href: registryUrl, + label: copy.detail.mcp.viewInRegistry, + kind: "marketplace", + }, + { + href: githubUrl, + label: copy.detail.mcp.github, + kind: "repo", + disabledReason: copy.detail.mcp.noGithubLink, + disabledAriaLabel: copy.detail.mcp.unavailableLink(copy.detail.mcp.github), + }, + { + href: websiteUrl, + label: copy.detail.mcp.website, + kind: "website", + disabledReason: copy.detail.mcp.noWebsiteLink, + disabledAriaLabel: copy.detail.mcp.unavailableLink(copy.detail.mcp.website), + }, + ]; +} + function Section({ heading, children }: { heading: string; children: ReactNode }) { return (
diff --git a/frontend/src/features/marketplace/i18n.ts b/frontend/src/features/marketplace/i18n.ts index 538a969..5525027 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,11 +69,13 @@ 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", - remote: "Remote", + viewInRegistry: "View in MCP Registry", + github: "GitHub", + website: "Website", + unavailableLink: (label: string) => `${label} unavailable`, + noGithubLink: "No GitHub repository is listed for this MCP server.", + noWebsiteLink: "No website is listed for this MCP server.", local: "Local", - verified: "Verified", - calls: (value: string) => `${value} calls`, copied: (label: string) => `${label} copied`, copyFailed: "Copy failed", about: "About", @@ -96,8 +91,8 @@ const englishMarketplaceCopy = { localStdioCommand: "Local stdio command", command: "Command", copy: "Copy", - sourceInstallerWillWrite: - "The source installer will write the actual local command into the selected harness.", + connectionMetadataUnavailable: + "Connection metadata is unavailable for this registry entry. Add it to MCPs to save the managed MCP record and check availability.", showMore: (count: number) => `Show ${count} more`, collapseTools: "Collapse tools", noDescription: "No description provided.", @@ -134,13 +129,8 @@ const englishMarketplaceCopy = { openInMcp: "Open in MCPs", openInMcpTooltip: "Open in MCP servers in use", openInMcpAria: (name: string) => `Open ${name} in MCPs`, - addToMcp: "Add to MCPs", - addToMcpAria: (name: string) => `Add ${name} to MCPs`, - addToMcpUnavailableAria: (name: string) => `Add ${name} to MCPs (unavailable)`, - loadingSourceHarnessInstallers: "Loading source harness installers", - noSupportedInstallers: "No supported Smithery source harness installers are available", - installIntoSourceHarness: "Install into source harness", - installWithSmitheryTarget: (client: string) => `Install with Smithery's ${client} target`, + addToMcp: "Install", + addToMcpAria: (name: string) => `Install ${name}`, addedToMcp: (name: string) => `${name} added to your MCP servers`, installFailed: "Install failed", }, @@ -163,7 +153,6 @@ export const marketplaceCopy = { title: "商城", previewOnlyNote: "仅预览 · Skill Manager 不会安装或管理 CLI", typeAria: "商城类型", - mcpFilterAria: "筛选 MCP 服务器", loading: { marketplace: "正在加载商城", skills: "正在加载商城", @@ -181,12 +170,6 @@ export const marketplaceCopy = { mcp: "MCP", clis: "CLI", }, - filters: { - all: "全部", - remote: "远程", - local: "本地", - verified: "已验证", - }, search: { skillsPlaceholder: "按名称或主题搜索 skills.sh", skillsLabel: "搜索 Skill 商城", @@ -235,11 +218,13 @@ export const marketplaceCopy = { unableDetail: "无法加载 MCP 服务器详情。", tryReopen: "请从商城网格中重新打开此服务器。", sourceLinksAria: (name: string) => `${name} 的来源链接`, - viewOnSmithery: "在 smithery.ai 查看", - remote: "远程", + viewInRegistry: "在 MCP Registry 查看", + github: "GitHub", + website: "Website", + unavailableLink: (label: string) => `${label} 不可用`, + noGithubLink: "此 MCP 服务器未提供 GitHub 仓库。", + noWebsiteLink: "此 MCP 服务器未提供官网链接。", local: "本地", - verified: "已验证", - calls: (value: string) => `${value} 次调用`, copied: (label: string) => `${label} 已复制`, copyFailed: "复制失败", about: "简介", @@ -255,7 +240,8 @@ export const marketplaceCopy = { localStdioCommand: "本地 stdio command", command: "Command", copy: "复制", - sourceInstallerWillWrite: "来源安装器会把实际的本地 command 写入所选 harness。", + connectionMetadataUnavailable: + "此 Registry 条目暂未提供连接元数据。添加到 MCP 后会保存 Registry 记录并检查可用性。", showMore: (count: number) => `再显示 ${count} 项`, collapseTools: "收起 tools", noDescription: "没有提供描述。", @@ -292,13 +278,8 @@ export const marketplaceCopy = { openInMcp: "在 MCP 服务器中打开", openInMcpTooltip: "在使用中的 MCP 服务器里打开", openInMcpAria: (name: string) => `在 MCP 服务器中打开 ${name}`, - addToMcp: "添加到 MCP 服务器", - addToMcpAria: (name: string) => `将 ${name} 添加到 MCP 服务器`, - addToMcpUnavailableAria: (name: string) => `将 ${name} 添加到 MCP 服务器(不可用)`, - loadingSourceHarnessInstallers: "正在加载来源 harness 安装器", - noSupportedInstallers: "没有可用的 Smithery 来源 harness 安装器", - installIntoSourceHarness: "安装到来源 harness", - installWithSmitheryTarget: (client: string) => `使用 Smithery 的 ${client} target 安装`, + addToMcp: "安装", + addToMcpAria: (name: string) => `安装 ${name}`, addedToMcp: (name: string) => `${name} 已添加到 MCP 服务器`, installFailed: "安装失败", }, diff --git a/frontend/src/features/marketplace/lazy.test.ts b/frontend/src/features/marketplace/lazy.test.ts new file mode 100644 index 0000000..3e39bfd --- /dev/null +++ b/frontend/src/features/marketplace/lazy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + prefetchMarketplaceCliFeed, + prefetchMarketplaceMcpFeed, + prefetchMarketplacePopularFeed, +} from "./lazy"; + +function queryClientStub() { + return { + prefetchInfiniteQuery: vi.fn(), + }; +} + +describe("marketplace feed prefetching", () => { + it("passes infinite pagination options when prefetching skills", () => { + const queryClient = queryClientStub(); + + prefetchMarketplacePopularFeed(queryClient as never); + + const options = queryClient.prefetchInfiniteQuery.mock.calls[0]?.[0]; + expect(options.getNextPageParam({ hasMore: true, nextOffset: 20 })).toBe(20); + expect(options.getNextPageParam({ hasMore: false, nextOffset: 40 })).toBeUndefined(); + }); + + it("passes infinite pagination options when prefetching MCP servers", () => { + const queryClient = queryClientStub(); + + prefetchMarketplaceMcpFeed(queryClient as never); + + const options = queryClient.prefetchInfiniteQuery.mock.calls[0]?.[0]; + expect(options.getNextPageParam({ hasMore: true, nextOffset: 30 })).toBe(30); + expect(options.getNextPageParam({ hasMore: false, nextOffset: 60 })).toBeUndefined(); + }); + + it("passes infinite pagination options when prefetching CLIs", () => { + const queryClient = queryClientStub(); + + prefetchMarketplaceCliFeed(queryClient as never); + + const options = queryClient.prefetchInfiniteQuery.mock.calls[0]?.[0]; + expect(options.getNextPageParam({ hasMore: true, nextOffset: 30 })).toBe(30); + expect(options.getNextPageParam({ hasMore: false, nextOffset: 60 })).toBeUndefined(); + }); +}); 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..3354d32 100644 --- a/frontend/src/features/marketplace/model/mcp-install-action.ts +++ b/frontend/src/features/marketplace/model/mcp-install-action.ts @@ -1,39 +1,12 @@ import { useCallback } from "react"; -import { useAddMcpServerMutation, useMcpInstallTargetsQuery } from "../api/mcp-queries"; +import { useAddMcpServerMutation } from "../api/mcp-queries"; import type { AddMcpServerResponseDto, - McpInstallTargetDto, - McpMarketplaceDetailDto, - McpMarketplaceItemDto, } from "../api/mcp-types"; import { type InstalledState, useInstalledServerLookup } from "./installed-lookup"; import { useInstallingState } from "./installing-context"; -export type McpInstallAvailability = - | { kind: "available" } - | { kind: "unavailable"; reason: string }; - -export type McpSourceHarness = string; -export type McpInstallTargetState = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; targets: McpInstallTargetDto[] }; - -const INSTALL_TARGET_LOAD_ERROR = "Unable to load source harness installers"; - -export function summaryInstallAvailability( - _item: Pick, -): McpInstallAvailability { - return { kind: "available" }; -} - -export function detailInstallAvailability( - _detail: McpMarketplaceDetailDto, -): McpInstallAvailability { - return { kind: "available" }; -} - interface UseMcpInstallActionStateParams { qualifiedName: string; displayName: string; @@ -42,9 +15,8 @@ interface UseMcpInstallActionStateParams { interface McpInstallActionState { installedState: InstalledState; - installTargetState: McpInstallTargetState; installing: boolean; - onInstall: (sourceHarness: McpSourceHarness) => void; + onInstall: () => void; } export function useMcpInstallActionState({ @@ -55,13 +27,16 @@ export function useMcpInstallActionState({ const { lookup } = useInstalledServerLookup(); const { isInstalling } = useInstallingState(); const installMutation = useAddMcpServerMutation(); - const installTargetsQuery = useMcpInstallTargetsQuery(); - const onInstall = useCallback( - (sourceHarness: McpSourceHarness) => { + const submitInstall = useCallback( + () => { installMutation.mutate( - { qualifiedName, sourceHarness, displayName }, - { onSuccess: (response) => onInstalled?.(response) }, + { qualifiedName, displayName }, + { + onSuccess: (response) => { + onInstalled?.(response); + }, + }, ); }, [displayName, installMutation, onInstalled, qualifiedName], @@ -69,30 +44,7 @@ export function useMcpInstallActionState({ return { installedState: lookup(qualifiedName), - installTargetState: resolveInstallTargetState( - installTargetsQuery.isPending, - installTargetsQuery.error, - installTargetsQuery.data?.targets, - ), installing: isInstalling(qualifiedName), - onInstall, + onInstall: submitInstall, }; } - -function resolveInstallTargetState( - isPending: boolean, - error: unknown, - targets: McpInstallTargetDto[] | undefined, -): McpInstallTargetState { - if (isPending) { - return { kind: "loading" }; - } - if (error) { - const message = error instanceof Error ? error.message.trim() : ""; - return { - kind: "error", - message: message ? `${INSTALL_TARGET_LOAD_ERROR}: ${message}` : INSTALL_TARGET_LOAD_ERROR, - }; - } - return { kind: "ready", targets: targets ?? [] }; -} 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..09dfbdd 100644 --- a/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx +++ b/frontend/src/features/marketplace/screens/MarketplaceMcpPage.test.tsx @@ -8,7 +8,7 @@ import MarketplaceMcpPage from "./MarketplaceMcpPage"; const fetchMock = vi.fn(); -function deferred() { +function controlledPromise() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; @@ -29,7 +29,7 @@ function pageItem() { useCount: 59087, createdAt: null, homepage: "https://exa.ai", - externalUrl: "https://smithery.ai/server/exa", + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", }; } @@ -47,7 +47,7 @@ function detailPayload() { resources: [], prompts: [], capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, - externalUrl: "https://smithery.ai/server/exa", + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", }; } @@ -75,17 +75,13 @@ describe("MarketplaceMcpPage", () => { fetchMock.mockReset(); }); - it("opens the detail modal while install targets are still loading", async () => { - const installTargets = deferred>(); - const detail = deferred>(); + it("opens the detail modal while detail data is still loading", async () => { + const detail = controlledPromise>(); 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")) { - return installTargets.promise; - } if (url.includes("/api/marketplace/mcp/items/exa")) { return detail.promise; } @@ -109,11 +105,6 @@ describe("MarketplaceMcpPage", () => { await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument(), ); - expect(screen.getByRole("button", { name: /add exa search to mcps \(unavailable\)/i })) - .toBeDisabled(); - - await act(async () => { - installTargets.resolve(okJson({ targets: [] })); - }); + expect(screen.getByRole("button", { name: /install exa search/i })).toBeEnabled(); }); }); diff --git a/frontend/src/features/marketplace/screens/MarketplaceMcpPage.tsx b/frontend/src/features/marketplace/screens/MarketplaceMcpPage.tsx index b3dfc09..7cff99f 100644 --- a/frontend/src/features/marketplace/screens/MarketplaceMcpPage.tsx +++ b/frontend/src/features/marketplace/screens/MarketplaceMcpPage.tsx @@ -37,7 +37,7 @@ export default function MarketplaceMcpPage({ ? feedQuery.error.message : copy.errors.mcp; - // Skills and CLIs have explicit marketplace namespaces; MCP owns Smithery ids. + // Skills and CLIs have explicit marketplace namespaces; MCP owns MCP Registry qualified names. const ownsItemId = Boolean( selectedName && !selectedName.startsWith("skillssh:") && diff --git a/frontend/src/features/marketplace/styles/cards.css b/frontend/src/features/marketplace/styles/cards.css index f8abb1d..79790b2 100644 --- a/frontend/src/features/marketplace/styles/cards.css +++ b/frontend/src/features/marketplace/styles/cards.css @@ -46,6 +46,10 @@ align-items: start; } +.market-card__identity { + min-width: 0; +} + .market-card__avatar { display: inline-flex; align-items: center; @@ -72,6 +76,9 @@ font-weight: 600; color: var(--color-text); letter-spacing: -0.005em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .market-card__repo { @@ -79,6 +86,9 @@ font-family: var(--font-mono); font-size: 0.76rem; color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .market-card__stars { diff --git a/frontend/src/features/marketplace/styles/mcp-detail.css b/frontend/src/features/marketplace/styles/mcp-detail.css index bb69aaf..210e67a 100644 --- a/frontend/src/features/marketplace/styles/mcp-detail.css +++ b/frontend/src/features/marketplace/styles/mcp-detail.css @@ -51,7 +51,7 @@ } .mcp-card__body { - -webkit-line-clamp: 3; + -webkit-line-clamp: 2; } .mcp-card__footer { @@ -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..477b170 100644 --- a/frontend/src/features/mcp/api/management-client.ts +++ b/frontend/src/features/mcp/api/management-client.ts @@ -2,12 +2,14 @@ import { deleteJson, fetchJson, postJson } from "../../../api/http"; import type { McpApplyConfigResponseDto, + McpAvailabilityCheckResponseDto, McpInventoryDto, McpServerDetailDto, McpNeedsReviewByServerDto, SetMcpHarnessesResponseDto, UninstallMcpResponseDto, } from "./management-types"; +import type { McpInstallConfigValues } from "../model/install-config"; export async function fetchMcpInventory(): Promise { return fetchJson("/mcp/servers"); @@ -16,9 +18,11 @@ export async function fetchMcpInventory(): Promise { export async function enableMcpServer(args: { name: string; harness: string; + config?: McpInstallConfigValues; }): Promise<{ ok: boolean }> { return postJson<{ ok: boolean }>(`/mcp/servers/${encodeURIComponent(args.name)}/enable`, { harness: args.harness, + config: args.config, }); } @@ -34,10 +38,11 @@ export async function disableMcpServer(args: { export async function setMcpServerHarnesses(args: { name: string; target: "enabled" | "disabled"; + config?: McpInstallConfigValues; }): Promise { return postJson( `/mcp/servers/${encodeURIComponent(args.name)}/set-harnesses`, - { target: args.target }, + { target: args.target, config: args.config }, ); } @@ -49,17 +54,23 @@ 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"; - sourceHarness?: string | null; + observedHarness?: string | null; harnesses?: string[]; }): Promise { return postJson( `/mcp/servers/${encodeURIComponent(args.name)}/reconcile`, { sourceKind: args.sourceKind, - sourceHarness: args.sourceHarness ?? null, + observedHarness: args.observedHarness ?? null, harnesses: args.harnesses, }, ); @@ -71,7 +82,7 @@ export async function fetchMcpNeedsReviewByServer(): Promise { return postJson("/mcp/unmanaged/adopt", body); 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..34dd0af 100644 --- a/frontend/src/features/mcp/api/management-types.ts +++ b/frontend/src/features/mcp/api/management-types.ts @@ -7,11 +7,14 @@ export type McpInventoryColumnDto = components["schemas"]["McpInventoryColumnRes export type McpBindingDto = components["schemas"]["McpBindingResponse"]; export type McpServerSpecDto = components["schemas"]["McpServerSpecResponse"]; export type McpInventoryEntryDto = components["schemas"]["McpInventoryEntryResponse"]; +export type McpStatusDto = components["schemas"]["McpStatusResponse"]; +export type McpInstallConfigStatusDto = components["schemas"]["McpInstallConfigStatusResponse"]; export type McpInventoryDto = components["schemas"]["McpInventoryResponse"]; export type McpNeedsReviewHarnessDto = components["schemas"]["McpUnmanagedHarnessResponse"]; 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/api/marketplace-client.ts b/frontend/src/features/mcp/api/marketplace-client.ts new file mode 100644 index 0000000..f488588 --- /dev/null +++ b/frontend/src/features/mcp/api/marketplace-client.ts @@ -0,0 +1,11 @@ +import { fetchJson } from "../../../api/http"; +import type { components } from "../../../api/generated"; + +export type McpMarketplaceDetailDto = components["schemas"]["McpMarketplaceDetailResponse"]; + +export async function fetchMcpMarketplaceDetail( + qualifiedName: string, +): Promise { + const encoded = qualifiedName.split("/").map(encodeURIComponent).join("/"); + return fetchJson(`/marketplace/mcp/items/${encoded}`); +} diff --git a/frontend/src/features/mcp/components/McpServerCard.tsx b/frontend/src/features/mcp/components/McpServerCard.tsx index 3619a51..042ecac 100644 --- a/frontend/src/features/mcp/components/McpServerCard.tsx +++ b/frontend/src/features/mcp/components/McpServerCard.tsx @@ -6,8 +6,10 @@ import { CardSelectCheckbox } from "../../../components/cards/CardSelectCheckbox import { OverflowTooltipText } from "../../../components/ui/OverflowTooltipText"; import type { McpInventoryColumnDto, McpInventoryEntryDto } from "../api/management-types"; import { useMcpCopy } from "../i18n"; +import type { McpInstallConfigValues } from "../model/install-config"; import { isMcpHarnessAddressable } from "../model/selectors"; import { McpHarnessLogoStack } from "./McpHarnessLogoStack"; +import { McpStatusChip } from "./McpStatusChip"; interface McpServerCardProps { entry: McpInventoryEntryDto; @@ -16,7 +18,7 @@ interface McpServerCardProps { checked: boolean; onOpenDetail: (name: string) => void; onToggleChecked: (name: string) => void; - onSetHarnesses: (name: string, target: "enabled" | "disabled") => void; + onSetHarnesses: (name: string, target: "enabled" | "disabled", config?: McpInstallConfigValues) => void; onRequestUninstall: (name: string) => void; } @@ -94,22 +96,24 @@ export function McpServerCard({ }} aria-label={copy.detail.openDetail(entry.displayName)} > -
+
{entry.displayName} -

diff --git a/frontend/src/features/mcp/components/McpServerCardList.tsx b/frontend/src/features/mcp/components/McpServerCardList.tsx index 7bc6609..e17bcdb 100644 --- a/frontend/src/features/mcp/components/McpServerCardList.tsx +++ b/frontend/src/features/mcp/components/McpServerCardList.tsx @@ -1,5 +1,6 @@ import type { McpInventoryColumnDto, McpInventoryEntryDto } from "../api/management-types"; import { useMcpCopy } from "../i18n"; +import type { McpInstallConfigValues } from "../model/install-config"; import { McpServerCard } from "./McpServerCard"; interface McpServerCardListProps { @@ -9,7 +10,7 @@ interface McpServerCardListProps { checkedNames: ReadonlySet; onOpenDetail: (name: string) => void; onToggleChecked: (name: string) => void; - onSetHarnesses: (name: string, target: "enabled" | "disabled") => void; + onSetHarnesses: (name: string, target: "enabled" | "disabled", config?: McpInstallConfigValues) => void; onRequestUninstall: (name: string) => void; ariaLabel?: string; } diff --git a/frontend/src/features/mcp/components/McpServerMatrixView.test.tsx b/frontend/src/features/mcp/components/McpServerMatrixView.test.tsx index a089967..83e80e4 100644 --- a/frontend/src/features/mcp/components/McpServerMatrixView.test.tsx +++ b/frontend/src/features/mcp/components/McpServerMatrixView.test.tsx @@ -27,6 +27,11 @@ function entries(): McpInventoryEntryDto[] { displayName: "Exa Search", kind: "managed", canEnable: true, + enabledStatus: "enabled", + availabilityStatus: "available", + availabilityReason: null, + mcpStatus: { kind: "available", reason: null }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: { name: "exa", displayName: "Exa Search", @@ -47,6 +52,14 @@ function entries(): McpInventoryEntryDto[] { displayName: "Drift Server", kind: "managed", canEnable: true, + enabledStatus: "disabled", + availabilityStatus: "unavailable", + availabilityReason: null, + mcpStatus: { + kind: "unchecked", + reason: null, + }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: { name: "drift", displayName: "Drift Server", diff --git a/frontend/src/features/mcp/components/McpStatusChip.tsx b/frontend/src/features/mcp/components/McpStatusChip.tsx new file mode 100644 index 0000000..e01d00d --- /dev/null +++ b/frontend/src/features/mcp/components/McpStatusChip.tsx @@ -0,0 +1,25 @@ +import type { McpStatusDto } from "../api/management-types"; +import { useMcpCopy } from "../i18n"; +import { mcpStatusReason } from "../model/mcp-status"; + +interface McpStatusChipProps { + status: McpStatusDto; +} + +export function McpStatusChip({ status }: McpStatusChipProps) { + const copy = useMcpCopy(); + const label = copy.detail.mcpStatus[status.kind]; + const reason = mcpStatusReason(status, copy); + + return ( + + + ); +} diff --git a/frontend/src/features/mcp/components/config/LinkifiedText.tsx b/frontend/src/features/mcp/components/config/LinkifiedText.tsx new file mode 100644 index 0000000..e3d72c9 --- /dev/null +++ b/frontend/src/features/mcp/components/config/LinkifiedText.tsx @@ -0,0 +1,57 @@ +const LINK_CANDIDATE_RE = + /https?:\/\/[^\s)]+|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s)]*)?/gi; + +export function LinkifiedText({ text }: { text: string }) { + const nodes: Array = []; + let cursor = 0; + for (const match of text.matchAll(LINK_CANDIDATE_RE)) { + const raw = match[0]; + const start = match.index ?? 0; + if (start > cursor) { + nodes.push(text.slice(cursor, start)); + } + const trimmed = trimTrailingLinkPunctuation(raw); + nodes.push({ + text: trimmed.link, + href: trimmed.link.match(/^https?:\/\//i) ? trimmed.link : `https://${trimmed.link}`, + }); + if (trimmed.trailing) { + nodes.push(trimmed.trailing); + } + cursor = start + raw.length; + } + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return ( + <> + {nodes.map((node, index) => + typeof node === "string" ? ( + node + ) : ( + + {node.text} + + ), + )} + + ); +} + +function trimTrailingLinkPunctuation(value: string): { link: string; trailing: string } { + const match = value.match(/[.,;:!?]+$/); + if (!match) { + return { link: value, trailing: "" }; + } + return { + link: value.slice(0, -match[0].length), + trailing: match[0], + }; +} diff --git a/frontend/src/features/mcp/components/config/McpInstallConfigDialog.tsx b/frontend/src/features/mcp/components/config/McpInstallConfigDialog.tsx new file mode 100644 index 0000000..839549a --- /dev/null +++ b/frontend/src/features/mcp/components/config/McpInstallConfigDialog.tsx @@ -0,0 +1,135 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2 } from "lucide-react"; + +import { DetailHeader } from "../../../../components/detail/DetailHeader"; +import { useMcpCopy } from "../../i18n"; +import { + buildInitialInstallConfigValues, + buildInstallConfigPayload, + type InstallConfigFormValues, + type McpInstallConfigValues, + missingRequiredInstallConfigFields, + type PendingMcpInstallConfig, +} from "../../model/install-config"; +import { McpInstallConfigField } from "./McpInstallConfigField"; + +interface McpInstallConfigDialogProps { + pending: PendingMcpInstallConfig | null; + installing: boolean; + onClose: () => void; + onSubmit: (config: McpInstallConfigValues) => void; +} + +export function McpInstallConfigDialog({ + pending, + installing, + onClose, + onSubmit, +}: McpInstallConfigDialogProps) { + const copy = useMcpCopy(); + const fields = useMemo(() => pending?.installConfig.fields ?? [], [pending]); + const [values, setValues] = useState({}); + const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); + + useEffect(() => { + setValues(buildInitialInstallConfigValues(fields)); + setVisibleSecrets(new Set()); + }, [fields]); + + if (!pending) { + return null; + } + + const missingRequired = missingRequiredInstallConfigFields(fields, values); + const canInstall = missingRequired.length === 0 && !installing; + + function toggleSecret(name: string): void { + setVisibleSecrets((current) => { + const next = new Set(current); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + } + + function handleSubmit(event: FormEvent): void { + event.preventDefault(); + if (!canInstall) { + return; + } + onSubmit(buildInstallConfigPayload(fields, values)); + } + + return ( + (next ? null : onClose())}> + + + + + {copy.detail.installConfig.title(pending.displayName)} + + + {copy.detail.installConfig.description(pending.targetLabel)} + + {copy.detail.installConfig.title(pending.displayName)}} + meta={ +

+ {copy.detail.installConfig.description(pending.targetLabel)} +

+ } + closeLabel={copy.detail.installConfig.cancel} + onClose={onClose} + /> +
+
+
+

+ {pending.installConfig.required + ? copy.detail.installConfig.requiredHint + : copy.detail.installConfig.optionalHint} +

+
+ {fields.map((field) => ( + toggleSecret(field.name)} + onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))} + /> + ))} +
+ {missingRequired.length > 0 ? ( +

+ {copy.detail.installConfig.missingRequired(missingRequired.join(", "))} +

+ ) : null} +
+
+
+ + +
+
+ + + + ); +} diff --git a/frontend/src/features/mcp/components/config/McpInstallConfigField.tsx b/frontend/src/features/mcp/components/config/McpInstallConfigField.tsx new file mode 100644 index 0000000..c9e7d0d --- /dev/null +++ b/frontend/src/features/mcp/components/config/McpInstallConfigField.tsx @@ -0,0 +1,104 @@ +import { Eye, EyeOff } from "lucide-react"; + +import { useMcpCopy } from "../../i18n"; +import type { McpInstallConfigFieldDto } from "../../model/install-config"; +import { LinkifiedText } from "./LinkifiedText"; + +interface McpInstallConfigFieldProps { + field: McpInstallConfigFieldDto; + value: string | boolean | undefined; + secretVisible: boolean; + onToggleSecret: () => void; + onChange: (value: string | boolean) => void; +} + +const SECRET_NAME_RE = /(key|token|secret|password|authorization)/i; + +export function McpInstallConfigField({ + field, + value, + secretVisible, + onToggleSecret, + onChange, +}: McpInstallConfigFieldProps) { + const copy = useMcpCopy(); + const fieldId = `mcp-install-config-${field.name}`; + const isSecret = isInstallConfigFieldSecret(field); + const label = `${field.label || field.name}${field.required ? " *" : ""}`; + + if (field.format === "boolean") { + return ( +
+ + +
+ ); + } + + const inputType = field.format === "number" ? "number" : isSecret && !secretVisible ? "password" : "text"; + return ( +
+ + + {field.choices?.length ? ( + + ) : ( + onChange(event.currentTarget.value)} + /> + )} + {isSecret ? ( + + ) : null} + + +
+ ); +} + +export function isInstallConfigFieldSecret(field: McpInstallConfigFieldDto): boolean { + return field.secret || SECRET_NAME_RE.test(`${field.name} ${field.description}`); +} + +function InstallConfigFieldHint({ description }: { description: string | null | undefined }) { + if (!description) { + return null; + } + return ( + + + + ); +} diff --git a/frontend/src/features/mcp/components/detail/McpServerDetailSheet.tsx b/frontend/src/features/mcp/components/detail/McpServerDetailSheet.tsx index 350ee6f..83e7354 100644 --- a/frontend/src/features/mcp/components/detail/McpServerDetailSheet.tsx +++ b/frontend/src/features/mcp/components/detail/McpServerDetailSheet.tsx @@ -2,6 +2,7 @@ import * as Dialog from "@radix-ui/react-dialog"; import type { McpInventoryColumnDto } from "../../api/management-types"; import { useMcpCopy } from "../../i18n"; +import type { McpInstallConfigValues } from "../../model/install-config"; import { McpServerDetailView } from "./McpServerDetailView"; interface McpServerDetailSheetProps { @@ -11,12 +12,12 @@ interface McpServerDetailSheetProps { isServerPending: boolean; isUninstalling: boolean; onClose: () => void; - onEnableHarness: (harness: string) => void; + onEnableHarness: (harness: string, config?: McpInstallConfigValues) => void; onDisableHarness: (harness: string) => void; onResolveConfig: ( args: { sourceKind: "managed" | "harness"; - sourceHarness?: string | null; + observedHarness?: string | null; harnesses?: string[]; }, ) => Promise; diff --git a/frontend/src/features/mcp/components/detail/McpServerDetailView.test.tsx b/frontend/src/features/mcp/components/detail/McpServerDetailView.test.tsx index 8f90fbb..1a0a759 100644 --- a/frontend/src/features/mcp/components/detail/McpServerDetailView.test.tsx +++ b/frontend/src/features/mcp/components/detail/McpServerDetailView.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "../../../../components/Toast"; +import { UiTooltipProvider } from "../../../../components/ui/UiTooltipProvider"; import { McpServerDetailView } from "./McpServerDetailView"; import type { McpInventoryColumnDto } from "../../api/management-types"; @@ -17,6 +18,15 @@ function okJson(payload: object) { }; } +function errorJson(payload: object, status = 500) { + return { + ok: false, + status, + statusText: "Error", + json: async () => payload, + }; +} + function columns(): McpInventoryColumnDto[] { return [ { harness: "cursor", label: "Cursor", logoKey: "cursor", installed: true, configPresent: true, mcpWritable: true }, @@ -29,6 +39,14 @@ function detailFixture(overrides: Partial> = {}) { name: "exa", displayName: "Exa Search", kind: "managed", + enabledStatus: "enabled", + availabilityStatus: "unavailable", + availabilityReason: null, + mcpStatus: { + kind: "unchecked", + reason: null, + }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: { name: "exa", displayName: "Exa Search", @@ -65,21 +83,23 @@ function renderView(props: Partial[0]> = const onUninstall = vi.fn(); const utils = render( - - - + + + + + , ); return { @@ -95,6 +115,14 @@ function renderView(props: Partial[0]> = describe("McpServerDetailView", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); }); afterEach(() => { vi.unstubAllGlobals(); @@ -105,16 +133,260 @@ describe("McpServerDetailView", () => { fetchMock.mockResolvedValue(okJson(detailFixture())); renderView(); await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + expect(screen.getByLabelText("MCP status: Unchecked")).toBeInTheDocument(); + expect(screen.getByText("Availability has not been checked yet.")).toBeInTheDocument(); expect(screen.getByText("Cursor")).toBeInTheDocument(); expect(screen.getByText("Claude")).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Cursor, Enabled" })).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Claude, Disabled" })).toBeInTheDocument(); - expect(screen.queryByText(/^Enabled$/)).not.toBeInTheDocument(); - expect(screen.queryByText(/^Disabled$/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Availability:/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Check" })).not.toBeInTheDocument(); expect(screen.getByText("EXA_API_KEY")).toBeInTheDocument(); expect(screen.getByText("long-random-literal-value-xxxx")).toBeInTheDocument(); }); + it("shows marketplace source links instead of transport and source chips", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + marketplaceLink: { + qualifiedName: "exa", + displayName: "Exa Search", + iconUrl: null, + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + githubUrl: "https://github.com/exa-labs/exa-mcp-server", + websiteUrl: "https://exa.ai", + description: "Fast search.", + isRemote: true, + isVerified: true, + }, + }), + ), + ); + + const { container } = renderView(); + + await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + const headerMeta = container.querySelector(".mcp-detail__meta-stack"); + expect(headerMeta).toBeInTheDocument(); + expect(within(headerMeta as HTMLElement).queryByText("http")).not.toBeInTheDocument(); + expect(within(headerMeta as HTMLElement).queryByText("marketplace")).not.toBeInTheDocument(); + expect(headerMeta?.querySelector(".chip")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toHaveAttribute( + "href", + "https://registry.modelcontextprotocol.io/?q=exa", + ); + expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute( + "href", + "https://github.com/exa-labs/exa-mcp-server", + ); + expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute("href", "https://exa.ai"); + }); + + it("uses the marketplace qualified name when the registry link metadata is unavailable", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + name: "ai.31st-mcp", + displayName: "31st.ai — AI Accountant for QuickBooks", + spec: { + name: "ai.31st-mcp", + displayName: "31st.ai — AI Accountant for QuickBooks", + source: { kind: "marketplace", locator: "ai.31st/mcp" }, + transport: "http", + url: "https://mcp.31st.ai/mcp", + installedAt: "2026-05-23T22:21:07Z", + revision: "abc", + }, + marketplaceLink: null, + }), + ), + ); + + renderView(); + + await waitFor(() => + expect(screen.getByRole("heading", { name: "31st.ai — AI Accountant for QuickBooks" })).toBeInTheDocument(), + ); + expect(screen.getByText("ai.31st/mcp")).toBeInTheDocument(); + expect(screen.queryByText("ai.31st-mcp")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toHaveAttribute( + "href", + "https://registry.modelcontextprotocol.io/?q=ai.31st%2Fmcp", + ); + }); + + it("disables the registry source pill for non-marketplace MCPs without registry metadata", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + name: "node_repl", + displayName: "node_repl", + spec: { + name: "node_repl", + displayName: "node_repl", + source: { kind: "manual", locator: "node_repl" }, + transport: "stdio", + command: "node", + args: ["server.js"], + installedAt: "2026-05-23T22:21:07Z", + revision: "abc", + }, + marketplaceLink: null, + }), + ), + ); + + renderView(); + + await waitFor(() => expect(screen.getByRole("heading", { name: "node_repl" })).toBeInTheDocument()); + expect(screen.getAllByText("node_repl").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "View in MCP Registry unavailable" })).toBeDisabled(); + }); + + it("shows disabled GitHub and Website source pills when marketplace links are missing", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + marketplaceLink: { + qualifiedName: "exa", + displayName: "Exa Search", + iconUrl: null, + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + githubUrl: null, + websiteUrl: null, + description: "Fast search.", + isRemote: true, + isVerified: true, + }, + }), + ), + ); + + renderView(); + + await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + expect(screen.getByRole("link", { name: "View in MCP Registry" })).toBeInTheDocument(); + const githubButton = screen.getByRole("button", { name: "GitHub unavailable" }); + expect(githubButton).toBeDisabled(); + expect(screen.getByRole("button", { name: "Website unavailable" })).toBeDisabled(); + fireEvent.focus(githubButton.closest(".ui-tooltip-trigger")!); + await waitFor(() => { + expect(document.querySelector(".ui-popup--tooltip")).toHaveTextContent( + "No GitHub repository is listed for this MCP server.", + ); + }); + }); + + it("does not run availability checks from the detail header", async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/availability/check")) { + throw new Error("availability check should not be called"); + } + if (url.includes("/api/mcp/servers/exa")) { + expect(init?.method ?? "GET").toBe("GET"); + return okJson( + detailFixture({ + availabilityStatus: "available", + mcpStatus: { kind: "available", reason: null }, + }), + ); + } + throw new Error(`Unhandled URL ${url}`); + }); + renderView(); + await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + expect(screen.queryByLabelText(/Availability:/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Check" })).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/availability/check"), + ), + ).toBe(false); + }); + + it("masks secret-like headers in the connection block", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + spec: { + name: "exa", + displayName: "Exa Search", + source: { kind: "marketplace", locator: "exa" }, + transport: "http", + url: "https://exa.run.tools", + headers: { + Authorization: "Bearer live-secret-token", + "X-Client-Name": "skill-manager", + }, + installedAt: "2026-04-21T00:00:00Z", + revision: "abc", + }, + }), + ), + ); + + renderView(); + + await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + expect(screen.queryByText(/live-secret-token/)).not.toBeInTheDocument(); + expect(screen.getByText(/Authorization/)).toHaveTextContent("••••••••"); + expect(screen.getByText(/X-Client-Name/)).toHaveTextContent("skill-manager"); + }); + + it("masks secret-like headers in config choice previews", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + mcpStatus: { + kind: "connection_issue", + reason: null, + }, + sightings: [ + { harness: "cursor", state: "drifted", driftDetail: "changed=headers" }, + { harness: "claude", state: "missing" }, + ], + configChoices: [ + { + sourceKind: "managed", + observedHarness: null, + label: "Skill Manager config", + logoKey: null, + configPath: null, + payloadPreview: { + url: "https://exa.run.tools", + headers: { + Authorization: "Bearer live-secret-token", + "X-Client-Name": "skill-manager", + }, + }, + spec: { + name: "exa", + displayName: "Exa Search", + source: { kind: "marketplace", locator: "exa" }, + transport: "http", + url: "https://exa.run.tools", + installedAt: "2026-04-21T00:00:00Z", + revision: "abc", + }, + env: [], + }, + ], + }), + ), + ); + + renderView(); + + await waitFor(() => expect(screen.getByText("Different configs found")).toBeInTheDocument()); + fireEvent.click(screen.getAllByRole("button", { name: "Resolve config" })[0]); + fireEvent.click(await screen.findByRole("button", { name: /show config preview/i })); + expect(screen.queryByText(/live-secret-token/)).not.toBeInTheDocument(); + expect(screen.getByText(/Authorization/)).toHaveTextContent("••••••••"); + expect(screen.getByText(/X-Client-Name/)).toHaveTextContent("skill-manager"); + }); + it("calls onEnableHarness when clicking Enable on a missing harness row", async () => { fetchMock.mockResolvedValue(okJson(detailFixture())); const { onEnableHarness } = renderView(); @@ -122,7 +394,105 @@ describe("McpServerDetailView", () => { const enableButton = screen.getByRole("button", { name: "Enable" }); expect(enableButton).toHaveClass("action-pill--accent"); fireEvent.click(enableButton); - expect(onEnableHarness).toHaveBeenCalledWith("claude"); + await waitFor(() => expect(onEnableHarness).toHaveBeenCalledWith("claude")); + }); + + it("prompts for registry config when enabling a marketplace MCP in an Agent", async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa")) { + return okJson( + detailFixture({ + installConfigStatus: { + hasFields: true, + missingRequired: ["EXA_API_KEY"], + configured: false, + }, + mcpStatus: { + kind: "needs_config", + reason: null, + }, + }), + ); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return okJson({ + qualifiedName: "exa", + managedName: "exa", + displayName: "Exa Search", + description: "Fast search.", + iconUrl: null, + isRemote: false, + connections: [], + tools: [], + resources: [], + prompts: [], + capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + installConfig: { + required: true, + fields: [ + { + name: "EXA_API_KEY", + label: "EXA_API_KEY", + description: "API key", + format: "string", + required: true, + secret: true, + default: null, + placeholder: null, + choices: [], + target: "env", + }, + ], + }, + }); + } + throw new Error(`Unhandled URL ${url}`); + }); + const { onEnableHarness } = renderView(); + + await waitFor(() => expect(screen.getByText("Claude")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Enable" })); + + const input = await screen.findByLabelText(/EXA_API_KEY/i, { selector: "input" }); + expect(onEnableHarness).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: "exa-key" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + expect(onEnableHarness).toHaveBeenCalledWith("claude", { EXA_API_KEY: "exa-key" }); + }); + + it("does not enable when registry config metadata fails to load", async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa")) { + return okJson( + detailFixture({ + installConfigStatus: { + hasFields: true, + missingRequired: ["EXA_API_KEY"], + configured: false, + }, + mcpStatus: { + kind: "needs_config", + reason: null, + }, + }), + ); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return errorJson({ detail: "Registry metadata unavailable" }, 503); + } + throw new Error(`Unhandled URL ${url}`); + }); + const { onEnableHarness } = renderView(); + + await waitFor(() => expect(screen.getByText("Claude")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Enable" })); + + await waitFor(() => expect(screen.getByText("Registry metadata unavailable")).toBeInTheDocument()); + expect(onEnableHarness).not.toHaveBeenCalled(); }); it("calls onDisableHarness when clicking Disable on a managed harness row", async () => { @@ -139,6 +509,10 @@ describe("McpServerDetailView", () => { fetchMock.mockResolvedValue( okJson( detailFixture({ + mcpStatus: { + kind: "connection_issue", + reason: null, + }, sightings: [ { harness: "cursor", state: "drifted", driftDetail: "changed=url" }, { harness: "claude", state: "missing" }, @@ -146,7 +520,7 @@ describe("McpServerDetailView", () => { configChoices: [ { sourceKind: "managed", - sourceHarness: null, + observedHarness: null, label: "Skill Manager config", logoKey: null, configPath: null, @@ -164,7 +538,7 @@ describe("McpServerDetailView", () => { }, { sourceKind: "harness", - sourceHarness: "cursor", + observedHarness: "cursor", label: "Cursor config", logoKey: "cursor", configPath: "/tmp/.cursor/mcp.json", @@ -185,7 +559,7 @@ describe("McpServerDetailView", () => { ), ); const { onDisableHarness, onResolveConfig } = renderView(); - await waitFor(() => expect(screen.getByText("Different config")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByLabelText("MCP status: Connection issue")).toBeInTheDocument()); const driftIdentity = screen.getByRole("group", { name: "Cursor, Different config" }); expect(driftIdentity).toBeInTheDocument(); expect(screen.getByText("Different configs found")).toBeInTheDocument(); @@ -209,6 +583,28 @@ describe("McpServerDetailView", () => { expect(onDisableHarness).not.toHaveBeenCalled(); }); + it("uses raw sightings for the detail-level drift banner", async () => { + fetchMock.mockResolvedValue( + okJson( + detailFixture({ + mcpStatus: { + kind: "connection_issue", + reason: null, + }, + sightings: [ + { harness: "cursor", state: "drifted", driftDetail: "changed=url" }, + { harness: "claude", state: "missing" }, + ], + }), + ), + ); + + renderView(); + + await waitFor(() => expect(screen.getByText("Connection issue")).toBeInTheDocument()); + expect(screen.getByText("Different configs found")).toBeInTheDocument(); + }); + it("opens uninstall confirm flow and calls onUninstall", async () => { fetchMock.mockResolvedValue(okJson(detailFixture())); const { onUninstall } = renderView(); diff --git a/frontend/src/features/mcp/components/detail/McpServerDetailView.tsx b/frontend/src/features/mcp/components/detail/McpServerDetailView.tsx index bfd9cff..423e80c 100644 --- a/frontend/src/features/mcp/components/detail/McpServerDetailView.tsx +++ b/frontend/src/features/mcp/components/detail/McpServerDetailView.tsx @@ -3,6 +3,7 @@ import { Loader2, Trash2 } from "lucide-react"; import { DetailHeader } from "../../../../components/detail/DetailHeader"; import { DetailSection } from "../../../../components/detail/DetailSection"; +import { DetailSourceLinks } from "../../../../components/detail/DetailSourceLinks"; import { ErrorBanner } from "../../../../components/ErrorBanner"; import { LoadingSpinner } from "../../../../components/LoadingSpinner"; import type { @@ -12,14 +13,20 @@ import type { } from "../../api/management-types"; import { useMcpServerDetailQuery } from "../../api/management-queries"; import { useMcpCopy, type McpCopy } from "../../i18n"; +import { formatDisplayHeaders } from "../../model/display-secrets"; +import type { McpInstallConfigValues } from "../../model/install-config"; +import { mcpStatusReason } from "../../model/mcp-status"; +import { mcpServerSourceLinks, resolveMcpRegistryName } from "../../model/mcp-source-links"; +import { useMcpEnableConfigGate } from "../../model/use-mcp-enable-config-gate"; +import { McpInstallConfigDialog } from "../config/McpInstallConfigDialog"; import { McpConfigChoiceDialog, type McpConfigChoiceOption, } from "../edit/McpConfigChoiceDialog"; +import { McpStatusChip } from "../McpStatusChip"; import { McpBindingMatrix } from "./McpBindingMatrix"; import { McpDetailShell } from "./McpDetailShell"; import { McpEnvTable } from "./McpEnvTable"; -import { McpMarketplaceLinkChip } from "./McpMarketplaceLinkChip"; interface McpServerDetailViewProps { name: string; @@ -28,12 +35,12 @@ interface McpServerDetailViewProps { isServerPending: boolean; isUninstalling: boolean; onClose: () => void; - onEnableHarness: (harness: string) => void; + onEnableHarness: (harness: string, config?: McpInstallConfigValues) => void; onDisableHarness: (harness: string) => void; onResolveConfig: ( args: { sourceKind: "managed" | "harness"; - sourceHarness?: string | null; + observedHarness?: string | null; harnesses?: string[]; }, ) => Promise; @@ -58,7 +65,18 @@ export function McpServerDetailView({ const detailQuery = useMcpServerDetailQuery(name); const detail = detailQuery.data ?? null; + const spec = detail?.spec ?? null; + const displayName = detail?.displayName ?? name; const errorMessage = detailQuery.error instanceof Error ? detailQuery.error.message : ""; + const { + requestEnable, + pendingConfig: pendingEnableConfig, + cancelConfig: cancelEnableConfig, + submitConfig: submitEnableConfig, + configError: enableConfigError, + } = useMcpEnableConfigGate({ + loadErrorMessage: copy.detail.unableToLoadInstallConfig, + }); if (!detail && detailQuery.isPending) { return ( @@ -99,17 +117,25 @@ export function McpServerDetailView({ ); } - const spec = detail.spec ?? null; const envEntries = detail.env ?? []; - const transport = spec?.transport ?? "—"; - const displayName = detail.displayName; - const sourceKind = spec?.source.kind ?? "manual"; const link = detail.marketplaceLink; + const hasMarketplaceSource = spec?.source.kind === "marketplace"; + const hasLinkedQualifiedName = Boolean(link?.qualifiedName?.trim()); + const registryName = resolveMcpRegistryName({ + fallbackName: detail.name, + sourceKind: spec?.source.kind, + sourceLocator: spec?.source.locator, + linkedQualifiedName: link?.qualifiedName, + }); const iconUrl = link?.iconUrl ?? null; const description = link?.description ?? ""; const configChoices = (detail.configChoices ?? []).map((choice) => configChoiceToOption(choice, copy)); const hasDifferentConfig = detail.sightings.some((binding) => binding.state === "drifted"); const canResolveConfig = configChoices.length > 0; + const hasProviderDocs = Boolean(link?.websiteUrl || link?.githubUrl); + const statusReason = mcpStatusReason(detail.mcpStatus, copy, { + documentationLinks: hasProviderDocs ? "available" : "missing", + }); return ( <> @@ -118,26 +144,31 @@ export function McpServerDetailView({ {displayName}} meta={ -
- {iconUrl ? ( - { - (event.currentTarget as HTMLImageElement).style.display = "none"; - }} - /> - ) : null} - {detail.name} - -
- - {transport} - - {sourceKind} - {link ? : null} +
+
+ {iconUrl ? ( + { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : null} + {registryName}
+
} closeLabel={copy.detail.close} @@ -146,6 +177,16 @@ export function McpServerDetailView({ )} body={( <> + {enableConfigError ? : null} + +
+ + {detail.mcpStatus.kind !== "available" && statusReason ?

{statusReason}

: null} +
+ {description ? (

{description}

@@ -180,7 +221,20 @@ export function McpServerDetailView({ canEnable={detail.canEnable} serverPending={isServerPending} pendingPerHarness={pendingPerHarness} - onEnable={onEnableHarness} + onEnable={(harness) => + requestEnable({ + spec, + displayName, + targetLabel: harness, + installConfigStatus: detail.installConfigStatus, + onProceed: (config) => { + if (config === undefined) { + onEnableHarness(harness); + return; + } + onEnableHarness(harness, config); + }, + })} onDisable={onDisableHarness} onResolveConfigClick={() => setResolveDialogOpen(true)} canResolveConfig={canResolveConfig} @@ -224,21 +278,27 @@ export function McpServerDetailView({ onConfirm={async (option) => { await onResolveConfig({ sourceKind: option.sourceKind, - sourceHarness: option.sourceHarness, + observedHarness: option.observedHarness, }); setResolveDialogOpen(false); }} /> ) : null} + ); } function configChoiceToOption(choice: McpConfigChoiceDto, copy: McpCopy): McpConfigChoiceOption { return { - id: choice.sourceKind === "managed" ? "managed" : (choice.sourceHarness ?? choice.label), + id: choice.sourceKind === "managed" ? "managed" : (choice.observedHarness ?? choice.label), sourceKind: choice.sourceKind, - sourceHarness: choice.sourceHarness, + observedHarness: choice.observedHarness, label: choice.sourceKind === "managed" ? copy.detail.skillManagerConfig : choice.label, logoKey: choice.logoKey, configPath: choice.configPath, @@ -274,7 +334,7 @@ function ConnectionBlock({ spec, copy }: { spec: McpServerSpecDto | null; copy: {spec.transport} - {spec.headers ? JSON.stringify(spec.headers) : "—"} + {formatDisplayHeaders(spec.headers)}
); diff --git a/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.test.tsx b/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.test.tsx index eec98cc..ec8817e 100644 --- a/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.test.tsx +++ b/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.test.tsx @@ -13,7 +13,7 @@ function options(): McpConfigChoiceOption[] { { id: "cursor", sourceKind: "harness", - sourceHarness: "cursor", + observedHarness: "cursor", label: "Cursor", logoKey: "cursor", configPath: "/c/.cursor/mcp.json", @@ -33,7 +33,7 @@ function options(): McpConfigChoiceOption[] { { id: "claude", sourceKind: "harness", - sourceHarness: "claude", + observedHarness: "claude", label: "Claude", logoKey: "claude", configPath: "/c/.claude.json", @@ -93,7 +93,7 @@ describe("McpConfigChoiceDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Adopt" })); await waitFor(() => expect(onConfirm).toHaveBeenCalled()); - expect(onConfirm.mock.calls[0][0].sourceHarness).toBe("claude"); + expect(onConfirm.mock.calls[0][0].observedHarness).toBe("claude"); }); it("uses resolve wording and apply label", async () => { diff --git a/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.tsx b/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.tsx index 50e157b..37833ed 100644 --- a/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.tsx +++ b/frontend/src/features/mcp/components/edit/McpConfigChoiceDialog.tsx @@ -9,6 +9,7 @@ import type { McpServerSpecDto, } from "../../api/management-types"; import { useMcpCopy } from "../../i18n"; +import { maskMcpPayloadPreview } from "../../model/display-secrets"; import { envChipLabel, formatEnvKeyPreview, @@ -19,7 +20,7 @@ import { export interface McpConfigChoiceOption { id: string; sourceKind: "managed" | "harness"; - sourceHarness?: string | null; + observedHarness?: string | null; label: string; logoKey?: string | null; configPath?: string | null; @@ -184,7 +185,7 @@ export function McpConfigChoiceDialog({ {isExpanded ? (
-                        {JSON.stringify(option.payloadPreview, null, 2)}
+                        {JSON.stringify(maskMcpPayloadPreview(option.payloadPreview), null, 2)}
                       
) : null}
@@ -227,7 +228,7 @@ export function McpConfigChoiceDialog({ function toConfigChoiceDto(option: McpConfigChoiceOption): McpConfigChoiceDto { return { sourceKind: option.sourceKind, - sourceHarness: option.sourceHarness ?? null, + observedHarness: option.observedHarness ?? null, label: option.label, logoKey: option.logoKey ?? null, configPath: option.configPath ?? null, diff --git a/frontend/src/features/mcp/i18n.ts b/frontend/src/features/mcp/i18n.ts index bce49d4..9c7b2ce 100644 --- a/frontend/src/features/mcp/i18n.ts +++ b/frontend/src/features/mcp/i18n.ts @@ -59,6 +59,7 @@ const englishMcpCopy = { loadingServer: "Loading MCP server", loading: "Loading…", unableTitle: "Unable to load MCP server", + unableToLoadInstallConfig: "Unable to load MCP configuration fields.", about: "About", differentConfigsTitle: "Different configs found", differentConfigsBody: "Choose which config Skill Manager should manage, then apply it to current bindings.", @@ -67,6 +68,14 @@ const englishMcpCopy = { bindings: "Bindings", environment: "Environment", uninstall: "Uninstall", + sourceLinksAria: (name: string) => `Source links for ${name}`, + viewInRegistry: "View in MCP Registry", + github: "GitHub", + website: "Website", + unavailableLink: (label: string) => `${label} unavailable`, + noRegistryLink: "This MCP server is not linked to an MCP Registry entry.", + noGithubLink: "No GitHub repository is listed for this MCP server.", + noWebsiteLink: "No website is listed for this MCP server.", skillManagerConfig: "Skill Manager config", noConnectionData: "No connection data.", command: "Command", @@ -79,9 +88,55 @@ const englishMcpCopy = { select: (name: string) => `Select ${name}`, deselect: (name: string) => `Deselect ${name}`, installedViaSkillManager: "Installed via skill-manager", + enabledStatus: { + enabled: "Enabled", + disabled: "Disabled", + }, + enabledStatusAria: (label: string) => `Status: ${label}`, + mcpStatus: { + available: "Available", + needs_config: "Needs config", + connection_issue: "Connection issue", + unchecked: "Unchecked", + }, + mcpStatusReason: { + available: "MCP endpoint is reachable.", + needs_config: "Required configuration is missing. Add it when enabling this MCP.", + connection_issue: "Connection failed. Check this MCP's config.", + unchecked: "Availability has not been checked yet.", + httpUnauthorized: () => + "Authentication required, but no auth link is listed.", + httpUnauthorizedWithDocs: () => + "Authentication required. Check the website or GitHub docs.", + httpUnauthorizedNoDocs: () => + "Authentication required, but no auth link or docs are listed.", + httpForbidden: () => + "Access refused. Check credentials, permissions, or quota.", + httpNotFound: () => + "Endpoint not found. Check the server URL.", + httpRateLimited: () => + "Rate limited. Try again later or check quota.", + httpServerError: () => + "Provider error. Try again later.", + }, + mcpStatusAria: (label: string) => `MCP status: ${label}`, enableOnAll: "Enable on all", enableOnAllAria: "Enable on all harnesses", disableEverywhere: "Disable everywhere", + installConfig: { + allHarnesses: "all harnesses", + title: (name: string) => `Configure ${name}`, + description: (harness: string) => `Configure for ${harness}. These values will be written to your local Agent MCP config.`, + bulkRequiresSingle: (name: string) => + `${name} requires credentials. Enable it by itself so Skill Manager can collect the required configuration.`, + requiredHint: "Complete the required fields before saving.", + optionalHint: "Optional configuration", + missingRequired: (fields: string) => `Missing required fields: ${fields}`, + install: "Save", + cancel: "Cancel", + showSecret: "Show secret", + hideSecret: "Hide secret", + }, review: { loadingServer: "Loading server", identicalAcross: (count: number) => `Identical across ${count} harnesses`, @@ -211,6 +266,7 @@ export const mcpCopy = { loadingServer: "正在加载 MCP 服务器", loading: "加载中...", unableTitle: "无法加载 MCP 服务器", + unableToLoadInstallConfig: "无法加载 MCP 配置字段。", about: "简介", differentConfigsTitle: "发现不同配置", differentConfigsBody: "选择 Skill Manager 应管理哪份配置,然后应用到当前绑定。", @@ -219,6 +275,14 @@ export const mcpCopy = { bindings: "绑定", environment: "环境", uninstall: "卸载", + sourceLinksAria: (name: string) => `${name} 的来源链接`, + viewInRegistry: "在 MCP Registry 查看", + github: "GitHub", + website: "Website", + unavailableLink: (label: string) => `${label} 不可用`, + noRegistryLink: "此 MCP 服务器未关联 MCP Registry 条目。", + noGithubLink: "此 MCP 服务器未提供 GitHub 仓库。", + noWebsiteLink: "此 MCP 服务器未提供官网链接。", skillManagerConfig: "Skill Manager 配置", noConnectionData: "没有连接数据。", command: "Command", @@ -231,9 +295,55 @@ export const mcpCopy = { select: (name: string) => `选择 ${name}`, deselect: (name: string) => `取消选择 ${name}`, installedViaSkillManager: "通过 skill-manager 安装", + enabledStatus: { + enabled: "已启用", + disabled: "未启用", + }, + enabledStatusAria: (label: string) => `状态:${label}`, + mcpStatus: { + available: "可用", + needs_config: "需要配置", + connection_issue: "连接异常", + unchecked: "未检查", + }, + mcpStatusReason: { + available: "MCP 端点可连接。", + needs_config: "缺少必填配置。启用此 MCP 时请补充这些配置。", + connection_issue: "Skill Manager 无法通过当前配置连接到此 MCP。", + unchecked: "尚未检查可用性。", + httpUnauthorized: () => + "远程 MCP 服务器需要认证,但 Registry 未提供认证入口。请打开 MCP 详情查看是否有服务商文档。", + httpUnauthorizedWithDocs: () => + "远程 MCP 服务器需要认证,但 Registry 未提供认证入口。请查看官网或 GitHub 文档了解服务商的授权方式。", + httpUnauthorizedNoDocs: () => + "远程 MCP 服务器需要认证,但 Registry 未提供认证入口或文档链接,Skill Manager 当前无法完成此 MCP 的认证。", + httpForbidden: () => + "远程 MCP 服务器拒绝访问。请检查 API key、账号权限、额度,或服务商是否限制了请求来源。", + httpNotFound: () => + "远程 MCP 地址不存在。请检查服务器 URL 是否仍然正确。", + httpRateLimited: () => + "远程 MCP 服务器正在限制请求频率。请稍后重试,或检查服务商额度限制。", + httpServerError: () => + "远程 MCP 服务器返回服务端错误。服务商可能暂时不可用。", + }, + mcpStatusAria: (label: string) => `MCP 状态:${label}`, enableOnAll: "全部启用", enableOnAllAria: "在所有 harness 上启用", disableEverywhere: "全部停用", + installConfig: { + allHarnesses: "所有 harness", + title: (name: string) => `配置 ${name}`, + description: (harness: string) => `配置到 ${harness}。这些值会写入本机 Agent MCP 配置。`, + bulkRequiresSingle: (name: string) => + `${name} 需要认证配置。请单独启用这个 MCP,以便 Skill Manager 收集必填配置。`, + requiredHint: "保存前请填写必填字段。", + optionalHint: "可选配置", + missingRequired: (fields: string) => `缺少必填字段:${fields}`, + install: "保存", + cancel: "取消", + showSecret: "显示密钥", + hideSecret: "隐藏密钥", + }, review: { loadingServer: "正在加载服务器", identicalAcross: (count: number) => `${count} 个 harness 中配置相同`, diff --git a/frontend/src/features/mcp/model/display-secrets.ts b/frontend/src/features/mcp/model/display-secrets.ts new file mode 100644 index 0000000..0c18649 --- /dev/null +++ b/frontend/src/features/mcp/model/display-secrets.ts @@ -0,0 +1,29 @@ +export const MASKED_MCP_SECRET_VALUE = "••••••••"; + +const SECRET_KEY_RE = /(authorization|api[-_]?key|token|secret|password)/i; + +export function formatDisplayHeaders(headers: Record | null | undefined): string { + if (!headers || Object.keys(headers).length === 0) { + return "—"; + } + return JSON.stringify(maskSecretLikeObject(headers)); +} + +export function maskMcpPayloadPreview(payload: Record): Record { + return maskSecretLikeObject(payload) as Record; +} + +function maskSecretLikeObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(maskSecretLikeObject); + } + if (!value || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + SECRET_KEY_RE.test(key) ? MASKED_MCP_SECRET_VALUE : maskSecretLikeObject(nested), + ]), + ); +} diff --git a/frontend/src/features/mcp/model/install-config.ts b/frontend/src/features/mcp/model/install-config.ts new file mode 100644 index 0000000..ea3ec11 --- /dev/null +++ b/frontend/src/features/mcp/model/install-config.ts @@ -0,0 +1,51 @@ +import type { components } from "../../../api/generated"; + +export type McpInstallConfigDto = components["schemas"]["McpInstallConfigResponse"]; +export type McpInstallConfigFieldDto = components["schemas"]["McpInstallConfigFieldResponse"]; +export type McpInstallConfigValues = Record; +export type InstallConfigFormValues = Record; + +export interface PendingMcpInstallConfig { + qualifiedName: string; + targetLabel: string; + displayName: string; + installConfig: McpInstallConfigDto; +} + +export function buildInitialInstallConfigValues( + fields: readonly McpInstallConfigFieldDto[], +): InstallConfigFormValues { + const initial: InstallConfigFormValues = {}; + for (const field of fields) { + initial[field.name] = field.format === "boolean" ? field.default === "true" : field.default || ""; + } + return initial; +} + +export function missingRequiredInstallConfigFields( + fields: readonly McpInstallConfigFieldDto[], + values: InstallConfigFormValues, +): string[] { + return fields + .filter((field) => field.required && isEmptyInstallConfigValue(values[field.name])) + .map((field) => field.name); +} + +export function buildInstallConfigPayload( + fields: readonly McpInstallConfigFieldDto[], + values: InstallConfigFormValues, +): McpInstallConfigValues { + const config: McpInstallConfigValues = {}; + for (const field of fields) { + const value = values[field.name]; + if (isEmptyInstallConfigValue(value)) { + continue; + } + config[field.name] = field.format === "number" ? Number(value) : value; + } + return config; +} + +export function isEmptyInstallConfigValue(value: string | boolean | undefined): boolean { + return value === undefined || value === ""; +} diff --git a/frontend/src/features/mcp/model/mcp-source-links.test.ts b/frontend/src/features/mcp/model/mcp-source-links.test.ts new file mode 100644 index 0000000..4b395e7 --- /dev/null +++ b/frontend/src/features/mcp/model/mcp-source-links.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { mcpCopy } from "../i18n"; +import { mcpServerSourceLinks, resolveMcpRegistryName } from "./mcp-source-links"; + +describe("resolveMcpRegistryName", () => { + it("prefers the linked registry qualified name", () => { + expect( + resolveMcpRegistryName({ + fallbackName: "local-name", + sourceKind: "marketplace", + sourceLocator: "registry/source", + linkedQualifiedName: "linked/name", + }), + ).toBe("linked/name"); + }); + + it("uses the marketplace locator before the managed local name", () => { + expect( + resolveMcpRegistryName({ + fallbackName: "ai.31st-mcp", + sourceKind: "marketplace", + sourceLocator: "ai.31st/mcp", + linkedQualifiedName: null, + }), + ).toBe("ai.31st/mcp"); + }); + + it("falls back to the local name for non-marketplace servers", () => { + expect( + resolveMcpRegistryName({ + fallbackName: "node_repl", + sourceKind: "manual", + sourceLocator: "local", + linkedQualifiedName: null, + }), + ).toBe("node_repl"); + }); +}); + +describe("mcpServerSourceLinks", () => { + it("falls back to registry search when externalUrl is blank but a registry identity exists", () => { + const [registry] = mcpServerSourceLinks({ + registryExternalUrl: "", + registryName: "ai.31st/mcp", + hasRegistryIdentity: true, + githubUrl: null, + websiteUrl: null, + copy: mcpCopy.en.detail, + }); + + expect(registry.href).toBe("https://registry.modelcontextprotocol.io/?q=ai.31st%2Fmcp"); + }); + + it("disables the registry link when there is no registry identity", () => { + const [registry] = mcpServerSourceLinks({ + registryExternalUrl: null, + registryName: "node_repl", + hasRegistryIdentity: false, + githubUrl: null, + websiteUrl: null, + copy: mcpCopy.en.detail, + }); + + expect(registry.href).toBeNull(); + expect(registry.disabledReason).toBe("This MCP server is not linked to an MCP Registry entry."); + }); +}); diff --git a/frontend/src/features/mcp/model/mcp-source-links.ts b/frontend/src/features/mcp/model/mcp-source-links.ts new file mode 100644 index 0000000..cfb1c34 --- /dev/null +++ b/frontend/src/features/mcp/model/mcp-source-links.ts @@ -0,0 +1,92 @@ +type McpSourceKind = "marketplace" | "adopted" | "manual"; +type McpSourceLinkKind = "repo" | "marketplace" | "website"; + +export interface McpSourceLink { + href?: string | null; + label: string; + kind?: McpSourceLinkKind; + disabledReason?: string; + disabledAriaLabel?: string; +} + +export interface McpSourceLinkCopy { + viewInRegistry: string; + github: string; + website: string; + unavailableLink: (label: string) => string; + noRegistryLink: string; + noGithubLink: string; + noWebsiteLink: string; +} + +export function resolveMcpRegistryName({ + fallbackName, + sourceKind, + sourceLocator, + linkedQualifiedName, +}: { + fallbackName: string; + sourceKind?: McpSourceKind | null; + sourceLocator?: string | null; + linkedQualifiedName?: string | null; +}): string { + const linkedName = nonBlank(linkedQualifiedName); + if (linkedName) return linkedName; + + const locator = nonBlank(sourceLocator); + if (sourceKind === "marketplace" && locator) return locator; + + return fallbackName; +} + +export function registrySearchUrl(name: string): string { + return `https://registry.modelcontextprotocol.io/?${new URLSearchParams({ q: name }).toString()}`; +} + +export function mcpServerSourceLinks({ + registryExternalUrl, + registryName, + hasRegistryIdentity, + githubUrl, + websiteUrl, + copy, +}: { + registryExternalUrl?: string | null; + registryName: string; + hasRegistryIdentity: boolean; + githubUrl?: string | null; + websiteUrl?: string | null; + copy: McpSourceLinkCopy; +}): McpSourceLink[] { + const registryUrl = nonBlank(registryExternalUrl) ?? (hasRegistryIdentity ? registrySearchUrl(registryName) : null); + + return [ + { + href: registryUrl, + label: copy.viewInRegistry, + kind: "marketplace", + disabledReason: copy.noRegistryLink, + disabledAriaLabel: copy.unavailableLink(copy.viewInRegistry), + }, + { + href: githubUrl, + label: copy.github, + kind: "repo", + disabledReason: copy.noGithubLink, + disabledAriaLabel: copy.unavailableLink(copy.github), + }, + { + href: websiteUrl, + label: copy.website, + kind: "website", + disabledReason: copy.noWebsiteLink, + disabledAriaLabel: copy.unavailableLink(copy.website), + }, + ]; +} + +function nonBlank(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} diff --git a/frontend/src/features/mcp/model/mcp-status.test.ts b/frontend/src/features/mcp/model/mcp-status.test.ts new file mode 100644 index 0000000..032941e --- /dev/null +++ b/frontend/src/features/mcp/model/mcp-status.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { mcpCopy } from "../i18n"; +import { mcpStatusReason } from "./mcp-status"; + +describe("mcpStatusReason", () => { + it("explains HTTP 403 connection errors with likely causes", () => { + const reason = mcpStatusReason( + { + kind: "connection_issue", + reason: "HTTP 403 Forbidden", + }, + mcpCopy.en, + ); + + expect(reason).toBe("Access refused. Check credentials, permissions, or quota."); + expect(reason).not.toContain("HTTP 403 Forbidden"); + }); + + it("explains HTTP 401 with documentation links when available", () => { + const reason = mcpStatusReason( + { + kind: "connection_issue", + reason: "HTTP 401 Unauthorized", + }, + mcpCopy.en, + { documentationLinks: "available" }, + ); + + expect(reason).toBe("Authentication required. Check the website or GitHub docs."); + expect(reason).not.toContain("HTTP 401 Unauthorized"); + }); + + it("explains HTTP 401 as not completable when no documentation links are available", () => { + const reason = mcpStatusReason( + { + kind: "connection_issue", + reason: "HTTP 401 Unauthorized", + }, + mcpCopy.en, + { documentationLinks: "missing" }, + ); + + expect(reason).toBe("Authentication required, but no auth link or docs are listed."); + expect(reason).not.toContain("HTTP 401 Unauthorized"); + }); +}); diff --git a/frontend/src/features/mcp/model/mcp-status.ts b/frontend/src/features/mcp/model/mcp-status.ts new file mode 100644 index 0000000..0923e93 --- /dev/null +++ b/frontend/src/features/mcp/model/mcp-status.ts @@ -0,0 +1,47 @@ +import type { McpStatusDto } from "../api/management-types"; +import type { McpCopy } from "../i18n"; + +interface McpStatusReasonOptions { + documentationLinks?: "available" | "missing" | "unknown"; +} + +export function mcpStatusReason( + status: McpStatusDto, + copy: McpCopy, + options: McpStatusReasonOptions = {}, +): string | null { + if (status.kind === "connection_issue" && status.reason) { + return connectionIssueReason(status.reason, copy, options); + } + return copy.detail.mcpStatusReason[status.kind]; +} + +function connectionIssueReason( + reason: string, + copy: McpCopy, + options: McpStatusReasonOptions, +): string { + const httpStatus = /^HTTP\s+(\d{3})\b/i.exec(reason.trim())?.[1] ?? null; + if (httpStatus === "401") { + if (options.documentationLinks === "available") { + return copy.detail.mcpStatusReason.httpUnauthorizedWithDocs(); + } + if (options.documentationLinks === "missing") { + return copy.detail.mcpStatusReason.httpUnauthorizedNoDocs(); + } + return copy.detail.mcpStatusReason.httpUnauthorized(); + } + if (httpStatus === "403") { + return copy.detail.mcpStatusReason.httpForbidden(); + } + if (httpStatus === "404") { + return copy.detail.mcpStatusReason.httpNotFound(); + } + if (httpStatus === "429") { + return copy.detail.mcpStatusReason.httpRateLimited(); + } + if (httpStatus && Number(httpStatus) >= 500) { + return copy.detail.mcpStatusReason.httpServerError(); + } + return reason; +} diff --git a/frontend/src/features/mcp/model/selectors.test.ts b/frontend/src/features/mcp/model/selectors.test.ts index 38440c5..0e4aaa3 100644 --- a/frontend/src/features/mcp/model/selectors.test.ts +++ b/frontend/src/features/mcp/model/selectors.test.ts @@ -21,11 +21,20 @@ function makeEntry( states: ("managed" | "drifted" | "missing")[], options: { transport?: "stdio" | "http" | "sse" } = {}, ): McpInventoryEntryDto { + const enabled = states.some((state) => state === "managed"); return { name, displayName: name, kind: "managed", canEnable: true, + enabledStatus: enabled ? "enabled" : "disabled", + availabilityStatus: "unavailable", + availabilityReason: null, + mcpStatus: { + kind: "unchecked", + reason: null, + }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: options.transport ? { name, diff --git a/frontend/src/features/mcp/model/selectors.ts b/frontend/src/features/mcp/model/selectors.ts index 1558c7f..651886f 100644 --- a/frontend/src/features/mcp/model/selectors.ts +++ b/frontend/src/features/mcp/model/selectors.ts @@ -302,12 +302,12 @@ export function pickRecommendedConfigChoice( if (harnessChoices.length === 0) return choices[0]?.sourceKind === "managed" ? "managed" : null; const hasEnvRef = (choice: McpConfigChoiceDto) => (choice.env ?? []).some((e) => e.isEnvRef); const tier1 = harnessChoices.find((choice) => choice.spec.transport === "stdio" && hasEnvRef(choice)); - if (tier1?.sourceHarness) return tier1.sourceHarness; + if (tier1?.observedHarness) return tier1.observedHarness; const tier2 = harnessChoices.find((choice) => choice.spec.transport === "stdio"); - if (tier2?.sourceHarness) return tier2.sourceHarness; + if (tier2?.observedHarness) return tier2.observedHarness; const tier3 = harnessChoices.find( (choice) => choice.spec.transport !== "stdio" && !urlHasEmbeddedCredential(choice.spec.url), ); - if (tier3?.sourceHarness) return tier3.sourceHarness; - return choices[0]?.sourceKind === "managed" ? "managed" : (harnessChoices[0]?.sourceHarness ?? null); + if (tier3?.observedHarness) return tier3.observedHarness; + return choices[0]?.sourceKind === "managed" ? "managed" : (harnessChoices[0]?.observedHarness ?? null); } diff --git a/frontend/src/features/mcp/model/use-mcp-enable-config-gate.ts b/frontend/src/features/mcp/model/use-mcp-enable-config-gate.ts new file mode 100644 index 0000000..8b65166 --- /dev/null +++ b/frontend/src/features/mcp/model/use-mcp-enable-config-gate.ts @@ -0,0 +1,99 @@ +import { useCallback, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { fetchMcpMarketplaceDetail } from "../api/marketplace-client"; +import type { + McpInstallConfigStatusDto, + McpServerSpecDto, +} from "../api/management-types"; +import type { McpInstallConfigValues, PendingMcpInstallConfig } from "./install-config"; + +const mcpRegistryDetailKey = (qualifiedName: string) => + ["mcp", "registry-detail", qualifiedName] as const; + +interface UseMcpEnableConfigGateParams { + loadErrorMessage: string; +} + +export function useMcpEnableConfigGate({ + loadErrorMessage, +}: UseMcpEnableConfigGateParams) { + const queryClient = useQueryClient(); + const [pendingConfig, setPendingConfig] = useState(null); + const [configError, setConfigError] = useState(""); + const pendingSubmitRef = useRef<((config?: McpInstallConfigValues) => void) | null>(null); + + const requestEnable = useCallback( + ({ + spec, + displayName, + targetLabel, + installConfigStatus, + onProceed, + }: { + spec: McpServerSpecDto | null; + displayName: string; + targetLabel: string; + installConfigStatus?: McpInstallConfigStatusDto; + onProceed: (config?: McpInstallConfigValues) => void; + }): void => { + const locator = spec?.source.kind === "marketplace" ? spec.source.locator : null; + if (!locator || !installConfigStatus?.missingRequired.length) { + onProceed(); + return; + } + + setConfigError(""); + pendingSubmitRef.current = onProceed; + void queryClient + .fetchQuery({ + queryKey: mcpRegistryDetailKey(locator), + queryFn: () => fetchMcpMarketplaceDetail(locator), + }) + .then((marketplaceDetail) => { + const installConfig = marketplaceDetail.installConfig; + if (installConfig?.fields?.length) { + setPendingConfig({ + qualifiedName: locator, + targetLabel, + displayName, + installConfig, + }); + return; + } + pendingSubmitRef.current = null; + setConfigError(loadErrorMessage); + }) + .catch((error) => { + pendingSubmitRef.current = null; + setConfigError(error instanceof Error ? error.message : loadErrorMessage); + }); + }, + [loadErrorMessage, queryClient], + ); + + const cancelConfig = useCallback(() => { + setPendingConfig(null); + pendingSubmitRef.current = null; + }, []); + + const submitConfig = useCallback( + (config: McpInstallConfigValues): void => { + if (!pendingConfig) { + return; + } + pendingSubmitRef.current?.(config); + pendingSubmitRef.current = null; + setPendingConfig(null); + }, + [pendingConfig], + ); + + return { + requestEnable, + pendingConfig, + cancelConfig, + submitConfig, + configError, + }; +} diff --git a/frontend/src/features/mcp/model/use-mcp-management-controller.test.tsx b/frontend/src/features/mcp/model/use-mcp-management-controller.test.tsx new file mode 100644 index 0000000..df8d741 --- /dev/null +++ b/frontend/src/features/mcp/model/use-mcp-management-controller.test.tsx @@ -0,0 +1,79 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + setHarnessesMutate: vi.fn(), + enableMutate: vi.fn(), + availabilityMutate: vi.fn(), +})); + +vi.mock("../api/management-queries", () => ({ + useMcpInventoryQuery: () => ({ + data: { columns: [], entries: [] }, + isPending: false, + error: null, + }), + useMcpNeedsReviewByServerQuery: () => ({ + data: { harnesses: [], servers: [], issues: [] }, + isPending: false, + error: null, + }), + useSetMcpServerHarnessesMutation: () => ({ + mutateAsync: hoisted.setHarnessesMutate, + }), + useEnableMcpServerMutation: () => ({ + mutateAsync: hoisted.enableMutate, + }), + useCheckMcpServerAvailabilityMutation: () => ({ + mutateAsync: hoisted.availabilityMutate, + }), + useDisableMcpServerMutation: () => ({ mutateAsync: vi.fn() }), + useUninstallMcpServerMutation: () => ({ mutateAsync: vi.fn() }), + useAdoptMcpServerMutation: () => ({ mutateAsync: vi.fn() }), + useReconcileMcpServerMutation: () => ({ mutateAsync: vi.fn() }), +})); + +import { useMcpManagementController } from "./use-mcp-management-controller"; + +describe("useMcpManagementController availability refresh", () => { + beforeEach(() => { + hoisted.setHarnessesMutate.mockReset(); + hoisted.enableMutate.mockReset(); + hoisted.availabilityMutate.mockReset(); + hoisted.setHarnessesMutate.mockResolvedValue({ ok: true, succeeded: ["cursor"], failed: [] }); + hoisted.enableMutate.mockResolvedValue({ ok: true }); + hoisted.availabilityMutate.mockReturnValue(new Promise(() => undefined)); + }); + + it("does not keep enable-all pending while availability check is still running", async () => { + const { result } = renderHook(() => useMcpManagementController()); + let settled = false; + + await act(async () => { + void result.current.handleSetServerHarnesses("exa", "enabled").then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(hoisted.setHarnessesMutate).toHaveBeenCalledWith({ name: "exa", target: "enabled" }); + expect(hoisted.availabilityMutate).toHaveBeenCalledWith("exa"); + expect(settled).toBe(true); + }); + + it("does not keep single-harness enable pending while availability check is still running", async () => { + const { result } = renderHook(() => useMcpManagementController()); + let settled = false; + + await act(async () => { + void result.current.handleEnableInHarness("exa", "cursor").then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(hoisted.enableMutate).toHaveBeenCalledWith({ name: "exa", harness: "cursor" }); + expect(hoisted.availabilityMutate).toHaveBeenCalledWith("exa"); + expect(settled).toBe(true); + }); +}); diff --git a/frontend/src/features/mcp/model/use-mcp-management-controller.ts b/frontend/src/features/mcp/model/use-mcp-management-controller.ts index ae74a4a..9f0b22a 100644 --- a/frontend/src/features/mcp/model/use-mcp-management-controller.ts +++ b/frontend/src/features/mcp/model/use-mcp-management-controller.ts @@ -1,9 +1,10 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { MultiSelectAction } from "../../../components/BulkActionBar"; import { usePendingRegistry } from "../../../lib/async/pending-registry"; import { useAdoptMcpServerMutation, + useCheckMcpServerAvailabilityMutation, useDisableMcpServerMutation, useEnableMcpServerMutation, useMcpInventoryQuery, @@ -12,6 +13,7 @@ import { useSetMcpServerHarnessesMutation, useUninstallMcpServerMutation, } from "../api/management-queries"; +import type { McpInstallConfigValues } from "./install-config"; export type McpStatus = "loading" | "ready" | "error"; @@ -24,9 +26,11 @@ export function useMcpManagementController() { const reconcileMutation = useReconcileMcpServerMutation(); const enableMutation = useEnableMcpServerMutation(); const disableMutation = useDisableMcpServerMutation(); + const availabilityMutation = useCheckMcpServerAvailabilityMutation(); + const autoAvailabilityChecks = useRef>(new Set()); const pendingServerRegistry = usePendingRegistry(); - const pendingAdoptRegistry = usePendingRegistry(); // key: name or name:sourceHarness + const pendingAdoptRegistry = usePendingRegistry(); // key: name or name:observedHarness const pendingPerHarnessRegistry = usePendingRegistry(); // key: name:harness const [actionErrorMessage, setActionErrorMessage] = useState(""); @@ -50,11 +54,41 @@ export function useMcpManagementController() { ? "error" : "loading"; + useEffect(() => { + if (!inventory) return; + for (const entry of inventory.entries) { + if ( + entry.kind !== "managed" || + entry.mcpStatus.kind === "needs_config" || + entry.availabilityStatus !== "unavailable" || + entry.availabilityReason !== null || + !entry.spec + ) { + continue; + } + const key = `${entry.name}:${entry.spec.revision}`; + if (autoAvailabilityChecks.current.has(key)) { + continue; + } + autoAvailabilityChecks.current.add(key); + void availabilityMutation.mutateAsync(entry.name).catch(() => { + autoAvailabilityChecks.current.delete(key); + }); + } + }, [availabilityMutation, inventory]); + const handleSetServerHarnesses = useCallback( - async (name: string, target: "enabled" | "disabled"): Promise => { + async ( + name: string, + target: "enabled" | "disabled", + config?: McpInstallConfigValues, + ): Promise => { try { await pendingServerRegistry.run(name, async () => { - const response = await setHarnessesMutation.mutateAsync({ name, target }); + const response = await setHarnessesMutation.mutateAsync({ name, target, config }); + if (target === "enabled" && response.succeeded.length > 0) { + void availabilityMutation.mutateAsync(name).catch(() => undefined); + } if (!response.ok) { const failed = response.failed.map((f) => `${f.harness}: ${f.error}`).join("; "); setActionErrorMessage(failed || "Some harnesses could not be updated"); @@ -64,7 +98,7 @@ export function useMcpManagementController() { setActionErrorMessage(error instanceof Error ? error.message : "Action failed"); } }, - [pendingServerRegistry, setHarnessesMutation], + [availabilityMutation, pendingServerRegistry, setHarnessesMutation], ); const handleUninstallServer = useCallback( @@ -83,17 +117,22 @@ export function useMcpManagementController() { // Per-harness enable/disable from detail sheet binding matrix ---------- const handleEnableInHarness = useCallback( - async (name: string, harness: string): Promise => { + async ( + name: string, + harness: string, + config?: McpInstallConfigValues, + ): Promise => { const key = `${name}:${harness}`; try { await pendingPerHarnessRegistry.run(key, async () => { - await enableMutation.mutateAsync({ name, harness }); + await enableMutation.mutateAsync({ name, harness, config }); + void availabilityMutation.mutateAsync(name).catch(() => undefined); }); } catch (error) { setActionErrorMessage(error instanceof Error ? error.message : "Enable failed"); } }, - [enableMutation, pendingPerHarnessRegistry], + [availabilityMutation, enableMutation, pendingPerHarnessRegistry], ); const handleDisableInHarness = useCallback( @@ -115,7 +154,7 @@ export function useMcpManagementController() { name: string, args: { sourceKind: "managed" | "harness"; - sourceHarness?: string | null; + observedHarness?: string | null; harnesses?: string[]; }, ): Promise => { @@ -136,9 +175,9 @@ export function useMcpManagementController() { const handleAdoptConfig = useCallback( async ( name: string, - args: { sourceHarness?: string | null; harnesses?: string[] } = {}, + args: { observedHarness?: string | null; harnesses?: string[] } = {}, ): Promise => { - const key = args.sourceHarness ? `${name}:${args.sourceHarness}` : name; + const key = args.observedHarness ? `${name}:${args.observedHarness}` : name; try { await pendingAdoptRegistry.run(key, async () => { await adoptMutation.mutateAsync({ name, ...args }); @@ -200,9 +239,12 @@ export function useMcpManagementController() { const handleMultiSelectEnableAll = useCallback(async (): Promise => { await runBulkAction("enable-all", async (name) => { - await setHarnessesMutation.mutateAsync({ name, target: "enabled" }); + const response = await setHarnessesMutation.mutateAsync({ name, target: "enabled" }); + if (response.succeeded.length > 0) { + void availabilityMutation.mutateAsync(name).catch(() => undefined); + } }); - }, [runBulkAction, setHarnessesMutation]); + }, [availabilityMutation, runBulkAction, setHarnessesMutation]); const handleMultiSelectDisableAll = useCallback(async (): Promise => { await runBulkAction("disable-all", async (name) => { diff --git a/frontend/src/features/mcp/public.ts b/frontend/src/features/mcp/public.ts index 96fe6b7..9ac2e25 100644 --- a/frontend/src/features/mcp/public.ts +++ b/frontend/src/features/mcp/public.ts @@ -9,6 +9,7 @@ export { useSetMcpServerHarnessesMutation, useUninstallMcpServerMutation, } from "./api/management-queries"; +export { checkMcpServerAvailability } from "./api/management-client"; export { invalidateMcpQueries } from "./api/invalidation"; export { mcpManagementKeys } from "./api/keys"; export type { diff --git a/frontend/src/features/mcp/screens/McpInUsePage.test.tsx b/frontend/src/features/mcp/screens/McpInUsePage.test.tsx index e8bc3cc..8d4a619 100644 --- a/frontend/src/features/mcp/screens/McpInUsePage.test.tsx +++ b/frontend/src/features/mcp/screens/McpInUsePage.test.tsx @@ -22,6 +22,11 @@ function inventoryFixture(): McpInventoryDto { displayName: "Exa Search", kind: "managed", canEnable: true, + enabledStatus: "enabled", + availabilityStatus: "available", + availabilityReason: null, + mcpStatus: { kind: "available", reason: null }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: { name: "exa", displayName: "Exa Search", @@ -42,6 +47,14 @@ function inventoryFixture(): McpInventoryDto { displayName: "Context7", kind: "managed", canEnable: true, + enabledStatus: "disabled", + availabilityStatus: "unavailable", + availabilityReason: null, + mcpStatus: { + kind: "unchecked", + reason: null, + }, + installConfigStatus: { hasFields: false, missingRequired: [], configured: true }, spec: { name: "ctx", displayName: "Context7", @@ -69,6 +82,10 @@ function driftInventoryFixture(): McpInventoryDto { entries: [ { ...inventory.entries[0], + mcpStatus: { + kind: "connection_issue", + reason: null, + }, sightings: [ { harness: "codex", state: "managed" }, { harness: "claude", state: "drifted", driftDetail: "changed=url" }, @@ -84,6 +101,25 @@ function emptyInventory() { return { columns: [], entries: [] }; } +function marketplaceDetailFixture(overrides: Record = {}) { + return { + qualifiedName: "exa", + managedName: "exa", + displayName: "Exa Search", + description: "Fast search.", + iconUrl: null, + isRemote: true, + connections: [], + tools: [], + resources: [], + prompts: [], + capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + installConfig: { required: false, fields: [] }, + ...overrides, + }; +} + function renderPage(route = "/mcp/use") { return renderWithAppProviders(, { route }); } @@ -141,6 +177,47 @@ describe("McpInUsePage", () => { expect(screen.getByText("Context7")).toBeInTheDocument(); expect(screen.getByText("1/3")).toBeInTheDocument(); expect(screen.getByText("0/3")).toBeInTheDocument(); + expect(screen.getByLabelText("MCP status: Available")).toBeInTheDocument(); + expect(screen.getByLabelText("MCP status: Unchecked")).toBeInTheDocument(); + }); + + it("renders all public MCP status labels", async () => { + const inventory = inventoryFixture(); + inventory.entries = [ + inventory.entries[0], + inventory.entries[1], + { + ...inventory.entries[1], + name: "needs-config", + displayName: "Needs Config", + mcpStatus: { kind: "needs_config", reason: null }, + installConfigStatus: { + hasFields: true, + missingRequired: ["API_KEY"], + configured: false, + }, + }, + { + ...inventory.entries[1], + name: "failed", + displayName: "Failed MCP", + availabilityReason: "Connection refused", + mcpStatus: { kind: "connection_issue", reason: "Connection refused" }, + }, + ]; + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers")) return okJson(inventory); + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + + expect(screen.getByLabelText("MCP status: Available")).toBeInTheDocument(); + expect(screen.getByLabelText("MCP status: Unchecked")).toBeInTheDocument(); + expect(screen.getByLabelText("MCP status: Needs config")).toBeInTheDocument(); + expect(screen.getByLabelText("MCP status: Connection issue")).toBeInTheDocument(); }); it("renders the matrix view from the URL parameter", async () => { @@ -279,6 +356,18 @@ describe("McpInUsePage", () => { expect(init?.method).toBe("POST"); return okJson({ ok: true, succeeded: ["codex"], failed: [] }); } + if (url.includes("/api/mcp/servers/exa/availability/check")) { + expect(init?.method).toBe("POST"); + return okJson({ + ok: true, + name: "exa", + availabilityStatus: "available", + availabilityReason: null, + }); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return okJson(marketplaceDetailFixture()); + } if (url.includes("/api/mcp/servers")) return okJson(inventoryFixture()); throw new Error(`Unhandled URL ${url}`); }); @@ -294,16 +383,279 @@ describe("McpInUsePage", () => { ), ).toBe(true), ); + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/availability/check"), + ), + ).toBe(true), + ); }); - it("keeps enable as a direct set-harnesses action", async () => { + it("checks availability automatically for managed servers with no cached result", async () => { const inventory = inventoryFixture(); + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/availability/check")) { + expect(init?.method).toBe("POST"); + return okJson({ + ok: true, + name: "exa", + availabilityStatus: "available", + availabilityReason: null, + }); + } + if (url.includes("/api/mcp/servers")) { + return okJson({ + ...inventory, + entries: [ + { + ...inventory.entries[0], + availabilityStatus: "unavailable", + availabilityReason: null, + mcpStatus: { + kind: "unchecked", + reason: null, + }, + }, + inventory.entries[1], + ], + }); + } + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/availability/check"), + ), + ).toBe(true), + ); + }); + + it("does not check availability automatically when a connection failure is cached", async () => { + const inventory = inventoryFixture(); + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/availability/check")) { + return okJson({ + ok: true, + name: "exa", + availabilityStatus: "available", + availabilityReason: null, + }); + } + if (url.includes("/api/mcp/servers")) { + expect(init?.method ?? "GET").toBe("GET"); + return okJson({ + ...inventory, + entries: [ + { + ...inventory.entries[0], + availabilityStatus: "unavailable", + availabilityReason: "Connection refused", + mcpStatus: { + kind: "connection_issue", + reason: "Connection refused", + }, + }, + inventory.entries[1], + ], + }); + } + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/availability/check"), + ), + ).toBe(false); + }); + + it("refreshes availability after enabling from the detail binding row", async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/enable")) { + expect(init?.method).toBe("POST"); + return okJson({ ok: true }); + } + if (url.includes("/api/mcp/servers/exa/availability/check")) { + expect(init?.method).toBe("POST"); + return okJson({ + ok: true, + name: "exa", + availabilityStatus: "available", + availabilityReason: null, + }); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return okJson({ + qualifiedName: "exa", + managedName: "exa", + displayName: "Exa Search", + description: "Fast search.", + iconUrl: null, + isRemote: false, + connections: [], + tools: [], + resources: [], + prompts: [], + capabilityCounts: { tools: 0, resources: 0, prompts: 0 }, + externalUrl: "https://registry.modelcontextprotocol.io/?q=exa", + installConfig: { required: false, fields: [] }, + }); + } + if (url.includes("/api/mcp/servers/exa")) { + return okJson({ + ...inventoryFixture().entries[0], + env: [], + configChoices: [], + marketplaceLink: null, + }); + } + if (url.includes("/api/mcp/servers")) return okJson(inventoryFixture()); + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage("/mcp/use?server=exa"); + await waitFor(() => expect(screen.getByRole("heading", { name: "Exa Search" })).toBeInTheDocument()); + fireEvent.click(screen.getAllByRole("button", { name: "Enable" })[0]); + + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/enable"), + ), + ).toBe(true), + ); + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/availability/check"), + ), + ).toBe(true), + ); + }); + + it("uses set-harnesses when no install config is required", async () => { + const inventory = inventoryFixture(); + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/set-harnesses")) { + expect(init?.method).toBe("POST"); + return okJson({ ok: true, succeeded: ["cursor"], failed: [] }); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return okJson(marketplaceDetailFixture()); + } + if (url.includes("/api/mcp/servers")) return okJson(inventory); + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + fireEvent.click(screen.getAllByLabelText(/enable on all harnesses/i)[0]); + + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/set-harnesses"), + ), + ).toBe(true), + ); + }); + + it("does not fetch registry config when install fields are optional only", async () => { + const inventory = inventoryFixture(); + inventory.entries[0] = { + ...inventory.entries[0], + installConfigStatus: { + hasFields: true, + missingRequired: [], + configured: true, + }, + }; + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/set-harnesses")) { + expect(init?.method).toBe("POST"); + return okJson({ ok: true, succeeded: ["cursor"], failed: [] }); + } + if (url.includes("/api/marketplace/mcp/items/exa")) { + throw new Error("registry detail should not be loaded for optional-only fields"); + } + if (url.includes("/api/mcp/servers")) return okJson(inventory); + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + fireEvent.click(screen.getAllByLabelText(/enable on all harnesses/i)[0]); + + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/set-harnesses"), + ), + ).toBe(true), + ); + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/marketplace/mcp/items/exa"), + ), + ).toBe(false); + }); + + it("collects required install config before enabling all from a card", async () => { + const inventory = inventoryFixture(); + inventory.entries[0] = { + ...inventory.entries[0], + installConfigStatus: { + hasFields: true, + missingRequired: ["EXA_API_KEY"], + configured: false, + }, + mcpStatus: { + kind: "needs_config", + reason: null, + }, + }; fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/api/mcp/servers/exa/set-harnesses")) { expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + target: "enabled", + config: { EXA_API_KEY: "secret" }, + }); return okJson({ ok: true, succeeded: ["cursor"], failed: [] }); } + if (url.includes("/api/marketplace/mcp/items/exa")) { + return okJson(marketplaceDetailFixture({ + installConfig: { + required: true, + fields: [ + { + name: "EXA_API_KEY", + label: "EXA_API_KEY", + description: "Exa API key", + format: "string", + required: true, + secret: true, + default: null, + }, + ], + }, + })); + } if (url.includes("/api/mcp/servers")) return okJson(inventory); throw new Error(`Unhandled URL ${url}`); }); @@ -312,6 +664,14 @@ describe("McpInUsePage", () => { await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); fireEvent.click(screen.getAllByLabelText(/enable on all harnesses/i)[0]); + await waitFor(() => + expect(screen.getAllByRole("heading", { name: /configure exa search/i }).length).toBeGreaterThan(0), + ); + fireEvent.change(screen.getByLabelText(/EXA_API_KEY/), { + target: { value: "secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect( fetchMock.mock.calls.some((call) => @@ -321,6 +681,45 @@ describe("McpInUsePage", () => { ); }); + it("blocks multi-select enable when one selected server needs config", async () => { + const inventory = inventoryFixture(); + inventory.entries[0] = { + ...inventory.entries[0], + installConfigStatus: { + hasFields: true, + missingRequired: ["EXA_API_KEY"], + configured: false, + }, + mcpStatus: { + kind: "needs_config", + reason: null, + }, + }; + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/servers/exa/set-harnesses")) { + throw new Error("multi-select should not enable servers that need config"); + } + if (url.includes("/api/mcp/servers")) return okJson(inventory); + throw new Error(`Unhandled URL ${url}`); + }); + + renderPage(); + await waitFor(() => expect(screen.getByText("Exa Search")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("checkbox", { name: /select exa search/i })); + fireEvent.click(screen.getByRole("checkbox", { name: /select context7/i })); + fireEvent.click(screen.getByRole("button", { name: /^enable all$/i })); + + await waitFor(() => + expect(screen.getByText(/exa search requires credentials/i)).toBeInTheDocument(), + ); + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes("/api/mcp/servers/exa/set-harnesses"), + ), + ).toBe(false); + }); + it("opens detail instead of toggling all when a server has a different config", async () => { fetchMock.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); diff --git a/frontend/src/features/mcp/screens/McpInUsePage.tsx b/frontend/src/features/mcp/screens/McpInUsePage.tsx index befff02..a427695 100644 --- a/frontend/src/features/mcp/screens/McpInUsePage.tsx +++ b/frontend/src/features/mcp/screens/McpInUsePage.tsx @@ -10,16 +10,20 @@ import { LoadingSpinner } from "../../../components/LoadingSpinner"; import { PageHeader } from "../../../components/PageHeader"; import { ViewModeToggle, type ViewModeOption } from "../../../components/ViewModeToggle"; import { McpServerDetailSheet } from "../components/detail/McpServerDetailSheet"; +import { McpInstallConfigDialog } from "../components/config/McpInstallConfigDialog"; import { McpFilterMenu } from "../components/McpFilterMenu"; import { McpServerCardList } from "../components/McpServerCardList"; import { McpServerMatrixView } from "../components/McpServerMatrixView"; +import type { McpInventoryEntryDto } from "../api/management-types"; import { useCommonCopy } from "../../../i18n"; import { useMcpCopy } from "../i18n"; +import type { McpInstallConfigValues } from "../model/install-config"; import { filterMcpServersInUse, pillCounts, type InUsePillValue, } from "../model/selectors"; +import { useMcpEnableConfigGate } from "../model/use-mcp-enable-config-gate"; import { useMcpManagementController } from "../model/use-mcp-management-controller"; import { useMcpInUseViewMode, type McpInUseViewMode } from "../model/useMcpInUseViewMode"; @@ -52,12 +56,22 @@ export default function McpInUsePage() { const [searchParams, setSearchParams] = useSearchParams(); const selectedName = searchParams.get(DETAIL_PARAM); const [confirmUninstallName, setConfirmUninstallName] = useState(null); + const [pageActionErrorMessage, setPageActionErrorMessage] = useState(""); const [search, setSearch] = useState(""); const [pill, setPill] = useState("all"); const [viewMode, setViewMode] = useMcpInUseViewMode(); const copy = useMcpCopy(); const common = useCommonCopy(); + const { + requestEnable, + pendingConfig: pendingEnableConfig, + cancelConfig: cancelEnableConfig, + submitConfig: submitEnableConfig, + configError: enableConfigError, + } = useMcpEnableConfigGate({ + loadErrorMessage: copy.detail.unableToLoadInstallConfig, + }); const viewModeOptions: readonly ViewModeOption[] = useMemo( () => [ { value: "cards", label: copy.inUse.viewModes.cards, icon: Grid2X2 }, @@ -76,6 +90,96 @@ export default function McpInUsePage() { const inventoryIssueMessage = inventory?.issues?.length ? copy.inUse.inventoryIssue(inventory.issues.length) : ""; + const visibleActionErrorMessage = + actionErrorMessage || enableConfigError || pageActionErrorMessage; + + const findEntry = useCallback( + (name: string): McpInventoryEntryDto | null => + inventory?.entries.find((entry) => entry.name === name) ?? null, + [inventory], + ); + + const findHarnessLabel = useCallback( + (harness: string): string => + inventory?.columns.find((column) => column.harness === harness)?.label ?? harness, + [inventory], + ); + + const requestConfiguredEnable = useCallback( + ( + name: string, + targetLabel: string, + onProceed: (config?: McpInstallConfigValues) => void, + ): void => { + const entry = findEntry(name); + if (!entry) return; + requestEnable({ + spec: entry.spec ?? null, + displayName: entry.displayName, + targetLabel, + installConfigStatus: entry.installConfigStatus, + onProceed, + }); + }, + [findEntry, requestEnable], + ); + + const handleCardSetHarnesses = useCallback( + ( + name: string, + target: "enabled" | "disabled", + config?: McpInstallConfigValues, + ): void => { + if (target === "disabled") { + void handleSetServerHarnesses(name, target, config); + return; + } + requestConfiguredEnable(name, copy.detail.installConfig.allHarnesses, (nextConfig) => { + void handleSetServerHarnesses(name, target, nextConfig); + }); + }, + [copy.detail.installConfig.allHarnesses, handleSetServerHarnesses, requestConfiguredEnable], + ); + + const handleMatrixEnableHarness = useCallback( + (name: string, harness: string): void => { + requestConfiguredEnable(name, findHarnessLabel(harness), (config) => { + void handleEnableInHarness(name, harness, config); + }); + }, + [findHarnessLabel, handleEnableInHarness, requestConfiguredEnable], + ); + + const handleBulkEnableAll = useCallback(async (): Promise => { + const selectedNames = Array.from(multiSelectedNames); + if (selectedNames.length === 1) { + const [name] = selectedNames; + handleCardSetHarnesses(name, "enabled"); + return; + } + const blocked = selectedNames + .map((name) => findEntry(name)) + .find((entry): entry is McpInventoryEntryDto => + Boolean(entry?.installConfigStatus.missingRequired.length), + ) ?? null; + if (blocked) { + setPageActionErrorMessage(copy.detail.installConfig.bulkRequiresSingle(blocked.displayName)); + return; + } + setPageActionErrorMessage(""); + await handleMultiSelectEnableAll(); + }, [ + copy.detail.installConfig, + findEntry, + handleCardSetHarnesses, + handleMultiSelectEnableAll, + multiSelectedNames, + ]); + + const dismissVisibleActionError = useCallback(() => { + dismissActionError(); + setPageActionErrorMessage(""); + }, [dismissActionError]); const setDetailName = useCallback( (name: string | null) => { @@ -154,8 +258,8 @@ export default function McpInUsePage() { ) : null}
- {actionErrorMessage ? ( - + {visibleActionErrorMessage ? ( + ) : null} {inventoryIssueMessage ? : null} @@ -176,9 +280,7 @@ export default function McpInUsePage() { checkedNames={multiSelectedNames} onOpenDetail={setDetailName} onToggleChecked={handleToggleMultiSelect} - onEnableHarness={(name, harness) => { - void handleEnableInHarness(name, harness); - }} + onEnableHarness={handleMatrixEnableHarness} onDisableHarness={(name, harness) => { void handleDisableInHarness(name, harness); }} @@ -191,7 +293,7 @@ export default function McpInUsePage() { checkedNames={multiSelectedNames} onOpenDetail={setDetailName} onToggleChecked={handleToggleMultiSelect} - onSetHarnesses={handleSetServerHarnesses} + onSetHarnesses={handleCardSetHarnesses} onRequestUninstall={confirmUninstall} /> ) @@ -243,8 +345,8 @@ export default function McpInUsePage() { isServerPending={isServerPendingSelected} isUninstalling={isUninstallingSelected} onClose={() => setDetailName(null)} - onEnableHarness={(harness) => { - if (selectedName) void handleEnableInHarness(selectedName, harness); + onEnableHarness={(harness, config) => { + if (selectedName) void handleEnableInHarness(selectedName, harness, config); }} onDisableHarness={(harness) => { if (selectedName) void handleDisableInHarness(selectedName, harness); @@ -263,7 +365,7 @@ export default function McpInUsePage() { selectedCount={multiSelectedNames.size} pending={multiSelectPending} onClear={handleClearMultiSelect} - onEnableAll={handleMultiSelectEnableAll} + onEnableAll={handleBulkEnableAll} onDisableAll={handleMultiSelectDisableAll} onDelete={handleMultiSelectUninstall} destructive={{ @@ -285,6 +387,12 @@ export default function McpInUsePage() { }} onConfirm={executeUninstall} /> + ); } diff --git a/frontend/src/features/mcp/screens/McpNeedsReviewPage.tsx b/frontend/src/features/mcp/screens/McpNeedsReviewPage.tsx index 6b92269..0f88468 100644 --- a/frontend/src/features/mcp/screens/McpNeedsReviewPage.tsx +++ b/frontend/src/features/mcp/screens/McpNeedsReviewPage.tsx @@ -185,7 +185,7 @@ export default function McpNeedsReviewPage() { onClose={() => setChooseConfigName(null)} onConfirm={async (option) => { await handleAdoptConfig(chooseConfigGroup.name, { - sourceHarness: option.sourceHarness, + observedHarness: option.observedHarness, harnesses: chooseConfigGroup.sightings.map((sighting) => sighting.harness), }); setChooseConfigName(null); @@ -200,7 +200,7 @@ function optionsForGroup(group: McpIdentityGroupDto): McpConfigChoiceOption[] { return group.sightings.map((sighting) => ({ id: sighting.harness, sourceKind: "harness", - sourceHarness: sighting.harness, + observedHarness: sighting.harness, label: sighting.label, logoKey: sighting.logoKey, configPath: sighting.configPath, diff --git a/frontend/src/features/mcp/styles/detail-sheet.css b/frontend/src/features/mcp/styles/detail-sheet.css index c919c52..b663968 100644 --- a/frontend/src/features/mcp/styles/detail-sheet.css +++ b/frontend/src/features/mcp/styles/detail-sheet.css @@ -43,6 +43,12 @@ gap: 12px; } +.mcp-detail__meta-stack { + display: grid; + gap: 10px; + min-width: 0; +} + .mcp-detail__qualified-name { font-family: var(--font-mono); font-size: var(--font-size-xs); @@ -56,6 +62,25 @@ line-height: 1.5; } +.mcp-detail__status-summary { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.mcp-detail__status-summary p { + flex: 1 1 auto; + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + .mcp-detail__drift-banner { display: flex; align-items: center; diff --git a/frontend/src/features/mcp/styles/pages.css b/frontend/src/features/mcp/styles/pages.css index 9359713..fc61093 100644 --- a/frontend/src/features/mcp/styles/pages.css +++ b/frontend/src/features/mcp/styles/pages.css @@ -14,6 +14,18 @@ opacity: 0.7; } +.mcp-server-card__head { + grid-template-columns: minmax(0, 1fr) auto auto; +} + +.mcp-server-card__actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + min-width: 0; +} + .mcp-server-card__transport { margin: 0; font-size: var(--font-size-xs); @@ -41,6 +53,48 @@ color: var(--color-warning, #f59e0b); } +.mcp-status-chip { + gap: 5px; + flex: 0 0 auto; + border: 1px solid var(--color-border); + max-width: 128px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-status-chip[data-kind="available"] { + border-color: color-mix(in srgb, var(--color-success) 36%, transparent); + background: color-mix(in srgb, var(--color-success-soft) 78%, transparent); + color: var(--color-success); +} + +.mcp-status-chip[data-kind="connection_issue"] { + border-color: color-mix(in srgb, var(--color-danger) 32%, transparent); + background: color-mix(in srgb, var(--color-danger-soft) 82%, transparent); + color: var(--color-danger); +} + +.mcp-status-chip[data-kind="needs_config"] { + border-color: color-mix(in srgb, var(--color-warning) 36%, transparent); + background: color-mix(in srgb, var(--color-warning-soft) 78%, transparent); + color: var(--color-warning); +} + +.mcp-status-chip[data-kind="unchecked"] { + border-color: var(--color-border); + background: color-mix(in srgb, var(--color-text) 4%, transparent); + color: var(--color-text-muted); +} + +.mcp-status-chip__dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; +} + .mcp-server-card__detail { font-family: var(--font-mono); font-size: var(--font-size-xs); diff --git a/frontend/src/features/overview/screens/OverviewPage.test.tsx b/frontend/src/features/overview/screens/OverviewPage.test.tsx index 56f32e1..2b0aa49 100644 --- a/frontend/src/features/overview/screens/OverviewPage.test.tsx +++ b/frontend/src/features/overview/screens/OverviewPage.test.tsx @@ -270,7 +270,7 @@ describe("OverviewPage", () => { expect(within(skillsRow).getByRole("link", { name: "Browse" })).toHaveAttribute("href", "/marketplace/skills"); const mcpRow = within(marketplaceSection).getByRole("heading", { name: "MCP Marketplace" }).closest("article") as HTMLElement; - expect(within(mcpRow).getByText("smithery.ai")).toBeInTheDocument(); + expect(within(mcpRow).getByText("MCP Registry")).toBeInTheDocument(); expect(within(mcpRow).getByRole("link", { name: "Browse" })).toHaveAttribute("href", "/marketplace/mcp"); const cliRow = within(marketplaceSection).getByRole("heading", { name: "CLI Marketplace" }).closest("article") as HTMLElement; diff --git a/frontend/src/features/skills/model/use-skill-scan.test.tsx b/frontend/src/features/skills/model/use-skill-scan.test.tsx new file mode 100644 index 0000000..5c243bd --- /dev/null +++ b/frontend/src/features/skills/model/use-skill-scan.test.tsx @@ -0,0 +1,93 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ScanResult } from "../api/scan-types"; +import { useSkillScan } from "./use-skill-scan"; + +const scanClient = vi.hoisted(() => ({ + scanSkill: vi.fn(), + getScanConfigs: vi.fn(), + createScanConfig: vi.fn(), + updateScanConfig: vi.fn(), + deleteScanConfig: vi.fn(), + setActiveScanConfig: vi.fn(), + validateScanConfig: vi.fn(), + revealScanConfigApiKey: vi.fn(), +})); + +vi.mock("../api/scan-client", () => scanClient); + +const scanResult: ScanResult = { + skillName: "Trace Lens", + isSafe: true, + maxSeverity: "SAFE", + findingsCount: 0, + findings: [], + analyzersUsed: ["llm_analyzer"], + durationSeconds: 1.2, +}; + +describe("useSkillScan", () => { + beforeEach(() => { + window.localStorage.clear(); + scanClient.scanSkill.mockReset(); + scanClient.getScanConfigs.mockReset(); + scanClient.createScanConfig.mockReset(); + scanClient.updateScanConfig.mockReset(); + scanClient.deleteScanConfig.mockReset(); + scanClient.setActiveScanConfig.mockReset(); + scanClient.validateScanConfig.mockReset(); + scanClient.revealScanConfigApiKey.mockReset(); + scanClient.getScanConfigs.mockResolvedValue({ + activeId: 1, + configs: [ + { + id: 1, + name: "Default", + baseUrl: "https://api.example.com/v1", + apiKeyMasked: "sk-t...cret", + model: "model-a", + provider: "openai-compatible", + apiVersion: "", + awsRegion: "", + awsProfile: "", + maxTokens: 8192, + consensusRuns: 1, + isActive: true, + lastValidatedAt: null, + lastValidationError: "", + }, + ], + }); + }); + + it("keeps an in-flight scan alive when the consuming page unmounts", async () => { + let resolveScan: (result: ScanResult) => void = () => undefined; + scanClient.scanSkill.mockReturnValue(new Promise((resolve) => { + resolveScan = resolve; + })); + + const first = renderHook(() => useSkillScan()); + await waitFor(() => expect(first.result.current.llmConfig?.id).toBe(1)); + + let pendingScan: Promise = Promise.resolve(); + act(() => { + pendingScan = first.result.current.scanSkill("shared:trace-lens"); + }); + await waitFor(() => { + expect(first.result.current.getScanState("shared:trace-lens").status).toBe("scanning"); + }); + + first.unmount(); + await act(async () => { + resolveScan(scanResult); + await pendingScan; + }); + + const second = renderHook(() => useSkillScan()); + await waitFor(() => { + expect(second.result.current.getScanState("shared:trace-lens").status).toBe("done"); + }); + expect(second.result.current.getScanState("shared:trace-lens").result?.skillName).toBe("Trace Lens"); + }); +}); diff --git a/frontend/src/features/skills/model/use-skill-scan.ts b/frontend/src/features/skills/model/use-skill-scan.ts index d840fa2..2913e1c 100644 --- a/frontend/src/features/skills/model/use-skill-scan.ts +++ b/frontend/src/features/skills/model/use-skill-scan.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; import type { ScanResult, ScanConfigItem } from "../api/scan-types"; import { @@ -128,46 +128,95 @@ export interface LLMScanConfigInput { awsSessionToken?: string; } +interface SkillScanStoreSnapshot { + scanState: ScanStateMap; + configs: ScanConfigItem[]; + activeConfigId: number | null; + llmConfig: LLMScanConfig | null; + configLoaded: boolean; +} + +let scanStoreSnapshot: SkillScanStoreSnapshot = { + scanState: {}, + configs: [], + activeConfigId: null, + llmConfig: null, + configLoaded: false, +}; +let hydratedCachedReports = false; + +const scanStoreListeners = new Set<() => void>(); + +function subscribeToScanStore(listener: () => void): () => void { + scanStoreListeners.add(listener); + return () => { + scanStoreListeners.delete(listener); + }; +} + +function getScanStoreSnapshot(): SkillScanStoreSnapshot { + return scanStoreSnapshot; +} + +function updateScanStore( + updater: (current: SkillScanStoreSnapshot) => SkillScanStoreSnapshot, +): void { + scanStoreSnapshot = updater(scanStoreSnapshot); + for (const listener of scanStoreListeners) { + listener(); + } +} + +function hydrateCachedScanReports(): void { + if (hydratedCachedReports) { + return; + } + hydratedCachedReports = true; + updateScanStore((current) => ({ + ...current, + scanState: { + ...readCachedScanReports(), + ...current.scanState, + }, + })); +} + export function useSkillScan() { - const [scanState, setScanState] = useState({}); - const [configs, setConfigs] = useState([]); - const [activeConfigId, setActiveConfigIdState] = useState(null); - const [llmConfig, setLlmConfigState] = useState(null); - const [configLoaded, setConfigLoaded] = useState(false); + const snapshot = useSyncExternalStore( + subscribeToScanStore, + getScanStoreSnapshot, + getScanStoreSnapshot, + ); const refreshConfigs = useCallback(async () => { try { const resp = await getScanConfigs(); - setConfigs(resp.configs); - setActiveConfigIdState(resp.activeId); - - if (resp.activeId !== null) { - const active = resp.configs.find((c) => c.id === resp.activeId); - if (active) { - setLlmConfigState(buildConfigFromItem(active)); - } - } else { - setLlmConfigState(null); - } + const active = resp.activeId !== null + ? resp.configs.find((c) => c.id === resp.activeId) + : null; + updateScanStore((current) => ({ + ...current, + configs: resp.configs, + activeConfigId: resp.activeId, + llmConfig: active ? buildConfigFromItem(active) : null, + configLoaded: true, + })); } catch { - /* ignore */ + updateScanStore((current) => ({ ...current, configLoaded: true })); } }, []); useEffect(() => { - refreshConfigs().finally(() => setConfigLoaded(true)); + void refreshConfigs(); }, [refreshConfigs]); useEffect(() => { - setScanState((current) => ({ - ...readCachedScanReports(), - ...current, - })); + hydrateCachedScanReports(); }, []); const getScanState = useCallback( - (skillRef: string): SkillScanState => scanState[skillRef] ?? IDLE_STATE, - [scanState], + (skillRef: string): SkillScanState => snapshot.scanState[skillRef] ?? IDLE_STATE, + [snapshot.scanState], ); const addConfig = useCallback( @@ -232,27 +281,41 @@ export function useSkillScan() { const scanSkill = useCallback( async (skillRef: string) => { - if (!llmConfig) return; - setScanState((prev) => ({ - ...prev, - [skillRef]: { status: "scanning", result: null, error: null, completedAt: null }, + if (!snapshot.llmConfig) return; + updateScanStore((current) => ({ + ...current, + scanState: { + ...current.scanState, + [skillRef]: { status: "scanning", result: null, error: null, completedAt: null }, + }, })); try { const result = await scanSkillApi(skillRef, { useLlm: true }); const completedAt = Date.now(); cacheScanResult(skillRef, result, completedAt); - setScanState((prev) => ({ - ...prev, - [skillRef]: { status: "done", result, error: null, completedAt }, + updateScanStore((current) => ({ + ...current, + scanState: { + ...current.scanState, + [skillRef]: { status: "done", result, error: null, completedAt }, + }, })); } catch (e) { - setScanState((prev) => ({ - ...prev, - [skillRef]: { status: "error", result: null, error: e instanceof Error ? e.message : String(e), completedAt: null }, + updateScanStore((current) => ({ + ...current, + scanState: { + ...current.scanState, + [skillRef]: { + status: "error", + result: null, + error: e instanceof Error ? e.message : String(e), + completedAt: null, + }, + }, })); } }, - [llmConfig], + [snapshot.llmConfig], ); const validateConfig = useCallback( @@ -269,18 +332,18 @@ export function useSkillScan() { ); return { - scanState, + scanState: snapshot.scanState, getScanState, scanSkill, - llmConfig, - configs, - activeConfigId, + llmConfig: snapshot.llmConfig, + configs: snapshot.configs, + activeConfigId: snapshot.activeConfigId, addConfig, editConfig, removeConfig, selectConfig, validateConfig, revealConfigApiKey, - configLoaded, + configLoaded: snapshot.configLoaded, }; } diff --git a/frontend/src/features/skills/styles/scan.css b/frontend/src/features/skills/styles/scan.css index 7f4b81b..63b26d5 100644 --- a/frontend/src/features/skills/styles/scan.css +++ b/frontend/src/features/skills/styles/scan.css @@ -44,6 +44,23 @@ line-height: 1.4; } +.scan-config-panel__checkbox { + display: inline-flex; + align-items: center; + gap: var(--space-3); + min-height: 38px; + color: var(--color-text); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.scan-config-panel__checkbox input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-accent); +} + .scan-config-panel__input { width: 100%; min-width: 0; @@ -124,6 +141,16 @@ line-height: 1.4; } +.scan-config-panel__hint-link { + color: var(--color-accent); + text-decoration: none; +} + +.scan-config-panel__hint-link:hover, +.scan-config-panel__hint-link:focus-visible { + text-decoration: underline; +} + .scan-config-panel__form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/styles/components/filter.css b/frontend/src/styles/components/filter.css index cfc6b9f..dbe0dba 100644 --- a/frontend/src/styles/components/filter.css +++ b/frontend/src/styles/components/filter.css @@ -15,12 +15,15 @@ position: relative; flex: 1 1 260px; min-width: 220px; + --filter-search-icon-space: 38px; + --filter-search-clear-size: 24px; + --filter-search-clear-inset: 7px; } .filter-bar__search input { width: 100%; height: 38px; - padding: 0 var(--space-4) 0 38px; + padding: 0 var(--filter-search-icon-space); border: none; border-radius: var(--radius-sm); background: var(--color-surface); @@ -29,6 +32,11 @@ font-size: 0.9rem; } +.filter-bar__search input::-webkit-search-cancel-button, +.filter-bar__search input::-webkit-search-decoration { + appearance: none; +} + .filter-bar__search input:focus-visible { outline: none; background: var(--color-surface-raised); @@ -39,7 +47,7 @@ color: var(--color-text-subtle); } -.filter-bar__search svg { +.filter-bar__search-icon { position: absolute; left: var(--space-3); top: 50%; @@ -48,6 +56,39 @@ pointer-events: none; } +.filter-bar__clear { + position: absolute; + right: var(--filter-search-clear-inset); + top: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--filter-search-clear-size); + height: var(--filter-search-clear-size); + padding: 0; + transform: translateY(-50%); + border: none; + border-radius: 999px; + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + color: var(--color-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease, transform 120ms ease; +} + +.filter-bar__clear:hover { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.filter-bar__clear:active { + transform: translateY(-50%) scale(0.94); +} + +.filter-bar__clear:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-softer); +} + .pill-group { display: inline-flex; align-items: center; diff --git a/frontend/src/test/fixtures/mcp.ts b/frontend/src/test/fixtures/mcp.ts index 0256756..424eae1 100644 --- a/frontend/src/test/fixtures/mcp.ts +++ b/frontend/src/test/fixtures/mcp.ts @@ -21,6 +21,22 @@ export function mcpInventoryEntry({ displayName = name, sightings = [], canEnable = kind === "managed", + enabledStatus = "disabled", + availabilityStatus = "unavailable", + availabilityReason = null, + mcpStatus = availabilityStatus === "available" + ? { kind: "available" as const, reason: null } + : availabilityReason + ? { + kind: "connection_issue" as const, + reason: availabilityReason, + } + : { kind: "unchecked" as const, reason: null }, + installConfigStatus = { + hasFields: false, + missingRequired: [], + configured: true, + }, spec = null, }: Pick & Partial): McpInventoryEntryDto { return { @@ -28,6 +44,11 @@ export function mcpInventoryEntry({ displayName, kind, canEnable, + enabledStatus, + availabilityStatus, + availabilityReason, + mcpStatus, + installConfigStatus, spec, sightings, }; diff --git a/skill_manager/api/routers/marketplace_mcp.py b/skill_manager/api/routers/marketplace_mcp.py index c7f7c1c..2a3d6a9 100644 --- a/skill_manager/api/routers/marketplace_mcp.py +++ b/skill_manager/api/routers/marketplace_mcp.py @@ -4,7 +4,6 @@ from skill_manager.api.deps import get_container from skill_manager.api.schemas import ( - McpInstallTargetsResponse, McpMarketplaceDetailResponse, McpMarketplacePageResponse, ) @@ -43,13 +42,6 @@ def search_mcp_marketplace( raise HTTPException(status_code=400, detail=str(error)) from error -@router.get("/install-targets", response_model=McpInstallTargetsResponse) -def get_mcp_install_targets( - container: BackendContainer = Depends(get_container), -) -> dict[str, object]: - return container.mcp_mutations.install_targets() - - @router.get("/items/{qualified_name:path}", response_model=McpMarketplaceDetailResponse) def get_mcp_marketplace_detail( qualified_name: str, diff --git a/skill_manager/api/routers/mcp.py b/skill_manager/api/routers/mcp.py index 098f6dc..51ab7da 100644 --- a/skill_manager/api/routers/mcp.py +++ b/skill_manager/api/routers/mcp.py @@ -9,6 +9,7 @@ DisableMcpServerRequest, EnableMcpServerRequest, McpApplyConfigResponse, + McpAvailabilityCheckResponse, McpInventoryResponse, McpServerDetailResponse, McpServerMutationResponse, @@ -36,15 +37,20 @@ def get_mcp_server( return container.mcp_queries.get_server(name) +@router.post("/servers/{name}/availability/check", response_model=McpAvailabilityCheckResponse) +def check_mcp_server_availability( + name: str, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_queries.check_availability(name) + + @router.post("/servers", response_model=McpServerMutationResponse) def install_mcp_server( body: AddMcpServerRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, object]: - return container.mcp_mutations.install_from_marketplace( - body.qualified_name, - source_harness=body.source_harness, - ) + return container.mcp_mutations.install_from_marketplace(body.qualified_name) @router.delete("/servers/{name}", response_model=McpSetHarnessesResultResponse) @@ -61,7 +67,7 @@ def enable_mcp_server( body: EnableMcpServerRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, bool]: - return container.mcp_mutations.enable_server(name, body.harness) + return container.mcp_mutations.enable_server(name, body.harness, config=body.config) @router.post("/servers/{name}/disable", response_model=OkResponse) @@ -82,7 +88,7 @@ def reconcile_mcp_server( return container.mcp_mutations.reconcile_server( name, source_kind=body.source_kind, - source_harness=body.source_harness, + observed_harness=body.observed_harness, harnesses=body.harnesses, ) @@ -93,7 +99,7 @@ def set_mcp_server_harnesses( body: SetMcpServerHarnessesRequest, container: BackendContainer = Depends(get_container), ) -> dict[str, object]: - return container.mcp_mutations.set_server_all_harnesses(name, body.target) + return container.mcp_mutations.set_server_all_harnesses(name, body.target, config=body.config) @router.get("/unmanaged/by-server", response_model=McpUnmanagedByServerResponse) @@ -110,6 +116,6 @@ def adopt_mcp_server( ) -> dict[str, object]: return container.mcp_mutations.adopt( body.name, - source_harness=body.source_harness, + observed_harness=body.observed_harness, harnesses=body.harnesses, ) diff --git a/skill_manager/api/schemas/__init__.py b/skill_manager/api/schemas/__init__.py index 0e501cd..c1f215f 100644 --- a/skill_manager/api/schemas/__init__.py +++ b/skill_manager/api/schemas/__init__.py @@ -10,18 +10,18 @@ DisableMcpServerRequest, EnableMcpServerRequest, McpApplyConfigResponse, + McpAvailabilityCheckResponse, McpAdoptionIssueResponse, McpBindingResponse, McpConfigChoiceResponse, McpEnvEntryResponse, McpIdentityGroupResponse, McpIdentitySightingResponse, - McpInstallTargetResponse, - McpInstallTargetsResponse, McpInventoryColumnResponse, McpInventoryEntryResponse, McpInventoryIssueResponse, McpInventoryResponse, + McpInstallConfigStatusResponse, McpMarketplaceCapabilityCountsResponse, McpMarketplaceConnectionResponse, McpMarketplaceDetailResponse, @@ -39,6 +39,7 @@ McpServerSpecResponse, McpSetHarnessesResultResponse, McpSourceResponse, + McpStatusResponse, McpUnmanagedByServerResponse, McpUnmanagedHarnessResponse, ReconcileMcpServerRequest, @@ -104,18 +105,18 @@ "InstallMarketplaceSkillRequest", "AddMcpServerRequest", "McpApplyConfigResponse", + "McpAvailabilityCheckResponse", "McpAdoptionIssueResponse", "McpBindingResponse", "McpConfigChoiceResponse", "McpEnvEntryResponse", "McpIdentityGroupResponse", "McpIdentitySightingResponse", - "McpInstallTargetResponse", - "McpInstallTargetsResponse", "McpInventoryColumnResponse", "McpInventoryEntryResponse", "McpInventoryIssueResponse", "McpInventoryResponse", + "McpInstallConfigStatusResponse", "McpMarketplaceCapabilityCountsResponse", "McpMarketplaceConnectionResponse", "McpMarketplaceDetailResponse", @@ -133,6 +134,7 @@ "McpServerSpecResponse", "McpSetHarnessesResultResponse", "McpSourceResponse", + "McpStatusResponse", "McpUnmanagedByServerResponse", "McpUnmanagedHarnessResponse", "OkResponse", diff --git a/skill_manager/api/schemas/mcp.py b/skill_manager/api/schemas/mcp.py index ae8ebc9..da892a1 100644 --- a/skill_manager/api/schemas/mcp.py +++ b/skill_manager/api/schemas/mcp.py @@ -8,14 +8,13 @@ class AddMcpServerRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="forbid") qualified_name: str = Field(..., alias="qualifiedName", min_length=1) - source_harness: str = Field(..., alias="sourceHarness", min_length=1) class EnableMcpServerRequest(HarnessTarget): - pass + config: dict[str, object] | None = None class DisableMcpServerRequest(HarnessTarget): @@ -24,13 +23,18 @@ class DisableMcpServerRequest(HarnessTarget): class SetMcpServerHarnessesRequest(BaseModel): target: Literal["enabled", "disabled"] + config: dict[str, object] | None = None class AdoptMcpRequest(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") name: str = Field(..., min_length=1) - source_harness: str | None = Field(default=None, alias="sourceHarness") + observed_harness: str | None = Field( + default=None, + alias="observedHarness", + title="Observed harness", + ) harnesses: list[str] | None = None @@ -38,7 +42,11 @@ class ReconcileMcpServerRequest(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") source_kind: Literal["managed", "harness"] = Field(..., alias="sourceKind") - source_harness: str | None = Field(default=None, alias="sourceHarness") + observed_harness: str | None = Field( + default=None, + alias="observedHarness", + title="Observed harness", + ) harnesses: list[str] | None = None @@ -82,12 +90,33 @@ class McpBindingResponse(BaseModel): driftDetail: str | None = None +class McpStatusResponse(BaseModel): + kind: Literal[ + "available", + "needs_config", + "connection_issue", + "unchecked", + ] + reason: str | None = None + + +class McpInstallConfigStatusResponse(BaseModel): + hasFields: bool + missingRequired: list[str] + configured: bool + + class McpInventoryEntryResponse(BaseModel): name: str displayName: str kind: Literal["managed", "unmanaged"] spec: McpServerSpecResponse | None = None canEnable: bool + enabledStatus: Literal["enabled", "disabled"] + availabilityStatus: Literal["available", "unavailable"] + availabilityReason: str | None = None + mcpStatus: McpStatusResponse + installConfigStatus: McpInstallConfigStatusResponse sightings: list[McpBindingResponse] @@ -113,6 +142,13 @@ class McpServerMutationResponse(BaseModel): server: McpServerSpecResponse +class McpAvailabilityCheckResponse(BaseModel): + ok: bool + name: str + availabilityStatus: Literal["available", "unavailable"] + availabilityReason: str | None = None + + class McpApplyConfigResponse(BaseModel): ok: bool server: McpServerSpecResponse @@ -128,7 +164,7 @@ class McpEnvEntryResponse(BaseModel): class McpConfigChoiceResponse(BaseModel): sourceKind: Literal["managed", "harness"] - sourceHarness: str | None = None + observedHarness: str | None = Field(default=None, title="Observed harness") label: str logoKey: str | None = None configPath: str | None = None @@ -142,6 +178,8 @@ class McpMarketplaceLinkResponse(BaseModel): displayName: str iconUrl: str | None = None externalUrl: str + githubUrl: str | None = None + websiteUrl: str | None = None description: str isRemote: bool isVerified: bool @@ -210,6 +248,8 @@ class McpMarketplaceItemResponse(BaseModel): useCount: int createdAt: str | None = None homepage: str | None = None + websiteUrl: str | None = None + githubUrl: str | None = None externalUrl: str @@ -219,19 +259,6 @@ class McpMarketplacePageResponse(BaseModel): hasMore: bool -class McpInstallTargetResponse(BaseModel): - harness: str - label: str - logoKey: str | None = None - smitheryClient: str | None = None - supported: bool - reason: str | None = None - - -class McpInstallTargetsResponse(BaseModel): - targets: list[McpInstallTargetResponse] - - class McpMarketplaceConnectionResponse(BaseModel): kind: str deploymentUrl: str | None = None @@ -243,6 +270,24 @@ class McpMarketplaceConnectionResponse(BaseModel): stdioArgs: list[str] | None = None +class McpInstallConfigFieldResponse(BaseModel): + name: str + label: str + description: str + format: Literal["string", "number", "boolean", "filepath"] + required: bool + secret: bool + default: str | None = None + placeholder: str | None = None + choices: list[str] = Field(default_factory=list) + target: Literal["env", "header", "urlVariable", "packageArgument", "runtimeArgument"] + + +class McpInstallConfigResponse(BaseModel): + required: bool + fields: list[McpInstallConfigFieldResponse] = Field(default_factory=list) + + class McpMarketplaceParameterResponse(BaseModel): name: str type: str @@ -302,7 +347,10 @@ class McpMarketplaceDetailResponse(BaseModel): resources: list[McpMarketplaceResourceResponse] prompts: list[McpMarketplacePromptResponse] capabilityCounts: McpMarketplaceCapabilityCountsResponse + websiteUrl: str | None = None + githubUrl: str | None = None externalUrl: str + installConfig: McpInstallConfigResponse = Field(default_factory=lambda: McpInstallConfigResponse(required=False)) __all__ = [ @@ -312,6 +360,7 @@ class McpMarketplaceDetailResponse(BaseModel): "AddMcpServerRequest", "McpServerMutationResponse", "McpApplyConfigResponse", + "McpAvailabilityCheckResponse", "McpAdoptionIssueResponse", "McpBindingResponse", "McpConfigChoiceResponse", @@ -322,8 +371,9 @@ class McpMarketplaceDetailResponse(BaseModel): "McpInventoryIssueResponse", "McpInventoryEntryResponse", "McpInventoryResponse", - "McpInstallTargetResponse", - "McpInstallTargetsResponse", + "McpInstallConfigFieldResponse", + "McpInstallConfigResponse", + "McpInstallConfigStatusResponse", "McpMarketplaceCapabilityCountsResponse", "McpMarketplaceConnectionResponse", "McpMarketplaceDetailResponse", @@ -340,6 +390,7 @@ class McpMarketplaceDetailResponse(BaseModel): "McpServerSpecResponse", "McpSetHarnessesResultResponse", "McpSourceResponse", + "McpStatusResponse", "McpUnmanagedByServerResponse", "McpUnmanagedHarnessResponse", "ReconcileMcpServerRequest", diff --git a/skill_manager/application/container.py b/skill_manager/application/container.py index 24c13e8..a7b9b56 100644 --- a/skill_manager/application/container.py +++ b/skill_manager/application/container.py @@ -11,10 +11,10 @@ from .cli_marketplace import CliMarketplaceCatalog from .invalidation import InvalidationFanout from .mcp.enrichment import McpEnrichmentService -from .mcp.installers import McpInstallProvider, SmitheryCliInstallProvider from .mcp.marketplace import McpMarketplaceCatalog from .mcp.mutations import McpMutationService from .mcp.planner import McpAdoptionPlanner +from .mcp.availability import McpAvailabilityProbe from .mcp.query import McpQueryService from .mcp.read_models import McpReadModelService from .mcp.store import McpServerStore @@ -86,7 +86,7 @@ def build_backend_container( mcp_marketplace_catalog: McpMarketplaceCatalog | None = None, cli_marketplace_catalog: CliMarketplaceCatalog | None = None, source_fetcher: SourceFetchService | None = None, - mcp_install_provider: McpInstallProvider | None = None, + mcp_availability_probe: McpAvailabilityProbe | None = None, ) -> BackendContainer: active_env = dict(os.environ) if env is not None: @@ -162,18 +162,24 @@ def build_backend_container( ) mcp_enrichment = McpEnrichmentService(mcp_catalog) mcp_planner = McpAdoptionPlanner(mcp_read_models) + mcp_availability_probe = mcp_availability_probe or McpAvailabilityProbe() + mcp_availability_cache = {} mcp_queries = McpQueryService( mcp_read_models, planner=mcp_planner, enrichment=mcp_enrichment, + marketplace_catalog=mcp_catalog, + availability_probe=mcp_availability_probe, + availability_cache=mcp_availability_cache, ) mcp_mutations = McpMutationService( store=mcp_store, read_models=mcp_read_models, planner=mcp_planner, marketplace_catalog=mcp_catalog, - install_provider=mcp_install_provider or SmitheryCliInstallProvider(env=active_env), enrichment=mcp_enrichment, + availability_probe=mcp_availability_probe, + availability_cache=mcp_availability_cache, ) db = Database(paths.db_path) diff --git a/skill_manager/application/marketplace_cache.py b/skill_manager/application/marketplace_cache.py index b20d9ad..5d99e1e 100644 --- a/skill_manager/application/marketplace_cache.py +++ b/skill_manager/application/marketplace_cache.py @@ -44,8 +44,17 @@ def load(self, namespace: str, key: str) -> StoredPayload | None: path = self._path_for(namespace, key) if path is None or not path.is_file(): return None - payload = json.loads(path.read_text(encoding="utf-8")) - fetched_at = float(payload.get("fetchedAt", 0)) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("cache payload must be a JSON object") + fetched_at = float(payload.get("fetchedAt", 0)) + except (OSError, ValueError): + try: + path.unlink() + except OSError: + pass + return None age = max(0.0, time.time() - fetched_at) return StoredPayload(payload=payload.get("payload"), fetched_at=fetched_at, age_seconds=age) @@ -54,10 +63,10 @@ def write(self, namespace: str, key: str, payload: object) -> None: if path is None: return path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps({"fetchedAt": time.time(), "payload": payload}, ensure_ascii=False, indent=2), - encoding="utf-8", - ) + encoded = json.dumps({"fetchedAt": time.time(), "payload": payload}, ensure_ascii=False, indent=2) + temp_path = path.with_suffix(f"{path.suffix}.tmp") + temp_path.write_text(encoded, encoding="utf-8") + temp_path.replace(path) def _path_for(self, namespace: str, key: str) -> Path | None: if self.root is None: diff --git a/skill_manager/application/mcp/__init__.py b/skill_manager/application/mcp/__init__.py index d36e2b2..2bef3cc 100644 --- a/skill_manager/application/mcp/__init__.py +++ b/skill_manager/application/mcp/__init__.py @@ -10,7 +10,6 @@ McpObservedEntry, ) from .identity import AdoptionIssue, AdoptionPlan, HarnessSighting, ServerIdentityGroup, build_identity_plan -from .installers import McpInstallProvider, McpInstallResult, SmitheryCliInstallProvider from .names import canonical_server_name from .inventory import build_inventory from .mappers import ( @@ -40,8 +39,6 @@ "McpHarnessAdapter", "McpHarnessScan", "McpHarnessStatus", - "McpInstallProvider", - "McpInstallResult", "McpInventory", "McpInventoryEntry", "McpManagedManifest", @@ -52,7 +49,6 @@ "OpenClawMapper", "OpenCodeMapper", "ServerIdentityGroup", - "SmitheryCliInstallProvider", "TransportMapper", "build_identity_plan", "build_inventory", diff --git a/skill_manager/application/mcp/availability.py b/skill_manager/application/mcp/availability.py new file mode 100644 index 0000000..f7c179f --- /dev/null +++ b/skill_manager/application/mcp/availability.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +import time +from dataclasses import dataclass +from typing import Callable, Literal +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .store import McpServerSpec + + +AvailabilityStatus = Literal["available", "unavailable"] +HttpPost = Callable[[str, dict[str, object], dict[str, str]], tuple[dict[str, object], dict[str, str]]] + + +@dataclass(frozen=True) +class McpAvailabilityResult: + status: AvailabilityStatus + reason: str | None = None + + +AvailabilityCache = dict[tuple[str, str], McpAvailabilityResult] + + +def availability_cache_key(name: str, spec: McpServerSpec) -> tuple[str, str]: + return (name, spec.revision) + + +class McpAvailabilityProbe: + def __init__( + self, + *, + timeout_seconds: float = 3.0, + retry_attempts: int = 2, + retry_delay_seconds: float = 0.25, + http_post: HttpPost | None = None, + ) -> None: + self.timeout_seconds = timeout_seconds + self.retry_attempts = max(1, retry_attempts) + self.retry_delay_seconds = max(0.0, retry_delay_seconds) + self._http_post = http_post or self._default_http_post + + def probe(self, spec: McpServerSpec) -> McpAvailabilityResult: + result = McpAvailabilityResult("unavailable") + for attempt in range(self.retry_attempts): + result = self._probe_once(spec) + if result.status == "available" or not _is_retryable_reason(result.reason): + return result + if attempt < self.retry_attempts - 1 and self.retry_delay_seconds > 0: + time.sleep(self.retry_delay_seconds) + return result + + def _probe_once(self, spec: McpServerSpec) -> McpAvailabilityResult: + if spec.transport in {"http", "sse"}: + return self._probe_http(spec) + if spec.transport == "stdio": + return self._probe_stdio(spec) + return McpAvailabilityResult("unavailable", f"unsupported MCP transport: {spec.transport}") + + def _probe_http(self, spec: McpServerSpec) -> McpAvailabilityResult: + if not spec.url: + return McpAvailabilityResult("unavailable", "missing MCP URL") + headers = _base_mcp_headers() + headers.update(spec.headers_dict()) + try: + initialize, response_headers = self._http_post( + spec.url, + _initialize_request(1), + headers, + ) + error = _json_rpc_error(initialize) + if error: + return McpAvailabilityResult("unavailable", error) + + session_id = _header_value(response_headers, "mcp-session-id") + if session_id: + headers["Mcp-Session-Id"] = session_id + + self._http_post(spec.url, _initialized_notification(), headers) + tools, _ = self._http_post(spec.url, _tools_list_request(2), headers) + except HTTPError as error: + return McpAvailabilityResult("unavailable", f"HTTP {error.code} {error.reason}") + except (OSError, TimeoutError, URLError, ValueError) as error: + return McpAvailabilityResult("unavailable", str(error) or error.__class__.__name__) + + error = _json_rpc_error(tools) + if error: + return McpAvailabilityResult("unavailable", error) + if isinstance(tools.get("result"), dict): + return McpAvailabilityResult("available") + return McpAvailabilityResult("unavailable", "MCP tools/list did not return a result") + + def _probe_stdio(self, spec: McpServerSpec) -> McpAvailabilityResult: + if not spec.command: + return McpAvailabilityResult("unavailable", "missing MCP command") + argv = [spec.command, *(spec.args or ())] + env = os.environ.copy() + if spec.env: + env.update(dict(spec.env)) + try: + process = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + except OSError as error: + return McpAvailabilityResult("unavailable", str(error) or error.__class__.__name__) + + assert process.stdin is not None + assert process.stdout is not None + lines: queue.Queue[str] = queue.Queue() + reader = threading.Thread(target=_read_stdout_lines, args=(process.stdout, lines), daemon=True) + reader.start() + try: + for payload in ( + _initialize_request(1), + _initialized_notification(), + _tools_list_request(2), + ): + process.stdin.write(json.dumps(payload) + "\n") + process.stdin.flush() + result = _wait_for_json_rpc_result(lines, request_id=2, timeout_seconds=self.timeout_seconds) + except (OSError, TimeoutError, ValueError) as error: + return McpAvailabilityResult("unavailable", str(error) or error.__class__.__name__) + finally: + _terminate_process(process) + + error = _json_rpc_error(result) + if error: + return McpAvailabilityResult("unavailable", error) + if isinstance(result.get("result"), dict): + return McpAvailabilityResult("available") + return McpAvailabilityResult("unavailable", "MCP tools/list did not return a result") + + def _default_http_post( + self, + url: str, + payload: dict[str, object], + headers: dict[str, str], + ) -> tuple[dict[str, object], dict[str, str]]: + request = Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + with urlopen(request, timeout=self.timeout_seconds) as response: + response_headers = dict(response.headers.items()) + content_type = _header_value(response_headers, "content-type") or "" + if "text/event-stream" in content_type.lower(): + response_payload = _read_sse_response(response) + else: + body = response.read().decode("utf-8") + response_payload = _parse_mcp_response(body) + return response_payload, response_headers + + +def _base_mcp_headers() -> dict[str, str]: + return { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + +def _initialize_request(request_id: int) -> dict[str, object]: + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "skill-manager", "version": "0.3.1"}, + }, + } + + +def _initialized_notification() -> dict[str, object]: + return {"jsonrpc": "2.0", "method": "notifications/initialized"} + + +def _tools_list_request(request_id: int) -> dict[str, object]: + return {"jsonrpc": "2.0", "id": request_id, "method": "tools/list", "params": {}} + + +def _parse_mcp_response(body: str) -> dict[str, object]: + stripped = body.strip() + if not stripped: + return {} + if stripped.startswith("event:") or stripped.startswith("data:"): + data_lines = [ + line.removeprefix("data:").strip() + for line in stripped.splitlines() + if line.startswith("data:") + ] + for line in data_lines: + if not line or line == "[DONE]": + continue + payload = json.loads(line) + if isinstance(payload, dict): + return payload + return {} + payload = json.loads(stripped) + if not isinstance(payload, dict): + raise ValueError("MCP response was not a JSON object") + return payload + + +def _read_sse_response(stream) -> dict[str, object]: + data_lines: list[str] = [] + while True: + raw_line = stream.readline() + if not raw_line: + break + line = raw_line.decode("utf-8").rstrip("\r\n") + if not line: + payload = _parse_sse_data_lines(data_lines) + if payload is not None: + return payload + data_lines = [] + continue + if line.startswith("data:"): + data_lines.append(line.removeprefix("data:").strip()) + + payload = _parse_sse_data_lines(data_lines) + return payload if payload is not None else {} + + +def _parse_sse_data_lines(data_lines: list[str]) -> dict[str, object] | None: + data = "\n".join(line for line in data_lines if line and line != "[DONE]").strip() + if not data: + return None + payload = json.loads(data) + if isinstance(payload, dict): + return payload + raise ValueError("MCP SSE data was not a JSON object") + + +def _header_value(headers: dict[str, str], name: str) -> str | None: + for key, value in headers.items(): + if key.lower() == name.lower(): + return value + return None + + +def _json_rpc_error(payload: dict[str, object]) -> str | None: + error = payload.get("error") + if isinstance(error, dict): + message = error.get("message") + code = error.get("code") + if message is not None and code is not None: + return f"{code}: {message}" + if message is not None: + return str(message) + return None + + +def _is_retryable_reason(reason: str | None) -> bool: + if reason is None: + return True + lower = reason.lower() + if lower.startswith("http 4"): + return False + if "missing mcp" in lower or "unsupported mcp" in lower: + return False + return True + + +def _read_stdout_lines(stream, lines: queue.Queue[str]) -> None: + for line in stream: + lines.put(line) + + +def _wait_for_json_rpc_result( + lines: queue.Queue[str], + *, + request_id: int, + timeout_seconds: float, +) -> dict[str, object]: + deadline = threading.Event() + timer = threading.Timer(timeout_seconds, deadline.set) + timer.start() + try: + while not deadline.is_set(): + try: + line = lines.get(timeout=0.05) + except queue.Empty: + continue + payload = json.loads(line) + if isinstance(payload, dict) and payload.get("id") == request_id: + return payload + finally: + timer.cancel() + raise TimeoutError("MCP probe timed out") + + +def _terminate_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + process.kill() + + +__all__ = ["AvailabilityStatus", "McpAvailabilityProbe", "McpAvailabilityResult"] diff --git a/skill_manager/application/mcp/enrichment.py b/skill_manager/application/mcp/enrichment.py index 216272f..8e74c8c 100644 --- a/skill_manager/application/mcp/enrichment.py +++ b/skill_manager/application/mcp/enrichment.py @@ -15,6 +15,8 @@ class MarketplaceLink: description: str is_remote: bool is_verified: bool + github_url: str | None = None + website_url: str | None = None def to_dict(self) -> dict[str, object]: return { @@ -22,6 +24,8 @@ def to_dict(self) -> dict[str, object]: "displayName": self.display_name, "iconUrl": self.icon_url, "externalUrl": self.external_url, + "githubUrl": self.github_url, + "websiteUrl": self.website_url, "description": self.description, "isRemote": self.is_remote, "isVerified": self.is_verified, @@ -29,7 +33,7 @@ def to_dict(self) -> dict[str, object]: def _canonical_lookup_key(qualified_name: str) -> str: - """Reverse of mutations._canonical_name; used to map local name → smithery id.""" + """Reverse of mutations._canonical_name; used to map local name → marketplace id.""" cleaned = qualified_name.lstrip("@") if "/" in cleaned: cleaned = cleaned.split("/", 1)[1] @@ -37,7 +41,7 @@ def _canonical_lookup_key(qualified_name: str) -> str: class McpEnrichmentService: - """Maps a local server name to a smithery marketplace entry, when one exists. + """Maps a local server name to a marketplace entry, when one exists. Lookups go through three tiers: 1. In-memory cache (per-process, hit immediately). @@ -80,6 +84,8 @@ def warm_from_popular(self) -> None: display_name=str(item.get("displayName") or key), icon_url=_optional_str(item.get("iconUrl")), external_url=str(item.get("externalUrl") or ""), + github_url=_optional_str(item.get("githubUrl")), + website_url=_optional_str(item.get("websiteUrl")), description=str(item.get("description") or ""), is_remote=bool(item.get("isRemote", False)), is_verified=bool(item.get("isVerified", False)), @@ -132,6 +138,8 @@ def _link_from_item(item: Mapping[str, object], qualified_name: str) -> Marketpl display_name=str(item.get("displayName") or qualified_name), icon_url=_optional_str(item.get("iconUrl")), external_url=str(item.get("externalUrl") or ""), + github_url=_optional_str(item.get("githubUrl")), + website_url=_optional_str(item.get("websiteUrl")), description=str(item.get("description") or ""), is_remote=bool(item.get("isRemote", False)), is_verified=bool(item.get("isVerified", False)), diff --git a/skill_manager/application/mcp/install_config.py b/skill_manager/application/mcp/install_config.py new file mode 100644 index 0000000..eea1724 --- /dev/null +++ b/skill_manager/application/mcp/install_config.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal, Mapping + +from skill_manager.errors import MutationError + + +McpInstallConfigTarget = Literal["env", "header", "urlVariable", "packageArgument", "runtimeArgument"] +McpInstallConfigFormat = Literal["string", "number", "boolean", "filepath"] + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") + + +@dataclass(frozen=True) +class McpInstallConfigField: + name: str + label: str + description: str + format: McpInstallConfigFormat = "string" + required: bool = False + secret: bool = False + default: str | None = None + placeholder: str | None = None + choices: tuple[str, ...] = () + target: McpInstallConfigTarget = "env" + + def to_dict(self) -> dict[str, object]: + return { + "name": self.name, + "label": self.label, + "description": self.description, + "format": self.format, + "required": self.required, + "secret": self.secret, + "default": self.default, + "placeholder": self.placeholder, + "choices": list(self.choices), + "target": self.target, + } + + +@dataclass(frozen=True) +class McpInstallConfig: + fields: tuple[McpInstallConfigField, ...] = () + + @property + def required(self) -> bool: + return any(field.required for field in self.fields) + + def to_dict(self) -> dict[str, object]: + return { + "required": self.required, + "fields": [field.to_dict() for field in self.fields], + } + + +@dataclass(frozen=True) +class EnvBinding: + key: str + field_name: str | None = None + value_template: str | None = None + + +@dataclass(frozen=True) +class HeaderBinding: + key: str + field_name: str | None = None + value_template: str | None = None + + +@dataclass(frozen=True) +class ArgumentBinding: + target: Literal["packageArgument", "runtimeArgument"] + kind: Literal["positional", "named"] + name: str | None = None + field_name: str | None = None + value_template: str | None = None + repeated: bool = False + + +def env_fields_and_bindings(package: Mapping[str, object], bindings: list[EnvBinding]) -> list[McpInstallConfigField]: + raw = package.get("environmentVariables") + if not isinstance(raw, list): + return [] + fields: list[McpInstallConfigField] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + name = _str(item.get("name")) + if not name: + continue + value = _optional_str(item.get("value")) + variable_fields = variable_fields_from_input(item.get("variables"), target="env") + if value is not None and variable_fields: + fields.extend(variable_fields) + bindings.append(EnvBinding(key=name, value_template=value)) + elif value is not None: + bindings.append(EnvBinding(key=name, value_template=value)) + else: + fields.append(field_from_input(name, item, target="env")) + bindings.append(EnvBinding(key=name, field_name=name)) + return fields + + +def header_fields_and_bindings(remote: Mapping[str, object], bindings: list[HeaderBinding]) -> list[McpInstallConfigField]: + raw = remote.get("headers") + if not isinstance(raw, list): + return [] + fields: list[McpInstallConfigField] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + name = _str(item.get("name")) + if not name: + continue + value = _optional_str(item.get("value")) + variable_fields = variable_fields_from_input(item.get("variables"), target="header") + if value is not None and variable_fields: + fields.extend(variable_fields) + bindings.append(HeaderBinding(key=name, value_template=value)) + elif value is not None: + bindings.append(HeaderBinding(key=name, value_template=value)) + else: + fields.append(field_from_input(name, item, target="header")) + bindings.append(HeaderBinding(key=name, field_name=name)) + return fields + + +def url_variable_fields(remote: Mapping[str, object], fields: list[McpInstallConfigField]) -> list[str]: + variables = remote.get("variables") + if not isinstance(variables, Mapping): + return [] + names: list[str] = [] + for name, definition in variables.items(): + if not isinstance(name, str) or not isinstance(definition, Mapping): + continue + fields.append(field_from_input(name, definition, target="urlVariable")) + names.append(name) + return names + + +def argument_fields_and_bindings( + raw: object, + target: Literal["packageArgument", "runtimeArgument"], + bindings: list[ArgumentBinding], +) -> list[McpInstallConfigField]: + if not isinstance(raw, list): + return [] + fields: list[McpInstallConfigField] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + if bool(item.get("isRepeated")): + continue + arg_type = _str(item.get("type")) + if arg_type not in {"positional", "named"}: + continue + value = _optional_str(item.get("value")) + variable_fields = variable_fields_from_input(item.get("variables"), target=target) + if value is not None and variable_fields: + fields.extend(variable_fields) + bindings.append(ArgumentBinding(target=target, kind=arg_type, name=_optional_str(item.get("name")), value_template=value)) + continue + if value is not None: + bindings.append(ArgumentBinding(target=target, kind=arg_type, name=_optional_str(item.get("name")), value_template=value)) + continue + field_name = _argument_field_name(item) + if not field_name: + continue + fields.append(field_from_input(field_name, item, target=target)) + bindings.append(ArgumentBinding(target=target, kind=arg_type, name=_optional_str(item.get("name")), field_name=field_name)) + return fields + + +def dedupe_fields(fields: list[McpInstallConfigField]) -> tuple[McpInstallConfigField, ...]: + by_name: dict[str, McpInstallConfigField] = {} + for field in fields: + current = by_name.get(field.name) + if current is None: + by_name[field.name] = field + continue + by_name[field.name] = McpInstallConfigField( + name=current.name, + label=current.label, + description=current.description or field.description, + format=current.format, + required=current.required or field.required, + secret=current.secret or field.secret, + default=current.default if current.default is not None else field.default, + placeholder=current.placeholder if current.placeholder is not None else field.placeholder, + choices=current.choices or field.choices, + target=current.target, + ) + return tuple(by_name.values()) + + +def resolved_config_values( + fields: tuple[McpInstallConfigField, ...], + provided: Mapping[str, object], + *, + allow_missing_required: bool = False, +) -> dict[str, str]: + values: dict[str, str] = {} + missing: list[str] = [] + for field in fields: + raw = provided.get(field.name) + if raw is None or raw == "": + if field.default is not None: + values[field.name] = field.default + elif field.required and not allow_missing_required: + missing.append(field.name) + continue + values[field.name] = _stringify_config_value(raw, field) + if missing: + raise MutationError(f"missing required install config: {', '.join(missing)}", status=400) + return values + + +def resolve_env(bindings: tuple[EnvBinding, ...], values: Mapping[str, str]) -> tuple[tuple[str, str], ...] | None: + pairs: list[tuple[str, str]] = [] + for binding in bindings: + value = binding_value(binding.field_name, binding.value_template, values) + if value is not None: + pairs.append((binding.key, value)) + return tuple(pairs) if pairs else None + + +def resolve_headers(bindings: tuple[HeaderBinding, ...], values: Mapping[str, str]) -> tuple[tuple[str, str], ...] | None: + pairs: list[tuple[str, str]] = [] + for binding in bindings: + value = binding_value(binding.field_name, binding.value_template, values) + if value is not None: + pairs.append((binding.key, value)) + return tuple(pairs) if pairs else None + + +def binding_value(field_name: str | None, value_template: str | None, values: Mapping[str, str]) -> str | None: + if value_template is not None: + return resolve_template(value_template, values) + if field_name is None: + return None + return values.get(field_name) + + +def resolve_template(template: str, values: Mapping[str, str]) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + return values.get(key, match.group(0)) + + return _PLACEHOLDER_RE.sub(replace, template) + + +def resolve_arguments( + bindings: tuple[ArgumentBinding, ...], + values: Mapping[str, str], + target: Literal["packageArgument", "runtimeArgument"], +) -> tuple[str, ...]: + args: list[str] = [] + for binding in bindings: + if binding.target != target: + continue + value = binding_value(binding.field_name, binding.value_template, values) + if value is None: + continue + if binding.kind == "named" and binding.name: + args.append(f"{binding.name}={value}") + else: + args.append(value) + return tuple(args) + + +def variable_fields_from_input(raw: object, *, target: McpInstallConfigTarget) -> list[McpInstallConfigField]: + if not isinstance(raw, Mapping): + return [] + fields: list[McpInstallConfigField] = [] + for name, definition in raw.items(): + if isinstance(name, str) and isinstance(definition, Mapping): + fields.append(field_from_input(name, definition, target=target)) + return fields + + +def field_from_input(name: str, raw: Mapping[str, object], *, target: McpInstallConfigTarget) -> McpInstallConfigField: + choices = raw.get("choices") + return McpInstallConfigField( + name=name, + label=name, + description=_str(raw.get("description")), + format=_input_format(raw.get("format")), + required=bool(raw.get("isRequired", False)), + secret=bool(raw.get("isSecret", False)), + default=_optional_str(raw.get("default")), + placeholder=_optional_str(raw.get("placeholder")), + choices=tuple(str(choice) for choice in choices) if isinstance(choices, list) else (), + target=target, + ) + + +def _argument_field_name(item: Mapping[str, object]) -> str: + return _str(item.get("valueHint")) or _str(item.get("name")) + + +def _input_format(value: object) -> McpInstallConfigFormat: + return value if value in {"string", "number", "boolean", "filepath"} else "string" # type: ignore[return-value] + + +def _stringify_config_value(value: object, field: McpInstallConfigField) -> str: + if field.choices and str(value) not in field.choices: + raise MutationError(f"invalid value for install config '{field.name}'", status=400) + if field.format == "boolean": + if isinstance(value, bool): + return "true" if value else "false" + normalized = str(value).strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return "true" + if normalized in {"false", "0", "no", "off"}: + return "false" + raise MutationError(f"invalid boolean value for install config '{field.name}'", status=400) + return str(value) + + +def _str(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _optional_str(value: object) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None diff --git a/skill_manager/application/mcp/install_resolver.py b/skill_manager/application/mcp/install_resolver.py new file mode 100644 index 0000000..21a9bf1 --- /dev/null +++ b/skill_manager/application/mcp/install_resolver.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Literal, Mapping + +from skill_manager.errors import MutationError + +from .install_config import ( + ArgumentBinding, + EnvBinding, + HeaderBinding, + McpInstallConfig, + McpInstallConfigField, + argument_fields_and_bindings, + dedupe_fields, + env_fields_and_bindings, + header_fields_and_bindings, + resolve_arguments, + resolve_env, + resolve_headers, + resolve_template, + resolved_config_values, + url_variable_fields, +) +from .store import McpServerSpec, McpSource + + +_SAFE_NAME_RE = re.compile(r"[^a-z0-9]+") + + +@dataclass(frozen=True) +class RegistryInstallOption: + transport: Literal["stdio", "http", "sse"] + command: str | None = None + args: tuple[str, ...] | None = None + url: str | None = None + fields: tuple[McpInstallConfigField, ...] = () + env_bindings: tuple[EnvBinding, ...] = () + header_bindings: tuple[HeaderBinding, ...] = () + url_variables: tuple[str, ...] = () + argument_bindings: tuple[ArgumentBinding, ...] = () + + +@dataclass(frozen=True) +class _PackageInstallInput: + registry_type: str + identifier: str + version: str + fields: tuple[McpInstallConfigField, ...] + env_bindings: tuple[EnvBinding, ...] + argument_bindings: tuple[ArgumentBinding, ...] + + +def registry_managed_name(qualified_name: str) -> str: + cleaned = qualified_name.strip().lstrip("@").lower() + normalized = _SAFE_NAME_RE.sub("-", cleaned).strip("-") + return normalized or "mcp-server" + + +def registry_install_config(detail: Mapping[str, object]) -> McpInstallConfig: + option = registry_install_option(detail) + return McpInstallConfig(option.fields) if option is not None else McpInstallConfig() + + +def registry_install_option(detail: Mapping[str, object]) -> RegistryInstallOption | None: + options = registry_install_options(_server_payload(detail)) + return options[0] if options else None + + +def resolve_registry_server_spec( + detail: Mapping[str, object], + *, + config: Mapping[str, object] | None = None, + allow_missing_required: bool = False, +) -> McpServerSpec: + server = _server_payload(detail) + qualified_name = _str(detail.get("qualifiedName")) or _str(server.get("name")) + if not qualified_name: + raise MutationError("registry server is missing a name", status=502) + display_name = _str(detail.get("displayName")) or _str(server.get("title")) or qualified_name + options = registry_install_options(server) + if not options: + raise MutationError( + f"registry server '{qualified_name}' has no supported install configuration", + status=400, + ) + option = options[0] + common = { + "name": registry_managed_name(qualified_name), + "display_name": display_name, + "source": McpSource.marketplace(qualified_name), + } + values = resolved_config_values( + option.fields, + config or {}, + allow_missing_required=allow_missing_required, + ) + if option.transport == "stdio": + return _resolve_stdio_spec(common, option, values) + if option.transport in {"http", "sse"}: + return _resolve_remote_spec(common, option, values) + raise MutationError( + f"registry server '{qualified_name}' has unsupported transport: {option.transport}", + status=400, + ) + + +def _resolve_stdio_spec( + common: Mapping[str, object], + option: RegistryInstallOption, + values: Mapping[str, str], +) -> McpServerSpec: + args = tuple(option.args or ()) + runtime_args = resolve_arguments(option.argument_bindings, values, "runtimeArgument") + package_args = resolve_arguments(option.argument_bindings, values, "packageArgument") + if runtime_args or package_args: + args = _merge_stdio_args(args, runtime_args, package_args) + return McpServerSpec( + **common, + transport="stdio", + command=option.command, + args=args, + env=resolve_env(option.env_bindings, values), + ) + + +def _resolve_remote_spec( + common: Mapping[str, object], + option: RegistryInstallOption, + values: Mapping[str, str], +) -> McpServerSpec: + return McpServerSpec( + **common, + transport=option.transport, + url=resolve_template(option.url or "", values), + headers=resolve_headers(option.header_bindings, values), + ) + + +def registry_install_options(server: Mapping[str, object]) -> tuple[RegistryInstallOption, ...]: + options: list[RegistryInstallOption] = [] + options.extend(_package_options(server)) + options.extend(_remote_options(server)) + return tuple(options) + + +def _package_options(server: Mapping[str, object]) -> list[RegistryInstallOption]: + packages = server.get("packages") + if not isinstance(packages, list): + return [] + options: list[RegistryInstallOption] = [] + for package in packages: + install_input = _package_install_input(package, server) + if install_input is None: + continue + option = _build_package_option(install_input) + if option is not None: + options.append(option) + return options + + +def _package_install_input(package: object, server: Mapping[str, object]) -> _PackageInstallInput | None: + if not isinstance(package, Mapping): + return None + transport = package.get("transport") + transport_type = _str(transport.get("type")) if isinstance(transport, Mapping) else "" + if transport_type != "stdio": + return None + identifier = _str(package.get("identifier")) + if not identifier: + return None + + fields: list[McpInstallConfigField] = [] + env_bindings: list[EnvBinding] = [] + fields.extend(env_fields_and_bindings(package, env_bindings)) + argument_bindings: list[ArgumentBinding] = [] + fields.extend(argument_fields_and_bindings(package.get("runtimeArguments"), "runtimeArgument", argument_bindings)) + fields.extend(argument_fields_and_bindings(package.get("packageArguments"), "packageArgument", argument_bindings)) + return _PackageInstallInput( + registry_type=_str(package.get("registryType")).lower(), + identifier=identifier, + version=_str(package.get("version")) or _str(server.get("version")), + fields=dedupe_fields(fields), + env_bindings=tuple(env_bindings), + argument_bindings=tuple(argument_bindings), + ) + + +def _build_package_option(install_input: _PackageInstallInput) -> RegistryInstallOption | None: + command_builder = _PACKAGE_COMMAND_BUILDERS.get(install_input.registry_type) + if command_builder is None: + return None + command, args = command_builder(install_input.identifier, install_input.version) + return RegistryInstallOption( + transport="stdio", + command=command, + args=args, + fields=install_input.fields, + env_bindings=install_input.env_bindings, + argument_bindings=install_input.argument_bindings, + ) + + +def _remote_options(server: Mapping[str, object]) -> list[RegistryInstallOption]: + remotes = server.get("remotes") + if not isinstance(remotes, list): + return [] + options: list[RegistryInstallOption] = [] + for remote in remotes: + if not isinstance(remote, Mapping): + continue + remote_type = _str(remote.get("type")).lower() + transport = _REMOTE_TRANSPORTS.get(remote_type) + if transport is None: + continue + url = _str(remote.get("url")) + if not url: + continue + fields: list[McpInstallConfigField] = [] + url_variables = url_variable_fields(remote, fields) + header_bindings: list[HeaderBinding] = [] + fields.extend(header_fields_and_bindings(remote, header_bindings)) + options.append( + RegistryInstallOption( + transport=transport, + url=url, + fields=dedupe_fields(fields), + header_bindings=tuple(header_bindings), + url_variables=tuple(url_variables), + ) + ) + return options + + +def _server_payload(detail: Mapping[str, object]) -> Mapping[str, object]: + server = detail.get("registryServer") + if isinstance(server, Mapping): + return server + server = detail.get("server") + if isinstance(server, Mapping): + return server + return detail + + +def _versioned_npm_identifier(identifier: str, version: str) -> str: + if not version: + return identifier + if identifier.startswith("@"): + if "@" in identifier[1:]: + return identifier + return f"{identifier}@{version}" + if "@" in identifier: + return identifier + return f"{identifier}@{version}" + + +def _versioned_oci_identifier(identifier: str, version: str) -> str: + if not version: + return identifier + last_segment = identifier.rsplit("/", 1)[-1] + if ":" in last_segment: + return identifier + return f"{identifier}:{version}" + + +def _npm_package_command(identifier: str, version: str) -> tuple[str, tuple[str, ...]]: + return "npx", ("-y", _versioned_npm_identifier(identifier, version)) + + +def _pypi_package_command(identifier: str, version: str) -> tuple[str, tuple[str, ...]]: + package_ref = f"{identifier}=={version}" if version else identifier + return "uvx", (package_ref,) + + +def _oci_package_command(identifier: str, version: str) -> tuple[str, tuple[str, ...]]: + return "docker", ("run", "--rm", "-i", _versioned_oci_identifier(identifier, version)) + + +_PACKAGE_COMMAND_BUILDERS: dict[str, Callable[[str, str], tuple[str, tuple[str, ...]]]] = { + "npm": _npm_package_command, + "pypi": _pypi_package_command, + "oci": _oci_package_command, +} + +_REMOTE_TRANSPORTS: dict[str, Literal["http", "sse"]] = { + "streamable-http": "http", + "sse": "sse", +} + + +def _merge_stdio_args(args: tuple[str, ...], runtime_args: tuple[str, ...], package_args: tuple[str, ...]) -> tuple[str, ...]: + if not runtime_args: + return args + package_args + if not args: + return runtime_args + package_args + return args[:-1] + runtime_args + args[-1:] + package_args + + +def _str(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _optional_str(value: object) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None + + +__all__ = [ + "McpInstallConfig", + "McpInstallConfigField", + "RegistryInstallOption", + "registry_install_config", + "registry_install_option", + "registry_install_options", + "registry_managed_name", + "resolve_registry_server_spec", +] diff --git a/skill_manager/application/mcp/install_state.py b/skill_manager/application/mcp/install_state.py new file mode 100644 index 0000000..79cde1b --- /dev/null +++ b/skill_manager/application/mcp/install_state.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, replace +from typing import Mapping + +from skill_manager.errors import MutationError + +from .install_config import ArgumentBinding +from .install_resolver import ( + RegistryInstallOption, + registry_install_option, + resolve_registry_server_spec, +) +from .store import McpServerSpec + + +@dataclass(frozen=True) +class McpInstallConfigStatus: + has_fields: bool = False + missing_required: tuple[str, ...] = () + + @property + def configured(self) -> bool: + return not self.missing_required + + def to_dict(self) -> dict[str, object]: + return { + "hasFields": self.has_fields, + "missingRequired": list(self.missing_required), + "configured": self.configured, + } + + +def install_config_status( + detail: Mapping[str, object] | None, + spec: McpServerSpec | None, +) -> McpInstallConfigStatus: + option = registry_install_option(detail or {}) + fields = option.fields if option is not None else () + if not fields: + return McpInstallConfigStatus() + values = current_install_config_values(option, spec) if spec is not None else {} + missing = tuple( + field.name + for field in fields + if field.required and field.default is None and not _has_value(values.get(field.name)) + ) + return McpInstallConfigStatus(has_fields=True, missing_required=missing) + + +def resolve_enable_spec( + detail: Mapping[str, object], + spec: McpServerSpec, + *, + config: Mapping[str, object] | None, +) -> McpServerSpec: + option = registry_install_option(detail) + if option is None: + return spec + status = install_config_status(detail, spec) + if config is None: + if status.missing_required: + raise MutationError( + f"missing required install config: {', '.join(status.missing_required)}", + status=400, + ) + return spec + + merged_config: dict[str, object] = dict(current_install_config_values(option, spec)) + for key, value in config.items(): + if value is None or value == "": + continue + merged_config[key] = value + + resolved = resolve_registry_server_spec(detail, config=merged_config) + return replace( + resolved, + name=spec.name, + display_name=spec.display_name, + source=spec.source, + installed_at=spec.installed_at, + ) + + +def current_install_config_values( + option: RegistryInstallOption, + spec: McpServerSpec, +) -> dict[str, str]: + values: dict[str, str] = {} + _collect_env_values(option, spec, values) + _collect_header_values(option, spec, values) + _collect_url_values(option, spec, values) + _collect_argument_values(option, spec, values) + return values + + +def _collect_env_values( + option: RegistryInstallOption, + spec: McpServerSpec, + values: dict[str, str], +) -> None: + env = spec.env_dict() + for binding in option.env_bindings: + actual = env.get(binding.key) + if actual is None: + continue + if binding.field_name: + _store_value(values, binding.field_name, actual) + continue + if binding.value_template: + _store_template_values(values, binding.value_template, actual) + + +def _collect_header_values( + option: RegistryInstallOption, + spec: McpServerSpec, + values: dict[str, str], +) -> None: + headers = spec.headers_dict() + for binding in option.header_bindings: + actual = headers.get(binding.key) + if actual is None: + continue + if binding.field_name: + _store_value(values, binding.field_name, actual) + continue + if binding.value_template: + _store_template_values(values, binding.value_template, actual) + + +def _collect_url_values( + option: RegistryInstallOption, + spec: McpServerSpec, + values: dict[str, str], +) -> None: + if option.url and spec.url: + _store_template_values(values, option.url, spec.url) + + +def _collect_argument_values( + option: RegistryInstallOption, + spec: McpServerSpec, + values: dict[str, str], +) -> None: + for target in ("runtimeArgument", "packageArgument"): + tokens = _dynamic_argument_tokens(option, spec, target) + remaining = list(tokens) + for binding in option.argument_bindings: + if binding.target != target: + continue + if binding.kind == "named" and binding.name: + actual = _pop_named_argument(remaining, binding.name) + if actual is None: + continue + _store_argument_binding_value(values, binding, actual) + continue + actual = _pop_positional_argument(remaining, binding) + if actual is not None: + _store_argument_binding_value(values, binding, actual) + + +def _store_argument_binding_value( + values: dict[str, str], + binding: ArgumentBinding, + actual: str, +) -> None: + if binding.field_name: + _store_value(values, binding.field_name, actual) + return + if binding.value_template: + _store_template_values(values, binding.value_template, actual) + + +def _dynamic_argument_tokens( + option: RegistryInstallOption, + spec: McpServerSpec, + target: str, +) -> tuple[str, ...]: + actual = list(spec.args or ()) + base = list(option.args or ()) + if not base: + return tuple(actual) + if actual[: len(base)] == base: + return tuple(actual[len(base) :]) if target == "packageArgument" else () + if len(base) == 1: + try: + base_index = actual.index(base[0]) + except ValueError: + return tuple(actual) + return tuple(actual[:base_index] if target == "runtimeArgument" else actual[base_index + 1 :]) + prefix = base[:-1] + if actual[: len(prefix)] != prefix: + return () + try: + final_base_index = actual.index(base[-1], len(prefix)) + except ValueError: + return () + if target == "runtimeArgument": + return tuple(actual[len(prefix) : final_base_index]) + return tuple(actual[final_base_index + 1 :]) + + +def _pop_named_argument(tokens: list[str], name: str) -> str | None: + prefix = f"{name}=" + for index, token in enumerate(tokens): + if token.startswith(prefix): + tokens.pop(index) + return token.removeprefix(prefix) + return None + + +def _pop_positional_argument( + tokens: list[str], + binding: ArgumentBinding, +) -> str | None: + if binding.value_template: + for index, token in enumerate(tokens): + if _template_values(binding.value_template, token): + return tokens.pop(index) + return None + if not tokens: + return None + return tokens.pop(0) + + +def _store_template_values(values: dict[str, str], template: str, actual: str) -> None: + for key, value in _template_values(template, actual).items(): + _store_value(values, key, value) + + +def _template_values(template: str, actual: str) -> dict[str, str]: + names = _placeholder_names(template) + if not names: + return {} + parts: list[str] = [] + cursor = 0 + for match in _PLACEHOLDER_RE.finditer(template): + parts.append(re.escape(template[cursor : match.start()])) + parts.append("(.+?)") + cursor = match.end() + parts.append(re.escape(template[cursor:])) + matched = re.fullmatch("".join(parts), actual) + if matched is None: + return {} + result: dict[str, str] = {} + for name, value in zip(names, matched.groups(), strict=False): + if value and value != f"{{{name}}}": + result[name] = value + return result + + +def _placeholder_names(template: str) -> tuple[str, ...]: + return tuple(match.group(1) for match in _PLACEHOLDER_RE.finditer(template)) + + +def _store_value(values: dict[str, str], key: str, value: str) -> None: + if _has_value(value): + values[key] = value + + +def _has_value(value: object) -> bool: + return isinstance(value, str) and value != "" + + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") + + +__all__ = [ + "McpInstallConfigStatus", + "current_install_config_values", + "install_config_status", + "resolve_enable_spec", +] diff --git a/skill_manager/application/mcp/installers.py b/skill_manager/application/mcp/installers.py deleted file mode 100644 index 5dc8c3e..0000000 --- a/skill_manager/application/mcp/installers.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -import subprocess -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping, Protocol -from uuid import uuid4 - -from skill_manager.errors import MutationError - - -_OPENCLAW_UNSUPPORTED_REASON = "Smithery does not provide an OpenClaw MCP installer target" -_SMITHERY_CLI_PACKAGE = "@smithery/cli@4.11.1" -_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") - - -@dataclass(frozen=True) -class McpInstallResult: - qualified_name: str - source_harness: str - installer: str - stdout: str - stderr: str - - -@dataclass(frozen=True) -class SmitheryClientTarget: - harness: str - smithery_client: str | None - supported: bool - reason: str | None = None - - -_SMITHERY_CLIENT_TARGETS: tuple[SmitheryClientTarget, ...] = ( - SmitheryClientTarget(harness="codex", smithery_client="codex", supported=True), - SmitheryClientTarget(harness="claude", smithery_client="claude-code", supported=True), - SmitheryClientTarget(harness="cursor", smithery_client="cursor", supported=True), - SmitheryClientTarget(harness="opencode", smithery_client="opencode", supported=True), - SmitheryClientTarget( - harness="openclaw", - smithery_client=None, - supported=False, - reason=_OPENCLAW_UNSUPPORTED_REASON, - ), -) -_SMITHERY_TARGETS_BY_HARNESS = {target.harness: target for target in _SMITHERY_CLIENT_TARGETS} - - -class McpInstallProvider(Protocol): - def install_targets(self) -> tuple[SmitheryClientTarget, ...]: ... - - def install( - self, - *, - qualified_name: str, - source_harness: str, - ) -> McpInstallResult: ... - - -class SmitheryCliInstallProvider: - def __init__( - self, - *, - env: Mapping[str, str] | None = None, - cwd: Path | None = None, - timeout_seconds: float = 120.0, - runner=subprocess.run, - ) -> None: - self._env = dict(env or {}) - self._cwd = cwd - self._timeout_seconds = timeout_seconds - self._runner = runner - - def install_targets(self) -> tuple[SmitheryClientTarget, ...]: - return _SMITHERY_CLIENT_TARGETS - - def install( - self, - *, - qualified_name: str, - source_harness: str, - ) -> McpInstallResult: - target = _SMITHERY_TARGETS_BY_HARNESS.get(source_harness) - if target is None or not target.supported or target.smithery_client is None: - message = ( - target.reason - if target and target.reason - else f"Smithery install is not supported for source harness: {source_harness}" - ) - raise MutationError( - message, - status=400, - ) - - command = [ - "npx", - "-y", - _SMITHERY_CLI_PACKAGE, - "mcp", - "add", - qualified_name, - "--client", - target.smithery_client, - "--config", - "{}", - ] - env = dict(os.environ) - env.update(self._env) - env["NO_COLOR"] = "1" - - try: - with tempfile.TemporaryDirectory(prefix="skill-manager-smithery-") as config_dir: - env["SMITHERY_CONFIG_PATH"] = config_dir - _write_noninteractive_smithery_settings(Path(env["SMITHERY_CONFIG_PATH"])) - result = self._runner( - command, - input="", - text=True, - env=env, - cwd=str(self._cwd or Path(env.get("HOME", str(Path.home())))), - capture_output=True, - timeout=self._timeout_seconds, - ) - except subprocess.TimeoutExpired as error: - raise MutationError( - f"Smithery install timed out after {self._timeout_seconds:.0f}s", - status=504, - ) from error - except OSError as error: - raise MutationError(f"Unable to run Smithery installer: {error}", status=502) from error - - stdout = _clean_output(getattr(result, "stdout", "") or "") - stderr = _clean_output(getattr(result, "stderr", "") or "") - if getattr(result, "returncode", 1) != 0: - message = _summarize_failure(stdout, stderr) or "Smithery install failed" - raise MutationError(message, status=502) - - return McpInstallResult( - qualified_name=qualified_name, - source_harness=source_harness, - installer="smithery", - stdout=stdout, - stderr=stderr, - ) - - -def _clean_output(value: str) -> str: - return _ANSI_RE.sub("", value).strip() - - -def _summarize_failure(stdout: str, stderr: str) -> str: - combined = "\n".join(part for part in (stderr, stdout) if part) - lines = [line.strip() for line in combined.splitlines() if line.strip()] - if not lines: - return "" - return lines[-1][:500] - - -def _write_noninteractive_smithery_settings(config_dir: Path) -> None: - config_dir.mkdir(parents=True, exist_ok=True) - settings_path = config_dir / "settings.json" - if settings_path.exists(): - return - settings_path.write_text( - json.dumps( - { - "userId": f"skill-manager-{uuid4()}", - "analyticsConsent": False, - "askedConsent": True, - "cache": {"servers": {}}, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - - -__all__ = [ - "McpInstallProvider", - "McpInstallResult", - "SmitheryCliInstallProvider", - "SmitheryClientTarget", -] diff --git a/skill_manager/application/mcp/mappers.py b/skill_manager/application/mcp/mappers.py index 5174d4c..77ba29c 100644 --- a/skill_manager/application/mcp/mappers.py +++ b/skill_manager/application/mcp/mappers.py @@ -33,7 +33,7 @@ class _TypedMcpServersMapper: repair configs written by older versions. """ - source_harness: str + observed_harness: str def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]: if spec.transport == "stdio": @@ -61,7 +61,7 @@ def dict_to_spec( return McpServerSpec( name=name, display_name=name, - source=source or McpSource.adopted(self.source_harness, name), + source=source or McpSource.adopted(self.observed_harness, name), transport="stdio", command=_str_or_none(raw.get("command")), args=_str_tuple(raw.get("args")), @@ -72,23 +72,23 @@ def dict_to_spec( return McpServerSpec( name=name, display_name=name, - source=source or McpSource.adopted(self.source_harness, name), + source=source or McpSource.adopted(self.observed_harness, name), transport=transport, url=_str_or_none(raw.get("url")), headers=_str_pairs(raw.get("headers")), ) raise MutationError( - f"unsupported {self.source_harness} mcp entry '{name}': missing 'command' and 'url'", + f"unsupported {self.observed_harness} mcp entry '{name}': missing 'command' and 'url'", status=400, ) class ClaudeCodeMapper(_TypedMcpServersMapper): - source_harness = "claude" + observed_harness = "claude" class CursorMapper(_TypedMcpServersMapper): - source_harness = "cursor" + observed_harness = "cursor" # OpenCode ----------------------------------------------------------------- diff --git a/skill_manager/application/mcp/marketplace/__init__.py b/skill_manager/application/mcp/marketplace/__init__.py index 0b9eda4..cc9548d 100644 --- a/skill_manager/application/mcp/marketplace/__init__.py +++ b/skill_manager/application/mcp/marketplace/__init__.py @@ -1,4 +1,4 @@ from .catalog import McpMarketplaceCatalog -from .client import SmitheryClient +from .client import McpRegistryClient -__all__ = ["McpMarketplaceCatalog", "SmitheryClient"] +__all__ = ["McpMarketplaceCatalog", "McpRegistryClient"] diff --git a/skill_manager/application/mcp/marketplace/catalog.py b/skill_manager/application/mcp/marketplace/catalog.py index 05a80ab..94f2317 100644 --- a/skill_manager/application/mcp/marketplace/catalog.py +++ b/skill_manager/application/mcp/marketplace/catalog.py @@ -1,28 +1,63 @@ from __future__ import annotations import hashlib -from typing import Callable -from urllib.parse import quote, urlencode +import time +from dataclasses import dataclass +from typing import Callable, Mapping +from urllib.parse import quote, urlencode, urlparse -from skill_manager.errors import MarketplaceUpstreamError from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.errors import MarketplaceUpstreamError -from ..names import canonical_server_name -from ..stdio import parse_static_stdio_function -from .client import SmitheryClient +from ..install_resolver import ( + McpInstallConfig, + RegistryInstallOption, + registry_install_options, + registry_managed_name, +) +from .client import McpRegistryClient -Fetcher = Callable[[str], dict[str, object]] -_SMITHERY_WEB_BASE_URL = "https://smithery.ai" -_DEFAULT_PAGE_SIZE = 30 -_MAX_PAGE_SIZE = 100 +Fetcher = Callable[[str], dict[str, object]] +SupportedRegistryEntry = tuple[Mapping[str, object], Mapping[str, object], tuple[RegistryInstallOption, ...]] +RegistrySummaryCandidate = tuple[Mapping[str, object], dict[str, object]] + +_REGISTRY_API_VERSION = "v0.1" +_REGISTRY_EXTERNAL_BASE_URL = "https://registry.modelcontextprotocol.io" +_OFFICIAL_META_KEY = "io.modelcontextprotocol.registry/official" +_DEFAULT_PAGE_SIZE = 20 +_MAX_PAGE_SIZE = 60 _POPULAR_TTL_SECONDS = 3600 _SEARCH_TTL_SECONDS = 900 _DETAIL_TTL_SECONDS = 86400 +_SEARCH_FETCH_FLOOR = 40 +_SEARCH_CACHE_LIMIT = 24 -_POPULAR_NAMESPACE = "smithery-popular-v1" -_SEARCH_NAMESPACE = "smithery-search-v1" -_DETAIL_NAMESPACE = "smithery-detail-v5" +_DETAIL_NAMESPACE = "mcp-registry-detail-v1" +_PAGE_NAMESPACE = "mcp-registry-page-v1" + + +@dataclass(frozen=True) +class McpRegistryInstallDetail: + qualified_name: str + display_name: str + registry_server: Mapping[str, object] + options: tuple[RegistryInstallOption, ...] + + def to_resolver_detail(self) -> dict[str, object]: + return { + "qualifiedName": self.qualified_name, + "displayName": self.display_name, + "registryServer": self.registry_server, + } + + +@dataclass(frozen=True) +class SearchSnapshot: + items: tuple[dict[str, object], ...] + fetched_limit: int + maybe_more: bool + fetched_at: float class McpMarketplaceCatalog: @@ -35,8 +70,9 @@ def __init__( fetcher: Fetcher | None = None, cache: MarketplaceCache | None = None, ) -> None: - self._fetcher = fetcher or SmitheryClient.from_environment().fetch_json + self._fetcher = fetcher or McpRegistryClient.from_environment().fetch_json self._cache = cache or MarketplaceCache() + self._search_cache: dict[tuple[str, bool | None, bool | None], SearchSnapshot] = {} @classmethod def from_environment( @@ -45,7 +81,7 @@ def from_environment( *, cache: MarketplaceCache | None = None, ) -> "McpMarketplaceCatalog": - client = SmitheryClient.from_environment(env) + client = McpRegistryClient.from_environment(env) return cls( fetcher=client.fetch_json, cache=cache or MarketplaceCache.from_environment(env), @@ -56,15 +92,13 @@ def cache(self) -> MarketplaceCache: return self._cache def popular_page(self, *, limit: int | None = None, offset: int = 0) -> dict[str, object]: - return self._list_page( - query=None, - limit=limit, - offset=offset, - remote=None, - verified=None, - namespace=_POPULAR_NAMESPACE, - ttl_seconds=_POPULAR_TTL_SECONDS, - ) + page_size = _normalize_limit(limit) + page_offset = max(offset, 0) + collected, maybe_more = self._collect_items(limit=page_offset + page_size + 1) + page_items = collected[page_offset : page_offset + page_size] + has_more = len(collected) > page_offset + page_size or maybe_more + next_offset = page_offset + len(page_items) if has_more and page_items else None + return {"items": page_items, "nextOffset": next_offset, "hasMore": next_offset is not None} def search_page( self, @@ -77,16 +111,21 @@ def search_page( ) -> dict[str, object]: trimmed = (query or "").strip() if len(trimmed) < 2 and (remote is None and verified is None): - raise ValueError("Enter at least 2 characters to search Smithery.") - return self._list_page( - query=trimmed or None, - limit=limit, - offset=offset, + raise ValueError("Enter at least 2 characters to search the MCP registry.") + page_size = _normalize_limit(limit) + page_offset = max(offset, 0) + fetch_limit = max(page_offset + page_size + 1, _SEARCH_FETCH_FLOOR) + snapshot = self._search_snapshot( + trimmed, remote=remote, verified=verified, - namespace=_SEARCH_NAMESPACE, - ttl_seconds=_SEARCH_TTL_SECONDS, + fetch_limit=fetch_limit, ) + items = list(snapshot.items) + page_items = items[page_offset : page_offset + page_size] + has_more = len(items) > page_offset + page_size or snapshot.maybe_more + next_offset = page_offset + len(page_items) if has_more and page_items else None + return {"items": page_items, "nextOffset": next_offset, "hasMore": next_offset is not None} def detail(self, qualified_name: str) -> dict[str, object] | None: name = (qualified_name or "").strip() @@ -95,72 +134,148 @@ def detail(self, qualified_name: str) -> dict[str, object] | None: cache_key = name cached = self._cache.read(_DETAIL_NAMESPACE, cache_key, ttl_seconds=_DETAIL_TTL_SECONDS) if cached is not None and isinstance(cached.payload, dict): - return cached.payload + payload = dict(cached.payload) + payload.pop("registryServer", None) + payload["externalUrl"] = _external_url(name) + return payload + resolved = self._latest_supported_detail(name) + if resolved is None: + return None + raw, options = resolved + payload = _map_detail(raw, qualified_name=name, options=options) + self._cache.write(_DETAIL_NAMESPACE, cache_key, payload) + return payload + + def install_detail(self, qualified_name: str) -> McpRegistryInstallDetail | None: + name = (qualified_name or "").strip() + if not name: + return None + resolved = self._latest_supported_detail(name) + if resolved is None: + return None + raw, options = resolved + server = _entry_server(raw) + if server is None: + return None + return McpRegistryInstallDetail( + qualified_name=name, + display_name=_coerce_str(server.get("title"), default=name), + registry_server=server, + options=options, + ) + + def _latest_supported_detail( + self, + name: str, + ) -> tuple[Mapping[str, object], tuple[RegistryInstallOption, ...]] | None: try: - raw = self._fetcher(f"/servers/{quote(name, safe='/')}") + versions = self._fetcher(f"/{_REGISTRY_API_VERSION}/servers/{quote(name, safe='')}/versions") except MarketplaceUpstreamError as error: if error.upstream_status == 404: return None raise - payload = _map_detail(raw, qualified_name=name) - self._cache.write(_DETAIL_NAMESPACE, cache_key, payload) - return payload + latest = _latest_active_entry(versions) + if latest is None: + return None + server = _entry_server(latest) + if server is None: + return None + version = _coerce_str(server.get("version")) + if not version: + return None + try: + raw = self._fetcher( + f"/{_REGISTRY_API_VERSION}/servers/{quote(name, safe='')}/versions/{quote(version, safe='')}" + ) + except MarketplaceUpstreamError as error: + if error.upstream_status == 404: + return None + raise + if not _is_latest_active(raw): + return None + detail_server = _entry_server(raw) + if detail_server is None: + return None + options = registry_install_options(detail_server) + if not options: + return None + return raw, options - def _list_page( + def _search_snapshot( self, + query: str, *, - query: str | None, - limit: int | None, - offset: int, remote: bool | None, verified: bool | None, - namespace: str, - ttl_seconds: int, - ) -> dict[str, object]: - page_size = _normalize_limit(limit) - page_offset = max(offset, 0) - page_number = (page_offset // page_size) + 1 - - params: list[tuple[str, str]] = [ - ("pageSize", str(page_size)), - ("page", str(page_number)), - ] - if query: - params.append(("q", query)) - if remote is True: - params.append(("remote", "true")) - elif remote is False: - params.append(("remote", "false")) - if verified is True: - params.append(("verified", "true")) - - path = f"/servers?{urlencode(params)}" + fetch_limit: int, + ) -> SearchSnapshot: + key = (query, remote, verified) + cached = self._search_cache.get(key) + if cached is not None and (time.time() - cached.fetched_at) < _SEARCH_TTL_SECONDS and cached.fetched_limit >= fetch_limit: + return cached + + collected, maybe_more = self._collect_items( + limit=fetch_limit + 1, + query=query, + remote=remote, + verified=verified, + ) + items = tuple(collected[:fetch_limit]) + snapshot = SearchSnapshot( + items=items, + fetched_limit=fetch_limit, + maybe_more=maybe_more or len(collected) > fetch_limit, + fetched_at=time.time(), + ) + self._search_cache[key] = snapshot + self._prune_search_cache() + return snapshot + + def _prune_search_cache(self) -> None: + if len(self._search_cache) <= _SEARCH_CACHE_LIMIT: + return + oldest = sorted(self._search_cache.items(), key=lambda item: item[1].fetched_at) + for key, _snapshot in oldest[: len(self._search_cache) - _SEARCH_CACHE_LIMIT]: + self._search_cache.pop(key, None) + + def _collect_items( + self, + *, + limit: int, + query: str = "", + remote: bool | None = None, + verified: bool | None = None, + ) -> tuple[list[dict[str, object]], bool]: + collected: list[dict[str, object]] = [] + cursor: str | None = None + while len(collected) < limit: + raw = self._list_registry_page(cursor=cursor, search=query) + for server, item in _summary_candidates(raw): + if not _item_matches_filters(item, server, query=query, remote=remote, verified=verified): + continue + collected.append(item) + if len(collected) >= limit: + break + cursor = _next_cursor(raw) + if not cursor: + break + return collected, cursor is not None + + def _list_registry_page(self, *, cursor: str | None = None, search: str = "") -> dict[str, object]: + params: list[tuple[str, str]] = [("limit", str(_MAX_PAGE_SIZE))] + if cursor: + params.append(("cursor", cursor)) + trimmed_search = search.strip() + if trimmed_search: + params.append(("search", trimmed_search)) + path = f"/{_REGISTRY_API_VERSION}/servers?{urlencode(params)}" cache_key = _cache_key_for_path(path) - cached = self._cache.read(namespace, cache_key, ttl_seconds=ttl_seconds) - raw: dict[str, object] | None = None + cached = self._cache.read(_PAGE_NAMESPACE, cache_key, ttl_seconds=_POPULAR_TTL_SECONDS) if cached is not None and isinstance(cached.payload, dict): - raw = cached.payload # type: ignore[assignment] - if raw is None: - raw = self._fetcher(path) - self._cache.write(namespace, cache_key, raw) - - servers_obj = raw.get("servers", []) if isinstance(raw, dict) else [] - servers = servers_obj if isinstance(servers_obj, list) else [] - pagination = raw.get("pagination", {}) if isinstance(raw, dict) else {} - total_pages = 0 - current_page = page_number - if isinstance(pagination, dict): - total_pages = _coerce_int(pagination.get("totalPages"), default=0) - current_page = _coerce_int(pagination.get("currentPage"), default=page_number) - - items = [_map_summary(server) for server in servers if isinstance(server, dict)] - has_more = current_page < total_pages and bool(items) - next_offset = page_offset + len(items) if has_more else None - return { - "items": items, - "nextOffset": next_offset, - "hasMore": has_more, - } + return cached.payload + raw = self._fetcher(path) + self._cache.write(_PAGE_NAMESPACE, cache_key, raw) + return raw def _normalize_limit(limit: int | None) -> int: @@ -173,19 +288,6 @@ def _cache_key_for_path(path: str) -> str: return hashlib.sha1(path.encode("utf-8")).hexdigest() -def _coerce_int(value: object, *, default: int) -> int: - if isinstance(value, bool): - return default - if isinstance(value, (int, float)): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError: - return default - return default - - def _coerce_str(value: object, *, default: str = "") -> str: return value if isinstance(value, str) else default @@ -196,127 +298,134 @@ def _coerce_optional_str(value: object) -> str | None: return None -def _coerce_bool(value: object, *, default: bool = False) -> bool: - return value if isinstance(value, bool) else default +def _entries(raw: Mapping[str, object]) -> list[Mapping[str, object]]: + servers = raw.get("servers") + if not isinstance(servers, list): + return [] + return [entry for entry in servers if isinstance(entry, Mapping)] + + +def _entry_server(entry: Mapping[str, object]) -> Mapping[str, object] | None: + server = entry.get("server") + return server if isinstance(server, Mapping) else None + + +def _official_meta(entry: Mapping[str, object]) -> Mapping[str, object]: + meta = entry.get("_meta") + if not isinstance(meta, Mapping): + return {} + official = meta.get(_OFFICIAL_META_KEY) + return official if isinstance(official, Mapping) else {} + +def _is_latest_active(entry: Mapping[str, object]) -> bool: + meta = _official_meta(entry) + return meta.get("status") == "active" and meta.get("isLatest") is True -def _map_summary(server: dict[str, object]) -> dict[str, object]: - qualified_name = _coerce_str(server.get("qualifiedName")) + +def _latest_active_entry(raw: Mapping[str, object]) -> Mapping[str, object] | None: + for entry in _entries(raw): + if _is_latest_active(entry): + return entry + return None + + +def _next_cursor(raw: Mapping[str, object]) -> str | None: + metadata = raw.get("metadata") + if not isinstance(metadata, Mapping): + return None + return _coerce_optional_str(metadata.get("nextCursor")) + + +def _supported_latest_entries( + raw: Mapping[str, object], +) -> list[SupportedRegistryEntry]: + supported: list[SupportedRegistryEntry] = [] + for entry in _entries(raw): + server = _entry_server(entry) + if server is None or not _is_latest_active(entry): + continue + options = registry_install_options(server) + if not options: + continue + supported.append((entry, server, options)) + return supported + + +def _summary_candidates(raw: Mapping[str, object]) -> list[RegistrySummaryCandidate]: + return [ + (server, _map_summary(entry, options=options)) + for entry, server, options in _supported_latest_entries(raw) + ] + + +def _item_matches_filters( + item: Mapping[str, object], + server: Mapping[str, object], + *, + query: str, + remote: bool | None, + verified: bool | None, +) -> bool: + if remote is not None and bool(item["isRemote"]) is not remote: + return False + if verified is not None and bool(item["isVerified"]) is not verified: + return False + if query and not _matches_query(server, query): + return False + return True + + +def _map_summary( + entry: Mapping[str, object], + *, + options: tuple[RegistryInstallOption, ...], +) -> dict[str, object]: + server = _entry_server(entry) or {} + qualified_name = _coerce_str(server.get("name")) + display_name = _coerce_str(server.get("title"), default=qualified_name) + is_remote = _options_are_remote(options) + official = _official_meta(entry) return { "qualifiedName": qualified_name, - "namespace": _coerce_str(server.get("namespace")), - "displayName": _coerce_str(server.get("displayName"), default=qualified_name), + "namespace": _namespace(qualified_name), + "displayName": display_name, "description": _coerce_str(server.get("description")), - "iconUrl": _coerce_optional_str(server.get("iconUrl")), - "isVerified": _coerce_bool(server.get("verified")), - "isRemote": _coerce_bool(server.get("remote")), - "isDeployed": _coerce_bool(server.get("isDeployed")), - "useCount": _coerce_int(server.get("useCount"), default=0), - "createdAt": _coerce_optional_str(server.get("createdAt")), - "homepage": _coerce_optional_str(server.get("homepage")), + "iconUrl": _icon_url(server), + "isVerified": True, + "isRemote": is_remote, + "isDeployed": is_remote, + "useCount": 0, + "createdAt": _coerce_optional_str(official.get("publishedAt")), + "homepage": _coerce_optional_str(server.get("websiteUrl")), + "websiteUrl": _coerce_optional_str(server.get("websiteUrl")), + "githubUrl": _github_repository_url(server), "externalUrl": _external_url(qualified_name), } -def _map_detail(raw: dict[str, object], *, qualified_name: str) -> dict[str, object]: - display_name = _coerce_str(raw.get("displayName"), default=qualified_name) - description = _coerce_str(raw.get("description")) - icon_url = _coerce_optional_str(raw.get("iconUrl")) - is_remote = _coerce_bool(raw.get("remote")) - deployment_url = _coerce_optional_str(raw.get("deploymentUrl")) - - connections_raw = raw.get("connections", []) - connections: list[dict[str, object]] = [] - if isinstance(connections_raw, list): - for connection in connections_raw: - if not isinstance(connection, dict): - continue - kind_raw = _coerce_str(connection.get("type"), default="unknown").lower() - kind = ( - "http" - if kind_raw in {"http", "streamable-http"} - else ("sse" if kind_raw == "sse" else ("stdio" if kind_raw == "stdio" else kind_raw or "unknown")) - ) - config_schema = connection.get("configSchema") - mapped_connection: dict[str, object] = { - "kind": kind, - "deploymentUrl": _coerce_optional_str(connection.get("deploymentUrl")), - "configSchema": config_schema if isinstance(config_schema, dict) else None, - } - if kind == "stdio": - stdio_function = _coerce_optional_str(connection.get("stdioFunction")) - bundle_url = _coerce_optional_str(connection.get("bundleUrl")) - runtime = _coerce_optional_str(connection.get("runtime")) - static_stdio = parse_static_stdio_function(stdio_function) - mapped_connection["stdioFunction"] = stdio_function - mapped_connection["bundleUrl"] = bundle_url - mapped_connection["runtime"] = runtime - mapped_connection["stdioCommand"] = static_stdio.command if static_stdio else None - mapped_connection["stdioArgs"] = list(static_stdio.args) if static_stdio else None - connections.append(mapped_connection) - - tools_raw = raw.get("tools", []) - tools: list[dict[str, object]] = [] - if isinstance(tools_raw, list): - for tool in tools_raw: - if not isinstance(tool, dict): - continue - name = _coerce_str(tool.get("name")) - if not name: - continue - tools.append( - { - "name": name, - "description": _coerce_str(tool.get("description")), - "parameters": _flatten_input_schema(tool.get("inputSchema")), - } - ) - - resources_raw = raw.get("resources", []) - resources: list[dict[str, object]] = [] - if isinstance(resources_raw, list): - for resource in resources_raw: - if not isinstance(resource, dict): - continue - resources.append( - { - "name": _coerce_str(resource.get("name")), - "uri": _coerce_str(resource.get("uri")), - "description": _coerce_str(resource.get("description")), - "mimeType": _coerce_optional_str(resource.get("mimeType")), - } - ) - - prompts_raw = raw.get("prompts", []) - prompts: list[dict[str, object]] = [] - if isinstance(prompts_raw, list): - for prompt in prompts_raw: - if not isinstance(prompt, dict): - continue - arguments_raw = prompt.get("arguments") - arguments: list[dict[str, object]] = [] - if isinstance(arguments_raw, list): - for argument in arguments_raw: - if not isinstance(argument, dict): - continue - arguments.append( - { - "name": _coerce_str(argument.get("name")), - "description": _coerce_str(argument.get("description")), - "required": _coerce_bool(argument.get("required")), - } - ) - prompts.append( - { - "name": _coerce_str(prompt.get("name")), - "description": _coerce_str(prompt.get("description")), - "arguments": arguments, - } - ) - +def _map_detail( + raw: Mapping[str, object], + *, + qualified_name: str, + options: tuple[RegistryInstallOption, ...], +) -> dict[str, object]: + server = _entry_server(raw) or {} + display_name = _coerce_str(server.get("title"), default=qualified_name) + description = _coerce_str(server.get("description")) + icon_url = _icon_url(server) + is_remote = _options_are_remote(options) + connections = [_connection_from_option(option) for option in options] + tools = _tools(server.get("tools")) + resources = _resources(server.get("resources")) + prompts = _prompts(server.get("prompts")) + deployment_url = next( + (connection.get("deploymentUrl") for connection in connections if connection.get("deploymentUrl")), + None, + ) return { "qualifiedName": qualified_name, - "managedName": canonical_server_name(qualified_name), + "managedName": registry_managed_name(qualified_name), "displayName": display_name, "description": description, "iconUrl": icon_url, @@ -331,25 +440,206 @@ def _map_detail(raw: dict[str, object], *, qualified_name: str) -> dict[str, obj "resources": len(resources), "prompts": len(prompts), }, + "websiteUrl": _coerce_optional_str(server.get("websiteUrl")), + "githubUrl": _github_repository_url(server), "externalUrl": _external_url(qualified_name), + "installConfig": McpInstallConfig(options[0].fields).to_dict() if options else McpInstallConfig().to_dict(), } +def _connection_from_option(option: RegistryInstallOption) -> dict[str, object]: + if option.transport == "stdio": + return { + "kind": "stdio", + "deploymentUrl": None, + "configSchema": None, + "stdioFunction": None, + "bundleUrl": None, + "runtime": None, + "stdioCommand": option.command, + "stdioArgs": list(option.args or ()), + } + return { + "kind": option.transport, + "deploymentUrl": option.url, + "configSchema": None, + "stdioFunction": None, + "bundleUrl": None, + "runtime": None, + "stdioCommand": None, + "stdioArgs": None, + } + + +def _options_are_remote(options: tuple[RegistryInstallOption, ...]) -> bool: + if not options: + return False + return all(option.transport in {"http", "sse"} for option in options) + + +def _namespace(qualified_name: str) -> str: + return qualified_name.split("/", 1)[0] if qualified_name else "" + + +def _icon_url(server: Mapping[str, object]) -> str | None: + icons = server.get("icons") + if isinstance(icons, list): + for icon in icons: + if isinstance(icon, Mapping): + value = _coerce_optional_str(icon.get("src")) + if value: + return value + return _github_repository_avatar_url(server) + + +def _github_repository_avatar_url(server: Mapping[str, object]) -> str | None: + repository = server.get("repository") + if not isinstance(repository, Mapping): + return None + raw_url = _coerce_optional_str(repository.get("url")) + if raw_url is None: + return None + owner = _github_owner_from_url(raw_url) + if owner is None: + return None + return f"https://github.com/{owner}.png?size=96" + + +def _github_repository_url(server: Mapping[str, object]) -> str | None: + repository = server.get("repository") + if not isinstance(repository, Mapping): + return None + raw_url = _coerce_optional_str(repository.get("url")) + if raw_url is None: + return None + path = _github_repository_path_from_url(raw_url) + if path is None: + return None + return f"https://github.com/{path}" + + +def _github_owner_from_url(raw_url: str) -> str | None: + path = _github_repository_path_from_url(raw_url) + if path is None: + return None + return path.split("/", 1)[0] + + +def _github_repository_path_from_url(raw_url: str) -> str | None: + if raw_url.startswith("git@github.com:"): + path = raw_url.removeprefix("git@github.com:") + else: + parsed = urlparse(raw_url) + if parsed.netloc.lower() not in {"github.com", "www.github.com"}: + return None + path = parsed.path + parts = [part for part in path.strip("/").split("/") if part] + if len(parts) < 2: + return None + owner = parts[0] + repo = parts[1].removesuffix(".git") + if not owner or not repo: + return None + return f"{owner}/{repo}" + + +def _matches_query(server: Mapping[str, object], query: str) -> bool: + needle = query.lower() + fields = [ + server.get("name"), + server.get("title"), + server.get("description"), + server.get("websiteUrl"), + ] + repository = server.get("repository") + if isinstance(repository, Mapping): + fields.append(repository.get("url")) + return any(isinstance(value, str) and needle in value.lower() for value in fields) + + +def _tools(raw: object) -> list[dict[str, object]]: + if not isinstance(raw, list): + return [] + tools: list[dict[str, object]] = [] + for tool in raw: + if not isinstance(tool, Mapping): + continue + name = _coerce_str(tool.get("name")) + if not name: + continue + tools.append( + { + "name": name, + "description": _coerce_str(tool.get("description")), + "parameters": _flatten_input_schema(tool.get("inputSchema")), + } + ) + return tools + + +def _resources(raw: object) -> list[dict[str, object]]: + if not isinstance(raw, list): + return [] + resources: list[dict[str, object]] = [] + for resource in raw: + if not isinstance(resource, Mapping): + continue + resources.append( + { + "name": _coerce_str(resource.get("name")), + "uri": _coerce_str(resource.get("uri")), + "description": _coerce_str(resource.get("description")), + "mimeType": _coerce_optional_str(resource.get("mimeType")), + } + ) + return resources + + +def _prompts(raw: object) -> list[dict[str, object]]: + if not isinstance(raw, list): + return [] + prompts: list[dict[str, object]] = [] + for prompt in raw: + if not isinstance(prompt, Mapping): + continue + arguments_raw = prompt.get("arguments") + arguments: list[dict[str, object]] = [] + if isinstance(arguments_raw, list): + for argument in arguments_raw: + if not isinstance(argument, Mapping): + continue + arguments.append( + { + "name": _coerce_str(argument.get("name")), + "description": _coerce_str(argument.get("description")), + "required": bool(argument.get("required", False)), + } + ) + prompts.append( + { + "name": _coerce_str(prompt.get("name")), + "description": _coerce_str(prompt.get("description")), + "arguments": arguments, + } + ) + return prompts + + def _flatten_input_schema(schema: object) -> list[dict[str, object]]: - if not isinstance(schema, dict): + if not isinstance(schema, Mapping): return [] properties = schema.get("properties") required_raw = schema.get("required") required_set: set[str] = set() if isinstance(required_raw, list): required_set = {item for item in required_raw if isinstance(item, str)} - if not isinstance(properties, dict): + if not isinstance(properties, Mapping): return [] parameters: list[dict[str, object]] = [] for name, value in properties.items(): if not isinstance(name, str): continue - entry = value if isinstance(value, dict) else {} + entry = value if isinstance(value, Mapping) else {} param: dict[str, object] = { "name": name, "type": _coerce_param_type(entry.get("type")), @@ -386,8 +676,8 @@ def _camel(value: str) -> str: def _external_url(qualified_name: str) -> str: if not qualified_name: - return _SMITHERY_WEB_BASE_URL - return f"{_SMITHERY_WEB_BASE_URL}/server/{quote(qualified_name, safe='/')}" + return _REGISTRY_EXTERNAL_BASE_URL + return f"{_REGISTRY_EXTERNAL_BASE_URL}/?{urlencode({'q': qualified_name})}" __all__ = [ diff --git a/skill_manager/application/mcp/marketplace/client.py b/skill_manager/application/mcp/marketplace/client.py index d481f6d..5e45ec0 100644 --- a/skill_manager/application/mcp/marketplace/client.py +++ b/skill_manager/application/mcp/marketplace/client.py @@ -8,40 +8,41 @@ from urllib.parse import urljoin from urllib.request import Request, urlopen -from skill_manager.errors import MarketplaceUpstreamError from skill_manager.application.marketplace_http import ( configured_marketplace_ca_file, marketplace_ssl_context, ) +from skill_manager.errors import MarketplaceUpstreamError + -DEFAULT_SMITHERY_BASE_URL = "https://api.smithery.ai" -SMITHERY_BASE_URL_ENV = "SKILL_MANAGER_MCP_MARKETPLACE_BASE_URL" +DEFAULT_MCP_REGISTRY_BASE_URL = "https://registry.modelcontextprotocol.io" +MCP_REGISTRY_BASE_URL_ENV = "SKILL_MANAGER_MCP_REGISTRY_BASE_URL" _TIMEOUT_SECONDS = 15 _USER_AGENT = "skill-manager/0.1" -def configured_smithery_base_url(env: dict[str, str] | None = None) -> str: +def configured_mcp_registry_base_url(env: dict[str, str] | None = None) -> str: active_env = os.environ if env is None else env - configured = active_env.get(SMITHERY_BASE_URL_ENV, DEFAULT_SMITHERY_BASE_URL).strip() - return (configured or DEFAULT_SMITHERY_BASE_URL).rstrip("/") + configured = active_env.get(MCP_REGISTRY_BASE_URL_ENV, DEFAULT_MCP_REGISTRY_BASE_URL).strip() + return (configured or DEFAULT_MCP_REGISTRY_BASE_URL).rstrip("/") -class SmitheryClient: +class McpRegistryClient: def __init__( self, *, - base_url: str = DEFAULT_SMITHERY_BASE_URL, + base_url: str = DEFAULT_MCP_REGISTRY_BASE_URL, timeout_seconds: float = _TIMEOUT_SECONDS, ssl_context: ssl.SSLContext | None = None, ) -> None: - self.base_url = (base_url or DEFAULT_SMITHERY_BASE_URL).rstrip("/") + self.base_url = (base_url or DEFAULT_MCP_REGISTRY_BASE_URL).rstrip("/") self.timeout_seconds = timeout_seconds self.ssl_context = ssl_context @classmethod - def from_environment(cls, env: dict[str, str] | None = None) -> "SmitheryClient": + def from_environment(cls, env: dict[str, str] | None = None) -> "McpRegistryClient": return cls( - base_url=configured_smithery_base_url(env), + base_url=configured_mcp_registry_base_url(env), ssl_context=marketplace_ssl_context(env), ) @@ -98,9 +99,9 @@ def _request(self, path_or_url: str, *, accept: str | None = None) -> bytes: __all__ = [ - "DEFAULT_SMITHERY_BASE_URL", - "SMITHERY_BASE_URL_ENV", - "SmitheryClient", - "configured_smithery_base_url", + "DEFAULT_MCP_REGISTRY_BASE_URL", + "MCP_REGISTRY_BASE_URL_ENV", + "McpRegistryClient", "configured_marketplace_ca_file", + "configured_mcp_registry_base_url", ] diff --git a/skill_manager/application/mcp/mutations.py b/skill_manager/application/mcp/mutations.py index 1b5a617..8408928 100644 --- a/skill_manager/application/mcp/mutations.py +++ b/skill_manager/application/mcp/mutations.py @@ -5,12 +5,18 @@ from skill_manager.errors import MutationError +from .availability import ( + AvailabilityCache, + McpAvailabilityProbe, + availability_cache_key, +) from .enrichment import McpEnrichmentService -from .installers import McpInstallProvider +from .install_resolver import resolve_registry_server_spec +from .install_state import resolve_enable_spec from .marketplace.catalog import McpMarketplaceCatalog -from .names import canonical_server_name from .planner import McpAdoptionPlanner from .read_models import McpReadModelService +from .redaction import redacted_spec_dict from .store import McpServerSpec, McpServerStore, McpSource @@ -28,76 +34,52 @@ def __init__( read_models: McpReadModelService, planner: McpAdoptionPlanner, marketplace_catalog: McpMarketplaceCatalog, - install_provider: McpInstallProvider, enrichment: McpEnrichmentService | None = None, + availability_probe: McpAvailabilityProbe | None = None, + availability_cache: AvailabilityCache | None = None, ) -> None: self.store = store self.read_models = read_models self.planner = planner self.marketplace = marketplace_catalog - self.install_provider = install_provider self.enrichment = enrichment + self.availability_probe = availability_probe or McpAvailabilityProbe() + self._availability_cache = availability_cache if availability_cache is not None else {} # Install / uninstall --------------------------------------------------- def install_from_marketplace( self, qualified_name: str, - *, - source_harness: str, ) -> dict[str, object]: if not qualified_name: raise MutationError("qualifiedName is required", status=400) - if not source_harness: - raise MutationError("sourceHarness is required", status=400) - self._require_install_target(source_harness) - managed_name = canonical_server_name(qualified_name) - existing = self._managed_for_marketplace(qualified_name) or self.store.get_managed(managed_name) + existing = self._managed_for_marketplace(qualified_name) if existing is not None: raise MutationError( f"a server named '{existing.name}' is already installed", status=409, ) - detail = self.marketplace.detail(qualified_name) + detail = self._marketplace_install_detail(qualified_name) if detail is None: raise MutationError(f"server not found in marketplace: {qualified_name}", status=404) - - before_names = self._observed_names(source_harness) - self.install_provider.install( - qualified_name=qualified_name, - source_harness=source_harness, - ) - self.read_models.invalidate() - observed = self._find_installed_observation( - source_harness=source_harness, - preferred_name=managed_name, - before_names=before_names, + source_spec = resolve_registry_server_spec( + detail, + allow_missing_required=True, ) - source_spec = observed.parsed_spec - if source_spec is None: - raise MutationError( - f"Smithery installed '{qualified_name}', but no readable MCP entry was found in {source_harness}", - status=502, - ) if self.store.get_managed(source_spec.name) is not None: raise MutationError( f"a server named '{source_spec.name}' is already installed", status=409, ) - stored = self.store.upsert_from_spec( - replace( - source_spec, - display_name=str(detail.get("displayName") or source_spec.display_name), - source=McpSource.marketplace(qualified_name), - ) - ) + stored = self.store.upsert_from_spec(source_spec) self.read_models.invalidate() - return {"ok": True, "server": stored.to_dict()} - - def install_targets(self) -> dict[str, object]: - return {"targets": self._resolved_install_targets()} + self._availability_cache[availability_cache_key(stored.name, stored)] = ( + self.availability_probe.probe(stored) + ) + return {"ok": True, "server": redacted_spec_dict(stored)} def uninstall_server(self, name: str) -> dict[str, object]: if self.store.get_managed(name) is None: @@ -125,12 +107,21 @@ def uninstall_server(self, name: str) -> dict[str, object]: # Per-harness toggle ---------------------------------------------------- - def enable_server(self, name: str, harness: str) -> dict[str, bool]: + def enable_server( + self, + name: str, + harness: str, + *, + config: dict[str, object] | None = None, + ) -> dict[str, bool]: spec = self._require_server(name) adapter = self.read_models.require_enabled_adapter(harness) if adapter.has_binding(name): return {"ok": True} - adapter.enable_server(spec) + binding_spec = self._binding_spec_for_enable(spec, config=config) + adapter.enable_server(binding_spec) + if binding_spec != spec: + self.store.upsert_from_spec(binding_spec) self.read_models.invalidate() return {"ok": True} @@ -142,10 +133,17 @@ def disable_server(self, name: str, harness: str) -> dict[str, bool]: self.read_models.invalidate() return {"ok": True} - def set_server_all_harnesses(self, name: str, target: str) -> dict[str, object]: + def set_server_all_harnesses( + self, + name: str, + target: str, + *, + config: dict[str, object] | None = None, + ) -> dict[str, object]: if target not in ("enabled", "disabled"): raise MutationError("target must be 'enabled' or 'disabled'", status=400) spec = self._require_server(name) + binding_spec = self._binding_spec_for_enable(spec, config=config) if target == "enabled" else spec bound_now = self._harnesses_in_states(name, {"managed", "drifted"}) @@ -165,7 +163,7 @@ def set_server_all_harnesses(self, name: str, target: str) -> dict[str, object]: continue try: if target == "enabled": - adapter.enable_server(spec) + adapter.enable_server(binding_spec) else: adapter.disable_server(name) except Exception as error: # noqa: BLE001 @@ -175,6 +173,8 @@ def set_server_all_harnesses(self, name: str, target: str) -> dict[str, object]: flipped_any = True if flipped_any: + if target == "enabled" and binding_spec != spec: + self.store.upsert_from_spec(binding_spec) self.read_models.invalidate() return { @@ -183,6 +183,19 @@ def set_server_all_harnesses(self, name: str, target: str) -> dict[str, object]: "failed": failures, } + def _binding_spec_for_enable( + self, + spec: McpServerSpec, + *, + config: dict[str, object] | None, + ) -> McpServerSpec: + if spec.source.kind != "marketplace": + return spec + detail = self._marketplace_install_detail(spec.source.locator) + if detail is None: + raise MutationError(f"server not found in marketplace: {spec.source.locator}", status=404) + return resolve_enable_spec(detail, spec, config=config) + # Reconciliation ------------------------------------------------------- def reconcile_server( @@ -190,7 +203,7 @@ def reconcile_server( name: str, *, source_kind: str, - source_harness: str | None = None, + observed_harness: str | None = None, harnesses: list[str] | None = None, ) -> dict[str, object]: if self.store.get_managed(name) is None: @@ -204,9 +217,9 @@ def reconcile_server( if source_kind == "managed": source_spec = current elif source_kind == "harness": - if not source_harness: - raise MutationError("sourceHarness is required when sourceKind is 'harness'", status=400) - observed_spec = self._observed_spec(name, source_harness) + if not observed_harness: + raise MutationError("observedHarness is required when sourceKind is 'harness'", status=400) + observed_spec = self._observed_spec(name, observed_harness) source_spec = replace( observed_spec, name=current.name, @@ -226,7 +239,7 @@ def reconcile_server( self.read_models.invalidate() return { "ok": not failures, - "server": stored.to_dict(), + "server": redacted_spec_dict(stored), "succeeded": succeeded, "failed": failures, } @@ -249,7 +262,7 @@ def adopt( self, name: str, *, - source_harness: str | None = None, + observed_harness: str | None = None, harnesses: list[str] | None = None, ) -> dict[str, object]: if self.store.get_managed(name) is not None: @@ -257,21 +270,21 @@ def adopt( f"a managed server named '{name}' already exists", status=409 ) group = self.planner.require_group(name) - if source_harness: + if observed_harness: target_spec = next( - (sighting.spec for sighting in group.sightings if sighting.harness == source_harness), + (sighting.spec for sighting in group.sightings if sighting.harness == observed_harness), None, ) if target_spec is None: raise MutationError( - f"server '{name}' was not observed in harness '{source_harness}'", + f"server '{name}' was not observed in harness '{observed_harness}'", status=400, ) else: target_spec = group.canonical_spec if target_spec is None: raise MutationError( - f"server '{name}' has different configs across harnesses; choose a sourceHarness to adopt", + f"server '{name}' has different configs across harnesses; choose an observedHarness to adopt", status=409, ) if target_spec.name != name: @@ -293,85 +306,21 @@ def adopt( response_spec = self.store.get_public_spec(stored.name) or stored_binding_spec return { "ok": not failures, - "server": response_spec.to_dict(), + "server": redacted_spec_dict(response_spec), "succeeded": succeeded, "failed": failures, } # Internal helpers ----------------------------------------------------- - def _observed_names(self, harness: str) -> set[str]: - adapter = self._source_adapter(harness) - scan = adapter.scan(self.store.list_binding_specs()) - return {entry.name for entry in scan.entries if entry.state != "missing"} - - def _resolved_install_targets(self) -> list[dict[str, object]]: - provider_targets = { - target.harness: target for target in self.install_provider.install_targets() - } - enabled = set(self.read_models.enabled_harnesses()) - targets: list[dict[str, object]] = [] - for status in self.read_models.harness_statuses(): - provider_target = provider_targets.get(status.harness) - smithery_client = provider_target.smithery_client if provider_target else None - supported = bool(provider_target and provider_target.supported and smithery_client) - reason = ( - provider_target.reason - if provider_target and provider_target.reason - else None - ) - if supported and status.harness not in enabled: - supported = False - reason = "Harness support is disabled" - elif supported and not status.mcp_writable: - supported = False - reason = status.mcp_unavailable_reason or "MCP config is not writable for this harness" - elif not supported and reason is None: - reason = "Smithery does not provide an MCP installer target for this harness" - targets.append( - { - "harness": status.harness, - "label": status.label, - "logoKey": status.logo_key, - "smitheryClient": smithery_client, - "supported": supported, - "reason": reason, - } - ) - return targets - - def _require_install_target(self, harness: str) -> None: - for target in self._resolved_install_targets(): - if target["harness"] != harness: - continue - if target["supported"]: - return - reason = target.get("reason") - raise MutationError(str(reason or f"source harness is not installable: {harness}"), status=400) - raise MutationError(f"unknown MCP source harness: {harness}", status=400) - - def _find_installed_observation(self, *, source_harness: str, preferred_name: str, before_names: set[str]): - adapter = self._source_adapter(source_harness) - scan = adapter.scan(self.store.list_binding_specs()) - entries = [entry for entry in scan.entries if entry.state in {"unmanaged", "drifted", "managed"}] - for entry in entries: - if entry.name == preferred_name: - return entry - new_entries = [entry for entry in entries if entry.name not in before_names] - if len(new_entries) == 1: - return new_entries[0] - raise MutationError( - f"Smithery installed the server, but Skill Manager could not identify the new {source_harness} config entry", - status=502, - ) - - def _source_adapter(self, harness: str): - if harness not in self.read_models.enabled_harnesses(): - raise MutationError(f"harness support is disabled: {harness}", status=400) - adapter = self.read_models.find_adapter(harness) - if adapter is None: - raise MutationError(f"unknown MCP source harness: {harness}", status=400) - return adapter + def _marketplace_install_detail(self, qualified_name: str): + install_detail = getattr(self.marketplace, "install_detail", None) + if callable(install_detail): + detail = install_detail(qualified_name) + if detail is not None: + to_resolver_detail = getattr(detail, "to_resolver_detail", None) + return to_resolver_detail() if callable(to_resolver_detail) else detail + return self.marketplace.detail(qualified_name) def _harnesses_in_states( self, diff --git a/skill_manager/application/mcp/query.py b/skill_manager/application/mcp/query.py index 530d689..16c700f 100644 --- a/skill_manager/application/mcp/query.py +++ b/skill_manager/application/mcp/query.py @@ -1,13 +1,24 @@ from __future__ import annotations +from typing import Literal + from skill_manager.errors import MutationError from .contracts import McpBinding, McpHarnessScan, McpInventory, McpInventoryIssue +from .availability import ( + AvailabilityCache, + McpAvailabilityProbe, + McpAvailabilityResult, + availability_cache_key, +) from .enrichment import McpEnrichmentService +from .install_state import McpInstallConfigStatus, install_config_status from .inventory import build_inventory +from .marketplace.catalog import McpMarketplaceCatalog from .planner import McpAdoptionPlanner from .read_models import McpReadModelService -from .env import annotate_env +from .redaction import annotate_redacted_env, redact_payload, redacted_spec_dict +from .store import McpServerSpec class McpQueryService: @@ -19,15 +30,27 @@ def __init__( *, planner: McpAdoptionPlanner | None = None, enrichment: McpEnrichmentService | None = None, + marketplace_catalog: McpMarketplaceCatalog | None = None, + availability_probe: McpAvailabilityProbe | None = None, + availability_cache: AvailabilityCache | None = None, ) -> None: self.read_models = read_models self.planner = planner self.enrichment = enrichment + self.marketplace = marketplace_catalog + self.availability_probe = availability_probe or McpAvailabilityProbe() + self._availability_cache = availability_cache if availability_cache is not None else {} def list_servers(self) -> dict[str, object]: snapshot = self.read_models.snapshot() inventory = self._inventory(snapshot.harness_scans) - return _inventory_to_payload(inventory, self.read_models.visible_scans(snapshot)) + install_config_statuses = self._install_config_statuses(inventory) + return _inventory_to_payload( + inventory, + self.read_models.visible_scans(snapshot), + self._availability_cache, + install_config_statuses, + ) def get_server(self, name: str) -> dict[str, object]: snapshot = self.read_models.snapshot() @@ -35,9 +58,14 @@ def get_server(self, name: str) -> dict[str, object]: visible_scans = self.read_models.visible_scans(snapshot) for entry in inventory.entries: if entry.name == name: - payload = _entry_to_payload(entry, visible_scans) + payload = _entry_to_payload( + entry, + visible_scans, + self._availability_cache.get(_availability_cache_key(entry)), + self._install_config_status_for_spec(entry.spec), + ) if entry.spec is not None: - payload["env"] = annotate_env(entry.spec.env) + payload["env"] = annotate_redacted_env(entry.spec.env) payload["configChoices"] = _config_choices_payload( name, entry.spec, @@ -49,6 +77,21 @@ def get_server(self, name: str) -> dict[str, object]: return payload raise MutationError(f"unknown mcp server: {name}", status=404) + def check_availability(self, name: str) -> dict[str, object]: + snapshot = self.read_models.snapshot() + inventory = self._inventory(snapshot.harness_scans) + entry = next((item for item in inventory.entries if item.name == name), None) + if entry is None or entry.spec is None: + raise MutationError(f"unknown mcp server: {name}", status=404) + result = self.availability_probe.probe(entry.spec) + self._availability_cache[_availability_cache_key(entry)] = result + return { + "ok": True, + "name": name, + "availabilityStatus": result.status, + "availabilityReason": result.reason, + } + def list_unmanaged_by_server(self) -> dict[str, object]: if self.planner is None: raise RuntimeError("unmanaged MCP planner is not configured") @@ -90,7 +133,7 @@ def list_unmanaged_by_server(self) -> dict[str, object]: "logoKey": issue.logo_key, "name": issue.name, "configPath": issue.config_path, - "payloadPreview": issue.payload, + "payloadPreview": redact_payload(issue.payload) if issue.payload is not None else None, "reason": issue.reason, } for issue in plan.issues @@ -110,9 +153,9 @@ def list_unmanaged_by_server(self) -> dict[str, object]: "label": s.label, "logoKey": s.logo_key, "configPath": s.config_path, - "payloadPreview": s.payload, - "spec": s.spec.to_dict(), - "env": annotate_env(s.spec.env), + "payloadPreview": redact_payload(s.payload), + "spec": redacted_spec_dict(s.spec), + "env": annotate_redacted_env(s.spec.env), } for s in sightings ] @@ -121,7 +164,7 @@ def list_unmanaged_by_server(self) -> dict[str, object]: { "name": group.name, "identical": group.identical, - "canonicalSpec": group.canonical_spec.to_dict() + "canonicalSpec": redacted_spec_dict(group.canonical_spec) if group.canonical_spec is not None else None, "sightings": sightings_payload, @@ -147,6 +190,41 @@ def _inventory(self, scans: tuple[McpHarnessScan, ...]) -> McpInventory: issues=issues, ) + def _install_config_statuses( + self, + inventory: McpInventory, + ) -> dict[str, McpInstallConfigStatus]: + return { + entry.name: self._install_config_status_for_spec(entry.spec) + for entry in inventory.entries + if entry.spec is not None + } + + def _install_config_status_for_spec( + self, + spec: McpServerSpec | None, + ) -> McpInstallConfigStatus: + if spec is None or spec.source.kind != "marketplace" or self.marketplace is None: + return McpInstallConfigStatus() + detail = self._marketplace_install_detail(spec.source.locator) + if detail is None: + return McpInstallConfigStatus() + return install_config_status(detail, spec) + + def _marketplace_install_detail(self, qualified_name: str): + if self.marketplace is None: + return None + install_detail = getattr(self.marketplace, "install_detail", None) + try: + if callable(install_detail): + detail = install_detail(qualified_name) + if detail is not None: + to_resolver_detail = getattr(detail, "to_resolver_detail", None) + return to_resolver_detail() if callable(to_resolver_detail) else detail + return self.marketplace.detail(qualified_name) + except Exception: + return None + def _binding_to_dict(binding: McpBinding) -> dict[str, object]: payload: dict[str, object] = { @@ -158,15 +236,86 @@ def _binding_to_dict(binding: McpBinding) -> dict[str, object]: return payload -def _entry_to_payload(entry, scans: tuple[McpHarnessScan, ...]) -> dict[str, object]: +def _is_scan_addressable(scan: McpHarnessScan) -> bool: + return scan.mcp_writable and (scan.installed or scan.config_present) + + +def _addressable_harnesses(scans: tuple[McpHarnessScan, ...]) -> set[str]: + return { + scan.harness + for scan in scans + if _is_scan_addressable(scan) + } + + +def _entry_enabled_status( + entry, + addressable_harnesses: set[str], +) -> Literal["enabled", "disabled"]: + for binding in entry.sightings: + if binding.harness in addressable_harnesses and binding.state == "managed": + return "enabled" + return "disabled" + + +def _entry_mcp_status( + entry, + availability: McpAvailabilityResult | None, + install_config_status: McpInstallConfigStatus, +) -> dict[str, object]: + if install_config_status.missing_required: + return { + "kind": "needs_config", + "reason": None, + } + if availability is None: + return { + "kind": "unchecked", + "reason": None, + } + if availability.status == "available": + return { + "kind": "available", + "reason": None, + } + if not availability.reason: + return { + "kind": "unchecked", + "reason": None, + } + return { + "kind": "connection_issue", + "reason": availability.reason, + } + + +def _entry_to_payload( + entry, + scans: tuple[McpHarnessScan, ...], + availability: McpAvailabilityResult | None = None, + config_status: McpInstallConfigStatus | None = None, +) -> dict[str, object]: visible_harnesses = {scan.harness for scan in scans} - spec_payload = entry.spec.to_dict() if entry.spec is not None else None + addressable_harnesses = _addressable_harnesses(scans) + spec_payload = redacted_spec_dict(entry.spec) if entry.spec is not None else None + enabled_status = _entry_enabled_status(entry, addressable_harnesses) + effective_availability = _entry_effective_availability(availability) + effective_config_status = config_status or McpInstallConfigStatus() return { "name": entry.name, "displayName": entry.display_name, "kind": entry.kind, "spec": spec_payload, "canEnable": entry.can_enable, + "enabledStatus": enabled_status, + "availabilityStatus": effective_availability.status, + "availabilityReason": effective_availability.reason, + "mcpStatus": _entry_mcp_status( + entry, + availability, + effective_config_status, + ), + "installConfigStatus": effective_config_status.to_dict(), "sightings": [ _binding_to_dict(binding) for binding in entry.sightings @@ -175,6 +324,20 @@ def _entry_to_payload(entry, scans: tuple[McpHarnessScan, ...]) -> dict[str, obj } +def _availability_cache_key(entry) -> tuple[str, str]: + if entry.spec is None: + return (entry.name, "") + return availability_cache_key(entry.name, entry.spec) + + +def _entry_effective_availability( + availability: McpAvailabilityResult | None, +) -> McpAvailabilityResult: + if availability is None: + return McpAvailabilityResult(status="unavailable", reason=None) + return availability + + def _config_choices_payload( name: str, managed_spec, @@ -183,13 +346,13 @@ def _config_choices_payload( choices: list[dict[str, object]] = [ { "sourceKind": "managed", - "sourceHarness": None, + "observedHarness": None, "label": "Managed config", "logoKey": None, "configPath": None, - "payloadPreview": managed_spec.to_dict(), - "spec": managed_spec.to_dict(), - "env": annotate_env(managed_spec.env), + "payloadPreview": redacted_spec_dict(managed_spec), + "spec": redacted_spec_dict(managed_spec), + "env": annotate_redacted_env(managed_spec.env), } ] for scan in scans: @@ -201,20 +364,26 @@ def _config_choices_payload( choices.append( { "sourceKind": "harness", - "sourceHarness": scan.harness, + "observedHarness": scan.harness, "label": f"{scan.label} config", "logoKey": scan.logo_key, "configPath": str(scan.config_path) if scan.config_present else None, - "payloadPreview": dict(observed.raw_payload or {}), - "spec": observed.parsed_spec.to_dict(), - "env": annotate_env(observed.parsed_spec.env), + "payloadPreview": redact_payload(dict(observed.raw_payload or {})), + "spec": redacted_spec_dict(observed.parsed_spec), + "env": annotate_redacted_env(observed.parsed_spec.env), } ) return choices -def _inventory_to_payload(inventory: McpInventory, scans: tuple[McpHarnessScan, ...]) -> dict[str, object]: +def _inventory_to_payload( + inventory: McpInventory, + scans: tuple[McpHarnessScan, ...], + availability_cache: dict[tuple[str, str], McpAvailabilityResult] | None = None, + install_config_statuses: dict[str, McpInstallConfigStatus] | None = None, +) -> dict[str, object]: visible_harnesses = {scan.harness for scan in scans} + statuses = install_config_statuses or {} return { "columns": [ { @@ -229,7 +398,12 @@ def _inventory_to_payload(inventory: McpInventory, scans: tuple[McpHarnessScan, for scan in scans ], "entries": [ - _entry_to_payload(entry, scans) + _entry_to_payload( + entry, + scans, + (availability_cache or {}).get(_availability_cache_key(entry)), + statuses.get(entry.name), + ) for entry in inventory.entries if entry.kind == "managed" or any(binding.harness in visible_harnesses for binding in entry.sightings) diff --git a/skill_manager/application/mcp/redaction.py b/skill_manager/application/mcp/redaction.py new file mode 100644 index 0000000..115dbb7 --- /dev/null +++ b/skill_manager/application/mcp/redaction.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import re +from dataclasses import replace +from typing import Mapping +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from .env import annotate_env, is_env_var_reference +from .store import McpServerSpec + + +REDACTED_MCP_SECRET_VALUE = "[redacted]" + +_SECRET_KEY_RE = re.compile(r"(authorization|api[-_]?key|token|secret|password)", re.IGNORECASE) + + +def redact_spec(spec: McpServerSpec) -> McpServerSpec: + return replace( + spec, + env=_redact_pairs(spec.env), + headers=_redact_pairs(spec.headers), + url=redact_url(spec.url), + ) + + +def redacted_spec_dict(spec: McpServerSpec) -> dict[str, object]: + return redact_spec(spec).to_dict() + + +def annotate_redacted_env( + env: Mapping[str, str] | tuple[tuple[str, str], ...] | None, +) -> list[dict[str, object]]: + return annotate_env(_redact_pairs(tuple(env.items()) if isinstance(env, Mapping) else env)) + + +def redact_payload(value: object, *, parent_key: str = "") -> object: + if isinstance(value, Mapping): + return { + str(key): _redact_value_for_key(str(key), nested) + for key, nested in value.items() + } + if isinstance(value, list): + return [redact_payload(item, parent_key=parent_key) for item in value] + if isinstance(value, str) and parent_key.lower() == "url": + return redact_url(value) + return value + + +def redact_url(url: str | None) -> str | None: + if not url: + return url + parts = urlsplit(url) + if not parts.query: + return url + query = [ + (key, REDACTED_MCP_SECRET_VALUE if is_secret_key(key) else value) + for key, value in parse_qsl(parts.query, keep_blank_values=True) + ] + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +def is_secret_key(key: str) -> bool: + return bool(_SECRET_KEY_RE.search(key)) + + +def _redact_value_for_key(key: str, value: object) -> object: + if is_secret_key(key): + if isinstance(value, str) and is_env_var_reference(value): + return value + return REDACTED_MCP_SECRET_VALUE + return redact_payload(value, parent_key=key) + + +def _redact_pairs(pairs: tuple[tuple[str, str], ...] | None) -> tuple[tuple[str, str], ...] | None: + if not pairs: + return pairs + return tuple( + ( + key, + value if is_env_var_reference(value) or not is_secret_key(key) else REDACTED_MCP_SECRET_VALUE, + ) + for key, value in pairs + ) + + +__all__ = [ + "REDACTED_MCP_SECRET_VALUE", + "annotate_redacted_env", + "is_secret_key", + "redact_payload", + "redact_spec", + "redact_url", + "redacted_spec_dict", +] diff --git a/skill_manager/application/mcp/stdio.py b/skill_manager/application/mcp/stdio.py index 7ae035e..ad549d3 100644 --- a/skill_manager/application/mcp/stdio.py +++ b/skill_manager/application/mcp/stdio.py @@ -16,7 +16,7 @@ class StaticStdioCommand: def parse_static_stdio_function(value: object) -> StaticStdioCommand | None: - """Extract a static command recipe from Smithery's stdioFunction string. + """Extract a static command recipe from a marketplace stdioFunction string. The marketplace field is JavaScript source. We intentionally parse only the simple object-literal subset and never evaluate code. diff --git a/tests/integration/test_mcp_routes.py b/tests/integration/test_mcp_routes.py index 58d52e4..14ee495 100644 --- a/tests/integration/test_mcp_routes.py +++ b/tests/integration/test_mcp_routes.py @@ -4,12 +4,10 @@ import unittest from pathlib import Path -from skill_manager.application.mcp.installers import McpInstallResult -from skill_manager.application.mcp.installers import SmitheryClientTarget -from skill_manager.application.mcp.mappers import get_mapper -from skill_manager.application.mcp.names import canonical_server_name +from skill_manager.application.mcp.availability import McpAvailabilityResult from skill_manager.application.mcp.stdio import parse_static_stdio_function from skill_manager.application.mcp.store import McpServerSpec, McpSource +from skill_manager.errors import MutationError from tests.support.app_harness import AppTestHarness @@ -25,9 +23,14 @@ def __init__( is_remote: bool = True, deployment_url: str | None = "https://mcp.exa.ai", connections: list[dict[str, object]] | None = None, - source_name: str | None = None, + registry_server: dict[str, object] | None = None, ) -> None: self.qualified_name = qualified_name + registry_server = registry_server or _registry_server_from_connections( + qualified_name, + deployment_url=deployment_url, + connections=connections, + ) self._payload = { "qualifiedName": qualified_name, "displayName": "Exa Search" if qualified_name == "exa" else qualified_name.title(), @@ -43,11 +46,21 @@ def __init__( "tools": [], "resources": [], "prompts": [], + "registryServer": registry_server, } def detail(self, qualified_name: str): if qualified_name == self.qualified_name: - return self._payload + return {key: value for key, value in self._payload.items() if key != "registryServer"} + return None + + def install_detail(self, qualified_name: str): + if qualified_name == self.qualified_name: + return { + "qualifiedName": self._payload["qualifiedName"], + "displayName": self._payload["displayName"], + "registryServer": self._payload["registryServer"], + } return None @@ -63,130 +76,69 @@ def __init__( is_remote: bool = True, deployment_url: str | None = "https://mcp.exa.ai", connections: list[dict[str, object]] | None = None, - source_name: str | None = None, + registry_server: dict[str, object] | None = None, ) -> None: self.harness = harness - # Patch the in-memory mutation service to use the fake marketplace. marketplace = FakeMcpMarketplace( qualified_name, config_schema, is_remote=is_remote, deployment_url=deployment_url, connections=connections, + registry_server=registry_server, ) harness.container.mcp_mutations.marketplace = marketplace - harness.container.mcp_mutations.install_provider = FakeMcpInstallProvider( - harness, - { - qualified_name: _source_spec_from_marketplace_payload( - marketplace._payload, # noqa: SLF001 - test stub data - qualified_name=qualified_name, - source_name=source_name, - ) - }, - ) + harness.container.mcp_queries.marketplace = marketplace -class FakeMcpInstallProvider: - def __init__(self, harness: AppTestHarness, specs: dict[str, McpServerSpec]) -> None: - self.harness = harness - self.specs = specs - - def install_targets(self) -> tuple[SmitheryClientTarget, ...]: - return ( - SmitheryClientTarget(harness="codex", smithery_client="codex", supported=True), - SmitheryClientTarget(harness="claude", smithery_client="claude-code", supported=True), - SmitheryClientTarget(harness="cursor", smithery_client="cursor", supported=True), - SmitheryClientTarget(harness="opencode", smithery_client="opencode", supported=True), - SmitheryClientTarget( - harness="openclaw", - smithery_client=None, - supported=False, - reason="Smithery does not provide an OpenClaw MCP installer target", - ), - ) +class FakeMcpAvailabilityProbe: + def __init__(self, status: str = "available", reason: str | None = None) -> None: + self.status = status + self.reason = reason + self.probed: list[str] = [] - def install(self, *, qualified_name: str, source_harness: str) -> McpInstallResult: - spec = self.specs[qualified_name] - if source_harness == "claude": - self._write_claude_code_project_scope(spec) - return McpInstallResult( - qualified_name=qualified_name, - source_harness=source_harness, - installer="fake", - stdout="", - stderr="", - ) - adapter = self.harness.container.mcp_read_models.find_adapter(source_harness) - if adapter is None: - raise AssertionError(f"missing test adapter for {source_harness}") - adapter.enable_server(spec) - return McpInstallResult( - qualified_name=qualified_name, - source_harness=source_harness, - installer="fake", - stdout="", - stderr="", - ) + def probe(self, spec: McpServerSpec) -> McpAvailabilityResult: + self.probed.append(spec.name) + return McpAvailabilityResult(status=self.status, reason=self.reason) - def _write_claude_code_project_scope(self, spec: McpServerSpec) -> None: - path = self.harness.spec.home / ".claude.json" - path.parent.mkdir(parents=True, exist_ok=True) - payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} - projects = payload.setdefault("projects", {}) - if not isinstance(projects, dict): - projects = {} - payload["projects"] = projects - project_key = str(self.harness.spec.home.resolve()) - project = projects.setdefault(project_key, {}) - if not isinstance(project, dict): - project = {} - projects[project_key] = project - servers = project.setdefault("mcpServers", {}) - if not isinstance(servers, dict): - servers = {} - project["mcpServers"] = servers - servers[spec.name] = get_mapper("claude-code").spec_to_dict(spec) - path.write_text(json.dumps(payload), encoding="utf-8") - - -def _source_spec_from_marketplace_payload( - payload: dict[str, object], - *, + +def _registry_server_from_connections( qualified_name: str, - source_name: str | None = None, -) -> McpServerSpec: - name = source_name or canonical_server_name(qualified_name) - connections = payload.get("connections") - first = connections[0] if isinstance(connections, list) and connections else {} - first = first if isinstance(first, dict) else {} - kind = str(first.get("kind") or first.get("type") or "http").lower() - display_name = str(payload.get("displayName") or name) - if kind == "stdio": + *, + deployment_url: str | None, + connections: list[dict[str, object]] | None, +) -> dict[str, object]: + server: dict[str, object] = { + "name": qualified_name, + "title": "Exa Search" if qualified_name == "exa" else qualified_name.title(), + "version": "1.0.0", + "description": "Search the web", + } + first = connections[0] if connections else None + if isinstance(first, dict) and str(first.get("kind") or first.get("type")).lower() == "stdio": stdio = parse_static_stdio_function(first.get("stdioFunction")) if stdio is None: raise AssertionError("test stdio fixture must include a static stdioFunction") - return McpServerSpec( - name=name, - display_name=display_name, - source=McpSource.marketplace(qualified_name), - transport="stdio", - command=stdio.command, - args=stdio.args, - ) - transport = "sse" if kind == "sse" else "http" - url = str(first.get("deploymentUrl") or payload.get("deploymentUrl") or "https://mcp.example") - return McpServerSpec( - name=name, - display_name=display_name, - source=McpSource.marketplace(qualified_name), - transport=transport, # type: ignore[arg-type] - url=url, - ) + package_ref = next((arg for arg in reversed(stdio.args) if not arg.startswith("-")), stdio.command) + server["packages"] = [ + { + "registryType": "npm", + "identifier": package_ref, + "version": "1.0.0", + "transport": {"type": "stdio"}, + } + ] + return server + kind = "streamable-http" + if isinstance(first, dict) and str(first.get("kind") or first.get("type")).lower() == "sse": + kind = "sse" + url = deployment_url or "https://mcp.example" + server["remotes"] = [{"type": kind, "url": url}] + return server def _install(harness: AppTestHarness, name: str = "exa") -> None: - harness.post_json("/api/mcp/servers", {"qualifiedName": name, "sourceHarness": "cursor"}) + harness.post_json("/api/mcp/servers", {"qualifiedName": name}) def _seed_manual_remote(harness: AppTestHarness, name: str = "remote") -> None: @@ -213,30 +165,10 @@ def test_list_servers_starts_empty(self) -> None: self.assertIn("codex", cols) self.assertIn("claude", cols) - def test_marketplace_install_targets_are_backend_owned(self) -> None: - with AppTestHarness() as harness: - _Container(harness, "exa") - payload = harness.get_json("/api/marketplace/mcp/install-targets") - assert isinstance(payload, dict) - targets = {target["harness"]: target for target in payload["targets"]} - - self.assertEqual(targets["codex"]["smitheryClient"], "codex") - self.assertEqual(targets["claude"]["smitheryClient"], "claude-code") - self.assertEqual(targets["cursor"]["smitheryClient"], "cursor") - self.assertEqual(targets["opencode"]["smitheryClient"], "opencode") - self.assertTrue(targets["claude"]["supported"]) - self.assertFalse(targets["openclaw"]["supported"]) - self.assertEqual( - targets["openclaw"]["reason"], - "Smithery does not provide an OpenClaw MCP installer target", - ) - - def test_install_delegates_to_source_harness_then_imports_raw_spec(self) -> None: + def test_install_resolves_registry_config_without_writing_harness(self) -> None: with AppTestHarness() as harness: _Container(harness, "exa") - response = harness.post_json( - "/api/mcp/servers", {"qualifiedName": "exa", "sourceHarness": "cursor"} - ) + response = harness.post_json("/api/mcp/servers", {"qualifiedName": "exa"}) self.assertTrue(response["ok"]) self.assertEqual(response["server"]["name"], "exa") self.assertEqual(response["server"]["transport"], "http") @@ -247,26 +179,179 @@ def test_install_delegates_to_source_harness_then_imports_raw_spec(self) -> None assert isinstance(servers, dict) names = [entry["name"] for entry in servers["entries"]] self.assertIn("exa", names) + entry = next(item for item in servers["entries"] if item["name"] == "exa") + self.assertEqual(entry["enabledStatus"], "disabled") - # The source harness was written by the native installer; others are untouched. - cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) - self.assertEqual(cursor_cfg["mcpServers"]["exa"]["url"], "https://mcp.exa.ai") + detail = harness.get_json("/api/mcp/servers/exa") + self.assertEqual(detail["enabledStatus"], "disabled") + + # Installing from the marketplace only updates Skill Manager's manifest. + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) self.assertFalse((harness.spec.home / ".claude.json").exists()) self.assertFalse((harness.spec.home / ".codex" / "config.toml").exists()) - def test_install_can_import_claude_code_project_scoped_config(self) -> None: + def test_install_without_target_harness_only_updates_manifest(self) -> None: with AppTestHarness() as harness: _Container(harness, "exa") - response = harness.post_json( - "/api/mcp/servers", {"qualifiedName": "exa", "sourceHarness": "claude"} + + response = harness.post_json("/api/mcp/servers", {"qualifiedName": "exa"}) + + self.assertTrue(response["ok"]) + self.assertEqual(response["server"]["name"], "exa") + servers = harness.get_json("/api/mcp/servers") + entry = next(item for item in servers["entries"] if item["name"] == "exa") + self.assertEqual(entry["enabledStatus"], "disabled") + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) + self.assertFalse((harness.spec.home / ".claude.json").exists()) + + def test_install_checks_availability_immediately(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + probe = FakeMcpAvailabilityProbe(status="available") + harness.container.mcp_mutations.availability_probe = probe + + response = harness.post_json("/api/mcp/servers", {"qualifiedName": "exa"}) + + self.assertTrue(response["ok"]) + self.assertEqual(probe.probed, ["exa"]) + detail = harness.get_json("/api/mcp/servers/exa") + self.assertEqual(detail["availabilityStatus"], "available") + self.assertEqual(detail["mcpStatus"]["kind"], "available") + + def test_list_servers_marks_mcp_disabled_when_no_addressable_harness_is_managed(self) -> None: + with AppTestHarness() as harness: + harness.container.mcp_store.upsert_from_spec( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.manual("remote"), + transport="http", + url="https://mcp.example.com", + ) ) + harness.container.mcp_read_models.invalidate() + + servers = harness.get_json("/api/mcp/servers") + entry = next(item for item in servers["entries"] if item["name"] == "remote") + self.assertEqual(entry["enabledStatus"], "disabled") + self.assertEqual(entry["mcpStatus"]["kind"], "unchecked") + + def test_list_servers_ignores_managed_bindings_on_non_addressable_harnesses_for_enabled_status(self) -> None: + with AppTestHarness() as harness: + harness.container.mcp_store.upsert_from_spec( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.manual("remote"), + transport="http", + url="https://mcp.example.com", + ) + ) + openclaw_cfg = harness.spec.home / ".openclaw" / "openclaw.json" + openclaw_cfg.parent.mkdir(parents=True, exist_ok=True) + openclaw_cfg.write_text( + json.dumps({"mcp": {"remote": {"type": "remote", "url": "https://mcp.example.com"}}}), + encoding="utf-8", + ) + harness.container.mcp_read_models.invalidate() + + servers = harness.get_json("/api/mcp/servers") + entry = next(item for item in servers["entries"] if item["name"] == "remote") + self.assertEqual(entry["enabledStatus"], "disabled") + self.assertEqual(entry["mcpStatus"]["kind"], "unchecked") + + def test_availability_check_updates_runtime_status(self) -> None: + with AppTestHarness() as harness: + probe = FakeMcpAvailabilityProbe(status="available") + harness.container.mcp_queries.availability_probe = probe + _seed_manual_remote(harness, name="remote") + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + + detail_before = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail_before["availabilityStatus"], "unavailable") + self.assertEqual(detail_before["mcpStatus"]["kind"], "unchecked") + self.assertIsNone(detail_before["mcpStatus"]["reason"]) + + result = harness.post_json("/api/mcp/servers/remote/availability/check") + self.assertTrue(result["ok"]) + self.assertEqual(result["availabilityStatus"], "available") + self.assertEqual(probe.probed, ["remote"]) + + detail_after = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail_after["availabilityStatus"], "available") + self.assertEqual(detail_after["mcpStatus"]["kind"], "available") + self.assertIsNone(detail_after["mcpStatus"]["reason"]) + + def test_availability_cache_is_scoped_to_spec_revision(self) -> None: + with AppTestHarness() as harness: + probe = FakeMcpAvailabilityProbe(status="available") + harness.container.mcp_queries.availability_probe = probe + _seed_manual_remote(harness, name="remote") + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + + first = harness.post_json("/api/mcp/servers/remote/availability/check") + self.assertEqual(first["availabilityStatus"], "available") + + harness.container.mcp_store.upsert_from_spec( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.manual("remote"), + transport="http", + url="https://changed.example.com", + ) + ) + harness.container.mcp_read_models.invalidate() + harness.post_json( + "/api/mcp/servers/remote/reconcile", + {"sourceKind": "managed", "harnesses": ["cursor"]}, + ) + probe.status = "unavailable" + probe.reason = "changed config failed" + + detail = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail["availabilityStatus"], "unavailable") + self.assertIsNone(detail["availabilityReason"]) + self.assertEqual(detail["mcpStatus"]["kind"], "unchecked") + self.assertIsNone(detail["mcpStatus"]["reason"]) + second = harness.post_json("/api/mcp/servers/remote/availability/check") + self.assertEqual(second["availabilityStatus"], "unavailable") + self.assertEqual(second["availabilityReason"], "changed config failed") + self.assertEqual(probe.probed, ["remote", "remote"]) + detail_after_failure = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail_after_failure["mcpStatus"]["kind"], "connection_issue") + self.assertEqual(detail_after_failure["mcpStatus"]["reason"], "changed config failed") + + def test_availability_check_runs_before_agent_enablement(self) -> None: + with AppTestHarness() as harness: + probe = FakeMcpAvailabilityProbe(status="available") + harness.container.mcp_queries.availability_probe = probe + _seed_manual_remote(harness, name="remote") + + result = harness.post_json("/api/mcp/servers/remote/availability/check") + self.assertTrue(result["ok"]) + self.assertEqual(result["availabilityStatus"], "available") + self.assertIsNone(result["availabilityReason"]) + self.assertEqual(probe.probed, ["remote"]) + + detail_after = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail_after["enabledStatus"], "disabled") + self.assertEqual(detail_after["availabilityStatus"], "available") + self.assertIsNone(detail_after["availabilityReason"]) + self.assertEqual(detail_after["mcpStatus"]["kind"], "available") + self.assertIsNone(detail_after["mcpStatus"]["reason"]) + + def test_enable_writes_claude_code_config(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + response = harness.post_json("/api/mcp/servers", {"qualifiedName": "exa"}) + harness.post_json("/api/mcp/servers/exa/enable", {"harness": "claude"}) self.assertTrue(response["ok"]) self.assertEqual(response["server"]["name"], "exa") self.assertEqual(response["server"]["url"], "https://mcp.exa.ai") claude_cfg = json.loads((harness.spec.home / ".claude.json").read_text()) - self.assertNotIn("mcpServers", claude_cfg) - project = claude_cfg["projects"][str(harness.spec.home.resolve())] + project = claude_cfg self.assertEqual( project["mcpServers"]["exa"]["url"], "https://mcp.exa.ai", @@ -319,8 +404,7 @@ def test_set_harnesses_fan_out(self) -> None: "/api/mcp/servers/exa/set-harnesses", {"target": "enabled"} ) self.assertTrue(response["ok"]) - # All five harnesses should have written - self.assertEqual(set(response["succeeded"]), {"codex", "claude", "opencode", "openclaw"}) + self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw"}) # Verify each config file self.assertTrue((harness.spec.home / ".cursor" / "mcp.json").is_file()) @@ -356,10 +440,33 @@ def test_install_unknown_qualified_name_returns_404(self) -> None: _Container(harness, "exa") harness.post_json( "/api/mcp/servers", - {"qualifiedName": "nonexistent", "sourceHarness": "cursor"}, + {"qualifiedName": "nonexistent"}, expected_status=404, ) + def test_enable_does_not_update_manifest_when_target_harness_write_fails(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + harness.post_json("/api/mcp/servers", {"qualifiedName": "exa"}) + adapter = harness.container.mcp_read_models.find_adapter("cursor") + assert adapter is not None + + def fail_enable(_spec: McpServerSpec) -> None: + raise MutationError("cursor write failed", status=400) + + adapter.enable_server = fail_enable # type: ignore[method-assign] + + harness.post_json( + "/api/mcp/servers/exa/enable", + {"harness": "cursor"}, + expected_status=400, + ) + + self.assertIsNotNone(harness.container.mcp_store.get_managed("exa")) + cursor_config_path = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg = json.loads(cursor_config_path.read_text()) if cursor_config_path.exists() else {} + self.assertNotIn("exa", cursor_cfg.get("mcpServers", {})) + def test_marketplace_schema_metadata_does_not_change_observed_install(self) -> None: schema = { "type": "object", @@ -374,35 +481,405 @@ def test_marketplace_schema_metadata_does_not_change_observed_install(self) -> N } with AppTestHarness() as harness: _Container(harness, "browserbase", schema) - install = harness.post_json( - "/api/mcp/servers", - {"qualifiedName": "browserbase", "sourceHarness": "cursor"}, - ) + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "browserbase"}) self.assertTrue(install["ok"]) self.assertEqual(install["server"]["name"], "browserbase") self.assertEqual(install["server"]["url"], "https://mcp.exa.ai") + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) + + def test_install_defers_required_registry_environment_config(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + probe = FakeMcpAvailabilityProbe(status="unavailable", reason="missing CUEAPI_API_KEY") + harness.container.mcp_mutations.availability_probe = probe + + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + + self.assertTrue(install["ok"]) + self.assertEqual(probe.probed, ["ai-cueapi-mcp"]) + self.assertIsNotNone(harness.container.mcp_store.get_managed("ai-cueapi-mcp")) + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) + detail = harness.get_json("/api/mcp/servers/ai-cueapi-mcp") + self.assertEqual(detail["installConfigStatus"]["hasFields"], True) + self.assertEqual(detail["installConfigStatus"]["missingRequired"], ["CUEAPI_API_KEY"]) + self.assertEqual(detail["installConfigStatus"]["configured"], False) + self.assertEqual(detail["mcpStatus"]["kind"], "needs_config") + servers = harness.get_json("/api/mcp/servers") + entry = next(item for item in servers["entries"] if item["name"] == "ai-cueapi-mcp") + self.assertEqual(entry["installConfigStatus"]["missingRequired"], ["CUEAPI_API_KEY"]) + self.assertEqual(entry["mcpStatus"]["kind"], "needs_config") + + def test_enable_writes_registry_environment_config_to_target_harness(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True}, + {"name": "CUEAPI_BASE_URL", "default": "https://api.cueapi.ai"}, + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/enable", + {"harness": "cursor", "config": {"CUEAPI_API_KEY": "cue-key"}}, + ) + + self.assertNotIn("CUEAPI_API_KEY", install["server"].get("env", {})) cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) self.assertEqual( - cursor_cfg["mcpServers"]["browserbase"]["url"], - "https://mcp.exa.ai", + cursor_cfg["mcpServers"]["ai-cueapi-mcp"]["env"], + { + "CUEAPI_API_KEY": "cue-key", + "CUEAPI_BASE_URL": "https://api.cueapi.ai", + }, ) + detail = harness.get_json("/api/mcp/servers/ai-cueapi-mcp") + self.assertEqual(detail["installConfigStatus"]["missingRequired"], []) + self.assertEqual(detail["installConfigStatus"]["configured"], True) - def test_install_stores_the_observed_source_harness_key(self) -> None: + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/enable", + {"harness": "claude"}, + ) + claude_cfg = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertEqual( + claude_cfg["mcpServers"]["ai-cueapi-mcp"]["env"], + { + "CUEAPI_API_KEY": "cue-key", + "CUEAPI_BASE_URL": "https://api.cueapi.ai", + }, + ) + + def test_registry_install_accepts_required_config_when_enabling_harness(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True}, + {"name": "CUEAPI_BASE_URL", "default": "https://api.cueapi.ai"}, + ], + } + ], + } with AppTestHarness() as harness: - _Container(harness, "@vendor/pkg", source_name="vendor-package") + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) install = harness.post_json( "/api/mcp/servers", - {"qualifiedName": "@vendor/pkg", "sourceHarness": "cursor"}, + {"qualifiedName": "ai.cueapi/mcp"}, ) - self.assertEqual(install["server"]["name"], "vendor-package") + self.assertTrue(install["ok"]) + self.assertNotIn("CUEAPI_API_KEY", install["server"].get("env", {})) + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) + + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/enable", + {"harness": "cursor", "config": {"CUEAPI_API_KEY": "cue-key"}}, + ) + + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertEqual( + cursor_cfg["mcpServers"]["ai-cueapi-mcp"]["env"], + { + "CUEAPI_API_KEY": "cue-key", + "CUEAPI_BASE_URL": "https://api.cueapi.ai", + }, + ) + + def test_registry_install_rejects_enabling_required_config_without_values(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/enable", + {"harness": "cursor"}, + expected_status=400, + ) + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/set-harnesses", + {"target": "enabled"}, + expected_status=400, + ) + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) + + def test_enable_with_config_does_not_update_manifest_when_harness_write_fails(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + adapter = harness.container.mcp_read_models.find_adapter("cursor") + assert adapter is not None + + def fail_enable(_spec: McpServerSpec) -> None: + raise MutationError("cursor write failed", status=400) + + adapter.enable_server = fail_enable # type: ignore[method-assign] + + harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/enable", + {"harness": "cursor", "config": {"CUEAPI_API_KEY": "cue-key"}}, + expected_status=400, + ) + + managed = harness.container.mcp_store.get_managed("ai-cueapi-mcp") + self.assertIsNotNone(managed) + assert managed is not None + self.assertNotIn("CUEAPI_API_KEY", managed.env_dict()) + + def test_enable_all_with_config_updates_manifest_after_partial_success(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + adapter = harness.container.mcp_read_models.find_adapter("cursor") + assert adapter is not None + + def fail_enable(_spec: McpServerSpec) -> None: + raise MutationError("cursor write failed", status=400) + + adapter.enable_server = fail_enable # type: ignore[method-assign] + + response = harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/set-harnesses", + {"target": "enabled", "config": {"CUEAPI_API_KEY": "cue-key"}}, + ) + + self.assertFalse(response["ok"]) + self.assertGreater(len(response["succeeded"]), 0) + managed = harness.container.mcp_store.get_managed("ai-cueapi-mcp") + self.assertIsNotNone(managed) + assert managed is not None + self.assertEqual(managed.env_dict()["CUEAPI_API_KEY"], "cue-key") + + def test_enable_all_with_config_leaves_manifest_when_every_write_fails(self) -> None: + registry_server = { + "name": "ai.cueapi/mcp", + "title": "CueAPI", + "version": "0.1.3", + "description": "Schedule agent work", + "packages": [ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.cueapi/mcp", is_remote=False, registry_server=registry_server) + harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.cueapi/mcp"}) + + def fail_enable(_spec: McpServerSpec) -> None: + raise MutationError("write failed", status=400) + + for adapter in harness.container.mcp_read_models.enabled_writable_adapters(): + adapter.enable_server = fail_enable # type: ignore[method-assign] + + response = harness.post_json( + "/api/mcp/servers/ai-cueapi-mcp/set-harnesses", + {"target": "enabled", "config": {"CUEAPI_API_KEY": "cue-key"}}, + ) + + self.assertFalse(response["ok"]) + self.assertEqual(response["succeeded"], []) + managed = harness.container.mcp_store.get_managed("ai-cueapi-mcp") + self.assertIsNotNone(managed) + assert managed is not None + self.assertNotIn("CUEAPI_API_KEY", managed.env_dict()) + + def test_optional_config_is_preserved_when_enabling_another_harness(self) -> None: + registry_server = { + "name": "ai.optional/mcp", + "title": "Optional MCP", + "version": "0.1.3", + "description": "Optional env", + "packages": [ + { + "registryType": "npm", + "identifier": "@optional/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "OPTIONAL_TOKEN", "isSecret": True} + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.optional/mcp", is_remote=False, registry_server=registry_server) + harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.optional/mcp"}) + + detail_before = harness.get_json("/api/mcp/servers/ai-optional-mcp") + self.assertEqual(detail_before["installConfigStatus"]["hasFields"], True) + self.assertEqual(detail_before["installConfigStatus"]["missingRequired"], []) + self.assertEqual(detail_before["installConfigStatus"]["configured"], True) + + harness.post_json( + "/api/mcp/servers/ai-optional-mcp/enable", + {"harness": "cursor", "config": {"OPTIONAL_TOKEN": "opt-token"}}, + ) + managed = harness.container.mcp_store.get_managed("ai-optional-mcp") + self.assertIsNotNone(managed) + assert managed is not None + self.assertEqual(managed.env_dict()["OPTIONAL_TOKEN"], "opt-token") + + harness.post_json( + "/api/mcp/servers/ai-optional-mcp/enable", + {"harness": "claude"}, + ) + + managed_after = harness.container.mcp_store.get_managed("ai-optional-mcp") + self.assertIsNotNone(managed_after) + assert managed_after is not None + self.assertEqual(managed_after.env_dict()["OPTIONAL_TOKEN"], "opt-token") + claude_cfg = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertEqual( + claude_cfg["mcpServers"]["ai-optional-mcp"]["env"], + {"OPTIONAL_TOKEN": "opt-token"}, + ) + + def test_install_writes_registry_remote_headers_and_url_variables(self) -> None: + registry_server = { + "name": "ai.example/remote", + "title": "Remote", + "version": "1.0.0", + "description": "Remote MCP", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.example.com/{workspace}/mcp", + "variables": {"workspace": {"isRequired": True}}, + "headers": [ + { + "name": "Authorization", + "value": "Bearer {API_TOKEN}", + "variables": {"API_TOKEN": {"isRequired": True, "isSecret": True}}, + } + ], + } + ], + } + with AppTestHarness() as harness: + _Container(harness, "ai.example/remote", registry_server=registry_server) + + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "ai.example/remote"}) + harness.post_json( + "/api/mcp/servers/ai-example-remote/enable", + {"harness": "cursor", "config": {"workspace": "acme", "API_TOKEN": "token-123"}}, + ) + + self.assertEqual(install["server"]["url"], "https://api.example.com/{workspace}/mcp") + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertEqual( + cursor_cfg["mcpServers"]["ai-example-remote"]["headers"], + {"Authorization": "Bearer token-123"}, + ) + + def test_install_stores_the_registry_managed_key(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "@vendor/pkg") + + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "@vendor/pkg"}) + + self.assertEqual(install["server"]["name"], "vendor-pkg") servers = harness.get_json("/api/mcp/servers") assert isinstance(servers, dict) - self.assertIn("vendor-package", [entry["name"] for entry in servers["entries"]]) - cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) - self.assertIn("vendor-package", cursor_cfg["mcpServers"]) + self.assertIn("vendor-pkg", [entry["name"] for entry in servers["entries"]]) + self.assertFalse((harness.spec.home / ".cursor" / "mcp.json").exists()) def test_static_stdio_marketplace_install_can_enable(self) -> None: with AppTestHarness() as harness: @@ -419,16 +896,14 @@ def test_static_stdio_marketplace_install_can_enable(self) -> None: } ], ) - install = harness.post_json( - "/api/mcp/servers", - {"qualifiedName": "desktop", "sourceHarness": "cursor"}, - ) + install = harness.post_json("/api/mcp/servers", {"qualifiedName": "desktop"}) + harness.post_json("/api/mcp/servers/desktop/enable", {"harness": "cursor"}) self.assertEqual(install["server"]["transport"], "stdio") cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) payload = cursor_cfg["mcpServers"]["desktop"] self.assertEqual(payload["command"], "npx") - self.assertEqual(payload["args"], ["-y", "@acme/desktop"]) + self.assertEqual(payload["args"], ["-y", "@acme/desktop@1.0.0"]) def test_get_unknown_server_returns_404(self) -> None: with AppTestHarness() as harness: @@ -478,7 +953,7 @@ def test_unmanaged_by_server_marks_differing_payloads(self) -> None: self.assertFalse(response["servers"][0]["identical"]) self.assertIsNone(response["servers"][0]["canonicalSpec"]) - def test_unmanaged_by_server_returns_raw_preview_fields(self) -> None: + def test_unmanaged_by_server_masks_secret_preview_fields(self) -> None: with AppTestHarness() as harness: cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" cursor_cfg.parent.mkdir(parents=True, exist_ok=True) @@ -503,17 +978,17 @@ def test_unmanaged_by_server_returns_raw_preview_fields(self) -> None: response = harness.get_json("/api/mcp/unmanaged/by-server") assert isinstance(response, dict) encoded = json.dumps(response) - self.assertIn("live_secret_value", encoded) + self.assertNotIn("live_secret_value", encoded) servers = {server["name"]: server for server in response["servers"]} remote = servers["secreted"] - self.assertIn("api_key=live_secret_value", remote["canonicalSpec"]["url"]) + self.assertIn("api_key=%5Bredacted%5D", remote["canonicalSpec"]["url"]) self.assertEqual( remote["sightings"][0]["spec"]["headers"]["Authorization"], - "Bearer live_secret_value", + "[redacted]", ) stdio = servers["secretenv"] - self.assertEqual(stdio["canonicalSpec"]["env"]["EXA_API_KEY"], "live_secret_value") - self.assertEqual(stdio["sightings"][0]["env"][0]["value"], "live_secret_value") + self.assertEqual(stdio["canonicalSpec"]["env"]["EXA_API_KEY"], "[redacted]") + self.assertEqual(stdio["sightings"][0]["env"][0]["value"], "[redacted]") def test_adopt_identical_promotes_all_harnesses_in_one_call(self) -> None: with AppTestHarness() as harness: @@ -534,7 +1009,7 @@ def test_adopt_identical_promotes_all_harnesses_in_one_call(self) -> None: assert isinstance(servers, dict) self.assertIn("context7", [e["name"] for e in servers["entries"]]) - def test_adopt_differing_without_source_harness_returns_409(self) -> None: + def test_adopt_differing_without_observed_harness_returns_409(self) -> None: with AppTestHarness() as harness: cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" cursor_cfg.parent.mkdir(parents=True, exist_ok=True) @@ -551,7 +1026,7 @@ def test_adopt_differing_without_source_harness_returns_409(self) -> None: expected_status=409, ) - def test_adopt_differing_uses_selected_source_harness(self) -> None: + def test_adopt_differing_uses_selected_observed_harness(self) -> None: with AppTestHarness() as harness: cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" cursor_cfg.parent.mkdir(parents=True, exist_ok=True) @@ -565,7 +1040,7 @@ def test_adopt_differing_uses_selected_source_harness(self) -> None: result = harness.post_json( "/api/mcp/unmanaged/adopt", - {"name": "foo", "sourceHarness": "claude"}, + {"name": "foo", "observedHarness": "claude"}, ) assert isinstance(result, dict) self.assertTrue(result["ok"]) @@ -587,7 +1062,7 @@ def test_adopt_silently_enriches_when_marketplace_match_exists(self) -> None: qualified_name="@upstash/context7", display_name="Context7", icon_url="https://icon.example/ctx7.png", - external_url="https://smithery.ai/server/@upstash/context7", + external_url="https://registry.modelcontextprotocol.io/?q=%40upstash%2Fcontext7", description="Docs MCP", is_remote=False, is_verified=True, @@ -695,6 +1170,11 @@ def test_reconcile_managed_overwrites_different_entry_with_managed_config(self) cursor_cfg.write_text( json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) ) + harness.container.mcp_read_models.invalidate() + detail_before = harness.get_json("/api/mcp/servers/remote") + self.assertEqual(detail_before["mcpStatus"]["kind"], "unchecked") + self.assertIsNone(detail_before["mcpStatus"]["reason"]) + result = harness.post_json( "/api/mcp/servers/remote/reconcile", {"sourceKind": "managed", "harnesses": ["cursor"]}, @@ -717,7 +1197,7 @@ def test_reconcile_harness_config_replaces_managed_config_and_applies_to_current ) result = harness.post_json( "/api/mcp/servers/remote/reconcile", - {"sourceKind": "harness", "sourceHarness": "cursor"}, + {"sourceKind": "harness", "observedHarness": "cursor"}, ) assert isinstance(result, dict) self.assertTrue(result["ok"]) @@ -746,7 +1226,7 @@ def test_get_server_includes_env_annotations(self) -> None: detail = harness.get_json("/api/mcp/servers/exa") assert isinstance(detail, dict) env_rows = {row["key"]: row for row in detail["env"]} - self.assertEqual(env_rows["EXA_API_KEY"]["value"], "long-secret-value-xxxx") + self.assertEqual(env_rows["EXA_API_KEY"]["value"], "[redacted]") self.assertFalse(env_rows["EXA_API_KEY"]["isEnvRef"]) diff --git a/tests/integration/test_skills_marketplace_api.py b/tests/integration/test_skills_marketplace_api.py index 52253d2..ecd9f0f 100644 --- a/tests/integration/test_skills_marketplace_api.py +++ b/tests/integration/test_skills_marketplace_api.py @@ -87,7 +87,7 @@ def test_marketplace_popular_uses_https_fixture_when_trusted(self) -> None: self.assertEqual(first["description"], "Switch between supported skill execution modes.") self.assertEqual(first["repoUrl"], "https://github.com/mode-io/skills") self.assertEqual(first["skillsDetailUrl"], f"{fixture.base_url}/mode-io/skills/mode-switch") - self.assertTrue(all(item["repoLabel"] != "smithery.ai" for item in payload["items"])) + self.assertTrue(all(item["repoLabel"] != "unsupported-source.example" for item in payload["items"])) def test_marketplace_search_uses_https_fixture_when_trusted(self) -> None: with MarketplaceFixtureServer() as fixture: @@ -105,7 +105,7 @@ def test_marketplace_search_degrades_when_fixture_search_result_has_no_detail_pa self.assertEqual(payload["items"][0]["name"], "ui-ux-pro-max") self.assertEqual(payload["items"][0]["repoLabel"], "broken-org/ui-ux-pro-max-skill") self.assertEqual(payload["items"][0]["description"], MarketplaceCatalog.DETAIL_MISSING_FALLBACK) - self.assertTrue(all(item["repoLabel"] != "smithery.ai" for item in payload["items"])) + self.assertTrue(all(item["repoLabel"] != "unsupported-source.example" for item in payload["items"])) def test_marketplace_popular_returns_503_when_fixture_is_untrusted(self) -> None: with MarketplaceFixtureServer() as fixture: @@ -159,7 +159,7 @@ def test_marketplace_detail_returns_404_for_unknown_item(self) -> None: def test_marketplace_detail_returns_404_for_filtered_unsupported_source(self) -> None: with MarketplaceFixtureServer() as fixture: with AppTestHarness(marketplace=_fixture_catalog(fixture.env())) as harness: - payload = harness.get_json("/api/marketplace/items/skillssh%3Asmithery.ai%3Aui-ux-pro-max", expected_status=404) + payload = harness.get_json("/api/marketplace/items/skillssh%3Aunsupported-source.example%3Aui-ux-pro-max", expected_status=404) self.assertIn("unknown marketplace item", payload["error"]) diff --git a/tests/support/app_harness.py b/tests/support/app_harness.py index a09d7f8..10e620e 100644 --- a/tests/support/app_harness.py +++ b/tests/support/app_harness.py @@ -10,7 +10,8 @@ from skill_manager.application import build_backend_container from skill_manager.application.cli_marketplace import CliMarketplaceCatalog -from skill_manager.application.mcp.installers import McpInstallProvider +from skill_manager.application.mcp.availability import McpAvailabilityResult +from skill_manager.application.mcp.marketplace import McpMarketplaceCatalog from skill_manager.application.skills.marketplace import MarketplaceCatalog from skill_manager.application.skills.source_fetch import SourceFetchService from skill_manager.runtime.server import serve_in_thread @@ -18,6 +19,30 @@ from .fake_home import FakeHomeSpec, create_fake_home_spec, seed_mixed_fixture +class EmptyMcpMarketplaceCatalog: + def popular_page(self, *, limit: int | None = None, offset: int = 0) -> dict[str, object]: + return {"items": [], "nextOffset": None, "hasMore": False} + + def search_page( + self, + query: str, + *, + limit: int | None = None, + offset: int = 0, + remote: bool | None = None, + verified: bool | None = None, + ) -> dict[str, object]: + return {"items": [], "nextOffset": None, "hasMore": False} + + def detail(self, qualified_name: str) -> dict[str, object] | None: + return None + + +class StaticMcpAvailabilityProbe: + def probe(self, _spec) -> McpAvailabilityResult: + return McpAvailabilityResult("unavailable", "not checked in tests") + + class AppTestHarness(AbstractContextManager["AppTestHarness"]): def __init__( self, @@ -27,10 +52,10 @@ def __init__( seed_openclaw: bool = True, fixture_factory: Callable[[FakeHomeSpec], None] | None = None, marketplace: MarketplaceCatalog | None = None, + mcp_marketplace: McpMarketplaceCatalog | None = None, cli_marketplace: CliMarketplaceCatalog | None = None, env_overrides: dict[str, str] | None = None, source_fetcher: SourceFetchService | None = None, - mcp_install_provider: McpInstallProvider | None = None, ) -> None: self._tempdir = TemporaryDirectory(prefix="skill-manager-tests-") self.spec = create_fake_home_spec(Path(self._tempdir.name), seed_openclaw_state=seed_openclaw) @@ -46,17 +71,19 @@ def __init__( self.container = build_backend_container( active_env, marketplace_catalog=MarketplaceCatalog.from_environment(active_env, warm_on_init=False), + mcp_marketplace_catalog=mcp_marketplace or EmptyMcpMarketplaceCatalog(), # type: ignore[arg-type] cli_marketplace_catalog=cli_marketplace, source_fetcher=source_fetcher, - mcp_install_provider=mcp_install_provider, + mcp_availability_probe=StaticMcpAvailabilityProbe(), # type: ignore[arg-type] ) else: self.container = build_backend_container( active_env, marketplace_catalog=marketplace, + mcp_marketplace_catalog=mcp_marketplace or EmptyMcpMarketplaceCatalog(), # type: ignore[arg-type] cli_marketplace_catalog=cli_marketplace, source_fetcher=source_fetcher, - mcp_install_provider=mcp_install_provider, + mcp_availability_probe=StaticMcpAvailabilityProbe(), # type: ignore[arg-type] ) # Ensure tests exercising a custom catalog use the same read-model root. self.container.skills_read_models.invalidate() diff --git a/tests/support/marketplace_payloads.py b/tests/support/marketplace_payloads.py index 08ad76c..829f29d 100644 --- a/tests/support/marketplace_payloads.py +++ b/tests/support/marketplace_payloads.py @@ -41,7 +41,7 @@ } UNSUPPORTED_SOURCE_SKILL = { - "repo": "smithery.ai", + "repo": "unsupported-source.example", "skillId": "ui-ux-pro-max", "name": "ui-ux-pro-max", "installs": 2048, diff --git a/tests/unit/test_github_repo_metadata.py b/tests/unit/test_github_repo_metadata.py index f11330a..aba1364 100644 --- a/tests/unit/test_github_repo_metadata.py +++ b/tests/unit/test_github_repo_metadata.py @@ -45,7 +45,7 @@ def metadata_fetcher(repo: str) -> GitHubRepoMetadata | None: client = GitHubRepoMetadataClient(metadata_fetcher=metadata_fetcher) - self.assertIsNone(client.metadata_for_repo("smithery.ai")) + self.assertIsNone(client.metadata_for_repo("unsupported-source.example")) self.assertEqual(calls, []) def test_transient_errors_are_propagated(self) -> None: diff --git a/tests/unit/test_marketplace_client.py b/tests/unit/test_marketplace_client.py index 688bb9c..85a3ed8 100644 --- a/tests/unit/test_marketplace_client.py +++ b/tests/unit/test_marketplace_client.py @@ -62,7 +62,7 @@ def test_search_skills_filters_unsupported_sources(self) -> None: client.fetch_json.return_value = { "skills": [ { - "source": "smithery.ai", + "source": "unsupported-source.example", "skillId": "ui-ux-pro-max", "name": "ui-ux-pro-max", "installs": 128, diff --git a/tests/unit/test_mcp_adapters.py b/tests/unit/test_mcp_adapters.py index 2b73b1d..97b24fc 100644 --- a/tests/unit/test_mcp_adapters.py +++ b/tests/unit/test_mcp_adapters.py @@ -296,7 +296,7 @@ def test_has_binding_after_enable(self) -> None: adapter.enable_server(_spec()) self.assertTrue(adapter.has_binding("exa")) - def test_claude_scans_smithery_project_scoped_servers(self) -> None: + def test_claude_scans_unsupported_source_project_scoped_servers(self) -> None: with TemporaryDirectory() as tmp: home = Path(tmp) store = McpServerStore(home / "manifest.json") @@ -306,7 +306,7 @@ def test_claude_scans_smithery_project_scoped_servers(self) -> None: display_name="Exa", source=McpSource.marketplace("exa"), transport="http", - url="https://server.smithery.ai/exa/mcp", + url="https://mcp.unsupported-source.example/exa/mcp", ) ) adapter = _adapter("claude", home=home) @@ -316,7 +316,7 @@ def test_claude_scans_smithery_project_scoped_servers(self) -> None: "projects": { str(home.resolve()): { "mcpServers": { - "exa": {"type": "http", "url": "https://server.smithery.ai/exa/mcp"} + "exa": {"type": "http", "url": "https://mcp.unsupported-source.example/exa/mcp"} } } } @@ -340,7 +340,7 @@ def test_claude_disable_removes_project_scoped_servers(self) -> None: "projects": { str(home.resolve()): { "mcpServers": { - "exa": {"type": "http", "url": "https://server.smithery.ai/exa/mcp"} + "exa": {"type": "http", "url": "https://mcp.unsupported-source.example/exa/mcp"} } } } diff --git a/tests/unit/test_mcp_availability.py b/tests/unit/test_mcp_availability.py new file mode 100644 index 0000000..e593ac4 --- /dev/null +++ b/tests/unit/test_mcp_availability.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +from skill_manager.application.mcp.availability import McpAvailabilityProbe +from skill_manager.application.mcp.store import McpServerSpec, McpSource + + +def _http_spec() -> McpServerSpec: + return McpServerSpec( + name="github", + display_name="GitHub", + source=McpSource.marketplace("github"), + transport="http", + url="https://server.example/github/mcp", + ) + + +class McpAvailabilityProbeTests(unittest.TestCase): + def test_http_probe_marks_server_available_when_tools_list_succeeds(self) -> None: + calls: list[str] = [] + + def post(_url: str, payload: dict[str, object], _headers: dict[str, str]) -> tuple[dict[str, object], dict[str, str]]: + method = str(payload.get("method")) + calls.append(method) + if method == "initialize": + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"protocolVersion": "2025-06-18"}}, { + "Mcp-Session-Id": "session-1" + } + if method == "notifications/initialized": + return {"jsonrpc": "2.0", "result": {}}, {} + if method == "tools/list": + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"tools": []}}, {} + raise AssertionError(f"unexpected method {method}") + + result = McpAvailabilityProbe(http_post=post).probe(_http_spec()) + + self.assertEqual(result.status, "available") + self.assertIsNone(result.reason) + self.assertEqual(calls, ["initialize", "notifications/initialized", "tools/list"]) + + def test_http_probe_sends_configured_headers_with_each_request(self) -> None: + seen_headers: list[dict[str, str]] = [] + spec = McpServerSpec( + name="github", + display_name="GitHub", + source=McpSource.marketplace("github"), + transport="http", + url="https://server.example/github/mcp", + headers=(("Authorization", "Bearer test-token"),), + ) + + def post(_url: str, payload: dict[str, object], headers: dict[str, str]) -> tuple[dict[str, object], dict[str, str]]: + seen_headers.append(dict(headers)) + method = str(payload.get("method")) + if method == "initialize": + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"protocolVersion": "2025-06-18"}}, { + "Mcp-Session-Id": "session-1" + } + if method == "notifications/initialized": + return {"jsonrpc": "2.0", "result": {}}, {} + if method == "tools/list": + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"tools": []}}, {} + raise AssertionError(f"unexpected method {method}") + + result = McpAvailabilityProbe(http_post=post).probe(spec) + + self.assertEqual(result.status, "available") + self.assertEqual([headers["Authorization"] for headers in seen_headers], [ + "Bearer test-token", + "Bearer test-token", + "Bearer test-token", + ]) + self.assertNotIn("Mcp-Session-Id", seen_headers[0]) + self.assertEqual(seen_headers[1]["Mcp-Session-Id"], "session-1") + self.assertEqual(seen_headers[2]["Mcp-Session-Id"], "session-1") + + def test_http_probe_marks_server_unavailable_when_auth_fails(self) -> None: + def post(_url: str, _payload: dict[str, object], _headers: dict[str, str]) -> tuple[dict[str, object], dict[str, str]]: + raise HTTPError(_url, 401, "Unauthorized", {}, None) + + result = McpAvailabilityProbe(http_post=post).probe(_http_spec()) + + self.assertEqual(result.status, "unavailable") + self.assertIn("401", result.reason or "") + + def test_http_probe_retries_transient_failures_before_returning_unavailable(self) -> None: + attempts = 0 + + def post(_url: str, payload: dict[str, object], _headers: dict[str, str]) -> tuple[dict[str, object], dict[str, str]]: + nonlocal attempts + if payload.get("method") == "initialize": + attempts += 1 + if attempts == 1: + raise URLError("connection refused") + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"protocolVersion": "2025-06-18"}}, {} + if payload.get("method") == "notifications/initialized": + return {"jsonrpc": "2.0", "result": {}}, {} + if payload.get("method") == "tools/list": + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"tools": []}}, {} + raise AssertionError(f"unexpected method {payload.get('method')}") + + result = McpAvailabilityProbe(http_post=post, retry_delay_seconds=0).probe(_http_spec()) + + self.assertEqual(result.status, "available") + self.assertEqual(attempts, 2) + + def test_default_http_post_returns_first_sse_data_event_without_reading_to_eof(self) -> None: + class SseResponse: + headers = {"Content-Type": "text/event-stream"} + + def __init__(self) -> None: + self.read_called = False + self._lines = iter( + [ + b"event: message\n", + b'data: {"jsonrpc":"2.0","id":1,"result":{"tools":[]}}\n', + b"\n", + ] + ) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self) -> bytes: + self.read_called = True + raise AssertionError("SSE responses should not be read to EOF") + + def readline(self) -> bytes: + return next(self._lines, b"") + + response = SseResponse() + + with patch("skill_manager.application.mcp.availability.urlopen", return_value=response): + payload, headers = McpAvailabilityProbe()._default_http_post( + "https://server.example/sse", + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + {}, + ) + + self.assertFalse(response.read_called) + self.assertEqual(payload["result"], {"tools": []}) + self.assertEqual(headers["Content-Type"], "text/event-stream") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_enrichment.py b/tests/unit/test_mcp_enrichment.py index eaf1e46..11ad23d 100644 --- a/tests/unit/test_mcp_enrichment.py +++ b/tests/unit/test_mcp_enrichment.py @@ -19,7 +19,9 @@ def test_warm_from_popular_caches_entries(self) -> None: "qualifiedName": "@exa/exa-mcp", "displayName": "Exa", "iconUrl": "https://icon.example/exa.png", - "externalUrl": "https://smithery.ai/server/@exa/exa-mcp", + "externalUrl": "https://registry.modelcontextprotocol.io/?q=%40exa%2Fexa-mcp", + "githubUrl": "https://github.com/exa-labs/exa-mcp-server", + "websiteUrl": "https://exa.ai", "description": "Web search", "isRemote": True, "isVerified": True, @@ -32,6 +34,10 @@ def test_warm_from_popular_caches_entries(self) -> None: assert link is not None self.assertEqual(link.qualified_name, "@exa/exa-mcp") self.assertEqual(link.display_name, "Exa") + self.assertEqual(link.github_url, "https://github.com/exa-labs/exa-mcp-server") + self.assertEqual(link.website_url, "https://exa.ai") + self.assertEqual(link.to_dict()["githubUrl"], "https://github.com/exa-labs/exa-mcp-server") + self.assertEqual(link.to_dict()["websiteUrl"], "https://exa.ai") catalog.popular_page.assert_called_once() def test_cold_miss_triggers_search(self) -> None: @@ -43,7 +49,9 @@ def test_cold_miss_triggers_search(self) -> None: "qualifiedName": "@other/context7", "displayName": "Context7", "iconUrl": None, - "externalUrl": "https://smithery.ai/server/@other/context7", + "externalUrl": "https://registry.modelcontextprotocol.io/?q=%40other%2Fcontext7", + "githubUrl": "https://github.com/upstash/context7", + "websiteUrl": "https://context7.com", "description": "", "isRemote": False, "isVerified": True, @@ -55,6 +63,8 @@ def test_cold_miss_triggers_search(self) -> None: self.assertIsNotNone(link) assert link is not None self.assertEqual(link.qualified_name, "@other/context7") + self.assertEqual(link.github_url, "https://github.com/upstash/context7") + self.assertEqual(link.website_url, "https://context7.com") catalog.search_page.assert_called_once_with("context7", limit=10, offset=0, verified=True) def test_cache_prevents_double_search(self) -> None: diff --git a/tests/unit/test_mcp_install_resolver.py b/tests/unit/test_mcp_install_resolver.py new file mode 100644 index 0000000..9ef2cbd --- /dev/null +++ b/tests/unit/test_mcp_install_resolver.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.mcp.install_resolver import ( + registry_install_config, + registry_managed_name, + resolve_registry_server_spec, +) +from skill_manager.errors import MutationError + + +def _detail( + *, + name: str = "ai.adeu/adeu", + title: str = "ADEU", + version: str = "1.7.1", + packages: list[dict[str, object]] | None = None, + remotes: list[dict[str, object]] | None = None, +) -> dict[str, object]: + server: dict[str, object] = { + "name": name, + "title": title, + "version": version, + "description": "description", + } + if packages is not None: + server["packages"] = packages + if remotes is not None: + server["remotes"] = remotes + return { + "qualifiedName": name, + "displayName": title, + "registryServer": server, + } + + +class RegistryManagedNameTests(unittest.TestCase): + def test_uses_full_registry_name_to_avoid_mcp_collisions(self) -> None: + self.assertEqual(registry_managed_name("ac.inference.sh/mcp"), "ac-inference-sh-mcp") + self.assertEqual(registry_managed_name("ai.adeu/adeu"), "ai-adeu-adeu") + self.assertEqual(registry_managed_name("@scope/pkg"), "scope-pkg") + + +class RegistryInstallResolverTests(unittest.TestCase): + def test_npm_stdio_package_maps_to_npx_command(self) -> None: + spec = resolve_registry_server_spec( + _detail( + packages=[ + { + "registryType": "npm", + "identifier": "@adeu/mcp-server", + "version": "1.7.1", + "transport": {"type": "stdio"}, + } + ] + ) + ) + + self.assertEqual(spec.name, "ai-adeu-adeu") + self.assertEqual(spec.display_name, "ADEU") + self.assertEqual(spec.source.locator, "ai.adeu/adeu") + self.assertEqual(spec.transport, "stdio") + self.assertEqual(spec.command, "npx") + self.assertEqual(spec.args, ("-y", "@adeu/mcp-server@1.7.1")) + + def test_package_environment_variables_generate_config_fields(self) -> None: + install_config = registry_install_config( + _detail( + name="ai.cueapi/mcp", + packages=[ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + { + "name": "CUEAPI_API_KEY", + "description": "CueAPI API key. Generate at https://cueapi.ai", + "isRequired": True, + "format": "string", + "isSecret": True, + }, + { + "name": "CUEAPI_BASE_URL", + "description": "Override the CueAPI base URL", + "format": "string", + "default": "https://api.cueapi.ai", + }, + ], + } + ], + ) + ) + + self.assertTrue(install_config.required) + self.assertEqual([field.name for field in install_config.fields], ["CUEAPI_API_KEY", "CUEAPI_BASE_URL"]) + self.assertTrue(install_config.fields[0].required) + self.assertTrue(install_config.fields[0].secret) + self.assertEqual(install_config.fields[0].target, "env") + + def test_package_environment_config_is_written_to_spec_env(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.cueapi/mcp", + packages=[ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True}, + {"name": "CUEAPI_BASE_URL", "default": "https://api.cueapi.ai"}, + ], + } + ], + ), + config={"CUEAPI_API_KEY": "cue-key"}, + ) + + self.assertEqual( + spec.env, + (("CUEAPI_API_KEY", "cue-key"), ("CUEAPI_BASE_URL", "https://api.cueapi.ai")), + ) + + def test_required_install_config_missing_returns_400(self) -> None: + with self.assertRaises(MutationError) as captured: + resolve_registry_server_spec( + _detail( + name="ai.cueapi/mcp", + packages=[ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [{"name": "CUEAPI_API_KEY", "isRequired": True}], + } + ], + ), + ) + + self.assertEqual(captured.exception.status, 400) + self.assertIn("missing required install config", str(captured.exception)) + + def test_pypi_stdio_package_maps_to_uvx_command(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.example/python", + version="2.0.0", + packages=[ + { + "registryType": "pypi", + "identifier": "python-mcp", + "version": "2.0.0", + "transport": {"type": "stdio"}, + } + ], + ) + ) + + self.assertEqual(spec.command, "uvx") + self.assertEqual(spec.args, ("python-mcp==2.0.0",)) + + def test_oci_stdio_package_maps_to_docker_command_and_appends_version(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.example/docker", + version="3.0.0", + packages=[ + { + "registryType": "oci", + "identifier": "ghcr.io/acme/mcp-server", + "version": "3.0.0", + "transport": {"type": "stdio"}, + } + ], + ) + ) + + self.assertEqual(spec.command, "docker") + self.assertEqual(spec.args, ("run", "--rm", "-i", "ghcr.io/acme/mcp-server:3.0.0")) + + def test_streamable_http_remote_maps_to_http_without_headers(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.example/remote", + packages=[], + remotes=[ + { + "type": "streamable-http", + "url": "https://api.example.com/mcp", + } + ], + ) + ) + + self.assertEqual(spec.transport, "http") + self.assertEqual(spec.url, "https://api.example.com/mcp") + self.assertIsNone(spec.headers) + + def test_remote_headers_and_url_variables_are_applied(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.example/remote", + packages=[], + remotes=[ + { + "type": "streamable-http", + "url": "https://api.example.com/{workspace}/mcp", + "variables": { + "workspace": {"description": "Workspace slug", "isRequired": True} + }, + "headers": [ + { + "name": "Authorization", + "value": "Bearer {API_TOKEN}", + "isRequired": True, + "isSecret": True, + "variables": { + "API_TOKEN": {"description": "API token", "isRequired": True, "isSecret": True} + }, + } + ], + } + ], + ), + config={"workspace": "acme", "API_TOKEN": "token-123"}, + ) + + self.assertEqual(spec.transport, "http") + self.assertEqual(spec.url, "https://api.example.com/acme/mcp") + self.assertEqual(spec.headers, (("Authorization", "Bearer token-123"),)) + + def test_sse_remote_maps_to_sse(self) -> None: + spec = resolve_registry_server_spec( + _detail( + name="ai.example/sse", + packages=[], + remotes=[{"type": "sse", "url": "https://api.example.com/sse"}], + ) + ) + + self.assertEqual(spec.transport, "sse") + self.assertEqual(spec.url, "https://api.example.com/sse") + + def test_local_packages_are_preferred_over_remotes(self) -> None: + spec = resolve_registry_server_spec( + _detail( + packages=[ + { + "registryType": "npm", + "identifier": "@adeu/mcp-server", + "version": "1.7.1", + "transport": {"type": "stdio"}, + } + ], + remotes=[{"type": "streamable-http", "url": "https://api.example.com/mcp"}], + ) + ) + + self.assertEqual(spec.transport, "stdio") + self.assertEqual(spec.command, "npx") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_installers.py b/tests/unit/test_mcp_installers.py deleted file mode 100644 index 3aa47f6..0000000 --- a/tests/unit/test_mcp_installers.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import json -import unittest -from pathlib import Path - -from skill_manager.application.mcp.installers import SmitheryCliInstallProvider -from skill_manager.errors import MutationError - - -class SmitheryCliInstallProviderTests(unittest.TestCase): - def test_install_targets_include_all_observable_smithery_clients(self) -> None: - provider = SmitheryCliInstallProvider() - - targets = {target.harness: target for target in provider.install_targets()} - - self.assertEqual(targets["codex"].smithery_client, "codex") - self.assertEqual(targets["claude"].smithery_client, "claude-code") - self.assertEqual(targets["cursor"].smithery_client, "cursor") - self.assertEqual(targets["opencode"].smithery_client, "opencode") - self.assertTrue(targets["claude"].supported) - self.assertFalse(targets["openclaw"].supported) - self.assertEqual( - targets["openclaw"].reason, - "Smithery does not provide an OpenClaw MCP installer target", - ) - - def test_unsupported_target_fails_before_running_cli(self) -> None: - calls: list[list[str]] = [] - - def runner(command, **_kwargs): # noqa: ANN001 - calls.append(command) - raise AssertionError("runner should not be called") - - provider = SmitheryCliInstallProvider(runner=runner) - - with self.assertRaises(MutationError): - provider.install(qualified_name="exa", source_harness="openclaw") - self.assertEqual(calls, []) - - def test_install_runs_smithery_cli_noninteractively_with_analytics_opt_out(self) -> None: - calls: list[dict[str, object]] = [] - - def runner(command, **kwargs): # noqa: ANN001 - env = kwargs["env"] - settings_path = Path(env["SMITHERY_CONFIG_PATH"]) / "settings.json" - calls.append( - { - "command": command, - "input": kwargs["input"], - "env": env, - "settings": json.loads(settings_path.read_text(encoding="utf-8")), - } - ) - - class Result: - returncode = 0 - stdout = "" - stderr = "" - - return Result() - - provider = SmitheryCliInstallProvider(runner=runner) - - result = provider.install(qualified_name="exa", source_harness="codex") - - self.assertEqual(result.installer, "smithery") - self.assertEqual(calls[0]["command"], [ - "npx", - "-y", - "@smithery/cli@4.11.1", - "mcp", - "add", - "exa", - "--client", - "codex", - "--config", - "{}", - ]) - self.assertEqual(calls[0]["input"], "") - env = calls[0]["env"] - self.assertEqual(env["NO_COLOR"], "1") - self.assertIn("SMITHERY_CONFIG_PATH", env) - settings = calls[0]["settings"] - self.assertTrue(settings["userId"].startswith("skill-manager-")) - self.assertFalse(settings["analyticsConsent"]) - self.assertTrue(settings["askedConsent"]) - self.assertEqual(settings["cache"], {"servers": {}}) - - def test_install_failure_summarizes_cleaned_smithery_output(self) -> None: - def runner(_command, **_kwargs): # noqa: ANN001 - class Result: - returncode = 1 - stdout = "\x1b[31mfirst line\x1b[0m\nstdout failure" - stderr = "stderr detail\nfinal smithery failure" - - return Result() - - provider = SmitheryCliInstallProvider(runner=runner) - - with self.assertRaises(MutationError) as captured: - provider.install(qualified_name="exa", source_harness="codex") - - self.assertEqual(str(captured.exception), "stdout failure") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_mcp_registry_catalog.py b/tests/unit/test_mcp_registry_catalog.py new file mode 100644 index 0000000..27a2b4d --- /dev/null +++ b/tests/unit/test_mcp_registry_catalog.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import unittest +from unittest import mock +from tempfile import TemporaryDirectory +from pathlib import Path + +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.application.mcp.marketplace.catalog import ( + McpMarketplaceCatalog, + _flatten_input_schema, +) +from skill_manager.errors import MarketplaceUpstreamError + + +_OFFICIAL_META = "io.modelcontextprotocol.registry/official" + + +def _entry( + name: str, + version: str, + *, + latest: bool = True, + status: str = "active", + title: str | None = None, + description: str = "Server description", + packages: list[dict[str, object]] | None = None, + remotes: list[dict[str, object]] | None = None, + website_url: str | None = None, + repository_url: str | None = None, +) -> dict[str, object]: + server: dict[str, object] = { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": name, + "version": version, + "description": description, + } + if title is not None: + server["title"] = title + if packages is not None: + server["packages"] = packages + if remotes is not None: + server["remotes"] = remotes + if website_url is not None: + server["websiteUrl"] = website_url + if repository_url is not None: + server["repository"] = {"url": repository_url, "source": "github"} + return { + "server": server, + "_meta": { + _OFFICIAL_META: { + "status": status, + "publishedAt": "2026-05-01T00:00:00Z", + "updatedAt": "2026-05-02T00:00:00Z", + "isLatest": latest, + } + }, + } + + +_NPM_PACKAGE = { + "registryType": "npm", + "identifier": "@adeu/mcp-server", + "version": "1.7.1", + "transport": {"type": "stdio"}, +} + +_PYPI_PACKAGE = { + "registryType": "pypi", + "identifier": "adeu", + "version": "1.5.2", + "transport": {"type": "stdio"}, +} + +_HTTP_REMOTE = {"type": "streamable-http", "url": "https://example.com/mcp"} + + +class FlattenInputSchemaTests(unittest.TestCase): + def test_flattens_properties_and_required(self) -> None: + params = _flatten_input_schema( + { + "type": "object", + "properties": { + "query": {"type": "string", "description": "text"}, + "numResults": {"type": "number", "minimum": 1, "maximum": 100, "default": 10}, + }, + "required": ["query"], + } + ) + + self.assertEqual(len(params), 2) + by_name = {param["name"]: param for param in params} + self.assertEqual(by_name["query"]["type"], "string") + self.assertTrue(by_name["query"]["required"]) + self.assertEqual(by_name["numResults"]["type"], "number") + self.assertFalse(by_name["numResults"]["required"]) + self.assertEqual(by_name["numResults"]["minimum"], 1) + self.assertEqual(by_name["numResults"]["maximum"], 100) + self.assertEqual(by_name["numResults"]["default"], 10) + + +class McpRegistryCatalogTests(unittest.TestCase): + def test_popular_page_returns_latest_active_supported_items(self) -> None: + response = { + "servers": [ + _entry("ai.adeu/adeu", "1.5.2", latest=False, packages=[_PYPI_PACKAGE]), + _entry( + "ai.adeu/adeu", + "1.7.1", + title="ADEU", + packages=[_NPM_PACKAGE], + website_url="https://adeu.ai", + repository_url="https://github.com/adeu/adeu-mcp", + ), + _entry("old.example/mcp", "1.0.0", status="deleted", remotes=[_HTTP_REMOTE]), + _entry( + "bad.example/mcp", + "1.0.0", + remotes=[{"type": "websocket", "url": "https://unsupported.example/bad/mcp"}], + ), + _entry("remote.example/mcp", "1.0.0", remotes=[_HTTP_REMOTE]), + ], + "metadata": {"count": 5}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + page = catalog.popular_page(limit=30, offset=0) + + self.assertEqual([item["qualifiedName"] for item in page["items"]], ["ai.adeu/adeu", "remote.example/mcp"]) + self.assertFalse(page["items"][0]["isRemote"]) + self.assertTrue(page["items"][0]["isVerified"]) + self.assertEqual(page["items"][0]["displayName"], "ADEU") + self.assertEqual( + page["items"][0]["externalUrl"], + "https://registry.modelcontextprotocol.io/?q=ai.adeu%2Fadeu", + ) + self.assertEqual(page["items"][0]["githubUrl"], "https://github.com/adeu/adeu-mcp") + self.assertEqual(page["items"][0]["websiteUrl"], "https://adeu.ai") + self.assertTrue(page["items"][1]["isRemote"]) + + def test_popular_page_reports_more_items_from_same_registry_page(self) -> None: + response = { + "servers": [ + _entry("first/mcp", "1.0.0", packages=[_NPM_PACKAGE]), + _entry("second/mcp", "1.0.0", packages=[_NPM_PACKAGE]), + _entry("third/mcp", "1.0.0", packages=[_NPM_PACKAGE]), + ], + "metadata": {"count": 3}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + page = catalog.popular_page(limit=2, offset=0) + + self.assertEqual([item["qualifiedName"] for item in page["items"]], ["first/mcp", "second/mcp"]) + self.assertEqual(page["nextOffset"], 2) + self.assertTrue(page["hasMore"]) + + def test_popular_page_defaults_to_twenty_items_like_skills_marketplace(self) -> None: + response = { + "servers": [ + _entry(f"server/{index}", "1.0.0", packages=[_NPM_PACKAGE]) + for index in range(25) + ], + "metadata": {"count": 25}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + page = catalog.popular_page() + + self.assertEqual(len(page["items"]), 20) + self.assertEqual(page["nextOffset"], 20) + self.assertTrue(page["hasMore"]) + + def test_popular_page_caps_limit_to_sixty_like_skills_marketplace(self) -> None: + response = { + "servers": [ + _entry(f"server/{index}", "1.0.0", packages=[_NPM_PACKAGE]) + for index in range(61) + ], + "metadata": {"count": 61}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + page = catalog.popular_page(limit=100) + + self.assertEqual(len(page["items"]), 60) + self.assertEqual(page["nextOffset"], 60) + self.assertTrue(page["hasMore"]) + + def test_popular_page_uses_github_owner_avatar_when_registry_icon_is_missing(self) -> None: + response = { + "servers": [ + _entry( + "github/mcp", + "1.0.0", + packages=[_NPM_PACKAGE], + repository_url="https://github.com/modelcontextprotocol/servers", + ), + ], + "metadata": {"count": 1}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + page = catalog.popular_page(limit=30, offset=0) + + self.assertEqual( + page["items"][0]["iconUrl"], + "https://github.com/modelcontextprotocol.png?size=96", + ) + + def test_search_filters_locally_by_text_and_remote_flag(self) -> None: + response = { + "servers": [ + _entry("ai.adeu/adeu", "1.7.1", title="ADEU", packages=[_NPM_PACKAGE]), + _entry("remote.example/mcp", "1.0.0", title="Remote Search", remotes=[_HTTP_REMOTE]), + ], + "metadata": {"count": 2}, + } + catalog = McpMarketplaceCatalog(fetcher=lambda _path: response, cache=MarketplaceCache()) + + local = catalog.search_page("adeu", limit=30, offset=0, remote=False) + remote = catalog.search_page("search", limit=30, offset=0, remote=True) + + self.assertEqual([item["qualifiedName"] for item in local["items"]], ["ai.adeu/adeu"]) + self.assertEqual([item["qualifiedName"] for item in remote["items"]], ["remote.example/mcp"]) + + def test_search_uses_registry_search_parameter(self) -> None: + response = { + "servers": [ + _entry("ai.i18nagent/i18n-agent", "1.0.0", title="i18n-agent", packages=[_NPM_PACKAGE]), + ], + "metadata": {}, + } + fetcher = mock.Mock(return_value=response) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + page = catalog.search_page("i18", limit=20, offset=0) + + self.assertEqual([item["qualifiedName"] for item in page["items"]], ["ai.i18nagent/i18n-agent"]) + self.assertEqual(fetcher.call_args.args[0], "/v0.1/servers?limit=60&search=i18") + + def test_search_uses_skills_marketplace_fetch_floor_before_stopping(self) -> None: + first_page = { + "servers": [ + _entry(f"github.example/{index}", "1.0.0", title=f"GitHub {index}", packages=[_NPM_PACKAGE]) + for index in range(21) + ], + "metadata": {"nextCursor": "next"}, + } + second_page = { + "servers": [ + _entry(f"github.more/{index}", "1.0.0", title=f"GitHub more {index}", packages=[_NPM_PACKAGE]) + for index in range(20) + ], + "metadata": {}, + } + fetcher = mock.Mock(side_effect=[first_page, second_page]) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + page = catalog.search_page("github", limit=20, offset=0) + + self.assertEqual(len(page["items"]), 20) + self.assertEqual(page["nextOffset"], 20) + self.assertTrue(page["hasMore"]) + self.assertEqual(fetcher.call_count, 2) + + def test_search_reuses_in_memory_snapshot_for_same_query_and_filter(self) -> None: + response = { + "servers": [ + _entry(f"github.example/{index}", "1.0.0", title=f"GitHub {index}", packages=[_NPM_PACKAGE]) + for index in range(45) + ], + "metadata": {"count": 45}, + } + fetcher = mock.Mock(return_value=response) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + first = catalog.search_page("github", limit=20, offset=0) + second = catalog.search_page("github", limit=20, offset=0) + + self.assertEqual(first, second) + self.assertEqual(fetcher.call_count, 1) + + def test_search_refetches_when_page_cache_is_corrupt(self) -> None: + response = { + "servers": [ + _entry("ai.adeu/adeu", "1.7.1", title="ADEU", packages=[_NPM_PACKAGE]), + ], + "metadata": {"count": 1}, + } + fetcher = mock.Mock(return_value=response) + with TemporaryDirectory() as temp_dir: + cache = MarketplaceCache(root=Path(temp_dir)) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=cache) + catalog.popular_page(limit=30, offset=0) + page_cache_file = next((Path(temp_dir) / "mcp-registry-page-v1").glob("*.json")) + page_cache_file.write_text('{"payload": {}}\n{"payload": {}}', encoding="utf-8") + + page = catalog.search_page("adeu", limit=30, offset=0) + + self.assertEqual([item["qualifiedName"] for item in page["items"]], ["ai.adeu/adeu"]) + self.assertEqual(fetcher.call_count, 2) + + def test_detail_uses_latest_version_and_maps_connections(self) -> None: + calls: list[str] = [] + + def fetcher(path: str) -> dict[str, object]: + calls.append(path) + if path == "/v0.1/servers/ac.inference.sh%2Fmcp/versions": + return { + "servers": [ + _entry( + "ac.inference.sh/mcp", + "1.0.1", + title="inference.sh", + remotes=[_HTTP_REMOTE], + website_url="https://inference.sh", + repository_url="git@github.com:acme/inference-mcp.git", + ), + _entry("ac.inference.sh/mcp", "1.0.0", latest=False, remotes=[_HTTP_REMOTE]), + ], + "metadata": {"count": 2}, + } + if path == "/v0.1/servers/ac.inference.sh%2Fmcp/versions/1.0.1": + return _entry( + "ac.inference.sh/mcp", + "1.0.1", + title="inference.sh", + remotes=[_HTTP_REMOTE], + website_url="https://inference.sh", + repository_url="git@github.com:acme/inference-mcp.git", + ) + raise AssertionError(path) + + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + detail = catalog.detail("ac.inference.sh/mcp") + + assert detail is not None + self.assertEqual(calls, [ + "/v0.1/servers/ac.inference.sh%2Fmcp/versions", + "/v0.1/servers/ac.inference.sh%2Fmcp/versions/1.0.1", + ]) + self.assertEqual(detail["qualifiedName"], "ac.inference.sh/mcp") + self.assertEqual(detail["managedName"], "ac-inference-sh-mcp") + self.assertTrue(detail["isRemote"]) + self.assertEqual(detail["connections"][0]["kind"], "http") + self.assertEqual(detail["connections"][0]["deploymentUrl"], "https://example.com/mcp") + self.assertEqual( + detail["externalUrl"], + "https://registry.modelcontextprotocol.io/?q=ac.inference.sh%2Fmcp", + ) + self.assertEqual(detail["githubUrl"], "https://github.com/acme/inference-mcp") + self.assertEqual(detail["websiteUrl"], "https://inference.sh") + self.assertNotIn("registryServer", detail) + + install_detail = catalog.install_detail("ac.inference.sh/mcp") + assert install_detail is not None + self.assertEqual(install_detail.qualified_name, "ac.inference.sh/mcp") + self.assertEqual(install_detail.display_name, "inference.sh") + self.assertEqual(install_detail.registry_server["name"], "ac.inference.sh/mcp") + self.assertEqual(install_detail.to_resolver_detail()["registryServer"]["name"], "ac.inference.sh/mcp") + + def test_detail_exposes_install_config_fields(self) -> None: + def fetcher(path: str) -> dict[str, object]: + if path == "/v0.1/servers/ai.cueapi%2Fmcp/versions": + return { + "servers": [ + _entry( + "ai.cueapi/mcp", + "0.1.3", + packages=[ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + ) + ], + "metadata": {"count": 1}, + } + if path == "/v0.1/servers/ai.cueapi%2Fmcp/versions/0.1.3": + return _entry( + "ai.cueapi/mcp", + "0.1.3", + packages=[ + { + "registryType": "npm", + "identifier": "@cueapi/mcp", + "version": "0.1.3", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "CUEAPI_API_KEY", "isRequired": True, "isSecret": True} + ], + } + ], + ) + raise AssertionError(path) + + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + detail = catalog.detail("ai.cueapi/mcp") + + assert detail is not None + self.assertTrue(detail["installConfig"]["required"]) + self.assertEqual(detail["installConfig"]["fields"][0]["name"], "CUEAPI_API_KEY") + self.assertTrue(detail["installConfig"]["fields"][0]["secret"]) + + def test_detail_returns_none_on_404(self) -> None: + def fetcher(_path: str) -> dict[str, object]: + raise MarketplaceUpstreamError("bad_status", "u", "x", upstream_status=404) + + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + self.assertIsNone(catalog.detail("missing")) + + def test_detail_caches_within_ttl(self) -> None: + fetcher = mock.Mock( + side_effect=[ + {"servers": [_entry("ai.adeu/adeu", "1.7.1", packages=[_NPM_PACKAGE])], "metadata": {"count": 1}}, + _entry("ai.adeu/adeu", "1.7.1", packages=[_NPM_PACKAGE]), + ] + ) + with TemporaryDirectory() as temp_dir: + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache(root=Path(temp_dir))) + + first = catalog.detail("ai.adeu/adeu") + second = catalog.detail("ai.adeu/adeu") + + self.assertEqual(first, second) + self.assertEqual(fetcher.call_count, 2) + + def test_detail_normalizes_legacy_cached_api_external_url(self) -> None: + fetcher = mock.Mock() + with TemporaryDirectory() as temp_dir: + cache = MarketplaceCache(root=Path(temp_dir)) + cache.write( + "mcp-registry-detail-v1", + "ac.inference.sh/mcp", + { + "qualifiedName": "ac.inference.sh/mcp", + "externalUrl": ( + "https://registry.modelcontextprotocol.io/v0.1/servers/" + "ac.inference.sh%2Fmcp/versions/1.0.1" + ), + "registryServer": {"name": "ac.inference.sh/mcp"}, + }, + ) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=cache) + + detail = catalog.detail("ac.inference.sh/mcp") + + assert detail is not None + self.assertEqual( + detail["externalUrl"], + "https://registry.modelcontextprotocol.io/?q=ac.inference.sh%2Fmcp", + ) + self.assertNotIn("registryServer", detail) + fetcher.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_smithery_catalog.py b/tests/unit/test_smithery_catalog.py deleted file mode 100644 index 4ae08ce..0000000 --- a/tests/unit/test_smithery_catalog.py +++ /dev/null @@ -1,361 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest import mock - -from skill_manager.application.marketplace_cache import MarketplaceCache -from skill_manager.application.mcp.marketplace.catalog import ( - McpMarketplaceCatalog, - _flatten_input_schema, - _map_detail, - _map_summary, -) -from skill_manager.errors import MarketplaceUpstreamError - - -_EXA_DETAIL_SAMPLE: dict[str, object] = { - "qualifiedName": "exa", - "displayName": "Exa Search", - "description": "Fast, intelligent web search and web crawling.", - "iconUrl": "https://api.smithery.ai/servers/exa/icon", - "remote": True, - "deploymentUrl": "https://exa.run.tools", - "connections": [ - { - "type": "http", - "deploymentUrl": "https://exa.run.tools", - "configSchema": {}, - } - ], - "security": None, - "tools": [ - { - "name": "web_search_exa", - "description": "Search the web for any topic.", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Natural language search query.", - }, - "numResults": { - "type": "number", - "description": "Number of search results to return.", - "minimum": 1, - "maximum": 100, - "default": 10, - }, - }, - "required": ["query"], - }, - } - ], - "resources": [ - { - "name": "tools_list", - "uri": "exa://tools/list", - "description": "List of available tools", - "mimeType": "application/json", - } - ], - "prompts": [ - { - "name": "web_search_help", - "description": "Get help with web search.", - "arguments": [], - } - ], -} - - -_DESKTOP_DETAIL_SAMPLE: dict[str, object] = { - "qualifiedName": "wonderwhy-er/desktop-commander", - "displayName": "Desktop Commander", - "description": "Execute terminal commands.", - "iconUrl": "https://icons.duckduckgo.com/ip3/desktopcommander.app.ico", - "remote": False, - "deploymentUrl": None, - "connections": [ - { - "type": "stdio", - "stdioFunction": "(config) => ({ command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander'] })", - "configSchema": {"type": "object", "properties": {}}, - } - ], - "security": None, - "tools": [ - { - "name": "read_file", - "description": "Read a file.", - "inputSchema": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "isUrl": {"type": "boolean", "default": False}, - }, - "required": ["path"], - }, - } - ], - "resources": [], - "prompts": [], -} - - -_SSE_DETAIL_SAMPLE: dict[str, object] = { - "qualifiedName": "@acme/stream-server", - "displayName": "Stream Server", - "description": "Remote SSE MCP server.", - "iconUrl": None, - "remote": True, - "deploymentUrl": "https://stream.example/mcp", - "connections": [ - { - "type": "sse", - "deploymentUrl": "https://stream.example/mcp", - "configSchema": None, - } - ], - "security": None, - "tools": [], - "resources": [], - "prompts": [], -} - - -_LIST_RESPONSE_SAMPLE: dict[str, object] = { - "servers": [ - { - "id": "uuid-1", - "qualifiedName": "exa", - "namespace": "exa", - "slug": "", - "displayName": "Exa Search", - "description": "Fast search.", - "iconUrl": "https://api.smithery.ai/servers/exa/icon", - "verified": True, - "useCount": 59906, - "remote": True, - "isDeployed": True, - "createdAt": "2024-12-13T15:46:50.750Z", - "homepage": "https://exa.ai", - "owner": "org_1", - "score": None, - }, - { - "id": "uuid-2", - "qualifiedName": "wonderwhy-er/desktop-commander", - "namespace": "wonderwhy-er", - "slug": "desktop-commander", - "displayName": "Desktop Commander", - "description": "Local terminal control.", - "iconUrl": None, - "verified": False, - "useCount": 728, - "remote": False, - "isDeployed": False, - "createdAt": "2025-02-01T00:00:00.000Z", - "homepage": None, - "owner": "org_2", - "score": None, - }, - ], - "pagination": { - "currentPage": 1, - "pageSize": 30, - "totalPages": 161, - "totalCount": 4814, - }, -} - - -class FlattenInputSchemaTests(unittest.TestCase): - def test_flattens_properties_and_required(self) -> None: - params = _flatten_input_schema( - { - "type": "object", - "properties": { - "query": {"type": "string", "description": "text"}, - "numResults": {"type": "number", "minimum": 1, "maximum": 100, "default": 10}, - }, - "required": ["query"], - } - ) - self.assertEqual(len(params), 2) - by_name = {param["name"]: param for param in params} - self.assertEqual(by_name["query"]["type"], "string") - self.assertTrue(by_name["query"]["required"]) - self.assertEqual(by_name["numResults"]["type"], "number") - self.assertFalse(by_name["numResults"]["required"]) - self.assertEqual(by_name["numResults"]["minimum"], 1) - self.assertEqual(by_name["numResults"]["maximum"], 100) - self.assertEqual(by_name["numResults"]["default"], 10) - - def test_missing_type_becomes_unknown(self) -> None: - params = _flatten_input_schema({"properties": {"odd": {"description": "no type"}}}) - self.assertEqual(len(params), 1) - self.assertEqual(params[0]["type"], "unknown") - - def test_type_as_array_picks_first_valid(self) -> None: - params = _flatten_input_schema( - {"properties": {"maybe": {"type": ["null", "string"]}}} - ) - self.assertEqual(params[0]["type"], "string") - - def test_empty_schema_returns_empty_list(self) -> None: - self.assertEqual(_flatten_input_schema(None), []) - self.assertEqual(_flatten_input_schema({"type": "object"}), []) - self.assertEqual(_flatten_input_schema({"type": "object", "properties": {}}), []) - - -class MapSummaryTests(unittest.TestCase): - def test_remote_verified_entry(self) -> None: - summary = _map_summary(_LIST_RESPONSE_SAMPLE["servers"][0]) - self.assertEqual(summary["qualifiedName"], "exa") - self.assertTrue(summary["isVerified"]) - self.assertTrue(summary["isRemote"]) - self.assertEqual(summary["useCount"], 59906) - self.assertEqual(summary["externalUrl"], "https://smithery.ai/server/exa") - - def test_local_unverified_entry(self) -> None: - summary = _map_summary(_LIST_RESPONSE_SAMPLE["servers"][1]) - self.assertFalse(summary["isVerified"]) - self.assertFalse(summary["isRemote"]) - self.assertIsNone(summary["iconUrl"]) - self.assertIsNone(summary["homepage"]) - self.assertEqual( - summary["externalUrl"], "https://smithery.ai/server/wonderwhy-er/desktop-commander" - ) - - -class MapDetailTests(unittest.TestCase): - def test_remote_detail_maps_connections_and_tools(self) -> None: - detail = _map_detail(_EXA_DETAIL_SAMPLE, qualified_name="exa") - self.assertTrue(detail["isRemote"]) - self.assertEqual(detail["managedName"], "exa") - self.assertEqual(detail["deploymentUrl"], "https://exa.run.tools") - self.assertEqual(detail["connections"][0]["kind"], "http") - self.assertEqual(detail["connections"][0]["deploymentUrl"], "https://exa.run.tools") - self.assertEqual(len(detail["tools"]), 1) - self.assertEqual(detail["tools"][0]["name"], "web_search_exa") - self.assertEqual(detail["capabilityCounts"], {"tools": 1, "resources": 1, "prompts": 1}) - - def test_local_detail_marks_stdio_connection(self) -> None: - detail = _map_detail( - _DESKTOP_DETAIL_SAMPLE, qualified_name="wonderwhy-er/desktop-commander" - ) - self.assertFalse(detail["isRemote"]) - self.assertIsNone(detail["deploymentUrl"]) - self.assertEqual(detail["connections"][0]["kind"], "stdio") - self.assertEqual(detail["connections"][0]["stdioCommand"], "npx") - self.assertEqual( - detail["connections"][0]["stdioArgs"], - ["-y", "@wonderwhy-er/desktop-commander"], - ) - self.assertEqual(detail["capabilityCounts"]["tools"], 1) - self.assertEqual(detail["capabilityCounts"]["resources"], 0) - - def test_sse_connection_is_preserved(self) -> None: - detail = _map_detail(_SSE_DETAIL_SAMPLE, qualified_name="@acme/stream-server") - self.assertEqual(detail["managedName"], "stream-server") - self.assertEqual(detail["connections"][0]["kind"], "sse") - - def test_config_schema_is_preserved_as_marketplace_metadata(self) -> None: - raw = { - **_EXA_DETAIL_SAMPLE, - "connections": [ - { - "type": "http", - "deploymentUrl": "https://exa.run.tools", - "configSchema": { - "type": "object", - "required": ["headers"], - "properties": { - "headers": {"type": "object", "description": "Custom headers"}, - }, - }, - } - ], - } - - detail = _map_detail(raw, qualified_name="exa") - - self.assertEqual( - detail["connections"][0]["configSchema"], - raw["connections"][0]["configSchema"], - ) - - def test_bundle_only_local_detail_preserves_metadata(self) -> None: - raw = { - **_DESKTOP_DETAIL_SAMPLE, - "connections": [ - { - "type": "stdio", - "bundleUrl": "https://backend.smithery.ai/storage/v1/object/public/bundles/@acme/server/server.mcpb", - "runtime": "node", - "configSchema": {"type": "object", "required": [], "properties": {}}, - } - ], - } - - detail = _map_detail(raw, qualified_name="acme/server") - - self.assertEqual(detail["connections"][0]["bundleUrl"], raw["connections"][0]["bundleUrl"]) - self.assertEqual(detail["connections"][0]["runtime"], "node") - - -class McpMarketplaceCatalogTests(unittest.TestCase): - def test_popular_page_maps_response_and_reports_has_more(self) -> None: - catalog = McpMarketplaceCatalog( - fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, - cache=MarketplaceCache(), - ) - page = catalog.popular_page(limit=30, offset=0) - self.assertEqual(len(page["items"]), 2) - self.assertEqual(page["items"][0]["qualifiedName"], "exa") - self.assertTrue(page["hasMore"]) - self.assertEqual(page["nextOffset"], 2) - - def test_search_with_only_filters_bypasses_min_query(self) -> None: - catalog = McpMarketplaceCatalog( - fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, - cache=MarketplaceCache(), - ) - page = catalog.search_page("", limit=30, offset=0, remote=False) - self.assertEqual(len(page["items"]), 2) - - def test_search_requires_min_query_when_no_filters(self) -> None: - catalog = McpMarketplaceCatalog( - fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, - cache=MarketplaceCache(), - ) - with self.assertRaises(ValueError): - catalog.search_page("a", limit=30, offset=0) - - def test_detail_returns_none_on_404(self) -> None: - def fetcher(_path: str) -> dict[str, object]: - raise MarketplaceUpstreamError("bad_status", "u", "x", upstream_status=404) - - catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) - self.assertIsNone(catalog.detail("missing")) - - def test_detail_caches_within_ttl(self) -> None: - fetcher = mock.Mock(return_value=_EXA_DETAIL_SAMPLE) - catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) - self.assertIsNone(catalog.detail(" ")) # empty name path - first = catalog.detail("exa") - second = catalog.detail("exa") - self.assertEqual(first, second) - # MarketplaceCache() with root=None does not persist, so each call hits fetcher. - # Use a tmp-backed cache to prove single fetch. - - def test_detail_path_encoded_for_namespaced_name(self) -> None: - fetcher = mock.Mock(return_value=_DESKTOP_DETAIL_SAMPLE) - catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) - catalog.detail("wonderwhy-er/desktop-commander") - called_path = fetcher.call_args.args[0] - self.assertEqual(called_path, "/servers/wonderwhy-er/desktop-commander") - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_sources.py b/tests/unit/test_sources.py index 65326e2..b0c4e05 100644 --- a/tests/unit/test_sources.py +++ b/tests/unit/test_sources.py @@ -153,7 +153,7 @@ def test_parse_homepage_leaderboard_filters_unsupported_sources(self) -> None: @@ -165,7 +165,7 @@ def test_parse_homepage_leaderboard_filters_unsupported_sources(self) -> None: def test_normalize_skill_rejects_unsupported_source(self) -> None: raw = raw_skill_from_payload({ - "source": "smithery.ai", + "source": "unsupported-source.example", "skillId": "ui-ux-pro-max", "name": "ui-ux-pro-max", "installs": 128, @@ -175,12 +175,12 @@ def test_normalize_skill_rejects_unsupported_source(self) -> None: def test_github_repo_helpers_validate_and_derive_owner_avatar(self) -> None: self.assertTrue(is_valid_github_repo("mode-io/skills")) - self.assertFalse(is_valid_github_repo("smithery.ai")) + self.assertFalse(is_valid_github_repo("unsupported-source.example")) self.assertEqual( github_owner_avatar_url("mode-io/skills"), "https://github.com/mode-io.png?size=96", ) - self.assertIsNone(github_owner_avatar_url("smithery.ai")) + self.assertIsNone(github_owner_avatar_url("unsupported-source.example")) def test_extract_detail_description_prefers_summary_then_skill_body_then_hint(self) -> None: summary_html = """