From f1b5e2a410a35f0917398b12c8ae7eb830926f74 Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:09:33 +0800 Subject: [PATCH 01/21] feat: add MCP connector directory --- .env.example | 5 + CONTRIBUTING.md | 16 + Dockerfile | 1 + .../internal/agent/codex/mcp_config.go | 7 + .../internal/agent/codex/mcp_config_test.go | 14 + .../internal/agent/codex/options.go | 10 +- .../internal/agent/opencode/options.go | 73 ++- .../internal/agent/opencode/options_test.go | 35 ++ apps/web/src/i18n/locales/en-US/admin.json | 68 ++- apps/web/src/i18n/locales/zh-CN/admin.json | 67 ++- apps/web/src/lib/api-marketplace.ts | 212 +++++++- apps/web/src/pages/admin/AgentsPage.tsx | 13 +- .../AddCapabilityVersionDialog.tsx | 167 ++++-- .../capabilities/ImportCapabilityDialog.tsx | 28 +- .../admin/capabilities/ImportMCPForm.tsx | 45 +- .../admin/capabilities/MarketplaceTab.tsx | 257 ++++++--- .../src/pages/admin/capabilities/index.tsx | 30 +- .../mcp-directory/ImportMCPDialog.tsx | 119 ++++ .../mcp-directory/MCPDirectory.tsx | 182 +++++++ .../mcp-directory/MCPDirectoryCard.tsx | 52 ++ .../mcp-directory/MCPDirectoryDetail.tsx | 206 +++++++ .../capabilities/mcp-directory/filters.ts | 26 + .../capabilities/mcp-directory/shared.tsx | 43 ++ .../admin/capabilities/mcp-directory/utils.ts | 3 + .../web/src/pages/admin/capabilities/types.ts | 4 +- catalog/mcp/README.md | 41 ++ catalog/mcp/catalog.json | 509 ++++++++++++++++++ catalog/mcp/catalog.schema.json | 114 ++++ catalog/mcp/embed.go | 9 + deploy/compose/.env.example | 4 + deploy/compose/compose.selfhost.yml | 1 + docker-compose.yml | 1 + docs/deploy/deploy-runbook.md | 3 +- docs/openapi/openapi.yaml | 266 ++++++++- server/cmd/server/main.go | 9 + server/internal/api/mcpdirectory/handler.go | 327 +++++++++++ .../internal/api/mcpdirectory/handler_test.go | 220 ++++++++ server/internal/capability/canonical/mcp.go | 48 +- .../capability/canonical/spec_test.go | 15 + .../internal/capability/parser/mcp_parser.go | 50 +- .../capability/parser/mcp_parser_test.go | 18 +- .../internal/capability/render/claudecode.go | 6 + server/internal/capability/render/codex.go | 6 + server/internal/capability/render/opencode.go | 6 + .../capability/render/renderer_test.go | 57 ++ .../agentdaemon/capability_runtime.go | 18 +- .../agentdaemon/capability_runtime_test.go | 20 + server/internal/db/queries/store.sql | 17 + server/internal/db/sqlc/store.sql.go | 44 ++ .../internal/dev/capability_import_routes.go | 52 +- .../dev/capability_import_routes_test.go | 105 ++++ server/internal/dev/capability_routes.go | 50 +- server/internal/dev/uploads_routes.go | 20 +- server/internal/dev/uploads_routes_test.go | 32 ++ server/internal/mcpcatalog/catalog_test.go | 202 +++++++ server/internal/mcpcatalog/loader.go | 180 +++++++ server/internal/mcpcatalog/types.go | 63 +++ server/internal/mcpcatalog/validate.go | 186 +++++++ server/internal/store/capability_import.go | 18 +- server/internal/store/mcp_directory.go | 37 ++ server/internal/store/mcp_directory_test.go | 86 +++ tests/e2e/mcp-directory.spec.ts | 471 ++++++++++++++++ 62 files changed, 4735 insertions(+), 259 deletions(-) create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts create mode 100644 catalog/mcp/README.md create mode 100644 catalog/mcp/catalog.json create mode 100644 catalog/mcp/catalog.schema.json create mode 100644 catalog/mcp/embed.go create mode 100644 server/internal/api/mcpdirectory/handler.go create mode 100644 server/internal/api/mcpdirectory/handler_test.go create mode 100644 server/internal/mcpcatalog/catalog_test.go create mode 100644 server/internal/mcpcatalog/loader.go create mode 100644 server/internal/mcpcatalog/types.go create mode 100644 server/internal/mcpcatalog/validate.go create mode 100644 server/internal/store/mcp_directory.go create mode 100644 server/internal/store/mcp_directory_test.go create mode 100644 tests/e2e/mcp-directory.spec.ts diff --git a/.env.example b/.env.example index fa7aeead..30a83cdc 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,11 @@ PARSAR_SHARED_RUNTIME_TOKEN= # Internal WebSocket URL advertised to compose-resident daemon runtimes. PARSAR_AGENT_DAEMON_WS_URL=ws://parsar-server:8080/agent-daemon/ws +# Optional trusted JSON endpoint for the MCP Connector Directory. Leave empty +# to use the catalog embedded in the Parsar server image. Remote failures fall +# back to the embedded catalog. +PARSAR_MCP_CATALOG_URL= + # ----------------------------------------------------------------------------- # Feishu Bot (optional — see docs/deploy/lan-deploy.md) # ----------------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84d458c3..a25e625f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -269,6 +269,22 @@ description and keep ownership on the side listed here. are committed artifacts, but never the source of truth. Change annotations or SQL first, then regenerate. +### Capability marketplace and MCP directory + +- The MCP Connector Directory is a repository- or operator-maintained catalog, + not a new capability type. Imports become ordinary private `mcp` + capabilities through `canonical.Spec`, `Store.ImportCapability`, capability + versions, and the existing Agent binding flow. +- Catalog data lives in `catalog/mcp/catalog.json` or the trusted deployment + override `PARSAR_MCP_CATALOG_URL`. It is validated and cached in memory; do + not add a connector catalog table or accept catalog URLs from API requests. +- Import saves configuration only. It must not execute a command, create empty + secrets, bind an Agent, or trust client-submitted command/args/env fields. +- Catalog provenance belongs in `capability_version.source_payload` using + `source_format=mcp_catalog`, stable `catalog_id`, `catalog_version`, and + `catalog_source`. Installation state uses that provenance, never a name + comparison. + ## Code quality & architecture Parsar favors small, single-purpose files and reused helpers over growing diff --git a/Dockerfile b/Dockerfile index e1404ec1..028a6031 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,6 +79,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY internal ./internal +COPY catalog ./catalog COPY server ./server # Build all three binaries in one RUN so the layer represents one diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go index 22437fb9..d66c6259 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -14,6 +14,7 @@ import ( // before spawning the app-server child. type mcpServerConfig struct { Name string + URL string Command string Args []string Env map[string]string @@ -49,6 +50,12 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString("[mcp_servers.") b.WriteString(tomlQuoteString(name)) b.WriteString("]\n") + if srv.URL != "" { + b.WriteString(`url = `) + b.WriteString(tomlQuoteString(srv.URL)) + b.WriteString("\n\n") + continue + } b.WriteString(`command = `) b.WriteString(tomlQuoteString(srv.Command)) b.WriteByte('\n') diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go index c040fd78..8f5cf6e6 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go @@ -61,6 +61,20 @@ func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { } } +func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { + dir := t.TempDir() + servers := map[string]mcpServerConfig{ + "docs": {Name: "docs", URL: "https://docs.example.com/mcp"}, + } + if err := writeCodexMCPConfig(dir, servers); err != nil { + t.Fatalf("write: %v", err) + } + body, _ := os.ReadFile(filepath.Join(dir, "config.toml")) + if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { + t.Fatalf("remote config: %s", body) + } +} + // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the // "fresh entries only" guarantee: callers allocate a brand-new // CODEX_HOME per prompt (BuildSessionPlan does this via allocCodexHome diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index f88ae261..2487dc77 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -342,6 +342,9 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) } srv := mcpServerConfig{Name: name} + if url, ok := entry["url"].(string); ok { + srv.URL = strings.TrimSpace(url) + } if cmd, ok := entry["command"].(string); ok { srv.Command = cmd } @@ -360,8 +363,11 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { } } } - if srv.Command == "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] missing command", name) + if srv.Command == "" && srv.URL == "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) + } + if srv.Command != "" && srv.URL != "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) } out[name] = srv } diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go index de8c6cf6..c1cbb524 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -55,7 +55,14 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, err } - if rawConfig := stringOpt(opts, "opencode_json"); rawConfig != "" { + rawConfig := stringOpt(opts, "opencode_json") + if servers, ok := opts["mcp_servers"]; ok && servers != nil { + rawConfig, err = mergeMCPConfig(rawConfig, servers) + if err != nil { + return result, err + } + } + if rawConfig != "" { configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) if err != nil { return result, err @@ -71,6 +78,70 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, nil } +func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + } + servers, ok := rawServers.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) + } + mcp, _ := config["mcp"].(map[string]any) + if mcp == nil { + mcp = map[string]any{} + } + for name, raw := range servers { + entry, ok := raw.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) + } + enabled := true + if value, ok := entry["enabled"].(bool); ok { + enabled = value + } + if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { + mcp[name] = map[string]any{ + "type": "remote", + "url": strings.TrimSpace(remoteURL), + "enabled": enabled, + } + continue + } + command, ok := entry["command"].(string) + if !ok || strings.TrimSpace(command) == "" { + return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) + } + commandParts := []string{command} + if args, ok := entry["args"].([]any); ok { + for _, arg := range args { + if value, ok := arg.(string); ok { + commandParts = append(commandParts, value) + } + } + } else if args, ok := entry["args"].([]string); ok { + commandParts = append(commandParts, args...) + } + local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} + if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { + local["environment"] = env + } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { + local["environment"] = env + } + mcp[name] = local + } + if len(mcp) > 0 { + config["mcp"] = mcp + } + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) + } + return string(encoded), nil +} + func resolveWorkDir(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go index 9106d9a5..bfe9e4fa 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -1,6 +1,7 @@ package opencode_test import ( + "encoding/json" "os" "path/filepath" "slices" @@ -92,6 +93,40 @@ func TestBuildArgsWritesManagedConfigUnderParsarHome(t *testing.T) { } } +func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ + "opencode_json": `{"provider":{}}`, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, + "docs": map[string]any{"url": "https://docs.example.com/mcp"}, + }, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + mcp := config["mcp"].(map[string]any) + remote := mcp["docs"].(map[string]any) + if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { + t.Fatalf("remote = %+v", remote) + } + local := mcp["local"].(map[string]any) + if local["type"] != "local" { + t.Fatalf("local = %+v", local) + } +} + func TestBuildArgsRejectsBadEnvShape(t *testing.T) { _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) if err == nil || !strings.Contains(err.Error(), "env") { diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 93408a61..d63fef4d 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -641,7 +641,8 @@ "browse": "Browse market" }, "permission": { - "adminOnly": "Owner / admin only" + "adminOnly": "Owner / admin only", + "create": "Members can add Skills; only owners / admins can add MCP" }, "status": { "active": "Available", @@ -823,7 +824,7 @@ "empty": { "title": "No capabilities have been added to this workspace yet.", "descriptionAdmin": "Capabilities live in the workspace capability pool. They are external tools (MCP) or loadable Skill packages that Agents in this workspace can enable as needed.", - "descriptionMember": "This workspace capability pool has no capabilities yet. Ask an owner / admin to add one." + "descriptionMember": "This workspace capability pool has no capabilities yet. Members can add or import Skills; ask an owner / admin to add MCP." }, "loadError": { "title": "Failed to load capabilities", @@ -888,6 +889,69 @@ "description": "Backend returned an error." } }, + "mcpDirectory": { + "title": "Connectors", + "description": "Browse curated local and remote MCP servers and import their configuration into this workspace.", + "verified": "Verified", + "securityNotice": "Import only saves the configuration and does not run it immediately. The MCP can execute in a Runtime only after you enable it and bind it to an Agent.", + "source": { + "builtin": "Built-in catalog", + "remote": "Remote catalog" + }, + "filters": { + "category": "Connector categories", + "allCategories": "All categories", + "verified": "Verified only", + "sort": "Sort connectors" + }, + "sort": { + "popular": "Most popular", + "name": "Name" + }, + "actions": { + "import": "Import", + "installed": "Installed", + "back": "Back to connectors", + "viewCapability": "View Capability", + "addToAgent": "Add to Agent" + }, + "loadError": { + "title": "Couldn't load the connectors directory", + "description": "Couldn't load the connectors directory. Some connector details may be missing. Retry without leaving the Capability Marketplace." + }, + "empty": { + "title": "No connectors match these filters", + "description": "Try another search term or clear the category and Verified filters." + }, + "detail": { + "loadError": "Failed to load connector details", + "notFound": "Connector not found", + "version": "Version", + "transport": "Transport", + "rank": "Popularity rank", + "timeout": "Startup timeout", + "seconds_one": "{{count}} second", + "seconds_other": "{{count}} seconds", + "command": "Startup command", + "endpoint": "Remote endpoint", + "authentication": "Authentication", + "noAuthentication": "Not required", + "environment": "Environment variable names", + "noEnvironment": "No environment variables are declared.", + "publisher": "Publisher", + "homepage": "Homepage", + "repository": "Repository", + "openLink": "Open link" + }, + "import": { + "title": "Import {{name}}?", + "description": "Review the connector configuration. No token is required during import, and nothing will run or bind to an Agent.", + "success": "{{name}} was imported as a workspace MCP Capability.", + "failed": "The connector could not be imported.", + "importing": "Importing...", + "cancel": "Cancel" + } + }, "marketplaceDetail": { "badge": "From market", "notFound": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index aee566e4..e7210f46 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -641,7 +641,8 @@ "browse": "浏览市场" }, "permission": { - "adminOnly": "仅 owner / admin 可添加" + "adminOnly": "仅 owner / admin 可添加", + "create": "Member 可添加 Skill;仅 owner / admin 可添加 MCP" }, "status": { "active": "可用", @@ -823,7 +824,7 @@ "empty": { "title": "本工作区还没添加能力。", "descriptionAdmin": "能力属于工作区能力池,是 Agent 可用的外部工具(MCP)或可加载的 Skill 包。添加后,本工作区下的 Agent 可按需启用。", - "descriptionMember": "本工作区能力池还没有任何能力。请联系 owner / admin 添加。" + "descriptionMember": "本工作区能力池还没有任何能力。Member 可以新增或导入 Skill;MCP 仍需由 owner / admin 添加。" }, "loadError": { "title": "无法加载能力列表", @@ -888,6 +889,68 @@ "description": "后端返回错误。" } }, + "mcpDirectory": { + "title": "连接器", + "description": "浏览经过筛选的本地与远程 MCP 服务,并将配置导入当前工作区。", + "verified": "已验证", + "securityNotice": "导入只会保存配置,不会立即运行。启用并绑定 Agent 后,该 MCP 才可能在 Runtime 中执行。", + "source": { + "builtin": "内置目录", + "remote": "远程目录" + }, + "filters": { + "category": "连接器分类", + "allCategories": "全部分类", + "verified": "仅已验证", + "sort": "连接器排序" + }, + "sort": { + "popular": "最受欢迎", + "name": "名称" + }, + "actions": { + "import": "导入", + "installed": "已安装", + "back": "返回连接器列表", + "viewCapability": "查看 Capability", + "addToAgent": "添加到 Agent" + }, + "loadError": { + "title": "无法加载连接器目录", + "description": "无法加载连接器目录,部分连接器信息可能缺失。你可以直接重试,不会影响 Skill 市场和工作区 Capability。" + }, + "empty": { + "title": "没有符合筛选条件的连接器", + "description": "请更换搜索词,或清除分类和已验证筛选。" + }, + "detail": { + "loadError": "无法加载连接器详情", + "notFound": "未找到该连接器", + "version": "版本", + "transport": "传输方式", + "rank": "热度排名", + "timeout": "启动超时", + "seconds_other": "{{count}} 秒", + "command": "启动命令", + "endpoint": "远程地址", + "authentication": "鉴权", + "noAuthentication": "无需鉴权", + "environment": "环境变量名称", + "noEnvironment": "未声明环境变量。", + "publisher": "发布者", + "homepage": "主页", + "repository": "代码仓库", + "openLink": "打开链接" + }, + "import": { + "title": "导入 {{name}}?", + "description": "请检查连接器配置。导入时不需要 Token,也不会运行 MCP 或绑定 Agent。", + "success": "已将 {{name}} 导入为工作区 MCP Capability。", + "failed": "无法导入该连接器。", + "importing": "正在导入...", + "cancel": "取消" + } + }, "marketplaceDetail": { "badge": "来自市场", "notFound": { diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index 9dc98ede..373b7522 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -1,7 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { apiRequest, noUnreachableRetry } from "./api-client" -import { KEY_AGENT_CAPABILITIES, KEY_CAPABILITIES_WORKSPACE, KEY_CAPABILITY_VERSIONS } from "./api-capabilities" +import { + KEY_AGENT_CAPABILITIES, + KEY_CAPABILITIES_WORKSPACE, + KEY_CAPABILITY_VERSIONS, +} from "./api-capabilities" import type { Capability } from "./api-types" export interface MarketplaceCapability extends Capability { @@ -58,7 +62,9 @@ export interface MarketplaceMCPDetail { export interface MarketplaceMCPServer { name: string - command: string + transport?: "stdio" | "streamable-http" + url?: string + command?: string args?: string[] env?: Record startup_timeout_sec?: number @@ -91,6 +97,46 @@ export interface EnabledMarketplaceAgent { version?: string } +export interface MCPDirectoryPublisher { + name: string + url: string +} + +export interface MCPDirectoryItem { + id: string + name: string + description: string + publisher: MCPDirectoryPublisher + icon_url?: string + homepage_url?: string + repository_url?: string + verified: boolean + categories: string[] + popularity_rank: number + version: string + transport: "stdio" | "streamable-http" + url?: string + command?: string + args?: string[] + env?: string[] + startup_timeout_sec?: number + installed: boolean + installed_capability_id: string | null +} + +export interface MCPDirectoryListResponse { + items: MCPDirectoryItem[] + updated_at: string + source: "builtin" | "remote" +} + +export interface MCPDirectoryImportResponse { + installed: boolean + capability_id: string + created: boolean + capability?: Capability +} + interface MarketplaceListResponse { capabilities?: MarketplaceCapability[] marketplace?: MarketplaceCapability[] @@ -118,12 +164,20 @@ interface EnabledAgentsResponse { items?: EnabledMarketplaceAgent[] } -export const KEY_MARKETPLACE_LIST = (workspaceID: string) => ["admin", "capabilityMarketplace", workspaceID] as const +export const KEY_MARKETPLACE_LIST = (workspaceID: string) => + ["admin", "capabilityMarketplace", workspaceID] as const export const KEY_MARKETPLACE_DETAIL = (workspaceID: string, capabilityID: string) => ["admin", "capabilityMarketplaceDetail", workspaceID, capabilityID] as const -export const KEY_TARGET_MARKETPLACE_INSTALLS = (workspaceID: string) => ["admin", "targetMarketplaceInstalls", workspaceID] as const -export const KEY_INSTALL_COUNT = (workspaceID: string, capabilityID: string) => ["admin", "capabilityInstallCount", workspaceID, capabilityID] as const -export const KEY_MARKETPLACE_ENABLED_AGENTS = (workspaceID: string, capabilityID: string) => ["admin", "marketplaceEnabledAgents", workspaceID, capabilityID] as const +export const KEY_TARGET_MARKETPLACE_INSTALLS = (workspaceID: string) => + ["admin", "targetMarketplaceInstalls", workspaceID] as const +export const KEY_INSTALL_COUNT = (workspaceID: string, capabilityID: string) => + ["admin", "capabilityInstallCount", workspaceID, capabilityID] as const +export const KEY_MARKETPLACE_ENABLED_AGENTS = (workspaceID: string, capabilityID: string) => + ["admin", "marketplaceEnabledAgents", workspaceID, capabilityID] as const +export const KEY_MCP_DIRECTORY = (workspaceID: string) => + ["admin", "mcpDirectory", workspaceID] as const +export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) => + ["admin", "mcpDirectoryDetail", workspaceID, catalogID] as const async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] @@ -132,7 +186,9 @@ async function listMarketplace(workspaceID: string | null): Promise( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/marketplace-installs`, ) - const items = Array.isArray(data) ? data : data.capabilities ?? data.installs ?? data.items ?? [] + const items = Array.isArray(data) + ? data + : (data.capabilities ?? data.installs ?? data.items ?? []) return items.map(normalizeMarketplaceInstall) } -async function getInstallCount(workspaceID: string | null, capabilityID: string | null): Promise { +async function getInstallCount( + workspaceID: string | null, + capabilityID: string | null, +): Promise { if (!workspaceID || !capabilityID) return 0 const data = await apiRequest( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/${encodeURIComponent(capabilityID)}/install-count`, @@ -164,18 +225,54 @@ async function getInstallCount(workspaceID: string | null, capabilityID: string return data.install_count ?? data.workspace_count ?? data.count ?? 0 } -async function listEnabledAgents(workspaceID: string | null, capabilityID: string | null): Promise { +async function listEnabledAgents( + workspaceID: string | null, + capabilityID: string | null, +): Promise { if (!workspaceID || !capabilityID) return [] const data = await apiRequest( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/${encodeURIComponent(capabilityID)}/enabled-agents`, ) - const items = Array.isArray(data) ? data : data.agents ?? data.items ?? [] + const items = Array.isArray(data) ? data : (data.agents ?? data.items ?? []) return items.map(normalizeEnabledAgent) } +async function listMCPDirectory(workspaceID: string | null): Promise { + if (!workspaceID) return { items: [], updated_at: "", source: "builtin" } + return apiRequest( + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory`, + ) +} + +async function getMCPDirectoryItem( + workspaceID: string | null, + catalogID: string | null, +): Promise { + if (!workspaceID || !catalogID) throw new Error("workspace and catalog item are required") + return apiRequest( + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}`, + ) +} + +async function importMCPDirectoryItem( + workspaceID: string, + catalogID: string, +): Promise { + return apiRequest( + `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/import`, + { method: "POST" }, + ) +} + function normalizeMarketplaceCapability(item: MarketplaceCapability): MarketplaceCapability { const id = item.id ?? item.capability_id ?? "" - return { ...item, id, latest_version: item.latest_version ?? item.latest_published_version, created_at: item.created_at ?? item.latest_version_created_at, updated_at: item.updated_at ?? item.latest_version_created_at } + return { + ...item, + id, + latest_version: item.latest_version ?? item.latest_published_version, + created_at: item.created_at ?? item.latest_version_created_at, + updated_at: item.updated_at ?? item.latest_version_created_at, + } } function normalizeMarketplaceInstall(item: TargetMarketplaceInstall): TargetMarketplaceInstall { @@ -186,7 +283,11 @@ function normalizeEnabledAgent(item: EnabledMarketplaceAgent): EnabledMarketplac return { ...item, name: item.name ?? item.agent_name ?? "—" } } -async function postWorkspaceCapability(workspaceID: string, capabilityID: string, action: "publish" | "unpublish" | "deprecate" | "undeprecate") { +async function postWorkspaceCapability( + workspaceID: string, + capabilityID: string, + action: "publish" | "unpublish" | "deprecate" | "undeprecate", +) { return apiRequest( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/${encodeURIComponent(capabilityID)}/${action}`, { method: "POST" }, @@ -207,7 +308,12 @@ async function deleteWorkspaceCapability(workspaceID: string, capabilityID: stri ) } -async function upgradeCapability(workspaceID: string, agentID: string, capabilityID: string, versionID: string) { +async function upgradeCapability( + workspaceID: string, + agentID: string, + capabilityID: string, + versionID: string, +) { return apiRequest( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/agents/${encodeURIComponent(agentID)}/capabilities/${encodeURIComponent(capabilityID)}/upgrade`, { method: "POST", body: { new_version_id: versionID } }, @@ -252,7 +358,10 @@ export function useInstallCount(workspaceID: string | null, capabilityID: string }) } -export function useMarketplaceEnabledAgents(workspaceID: string | null, capabilityID: string | null) { +export function useMarketplaceEnabledAgents( + workspaceID: string | null, + capabilityID: string | null, +) { return useQuery({ queryKey: KEY_MARKETPLACE_ENABLED_AGENTS(workspaceID ?? "_none", capabilityID ?? "_none"), queryFn: () => listEnabledAgents(workspaceID, capabilityID), @@ -262,19 +371,82 @@ export function useMarketplaceEnabledAgents(workspaceID: string | null, capabili }) } -function invalidateMarketplace(qc: ReturnType, workspaceID: string | null, capabilityID?: string) { +export function useMCPDirectory(workspaceID: string | null) { + return useQuery({ + queryKey: KEY_MCP_DIRECTORY(workspaceID ?? "_none"), + queryFn: () => listMCPDirectory(workspaceID), + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useMCPDirectoryDetail(workspaceID: string | null, catalogID: string | null) { + return useQuery({ + queryKey: KEY_MCP_DIRECTORY_DETAIL(workspaceID ?? "_none", catalogID ?? "_none"), + queryFn: () => getMCPDirectoryItem(workspaceID, catalogID), + enabled: !!workspaceID && !!catalogID, + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useImportMCPDirectoryItem(workspaceID: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (catalogID: string) => { + if (!workspaceID) throw new Error("workspace is required") + return importMCPDirectoryItem(workspaceID, catalogID) + }, + retry: noUnreachableRetry, + onSuccess: (result, catalogID) => { + if (!workspaceID) return + qc.setQueryData(KEY_MCP_DIRECTORY(workspaceID), (current) => + current + ? { + ...current, + items: current.items.map((item) => + item.id === catalogID + ? { ...item, installed: true, installed_capability_id: result.capability_id } + : item, + ), + } + : current, + ) + qc.setQueryData( + KEY_MCP_DIRECTORY_DETAIL(workspaceID, catalogID), + (current) => + current + ? { ...current, installed: true, installed_capability_id: result.capability_id } + : current, + ) + void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID) }) + void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) + }, + }) +} + +function invalidateMarketplace( + qc: ReturnType, + workspaceID: string | null, + capabilityID?: string, +) { void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_LIST(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: KEY_TARGET_MARKETPLACE_INSTALLS(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) if (workspaceID && capabilityID) { void qc.invalidateQueries({ queryKey: KEY_INSTALL_COUNT(workspaceID, capabilityID) }) - void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_ENABLED_AGENTS(workspaceID, capabilityID) }) + void qc.invalidateQueries({ + queryKey: KEY_MARKETPLACE_ENABLED_AGENTS(workspaceID, capabilityID), + }) void qc.invalidateQueries({ queryKey: KEY_CAPABILITY_VERSIONS(workspaceID, capabilityID) }) } } -function useWorkspaceAction(workspaceID: string | null, action: "publish" | "unpublish" | "deprecate" | "undeprecate") { +function useWorkspaceAction( + workspaceID: string | null, + action: "publish" | "unpublish" | "deprecate" | "undeprecate", +) { const qc = useQueryClient() return useMutation({ mutationFn: (capabilityID: string) => { @@ -351,6 +523,8 @@ export function useUpgrade(workspaceID: string | null, agentID: string | null) { }) } -export function marketplaceSourceName(capability: Partial): string { +export function marketplaceSourceName( + capability: Partial, +): string { return capability.source_workspace_name ?? "" } diff --git a/apps/web/src/pages/admin/AgentsPage.tsx b/apps/web/src/pages/admin/AgentsPage.tsx index 42ed8f44..17f5beab 100644 --- a/apps/web/src/pages/admin/AgentsPage.tsx +++ b/apps/web/src/pages/admin/AgentsPage.tsx @@ -21,6 +21,7 @@ import { } from "../../components/ui/tabs" import { useAdminView } from "../../lib/admin-router" import { ApiError } from "../../lib/api-client" +import { useCapabilitiesQuery } from "../../lib/api-capabilities" import { createAgentConversation } from "../../lib/api-conversations" import { useCreateAgent, @@ -48,7 +49,13 @@ import { DeleteAgentDialog } from "./agents/DeleteAgentDialog" function usePendingCapability(workspaceID: string | null) { const id = new URLSearchParams(window.location.search).get("pendingCapability") const marketplaceQ = useMarketplaceList(workspaceID) - const capability = (marketplaceQ.data ?? []).find((item) => item.id === id) + const workspaceQ = useCapabilitiesQuery(id ? workspaceID : null) + const workspaceCapabilities = [ + ...(workspaceQ.data?.capabilities ?? []), + ...(workspaceQ.data?.marketplace_installs ?? []), + ] + const capability = workspaceCapabilities.find((item) => item.id === id) + ?? (marketplaceQ.data ?? []).find((item) => item.id === id) return { id, capability } } @@ -127,7 +134,7 @@ export function AgentsPage() { onCancel={() => navigate("agents", { pendingCapability: null })} > {pendingCapability.capability - ? t("agents.pendingCapability.banner", { name: pendingCapability.capability.name, source: pendingCapability.capability.source_workspace_name ?? "—" }) + ? t("agents.pendingCapability.banner", { name: pendingCapability.capability.name, source: pendingCapability.capability.source_workspace_name ?? workspaceName ?? "—" }) : t("agents.pendingCapability.loading")} )} @@ -410,7 +417,7 @@ export function AgentDetailPage({ id }: { id: string }) { > {t("agents.pendingCapability.detailBanner", { name: pendingCapability.capability?.name ?? pendingCapability.id, - source: pendingCapability.capability?.source_workspace_name ?? "—", + source: pendingCapability.capability?.source_workspace_name ?? currentWorkspace?.name ?? "—", })} )} diff --git a/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx b/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx index add8e377..e32d901e 100644 --- a/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx +++ b/apps/web/src/pages/admin/capabilities/AddCapabilityVersionDialog.tsx @@ -50,6 +50,7 @@ interface Props { capability: Capability /** Most-recent version, used for prefill. Undefined when capability has no versions yet. */ latestVersion: CapabilityVersion | undefined + latestVersionLoading?: boolean open: boolean onOpenChange: (open: boolean) => void /** Toast / parent feedback after a successful commit. */ @@ -60,6 +61,7 @@ export function AddCapabilityVersionDialog({ workspaceID, capability, latestVersion, + latestVersionLoading = false, open, onOpenChange, onCommitted, @@ -99,10 +101,10 @@ export function AddCapabilityVersionDialog({ return tail }, [latestVersion]) - // Reset only on the open transition — resetting on every render would - // clobber the user's edits. + // Initialize once the dialog is open and its latest-version request has + // completed. Mounting the editor earlier would lock in an empty prefill. useEffect(() => { - if (!open) return + if (!open || latestVersionLoading) return setName(capability.name) setDescription(capability.description ?? "") setSpec(null) @@ -113,27 +115,27 @@ export function AddCapabilityVersionDialog({ setSkillOssKey(null) commitMut.reset() updateMut.reset() - // intentionally only on the open transition + // intentionally only on open/loading transitions // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) - - const errMsg = commitMut.error instanceof ApiError - ? commitMut.error.envelope.message - : commitMut.error instanceof Error - ? commitMut.error.message - : updateMut.error instanceof ApiError - ? updateMut.error.envelope.message - : updateMut.error instanceof Error - ? updateMut.error.message - : null + }, [open, latestVersionLoading]) + + const errMsg = + commitMut.error instanceof ApiError + ? commitMut.error.envelope.message + : commitMut.error instanceof Error + ? commitMut.error.message + : updateMut.error instanceof ApiError + ? updateMut.error.envelope.message + : updateMut.error instanceof Error + ? updateMut.error.message + : null const trimmedName = name.trim() - const nameError = - !trimmedName - ? t("capabilities.errors.nameRequired") - : trimmedName.length > 50 - ? t("capabilities.errors.nameTooLong") - : null + const nameError = !trimmedName + ? t("capabilities.errors.nameRequired") + : trimmedName.length > 50 + ? t("capabilities.errors.nameTooLong") + : null // For plugin / skill-zip kinds we accept "no new upload" and let the server // reuse the previous OSS blob. So the canSubmit guard relaxes when an // inherited blob exists. @@ -141,15 +143,18 @@ export function AddCapabilityVersionDialog({ kind !== "plugin" ? true : pluginUpload.ossKey - ? pluginUpload.validation?.valid ?? false + ? (pluginUpload.validation?.valid ?? false) : !!inheritedOssLabel const skillSpecReady = kind !== "skill" ? true - : !!skillOssKey || !!inheritedOssLabel || (!!spec && isImportSpecReady(kind, spec, inlineSecrets)) + : !!skillOssKey || + !!inheritedOssLabel || + (!!spec && isImportSpecReady(kind, spec, inlineSecrets)) - const mcpSpecReady = kind !== "mcp" ? true : !!spec && isImportSpecReady(kind, spec, inlineSecrets) + const mcpSpecReady = + kind !== "mcp" ? true : !!spec && isImportSpecReady(kind, spec, inlineSecrets) const canSubmit = !commitMut.isPending && @@ -194,13 +199,13 @@ export function AddCapabilityVersionDialog({ const ossKeyToSend = kind === "plugin" - ? pluginUpload.ossKey ?? undefined + ? (pluginUpload.ossKey ?? undefined) : kind === "skill" - ? skillOssKey ?? undefined + ? (skillOssKey ?? undefined) : undefined const uploadSourceToSend = kind === "plugin" - ? pluginUpload.uploadSource ?? undefined + ? (pluginUpload.uploadSource ?? undefined) : kind === "skill" && skillOssKey ? "zip" : undefined @@ -208,9 +213,7 @@ export function AddCapabilityVersionDialog({ const payload: ImportCapabilityVersionCommitRequest = { canonical_spec: fallbackSpec, inline_secrets: kind === "plugin" || inlineSecrets.length === 0 ? undefined : inlineSecrets, - source_payload: rawText - ? { raw_text: rawText, source_format: sourceFormat } - : undefined, + source_payload: rawText ? { raw_text: rawText, source_format: sourceFormat } : undefined, // omit oss_key on plugin/skill-zip reuse — backend treats missing key // as "carry forward the previous version's blob". oss_key: ossKeyToSend, @@ -243,16 +246,15 @@ export function AddCapabilityVersionDialog({ {t("capabilities.versions.add.title", { name: capability.name })} - - {t("capabilities.versions.add.description")} - + {t("capabilities.versions.add.description")} {prefill.didPrefill && ( {t("capabilities.versions.add.prefillFromLatest", { version: latestVersion?.version ?? "", - defaultValue: "Pre-filled with the previous version ({{version}}). Edits will be submitted as a new version.", + defaultValue: + "Pre-filled with the previous version ({{version}}). Edits will be submitted as a new version.", })} )} @@ -260,17 +262,17 @@ export function AddCapabilityVersionDialog({ {t("capabilities.versions.add.reuseExistingZip", { filename: inheritedOssLabel, - defaultValue: "Current version package: {{filename}}. If you do not re-upload, the new version will reuse this package.", + defaultValue: + "Current version package: {{filename}}. If you do not re-upload, the new version will reuse this package.", })} )} {inheritedInlineSecrets.length > 0 && ( {t("capabilities.versions.add.inlineSecretLostWarning", { - keys: inheritedInlineSecrets - .map((e) => `${e.server}.${e.envKey}`) - .join(", "), - defaultValue: "Previous-version inline secrets ({{keys}}) are hidden. Re-enter them in plaintext to keep, or switch to managed credentials.", + keys: inheritedInlineSecrets.map((e) => `${e.server}.${e.envKey}`).join(", "), + defaultValue: + "Previous-version inline secrets ({{keys}}) are hidden. Re-enter them in plaintext to keep, or switch to managed credentials.", })} )} @@ -302,7 +304,11 @@ export function AddCapabilityVersionDialog({ )}
- {kind === "mcp" ? ( + {latestVersionLoading ? ( +
+ +
+ ) : kind === "mcp" ? ( {t("capabilities.actions.cancel")} -
- {capability.description &&

{capability.description}

} + {capability.description && ( +

+ {capability.description} +

+ )}
- {t("capabilities.marketplace.card.latest", { version: capability.latest_version ?? "—" })} + + {t("capabilities.marketplace.card.latest", { + version: capability.latest_version ?? "—", + })} + · {t("capabilities.marketplace.card.added", { count })} · - {t("capabilities.marketplace.card.credential", { kind: requiredCredentialsLabel(capability.required_credentials, language, t("capabilities.credentials.none")) })} + + {t("capabilities.marketplace.card.credential", { + kind: requiredCredentialsLabel( + capability.required_credentials, + language, + t("capabilities.credentials.none"), + ), + })} +
@@ -144,7 +230,12 @@ function MarketplaceCard({ capability, language, onOpen, onInstall }: { ) } -function MarketplaceItemDetail({ capability, language, onBack, onInstall }: { +function MarketplaceItemDetail({ + capability, + language, + onBack, + onInstall, +}: { capability: MarketplaceCapability language: string onBack: () => void @@ -166,12 +257,34 @@ function MarketplaceItemDetail({ capability, language, onBack, onInstall }: {

{capability.name}

- {source &&

{t("capabilities.marketplace.card.source", { source })}

} - {capability.description &&

{capability.description}

} + {source && ( +

+ {t("capabilities.marketplace.card.source", { source })} +

+ )} + {capability.description && ( +

{capability.description}

+ )}
- - - + + +
{previewable && (
@@ -205,7 +318,9 @@ function MarketplaceItemDetail({ capability, language, onBack, onInstall }: { )}
@@ -377,7 +492,11 @@ function buildSkillFileTree(paths: string[]): SkillFileTreeNode[] { return roots } -function SkillFileTree({ paths, selectedPath, onSelect }: { +function SkillFileTree({ + paths, + selectedPath, + onSelect, +}: { paths: string[] selectedPath: string onSelect: (path: string) => void @@ -397,7 +516,12 @@ function SkillFileTree({ paths, selectedPath, onSelect }: { ) } -function SkillFileTreeItem({ node, selectedPath, onSelect, depth = 0 }: { +function SkillFileTreeItem({ + node, + selectedPath, + onSelect, + depth = 0, +}: { node: SkillFileTreeNode selectedPath: string onSelect: (path: string) => void @@ -422,15 +546,16 @@ function SkillFileTreeItem({ node, selectedPath, onSelect, depth = 0 }: { {node.name} - {expanded && node.children.map((child) => ( - - ))} + {expanded && + node.children.map((child) => ( + + ))} ) } @@ -464,7 +589,11 @@ function MCPPreview({ detail }: { detail: MarketplaceCapabilityDetail }) { const env = Object.entries(server.env ?? {}).sort(([left], [right]) => left.localeCompare(right), ) - const command = [server.command, ...(server.args ?? [])].map(formatCommandPart).join(" ") + const isRemote = server.transport === "streamable-http" + const command = [server.command ?? "", ...(server.args ?? [])] + .filter(Boolean) + .map(formatCommandPart) + .join(" ") return (
@@ -475,40 +604,46 @@ function MCPPreview({ detail }: { detail: MarketplaceCapabilityDetail }) {

- {t("capabilities.marketplace.detail.command")} + {t( + isRemote + ? "capabilities.mcpDirectory.detail.endpoint" + : "capabilities.marketplace.detail.command", + )}

-                  {command}
+                  {isRemote ? server.url : command}
                 
-
-

- {t("capabilities.marketplace.detail.environment")} -

- {env.length === 0 ? ( -

- {t("capabilities.marketplace.detail.noEnvironment")} + {!isRemote ? ( +

+

+ {t("capabilities.marketplace.detail.environment")}

- ) : ( -
- {env.map(([name, value]) => ( -
- {name} - - {formatMCPEnvValue( - value, - t("capabilities.marketplace.detail.redactedSecret"), - )} - -
- ))} -
- )} -
- {server.startup_timeout_sec ? ( + {env.length === 0 ? ( +

+ {t("capabilities.marketplace.detail.noEnvironment")} +

+ ) : ( +
+ {env.map(([name, value]) => ( +
+ {name} + + {formatMCPEnvValue( + value, + t("capabilities.marketplace.detail.redactedSecret"), + )} + +
+ ))} +
+ )} +
+ ) : null} + {!isRemote && server.startup_timeout_sec ? (

{t("capabilities.marketplace.detail.timeout", { seconds: server.startup_timeout_sec, diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 60b2cda8..8f9adeaa 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -57,7 +57,6 @@ import { useUndeprecate, useUninstall, useUnpublish, - type MarketplaceCapability, type TargetMarketplaceInstall, marketplaceSourceName, } from "../../../lib/api-marketplace" @@ -120,6 +119,7 @@ export function CapabilitiesPage() { const [toast, setToast] = useState(null) const workspaceRole = workspacesQ.data?.workspaces.find((w) => w.id === wid)?.role const isAdmin = workspaceRole === "owner" || workspaceRole === "admin" + const canCreateCapability = isAdmin || workspaceRole === "member" const marketInstallCountQ = useInstallCount(wid, marketTarget?.capability.id ?? null) const uninstallAgentsQ = useMarketplaceEnabledAgents(wid, uninstallTarget?.id ?? null) @@ -143,13 +143,13 @@ export function CapabilitiesPage() { navigate("capabilities", { tab: next === "marketplace" ? "marketplace" : null, item: null }) } const marketplaceItem = pageTab === "marketplace" ? itemParam : null - const goToAgentsForCapability = (capability: MarketplaceCapability) => { + const goToAgentsForCapability = (capabilityID: string) => { const url = new URL(window.location.href) url.searchParams.set("admin", "agents") url.searchParams.delete("id") url.searchParams.delete("tab") url.searchParams.delete("item") - url.searchParams.set("pendingCapability", capability.id) + url.searchParams.set("pendingCapability", capabilityID) window.history.pushState({}, "", url.toString()) window.dispatchEvent(new Event("admin:navigate")) } @@ -178,6 +178,9 @@ export function CapabilitiesPage() { const versionSummary = useCapabilityVersionSummary(wid, ownCapabilities) const latestVersions = versionSummary.latest const selectedLatestVersion = addVersionCapability ? latestVersions.get(addVersionCapability.id) : undefined + const selectedLatestVersionLoading = addVersionCapability + ? versionSummary.loading.has(addVersionCapability.id) + : false const uninstallAgents = uninstallAgentsQ.data ?? uninstallTarget?.enabled_agents ?? [] const enabledCounts = useMemo( () => countCapabilityInstalls(agentCapabilityQueries.map((q) => q.data?.installed ?? [])), @@ -229,7 +232,7 @@ export function CapabilitiesPage() { setImportOpen(true)}> {t("capabilities.actions.create")} @@ -245,7 +248,7 @@ export function CapabilitiesPage() { - {t("capabilities.permission.adminOnly")} + {t("capabilities.permission.create")} @@ -279,8 +282,11 @@ export function CapabilitiesPage() { itemID={marketplaceItem} query={query} typeFilter={typeFilter} + canImport={isAdmin} onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })} - onInstall={goToAgentsForCapability} + onInstall={(capability) => goToAgentsForCapability(capability.id)} + onViewCapability={(capabilityID) => navigate("capabilities", { id: capabilityID, tab: null, item: null })} + onAddToAgent={goToAgentsForCapability} /> ) : err ? ( setImportOpen(true)}> {t("capabilities.actions.create")} : undefined} + action={canCreateCapability ? : undefined} /> ) : ( @@ -424,6 +430,7 @@ export function CapabilitiesPage() { workspaceID={wid} open={importOpen} onOpenChange={setImportOpen} + skillOnly={!isAdmin} onCreated={(capabilityID) => { setToast(t("capabilities.toast.created", { name: capabilityID })) }} @@ -434,6 +441,7 @@ export function CapabilitiesPage() { open={!!addVersionCapability} capability={addVersionCapability} latestVersion={selectedLatestVersion} + latestVersionLoading={selectedLatestVersionLoading} onOpenChange={(open) => { if (open) return setAddVersionCapability(null) @@ -1047,6 +1055,7 @@ export function CapabilityDetailPage({ id }: { id: string }) { workspaceID={wid} capability={capability} latestVersion={latestVersion} + latestVersionLoading={versionsQ.isLoading} open={addVersionOpen} onOpenChange={(open) => { setAddVersionOpen(open) @@ -1291,7 +1300,7 @@ function useCapabilityVersionSummary(workspaceID: string | null, capabilities: C staleTime: 30_000, })), }) - return useMemo(() => { + const summary = useMemo(() => { const latest = new Map() const byCapability = new Map() queries.forEach((q, idx) => { @@ -1303,6 +1312,11 @@ function useCapabilityVersionSummary(workspaceID: string | null, capabilities: C return { latest, byCapability } // eslint-disable-next-line react-hooks/exhaustive-deps }, [capabilities, queries.map((q) => q.dataUpdatedAt).join(":")]) + const loading = new Set() + queries.forEach((query, index) => { + if (query.isLoading) loading.add(capabilities[index].id) + }) + return { ...summary, loading } } function useCapabilityEnabledAgents(wid: string | null, agents: Array<{ id: string; name: string }>, capability: Capability | null, versions: CapabilityVersion[]) { diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx new file mode 100644 index 00000000..264d7675 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from "react-i18next" + +import { Button } from "../../../../components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../components/ui/dialog" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" +import { formatCommandPart } from "./utils" + +export function ImportMCPDialog({ + open, + item, + loading, + error, + pending, + mutationError, + onRetry, + onOpenChange, + onConfirm, +}: { + open: boolean + item: MCPDirectoryItem | null + loading: boolean + error: unknown + pending: boolean + mutationError: unknown + onRetry: () => void + onOpenChange: (open: boolean) => void + onConfirm: () => void +}) { + const { t } = useTranslation("admin") + const command = item?.command + ? [item.command, ...(item.args ?? [])].map(formatCommandPart).join(" ") + : "" + const isRemote = item?.transport === "streamable-http" + return ( +

+ + + + {t("capabilities.mcpDirectory.import.title", { name: item?.name ?? "" })} + + {t("capabilities.mcpDirectory.import.description")} + + {loading ? ( +
+ + +
+ ) : error ? ( + + ) : item ? ( +
+
+

+ {t( + isRemote + ? "capabilities.mcpDirectory.detail.endpoint" + : "capabilities.mcpDirectory.detail.command", + )} +

+
+                {isRemote ? item.url : command}
+              
+
+
+

+ {t( + isRemote + ? "capabilities.mcpDirectory.detail.authentication" + : "capabilities.mcpDirectory.detail.environment", + )} +

+

+ {isRemote + ? t("capabilities.mcpDirectory.detail.noAuthentication") + : item.env?.join(", ") || t("capabilities.mcpDirectory.detail.noEnvironment")} +

+
+

+ {t("capabilities.mcpDirectory.securityNotice")} +

+
+ ) : null} + {mutationError ? ( +

+ {mutationError instanceof Error + ? mutationError.message + : t("capabilities.mcpDirectory.import.failed")} +

+ ) : null} + + + + +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx new file mode 100644 index 00000000..65906ded --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -0,0 +1,182 @@ +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Check, PackageCheck, Server } from "lucide-react" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import { + useImportMCPDirectoryItem, + useMCPDirectory, + useMCPDirectoryDetail, +} from "../../../../lib/api-marketplace" +import { useWorkspaceId } from "../../../../lib/workspace" +import { DirectoryCard } from "./MCPDirectoryCard" +import { DirectoryDetail } from "./MCPDirectoryDetail" +import { ImportMCPDialog } from "./ImportMCPDialog" +import { filterMCPDirectoryItems, type DirectorySort } from "./filters" + +interface MCPDirectoryProps { + itemID: string | null + query: string + canImport: boolean + onSelectItem: (id: string | null) => void + onViewCapability: (capabilityID: string) => void + onAddToAgent: (capabilityID: string) => void +} + +export function MCPDirectory({ + itemID, + query, + canImport, + onSelectItem, + onViewCapability, + onAddToAgent, +}: MCPDirectoryProps) { + const { t } = useTranslation("admin") + const workspaceID = useWorkspaceId() + const directoryQ = useMCPDirectory(workspaceID) + const importMut = useImportMCPDirectoryItem(workspaceID) + const [category, setCategory] = useState("") + const [verifiedOnly, setVerifiedOnly] = useState(false) + const [sort, setSort] = useState("popular") + const [confirmID, setConfirmID] = useState(null) + const [success, setSuccess] = useState<{ name: string; capabilityID: string } | null>(null) + const detailID = confirmID ?? itemID + const detailQ = useMCPDirectoryDetail(workspaceID, detailID) + + const items = useMemo(() => directoryQ.data?.items ?? [], [directoryQ.data?.items]) + const categories = useMemo( + () => Array.from(new Set(items.flatMap((item) => item.categories))).sort((left, right) => left.localeCompare(right)), + [items], + ) + const filtered = useMemo( + () => filterMCPDirectoryItems(items, { query, category, verifiedOnly, sort }), + [items, query, category, verifiedOnly, sort], + ) + const selectedSummary = items.find((item) => item.id === itemID) ?? null + const selected = detailQ.data?.id === itemID ? detailQ.data : selectedSummary + const confirmItem = detailQ.data?.id === confirmID ? detailQ.data : items.find((item) => item.id === confirmID) ?? null + + const requestImport = (id: string) => { + if (!canImport) return + importMut.reset() + setConfirmID(id) + } + const closeImportDialog = () => { + importMut.reset() + setConfirmID(null) + } + const confirmImport = () => { + if (!confirmID || !confirmItem || confirmItem.installed) return + importMut.mutate(confirmID, { + onSuccess: (result) => { + setSuccess({ name: confirmItem.name, capabilityID: result.capability_id }) + closeImportDialog() + }, + }) + } + + const importDialog = ( + void detailQ.refetch()} + onOpenChange={(open) => !open && closeImportDialog()} + onConfirm={confirmImport} + /> + ) + + if (itemID) { + return ( + <> + {success ? : null} + onSelectItem(null)} + onRetry={() => void detailQ.refetch()} + onImport={() => requestImport(itemID)} + onViewCapability={onViewCapability} + onAddToAgent={onAddToAgent} + /> + {importDialog} + + ) + } + + return ( +
+
+
+
+
+ +

{t("capabilities.mcpDirectory.title")}

+
+

{t("capabilities.mcpDirectory.description")}

+
+ {directoryQ.data?.source ? {t(`capabilities.mcpDirectory.source.${directoryQ.data.source}`)} : null} +
+
+
+ setCategory("")}>{t("capabilities.mcpDirectory.filters.allCategories")} + {categories.map((value) => setCategory(value)}>{value})} +
+ + +
+
+ + {success ? : null} + {directoryQ.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => )} +
+ ) : directoryQ.error ? ( + void directoryQ.refetch()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((item) => onSelectItem(item.id)} onImport={() => requestImport(item.id)} onViewCapability={onViewCapability} />)} +
+ )} + {importDialog} +
+ ) +} + +function SuccessBanner({ success, onViewCapability, onAddToAgent }: { + success: { name: string; capabilityID: string } + onViewCapability: (capabilityID: string) => void + onAddToAgent: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ +

{t("capabilities.mcpDirectory.import.success", { name: success.name })}

+ + +
+ ) +} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) { + return +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx new file mode 100644 index 00000000..6ba38b6c --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -0,0 +1,52 @@ +import { ArrowRight, Check } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" +import { ConnectorIcon, VerifiedBadge } from "./shared" + +export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapability }: { + item: MCPDirectoryItem + canImport: boolean + onOpen: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ +
+ {item.installed && item.installed_capability_id ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx new file mode 100644 index 00000000..2f5b4837 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx @@ -0,0 +1,206 @@ +import { ArrowLeft, Server, ShieldCheck } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" +import { ConnectorIcon, ExternalLinkRow, Metadata, VerifiedBadge } from "./shared" +import { formatCommandPart } from "./utils" + +export function DirectoryDetail({ + item, + loading, + error, + canImport, + onBack, + onRetry, + onImport, + onViewCapability, + onAddToAgent, +}: { + item: MCPDirectoryItem | null + loading: boolean + error: unknown + canImport: boolean + onBack: () => void + onRetry: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void + onAddToAgent: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + if (loading && !item) + return ( +
+ + +
+ ) + if (error) + return ( + + ) + if (!item) + return ( + + {t("capabilities.mcpDirectory.actions.back")} + + } + /> + ) + const command = [item.command ?? "", ...(item.args ?? [])] + .filter(Boolean) + .map(formatCommandPart) + .join(" ") + const isRemote = item.transport === "streamable-http" + return ( +
+ +
+
+ +
+
+

{item.name}

+ {item.verified ? : null} + {item.installed ? ( + {t("capabilities.mcpDirectory.actions.installed")} + ) : null} +
+

{item.publisher.name}

+

{item.description}

+
+
+
+ + + + +
+
+
+
+

+ {t( + isRemote + ? "capabilities.mcpDirectory.detail.endpoint" + : "capabilities.mcpDirectory.detail.command", + )} +

+
+                {isRemote ? item.url : command}
+              
+
+ {!isRemote ? ( +
+

+ {t("capabilities.mcpDirectory.detail.environment")} +

+ {(item.env ?? []).length ? ( +
+ {item.env?.map((name) => ( +
+ {name} +
+ ))} +
+ ) : ( +

+ {t("capabilities.mcpDirectory.detail.noEnvironment")} +

+ )} +
+ ) : null} +
+
+ + {t("capabilities.mcpDirectory.securityNotice")} +
+
+
+ +
+
+ {item.installed && item.installed_capability_id ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts new file mode 100644 index 00000000..c38956bc --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts @@ -0,0 +1,26 @@ +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" + +export type DirectorySort = "popular" | "name" + +interface DirectoryFilters { + query: string + category: string + verifiedOnly: boolean + sort: DirectorySort +} + +export function filterMCPDirectoryItems(items: MCPDirectoryItem[], filters: DirectoryFilters): MCPDirectoryItem[] { + const needle = filters.query.trim().toLocaleLowerCase() + const filtered = items.filter((item) => { + if (filters.category && !item.categories.includes(filters.category)) return false + if (filters.verifiedOnly && !item.verified) return false + if (!needle) return true + return [item.name, item.description, item.publisher.name, ...item.categories] + .join(" ") + .toLocaleLowerCase() + .includes(needle) + }) + return filtered.sort((left, right) => filters.sort === "name" + ? left.name.localeCompare(right.name) + : left.popularity_rank - right.popularity_rank || left.name.localeCompare(right.name)) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx new file mode 100644 index 00000000..c1d4540a --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx @@ -0,0 +1,43 @@ +import { ExternalLink, Server, ShieldCheck } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" + +export function ConnectorIcon({ item, large = false }: { item: MCPDirectoryItem; large?: boolean }) { + const size = large ? "h-14 w-14 rounded-xl" : "h-11 w-11 rounded-lg" + return ( + + {item.icon_url ? : } + + ) +} + +export function VerifiedBadge() { + const { t } = useTranslation("admin") + return {t("capabilities.mcpDirectory.verified")} +} + +export function Metadata({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return

{label}

{value}

+} + +export function ExternalLinkRow({ label, value, href }: { label: string; value: string; href?: string }) { + const safeHref = safeExternalURL(href) + return ( +
+

{label}

+ {safeHref ? {value} :

} +
+ ) +} + +function safeExternalURL(value?: string): string | undefined { + if (!value) return undefined + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined + } catch { + return undefined + } +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts b/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts new file mode 100644 index 00000000..a0396fcf --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts @@ -0,0 +1,3 @@ +export function formatCommandPart(value: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : JSON.stringify(value) +} diff --git a/apps/web/src/pages/admin/capabilities/types.ts b/apps/web/src/pages/admin/capabilities/types.ts index bd83b0a4..f6eb2365 100644 --- a/apps/web/src/pages/admin/capabilities/types.ts +++ b/apps/web/src/pages/admin/capabilities/types.ts @@ -31,7 +31,9 @@ export interface CanonicalEnvValue { export interface CanonicalMCPServer { name: string - command: string + transport?: "stdio" | "streamable-http" + url?: string + command?: string args?: string[] env?: Record startup_timeout_sec?: number diff --git a/catalog/mcp/README.md b/catalog/mcp/README.md new file mode 100644 index 00000000..e867da67 --- /dev/null +++ b/catalog/mcp/README.md @@ -0,0 +1,41 @@ +# MCP Connector Directory Catalog + +`catalog.json` is the repository-maintained source for Parsar's built-in MCP +Connector Directory. It contains metadata plus either stdio launch configuration +or a credential-free Streamable HTTP endpoint. Importing an item saves a +workspace capability and never executes it. + +## Updating the catalog + +- Add only MCP servers that can be verified in an official repository or the + official MCP Registry. +- Keep `id` stable and unique. Renaming an item does not require changing its + ID. +- Pin npm and Python packages to an explicit version. Do not use `latest`. +- Stdio entries use `command`, `args`, `env`, and `startup_timeout_sec`. +- Streamable HTTP entries use an HTTPS `url` only. Built-in remote entries must + complete an MCP initialize request without headers, API keys, OAuth, or other + user credentials before they are added. +- Catalog entries may rely on tools such as `npx` or `uvx` being available in + the eventual Runtime. Importing a connector does not install those tools or + download its package. +- `env` declares variable names. Every value must be an empty string; secrets, + API keys, tokens, and passwords must never be committed to the catalog. +- Use only `http` or `https` metadata URLs without embedded credentials. Remote + MCP endpoints must use HTTPS. +- Update `updated_at` whenever catalog content changes. + +Validate changes with the Go tests in `server/internal/mcpcatalog` and the full +repository gate: + +```bash +go test ./server/internal/mcpcatalog +make check +``` + +## Remote catalog override + +Operators may set `PARSAR_MCP_CATALOG_URL` to a trusted JSON endpoint with the +same schema. Parsar applies a bounded download size, HTTP timeout, redirect +limit, and full structural validation. A failed remote load falls back to the +embedded catalog. Catalog URLs cannot be supplied through an API request. diff --git a/catalog/mcp/catalog.json b/catalog/mcp/catalog.json new file mode 100644 index 00000000..145b220b --- /dev/null +++ b/catalog/mcp/catalog.json @@ -0,0 +1,509 @@ +{ + "schema_version": 1, + "updated_at": "2026-07-22T06:52:30Z", + "items": [ + { + "id": "filesystem", + "name": "Filesystem", + "description": "Read and write files within directories explicitly exposed to the MCP server.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Developer Tools", "Files"], + "popularity_rank": 1, + "version": "2026.7.10", + "transport": "stdio", + "server": { + "name": "filesystem", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem@2026.7.10", + "." + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "playwright", + "name": "Playwright", + "description": "Automate browser navigation and interaction through structured accessibility snapshots.", + "publisher": { + "name": "Microsoft", + "url": "https://github.com/microsoft" + }, + "icon_url": "https://github.com/microsoft.png?size=128", + "homepage_url": "https://playwright.dev", + "repository_url": "https://github.com/microsoft/playwright-mcp", + "verified": true, + "categories": ["Developer Tools", "Browser Automation"], + "popularity_rank": 2, + "version": "0.0.78", + "transport": "stdio", + "server": { + "name": "playwright", + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@0.0.78", + "--headless" + ], + "env": {}, + "startup_timeout_sec": 60 + } + }, + { + "id": "context7", + "name": "Context7", + "description": "Retrieve current library documentation and code examples for coding workflows.", + "publisher": { + "name": "Upstash", + "url": "https://github.com/upstash" + }, + "icon_url": "https://github.com/upstash.png?size=128", + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "verified": true, + "categories": ["Developer Tools", "Documentation"], + "popularity_rank": 3, + "version": "3.2.4", + "transport": "stdio", + "server": { + "name": "context7", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@3.2.4" + ], + "env": { + "CONTEXT7_API_KEY": "" + }, + "startup_timeout_sec": 30 + } + }, + { + "id": "fetch", + "name": "Fetch", + "description": "Fetch web content and convert it into a model-friendly representation.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Web", "Research"], + "popularity_rank": 4, + "version": "2026.7.10", + "transport": "stdio", + "server": { + "name": "fetch", + "command": "uvx", + "args": [ + "--from", + "mcp-server-fetch==2026.7.10", + "mcp-server-fetch" + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "git", + "name": "Git", + "description": "Inspect, search, and modify Git repositories available in the configured working directory.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Developer Tools", "Version Control"], + "popularity_rank": 5, + "version": "2026.7.10", + "transport": "stdio", + "server": { + "name": "git", + "command": "uvx", + "args": [ + "--from", + "mcp-server-git==2026.7.10", + "mcp-server-git", + "--repository", + "." + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "memory", + "name": "Memory", + "description": "Store and retrieve persistent knowledge through a local knowledge graph.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Productivity", "Knowledge"], + "popularity_rank": 6, + "version": "2026.7.4", + "transport": "stdio", + "server": { + "name": "memory", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory@2026.7.4" + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "time", + "name": "Time", + "description": "Get the current time and convert values between IANA time zones.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/time", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Productivity", "Utilities"], + "popularity_rank": 7, + "version": "2026.7.10", + "transport": "stdio", + "server": { + "name": "time", + "command": "uvx", + "args": [ + "--from", + "mcp-server-time==2026.7.10", + "mcp-server-time" + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "sequential-thinking", + "name": "Sequential Thinking", + "description": "Break complex problems into explicit, revisable reasoning steps.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Developer Tools", "Reasoning"], + "popularity_rank": 8, + "version": "2026.7.4", + "transport": "stdio", + "server": { + "name": "sequential-thinking", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking@2026.7.4" + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "everything", + "name": "Everything", + "description": "Exercise MCP tools, resources, prompts, sampling, and other protocol features for client testing.", + "publisher": { + "name": "Model Context Protocol", + "url": "https://github.com/modelcontextprotocol" + }, + "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", + "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything", + "repository_url": "https://github.com/modelcontextprotocol/servers", + "verified": true, + "categories": ["Developer Tools", "Testing"], + "popularity_rank": 9, + "version": "2026.7.4", + "transport": "stdio", + "server": { + "name": "everything", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-everything@2026.7.4" + ], + "env": {}, + "startup_timeout_sec": 30 + } + }, + { + "id": "cloudflare-docs", + "name": "Cloudflare Documentation", + "description": "Search Cloudflare product documentation and retrieve current implementation guidance.", + "publisher": { + "name": "Cloudflare", + "url": "https://www.cloudflare.com" + }, + "icon_url": "https://github.com/cloudflare.png?size=128", + "homepage_url": "https://developers.cloudflare.com/agents/model-context-protocol/mcp-servers-for-cloudflare/", + "repository_url": "https://github.com/cloudflare/mcp-server-cloudflare", + "verified": true, + "categories": ["Documentation", "Cloud"], + "popularity_rank": 10, + "version": "0.4.9", + "transport": "streamable-http", + "server": { + "name": "cloudflare-docs", + "url": "https://docs.mcp.cloudflare.com/mcp" + } + }, + { + "id": "microsoft-learn", + "name": "Microsoft Learn", + "description": "Search official Microsoft technical documentation and code samples.", + "publisher": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "icon_url": "https://github.com/MicrosoftDocs.png?size=128", + "homepage_url": "https://github.com/MicrosoftDocs/mcp", + "repository_url": "https://github.com/MicrosoftDocs/mcp", + "verified": true, + "categories": ["Documentation", "Developer Tools"], + "popularity_rank": 11, + "version": "1.0.0", + "transport": "streamable-http", + "server": { + "name": "microsoft-learn", + "url": "https://learn.microsoft.com/api/mcp" + } + }, + { + "id": "aws-knowledge", + "name": "AWS Knowledge", + "description": "Search AWS documentation, API references, architecture guidance, and service information.", + "publisher": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "icon_url": "https://github.com/awslabs.png?size=128", + "homepage_url": "https://awslabs.github.io/mcp/servers/aws-knowledge-mcp-server", + "repository_url": "https://github.com/awslabs/mcp", + "verified": true, + "categories": ["Documentation", "Cloud"], + "popularity_rank": 12, + "version": "1.0.0", + "transport": "streamable-http", + "server": { + "name": "aws-knowledge", + "url": "https://knowledge-mcp.global.api.aws" + } + }, + { + "id": "deepwiki", + "name": "DeepWiki", + "description": "Read public GitHub repositories as generated documentation and ask repository questions.", + "publisher": { + "name": "Cognition", + "url": "https://www.cognition.ai" + }, + "icon_url": "https://deepwiki.com/favicon.ico", + "homepage_url": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + "verified": true, + "categories": ["Documentation", "Version Control"], + "popularity_rank": 13, + "version": "2.14.3", + "transport": "streamable-http", + "server": { + "name": "deepwiki", + "url": "https://mcp.deepwiki.com/mcp" + } + }, + { + "id": "agent-web", + "name": "Agent Web", + "description": "Read public web pages as clean model-ready content while respecting robots.txt.", + "publisher": { + "name": "Foomworks", + "url": "https://github.com/foomworks" + }, + "icon_url": "https://github.com/foomworks.png?size=128", + "homepage_url": "https://agent-web.foomworks.workers.dev", + "repository_url": "https://github.com/foomworks/agent-web", + "verified": false, + "categories": ["Web", "Research"], + "popularity_rank": 14, + "version": "0.2.1", + "transport": "streamable-http", + "server": { + "name": "agent-web", + "url": "https://agent-web.foomworks.workers.dev/mcp" + } + }, + { + "id": "arxiv", + "name": "arXiv", + "description": "Search arXiv papers, retrieve metadata, and inspect available full text.", + "publisher": { + "name": "cyanheads", + "url": "https://github.com/cyanheads" + }, + "icon_url": "https://github.com/cyanheads.png?size=128", + "homepage_url": "https://github.com/cyanheads/arxiv-mcp-server", + "repository_url": "https://github.com/cyanheads/arxiv-mcp-server", + "verified": false, + "categories": ["Research", "Science"], + "popularity_rank": 15, + "version": "1.2.15", + "transport": "streamable-http", + "server": { + "name": "arxiv", + "url": "https://arxiv.caseyjhand.com/mcp" + } + }, + { + "id": "pubmed", + "name": "PubMed", + "description": "Search biomedical literature and retrieve article metadata, citations, and available full text.", + "publisher": { + "name": "cyanheads", + "url": "https://github.com/cyanheads" + }, + "icon_url": "https://github.com/cyanheads.png?size=128", + "homepage_url": "https://github.com/cyanheads/pubmed-mcp-server", + "repository_url": "https://github.com/cyanheads/pubmed-mcp-server", + "verified": false, + "categories": ["Research", "Health"], + "popularity_rank": 16, + "version": "2.9.8", + "transport": "streamable-http", + "server": { + "name": "pubmed", + "url": "https://pubmed.caseyjhand.com/mcp" + } + }, + { + "id": "us-weather", + "name": "US Weather", + "description": "Get United States forecasts, active alerts, and current observations from public weather data.", + "publisher": { + "name": "cyanheads", + "url": "https://github.com/cyanheads" + }, + "icon_url": "https://github.com/cyanheads.png?size=128", + "homepage_url": "https://github.com/cyanheads/nws-weather-mcp-server", + "repository_url": "https://github.com/cyanheads/nws-weather-mcp-server", + "verified": false, + "categories": ["Utilities", "Weather"], + "popularity_rank": 17, + "version": "0.7.2", + "transport": "streamable-http", + "server": { + "name": "us-weather", + "url": "https://nws.caseyjhand.com/mcp" + } + }, + { + "id": "mdn-search", + "name": "MDN Search", + "description": "Search MDN Web Docs for browser APIs, JavaScript, CSS, and HTML guidance.", + "publisher": { + "name": "PipeWorx", + "url": "https://pipeworx.io" + }, + "icon_url": "https://github.com/pipeworx-io.png?size=128", + "homepage_url": "https://pipeworx.io/packs/mdn-search", + "repository_url": "https://github.com/pipeworx-io/mcp-mdn-search", + "verified": false, + "categories": ["Documentation", "Web"], + "popularity_rank": 18, + "version": "0.1.0", + "transport": "streamable-http", + "server": { + "name": "mdn-search", + "url": "https://gateway.pipeworx.io/mdn-search/mcp" + } + }, + { + "id": "npm-registry", + "name": "npm Registry", + "description": "Look up public npm packages, versions, metadata, maintainers, and download information.", + "publisher": { + "name": "PipeWorx", + "url": "https://pipeworx.io" + }, + "icon_url": "https://github.com/pipeworx-io.png?size=128", + "homepage_url": "https://pipeworx.io/packs/npm", + "repository_url": "https://github.com/pipeworx-io/mcp-npm", + "verified": false, + "categories": ["Developer Tools", "Packages"], + "popularity_rank": 19, + "version": "0.1.0", + "transport": "streamable-http", + "server": { + "name": "npm-registry", + "url": "https://gateway.pipeworx.io/npm/mcp" + } + }, + { + "id": "docker-hub", + "name": "Docker Hub", + "description": "Search public Docker Hub repositories, tags, image metadata, and pull statistics.", + "publisher": { + "name": "PipeWorx", + "url": "https://pipeworx.io" + }, + "icon_url": "https://github.com/pipeworx-io.png?size=128", + "homepage_url": "https://pipeworx.io/packs/dockerhub", + "repository_url": "https://github.com/pipeworx-io/mcp-dockerhub", + "verified": false, + "categories": ["Developer Tools", "Containers"], + "popularity_rank": 20, + "version": "0.1.0", + "transport": "streamable-http", + "server": { + "name": "docker-hub", + "url": "https://gateway.pipeworx.io/dockerhub/mcp" + } + }, + { + "id": "wikipedia", + "name": "Wikipedia", + "description": "Search Wikipedia and retrieve public article summaries and page content.", + "publisher": { + "name": "PipeWorx", + "url": "https://pipeworx.io" + }, + "icon_url": "https://github.com/pipeworx-io.png?size=128", + "homepage_url": "https://pipeworx.io/packs/wikipedia", + "repository_url": "https://github.com/pipeworx-io/mcp-wikipedia", + "verified": false, + "categories": ["Research", "Knowledge"], + "popularity_rank": 21, + "version": "0.1.0", + "transport": "streamable-http", + "server": { + "name": "wikipedia", + "url": "https://gateway.pipeworx.io/wikipedia/mcp" + } + } + ] +} diff --git a/catalog/mcp/catalog.schema.json b/catalog/mcp/catalog.schema.json new file mode 100644 index 00000000..6a05e9b8 --- /dev/null +++ b/catalog/mcp/catalog.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/MiniMax-AI-Dev/parsar/catalog/mcp/catalog.schema.json", + "title": "Parsar MCP Connector Directory Catalog", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "updated_at", "items"], + "properties": { + "schema_version": { "const": 1 }, + "updated_at": { "type": "string", "format": "date-time" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/item" } + } + }, + "$defs": { + "httpUrl": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "publisher": { + "type": "object", + "additionalProperties": false, + "required": ["name", "url"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpUrl" } + } + }, + "server": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpUrl" }, + "command": { "type": "string", "minLength": 1 }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "object", + "additionalProperties": { "const": "" } + }, + "startup_timeout_sec": { "type": "integer", "minimum": 0, "maximum": 300 } + } + }, + "item": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "publisher", + "verified", + "categories", + "popularity_rank", + "version", + "transport", + "server" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "publisher": { "$ref": "#/$defs/publisher" }, + "icon_url": { "$ref": "#/$defs/httpUrl" }, + "homepage_url": { "$ref": "#/$defs/httpUrl" }, + "repository_url": { "$ref": "#/$defs/httpUrl" }, + "verified": { "type": "boolean" }, + "categories": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "popularity_rank": { "type": "integer", "minimum": 1 }, + "version": { "type": "string", "minLength": 1 }, + "transport": { "enum": ["stdio", "streamable-http"] }, + "server": { "$ref": "#/$defs/server" } + }, + "allOf": [ + { + "if": { "properties": { "transport": { "const": "stdio" } } }, + "then": { + "properties": { + "server": { "required": ["name", "command", "args", "env", "startup_timeout_sec"] } + } + } + }, + { + "if": { "properties": { "transport": { "const": "streamable-http" } } }, + "then": { + "properties": { + "server": { + "required": ["name", "url"], + "not": { "anyOf": [ + { "required": ["command"] }, + { "required": ["args"] }, + { "required": ["env"] } + ] } + } + } + } + } + ] + } + } +} diff --git a/catalog/mcp/embed.go b/catalog/mcp/embed.go new file mode 100644 index 00000000..11c9d9ad --- /dev/null +++ b/catalog/mcp/embed.go @@ -0,0 +1,9 @@ +package mcpcatalogdata + +import _ "embed" + +//go:embed catalog.json +var CatalogJSON []byte + +//go:embed catalog.schema.json +var CatalogSchemaJSON []byte diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 141f810e..d042323c 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -63,6 +63,10 @@ PARSAR_PUBLIC_URL=https://parsar. # openssl rand -hex 32 PARSAR_MASTER_KEY= +# Optional trusted JSON endpoint for the MCP Connector Directory. Leave empty +# to use the catalog embedded in the server image. +PARSAR_MCP_CATALOG_URL= + # ----------------------------------------------------------------------------- # Optional Feishu SSO + event subscription # ----------------------------------------------------------------------------- diff --git a/deploy/compose/compose.selfhost.yml b/deploy/compose/compose.selfhost.yml index 14319fd1..fa00384e 100644 --- a/deploy/compose/compose.selfhost.yml +++ b/deploy/compose/compose.selfhost.yml @@ -100,6 +100,7 @@ services: DATABASE_URL: postgres://${PARSAR_PG_USER}:${PARSAR_PG_PASSWORD}@postgres:5432/${PARSAR_PG_DB}?sslmode=disable PARSAR_MASTER_KEY: ${PARSAR_MASTER_KEY:?PARSAR_MASTER_KEY is required - generate with openssl rand -hex 32} PARSAR_PUBLIC_URL: ${PARSAR_PUBLIC_URL:?PARSAR_PUBLIC_URL is required - e.g. https://parsar.your-domain.com} + PARSAR_MCP_CATALOG_URL: ${PARSAR_MCP_CATALOG_URL:-} # ---------- Listen / runtime path ---------- PARSAR_ADDR: ":8080" diff --git a/docker-compose.yml b/docker-compose.yml index 3fb9d43f..cbbb0939 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: PARSAR_MASTER_KEY: "${PARSAR_MASTER_KEY:-0000000000000000000000000000000000000000000000000000000000000000}" PARSAR_SHARED_RUNTIME_TOKEN: "${PARSAR_SHARED_RUNTIME_TOKEN:-parsar-local-runtime-token-change-me}" PARSAR_AGENT_DAEMON_WS_URL: "ws://parsar-server:8080/agent-daemon/ws" + PARSAR_MCP_CATALOG_URL: "${PARSAR_MCP_CATALOG_URL:-}" volumes: - ${PARSAR_DATA_DIR:-server-data}:/var/lib/parsar healthcheck: diff --git a/docs/deploy/deploy-runbook.md b/docs/deploy/deploy-runbook.md index 9093cd01..413cd630 100644 --- a/docs/deploy/deploy-runbook.md +++ b/docs/deploy/deploy-runbook.md @@ -98,6 +98,7 @@ the repo**. | Bootstrap token | `PARSAR_BOOTSTRAP_TOKEN` | empty (HTTP bootstrap off) | | Dev auth toggle | `PARSAR_DEV_AUTH` | `false` (must be false in production) | | Runtime profile | `PARSAR_RUNTIME_PROFILE` | `managed` for managed deployments where the platform manages cloud sandboxes | +| MCP catalog override | `PARSAR_MCP_CATALOG_URL` | empty (use the catalog embedded in the server image) | Feishu OAuth / event-related env vars are documented in [feishu-prod.md](./feishu-prod.md). @@ -251,7 +252,7 @@ layer**. To make a deployment truly production-ready you still need: | Smoke — end-to-end AgentRun / audit / usage | Missing `/api/v1/workspaces/{wid}/{agent-runs,audit-records,usage}` and other cookie-session entry points; smoke-core marks this SKIP/TODO | Later phase | | Real audit sink (Kafka / self-hosted storage) | In-memory + Postgres sink for now; the interface is already abstracted | Later phase | | Memory L0-L3 | Not implemented | Later phase | -| Capability marketplace | Not implemented | Later phase | +| Capability marketplace | Workspace-published Skill market and repository-backed stdio / Streamable HTTP MCP Connector Directory are available | — | **Invariants delivered by this track:** diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index fe850d59..c558ac62 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -114,6 +114,9 @@ definitions: description: user id type: string deliveryID: + description: |- + DeliveryID is the caller's stable idempotency base. The agent-daemon + connector adds a unique suffix for each wire attempt before awaiting ack. type: string deviceID: type: string @@ -133,6 +136,7 @@ definitions: cancelled: type: boolean deliveryID: + description: DeliveryID follows PermissionDecision's stable-base semantics. type: string deviceID: type: string @@ -1128,6 +1132,82 @@ definitions: workspace_id: type: string type: object + mcpcatalog.Publisher: + properties: + name: + type: string + url: + type: string + type: object + mcpdirectory.importResponse: + properties: + capability: + $ref: '#/definitions/store.CapabilityRead' + capability_id: + type: string + created: + type: boolean + installed: + type: boolean + type: object + mcpdirectory.itemResponse: + properties: + args: + items: + type: string + type: array + categories: + items: + type: string + type: array + command: + type: string + description: + type: string + env: + items: + type: string + type: array + homepage_url: + type: string + icon_url: + type: string + id: + type: string + installed: + type: boolean + installed_capability_id: + type: string + name: + type: string + popularity_rank: + type: integer + publisher: + $ref: '#/definitions/mcpcatalog.Publisher' + repository_url: + type: string + startup_timeout_sec: + type: integer + transport: + type: string + url: + type: string + verified: + type: boolean + version: + type: string + type: object + mcpdirectory.listResponse: + properties: + items: + items: + $ref: '#/definitions/mcpdirectory.itemResponse' + type: array + source: + type: string + updated_at: + type: string + type: object password.errorResponse: properties: code: @@ -1509,6 +1589,43 @@ definitions: workspace_id: type: string type: object + store.CapabilityRead: + properties: + created_at: + type: string + creator_id: + type: string + deleted_at: + type: string + deprecated_at: + type: string + description: + type: string + id: + type: string + latest_version: + type: string + latest_version_created_at: + type: string + latest_version_id: + type: string + name: + type: string + required_credentials: + items: + $ref: '#/definitions/store.RequiredCredential' + type: array + status: + type: string + type: + type: string + updated_at: + type: string + visibility: + type: string + workspace_id: + type: string + type: object store.CredentialKindRead: properties: built_in: @@ -6237,7 +6354,8 @@ paths: - application/json description: Encrypts inline_secrets then runs the whole MCP or Skill import (capability + capability_version + secrets) in a single transaction. For Skill - zip imports the canonical_spec is rebuilt from OSS bytes. Owner/admin only. + zip imports the canonical_spec is rebuilt from OSS bytes. Members may create + workspace-private Skills; MCP remains owner/admin only. operationId: commitDevCapabilityImport parameters: - description: Workspace UUID @@ -6266,8 +6384,8 @@ paths: type: string type: object "403": - description: Caller is not workspace owner/admin, or oss_key not owned by - this workspace + description: Caller lacks permission for this capability kind, requests + public visibility as a member, or oss_key is not owned by this workspace schema: additionalProperties: type: string @@ -6298,7 +6416,8 @@ paths: consumes: - application/json description: Pure parse for MCP or Skill imports. Skill zip uploads are downloaded - from object storage and validated. Owner/admin only. + from object storage and validated. Members may import Skills; MCP remains + owner/admin only. operationId: previewDevCapabilityImport parameters: - description: Workspace UUID @@ -6327,8 +6446,8 @@ paths: type: string type: object "403": - description: Caller is not workspace owner/admin, or oss_key not owned by - this workspace + description: Caller lacks permission for this capability kind, or oss_key + is not owned by this workspace schema: additionalProperties: type: string @@ -6914,6 +7033,136 @@ paths: summary: Resolve a pending approval or user question tags: - interactions + /api/v1/workspaces/{workspaceID}/mcp-directory: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcpdirectory.listResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: List MCP Connector Directory items + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcpdirectory.itemResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get an MCP Connector Directory item + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import: + post: + description: Saves the catalog entry as a private workspace MCP capability. + It does not execute the MCP server or bind it to an agent. + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + produces: + - application/json + responses: + "200": + description: already installed + schema: + $ref: '#/definitions/mcpdirectory.importResponse' + "201": + description: imported + schema: + $ref: '#/definitions/mcpdirectory.importResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + summary: Import an MCP Connector Directory item + tags: + - mcp-directory /api/v1/workspaces/{workspaceID}/members: get: description: Returns members of the workspace. Caller must be a workspace member. @@ -8418,7 +8667,8 @@ paths: - application/json description: Returns a presigned URL the browser PUTs the plugin/skill zip to. The blob backend (OSS or PG) mints a workspace-scoped ref that later downloads - verify against. Caller must be workspace capability admin. + verify against. Members may upload Skill zips; Plugin uploads remain owner/admin + only. operationId: createDevWorkspaceUploadPresign parameters: - description: Workspace UUID @@ -8446,7 +8696,7 @@ paths: type: string type: object "403": - description: Caller lacks capability admin permission + description: Caller lacks permission for the requested upload kind schema: additionalProperties: type: string diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1f50fd4d..0d227418 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -40,6 +40,7 @@ import ( agentdaemongateway "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/server/internal/api" imhistoryapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/imhistoryapi" + mcpdirectoryapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/mcpdirectory" runtimeapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/runtime" specmemapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/specmem" "github.com/MiniMax-AI-Dev/parsar/server/internal/audit" @@ -66,6 +67,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/inbound/teamsrunner" "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/inflight" "github.com/MiniMax-AI-Dev/parsar/server/internal/interaction" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" "github.com/MiniMax-AI-Dev/parsar/server/internal/otlp" "github.com/MiniMax-AI-Dev/parsar/server/internal/runstream" "github.com/MiniMax-AI-Dev/parsar/server/internal/runtime/scheduler" @@ -704,10 +706,17 @@ func main() { Store: dbStore, SharedRuntimeToken: strings.TrimSpace(envLookup("PARSAR_SHARED_RUNTIME_TOKEN")), } + mcpCatalog := mcpcatalog.New(mcpcatalog.Options{ + RemoteURL: strings.TrimSpace(envLookup(mcpcatalog.EnvCatalogURL)), + }) sessionStore := auth.NewPostgresSessionStore(sqlc.New(pool)) authMw := auth.NewMiddleware(sessionStore).WithDevAuth(cfg.Auth.DevAuth) r.Group(func(r chi.Router) { r.Use(authMw.Require) + mcpdirectoryapi.RegisterRoutes(r, mcpdirectoryapi.Deps{ + Catalog: mcpCatalog, + Store: dbStore, + }) runtimeapi.RegisterAdminRoutes(r, runtimeDeps) }) runtimeapi.RegisterRunnerRoutes(r, runtimeDeps) diff --git a/server/internal/api/mcpdirectory/handler.go b/server/internal/api/mcpdirectory/handler.go new file mode 100644 index 00000000..7b68717d --- /dev/null +++ b/server/internal/api/mcpdirectory/handler.go @@ -0,0 +1,327 @@ +// Package mcpdirectory exposes the repository-backed MCP Connector Directory. +// Directory items are imported as ordinary workspace MCP capabilities; this +// package does not execute servers or create agent bindings. +package mcpdirectory + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type catalogLoader interface { + Load(ctx context.Context) (mcpcatalog.Snapshot, error) +} + +type directoryStore interface { + auth.RoleStore + ListMCPDirectoryInstalls(ctx context.Context, workspaceID string) ([]store.MCPDirectoryInstall, error) + ImportCapability(ctx context.Context, input store.ImportCapabilityInput) (store.ImportCapabilityResult, error) +} + +type Deps struct { + Catalog catalogLoader + Store directoryStore +} + +type handler struct { + deps Deps +} + +type itemResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Publisher mcpcatalog.Publisher `json:"publisher"` + IconURL string `json:"icon_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Verified bool `json:"verified"` + Categories []string `json:"categories"` + PopularityRank int `json:"popularity_rank"` + Version string `json:"version"` + Transport string `json:"transport"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env []string `json:"env,omitempty"` + StartupTimeoutSec int `json:"startup_timeout_sec,omitempty"` + Installed bool `json:"installed"` + InstalledCapabilityID *string `json:"installed_capability_id"` +} + +type listResponse struct { + Items []itemResponse `json:"items"` + UpdatedAt string `json:"updated_at"` + Source string `json:"source"` +} + +type importResponse struct { + Installed bool `json:"installed"` + CapabilityID string `json:"capability_id"` + Created bool `json:"created"` + Capability *store.CapabilityRead `json:"capability,omitempty"` +} + +type sourcePayload struct { + SourceFormat string `json:"source_format"` + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CatalogSource string `json:"catalog_source"` +} + +func RegisterRoutes(r chi.Router, deps Deps) { + h := &handler{deps: deps} + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory", h.list) + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}", h.get) + r.Post("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import", h.importItem) +} + +// list godoc +// +// @Summary List MCP Connector Directory items +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Success 200 {object} listResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory [get] +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, false) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + byCatalog := installMap(installs) + items := make([]itemResponse, 0, len(snapshot.Catalog.Items)) + for _, item := range snapshot.Catalog.Items { + items = append(items, summarizeItem(item, byCatalog[item.ID])) + } + writeJSON(w, http.StatusOK, listResponse{ + Items: items, + UpdatedAt: snapshot.Catalog.UpdatedAt, + Source: string(snapshot.Source), + }) +} + +// get godoc +// +// @Summary Get an MCP Connector Directory item +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Success 200 {object} itemResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID} [get] +func (h *handler) get(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, false) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := snapshot.Find(chi.URLParam(r, "catalogID")) + if !found { + writeError(w, http.StatusNotFound, "connector_not_found") + return + } + response := summarizeItem(item, installMap(installs)[item.ID]) + response.URL = item.Server.URL + response.Command = item.Server.Command + response.Args = append([]string(nil), item.Server.Args...) + response.Env = sortedEnvNames(item.Server.Env) + response.StartupTimeoutSec = item.Server.StartupTimeoutSec + writeJSON(w, http.StatusOK, response) +} + +// importItem godoc +// +// @Summary Import an MCP Connector Directory item +// @Description Saves the catalog entry as a private workspace MCP capability. It does not execute the MCP server or bind it to an agent. +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Success 200 {object} importResponse "already installed" +// @Success 201 {object} importResponse "imported" +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import [post] +func (h *handler) importItem(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, true) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := snapshot.Find(chi.URLParam(r, "catalogID")) + if !found { + writeError(w, http.StatusNotFound, "connector_not_found") + return + } + if existing, installed := installMap(installs)[item.ID]; installed { + writeJSON(w, http.StatusOK, importResponse{Installed: true, CapabilityID: existing.CapabilityID}) + return + } + + payload, err := json.Marshal(sourcePayload{ + SourceFormat: "mcp_catalog", + CatalogID: item.ID, + CatalogVersion: item.Version, + CatalogSource: string(snapshot.Source), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "catalog_source_encode_failed") + return + } + result, err := h.deps.Store.ImportCapability(r.Context(), store.ImportCapabilityInput{ + WorkspaceID: workspaceID, + Name: item.Name, + Description: item.Description, + Visibility: "workspace", + Type: "mcp", + CreatorID: auth.UserIDFromContext(r.Context()), + Version: item.Version, + SourcePayload: payload, + Spec: item.CanonicalSpec(), + }) + if err != nil { + if errors.Is(err, store.ErrCapabilityNameTaken) { + // A concurrent identical import can lose the capability name race. + // Re-read provenance before reporting a real name conflict. + if current, listErr := h.deps.Store.ListMCPDirectoryInstalls(r.Context(), workspaceID); listErr == nil { + if existing, installed := installMap(current)[item.ID]; installed { + writeJSON(w, http.StatusOK, importResponse{Installed: true, CapabilityID: existing.CapabilityID}) + return + } + } + writeError(w, http.StatusConflict, "capability_name_conflict") + return + } + writeError(w, http.StatusInternalServerError, "connector_import_failed") + return + } + writeJSON(w, http.StatusCreated, importResponse{ + Installed: true, + CapabilityID: result.Capability.ID, + Created: true, + Capability: &result.Capability, + }) +} + +func (h *handler) authorize(w http.ResponseWriter, r *http.Request, admin bool) (string, bool) { + if h.deps.Catalog == nil || h.deps.Store == nil { + writeError(w, http.StatusServiceUnavailable, "mcp_directory_unavailable") + return "", false + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if _, err := uuid.Parse(workspaceID); err != nil { + writeError(w, http.StatusBadRequest, "invalid_workspace_id") + return "", false + } + allowed := []string{"owner", "admin", "member", "viewer"} + if admin { + allowed = []string{"owner", "admin"} + } + if err := auth.RequireWorkspaceRole(r.Context(), h.deps.Store, workspaceID, allowed...); err != nil { + switch { + case errors.Is(err, auth.ErrUnauthenticated): + writeError(w, http.StatusUnauthorized, "unauthenticated") + case errors.Is(err, auth.ErrForbidden), errors.Is(err, auth.ErrNotMember): + writeError(w, http.StatusForbidden, "forbidden") + default: + writeError(w, http.StatusInternalServerError, "workspace_authorization_failed") + } + return "", false + } + return workspaceID, true +} + +func (h *handler) load(w http.ResponseWriter, r *http.Request, workspaceID string) (mcpcatalog.Snapshot, []store.MCPDirectoryInstall, bool) { + snapshot, err := h.deps.Catalog.Load(r.Context()) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "mcp_catalog_unavailable") + return mcpcatalog.Snapshot{}, nil, false + } + installs, err := h.deps.Store.ListMCPDirectoryInstalls(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "directory_install_state_failed") + return mcpcatalog.Snapshot{}, nil, false + } + return snapshot, installs, true +} + +func installMap(installs []store.MCPDirectoryInstall) map[string]store.MCPDirectoryInstall { + result := make(map[string]store.MCPDirectoryInstall, len(installs)) + for _, install := range installs { + result[install.CatalogID] = install + } + return result +} + +func summarizeItem(item mcpcatalog.Item, install store.MCPDirectoryInstall) itemResponse { + var installedCapabilityID *string + if install.CapabilityID != "" { + id := install.CapabilityID + installedCapabilityID = &id + } + return itemResponse{ + ID: item.ID, + Name: item.Name, + Description: item.Description, + Publisher: item.Publisher, + IconURL: item.IconURL, + HomepageURL: item.HomepageURL, + RepositoryURL: item.RepositoryURL, + Verified: item.Verified, + Categories: append([]string(nil), item.Categories...), + PopularityRank: item.PopularityRank, + Version: item.Version, + Transport: item.Transport, + Installed: install.CapabilityID != "", + InstalledCapabilityID: installedCapabilityID, + } +} + +func sortedEnvNames(env map[string]string) []string { + result := make([]string, 0, len(env)) + for name := range env { + result = append(result, name) + } + slices.Sort(result) + return result +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} diff --git a/server/internal/api/mcpdirectory/handler_test.go b/server/internal/api/mcpdirectory/handler_test.go new file mode 100644 index 00000000..2024b09d --- /dev/null +++ b/server/internal/api/mcpdirectory/handler_test.go @@ -0,0 +1,220 @@ +package mcpdirectory + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +const ( + testWorkspaceID = "00000000-0000-0000-0000-000000000011" + testUserID = "00000000-0000-0000-0000-000000000022" + testCapabilityID = "00000000-0000-0000-0000-000000000033" +) + +type fakeCatalog struct { + snapshot mcpcatalog.Snapshot + err error +} + +func (f fakeCatalog) Load(context.Context) (mcpcatalog.Snapshot, error) { return f.snapshot, f.err } + +type fakeDirectoryStore struct { + role string + roleErr error + installs []store.MCPDirectoryInstall + listErr error + importErr error + concurrentInstall bool + imported *store.ImportCapabilityInput +} + +func (f *fakeDirectoryStore) GetWorkspaceMemberRole(context.Context, string, string) (string, error) { + if f.roleErr != nil { + return "", f.roleErr + } + return f.role, nil +} + +func (f *fakeDirectoryStore) ListMCPDirectoryInstalls(context.Context, string) ([]store.MCPDirectoryInstall, error) { + return append([]store.MCPDirectoryInstall(nil), f.installs...), f.listErr +} + +func (f *fakeDirectoryStore) ImportCapability(_ context.Context, input store.ImportCapabilityInput) (store.ImportCapabilityResult, error) { + f.imported = &input + if f.importErr != nil { + if f.concurrentInstall { + f.installs = append(f.installs, store.MCPDirectoryInstall{CatalogID: "filesystem", CatalogVersion: "1.0.0", CapabilityID: testCapabilityID}) + } + return store.ImportCapabilityResult{}, f.importErr + } + f.installs = append(f.installs, store.MCPDirectoryInstall{CatalogID: "filesystem", CatalogVersion: "1.0.0", CapabilityID: testCapabilityID}) + return store.ImportCapabilityResult{Capability: store.CapabilityRead{ID: testCapabilityID, Name: input.Name, Type: input.Type}}, nil +} + +func TestDirectoryReadAllowsWorkspaceMember(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response listResponse + decodeResponse(t, rec, &response) + if len(response.Items) != 1 || response.Items[0].ID != "filesystem" { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryImportRequiresAdmin(t *testing.T) { + for _, role := range []string{"member", "viewer"} { + t.Run(role, func(t *testing.T) { + fs := &fakeDirectoryStore{role: role} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/filesystem/import") + if rec.Code != http.StatusForbidden || fs.imported != nil { + t.Fatalf("status=%d imported=%v body=%s", rec.Code, fs.imported != nil, rec.Body.String()) + } + }) + } +} + +func TestDirectoryImportUsesServerCatalogAndCreatesNoSecretsOrBindings(t *testing.T) { + for _, role := range []string{"owner", "admin"} { + t.Run(role, func(t *testing.T) { + fs := &fakeDirectoryStore{role: role} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/filesystem/import") + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + input := fs.imported + if input == nil || input.Type != "mcp" || input.Visibility != "workspace" || input.CreatorID != testUserID { + t.Fatalf("input=%+v", input) + } + if len(input.InlineSecrets) != 0 { + t.Fatalf("inline secrets=%+v", input.InlineSecrets) + } + if input.Spec.MCP == nil || input.Spec.MCP.Servers[0].Command != "npx" { + t.Fatalf("spec=%+v", input.Spec) + } + var source sourcePayload + if err := json.Unmarshal(input.SourcePayload, &source); err != nil { + t.Fatal(err) + } + if source.SourceFormat != "mcp_catalog" || source.CatalogID != "filesystem" || source.CatalogSource != "builtin" { + t.Fatalf("source=%+v", source) + } + }) + } +} + +func TestDirectoryImportIsIdempotent(t *testing.T) { + fs := &fakeDirectoryStore{role: "admin", installs: []store.MCPDirectoryInstall{{CatalogID: "filesystem", CapabilityID: testCapabilityID}}} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/filesystem/import") + if rec.Code != http.StatusOK || fs.imported != nil { + t.Fatalf("status=%d imported=%v body=%s", rec.Code, fs.imported != nil, rec.Body.String()) + } + var response importResponse + decodeResponse(t, rec, &response) + if !response.Installed || response.CapabilityID != testCapabilityID || response.Created { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryImportRecoversConcurrentIdenticalImport(t *testing.T) { + fs := &fakeDirectoryStore{role: "admin", importErr: store.ErrCapabilityNameTaken, concurrentInstall: true} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/filesystem/import") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryUnknownCatalogItem(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/unknown") + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryDetailIncludesStreamableHTTPURL(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + snapshot := testSnapshot() + snapshot.Catalog.Items = []mcpcatalog.Item{{ + ID: "docs", Name: "Docs", Description: "Search docs.", + Publisher: mcpcatalog.Publisher{Name: "Publisher", URL: "https://example.com"}, + Verified: true, Categories: []string{"Documentation"}, PopularityRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Server: mcpcatalog.Server{Name: "docs", URL: "https://docs.example.com/mcp"}, + }} + rec := requestWithSnapshot(t, fs, snapshot, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/docs") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response itemResponse + decodeResponse(t, rec, &response) + if response.Transport != "streamable-http" || response.URL != "https://docs.example.com/mcp" || response.Command != "" { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryRejectsNonMember(t *testing.T) { + fs := &fakeDirectoryStore{roleErr: store.ErrNotMember} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory") + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryRejectsInvalidWorkspaceID(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/not-a-uuid/mcp-directory") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func request(t *testing.T, fs *fakeDirectoryStore, method, path string) *httptest.ResponseRecorder { + return requestWithSnapshot(t, fs, testSnapshot(), method, path) +} + +func requestWithSnapshot(t *testing.T, fs *fakeDirectoryStore, snapshot mcpcatalog.Snapshot, method, path string) *httptest.ResponseRecorder { + t.Helper() + router := chi.NewRouter() + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), testUserID))) + }) + }) + RegisterRoutes(router, Deps{Catalog: fakeCatalog{snapshot: snapshot}, Store: fs}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func testSnapshot() mcpcatalog.Snapshot { + return mcpcatalog.Snapshot{Source: mcpcatalog.SourceBuiltin, Catalog: mcpcatalog.Catalog{ + SchemaVersion: 1, + UpdatedAt: "2026-07-22T00:00:00Z", + Items: []mcpcatalog.Item{{ + ID: "filesystem", Name: "Filesystem", Description: "Access configured files.", + Publisher: mcpcatalog.Publisher{Name: "MCP", URL: "https://example.com"}, + Verified: true, Categories: []string{"Files"}, PopularityRank: 1, + Version: "1.0.0", Transport: "stdio", + Server: mcpcatalog.Server{Name: "filesystem", Command: "npx", Args: []string{"package@1.0.0"}, Env: map[string]string{"ROOT": ""}, StartupTimeoutSec: 30}, + }}, + }} +} + +func decodeResponse(t *testing.T, rec *httptest.ResponseRecorder, target any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } +} diff --git a/server/internal/capability/canonical/mcp.go b/server/internal/capability/canonical/mcp.go index 3b123eb1..a32622bf 100644 --- a/server/internal/capability/canonical/mcp.go +++ b/server/internal/capability/canonical/mcp.go @@ -2,29 +2,43 @@ package canonical import ( "fmt" + "net/url" "strings" ) -// MCPSpec carries one or more MCP stdio servers. HTTP transport is not -// modeled; the import pipeline only accepts stdio. +const ( + MCPTransportStdio = "stdio" + MCPTransportStreamableHTTP = "streamable-http" +) + +// MCPSpec carries one or more MCP servers. Existing specs omit transport and +// therefore continue to resolve as stdio. type MCPSpec struct { Servers []MCPServer `json:"servers"` } -// MCPServer is one launchable MCP stdio server. Command + Args stay separate -// because renderers join them differently (Claude Code accepts string or -// array; OpenCode wants an array). +// MCPServer is either a launchable stdio process or a streamable HTTP URL. +// Command + Args stay separate because renderers join them differently. // // StartupTimeoutSec=0 means "use scaffold default"; preserved because Codex's // TOML uses it explicitly. type MCPServer struct { Name string `json:"name"` - Command string `json:"command"` + Transport string `json:"transport,omitempty"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]EnvValue `json:"env,omitempty"` StartupTimeoutSec int `json:"startup_timeout_sec,omitempty"` } +func (s MCPServer) EffectiveTransport() string { + if strings.TrimSpace(s.Transport) == "" { + return MCPTransportStdio + } + return strings.ToLower(strings.TrimSpace(s.Transport)) +} + // Validate checks structure only — it does NOT resolve cross-table references // (e.g. SecretID existence). Commit-time checks live in the import handler. func (m MCPSpec) Validate() error { @@ -49,12 +63,28 @@ func (s MCPServer) Validate() error { if strings.TrimSpace(s.Name) == "" { return fmt.Errorf("%w: server name is required", ErrInvalidMCP) } - if strings.TrimSpace(s.Command) == "" { - return fmt.Errorf("%w: server %q: command is required", ErrInvalidMCP, s.Name) - } if s.StartupTimeoutSec < 0 { return fmt.Errorf("%w: server %q: startup_timeout_sec must be >= 0", ErrInvalidMCP, s.Name) } + switch s.EffectiveTransport() { + case MCPTransportStdio: + if strings.TrimSpace(s.Command) == "" { + return fmt.Errorf("%w: server %q: command is required", ErrInvalidMCP, s.Name) + } + if strings.TrimSpace(s.URL) != "" { + return fmt.Errorf("%w: server %q: stdio transport must not set url", ErrInvalidMCP, s.Name) + } + case MCPTransportStreamableHTTP: + parsed, err := url.Parse(strings.TrimSpace(s.URL)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return fmt.Errorf("%w: server %q: streamable-http url must be an http or https URL without embedded credentials", ErrInvalidMCP, s.Name) + } + if strings.TrimSpace(s.Command) != "" || len(s.Args) > 0 || len(s.Env) > 0 { + return fmt.Errorf("%w: server %q: streamable-http transport must not set command, args, or env", ErrInvalidMCP, s.Name) + } + default: + return fmt.Errorf("%w: server %q: unsupported transport %q", ErrInvalidMCP, s.Name, s.Transport) + } for name, value := range s.Env { if strings.TrimSpace(name) == "" { return fmt.Errorf("%w: server %q: empty env name", ErrInvalidMCP, s.Name) diff --git a/server/internal/capability/canonical/spec_test.go b/server/internal/capability/canonical/spec_test.go index 05a1fba9..a5dc4cc5 100644 --- a/server/internal/capability/canonical/spec_test.go +++ b/server/internal/capability/canonical/spec_test.go @@ -144,3 +144,18 @@ func TestMCPSpec_ValidateDetectsDuplicateName(t *testing.T) { t.Fatalf("expected duplicate name error, got %v", err) } } + +func TestMCPSpec_ValidateStreamableHTTP(t *testing.T) { + s := MCPSpec{Servers: []MCPServer{{ + Name: "docs", + Transport: MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}} + if err := s.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + s.Servers[0].Command = "npx" + if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "must not set command") { + t.Fatalf("expected remote command rejection, got %v", err) + } +} diff --git a/server/internal/capability/parser/mcp_parser.go b/server/internal/capability/parser/mcp_parser.go index 90ecfa11..1bbc1bac 100644 --- a/server/internal/capability/parser/mcp_parser.go +++ b/server/internal/capability/parser/mcp_parser.go @@ -59,6 +59,7 @@ type jsonMCPServer struct { Args []string `json:"args"` Env map[string]string `json:"env"` Environment map[string]string `json:"environment"` + URL string `json:"url"` StartupTimeoutSec int `json:"startup_timeout_sec"` Enabled *bool `json:"enabled"` Type string `json:"type"` @@ -104,21 +105,31 @@ func buildMCPResult(servers map[string]jsonMCPServer) (MCPParseResult, error) { warnings = append(warnings, "ignored server with empty name") continue } - // Only stdio is modeled; HTTP/SSE servers parse but renderers may fail. - if t := strings.ToLower(strings.TrimSpace(srv.Type)); t != "" && t != "stdio" { - warnings = append(warnings, fmt.Sprintf("server %q has type=%q which is not supported in v1 (only stdio); the entry will still be parsed but downstream renderers may fail", name, t)) - } - command, args, err := flattenMCPCommand(srv.Command, srv.Args) + transport, err := normalizeMCPTransport(srv.Type, srv.URL) if err != nil { - return MCPParseResult{}, fmt.Errorf("mcp parse: server %q command: %w", name, err) + return MCPParseResult{}, fmt.Errorf("mcp parse: server %q: %w", name, err) + } + var command string + var args []string + var env map[string]canonical.EnvValue + if transport == canonical.MCPTransportStdio { + command, args, err = flattenMCPCommand(srv.Command, srv.Args) + if err != nil { + return MCPParseResult{}, fmt.Errorf("mcp parse: server %q command: %w", name, err) + } + var envWarnings []string + env, envWarnings = mergeMCPEnv(name, srv.Env, srv.Environment) + warnings = append(warnings, envWarnings...) + } else if srv.Command != nil || len(srv.Args) > 0 || len(srv.Env) > 0 || len(srv.Environment) > 0 { + return MCPParseResult{}, fmt.Errorf("mcp parse: server %q: remote HTTP entries must not set command, args, env, or environment", name) } - env, envWarnings := mergeMCPEnv(name, srv.Env, srv.Environment) - warnings = append(warnings, envWarnings...) if srv.Enabled != nil && !*srv.Enabled { warnings = append(warnings, fmt.Sprintf("server %q has enabled=false — the parser preserves the entry but the renderer will treat all imported servers as enabled", name)) } out.Servers = append(out.Servers, canonical.MCPServer{ Name: name, + Transport: transport, + URL: strings.TrimSpace(srv.URL), Command: command, Args: args, Env: env, @@ -140,6 +151,27 @@ func buildMCPResult(servers map[string]jsonMCPServer) (MCPParseResult, error) { }, nil } +func normalizeMCPTransport(rawType, rawURL string) (string, error) { + t := strings.ToLower(strings.TrimSpace(rawType)) + hasURL := strings.TrimSpace(rawURL) != "" + switch t { + case "", canonical.MCPTransportStdio, "local": + if hasURL { + return canonical.MCPTransportStreamableHTTP, nil + } + return canonical.MCPTransportStdio, nil + case "http", canonical.MCPTransportStreamableHTTP, "remote": + if !hasURL { + return "", fmt.Errorf("type=%q requires url", t) + } + return canonical.MCPTransportStreamableHTTP, nil + case "sse", "ws", "websocket": + return "", fmt.Errorf("type=%q is not supported; use streamable HTTP", t) + default: + return "", fmt.Errorf("unsupported type=%q", t) + } +} + // flattenMCPCommand normalizes "command" + "args" to a single executable + // positional args. Vendor docs use a string; some examples use an array. // When given an array we split the first element off as the executable. @@ -220,6 +252,7 @@ type tomlCodexServer struct { Command string `toml:"command"` Args []string `toml:"args"` Env map[string]string `toml:"env"` + URL string `toml:"url"` StartupTimeoutSec int `toml:"startup_timeout_sec"` } @@ -238,6 +271,7 @@ func parseMCPTOML(raw string) (MCPParseResult, error) { Command: srv.Command, Args: srv.Args, Env: srv.Env, + URL: srv.URL, StartupTimeoutSec: srv.StartupTimeoutSec, } } diff --git a/server/internal/capability/parser/mcp_parser_test.go b/server/internal/capability/parser/mcp_parser_test.go index c43c14af..79faa1f6 100644 --- a/server/internal/capability/parser/mcp_parser_test.go +++ b/server/internal/capability/parser/mcp_parser_test.go @@ -180,20 +180,26 @@ func TestParseMCP_MultipleServersSortedAndFirstSuggested(t *testing.T) { } } -// TestParseMCP_HTTPTransportEmitsWarning: HTTP/SSE servers parse but warn -// since downstream renderers may not handle them. -func TestParseMCP_HTTPTransportEmitsWarning(t *testing.T) { +func TestParseMCP_StreamableHTTP(t *testing.T) { raw := `{ "mcpServers": { - "http-server": {"command": "x", "type": "http"} + "docs": {"type": "http", "url": "https://docs.example.com/mcp"} } }` res, err := ParseMCP(raw, SourceFormatJSON) if err != nil { t.Fatalf("parse: %v", err) } - if len(res.Warnings) == 0 || !strings.Contains(strings.Join(res.Warnings, "|"), "type=") { - t.Fatalf("expected a warning mentioning type=, got %v", res.Warnings) + srv := res.Spec.MCP.Servers[0] + if srv.Transport != canonical.MCPTransportStreamableHTTP || srv.URL != "https://docs.example.com/mcp" || srv.Command != "" { + t.Fatalf("remote server = %+v", srv) + } +} + +func TestParseMCP_RejectsSSE(t *testing.T) { + _, err := ParseMCP(`{"mcpServers":{"legacy":{"type":"sse","url":"https://example.com/sse"}}}`, SourceFormatJSON) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("error = %v", err) } } diff --git a/server/internal/capability/render/claudecode.go b/server/internal/capability/render/claudecode.go index fb76d4db..ed0b04d9 100644 --- a/server/internal/capability/render/claudecode.go +++ b/server/internal/capability/render/claudecode.go @@ -28,6 +28,8 @@ type claudeCodeMCPDocument struct { } type claudeCodeMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -73,6 +75,10 @@ func renderClaudeCodeMCP(s *canonical.MCPSpec) (Output, error) { } doc := claudeCodeMCPDocument{MCPServers: make(map[string]claudeCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = claudeCodeMCPServer{Type: "http", URL: srv.URL} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("claudecode render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/codex.go b/server/internal/capability/render/codex.go index eaf6037c..109d8408 100644 --- a/server/internal/capability/render/codex.go +++ b/server/internal/capability/render/codex.go @@ -36,6 +36,8 @@ type codexMCPDocument struct { } type codexMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -69,6 +71,10 @@ func renderCodexMCP(s *canonical.MCPSpec) (Output, error) { } doc := codexMCPDocument{MCPServers: make(map[string]codexMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = codexMCPServer{Type: "http", URL: srv.URL} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("codex render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/opencode.go b/server/internal/capability/render/opencode.go index a932ea1c..a1b5799c 100644 --- a/server/internal/capability/render/opencode.go +++ b/server/internal/capability/render/opencode.go @@ -25,6 +25,8 @@ type openCodeMCPDocument struct { // Enabled is always true — per-server enable/disable is not modeled in // canonical.Spec; every server in a Spec is wanted. type openCodeMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -58,6 +60,10 @@ func renderOpenCodeMCP(s *canonical.MCPSpec) (Output, error) { } doc := openCodeMCPDocument{MCPServers: make(map[string]openCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = openCodeMCPServer{Type: "remote", URL: srv.URL, Enabled: true} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("opencode render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/renderer_test.go b/server/internal/capability/render/renderer_test.go index 7916a2aa..7f13a6af 100644 --- a/server/internal/capability/render/renderer_test.go +++ b/server/internal/capability/render/renderer_test.go @@ -45,6 +45,18 @@ func skillFixture() canonical.Spec { } } +func remoteMCPFixture() canonical.Spec { + return canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: "docs", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}}, + } +} + // TestFor_KnownTargets catches "added a Target without wiring For()". func TestFor_KnownTargets(t *testing.T) { for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi} { @@ -135,6 +147,21 @@ func TestClaudeCodeRenderer_MCPGolden(t *testing.T) { } } +func TestClaudeCodeRenderer_StreamableHTTP(t *testing.T) { + out, err := claudeCodeRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got claudeCodeMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "http" || srv.URL != "https://docs.example.com/mcp" || srv.Command != "" { + t.Fatalf("server = %+v", srv) + } +} + func TestClaudeCodeRenderer_SkillGolden(t *testing.T) { out, err := claudeCodeRenderer{}.Render(context.Background(), skillFixture()) if err != nil { @@ -212,6 +239,36 @@ func TestCodexRenderer_MCPGolden(t *testing.T) { } } +func TestCodexRenderer_StreamableHTTP(t *testing.T) { + out, err := codexRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got codexMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "http" || srv.URL != "https://docs.example.com/mcp" || srv.Command != "" { + t.Fatalf("server = %+v", srv) + } +} + +func TestOpenCodeRenderer_StreamableHTTP(t *testing.T) { + out, err := openCodeRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got openCodeMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "remote" || srv.URL != "https://docs.example.com/mcp" || !srv.Enabled { + t.Fatalf("server = %+v", srv) + } +} + // TestCodexRenderer_SkillAndPluginUnsupported pins the soft-degrade // contract — codex must return ErrUnsupported for Skill and Plugin so // the agentdaemon connector skips them with a Disabled notice instead diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index ad874a7e..46a968a0 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -600,6 +600,17 @@ func (c *Connector) resolveMCPCapability( // Build the daemon-consumable map: server_name → config object. result := map[string]any{} for name, server := range parsed.MCPServers { + if server.URL != "" { + entry := map[string]any{"url": server.URL} + if server.Type != "" { + entry["type"] = server.Type + } + if server.Enabled != nil { + entry["enabled"] = *server.Enabled + } + result[name] = entry + continue + } env := map[string]string{} for key, value := range server.Env { if match := credentialPlaceholderRe.FindStringSubmatch(value); match != nil { @@ -673,9 +684,9 @@ func (c *Connector) resolveMCPCapability( // // - values: kind → decrypted plaintext // - sharedSecretIDs: kind → secret_id (only for shared bindings; used -// by audit emits) +// by audit emits) // - missing: kinds the resolver could not fulfil (personal-binding -// kinds whose initiator has not configured the credential) +// kinds whose initiator has not configured the credential) // // A missing entry DOES NOT short-circuit — the caller treats them as // "this MCP must be disabled this turn". Decrypt / payload-shape errors @@ -848,9 +859,12 @@ type claudeCodeMCPDocument struct { } type claudeCodeMCPServerEntry struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // resolveSkillCapability mirrors resolvePluginCapability — skill and diff --git a/server/internal/connector/agentdaemon/capability_runtime_test.go b/server/internal/connector/agentdaemon/capability_runtime_test.go index d25fab12..f3e75f5c 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_test.go @@ -427,6 +427,26 @@ func TestResolveCapabilityAdditions_MCPNoCreds(t *testing.T) { } } +func TestResolveCapabilityAdditions_MCPStreamableHTTP(t *testing.T) { + row := newMCPRow(t, "mcp-http", "docs", []canonical.MCPServer{{ + Name: "docs", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}, nil) + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + server := got.MCPServers["docs"].(map[string]any) + if server["type"] != "http" || server["url"] != "https://docs.example.com/mcp" { + t.Fatalf("server = %+v", server) + } +} + func TestResolveCapabilityAdditions_MCPWithCredential(t *testing.T) { svc := testSecretsService(t) ciphertext := encryptPayload(t, svc, map[string]any{"token": "ghp_realtoken123"}) diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 882ff2d4..a021d26c 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3572,6 +3572,23 @@ where c.workspace_id = @workspace_id::uuid and c.deleted_at is null order by c.name asc, c.created_at desc; +-- name: ListMCPDirectoryInstalls :many +-- Catalog provenance lives on capability versions rather than the capability +-- row. Keep the newest matching provenance per catalog id so a later catalog +-- re-import can update catalog_version without creating a second install. +select distinct on (cv.source_payload->>'catalog_id') + coalesce(cv.source_payload->>'catalog_id', '')::text as catalog_id, + coalesce(cv.source_payload->>'catalog_version', '')::text as catalog_version, + c.id::text as capability_id +from capability c +join capability_version cv on cv.capability_id = c.id +where c.workspace_id = @workspace_id::uuid + and c.type = 'mcp' + and c.deleted_at is null + and cv.source_payload->>'source_format' = 'mcp_catalog' + and coalesce(cv.source_payload->>'catalog_id', '') <> '' +order by cv.source_payload->>'catalog_id', cv.created_at desc, cv.id desc; + -- name: UpdateCapability :one update capability set name = @name, diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index 7cae5414..d7b04318 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8280,6 +8280,50 @@ func (q *Queries) ListIdleSandboxBindings(ctx context.Context, arg ListIdleSandb return items, nil } +const listMCPDirectoryInstalls = `-- name: ListMCPDirectoryInstalls :many +select distinct on (cv.source_payload->>'catalog_id') + coalesce(cv.source_payload->>'catalog_id', '')::text as catalog_id, + coalesce(cv.source_payload->>'catalog_version', '')::text as catalog_version, + c.id::text as capability_id +from capability c +join capability_version cv on cv.capability_id = c.id +where c.workspace_id = $1::uuid + and c.type = 'mcp' + and c.deleted_at is null + and cv.source_payload->>'source_format' = 'mcp_catalog' + and coalesce(cv.source_payload->>'catalog_id', '') <> '' +order by cv.source_payload->>'catalog_id', cv.created_at desc, cv.id desc +` + +type ListMCPDirectoryInstallsRow struct { + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CapabilityID string `json:"capability_id"` +} + +// Catalog provenance lives on capability versions rather than the capability +// row. Keep the newest matching provenance per catalog id so a later catalog +// re-import can update catalog_version without creating a second install. +func (q *Queries) ListMCPDirectoryInstalls(ctx context.Context, workspaceID pgtype.UUID) ([]ListMCPDirectoryInstallsRow, error) { + rows, err := q.db.Query(ctx, listMCPDirectoryInstalls, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMCPDirectoryInstallsRow{} + for rows.Next() { + var i ListMCPDirectoryInstallsRow + if err := rows.Scan(&i.CatalogID, &i.CatalogVersion, &i.CapabilityID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMarketplaceCapabilities = `-- name: ListMarketplaceCapabilities :many with installed as ( select distinct ac.capability_id diff --git a/server/internal/dev/capability_import_routes.go b/server/internal/dev/capability_import_routes.go index a8b1185c..71c1baa7 100644 --- a/server/internal/dev/capability_import_routes.go +++ b/server/internal/dev/capability_import_routes.go @@ -99,7 +99,7 @@ type commitCapabilityImportResponse struct { // Pure parse for mcp/skill (no DB writes). // // @Summary Preview a capability import -// @Description Pure parse for MCP or Skill imports. Skill zip uploads are downloaded from object storage and validated. Owner/admin only. +// @Description Pure parse for MCP or Skill imports. Skill zip uploads are downloaded from object storage and validated. Members may import Skills; MCP remains owner/admin only. // @Tags capabilities // @ID previewDevCapabilityImport // @Accept json @@ -108,16 +108,12 @@ type commitCapabilityImportResponse struct { // @Param body body previewCapabilityImportBody true "Import preview payload (kind, raw_text or oss_key)" // @Success 200 {object} map[string]interface{} "Parsed canonical spec, warnings, suggested name" // @Failure 400 {object} map[string]string "Missing kind, unknown source_format, or parse error" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin, or oss_key not owned by this workspace" +// @Failure 403 {object} map[string]string "Caller lacks permission for this capability kind, or oss_key is not owned by this workspace" // @Failure 502 {object} map[string]string "Failed to fetch uploaded zip from object storage" // @Failure 503 {object} map[string]string "Object storage or database not configured" // @Router /api/v1/workspaces/{workspaceID}/capabilities/import/preview [post] func previewCapabilityImport(runtimeStore RuntimeStore, blobStore blob.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) - if !ok { - return - } var body previewCapabilityImportBody if err := decodeBody(r, &body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) @@ -128,13 +124,19 @@ func previewCapabilityImport(runtimeStore RuntimeStore, blobStore blob.Store) ht writeJSON(w, http.StatusBadRequest, map[string]string{"error": "kind is required (mcp|skill)"}) return } + if kind != string(canonical.KindMCP) && kind != string(canonical.KindSkill) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unknown kind %q (want mcp|skill)", kind)}) + return + } + workspaceID, _, ok := requireWorkspaceCapabilityImport(w, r, runtimeStore, kind) + if !ok { + return + } switch kind { case "mcp": previewMCPOrSkillImport(w, body, parseAsMCP) case "skill": previewSkillImport(r.Context(), w, workspaceID, body, blobStore) - default: - writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unknown kind %q (want mcp|skill)", kind)}) } } } @@ -324,7 +326,7 @@ func previewPluginImport(ctx context.Context, w http.ResponseWriter, workspaceID // is rebuilt from OSS bytes (the on-disk zip is authoritative). // // @Summary Commit a capability import -// @Description Encrypts inline_secrets then runs the whole MCP or Skill import (capability + capability_version + secrets) in a single transaction. For Skill zip imports the canonical_spec is rebuilt from OSS bytes. Owner/admin only. +// @Description Encrypts inline_secrets then runs the whole MCP or Skill import (capability + capability_version + secrets) in a single transaction. For Skill zip imports the canonical_spec is rebuilt from OSS bytes. Members may create workspace-private Skills; MCP remains owner/admin only. // @Tags capabilities // @ID commitDevCapabilityImport // @Accept json @@ -333,21 +335,13 @@ func previewPluginImport(ctx context.Context, w http.ResponseWriter, workspaceID // @Param body body commitCapabilityImportBody true "Import commit payload" // @Success 201 {object} map[string]interface{} "Created capability, version, and secret ids" // @Failure 400 {object} map[string]string "Missing name/kind, unknown kind, or spec rebuild failed" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin, or oss_key not owned by this workspace" +// @Failure 403 {object} map[string]string "Caller lacks permission for this capability kind, requests public visibility as a member, or oss_key is not owned by this workspace" // @Failure 500 {object} map[string]string "Secrets service unavailable" // @Failure 502 {object} map[string]string "Failed to fetch uploaded zip from object storage" // @Failure 503 {object} map[string]string "Object storage or database not configured" // @Router /api/v1/workspaces/{workspaceID}/capabilities/import/commit [post] func commitCapabilityImport(runtimeStore RuntimeStore, blobStore blob.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) - if !ok { - return - } - actorID, ok := devActorID(w, r) - if !ok { - return - } var body commitCapabilityImportBody if err := decodeBody(r, &body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) @@ -365,6 +359,26 @@ func commitCapabilityImport(runtimeStore RuntimeStore, blobStore blob.Store) htt writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unknown kind %q (want mcp|skill)", kind)}) return } + if body.CanonicalSpec.Kind != canonical.Kind(kind) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "canonical_spec.kind must match kind"}) + return + } + if bodyType := strings.ToLower(strings.TrimSpace(body.Type)); bodyType != "" && bodyType != kind { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must match kind"}) + return + } + workspaceID, isAdmin, ok := requireWorkspaceCapabilityImport(w, r, runtimeStore, kind) + if !ok { + return + } + if !isAdmin && strings.EqualFold(strings.TrimSpace(body.Visibility), "public") { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "members may only import workspace-private skills"}) + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } // Skill-zip imports rebuild canonical_spec server-side from the OSS // zip; the client-supplied spec is discarded so forged file metadata @@ -419,7 +433,7 @@ func commitCapabilityImport(runtimeStore RuntimeStore, blobStore blob.Store) htt Name: body.Name, Description: body.Description, Visibility: body.Visibility, - Type: fallback(body.Type, kind), + Type: kind, CreatorID: actorID, Version: body.Version, SourcePayload: sourcePayload, diff --git a/server/internal/dev/capability_import_routes_test.go b/server/internal/dev/capability_import_routes_test.go index d7d97a0f..39706862 100644 --- a/server/internal/dev/capability_import_routes_test.go +++ b/server/internal/dev/capability_import_routes_test.go @@ -100,6 +100,111 @@ func TestCapabilityImportPreview_DefaultsEnvToLiteral(t *testing.T) { } } +func TestCapabilityImport_MemberMayImportWorkspaceSkillsOnly(t *testing.T) { + ids := store.DefaultDevFixtureIDs() + r, _ := capabilityTestRouter(t, map[string]string{ids.UserID: "member"}, nil) + + rawSkill := "---\nslug: member-skill\ntitle: Member Skill\ndescription: A workspace skill imported by a member\n---\n# Instructions\n\nDo the requested task." + previewBody := mustJSON(t, map[string]any{ + "kind": "skill", + "source_format": "markdown", + "raw_text": rawSkill, + }) + preview := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/preview", + previewBody, ids.UserID) + if preview.Code != http.StatusOK { + t.Fatalf("member skill preview expected 200, got %d: %s", preview.Code, preview.Body.String()) + } + + skillSpec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindSkill, + Skill: &canonical.SkillSpec{ + Slug: "member-skill", + Title: "Member Skill", + Description: "A workspace skill imported by a member", + Instruction: "# Instructions\n\nDo the requested task.", + }, + } + commitBody := mustJSON(t, map[string]any{ + "kind": "skill", + "name": "Member Skill", + "type": "skill", + "canonical_spec": skillSpec, + }) + commit := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/commit", + commitBody, ids.UserID) + if commit.Code != http.StatusCreated || !strings.Contains(commit.Body.String(), `"visibility":"workspace"`) { + t.Fatalf("member skill commit expected 201 workspace visibility, got %d: %s", commit.Code, commit.Body.String()) + } + + publicBody := mustJSON(t, map[string]any{ + "kind": "skill", + "name": "Public Member Skill", + "type": "skill", + "visibility": "public", + "canonical_spec": skillSpec, + }) + publicCommit := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/commit", + publicBody, ids.UserID) + if publicCommit.Code != http.StatusForbidden { + t.Fatalf("member public skill commit expected 403, got %d: %s", publicCommit.Code, publicCommit.Body.String()) + } + + mcpRaw := `{"mcpServers":{"example":{"command":"echo"}}}` + mcpPreviewBody := mustJSON(t, map[string]any{ + "kind": "mcp", + "source_format": "json", + "raw_text": mcpRaw, + }) + mcpPreview := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/preview", + mcpPreviewBody, ids.UserID) + if mcpPreview.Code != http.StatusForbidden { + t.Fatalf("member MCP preview expected 403, got %d: %s", mcpPreview.Code, mcpPreview.Body.String()) + } + + mcpSpec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: "example", + Command: "echo", + }}}, + } + mcpCommitBody := mustJSON(t, map[string]any{ + "kind": "mcp", + "name": "Member MCP", + "type": "mcp", + "canonical_spec": mcpSpec, + }) + mcpCommit := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/commit", + mcpCommitBody, ids.UserID) + if mcpCommit.Code != http.StatusForbidden { + t.Fatalf("member MCP commit expected 403, got %d: %s", mcpCommit.Code, mcpCommit.Body.String()) + } +} + +func TestCapabilityImport_ViewerCannotImportSkill(t *testing.T) { + ids := store.DefaultDevFixtureIDs() + r, _ := capabilityTestRouter(t, map[string]string{ids.UserID: "viewer"}, nil) + body := mustJSON(t, map[string]any{ + "kind": "skill", + "source_format": "markdown", + "raw_text": "---\nslug: viewer-skill\ntitle: Viewer Skill\n---\nBody", + }) + res := serveCapabilityRoute(t, r, http.MethodPost, + "/api/v1/workspaces/"+ids.WorkspaceID+"/capabilities/import/preview", + body, ids.UserID) + if res.Code != http.StatusForbidden { + t.Fatalf("viewer skill preview expected 403, got %d: %s", res.Code, res.Body.String()) + } +} + // TestCapabilityImportCommit_InlineSecretLandsInSecretsTable verifies the // cleartext-flows-into-encrypted-row safety property. // diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 0acbcaf8..ef0ef553 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -1557,13 +1557,8 @@ func upgradeAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc { } func requireWorkspaceCapabilityRead(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (string, bool) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) - return "", false - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + workspaceID, ok := requireWorkspaceCapabilityWorkspace(w, r, runtimeStore) + if !ok { return "", false } if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { @@ -1573,7 +1568,7 @@ func requireWorkspaceCapabilityRead(w http.ResponseWriter, r *http.Request, runt return workspaceID, true } -func requireWorkspaceCapabilityAdmin(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (string, bool) { +func requireWorkspaceCapabilityWorkspace(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (string, bool) { if runtimeStore == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) return "", false @@ -1583,6 +1578,14 @@ func requireWorkspaceCapabilityAdmin(w http.ResponseWriter, r *http.Request, run writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) return "", false } + return workspaceID, true +} + +func requireWorkspaceCapabilityAdmin(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (string, bool) { + workspaceID, ok := requireWorkspaceCapabilityWorkspace(w, r, runtimeStore) + if !ok { + return "", false + } if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { writeRBACError(w, err) return "", false @@ -1590,6 +1593,37 @@ func requireWorkspaceCapabilityAdmin(w http.ResponseWriter, r *http.Request, run return workspaceID, true } +// requireWorkspaceCapabilityImport allows non-viewer members to import Skills +// while keeping MCP imports restricted to owners/admins. It returns whether +// the caller has admin-level capability permissions so commit handlers can +// keep member-created Skills workspace-private. +func requireWorkspaceCapabilityImport(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, kind string) (string, bool, bool) { + workspaceID, ok := requireWorkspaceCapabilityWorkspace(w, r, runtimeStore) + if !ok { + return "", false, false + } + ctx := requestContextForRBAC(r) + userID := auth.UserIDFromContext(ctx) + if userID == "" { + writeRBACError(w, auth.ErrUnauthenticated) + return "", false, false + } + if auth.IsPlatformAdmin(userID) { + return workspaceID, true, true + } + role, err := runtimeStore.GetWorkspaceMemberRole(ctx, workspaceID, userID) + if err != nil { + writeRBACError(w, err) + return "", false, false + } + isAdmin := role == "owner" || role == "admin" + if !isAdmin && !(kind == string(canonical.KindSkill) && role == "member") { + writeRBACError(w, auth.ErrForbidden) + return "", false, false + } + return workspaceID, isAdmin, true +} + func requireWorkspaceCapabilityByID(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, admin bool) (string, string, bool) { var workspaceID string var ok bool diff --git a/server/internal/dev/uploads_routes.go b/server/internal/dev/uploads_routes.go index d1beb700..bb00a292 100644 --- a/server/internal/dev/uploads_routes.go +++ b/server/internal/dev/uploads_routes.go @@ -62,7 +62,7 @@ var allowedKinds = map[string]struct{}{ // in so later downloads can verify ownership. // // @Summary Presign a plugin/skill upload -// @Description Returns a presigned URL the browser PUTs the plugin/skill zip to. The blob backend (OSS or PG) mints a workspace-scoped ref that later downloads verify against. Caller must be workspace capability admin. +// @Description Returns a presigned URL the browser PUTs the plugin/skill zip to. The blob backend (OSS or PG) mints a workspace-scoped ref that later downloads verify against. Members may upload Skill zips; Plugin uploads remain owner/admin only. // @Tags uploads // @ID createDevWorkspaceUploadPresign // @Accept json @@ -71,20 +71,12 @@ var allowedKinds = map[string]struct{}{ // @Param body body presignUploadRequest true "Presign upload payload" // @Success 200 {object} presignUploadResponse "Presigned upload spec" // @Failure 400 {object} map[string]string "Body invalid, filename empty, or prefix not in {plugin,skill}" -// @Failure 403 {object} map[string]string "Caller lacks capability admin permission" +// @Failure 403 {object} map[string]string "Caller lacks permission for the requested upload kind" // @Failure 500 {object} map[string]string "Blob backend error" // @Failure 503 {object} map[string]string "Object storage not configured" // @Router /api/v1/workspaces/{workspaceID}/uploads/presign-upload [post] func presignUpload(runtimeStore RuntimeStore, store blob.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) - if !ok { - return - } - if store == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "object storage is not configured on this deployment", "code": "OSS_NOT_CONFIGURED"}) - return - } // 4 KiB bound on a malicious admin OOM attempt; generous for a // filename + prefix envelope. r.Body = http.MaxBytesReader(w, r.Body, 4*1024) @@ -103,6 +95,14 @@ func presignUpload(runtimeStore RuntimeStore, store blob.Store) http.HandlerFunc writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("prefix must be one of %s", knownKinds())}) return } + workspaceID, _, ok := requireWorkspaceCapabilityImport(w, r, runtimeStore, kind) + if !ok { + return + } + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "object storage is not configured on this deployment", "code": "OSS_NOT_CONFIGURED"}) + return + } ref, err := store.NewRef(kind, workspaceID, filename) if err != nil { diff --git a/server/internal/dev/uploads_routes_test.go b/server/internal/dev/uploads_routes_test.go index f876c4c0..7f3cdcd8 100644 --- a/server/internal/dev/uploads_routes_test.go +++ b/server/internal/dev/uploads_routes_test.go @@ -205,6 +205,38 @@ func TestPresignUpload_PluginPrefixMintsWorkspaceScopedKey(t *testing.T) { } } +func TestPresignUpload_MemberMayUploadSkillButNotPlugin(t *testing.T) { + t.Parallel() + f := newFakeOSS() + r, rt := newUploadsTestRouter(t, f) + rt.roleStubStore = newRoleStubStore(map[string]string{uploadsTestUserID: "member"}) + + skillStatus, skillBody := callRouter(t, r, "POST", uploadsPath("presign-upload"), `{"filename":"member-skill.zip","prefix":"skill"}`) + if skillStatus != http.StatusOK { + t.Fatalf("member skill upload status = %d, want 200; body=%v", skillStatus, skillBody) + } + if key, _ := skillBody["ossKey"].(string); !strings.Contains(key, "/skill/") && !strings.Contains(key, "/skills/") { + t.Fatalf("member skill upload returned unexpected ossKey %q", key) + } + + pluginStatus, _ := callRouter(t, r, "POST", uploadsPath("presign-upload"), `{"filename":"member-plugin.zip","prefix":"plugin"}`) + if pluginStatus != http.StatusForbidden { + t.Fatalf("member plugin upload status = %d, want 403", pluginStatus) + } +} + +func TestPresignUpload_ViewerCannotUploadSkill(t *testing.T) { + t.Parallel() + f := newFakeOSS() + r, rt := newUploadsTestRouter(t, f) + rt.roleStubStore = newRoleStubStore(map[string]string{uploadsTestUserID: "viewer"}) + + status, _ := callRouter(t, r, "POST", uploadsPath("presign-upload"), `{"filename":"viewer-skill.zip","prefix":"skill"}`) + if status != http.StatusForbidden { + t.Fatalf("viewer skill upload status = %d, want 403", status) + } +} + func TestPresignUpload_DefaultTTLDelegatedToClient(t *testing.T) { t.Parallel() f := newFakeOSS() diff --git a/server/internal/mcpcatalog/catalog_test.go b/server/internal/mcpcatalog/catalog_test.go new file mode 100644 index 00000000..06bda87c --- /dev/null +++ b/server/internal/mcpcatalog/catalog_test.go @@ -0,0 +1,202 @@ +package mcpcatalog + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestBuiltinCatalogLoads(t *testing.T) { + snapshot, err := New(Options{}).Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if snapshot.Source != SourceBuiltin || len(snapshot.Catalog.Items) == 0 { + t.Fatalf("snapshot = %+v", snapshot) + } + for _, item := range snapshot.Catalog.Items { + if err := item.CanonicalSpec().Validate(); err != nil { + t.Fatalf("item %q canonical spec: %v", item.ID, err) + } + } +} + +func TestBuiltinCatalogContainsCuratedConnectors(t *testing.T) { + snapshot, err := New(Options{}).Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := map[string]string{ + "filesystem": "2026.7.10", + "playwright": "0.0.78", + "context7": "3.2.4", + "fetch": "2026.7.10", + "git": "2026.7.10", + "memory": "2026.7.4", + "time": "2026.7.10", + "sequential-thinking": "2026.7.4", + "everything": "2026.7.4", + "cloudflare-docs": "0.4.9", + "microsoft-learn": "1.0.0", + "aws-knowledge": "1.0.0", + "deepwiki": "2.14.3", + "agent-web": "0.2.1", + "arxiv": "1.2.15", + "pubmed": "2.9.8", + "us-weather": "0.7.2", + "mdn-search": "0.1.0", + "npm-registry": "0.1.0", + "docker-hub": "0.1.0", + "wikipedia": "0.1.0", + } + if len(snapshot.Catalog.Items) != len(want) { + t.Fatalf("items=%d, want %d", len(snapshot.Catalog.Items), len(want)) + } + for _, item := range snapshot.Catalog.Items { + version, ok := want[item.ID] + if !ok { + t.Fatalf("unexpected connector %q", item.ID) + } + if item.Version != version { + t.Fatalf("connector %q version=%q, want %q", item.ID, item.Version, version) + } + } +} + +func TestRemoteCatalogLoadsAndCaches(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(validCatalogJSON(t, "remote")) + })) + defer server.Close() + + loader := New(Options{RemoteURL: server.URL, CacheTTL: time.Minute}) + first, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("first Load: %v", err) + } + second, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("second Load: %v", err) + } + if first.Source != SourceRemote || second.Source != SourceRemote || calls.Load() != 1 { + t.Fatalf("sources=%q/%q calls=%d", first.Source, second.Source, calls.Load()) + } +} + +func TestRemoteFailureFallsBackToBuiltin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusBadGateway) + })) + defer server.Close() + snapshot, err := New(Options{RemoteURL: server.URL}).Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if snapshot.Source != SourceBuiltin { + t.Fatalf("source = %q", snapshot.Source) + } +} + +func TestCatalogLoadFailsClearlyWhenRemoteAndBuiltinAreInvalid(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"schema_version":2}`)) + })) + defer server.Close() + _, err := New(Options{RemoteURL: server.URL, BuiltinJSON: []byte(`not-json`)}).Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "load remote catalog") || !strings.Contains(err.Error(), "load builtin catalog") { + t.Fatalf("error = %v", err) + } +} + +func TestRemoteCatalogResponseSizeIsBounded(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(validCatalogJSON(t, "oversized")) + })) + defer server.Close() + _, err := New(Options{RemoteURL: server.URL, BuiltinJSON: []byte(`not-json`), MaxResponseBytes: 16}).Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "response exceeds") { + t.Fatalf("error = %v", err) + } +} + +func TestCatalogValidationRejectsInvalidContent(t *testing.T) { + tests := []struct { + name string + edit func(*Catalog) + want string + }{ + {"schema version", func(c *Catalog) { c.SchemaVersion = 2 }, "schema_version"}, + {"duplicate id", func(c *Catalog) { c.Items = append(c.Items, c.Items[0]) }, "duplicated"}, + {"transport", func(c *Catalog) { c.Items[0].Transport = "sse" }, "unsupported"}, + {"empty command", func(c *Catalog) { c.Items[0].Server.Command = "" }, "command is required"}, + {"invalid url", func(c *Catalog) { c.Items[0].RepositoryURL = "file:///tmp/mcp" }, "http or https"}, + {"env secret", func(c *Catalog) { c.Items[0].Server.Env = map[string]string{"API_TOKEN": "real-secret"} }, "must not contain a value"}, + {"arg secret", func(c *Catalog) { c.Items[0].Server.Args = []string{"--token=real-secret"} }, "credential value"}, + {"latest package", func(c *Catalog) { c.Items[0].Server.Args = []string{"package@latest"} }, "unpinned latest"}, + {"remote URL", func(c *Catalog) { + c.Items[0].Transport = "streamable-http" + c.Items[0].Server = Server{Name: "remote", URL: "file:///tmp/mcp"} + }, "http or https"}, + {"remote command", func(c *Catalog) { + c.Items[0].Transport = "streamable-http" + c.Items[0].Server = Server{Name: "remote", URL: "https://example.com/mcp", Command: "npx"} + }, "must not set command"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + catalog := validCatalog("connector") + tc.edit(&catalog) + data, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + _, err = Decode(data) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + }) + } +} + +func validCatalogJSON(t *testing.T, id string) []byte { + t.Helper() + data, err := json.Marshal(validCatalog(id)) + if err != nil { + t.Fatal(err) + } + return data +} + +func validCatalog(id string) Catalog { + return Catalog{ + SchemaVersion: SchemaVersion, + UpdatedAt: "2026-07-22T00:00:00Z", + Items: []Item{{ + ID: id, + Name: "Connector", + Description: "A connector used by tests.", + Publisher: Publisher{Name: "Publisher", URL: "https://example.com"}, + RepositoryURL: "https://example.com/repository", + Verified: true, + Categories: []string{"Developer Tools"}, + PopularityRank: 1, + Version: "1.0.0", + Transport: "stdio", + Server: Server{ + Name: id, + Command: "npx", + Args: []string{"-y", "package@1.0.0"}, + Env: map[string]string{"OPTIONAL_TOKEN": ""}, + StartupTimeoutSec: 30, + }, + }}, + } +} diff --git a/server/internal/mcpcatalog/loader.go b/server/internal/mcpcatalog/loader.go new file mode 100644 index 00000000..41edaa04 --- /dev/null +++ b/server/internal/mcpcatalog/loader.go @@ -0,0 +1,180 @@ +package mcpcatalog + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + mcpcatalogdata "github.com/MiniMax-AI-Dev/parsar/catalog/mcp" +) + +const ( + EnvCatalogURL = "PARSAR_MCP_CATALOG_URL" + defaultCacheTTL = 5 * time.Minute + defaultHTTPTimeout = 5 * time.Second + defaultMaxResponseSize = 2 << 20 +) + +type Source string + +const ( + SourceBuiltin Source = "builtin" + SourceRemote Source = "remote" +) + +type Snapshot struct { + Catalog Catalog + Source Source +} + +type Options struct { + RemoteURL string + HTTPClient *http.Client + CacheTTL time.Duration + MaxResponseBytes int64 + BuiltinJSON []byte +} + +type Loader struct { + remoteURL *url.URL + remoteConfigErr error + client *http.Client + cacheTTL time.Duration + maxResponseBytes int64 + builtin Catalog + builtinErr error + + mu sync.Mutex + cached Snapshot + expiresAt time.Time +} + +func New(options Options) *Loader { + builtinJSON := options.BuiltinJSON + if len(builtinJSON) == 0 { + builtinJSON = mcpcatalogdata.CatalogJSON + } + builtin, builtinErr := Decode(builtinJSON) + + cacheTTL := options.CacheTTL + if cacheTTL <= 0 { + cacheTTL = defaultCacheTTL + } + maxResponseBytes := options.MaxResponseBytes + if maxResponseBytes <= 0 { + maxResponseBytes = defaultMaxResponseSize + } + + client := http.Client{} + if options.HTTPClient != nil { + client = *options.HTTPClient + } + if client.Timeout <= 0 { + client.Timeout = defaultHTTPTimeout + } + previousRedirect := client.CheckRedirect + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("too many catalog redirects") + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("catalog redirect uses unsupported scheme %q", req.URL.Scheme) + } + if previousRedirect != nil { + return previousRedirect(req, via) + } + return nil + } + + var remoteURL *url.URL + var remoteConfigErr error + if raw := strings.TrimSpace(options.RemoteURL); raw != "" { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + remoteConfigErr = fmt.Errorf("%s must be an http or https URL without embedded credentials", EnvCatalogURL) + } else { + remoteURL = parsed + } + } + + return &Loader{ + remoteURL: remoteURL, + remoteConfigErr: remoteConfigErr, + client: &client, + cacheTTL: cacheTTL, + maxResponseBytes: maxResponseBytes, + builtin: builtin, + builtinErr: builtinErr, + } +} + +func (l *Loader) Load(ctx context.Context) (Snapshot, error) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + if !l.expiresAt.IsZero() && now.Before(l.expiresAt) { + return l.cached, nil + } + + var remoteErr error + if l.remoteConfigErr != nil { + remoteErr = l.remoteConfigErr + } else if l.remoteURL != nil { + catalog, err := l.loadRemote(ctx) + if err == nil { + l.cached = Snapshot{Catalog: catalog, Source: SourceRemote} + l.expiresAt = now.Add(l.cacheTTL) + return l.cached, nil + } + remoteErr = err + } + + if l.builtinErr == nil { + l.cached = Snapshot{Catalog: l.builtin, Source: SourceBuiltin} + l.expiresAt = now.Add(l.cacheTTL) + return l.cached, nil + } + if remoteErr != nil { + return Snapshot{}, fmt.Errorf("load remote catalog: %v; load builtin catalog: %w", remoteErr, l.builtinErr) + } + return Snapshot{}, fmt.Errorf("load builtin catalog: %w", l.builtinErr) +} + +func (l *Loader) loadRemote(ctx context.Context) (Catalog, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.remoteURL.String(), nil) + if err != nil { + return Catalog{}, fmt.Errorf("build catalog request: %w", err) + } + resp, err := l.client.Do(req) + if err != nil { + return Catalog{}, fmt.Errorf("fetch catalog: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Catalog{}, fmt.Errorf("fetch catalog: unexpected HTTP status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, l.maxResponseBytes+1)) + if err != nil { + return Catalog{}, fmt.Errorf("read catalog: %w", err) + } + if int64(len(data)) > l.maxResponseBytes { + return Catalog{}, fmt.Errorf("read catalog: response exceeds %d bytes", l.maxResponseBytes) + } + return Decode(data) +} + +func (s Snapshot) Find(id string) (Item, bool) { + id = strings.TrimSpace(id) + for _, item := range s.Catalog.Items { + if item.ID == id { + return item, true + } + } + return Item{}, false +} diff --git a/server/internal/mcpcatalog/types.go b/server/internal/mcpcatalog/types.go new file mode 100644 index 00000000..9843639d --- /dev/null +++ b/server/internal/mcpcatalog/types.go @@ -0,0 +1,63 @@ +package mcpcatalog + +import ( + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +const SchemaVersion = 1 + +type Catalog struct { + SchemaVersion int `json:"schema_version"` + UpdatedAt string `json:"updated_at"` + Items []Item `json:"items"` +} + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Publisher Publisher `json:"publisher"` + IconURL string `json:"icon_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Verified bool `json:"verified"` + Categories []string `json:"categories"` + PopularityRank int `json:"popularity_rank"` + Version string `json:"version"` + Transport string `json:"transport"` + Server Server `json:"server"` +} + +type Publisher struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type Server struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + StartupTimeoutSec int `json:"startup_timeout_sec,omitempty"` +} + +func (i Item) CanonicalSpec() canonical.Spec { + env := make(map[string]canonical.EnvValue, len(i.Server.Env)) + for name := range i.Server.Env { + env[name] = canonical.EnvValue{Mode: canonical.EnvModeLiteral} + } + return canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: i.Server.Name, + Transport: i.Transport, + URL: i.Server.URL, + Command: i.Server.Command, + Args: append([]string(nil), i.Server.Args...), + Env: env, + StartupTimeoutSec: i.Server.StartupTimeoutSec, + }}}, + } +} diff --git a/server/internal/mcpcatalog/validate.go b/server/internal/mcpcatalog/validate.go new file mode 100644 index 00000000..19aa3f75 --- /dev/null +++ b/server/internal/mcpcatalog/validate.go @@ -0,0 +1,186 @@ +package mcpcatalog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "regexp" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +var ( + idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + envPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + credentialAssignmentRE = regexp.MustCompile(`(?i)(api[_-]?key|access[_-]?token|token|secret|password)\s*[:=]\s*\S+`) + bearerValueRE = regexp.MustCompile(`(?i)\bbearer\s+[a-z0-9._~+/=-]{8,}`) +) + +func Decode(data []byte) (Catalog, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var catalog Catalog + if err := decoder.Decode(&catalog); err != nil { + return Catalog{}, fmt.Errorf("decode catalog: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Catalog{}, fmt.Errorf("decode catalog: trailing JSON data") + } + if err := catalog.Validate(); err != nil { + return Catalog{}, err + } + return catalog, nil +} + +func (c Catalog) Validate() error { + if c.SchemaVersion != SchemaVersion { + return fmt.Errorf("catalog schema_version %d is unsupported", c.SchemaVersion) + } + if _, err := time.Parse(time.RFC3339, strings.TrimSpace(c.UpdatedAt)); err != nil { + return fmt.Errorf("catalog updated_at must be RFC3339: %w", err) + } + seen := make(map[string]struct{}, len(c.Items)) + for index, item := range c.Items { + if err := item.Validate(); err != nil { + return fmt.Errorf("catalog item[%d]: %w", index, err) + } + if _, duplicate := seen[item.ID]; duplicate { + return fmt.Errorf("catalog item id %q is duplicated", item.ID) + } + seen[item.ID] = struct{}{} + } + return nil +} + +func (i Item) Validate() error { + if !idPattern.MatchString(i.ID) { + return fmt.Errorf("id %q must contain only lowercase letters, digits, dots, hyphens, or underscores", i.ID) + } + if strings.TrimSpace(i.Name) == "" { + return fmt.Errorf("item %q name is required", i.ID) + } + if strings.TrimSpace(i.Description) == "" { + return fmt.Errorf("item %q description is required", i.ID) + } + if strings.TrimSpace(i.Publisher.Name) == "" { + return fmt.Errorf("item %q publisher name is required", i.ID) + } + if err := validateHTTPURL("publisher.url", i.Publisher.URL, true); err != nil { + return fmt.Errorf("item %q: %w", i.ID, err) + } + for label, value := range map[string]string{ + "icon_url": i.IconURL, + "homepage_url": i.HomepageURL, + "repository_url": i.RepositoryURL, + } { + if err := validateHTTPURL(label, value, false); err != nil { + return fmt.Errorf("item %q: %w", i.ID, err) + } + } + if i.PopularityRank < 1 { + return fmt.Errorf("item %q popularity_rank must be positive", i.ID) + } + if strings.TrimSpace(i.Version) == "" { + return fmt.Errorf("item %q version is required", i.ID) + } + if i.Transport != canonical.MCPTransportStdio && i.Transport != canonical.MCPTransportStreamableHTTP { + return fmt.Errorf("item %q transport %q is unsupported", i.ID, i.Transport) + } + categorySeen := make(map[string]struct{}, len(i.Categories)) + for _, category := range i.Categories { + category = strings.TrimSpace(category) + if category == "" { + return fmt.Errorf("item %q has an empty category", i.ID) + } + if _, duplicate := categorySeen[category]; duplicate { + return fmt.Errorf("item %q category %q is duplicated", i.ID, category) + } + categorySeen[category] = struct{}{} + } + return i.Server.Validate(i.ID, i.Transport) +} + +func (s Server) Validate(itemID, transport string) error { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("item %q server name is required", itemID) + } + if s.StartupTimeoutSec < 0 || s.StartupTimeoutSec > 300 { + return fmt.Errorf("item %q startup_timeout_sec must be between 0 and 300", itemID) + } + switch transport { + case canonical.MCPTransportStdio: + if strings.TrimSpace(s.Command) == "" { + return fmt.Errorf("item %q server command is required", itemID) + } + if strings.TrimSpace(s.URL) != "" { + return fmt.Errorf("item %q stdio server must not set url", itemID) + } + case canonical.MCPTransportStreamableHTTP: + if err := validateHTTPURL("server.url", s.URL, true); err != nil { + return fmt.Errorf("item %q: %w", itemID, err) + } + parsed, _ := url.Parse(strings.TrimSpace(s.URL)) + if parsed.Scheme != "https" { + return fmt.Errorf("item %q streamable-http server.url must use https", itemID) + } + if strings.TrimSpace(s.Command) != "" || len(s.Args) > 0 || len(s.Env) > 0 { + return fmt.Errorf("item %q streamable-http server must not set command, args, or env", itemID) + } + default: + return fmt.Errorf("item %q transport %q is unsupported", itemID, transport) + } + for _, arg := range s.Args { + if strings.ContainsRune(arg, '\x00') { + return fmt.Errorf("item %q contains a NUL byte in args", itemID) + } + if credentialAssignmentRE.MatchString(arg) || bearerValueRE.MatchString(arg) { + return fmt.Errorf("item %q args appear to contain a credential value", itemID) + } + lower := strings.ToLower(strings.TrimSpace(arg)) + if strings.Contains(lower, "@latest") || strings.HasSuffix(lower, ":latest") { + return fmt.Errorf("item %q uses an unpinned latest package", itemID) + } + } + for name, value := range s.Env { + if !envPattern.MatchString(name) { + return fmt.Errorf("item %q env name %q is invalid", itemID, name) + } + if value != "" { + return fmt.Errorf("item %q env %q must not contain a value", itemID, name) + } + } + return nil +} + +func validateHTTPURL(label, value string, required bool) error { + value = strings.TrimSpace(value) + if value == "" { + if required { + return fmt.Errorf("%s is required", label) + } + return nil + } + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("%s must be an http or https URL", label) + } + if parsed.User != nil { + return fmt.Errorf("%s must not contain embedded credentials", label) + } + for key, values := range parsed.Query() { + lower := strings.ToLower(key) + if strings.Contains(lower, "token") || strings.Contains(lower, "secret") || strings.Contains(lower, "password") || strings.Contains(lower, "api_key") || strings.Contains(lower, "apikey") { + for _, value := range values { + if value != "" { + return fmt.Errorf("%s must not contain credential query parameters", label) + } + } + } + } + return nil +} diff --git a/server/internal/store/capability_import.go b/server/internal/store/capability_import.go index c98a64b0..6c25ea8b 100644 --- a/server/internal/store/capability_import.go +++ b/server/internal/store/capability_import.go @@ -682,12 +682,24 @@ func validateMCPSpecPreCommit(m canonical.MCPSpec) error { if strings.TrimSpace(srv.Name) == "" { return fmt.Errorf("server[%d]: name is required", i) } - if strings.TrimSpace(srv.Command) == "" { - return fmt.Errorf("server %q: command is required", srv.Name) - } if srv.StartupTimeoutSec < 0 { return fmt.Errorf("server %q: startup_timeout_sec must be >= 0", srv.Name) } + switch srv.EffectiveTransport() { + case canonical.MCPTransportStdio: + if strings.TrimSpace(srv.Command) == "" { + return fmt.Errorf("server %q: command is required", srv.Name) + } + if strings.TrimSpace(srv.URL) != "" { + return fmt.Errorf("server %q: stdio transport must not set url", srv.Name) + } + case canonical.MCPTransportStreamableHTTP: + if err := srv.Validate(); err != nil { + return err + } + default: + return fmt.Errorf("server %q: unsupported transport %q", srv.Name, srv.Transport) + } for name, value := range srv.Env { if strings.TrimSpace(name) == "" { return fmt.Errorf("server %q: empty env name", srv.Name) diff --git a/server/internal/store/mcp_directory.go b/server/internal/store/mcp_directory.go new file mode 100644 index 00000000..6361eed7 --- /dev/null +++ b/server/internal/store/mcp_directory.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" +) + +// MCPDirectoryInstall identifies the workspace capability created from one +// MCP Directory catalog item. CatalogVersion is retained for future update +// detection; v1 only reports it. +type MCPDirectoryInstall struct { + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CapabilityID string `json:"capability_id"` +} + +func (s *Store) ListMCPDirectoryInstalls(ctx context.Context, workspaceID string) ([]MCPDirectoryInstall, error) { + wid, err := uuid(workspaceID) + if err != nil { + return nil, fmt.Errorf("list mcp directory installs: workspace_id: %w", err) + } + rows, err := sqlc.New(s.db).ListMCPDirectoryInstalls(ctx, wid) + if err != nil { + return nil, fmt.Errorf("list mcp directory installs: %w", err) + } + installs := make([]MCPDirectoryInstall, 0, len(rows)) + for _, row := range rows { + installs = append(installs, MCPDirectoryInstall{ + CatalogID: row.CatalogID, + CatalogVersion: row.CatalogVersion, + CapabilityID: row.CapabilityID, + }) + } + return installs, nil +} diff --git a/server/internal/store/mcp_directory_test.go b/server/internal/store/mcp_directory_test.go new file mode 100644 index 00000000..d0ca7b3b --- /dev/null +++ b/server/internal/store/mcp_directory_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +func TestMCPDirectoryImportPersistsProvenanceWithoutSecretsOrBindings(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + st := New(db) + ids := mustSeedDevFixture(t, ctx, st) + + var secretsBefore int + if err := db.QueryRow(ctx, `select count(*) from secrets`).Scan(&secretsBefore); err != nil { + t.Fatal(err) + } + source := json.RawMessage(`{"source_format":"mcp_catalog","catalog_id":"filesystem","catalog_version":"1.0.0","catalog_source":"builtin"}`) + result, err := st.ImportCapability(ctx, ImportCapabilityInput{ + WorkspaceID: ids.WorkspaceID, + Name: "Directory Filesystem", + Description: "Read and write configured files.", + Visibility: "workspace", + Type: "mcp", + CreatorID: ids.UserID, + Version: "1.0.0", + SourcePayload: source, + Spec: canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: "filesystem", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem@1.0.0"}, + Env: map[string]canonical.EnvValue{"FILESYSTEM_ROOT": {Mode: canonical.EnvModeLiteral}}, + StartupTimeoutSec: 30, + }}}, + }, + }) + if err != nil { + t.Fatalf("ImportCapability: %v", err) + } + if result.Capability.Type != "mcp" || result.Capability.Visibility != "workspace" { + t.Fatalf("capability=%+v", result.Capability) + } + if len(result.CreatedSecretIDs) != 0 { + t.Fatalf("created secrets=%v", result.CreatedSecretIDs) + } + + installs, err := st.ListMCPDirectoryInstalls(ctx, ids.WorkspaceID) + if err != nil { + t.Fatalf("ListMCPDirectoryInstalls: %v", err) + } + if len(installs) != 1 || installs[0].CatalogID != "filesystem" || installs[0].CatalogVersion != "1.0.0" || installs[0].CapabilityID != result.Capability.ID { + t.Fatalf("installs=%+v", installs) + } + + var bindings, secretsAfter int + if err := db.QueryRow(ctx, `select count(*) from agent_capabilities where capability_id = $1`, result.Capability.ID).Scan(&bindings); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(ctx, `select count(*) from secrets`).Scan(&secretsAfter); err != nil { + t.Fatal(err) + } + if bindings != 0 { + t.Fatalf("agent bindings=%d, want 0", bindings) + } + if secretsAfter != secretsBefore { + t.Fatalf("secret count changed from %d to %d", secretsBefore, secretsAfter) + } + + var stored json.RawMessage + if err := db.QueryRow(ctx, `select source_payload from capability_version where id = $1`, result.CapabilityVersion.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + var provenance map[string]string + if err := json.Unmarshal(stored, &provenance); err != nil { + t.Fatal(err) + } + if provenance["catalog_id"] != "filesystem" || provenance["catalog_source"] != "builtin" { + t.Fatalf("source_payload=%s", stored) + } +} diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts new file mode 100644 index 00000000..5ab2fda2 --- /dev/null +++ b/tests/e2e/mcp-directory.spec.ts @@ -0,0 +1,471 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const WORKSPACE_ID = "00000000-0000-0000-0000-000000000011"; +const CAPABILITY_ID = "00000000-0000-0000-0000-000000000033"; + +const directoryItems = [ + { + id: "filesystem", + name: "Filesystem", + description: "Read and write files from configured directories.", + publisher: { + name: "Model Context Protocol", + url: "https://example.com/mcp", + }, + repository_url: "https://example.com/filesystem", + verified: true, + categories: ["Developer Tools", "Files"], + popularity_rank: 1, + version: "1.0.0", + transport: "stdio", + installed: false, + installed_capability_id: null, + }, + { + id: "memory", + name: "Memory", + description: "Store knowledge in a local graph.", + publisher: { + name: "Model Context Protocol", + url: "https://example.com/mcp", + }, + verified: true, + categories: ["Data"], + popularity_rank: 2, + version: "1.1.0", + transport: "stdio", + installed: false, + installed_capability_id: null, + }, + { + id: "community-clock", + name: "Community Clock", + description: "An unverified test connector.", + publisher: { name: "Community", url: "https://example.com/community" }, + verified: false, + categories: ["Utilities"], + popularity_rank: 3, + version: "0.2.0", + transport: "stdio", + installed: false, + installed_capability_id: null, + }, + { + id: "deepwiki", + name: "DeepWiki", + description: "Read public repositories as generated documentation.", + publisher: { name: "Cognition", url: "https://www.cognition.ai" }, + verified: true, + categories: ["Documentation"], + popularity_rank: 4, + version: "2.14.3", + transport: "streamable-http", + installed: false, + installed_capability_id: null, + }, +]; + +test("browse, filter, inspect, and import an MCP connector without affecting Skills", async ({ + page, +}) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); + + const search = page.getByPlaceholder("Search capability name / description"); + await search.fill("memory"); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(1); + await expect(page.getByRole("heading", { name: "Memory" })).toBeVisible(); + await search.clear(); + + await page.getByRole("button", { name: "Files", exact: true }).click(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(1); + await page.getByRole("button", { name: "All categories" }).click(); + + await page.getByRole("checkbox", { name: "Verified only" }).check(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); + await page.getByRole("checkbox", { name: "Verified only" }).uncheck(); + + await page + .getByRole("combobox", { name: "Sort connectors" }) + .selectOption("name"); + await expect(page.getByTestId("mcp-directory-card").first()).toHaveAttribute( + "data-catalog-id", + "community-clock", + ); + + await page.getByRole("heading", { name: "Filesystem" }).click(); + await expect(page.getByTestId("mcp-directory-detail")).toContainText( + "npx -y @modelcontextprotocol/server-filesystem@1.0.0", + ); + await expect(page.getByTestId("mcp-directory-detail")).toContainText( + "FILESYSTEM_ROOT", + ); + + await page.getByRole("button", { name: "Import", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("No token is required during import"); + await expect(dialog.getByRole("textbox")).toHaveCount(0); + await dialog.getByRole("button", { name: "Import", exact: true }).click(); + + const success = page.getByRole("status"); + await expect(success).toContainText("imported as a workspace MCP Capability"); + await expect( + success.getByRole("button", { name: "View Capability" }), + ).toBeVisible(); + await expect( + success.getByRole("button", { name: "Add to Agent" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Import", exact: true }), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Back to connectors" }).click(); + await page.getByRole("tab", { name: "Skill" }).click(); + await expect( + page.getByRole("heading", { name: "Diagram Maker" }), + ).toBeVisible(); +}); + +test("shows a retryable connector directory error", async ({ page }) => { + let directoryCalls = 0; + await mockApp(page, async (route) => { + directoryCalls += 1; + if (directoryCalls === 1) { + await json(route, { error: "mcp_catalog_unavailable" }, 503); + return true; + } + return false; + }); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await expect( + page.getByText( + "Couldn't load the connectors directory. Some connector details may be missing.", + { exact: false }, + ), + ).toBeVisible(); + await page.getByRole("button", { name: "Retry" }).click(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); +}); + +test("shows a loading state while the connector catalog is pending", async ({ + page, +}) => { + let releaseDirectory: (() => void) | undefined; + const directoryReady = new Promise((resolve) => { + releaseDirectory = resolve; + }); + await mockApp(page, async () => { + await directoryReady; + return false; + }); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await expect(page.getByTestId("mcp-directory-loading")).toBeVisible(); + releaseDirectory?.(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); +}); + +test("shows a no-auth streamable HTTP connector endpoint", async ({ page }) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await page.getByRole("heading", { name: "DeepWiki" }).click(); + const detail = page.getByTestId("mcp-directory-detail"); + await expect(detail).toContainText("streamable-http"); + await expect(detail).toContainText("https://mcp.deepwiki.com/mcp"); + await expect(detail).toContainText("Not required"); + + await page.getByRole("button", { name: "Import", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("https://mcp.deepwiki.com/mcp"); + await expect(dialog).toContainText("Not required"); + await expect(dialog.getByRole("textbox")).toHaveCount(0); +}); + +test("resolves an imported workspace connector on the Add to Agent path", async ({ + page, +}) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await page.getByRole("heading", { name: "Filesystem" }).click(); + await page.getByRole("button", { name: "Import", exact: true }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Import", exact: true }) + .click(); + await page + .getByRole("status") + .getByRole("button", { name: "Add to Agent" }) + .click(); + + await expect(page).toHaveURL( + new RegExp(`admin=agents.*pendingCapability=${CAPABILITY_ID}`), + ); + await expect( + page.getByText('You are preparing to add "Filesystem"', { exact: false }), + ).toBeVisible(); +}); + +test("prefills an imported connector edit from its canonical spec", async ({ + page, +}) => { + await mockApp(page, undefined, true, "owner", 500); + await page.goto( + `/?admin=capabilities&id=${CAPABILITY_ID}&ws=${WORKSPACE_ID}`, + ); + + await page.getByRole("button", { name: "Submit new version" }).click(); + const editor = page.getByRole("dialog").locator("textarea"); + await expect(editor).toHaveValue(/mcp-server-git==2026\.7\.10/); + await expect(editor).toHaveValue(/"--repository"/); + await expect(editor).toHaveValue(/"\."/); + await expect(editor).toHaveValue(/"startup_timeout_sec": 30/); +}); + +test("lets members open a Skill-only capability import", async ({ page }) => { + await mockApp(page, undefined, false, "member"); + await page.goto(`/?admin=capabilities&ws=${WORKSPACE_ID}`); + + await page.getByRole("button", { name: "New capability" }).first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("Paste a SKILL.md or upload a zip"); + await expect(dialog.getByRole("tab", { name: "MCP" })).toHaveCount(0); + await expect(dialog.locator("textarea")).toBeVisible(); +}); + +async function mockApp( + page: Page, + directoryOverride?: (route: Route) => Promise, + initiallyImported = false, + workspaceRole = "owner", + versionDelayMs = 0, +) { + let imported = initiallyImported; + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (path === "/api/v1/me") + return json(route, { + user_id: "user-1", + email: "admin@example.com", + name: "Admin", + avatar_url: "", + }); + if (path === "/api/v1/me/workspaces") + return json(route, { + user_id: "user-1", + workspaces: [ + { + id: WORKSPACE_ID, + name: "Directory Test", + slug: "directory-test", + visibility: "private", + role: workspaceRole, + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }, + ], + }); + if (path === "/api/v1/me/discoverable-workspaces") + return json(route, { + user_id: "user-1", + workspaces: [], + total: 0, + limit: 5, + offset: 0, + }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/agents`) + return json(route, { agents: [] }); + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/marketplace-installs` + ) + return json(route, { capabilities: [] }); + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/${CAPABILITY_ID}` + ) + return json(route, { + id: CAPABILITY_ID, + workspace_id: WORKSPACE_ID, + type: "mcp", + name: "Git", + description: "Read, search, and inspect a local Git repository.", + visibility: "workspace", + status: "active", + creator_id: "user-1", + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }); + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/${CAPABILITY_ID}/versions` + ) { + if (versionDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, versionDelayMs)); + } + return json(route, { + versions: [ + { + id: "version-1", + capability_id: CAPABILITY_ID, + version: "2026.7.10", + source_payload: { + source_format: "mcp_catalog", + catalog_id: "git", + catalog_version: "2026.7.10", + catalog_source: "builtin", + }, + canonical_spec: { + schema_version: 1, + kind: "mcp", + mcp: { + servers: [ + { + name: "git", + command: "uvx", + args: [ + "--from", + "mcp-server-git==2026.7.10", + "mcp-server-git", + "--repository", + ".", + ], + startup_timeout_sec: 30, + }, + ], + }, + }, + creator_id: "user-1", + created_at: "2026-07-22T00:00:00Z", + }, + ], + }); + } + if ( + path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/import/preview` + ) + return json(route, { + canonical_spec: { + schema_version: 1, + kind: "mcp", + mcp: { + servers: [ + { + name: "git", + command: "uvx", + args: [ + "--from", + "mcp-server-git==2026.7.10", + "mcp-server-git", + "--repository", + ".", + ], + startup_timeout_sec: 30, + }, + ], + }, + }, + warnings: [], + suggested_name: "git", + }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities`) + return json(route, { + capabilities: imported + ? [ + { + id: CAPABILITY_ID, + workspace_id: WORKSPACE_ID, + type: "mcp", + name: "Filesystem", + description: + "Read and write files from configured directories.", + visibility: "workspace", + status: "active", + creator_id: "user-1", + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }, + ] + : [], + marketplace_installs: [], + total: imported ? 1 : 0, + }); + if (path === "/api/v1/capabilities/marketplace") + return json(route, { + capabilities: [ + { + id: "00000000-0000-0000-0000-000000000044", + capability_id: "00000000-0000-0000-0000-000000000044", + workspace_id: "00000000-0000-0000-0000-000000000055", + type: "skill", + name: "Diagram Maker", + description: "Create diagrams.", + visibility: "public", + status: "active", + required_credentials: [], + latest_version: "1.0.0", + source_workspace_name: "Public Catalog", + installed: false, + self_published: false, + }, + ], + }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory`) { + if (directoryOverride && (await directoryOverride(route))) return; + return json(route, { + items: directoryItems, + updated_at: "2026-07-22T00:00:00Z", + source: "builtin", + }); + } + if ( + path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/filesystem` && + request.method() === "GET" + ) { + return json(route, { + ...directoryItems[0], + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem@1.0.0"], + env: ["FILESYSTEM_ROOT"], + startup_timeout_sec: 30, + }); + } + if ( + path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/deepwiki` && + request.method() === "GET" + ) { + return json(route, { + ...directoryItems[3], + url: "https://mcp.deepwiki.com/mcp", + }); + } + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/filesystem/import` + ) { + imported = true; + return json( + route, + { installed: true, capability_id: CAPABILITY_ID, created: true }, + 201, + ); + } + return json(route, {}); + }); +} + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} From 4816281526edcb5fc034403be5dc185c81d5d4f5 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 23 Jul 2026 16:05:49 +0800 Subject: [PATCH 02/21] feat: complete MCP connector directory workflows --- .env.example | 5 - CONTRIBUTING.md | 18 +- .../internal/agent/codex/mcp_config.go | 21 +- .../internal/agent/codex/mcp_config_test.go | 9 +- .../internal/agent/codex/options.go | 10 + .../internal/agent/codex/protocol.go | 24 + .../internal/agent/codex/server_requests.go | 339 +++++++++- .../agent/codex/server_requests_test.go | 77 +++ .../internal/agent/codex/session.go | 1 + .../internal/agent/opencode/options.go | 23 +- .../internal/agent/opencode/options_test.go | 9 +- .../components/conversation/StepDisplay.tsx | 31 +- apps/web/src/i18n/locales/en-US/admin.json | 48 +- apps/web/src/i18n/locales/zh-CN/admin.json | 48 +- apps/web/src/lib/api-marketplace.ts | 85 ++- apps/web/src/pages/admin/AgentsPage.tsx | 16 +- .../web/src/pages/admin/ConversationsPage.tsx | 67 +- .../web/src/pages/admin/CreateAgentDialog.tsx | 20 +- .../src/pages/admin/agents/AgentConfigTab.tsx | 87 ++- .../AddCapabilityToAgentDialog.tsx | 212 ++++++ .../src/pages/admin/capabilities/index.tsx | 77 ++- .../mcp-directory/ImportMCPDialog.tsx | 31 +- .../mcp-directory/MCPDirectory.tsx | 263 +++++++- .../mcp-directory/MCPDirectoryCard.tsx | 71 +- .../mcp-directory/MCPDirectoryDetail.tsx | 121 +++- .../capabilities/mcp-directory/filters.ts | 4 +- .../capabilities/mcp-directory/shared.tsx | 70 +- .../admin/capabilities/mcp-directory/utils.ts | 10 + .../admin/conversation-runtime-errors.ts | 59 ++ catalog/mcp/README.md | 46 +- catalog/mcp/catalog.json | 570 ++++------------ catalog/mcp/catalog.schema.json | 32 +- deploy/compose/.env.example | 4 - deploy/compose/compose.selfhost.yml | 2 - docker-compose.yml | 1 - docs/deploy/deploy-runbook.md | 3 +- docs/openapi/openapi.yaml | 170 ++++- server/cmd/server/main.go | 22 +- server/internal/api/mcpdirectory/handler.go | 161 ++++- .../internal/api/mcpdirectory/handler_test.go | 63 +- server/internal/api/mcpdirectory/oauth.go | 465 +++++++++++++ .../internal/api/mcpdirectory/oauth_scope.go | 111 +++ .../internal/api/mcpdirectory/oauth_test.go | 479 +++++++++++++ server/internal/auth/mcpoauth/client.go | 630 ++++++++++++++++++ server/internal/auth/mcpoauth/client_test.go | 253 +++++++ server/internal/auth/mcpoauth/credential.go | 134 ++++ server/internal/capability/canonical/mcp.go | 15 + server/internal/capability/canonical/spec.go | 9 +- .../internal/capability/render/claudecode.go | 7 +- server/internal/capability/render/codex.go | 7 +- server/internal/capability/render/opencode.go | 7 +- .../capability/render/placeholders.go | 4 +- .../agentdaemon/capability_runtime.go | 202 +++++- .../capability_runtime_dispatch_test.go | 6 +- .../agentdaemon/capability_runtime_test.go | 193 ++++++ .../agentdaemon/model_injection_test.go | 10 + server/internal/db/queries/store.sql | 26 +- server/internal/db/sqlc/store.sql.go | 126 +++- server/internal/dev/routes_agents.go | 4 +- server/internal/mcpcatalog/catalog_test.go | 163 ++--- server/internal/mcpcatalog/loader.go | 139 +--- server/internal/mcpcatalog/types.go | 88 ++- server/internal/mcpcatalog/validate.go | 27 +- server/internal/store/capability_import.go | 33 +- server/internal/store/store.go | 89 ++- server/internal/store/store_test.go | 79 +++ server/migrations/000010_notion_mcp_oauth.sql | 18 + server/migrations/000011_common_mcp_oauth.sql | 36 + .../migrations/000012_postman_mcp_oauth.sql | 17 + tests/e2e/conversation-runtime-errors.spec.ts | 79 +++ tests/e2e/mcp-directory.spec.ts | 384 ++++++++++- 71 files changed, 5781 insertions(+), 989 deletions(-) create mode 100644 apps/web/src/pages/admin/capabilities/AddCapabilityToAgentDialog.tsx create mode 100644 apps/web/src/pages/admin/conversation-runtime-errors.ts create mode 100644 server/internal/api/mcpdirectory/oauth.go create mode 100644 server/internal/api/mcpdirectory/oauth_scope.go create mode 100644 server/internal/api/mcpdirectory/oauth_test.go create mode 100644 server/internal/auth/mcpoauth/client.go create mode 100644 server/internal/auth/mcpoauth/client_test.go create mode 100644 server/internal/auth/mcpoauth/credential.go create mode 100644 server/migrations/000010_notion_mcp_oauth.sql create mode 100644 server/migrations/000011_common_mcp_oauth.sql create mode 100644 server/migrations/000012_postman_mcp_oauth.sql create mode 100644 tests/e2e/conversation-runtime-errors.spec.ts diff --git a/.env.example b/.env.example index 30a83cdc..fa7aeead 100644 --- a/.env.example +++ b/.env.example @@ -64,11 +64,6 @@ PARSAR_SHARED_RUNTIME_TOKEN= # Internal WebSocket URL advertised to compose-resident daemon runtimes. PARSAR_AGENT_DAEMON_WS_URL=ws://parsar-server:8080/agent-daemon/ws -# Optional trusted JSON endpoint for the MCP Connector Directory. Leave empty -# to use the catalog embedded in the Parsar server image. Remote failures fall -# back to the embedded catalog. -PARSAR_MCP_CATALOG_URL= - # ----------------------------------------------------------------------------- # Feishu Bot (optional — see docs/deploy/lan-deploy.md) # ----------------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a25e625f..d22620b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -275,11 +275,23 @@ description and keep ownership on the side listed here. not a new capability type. Imports become ordinary private `mcp` capabilities through `canonical.Spec`, `Store.ImportCapability`, capability versions, and the existing Agent binding flow. -- Catalog data lives in `catalog/mcp/catalog.json` or the trusted deployment - override `PARSAR_MCP_CATALOG_URL`. It is validated and cached in memory; do - not add a connector catalog table or accept catalog URLs from API requests. +- Catalog data lives in `catalog/mcp/catalog.json`. It is embedded in the + server image; do not add a connector catalog table or accept catalog URLs + from API requests. - Import saves configuration only. It must not execute a command, create empty secrets, bind an Agent, or trust client-submitted command/args/env fields. +- OAuth directory items declare an authentication type, a registered + `credential_kinds.code`, and whether credentials may be user-scoped, + workspace-scoped, or both. Provider discovery, dynamic client registration, + PKCE, code exchange, and refresh stay server-side. User tokens are encrypted + in `user_credentials`; workspace tokens use workspace-stamped + `capability_inline` secrets and existing Agent credential bindings as the + allow list. Tokens never belong in the catalog, canonical spec, frontend + state, or logs. +- Remote MCP authorization is represented as a credential-backed canonical + header. The connector resolves it for the conversation initiator and the + daemon writes it only into the per-run MCP configuration. Renderers and + adapters must preserve remote headers for every supported Agent engine. - Catalog provenance belongs in `capability_version.source_payload` using `source_format=mcp_catalog`, stable `catalog_id`, `catalog_version`, and `catalog_source`. Installation state uses that provenance, never a name diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go index d66c6259..63a65992 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -15,6 +15,7 @@ import ( type mcpServerConfig struct { Name string URL string + Headers map[string]string Command string Args []string Env map[string]string @@ -53,7 +54,25 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e if srv.URL != "" { b.WriteString(`url = `) b.WriteString(tomlQuoteString(srv.URL)) - b.WriteString("\n\n") + b.WriteByte('\n') + if len(srv.Headers) > 0 { + headerKeys := make([]string, 0, len(srv.Headers)) + for key := range srv.Headers { + headerKeys = append(headerKeys, key) + } + sort.Strings(headerKeys) + b.WriteString("http_headers = {") + for index, key := range headerKeys { + if index > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(key)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(srv.Headers[key])) + } + b.WriteString("}\n") + } + b.WriteByte('\n') continue } b.WriteString(`command = `) diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go index 8f5cf6e6..5f394cef 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go @@ -64,7 +64,11 @@ func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { dir := t.TempDir() servers := map[string]mcpServerConfig{ - "docs": {Name: "docs", URL: "https://docs.example.com/mcp"}, + "docs": { + Name: "docs", + URL: "https://docs.example.com/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + }, } if err := writeCodexMCPConfig(dir, servers); err != nil { t.Fatalf("write: %v", err) @@ -73,6 +77,9 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { t.Fatalf("remote config: %s", body) } + if !strings.Contains(string(body), `http_headers = {"Authorization" = "Bearer secret"}`) { + t.Fatalf("remote headers: %s", body) + } } // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 2487dc77..883403c7 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -363,6 +363,16 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { } } } + if headers, ok := entry["headers"].(map[string]any); ok { + srv.Headers = make(map[string]string, len(headers)) + for key, value := range headers { + if text, ok := value.(string); ok { + srv.Headers[key] = text + } + } + } else if headers, ok := entry["headers"].(map[string]string); ok { + srv.Headers = headers + } if srv.Command == "" && srv.URL == "" { return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) } diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/parsar-daemon/internal/agent/codex/protocol.go index f33592da..0929f9ef 100644 --- a/apps/parsar-daemon/internal/agent/codex/protocol.go +++ b/apps/parsar-daemon/internal/agent/codex/protocol.go @@ -442,3 +442,27 @@ type ToolRequestUserInputAnswer struct { type ToolRequestUserInputResponse struct { Answers map[string]ToolRequestUserInputAnswer `json:"answers"` } + +// MCPServerElicitationRequestParams mirrors Codex app-server's +// mcpServer/elicitation/request payload. requestedSchema remains generic so +// Parsar can tolerate additive MCP schema fields while mapping the supported +// primitive form controls into its existing user-choice interaction. +type MCPServerElicitationRequestParams struct { + ThreadID string `json:"threadId"` + TurnID *string `json:"turnId"` + ServerName string `json:"serverName"` + Mode string `json:"mode"` + Meta any `json:"_meta"` + Message string `json:"message"` + RequestedSchema map[string]any `json:"requestedSchema"` + URL string `json:"url"` + ElicitationID string `json:"elicitationId"` +} + +// MCPServerElicitationResponse is the response body expected by Codex and the +// upstream MCP server. Content is populated only for accepted form requests. +type MCPServerElicitationResponse struct { + Action string `json:"action"` + Content map[string]any `json:"content"` + Meta any `json:"_meta"` +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/parsar-daemon/internal/agent/codex/server_requests.go index 7bc958d0..0719fd8d 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "sort" + stdstrconv "strconv" "strings" "sync" "time" @@ -26,10 +28,27 @@ type pendingCodexAsk struct { rpcID any questionIDs []string answerKeys []string + kind codexAskKind + mcpFields []mcpElicitationField timeout time.Duration timer *time.Timer } +type codexAskKind uint8 + +const ( + codexAskUserInput codexAskKind = iota + codexAskMCPElicitationForm + codexAskMCPElicitationURL +) + +type mcpElicitationField struct { + ID string + Type string + MultiSelect bool + OptionValue map[string]string +} + const codexInteractionTimeout = 10 * time.Minute type codexPermissionKind uint8 @@ -138,7 +157,6 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err if len(params.Questions) == 0 { return nil, errors.New("requestUserInput contains no questions") } - askID := codexInteractionID("ask") questions := make([]proto.PromptForUserChoiceQuestion, 0, len(params.Questions)) questionIDs := make([]string, 0, len(params.Questions)) answerKeys := make([]string, 0, len(params.Questions)) @@ -169,13 +187,40 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err timeout = time.Duration(*params.AutoResolutionMs) * time.Millisecond } } - pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, answerKeys: answerKeys, timeout: timeout} + pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, answerKeys: answerKeys, kind: codexAskUserInput, timeout: timeout} + return s.deferCodexAsk(pending, questions, params.AutoResolutionMs) +} + +func (s *Session) handleCodexMCPElicitation(raw json.RawMessage, rpcID any) (any, error) { + var params MCPServerElicitationRequestParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode MCP elicitation: %w", err) + } + questions, fields, kind, err := mcpElicitationQuestions(params) + if err != nil { + return nil, err + } + questionIDs := make([]string, 0, len(questions)) + answerKeys := make([]string, 0, len(questions)) + for _, question := range questions { + questionIDs = append(questionIDs, question.ID) + answerKeys = append(answerKeys, question.Header) + } + pending := pendingCodexAsk{ + rpcID: rpcID, questionIDs: questionIDs, answerKeys: answerKeys, + kind: kind, mcpFields: fields, timeout: codexInteractionTimeout, + } + return s.deferCodexAsk(pending, questions, nil) +} + +func (s *Session) deferCodexAsk(pending pendingCodexAsk, questions []proto.PromptForUserChoiceQuestion, autoResolutionMs *uint64) (any, error) { + askID := codexInteractionID("ask") s.interactions.mu.Lock() pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) s.interactions.asks[askID] = pending s.interactions.mu.Unlock() env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, s.runID, proto.PromptForUserChoicePayload{ - AskID: askID, Questions: questions, AutoResolutionMs: params.AutoResolutionMs, + AskID: askID, Questions: questions, AutoResolutionMs: autoResolutionMs, }) if err != nil { s.interactions.mu.Lock() @@ -193,6 +238,186 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err return DeferReply, nil } +func mcpElicitationQuestions(params MCPServerElicitationRequestParams) ([]proto.PromptForUserChoiceQuestion, []mcpElicitationField, codexAskKind, error) { + serverName := strings.TrimSpace(params.ServerName) + if serverName == "" { + serverName = "MCP server" + } + message := strings.TrimSpace(params.Message) + if message == "" { + message = serverName + " needs additional input." + } + if params.Mode == "url" { + url := strings.TrimSpace(params.URL) + if url == "" { + return nil, nil, 0, errors.New("MCP URL elicitation is missing url") + } + return []proto.PromptForUserChoiceQuestion{{ + ID: "continue", Header: serverName, Question: message, + Options: []proto.PromptForUserChoiceOption{{Label: "Continue", Description: url}}, + }}, []mcpElicitationField{{ID: "continue", Type: "url"}}, codexAskMCPElicitationURL, nil + } + if params.Mode != "form" && params.Mode != "openai/form" { + return nil, nil, 0, fmt.Errorf("unsupported MCP elicitation mode %q", params.Mode) + } + + properties, ok := params.RequestedSchema["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return []proto.PromptForUserChoiceQuestion{{ + ID: "confirm", Header: serverName, Question: message, + Options: []proto.PromptForUserChoiceOption{{Label: "Continue"}}, + }}, []mcpElicitationField{{ID: "confirm", Type: "confirm"}}, codexAskMCPElicitationForm, nil + } + required := stringSet(params.RequestedSchema["required"]) + keys := make([]string, 0, len(properties)) + for key := range properties { + keys = append(keys, key) + } + sort.Strings(keys) + questions := make([]proto.PromptForUserChoiceQuestion, 0, len(keys)) + fields := make([]mcpElicitationField, 0, len(keys)) + for _, key := range keys { + property, ok := properties[key].(map[string]any) + if !ok { + return nil, nil, 0, fmt.Errorf("MCP elicitation property %q must be an object", key) + } + question, field, err := mcpElicitationQuestion(key, property, message, required[key]) + if err != nil { + return nil, nil, 0, err + } + questions = append(questions, question) + fields = append(fields, field) + } + return questions, fields, codexAskMCPElicitationForm, nil +} + +func mcpElicitationQuestion(id string, property map[string]any, fallback string, required bool) (proto.PromptForUserChoiceQuestion, mcpElicitationField, error) { + typeName, _ := property["type"].(string) + title, _ := property["title"].(string) + if strings.TrimSpace(title) == "" { + title = id + } + description, _ := property["description"].(string) + if strings.TrimSpace(description) == "" { + description = fallback + } + question := proto.PromptForUserChoiceQuestion{ID: id, Header: title, Question: description} + field := mcpElicitationField{ID: id, Type: typeName, OptionValue: map[string]string{}} + + options, multiSelect := mcpElicitationOptions(property) + if len(options) > 0 { + question.Options = make([]proto.PromptForUserChoiceOption, 0, len(options)+1) + question.MultiSelect = multiSelect + field.MultiSelect = multiSelect + for _, option := range options { + question.Options = append(question.Options, proto.PromptForUserChoiceOption{Label: option.label, Description: option.description}) + field.OptionValue[option.label] = option.value + } + } else { + switch typeName { + case "boolean": + question.Options = []proto.PromptForUserChoiceOption{{Label: "Yes"}, {Label: "No"}} + field.OptionValue["Yes"] = "true" + field.OptionValue["No"] = "false" + case "string", "number", "integer": + question.IsOther = true + case "array": + return proto.PromptForUserChoiceQuestion{}, mcpElicitationField{}, fmt.Errorf("MCP elicitation property %q array must declare enum items", id) + default: + return proto.PromptForUserChoiceQuestion{}, mcpElicitationField{}, fmt.Errorf("MCP elicitation property %q has unsupported type %q", id, typeName) + } + } + if !required { + question.Options = append(question.Options, proto.PromptForUserChoiceOption{Label: "Skip"}) + field.OptionValue["Skip"] = "" + } + return question, field, nil +} + +type mcpElicitationOption struct { + label string + value string + description string +} + +func mcpElicitationOptions(property map[string]any) ([]mcpElicitationOption, bool) { + if raw, ok := property["enum"].([]any); ok { + names, _ := property["enumNames"].([]any) + return stringOptions(raw, names), false + } + if raw, ok := property["oneOf"].([]any); ok { + return constOptions(raw), false + } + items, _ := property["items"].(map[string]any) + if raw, ok := items["enum"].([]any); ok { + return stringOptions(raw, nil), true + } + if raw, ok := items["anyOf"].([]any); ok { + return constOptions(raw), true + } + if raw, ok := items["oneOf"].([]any); ok { + return constOptions(raw), true + } + return nil, false +} + +func stringOptions(values, names []any) []mcpElicitationOption { + options := make([]mcpElicitationOption, 0, len(values)) + for index, raw := range values { + value, ok := raw.(string) + if !ok { + continue + } + label := value + if index < len(names) { + if named, ok := names[index].(string); ok && strings.TrimSpace(named) != "" { + label = named + } + } + description := "" + if label != value { + description = value + } + options = append(options, mcpElicitationOption{label: label, value: value, description: description}) + } + return options +} + +func constOptions(values []any) []mcpElicitationOption { + options := make([]mcpElicitationOption, 0, len(values)) + for _, raw := range values { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + value, _ := entry["const"].(string) + if value == "" { + continue + } + label, _ := entry["title"].(string) + if strings.TrimSpace(label) == "" { + label = value + } + description := "" + if label != value { + description = value + } + options = append(options, mcpElicitationOption{label: label, value: value, description: description}) + } + return options +} + +func stringSet(raw any) map[string]bool { + result := map[string]bool{} + values, _ := raw.([]any) + for _, value := range values { + if text, ok := value.(string); ok { + result[text] = true + } + } + return result +} + func (s *Session) submitCodexPermission(requestID string, decision proto.PermissionDecisionPayload) error { s.interactions.mu.Lock() pending, ok := s.interactions.permissions[requestID] @@ -247,6 +472,18 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse if pending.timer != nil { pending.timer.Stop() } + if pending.kind == codexAskMCPElicitationForm || pending.kind == codexAskMCPElicitationURL { + response, err := mcpElicitationResponse(pending, decision) + if err != nil { + s.restoreCodexAsk(askID, pending) + return err + } + if err := s.rpc.SendServerReply(pending.rpcID, response); err != nil { + s.restoreCodexAsk(askID, pending) + return err + } + return nil + } if decision.Cancelled { reason := strings.TrimSpace(decision.Reason) if reason == "" { @@ -261,6 +498,76 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse } return nil } + answers := codexAskAnswers(pending, decision) + result := ToolRequestUserInputResponse{Answers: make(map[string]ToolRequestUserInputAnswer, len(pending.questionIDs))} + for index, questionID := range pending.questionIDs { + result.Answers[questionID] = ToolRequestUserInputAnswer{Answers: answers[index]} + } + if err := s.rpc.SendServerReply(pending.rpcID, result); err != nil { + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) + s.interactions.asks[askID] = pending + s.interactions.mu.Unlock() + return err + } + return nil +} + +func mcpElicitationResponse(pending pendingCodexAsk, decision proto.PromptForUserChoiceDecisionPayload) (MCPServerElicitationResponse, error) { + if decision.Cancelled { + return MCPServerElicitationResponse{Action: "cancel"}, nil + } + if pending.kind == codexAskMCPElicitationURL { + return MCPServerElicitationResponse{Action: "accept"}, nil + } + answers := codexAskAnswers(pending, decision) + content := make(map[string]any, len(pending.mcpFields)) + for index, field := range pending.mcpFields { + if index >= len(answers) || len(answers[index]) == 0 { + continue + } + values := answers[index] + resolved := make([]string, 0, len(values)) + for _, value := range values { + if mapped, ok := field.OptionValue[value]; ok { + value = mapped + } + if value != "" { + resolved = append(resolved, value) + } + } + if len(resolved) == 0 { + continue + } + switch field.Type { + case "boolean": + value, err := stdstrconv.ParseBool(resolved[0]) + if err != nil { + return MCPServerElicitationResponse{}, fmt.Errorf("MCP elicitation %q requires true or false", field.ID) + } + content[field.ID] = value + case "integer": + value, err := stdstrconv.ParseInt(resolved[0], 10, 64) + if err != nil { + return MCPServerElicitationResponse{}, fmt.Errorf("MCP elicitation %q requires an integer", field.ID) + } + content[field.ID] = value + case "number": + value, err := stdstrconv.ParseFloat(resolved[0], 64) + if err != nil { + return MCPServerElicitationResponse{}, fmt.Errorf("MCP elicitation %q requires a number", field.ID) + } + content[field.ID] = value + case "array": + content[field.ID] = resolved + default: + content[field.ID] = resolved[0] + } + } + return MCPServerElicitationResponse{Action: "accept", Content: content}, nil +} + +func codexAskAnswers(pending pendingCodexAsk, decision proto.PromptForUserChoiceDecisionPayload) [][]string { byID := make(map[string][]string, len(decision.QuestionAnswers)) byHeader := make(map[string][]string, len(decision.QuestionAnswers)) for _, answer := range decision.QuestionAnswers { @@ -275,7 +582,7 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse byHeader[answer.Header] = values } } - result := ToolRequestUserInputResponse{Answers: make(map[string]ToolRequestUserInputAnswer, len(pending.questionIDs))} + result := make([][]string, len(pending.questionIDs)) for index, questionID := range pending.questionIDs { values := byID[questionID] if len(values) == 0 && index < len(pending.answerKeys) { @@ -290,16 +597,16 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse if len(values) == 0 && index == 0 && len(decision.Answers) > 0 { values = decision.Answers } - result.Answers[questionID] = ToolRequestUserInputAnswer{Answers: values} - } - if err := s.rpc.SendServerReply(pending.rpcID, result); err != nil { - s.interactions.mu.Lock() - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) - s.interactions.asks[askID] = pending - s.interactions.mu.Unlock() - return err + result[index] = values } - return nil + return result +} + +func (s *Session) restoreCodexAsk(askID string, pending pendingCodexAsk) { + s.interactions.mu.Lock() + pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) + s.interactions.asks[askID] = pending + s.interactions.mu.Unlock() } func (s *Session) expireCodexPermission(requestID string) { @@ -328,7 +635,11 @@ func (s *Session) expireCodexAsk(askID string) { if pending.timer != nil { pending.timer.Stop() } - _ = s.rpc.SendServerError(pending.rpcID, -32001, "input request timed out", nil) + if pending.kind == codexAskMCPElicitationForm || pending.kind == codexAskMCPElicitationURL { + _ = s.rpc.SendServerReply(pending.rpcID, MCPServerElicitationResponse{Action: "cancel"}) + } else { + _ = s.rpc.SendServerError(pending.rpcID, -32001, "input request timed out", nil) + } } } diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go index 0665a618..2b388294 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go @@ -239,6 +239,83 @@ func TestCodexUserInputCancellationReturnsErrorInsteadOfEmptyAnswers(t *testing. } } +func TestCodexMCPElicitationMapsFormAnswers(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + + if err := SendServerRequest(srv, "rpc-mcp-form", "mcpServer/elicitation/request", MCPServerElicitationRequestParams{ + ThreadID: "thread-1", ServerName: "Massive Market Data", Mode: "form", + Message: "Allow this market data request?", + RequestedSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "approved": map[string]any{"type": "boolean", "title": "Allow request"}, + }, + "required": []any{"approved"}, + }, + }); err != nil { + t.Fatalf("send MCP elicitation: %v", err) + } + + var request proto.PromptForUserChoicePayload + if err := (<-out).DecodePayload(&request); err != nil { + t.Fatalf("decode MCP elicitation payload: %v", err) + } + if len(request.Questions) != 1 || request.Questions[0].ID != "approved" || len(request.Questions[0].Options) != 2 { + t.Fatalf("MCP elicitation questions = %+v", request.Questions) + } + done := make(chan error, 1) + go func() { + done <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "approved", Answers: []string{"Yes"}}}, + }) + }() + var reply struct { + ID string `json:"id"` + Result MCPServerElicitationResponse `json:"result"` + } + decodeCodexReply(t, srv, &reply) + if err := <-done; err != nil { + t.Fatalf("submit MCP elicitation: %v", err) + } + if reply.ID != "rpc-mcp-form" || reply.Result.Action != "accept" || reply.Result.Content["approved"] != true { + t.Fatalf("MCP elicitation reply = %+v", reply) + } +} + +func TestCodexMCPElicitationCancellationUsesMCPResponse(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + + if err := SendServerRequest(srv, "rpc-mcp-url", "mcpServer/elicitation/request", MCPServerElicitationRequestParams{ + ThreadID: "thread-1", ServerName: "Example", Mode: "url", + Message: "Open the provider page, then continue.", URL: "https://example.com/confirm", + }); err != nil { + t.Fatalf("send MCP URL elicitation: %v", err) + } + var request proto.PromptForUserChoicePayload + if err := (<-out).DecodePayload(&request); err != nil { + t.Fatalf("decode MCP URL elicitation payload: %v", err) + } + done := make(chan error, 1) + go func() { + done <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{Cancelled: true}) + }() + var reply struct { + ID string `json:"id"` + Result MCPServerElicitationResponse `json:"result"` + } + decodeCodexReply(t, srv, &reply) + if err := <-done; err != nil { + t.Fatalf("cancel MCP elicitation: %v", err) + } + if reply.ID != "rpc-mcp-url" || reply.Result.Action != "cancel" || reply.Result.Content != nil { + t.Fatalf("MCP cancellation reply = %+v", reply) + } +} + func TestCodexInteractionExpiryUnblocksRuntime(t *testing.T) { t.Run("permission declines", func(t *testing.T) { tc, srv, cleanup := NewTestClient() diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 7539f65a..05560da1 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -345,6 +345,7 @@ func (s *Session) registerHandlers() { // Older app-server releases used this unseparated method name. rpc.OnServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) rpc.OnServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) + rpc.OnServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) } func (s *Session) onThreadStarted(raw json.RawMessage) { diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go index c1cbb524..3e9be912 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -103,11 +103,15 @@ func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { enabled = value } if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { - mcp[name] = map[string]any{ + remote := map[string]any{ "type": "remote", "url": strings.TrimSpace(remoteURL), "enabled": enabled, } + if headers := stringMap(entry["headers"]); len(headers) > 0 { + remote["headers"] = headers + } + mcp[name] = remote continue } command, ok := entry["command"].(string) @@ -142,6 +146,23 @@ func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { return string(encoded), nil } +func stringMap(value any) map[string]string { + switch typed := value.(type) { + case map[string]string: + return typed + case map[string]any: + result := make(map[string]string, len(typed)) + for key, raw := range typed { + if text, ok := raw.(string); ok { + result[key] = text + } + } + return result + default: + return nil + } +} + func resolveWorkDir(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go index bfe9e4fa..47e8ec23 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -100,7 +100,10 @@ func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { "opencode_json": `{"provider":{}}`, "mcp_servers": map[string]any{ "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, - "docs": map[string]any{"url": "https://docs.example.com/mcp"}, + "docs": map[string]any{ + "url": "https://docs.example.com/mcp", + "headers": map[string]any{"Authorization": "Bearer secret"}, + }, }, }) if err != nil { @@ -121,6 +124,10 @@ func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { t.Fatalf("remote = %+v", remote) } + headers := remote["headers"].(map[string]any) + if headers["Authorization"] != "Bearer secret" { + t.Fatalf("remote headers = %+v", headers) + } local := mcp["local"].(map[string]any) if local["type"] != "local" { t.Fatalf("local = %+v", local) diff --git a/apps/web/src/components/conversation/StepDisplay.tsx b/apps/web/src/components/conversation/StepDisplay.tsx index 3548eae9..cf60d1ee 100644 --- a/apps/web/src/components/conversation/StepDisplay.tsx +++ b/apps/web/src/components/conversation/StepDisplay.tsx @@ -31,6 +31,14 @@ function toolIcon(name: string) { return TOOL_ICONS[key] ?? Wrench } +function displayToolName(name: string): string { + const match = /^mcp__([^_]+)__(.+)$/i.exec(name.trim()) + if (!match) return (name || "tool").toUpperCase() + const server = match[1].replace(/[-_]+/g, " ").toUpperCase() + const tool = match[2].replace(/[-_]+/g, " ").toUpperCase() + return `${server} · ${tool}` +} + const SUMMARY_MAX = 80 /** Picks the most informative single field from a tool's args payload. @@ -100,7 +108,7 @@ export function StepItem({ durationMs?: number }) { const Icon = toolIcon(name) - const upper = (name || "tool").toUpperCase() + const upper = displayToolName(name) const summary = detail ? ellipsizeMiddle(detail) : "" return (
@@ -121,14 +129,12 @@ export function StepItem({ ? "text-danger-emphasis" : "text-fg-subtle", )} + title={name} > {upper} {summary && ( - + {summary} )} @@ -169,8 +175,7 @@ export function WorkingSteps({ const lastEnded = !anyRunning ? Math.max(...completedSteps.map((s) => s.ended_at ?? s.started_at), 0) : null - const overallMs = - firstStart === null ? 0 : (lastEnded ?? now) - firstStart + const overallMs = firstStart === null ? 0 : (lastEnded ?? now) - firstStart return (
@@ -179,9 +184,7 @@ export function WorkingSteps({ type="button" aria-expanded={expanded} aria-label={ - expanded - ? t("conversations.steps.collapseAria") - : t("conversations.steps.expandAria") + expanded ? t("conversations.steps.collapseAria") : t("conversations.steps.expandAria") } onClick={() => setExpanded((v) => !v)} className="flex shrink-0 items-center text-fg-faint transition-colors hover:text-fg-muted" @@ -235,7 +238,9 @@ export function WorkingSteps({ type="button" onClick={onCancel} disabled={cancelling} - aria-label={t("conversations.steps.cancelAria", { defaultValue: "Cancel current task" })} + aria-label={t("conversations.steps.cancelAria", { + defaultValue: "Cancel current task", + })} title={t("conversations.steps.cancelAria", { defaultValue: "Cancel current task" })} className="rounded p-0.5 text-fg-faint transition-colors hover:bg-surface-muted hover:text-danger disabled:opacity-40" > @@ -292,7 +297,9 @@ export function StepTrace({ steps }: { steps: ToolStep[] }) {
) } - if (messageType === "runtime_error") { + if (isRuntimeErrorMessage(messageType, metadata)) { const runtimeError = runtimeErrorViewModel(metadata, content, conversationId, i18n.language, t) + const runtimeErrorSubKind = + stringMeta(metadata, "sub_kind") || stringMeta(metadata, "payload.sub_kind") + const runtimeErrorBadge = runtimeErrorSubKind.startsWith("capability_") + ? t("conversations.runtime_error.capabilityBadge") + : t("conversations.runtime_error.badge") return (
- {t("conversations.runtime_error.badge")} + {runtimeErrorBadge}

{runtimeError.message}

@@ -1229,9 +1242,7 @@ function MessageRow({ {runtimeError.action} )} -

- {t("conversations.runtime_error.retryHint")} -

+

{runtimeError.hint}

{agentName ? `${stamp} · ${agentName}` : stamp} @@ -1301,6 +1312,16 @@ function runtimeErrorViewModel( switch (subKind) { case "capability_credential_missing": + if (!credentialKind) { + return { + message: t("conversations.runtime_error.capability_unsupported", { + name: capabilityName, + }), + action: t("conversations.runtime_error.manageCapability"), + href: manageCapabilityHref, + hint: t("conversations.runtime_error.unsupportedHint"), + } + } return { message: t("conversations.runtime_error.capability_credential_missing", { name: capabilityName, @@ -1308,6 +1329,7 @@ function runtimeErrorViewModel( }), action: t("conversations.runtime_error.addCredential"), href, + hint: t("conversations.runtime_error.retryHint"), } case "capability_credential_decrypt_failed": return { @@ -1316,6 +1338,7 @@ function runtimeErrorViewModel( }), action: "", href: "", + hint: t("conversations.runtime_error.retryHint"), } case "capability_credential_kind_mismatch": return { @@ -1324,6 +1347,16 @@ function runtimeErrorViewModel( }), action: t("conversations.runtime_error.resetCredential"), href, + hint: t("conversations.runtime_error.retryHint"), + } + case "capability_unsupported": + return { + message: t("conversations.runtime_error.capability_unsupported", { + name: capabilityName, + }), + action: t("conversations.runtime_error.manageCapability"), + href: manageCapabilityHref, + hint: t("conversations.runtime_error.unsupportedHint"), } case "capability_version_unavailable": // Daemon resolver couldn't find a usable zip (empty oss_key) for @@ -1337,26 +1370,18 @@ function runtimeErrorViewModel( }), action: t("conversations.runtime_error.manageCapability"), href: manageCapabilityHref, + hint: t("conversations.runtime_error.versionUnavailableHint"), } default: - return { message: fallback || t("conversations.runtime_error.generic"), action: "", href: "" } + return { + message: fallback || t("conversations.runtime_error.generic"), + action: "", + href: "", + hint: t("conversations.runtime_error.retryHint"), + } } } -function stringMeta(metadata: Record | undefined, key: string): string { - if (!metadata) return "" - const value = key.includes(".") - ? key - .split(".") - .reduce( - (acc, part) => - acc && typeof acc === "object" ? (acc as Record)[part] : undefined, - metadata, - ) - : metadata[key] - return typeof value === "string" ? value : "" -} - /* ============================================================== */ /* Composer — task input, Enter to send */ /* ============================================================== */ diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index ec5f3c12..b18e2436 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -311,6 +311,15 @@ export function CreateAgentDialog({ () => (secretsQ.data?.secrets ?? []).filter((s) => s.kind === "capability_inline" && s.status === "active"), [secretsQ.data?.secrets], ) + const workspaceOAuthCredentialKinds = useMemo( + () => new Set( + sharedSecrets + .filter((secret) => secret.auth_type === "oauth2" && typeof secret.metadata.catalog_id === "string") + .map((secret) => secret.metadata.credential_kind_code) + .filter((kind): kind is string => typeof kind === "string" && kind !== ""), + ), + [sharedSecrets], + ) const activeModels = useMemo(() => models.filter((m) => m.status === "active"), [models]) // Default to the first model the chosen engine can actually drive, so a // fresh create (and any engine switch) never lands on a greyed-out model @@ -463,10 +472,13 @@ export function CreateAgentDialog({ return [...own, ...installed, ...available] }, [allCapabilitiesQ.data]) const aggregatedRequiredKinds = useMemo( - () => mode === "create" - ? aggregateRequiredCredentialsByID(selectedCapabilityIDs, allCapabilitiesPool) - : aggregateRequiredCredentials(capabilities, allCapabilitiesPool), - [capabilities, mode, selectedCapabilityIDs, allCapabilitiesPool] + () => { + const required = mode === "create" + ? aggregateRequiredCredentialsByID(selectedCapabilityIDs, allCapabilitiesPool) + : aggregateRequiredCredentials(capabilities, allCapabilitiesPool) + return required.filter((credential) => !workspaceOAuthCredentialKinds.has(credential.kind)) + }, + [capabilities, mode, selectedCapabilityIDs, allCapabilitiesPool, workspaceOAuthCredentialKinds] ) const admin = isAdminRole(workspaceRole) diff --git a/apps/web/src/pages/admin/agents/AgentConfigTab.tsx b/apps/web/src/pages/admin/agents/AgentConfigTab.tsx index f38690d6..ad3bca31 100644 --- a/apps/web/src/pages/admin/agents/AgentConfigTab.tsx +++ b/apps/web/src/pages/admin/agents/AgentConfigTab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { Loader2, Plus, Search } from "lucide-react" import { useTranslation } from "react-i18next" @@ -36,8 +36,9 @@ import { useToggleBuiltinCapabilityMutation, } from "../../../lib/api-capabilities" import { useMyCredentials } from "../../../lib/api-credentials" +import { useSecrets } from "../../../lib/api-secrets" import { agentExecutionPlacement } from "../../../lib/agent-runtime" -import type { Agent, AgentCapability, AgentDetail, Capability, CapabilityVersion, UserCredential } from "../../../lib/api-types" +import type { Agent, AgentCapability, AgentDetail, Capability, CapabilityVersion, Secret, UserCredential } from "../../../lib/api-types" import { CapabilityTypeBadge } from "../CapabilitiesPage" import { UpgradeCapabilityDialog } from "../capabilities/UpgradeCapabilityDialog" import { credentialKindLabel } from "../capability-ui" @@ -95,8 +96,17 @@ function requiredCredentialKinds(capability: Capability) { return (capability.required_credentials ?? []).filter((rc) => rc.required) } -function hasCredentialKind(credentials: UserCredential[], kind: string) { - return credentials.some((credential) => credential.kind === kind) +function workspaceOAuthCredentialKinds(secrets: Secret[]) { + return new Set( + secrets + .filter((secret) => secret.kind === "capability_inline" && secret.auth_type === "oauth2" && secret.status === "active" && typeof secret.metadata.catalog_id === "string") + .map((secret) => secret.metadata.credential_kind_code) + .filter((kind): kind is string => typeof kind === "string" && kind !== ""), + ) +} + +function hasCredentialKind(credentials: UserCredential[], workspaceOAuthKinds: Set, kind: string) { + return workspaceOAuthKinds.has(kind) || credentials.some((credential) => credential.kind === kind) } function useCapabilityVersions( @@ -166,6 +176,7 @@ function CapabilityCard({ agent, workspaceID, credentials, + workspaceOAuthKinds, mode, onToast, }: { @@ -173,6 +184,7 @@ function CapabilityCard({ agent: Agent workspaceID: string | null credentials: UserCredential[] + workspaceOAuthKinds: Set mode: "enabled" | "available" onToast: (message: string) => void }) { @@ -182,7 +194,7 @@ function CapabilityCard({ const { latest, versions, versionsQ } = useCapabilityVersions(workspaceID, capability, mode === "enabled") const boundVersion = versions.find((version) => version.id === binding?.capability_version_id) ?? (binding?.capability_version_id && capability?.pinned_version ? { id: binding.capability_version_id, capability_id: capability.id, version: capability.pinned_version, created_at: capability.latest_version_created_at ?? capability.created_at } as CapabilityVersion : undefined) const versionDeleted = !!binding && !versionsQ.isLoading && !boundVersion && !capability?.latest_version_id - const missingCredential = capability ? requiredCredentialKinds(capability).some((rc) => !hasCredentialKind(credentials, rc.kind)) : false + const missingCredential = capability ? requiredCredentialKinds(capability).some((rc) => !hasCredentialKind(credentials, workspaceOAuthKinds, rc.kind)) : false const fromMarketplace = !!capability?.from_marketplace || (!!capability?.source_workspace_id && capability.source_workspace_id !== workspaceID) const deprecated = !!capability?.deprecated_at const border = mode === "available" ? "border-dashed border-line-strong" : "border-line" @@ -220,6 +232,7 @@ function CapabilityCard({ agent={agent} capability={capability} binding={binding} + workspaceOAuthKinds={workspaceOAuthKinds} workspaceID={workspaceID} triggerLabel={t("agents.detail.capabilities.bindings.versionDeleted.switchAction")} triggerVariant="link" @@ -250,7 +263,7 @@ function CapabilityCard({ /> )} - +
@@ -267,6 +280,7 @@ function CapabilityCard({ agent={agent} capability={capability} credentials={credentials} + workspaceOAuthKinds={workspaceOAuthKinds} workspaceID={workspaceID} onToast={onToast} /> @@ -278,6 +292,7 @@ function CapabilityCard({ agent={agent} capability={capability} binding={binding} + workspaceOAuthKinds={workspaceOAuthKinds} workspaceID={workspaceID} onToast={onToast} /> @@ -297,7 +312,15 @@ function CapabilityCard({ ) } -function CredentialStatus({ capability, credentials }: { capability: Capability; credentials: UserCredential[] }) { +function CredentialStatus({ + capability, + credentials, + workspaceOAuthKinds, +}: { + capability: Capability + credentials: UserCredential[] + workspaceOAuthKinds: Set +}) { const { t, i18n } = useTranslation("admin") const requiredCreds = capability.required_credentials ?? [] if (requiredCreds.length === 0) { @@ -307,11 +330,12 @@ function CredentialStatus({ capability, credentials }: { capability: Capability;
{requiredCreds.map((rc) => { const credential = credentials.find((cred) => cred.kind === rc.kind) + const workspaceConnected = workspaceOAuthKinds.has(rc.kind) const label = credentialKindLabel(rc.kind, i18n.language, rc.kind) return ( -
- {credential ? ( - {t("agents.detail.capabilities.credential.present", { kind: label, name: credential.display_name || t("agents.detail.capabilities.credential.defaultName") })} +
+ {credential || workspaceConnected ? ( + {t("agents.detail.capabilities.credential.present", { kind: label, name: workspaceConnected ? t("capabilities.mcpDirectory.oauth.workspaceCredential") : credential?.display_name || t("agents.detail.capabilities.credential.defaultName") })} ) : ( {t("agents.detail.capabilities.credential.missing", { kind: label })} )} @@ -365,15 +389,17 @@ function VersionSelect({ versions, value, onChange }: { versions: CapabilityVers function EnableCredentialStatusList({ requiredKinds, credentials, + workspaceOAuthKinds, }: { requiredKinds: { kind: string }[] credentials: UserCredential[] + workspaceOAuthKinds: Set }) { const { t } = useTranslation("admin") return (
{requiredKinds.map((rc) => { - const has = hasCredentialKind(credentials, rc.kind) + const has = hasCredentialKind(credentials, workspaceOAuthKinds, rc.kind) return (
workspaceID: string | null binding?: AgentCapability triggerLabel?: string @@ -423,7 +451,7 @@ function CapabilityVersionDialog({ ? versions.find((version) => version.id === selected) ?? (mode === "enable" ? latest : versions[0]) : mode === "enable" ? latest : versions[0] const requiredKinds = mode === "enable" ? requiredCredentialKinds(capability) : [] - const missingRequiredCredential = requiredKinds.some((rc) => !hasCredentialKind(credentials, rc.kind)) + const missingRequiredCredential = requiredKinds.some((rc) => !hasCredentialKind(credentials, workspaceOAuthKinds, rc.kind)) const canSubmit = !!selectedVersion && !mut.isPending && (mode === "enable" ? !missingRequiredCredential : selectedVersion.id !== binding?.capability_version_id) @@ -476,7 +504,7 @@ function CapabilityVersionDialog({ {versionsQ.isLoading ? : }
{requiredKinds.length > 0 ? ( - + ) : (
{t("agents.detail.capabilities.enableDialog.noCredential")} @@ -562,18 +590,22 @@ export function AgentConfigTab({ workspaceID, workspaceRole, modelLabel, + pendingCapabilityID, onToast, }: { agent: AgentDetail workspaceID: string | null workspaceRole?: string modelLabel: string + pendingCapabilityID?: string | null onToast: (message: string) => void }) { const agentCapabilitiesQ = useAgentCapabilitiesQuery(workspaceID, agent.id) const workspaceCapabilitiesQ = useCapabilitiesQuery(workspaceID) const credentialsQ = useMyCredentials() + const secretsQ = useSecrets(workspaceID) const credentials = credentialsQ.data?.credentials ?? [] + const workspaceOAuthKinds = workspaceOAuthCredentialKinds(secretsQ.data?.secrets ?? []) const installedCapabilities = agentCapabilitiesQ.data?.installed ?? [] const availableCapabilities = agentCapabilitiesQ.data?.available ?? workspaceCapabilitiesQ.data?.capabilities ?? [] const installedIDs = new Set(installedCapabilities.map((item) => item.capability_id)) @@ -605,9 +637,11 @@ export function AgentConfigTab({ isAdmin={isAdmin} enabledCaps={enabledCaps} installable={installable} + pendingCapabilityID={pendingCapabilityID} credentials={credentials} - loading={agentCapabilitiesQ.isLoading || workspaceCapabilitiesQ.isLoading} - error={agentCapabilitiesQ.error ?? workspaceCapabilitiesQ.error} + workspaceOAuthKinds={workspaceOAuthKinds} + loading={agentCapabilitiesQ.isLoading || workspaceCapabilitiesQ.isLoading || secretsQ.isLoading} + error={agentCapabilitiesQ.error ?? workspaceCapabilitiesQ.error ?? secretsQ.error} onToast={onToast} />
@@ -620,7 +654,9 @@ function ConfigCapabilitiesSection({ isAdmin, enabledCaps, installable, + pendingCapabilityID, credentials, + workspaceOAuthKinds, loading, error, onToast, @@ -630,13 +666,23 @@ function ConfigCapabilitiesSection({ isAdmin: boolean enabledCaps: Array<{ binding: AgentCapability; capability?: Capability }> installable: Capability[] + pendingCapabilityID?: string | null credentials: UserCredential[] + workspaceOAuthKinds: Set loading: boolean error: unknown onToast: (message: string) => void }) { const { t } = useTranslation("admin") const [addOpen, setAddOpen] = useState(false) + const openedPendingCapability = useRef(null) + + useEffect(() => { + if (!isAdmin || !pendingCapabilityID || openedPendingCapability.current === pendingCapabilityID) return + if (!installable.some((capability) => capability.id === pendingCapabilityID)) return + openedPendingCapability.current = pendingCapabilityID + setAddOpen(true) + }, [installable, isAdmin, pendingCapabilityID]) if (loading) { return ( @@ -700,6 +746,7 @@ function ConfigCapabilitiesSection({ agent={agent} workspaceID={workspaceID} credentials={credentials} + workspaceOAuthKinds={workspaceOAuthKinds} mode="enabled" onToast={onToast} /> @@ -714,7 +761,9 @@ function ConfigCapabilitiesSection({ agent={agent} workspaceID={workspaceID} installable={installable} + preferredCapabilityID={pendingCapabilityID} credentials={credentials} + workspaceOAuthKinds={workspaceOAuthKinds} onToast={onToast} /> @@ -727,7 +776,9 @@ function AddCapabilityDialog({ agent, workspaceID, installable, + preferredCapabilityID, credentials, + workspaceOAuthKinds, onToast, }: { open: boolean @@ -735,12 +786,15 @@ function AddCapabilityDialog({ agent: Agent workspaceID: string | null installable: Capability[] + preferredCapabilityID?: string | null credentials: UserCredential[] + workspaceOAuthKinds: Set onToast: (message: string) => void }) { const { t } = useTranslation("admin") const [q, setQ] = useState("") - const filtered = installable.filter((cap) => { + const preferredCapability = installable.find((cap) => cap.id === preferredCapabilityID) + const filtered = !q.trim() && preferredCapability ? [preferredCapability] : installable.filter((cap) => { if (!q.trim()) return true const needle = q.toLowerCase() return cap.name.toLowerCase().includes(needle) @@ -777,6 +831,7 @@ function AddCapabilityDialog({ agent={agent} workspaceID={workspaceID} credentials={credentials} + workspaceOAuthKinds={workspaceOAuthKinds} mode="available" onToast={(msg) => { onToast(msg) diff --git a/apps/web/src/pages/admin/capabilities/AddCapabilityToAgentDialog.tsx b/apps/web/src/pages/admin/capabilities/AddCapabilityToAgentDialog.tsx new file mode 100644 index 00000000..3a415d97 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/AddCapabilityToAgentDialog.tsx @@ -0,0 +1,212 @@ +import { useMemo, useState } from "react" +import { useQueries } from "@tanstack/react-query" +import { Bot, Check, Loader2 } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Button } from "../../../components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog" +import { noUnreachableRetry } from "../../../lib/api-client" +import { useAgents } from "../../../lib/api-agents" +import { + KEY_AGENT_CAPABILITIES, + listAgentCapabilities, + useCapabilityQuery, + useCapabilityVersionsQuery, + useEnableAgentCapabilityMutation, +} from "../../../lib/api-capabilities" +import { useAuth } from "../../../lib/auth-context" + +interface AddCapabilityToAgentDialogProps { + workspaceID: string | null + capabilityID: string + onOpenChange: (open: boolean) => void + onAdded: (capabilityName: string, agentName: string) => void +} + +export function AddCapabilityToAgentDialog({ + workspaceID, + capabilityID, + onOpenChange, + onAdded, +}: AddCapabilityToAgentDialogProps) { + const { t } = useTranslation("admin") + const { user } = useAuth() + const [selectedAgentID, setSelectedAgentID] = useState(null) + const capabilityQ = useCapabilityQuery(workspaceID, capabilityID) + const versionsQ = useCapabilityVersionsQuery(workspaceID, capabilityID) + const agentsQ = useAgents(workspaceID) + + const eligibleAgents = useMemo( + () => + (agentsQ.data?.agents ?? []).filter( + (agent) => !agent.created_by_user_id || agent.created_by_user_id === user?.user_id, + ), + [agentsQ.data?.agents, user?.user_id], + ) + const agentCapabilityQueries = useQueries({ + queries: eligibleAgents.map((agent) => ({ + queryKey: KEY_AGENT_CAPABILITIES(workspaceID ?? "_none", agent.id), + queryFn: () => listAgentCapabilities(workspaceID, agent.id), + enabled: !!workspaceID, + retry: noUnreachableRetry, + staleTime: 30_000, + })), + }) + const installedAgentIDs = new Set( + eligibleAgents + .filter((_, index) => + agentCapabilityQueries[index].data?.installed.some( + (item) => item.capability_id === capabilityID, + ), + ) + .map((agent) => agent.id), + ) + + const latestVersion = versionsQ.data?.versions[0] + const selectedAgent = eligibleAgents.find((agent) => agent.id === selectedAgentID) + const enableMutation = useEnableAgentCapabilityMutation(workspaceID, selectedAgentID) + const loading = capabilityQ.isLoading || versionsQ.isLoading || agentsQ.isLoading + const loadError = capabilityQ.error ?? versionsQ.error ?? agentsQ.error + + const submit = () => { + if (!latestVersion || !selectedAgent) return + enableMutation.mutate( + { capabilityVersionID: latestVersion.id, pinningMode: "latest" }, + { + onSuccess: () => { + onAdded(capabilityQ.data?.name ?? capabilityID, selectedAgent.name) + onOpenChange(false) + }, + }, + ) + } + + return ( + + + + + {t("capabilities.mcpDirectory.addToAgent.title", { + name: + capabilityQ.data?.name ?? + t("capabilities.mcpDirectory.addToAgent.connectorFallback"), + })} + + + {t("capabilities.mcpDirectory.addToAgent.description")} + + + + {loading ? ( +
+ +
+ ) : loadError || !latestVersion ? ( +
+ {t("capabilities.mcpDirectory.addToAgent.loadError")} +
+ ) : eligibleAgents.length === 0 ? ( +
+ {t("capabilities.mcpDirectory.addToAgent.empty")} +
+ ) : ( +
+ {eligibleAgents.map((agent, index) => { + const installed = installedAgentIDs.has(agent.id) + const checking = agentCapabilityQueries[index]?.isLoading + const selected = selectedAgentID === agent.id + return ( + + ) + })} +
+ )} + + {enableMutation.isError && ( +
+ {enableMutation.error instanceof Error + ? enableMutation.error.message + : t("capabilities.mcpDirectory.addToAgent.submitError")} +
+ )} + + + + + +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 8f9adeaa..490ff75d 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -71,6 +71,7 @@ import { DeprecateCapabilityDialog } from "./DeprecateCapabilityDialog" import { DeleteCapabilityDialog } from "./DeleteCapabilityDialog" import { ImportCapabilityDialog } from "./ImportCapabilityDialog" import { AddCapabilityVersionDialog } from "./AddCapabilityVersionDialog" +import { AddCapabilityToAgentDialog } from "./AddCapabilityToAgentDialog" import { UninstallMarketplaceDialog } from "./UninstallMarketplaceDialog" type MarketAction = "publish" | "unpublish" | "deprecate" | "undeprecate" | null @@ -116,6 +117,7 @@ export function CapabilitiesPage() { const [marketClientError, setMarketClientError] = useState(null) const [uninstallTarget, setUninstallTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + const [addToAgentCapabilityID, setAddToAgentCapabilityID] = useState(null) const [toast, setToast] = useState(null) const workspaceRole = workspacesQ.data?.workspaces.find((w) => w.id === wid)?.role const isAdmin = workspaceRole === "owner" || workspaceRole === "admin" @@ -286,7 +288,7 @@ export function CapabilitiesPage() { onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })} onInstall={(capability) => goToAgentsForCapability(capability.id)} onViewCapability={(capabilityID) => navigate("capabilities", { id: capabilityID, tab: null, item: null })} - onAddToAgent={goToAgentsForCapability} + onAddToAgent={setAddToAgentCapabilityID} /> ) : err ? ( { const fromMarketplace = !!cap.from_marketplace || cap.workspace_id !== wid const marketCap = cap as TargetMarketplaceInstall + const directoryManaged = isMCPDirectoryCapability( + versionSummary.byCapability.get(cap.id) ?? [], + ) const enabledCount = fromMarketplace ? marketCap.enabled_agent_count ?? enabledCounts.get(cap.id) ?? 0 : enabledCounts.get(cap.id) ?? 0 const sourceLine = fromMarketplace ? t("capabilities.marketplace.sourceLine", { @@ -394,6 +399,7 @@ export function CapabilitiesPage() { + {addToAgentCapabilityID && ( + { + if (!open) setAddToAgentCapabilityID(null) + }} + onAdded={(capabilityName, agentName) => { + setToast(t("capabilities.mcpDirectory.addToAgent.success", { capabilityName, agentName })) + }} + /> + )} {addVersionCapability && ( void @@ -748,12 +771,14 @@ function CapabilityRowMoreMenu({ <> - onMarketAction(published ? "unpublish" : "publish")} - /> + {(!directoryManaged || published) && ( + onMarketAction(published ? "unpublish" : "publish")} + /> + )} {/* "Delete" releases the capability.name workspace-unique index, allowing a same-name capability to be re-imported. The server @@ -833,6 +858,8 @@ export function CapabilityDetailPage({ id }: { id: string }) { const isAdmin = workspaceRole === "owner" || workspaceRole === "admin" const capability = capQ.data ?? null const latestVersion = versionsQ.data?.versions?.[0] + const directoryManaged = isMCPDirectoryCapability(versionsQ.data?.versions ?? []) + const published = capability?.visibility === "public" || capability?.scope === "public" const installationSummary = useCapabilityEnabledAgents(wid, agentsQ.data?.agents ?? [], capability, versionsQ.data?.versions ?? []) const enabledCount = installationSummary.installations.length @@ -882,6 +909,7 @@ export function CapabilityDetailPage({ id }: { id: string }) { const requestMarketAction = (action: MarketAction) => { setMarketClientError(null) + if (action === "publish" && directoryManaged) return if (action === "publish" && capability.type === "mcp") { const leakingVersion = (versionsQ.data?.versions ?? []).find((version) => containsPlaintextSecretPattern(JSON.stringify(version.content ?? {}))) if (leakingVersion) { @@ -986,12 +1014,18 @@ export function CapabilityDetailPage({ id }: { id: string }) {
- - {capability.visibility === "public" || capability.scope === "public" ? t("capabilities.marketStatus.published") : t("capabilities.marketStatus.unpublished")} + + {directoryManaged + ? t("capabilities.marketStatus.directoryManaged") + : published + ? t("capabilities.marketStatus.published") + : t("capabilities.marketStatus.unpublished")} {capability.deprecated_at && {t("capabilities.deprecated.badgeSource")}}
-

{t("capabilities.marketStatus.installCount", { count: installCountQ.data ?? 0 })}

+ {!directoryManaged && ( +

{t("capabilities.marketStatus.installCount", { count: installCountQ.data ?? 0 })}

+ )}
{/* @@ -1022,10 +1056,12 @@ export function CapabilityDetailPage({ id }: { id: string }) { - {(capability.visibility === "public" || capability.scope === "public") ? ( - - ) : ( - + {(!directoryManaged || published) && ( + published ? ( + + ) : ( + + ) )}
@@ -1358,6 +1394,19 @@ function countCapabilityInstalls(groups: AgentCapability[][]) { return counts } +function isMCPDirectoryCapability(versions: CapabilityVersion[]): boolean { + return versions.some((version) => { + const source = version.source_payload + return ( + source !== null && + typeof source === "object" && + !Array.isArray(source) && + "source_format" in source && + source.source_format === "mcp_catalog" + ) + }) +} + const plaintextSecretPatternRes = [ /github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}/i, /xoxb-[A-Za-z0-9-]{20,}/, diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx index 264d7675..2a972abf 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx @@ -23,6 +23,7 @@ export function ImportMCPDialog({ mutationError, onRetry, onOpenChange, + onConnect, onConfirm, }: { open: boolean @@ -33,6 +34,7 @@ export function ImportMCPDialog({ mutationError: unknown onRetry: () => void onOpenChange: (open: boolean) => void + onConnect: () => void onConfirm: () => void }) { const { t } = useTranslation("admin") @@ -40,6 +42,7 @@ export function ImportMCPDialog({ ? [item.command, ...(item.args ?? [])].map(formatCommandPart).join(" ") : "" const isRemote = item?.transport === "streamable-http" + const needsOAuth = item?.authentication === "oauth2" && !item.connected return ( @@ -84,7 +87,11 @@ export function ImportMCPDialog({

{isRemote - ? t("capabilities.mcpDirectory.detail.noAuthentication") + ? item.authentication === "oauth2" + ? item.connected + ? t("capabilities.mcpDirectory.oauth.connected") + : t("capabilities.mcpDirectory.oauth.authorizeBeforeImport") + : t("capabilities.mcpDirectory.detail.noAuthentication") : item.env?.join(", ") || t("capabilities.mcpDirectory.detail.noEnvironment")}

@@ -104,14 +111,20 @@ export function ImportMCPDialog({ - + {needsOAuth ? ( + + ) : ( + + )} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx index 65906ded..0d734f9d 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { Check, PackageCheck, Server } from "lucide-react" @@ -8,9 +8,11 @@ import { EmptyState } from "../../../../components/ui/empty-state" import { ErrorState } from "../../../../components/ui/error-state" import { Skeleton } from "../../../../components/ui/skeleton" import { + mcpDirectoryOAuthStartURL, useImportMCPDirectoryItem, useMCPDirectory, useMCPDirectoryDetail, + useTestMCPDirectoryConnection, } from "../../../../lib/api-marketplace" import { useWorkspaceId } from "../../../../lib/workspace" import { DirectoryCard } from "./MCPDirectoryCard" @@ -27,6 +29,25 @@ interface MCPDirectoryProps { onAddToAgent: (capabilityID: string) => void } +const MCP_OAUTH_COMPLETE = "parsar:mcp-oauth-complete" + +interface MCPOAuthCompleteMessage { + type: typeof MCP_OAUTH_COMPLETE + catalogID: string + intent?: "import" +} + +function isMCPOAuthCompleteMessage(value: unknown): value is MCPOAuthCompleteMessage { + if (!value || typeof value !== "object") return false + const message = value as Partial + return ( + message.type === MCP_OAUTH_COMPLETE && + typeof message.catalogID === "string" && + message.catalogID.trim() !== "" && + (message.intent === undefined || message.intent === "import") + ) +} + export function MCPDirectory({ itemID, query, @@ -39,9 +60,10 @@ export function MCPDirectory({ const workspaceID = useWorkspaceId() const directoryQ = useMCPDirectory(workspaceID) const importMut = useImportMCPDirectoryItem(workspaceID) + const connectionMut = useTestMCPDirectoryConnection(workspaceID) const [category, setCategory] = useState("") const [verifiedOnly, setVerifiedOnly] = useState(false) - const [sort, setSort] = useState("popular") + const [sort, setSort] = useState("featured") const [confirmID, setConfirmID] = useState(null) const [success, setSuccess] = useState<{ name: string; capabilityID: string } | null>(null) const detailID = confirmID ?? itemID @@ -49,7 +71,10 @@ export function MCPDirectory({ const items = useMemo(() => directoryQ.data?.items ?? [], [directoryQ.data?.items]) const categories = useMemo( - () => Array.from(new Set(items.flatMap((item) => item.categories))).sort((left, right) => left.localeCompare(right)), + () => + Array.from(new Set(items.flatMap((item) => item.categories))).sort((left, right) => + left.localeCompare(right), + ), [items], ) const filtered = useMemo( @@ -58,7 +83,60 @@ export function MCPDirectory({ ) const selectedSummary = items.find((item) => item.id === itemID) ?? null const selected = detailQ.data?.id === itemID ? detailQ.data : selectedSummary - const confirmItem = detailQ.data?.id === confirmID ? detailQ.data : items.find((item) => item.id === confirmID) ?? null + const connectionTestIsCurrent = connectionMut.variables?.catalogID === itemID + const confirmItem = + detailQ.data?.id === confirmID + ? detailQ.data + : (items.find((item) => item.id === confirmID) ?? null) + + useEffect(() => { + if (!itemID || !directoryQ.isSuccess) return + if (items.some((item) => item.id === itemID)) return + onSelectItem(null) + }, [directoryQ.isSuccess, itemID, items, onSelectItem]) + + useEffect(() => { + const currentURL = new URL(window.location.href) + const connectedID = currentURL.searchParams.get("connected")?.trim() + if (!connectedID || !window.opener || window.opener.closed) return + + const message: MCPOAuthCompleteMessage = { + type: MCP_OAUTH_COMPLETE, + catalogID: connectedID, + intent: currentURL.searchParams.has("import") ? "import" : undefined, + } + window.opener.postMessage(message, window.location.origin) + window.close() + }, []) + + useEffect(() => { + const handleOAuthComplete = (event: MessageEvent) => { + if (event.origin !== window.location.origin || !isMCPOAuthCompleteMessage(event.data)) return + const { catalogID, intent } = event.data + if (intent === "import" && canImport) setConfirmID(catalogID) + void directoryQ.refetch() + if (detailID === catalogID) void detailQ.refetch() + } + window.addEventListener("message", handleOAuthComplete) + return () => window.removeEventListener("message", handleOAuthComplete) + }, [canImport, detailID, detailQ, directoryQ]) + + useEffect(() => { + const currentURL = new URL(window.location.href) + const importID = currentURL.searchParams.get("import")?.trim() + if (!importID) return + + currentURL.searchParams.delete("import") + window.history.replaceState( + window.history.state, + "", + `${currentURL.pathname}${currentURL.search}${currentURL.hash}`, + ) + // The OAuth callback URL is external input that must open the import + // dialog before Strict Mode re-runs this effect after the URL is cleaned. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (canImport) setConfirmID(importID) + }, [canImport]) const requestImport = (id: string) => { if (!canImport) return @@ -78,6 +156,27 @@ export function MCPDirectory({ }, }) } + const connectOAuth = (id: string, intent?: "import") => { + if (!workspaceID) return + const oauthURL = new URL( + mcpDirectoryOAuthStartURL(workspaceID, id, { intent }), + window.location.origin, + ).toString() + const width = 560 + const height = 720 + const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2)) + const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2)) + const popup = window.open( + oauthURL, + `parsar-mcp-oauth-${id}`, + `popup=yes,width=${width},height=${height},left=${left},top=${top}`, + ) + if (!popup) { + window.location.assign(oauthURL) + return + } + popup.focus() + } const importDialog = ( void detailQ.refetch()} onOpenChange={(open) => !open && closeImportDialog()} + onConnect={() => confirmID && connectOAuth(confirmID, "import")} onConfirm={confirmImport} /> ) @@ -96,7 +196,13 @@ export function MCPDirectory({ if (itemID) { return ( <> - {success ? : null} + {success ? ( + + ) : null} onSelectItem(null)} onRetry={() => void detailQ.refetch()} onImport={() => requestImport(itemID)} + onConnect={() => connectOAuth(itemID)} + onTestConnection={() => connectionMut.mutate({ catalogID: itemID })} + testingConnection={connectionTestIsCurrent && connectionMut.isPending} + connectionTestSucceeded={ + connectionTestIsCurrent && connectionMut.isSuccess && connectionMut.data.verified + } + connectionTestFailed={ + connectionTestIsCurrent && connectionMut.isSuccess && !connectionMut.data.verified + } + connectionTestError={connectionTestIsCurrent ? connectionMut.error : null} onViewCapability={onViewCapability} onAddToAgent={onAddToAgent} /> @@ -120,40 +236,99 @@ export function MCPDirectory({
-

{t("capabilities.mcpDirectory.title")}

+

+ {t("capabilities.mcpDirectory.title")} +

-

{t("capabilities.mcpDirectory.description")}

+

+ {t("capabilities.mcpDirectory.description")} +

- {directoryQ.data?.source ? {t(`capabilities.mcpDirectory.source.${directoryQ.data.source}`)} : null} + {directoryQ.data?.source ? ( + + {t(`capabilities.mcpDirectory.source.${directoryQ.data.source}`)} + + ) : null}
-
- setCategory("")}>{t("capabilities.mcpDirectory.filters.allCategories")} - {categories.map((value) => setCategory(value)}>{value})} +
+ setCategory("")}> + {t("capabilities.mcpDirectory.filters.allCategories")} + + {categories.map((value) => ( + setCategory(value)} + > + {value} + + ))}
- setSort(event.target.value as DirectorySort)} + className="h-8 rounded-md border border-line bg-surface px-2.5 text-sm text-fg-muted outline-none focus:border-line-strong" + > +
- {success ? : null} + {success ? ( + + ) : null} {directoryQ.isLoading ? ( -
- {Array.from({ length: 6 }).map((_, index) => )} +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))}
) : directoryQ.error ? ( - void directoryQ.refetch()} /> + void directoryQ.refetch()} + /> ) : filtered.length === 0 ? ( - + ) : (
- {filtered.map((item) => onSelectItem(item.id)} onImport={() => requestImport(item.id)} onViewCapability={onViewCapability} />)} + {filtered.map((item) => ( + onSelectItem(item.id)} + onImport={() => requestImport(item.id)} + onViewCapability={onViewCapability} + /> + ))}
)} {importDialog} @@ -161,22 +336,54 @@ export function MCPDirectory({ ) } -function SuccessBanner({ success, onViewCapability, onAddToAgent }: { +function SuccessBanner({ + success, + onViewCapability, + onAddToAgent, +}: { success: { name: string; capabilityID: string } onViewCapability: (capabilityID: string) => void onAddToAgent: (capabilityID: string) => void }) { const { t } = useTranslation("admin") return ( -
- -

{t("capabilities.mcpDirectory.import.success", { name: success.name })}

- - +
+ + + +

+ {t("capabilities.mcpDirectory.import.success", { name: success.name })} +

+ +
) } -function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) { - return +function FilterChip({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: string +}) { + return ( + + ) } diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx index 6ba38b6c..9dd9c97d 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -5,8 +5,15 @@ import { Badge } from "../../../../components/ui/badge" import { Button } from "../../../../components/ui/button" import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" import { ConnectorIcon, VerifiedBadge } from "./shared" +import { isConnectorConnectionActive } from "./utils" -export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapability }: { +export function DirectoryCard({ + item, + canImport, + onOpen, + onImport, + onViewCapability, +}: { item: MCPDirectoryItem canImport: boolean onOpen: () => void @@ -14,8 +21,15 @@ export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapabil onViewCapability: (capabilityID: string) => void }) { const { t } = useTranslation("admin") + const connectionUnavailable = + item.authentication === "oauth2" && item.connection_supported === false + const connectionActive = isConnectorConnectionActive(item) return ( -
+

{item.description}

- {item.categories.slice(0, 2).map((category) => {category})} - #{item.popularity_rank} + {item.categories.slice(0, 2).map((category) => ( + + {category} + + ))}
{item.installed && item.installed_capability_id ? ( - ) : ( - )}
diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx index 2f5b4837..d48b0466 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Server, ShieldCheck } from "lucide-react" +import { ArrowLeft, Check, Loader2, Server, ShieldCheck } from "lucide-react" import { useTranslation } from "react-i18next" import { Badge } from "../../../../components/ui/badge" @@ -8,7 +8,7 @@ import { ErrorState } from "../../../../components/ui/error-state" import { Skeleton } from "../../../../components/ui/skeleton" import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" import { ConnectorIcon, ExternalLinkRow, Metadata, VerifiedBadge } from "./shared" -import { formatCommandPart } from "./utils" +import { formatCommandPart, isConnectorConnectionActive } from "./utils" export function DirectoryDetail({ item, @@ -18,6 +18,12 @@ export function DirectoryDetail({ onBack, onRetry, onImport, + onConnect, + onTestConnection, + testingConnection, + connectionTestSucceeded, + connectionTestFailed, + connectionTestError, onViewCapability, onAddToAgent, }: { @@ -28,6 +34,12 @@ export function DirectoryDetail({ onBack: () => void onRetry: () => void onImport: () => void + onConnect: () => void + onTestConnection: () => void + testingConnection: boolean + connectionTestSucceeded: boolean + connectionTestFailed: boolean + connectionTestError: unknown onViewCapability: (capabilityID: string) => void onAddToAgent: (capabilityID: string) => void }) { @@ -64,6 +76,12 @@ export function DirectoryDetail({ .map(formatCommandPart) .join(" ") const isRemote = item.transport === "streamable-http" + const oauthStatus = item.connection_status ?? (item.connected ? "authorized" : "not_connected") + const connectionVerified = oauthStatus === "verified" + const connectionHealthy = connectionVerified || connectionTestSucceeded + const connectionActive = isConnectorConnectionActive(item) + const connectionUnavailable = + item.authentication === "oauth2" && item.connection_supported === false return (

{item.publisher.name}

{item.description}

-
+
-
+ {connectionUnavailable ? ( +
+ {t("capabilities.mcpDirectory.oauth.approvedClientRequired", { name: item.name })} +
+ ) : null} + {connectionTestError || connectionTestFailed ? ( +

+ {t("capabilities.mcpDirectory.oauth.testFailed")} +

+ ) : null}
+ {!connectionUnavailable && item.authentication === "oauth2" && item.connected ? ( + + ) : null} + {!connectionUnavailable && item.authentication === "oauth2" ? ( + + ) : null} {item.installed && item.installed_capability_id ? ( <> - + {!connectionUnavailable ? ( + + ) : null} ) : ( )}
diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts index c38956bc..39b838c0 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts @@ -1,6 +1,6 @@ import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" -export type DirectorySort = "popular" | "name" +export type DirectorySort = "featured" | "name" interface DirectoryFilters { query: string @@ -22,5 +22,5 @@ export function filterMCPDirectoryItems(items: MCPDirectoryItem[], filters: Dire }) return filtered.sort((left, right) => filters.sort === "name" ? left.name.localeCompare(right.name) - : left.popularity_rank - right.popularity_rank || left.name.localeCompare(right.name)) + : left.featured_rank - right.featured_rank || left.name.localeCompare(right.name)) } diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx index c1d4540a..e62b0936 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx @@ -4,30 +4,84 @@ import { useTranslation } from "react-i18next" import { Badge } from "../../../../components/ui/badge" import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" -export function ConnectorIcon({ item, large = false }: { item: MCPDirectoryItem; large?: boolean }) { +export function ConnectorIcon({ + item, + large = false, +}: { + item: MCPDirectoryItem + large?: boolean +}) { const size = large ? "h-14 w-14 rounded-xl" : "h-11 w-11 rounded-lg" return ( - - {item.icon_url ? : } + + {item.icon_url ? ( + + ) : ( + + )} ) } export function VerifiedBadge() { const { t } = useTranslation("admin") - return {t("capabilities.mcpDirectory.verified")} + return ( + + {t("capabilities.mcpDirectory.verified")} + + ) } -export function Metadata({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { - return

{label}

{value}

+export function Metadata({ + label, + value, + mono = false, +}: { + label: string + value: string + mono?: boolean +}) { + return ( +
+

{label}

+

{value}

+
+ ) } -export function ExternalLinkRow({ label, value, href }: { label: string; value: string; href?: string }) { +export function ExternalLinkRow({ + label, + value, + href, +}: { + label: string + value: string + href?: string +}) { const safeHref = safeExternalURL(href) return (

{label}

- {safeHref ? {value} :

} + {safeHref ? ( + + {value} + + + ) : ( +

+ )}
) } diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts b/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts index a0396fcf..64e1469d 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/utils.ts @@ -1,3 +1,13 @@ +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" + export function formatCommandPart(value: string): string { return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : JSON.stringify(value) } + +export function isConnectorConnectionActive(item: MCPDirectoryItem): boolean { + return ( + item.connected && + item.connection_status !== "reconnect_required" && + item.connection_status !== "unavailable" + ) +} diff --git a/apps/web/src/pages/admin/conversation-runtime-errors.ts b/apps/web/src/pages/admin/conversation-runtime-errors.ts new file mode 100644 index 00000000..c2e424d1 --- /dev/null +++ b/apps/web/src/pages/admin/conversation-runtime-errors.ts @@ -0,0 +1,59 @@ +import type { ConversationTimelineMessage } from "../../lib/api-types" + +export function stringMeta(metadata: Record | undefined, key: string): string { + if (!metadata) return "" + const value = key.includes(".") + ? key + .split(".") + .reduce( + (acc, part) => + acc && typeof acc === "object" ? (acc as Record)[part] : undefined, + metadata, + ) + : metadata[key] + return typeof value === "string" ? value : "" +} + +export function isRuntimeErrorMessage( + messageType: string | undefined, + metadata: Record | undefined, +): boolean { + if (messageType === "runtime_error") return true + if (messageType !== "error") return false + return ( + stringMeta(metadata, "kind") === "runtime_error" || + stringMeta(metadata, "error.source") === "runtime" + ) +} + +function capabilityRuntimeDiagnosticKey(message: ConversationTimelineMessage): string { + if (!isRuntimeErrorMessage(message.kind, message.metadata)) return "" + let subKind = + stringMeta(message.metadata, "sub_kind") || stringMeta(message.metadata, "payload.sub_kind") + const capabilityID = + stringMeta(message.metadata, "capability_id") || + stringMeta(message.metadata, "payload.capability_id") + const credentialKind = + stringMeta(message.metadata, "credential_kind") || + stringMeta(message.metadata, "payload.credential_kind") + if (subKind === "capability_credential_missing" && !credentialKind) { + subKind = "capability_unsupported" + } + if (!subKind.startsWith("capability_") || !capabilityID) return "" + return `${subKind}\u0000${capabilityID}\u0000${credentialKind}` +} + +export function dedupeCapabilityRuntimeDiagnostics( + messages: ConversationTimelineMessage[], +): ConversationTimelineMessage[] { + const seen = new Set() + const newestFirst: ConversationTimelineMessage[] = [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + const key = capabilityRuntimeDiagnosticKey(message) + if (key && seen.has(key)) continue + if (key) seen.add(key) + newestFirst.push(message) + } + return newestFirst.reverse() +} diff --git a/catalog/mcp/README.md b/catalog/mcp/README.md index e867da67..dc97b4d5 100644 --- a/catalog/mcp/README.md +++ b/catalog/mcp/README.md @@ -1,8 +1,9 @@ # MCP Connector Directory Catalog `catalog.json` is the repository-maintained source for Parsar's built-in MCP -Connector Directory. It contains metadata plus either stdio launch configuration -or a credential-free Streamable HTTP endpoint. Importing an item saves a +Connector Directory. It contains publisher-maintained Streamable HTTP endpoints +that have been exercised with Parsar's current MCP client. Entries may be +credential-free or use the standard MCP OAuth flow. Importing an item saves a workspace capability and never executes it. ## Updating the catalog @@ -11,14 +12,32 @@ workspace capability and never executes it. official MCP Registry. - Keep `id` stable and unique. Renaming an item does not require changing its ID. -- Pin npm and Python packages to an explicit version. Do not use `latest`. -- Stdio entries use `command`, `args`, `env`, and `startup_timeout_sec`. -- Streamable HTTP entries use an HTTPS `url` only. Built-in remote entries must - complete an MCP initialize request without headers, API keys, OAuth, or other - user credentials before they are added. -- Catalog entries may rely on tools such as `npx` or `uvx` being available in - the eventual Runtime. Importing a connector does not install those tools or - download its package. +- Streamable HTTP entries use an HTTPS `url` only. Credential-free entries must + complete MCP initialize, tools/list, and a harmless tool call without headers. + OAuth entries must complete the full Parsar authorization and connection-test + flow using official protected-resource and authorization-server discovery, + dynamic client registration, and PKCE; do not add provider-specific client + secrets. +- OAuth entries set `authentication.type` to `oauth2` and reference a built-in + `credential_kind`. A member authorizes with their provider account, and + Parsar stores the token as a workspace-scoped shared credential. Agents in + that workspace use it automatically after the connector is enabled. Tokens + are never stored in this catalog. +- Do not list unavailable or approved-client-only connectors. Add them only + after Parsar can complete their authorization and connection-test flow. +- Do not add runtime-specific `npx` or `uvx` entries to the built-in directory. + They may execute inside a container instead of the user's device and create a + misleading product experience. +- Do not add MCPB/DXT desktop extensions as stdio entries. Control Chrome, + PowerPoint (By Anthropic), Word (By Anthropic), and PDF Tools currently depend + on Claude's desktop bundle installation lifecycle and cannot be represented by + a truthful Parsar `command`/`args` pair. Add them only after Parsar supports + audited MCPB installation. +- Do not list MCP Apps whose primary experience requires an embedded + `io.modelcontextprotocol/ui` host. A runnable stdio example alone is not a + complete Parsar connector until the web client can render that UI contract. +- `featured_rank` is the repository's explicit curation order. It is not a + claim about usage or popularity. - `env` declares variable names. Every value must be an empty string; secrets, API keys, tokens, and passwords must never be committed to the catalog. - Use only `http` or `https` metadata URLs without embedded credentials. Remote @@ -32,10 +51,3 @@ repository gate: go test ./server/internal/mcpcatalog make check ``` - -## Remote catalog override - -Operators may set `PARSAR_MCP_CATALOG_URL` to a trusted JSON endpoint with the -same schema. Parsar applies a bounded download size, HTTP timeout, redirect -limit, and full structural validation. A failed remote load falls back to the -embedded catalog. Catalog URLs cannot be supplied through an API request. diff --git a/catalog/mcp/catalog.json b/catalog/mcp/catalog.json index 145b220b..97e74d7e 100644 --- a/catalog/mcp/catalog.json +++ b/catalog/mcp/catalog.json @@ -1,508 +1,212 @@ { "schema_version": 1, - "updated_at": "2026-07-22T06:52:30Z", + "updated_at": "2026-07-23T07:54:39Z", "items": [ - { - "id": "filesystem", - "name": "Filesystem", - "description": "Read and write files within directories explicitly exposed to the MCP server.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Developer Tools", "Files"], - "popularity_rank": 1, - "version": "2026.7.10", - "transport": "stdio", - "server": { - "name": "filesystem", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem@2026.7.10", - "." - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "playwright", - "name": "Playwright", - "description": "Automate browser navigation and interaction through structured accessibility snapshots.", - "publisher": { - "name": "Microsoft", - "url": "https://github.com/microsoft" - }, - "icon_url": "https://github.com/microsoft.png?size=128", - "homepage_url": "https://playwright.dev", - "repository_url": "https://github.com/microsoft/playwright-mcp", - "verified": true, - "categories": ["Developer Tools", "Browser Automation"], - "popularity_rank": 2, - "version": "0.0.78", - "transport": "stdio", - "server": { - "name": "playwright", - "command": "npx", - "args": [ - "-y", - "@playwright/mcp@0.0.78", - "--headless" - ], - "env": {}, - "startup_timeout_sec": 60 - } - }, { "id": "context7", "name": "Context7", - "description": "Retrieve current library documentation and code examples for coding workflows.", + "description": "Retrieve current documentation and code examples for software libraries and frameworks.", "publisher": { "name": "Upstash", - "url": "https://github.com/upstash" + "url": "https://upstash.com" }, - "icon_url": "https://github.com/upstash.png?size=128", + "icon_url": "https://context7.com/context7-icon-green.png", "homepage_url": "https://context7.com", "repository_url": "https://github.com/upstash/context7", "verified": true, - "categories": ["Developer Tools", "Documentation"], - "popularity_rank": 3, - "version": "3.2.4", - "transport": "stdio", + "categories": [ + "Documentation", + "Developer Tools" + ], + "featured_rank": 1, + "version": "3.2.3", + "transport": "streamable-http", "server": { "name": "context7", - "command": "npx", - "args": [ - "-y", - "@upstash/context7-mcp@3.2.4" - ], - "env": { - "CONTEXT7_API_KEY": "" - }, - "startup_timeout_sec": 30 - } - }, - { - "id": "fetch", - "name": "Fetch", - "description": "Fetch web content and convert it into a model-friendly representation.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Web", "Research"], - "popularity_rank": 4, - "version": "2026.7.10", - "transport": "stdio", - "server": { - "name": "fetch", - "command": "uvx", - "args": [ - "--from", - "mcp-server-fetch==2026.7.10", - "mcp-server-fetch" - ], - "env": {}, - "startup_timeout_sec": 30 + "url": "https://mcp.context7.com/mcp" } }, { - "id": "git", - "name": "Git", - "description": "Inspect, search, and modify Git repositories available in the configured working directory.", + "id": "exa", + "name": "Exa", + "description": "Search the web and fetch page contents through Exa's hosted MCP server.", "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" + "name": "Exa", + "url": "https://exa.ai" }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", - "repository_url": "https://github.com/modelcontextprotocol/servers", + "icon_url": "https://exa.ai/images/favicon-32x32.png", + "repository_url": "https://github.com/exa-labs/exa-mcp-server", "verified": true, - "categories": ["Developer Tools", "Version Control"], - "popularity_rank": 5, - "version": "2026.7.10", - "transport": "stdio", - "server": { - "name": "git", - "command": "uvx", - "args": [ - "--from", - "mcp-server-git==2026.7.10", - "mcp-server-git", - "--repository", - "." - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "memory", - "name": "Memory", - "description": "Store and retrieve persistent knowledge through a local knowledge graph.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Productivity", "Knowledge"], - "popularity_rank": 6, - "version": "2026.7.4", - "transport": "stdio", - "server": { - "name": "memory", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-memory@2026.7.4" - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "time", - "name": "Time", - "description": "Get the current time and convert values between IANA time zones.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/time", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Productivity", "Utilities"], - "popularity_rank": 7, - "version": "2026.7.10", - "transport": "stdio", - "server": { - "name": "time", - "command": "uvx", - "args": [ - "--from", - "mcp-server-time==2026.7.10", - "mcp-server-time" - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "sequential-thinking", - "name": "Sequential Thinking", - "description": "Break complex problems into explicit, revisable reasoning steps.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Developer Tools", "Reasoning"], - "popularity_rank": 8, - "version": "2026.7.4", - "transport": "stdio", - "server": { - "name": "sequential-thinking", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-sequential-thinking@2026.7.4" - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "everything", - "name": "Everything", - "description": "Exercise MCP tools, resources, prompts, sampling, and other protocol features for client testing.", - "publisher": { - "name": "Model Context Protocol", - "url": "https://github.com/modelcontextprotocol" - }, - "icon_url": "https://avatars.githubusercontent.com/u/182288589?s=128&v=4", - "homepage_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything", - "repository_url": "https://github.com/modelcontextprotocol/servers", - "verified": true, - "categories": ["Developer Tools", "Testing"], - "popularity_rank": 9, - "version": "2026.7.4", - "transport": "stdio", - "server": { - "name": "everything", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-everything@2026.7.4" - ], - "env": {}, - "startup_timeout_sec": 30 - } - }, - { - "id": "cloudflare-docs", - "name": "Cloudflare Documentation", - "description": "Search Cloudflare product documentation and retrieve current implementation guidance.", - "publisher": { - "name": "Cloudflare", - "url": "https://www.cloudflare.com" - }, - "icon_url": "https://github.com/cloudflare.png?size=128", - "homepage_url": "https://developers.cloudflare.com/agents/model-context-protocol/mcp-servers-for-cloudflare/", - "repository_url": "https://github.com/cloudflare/mcp-server-cloudflare", - "verified": true, - "categories": ["Documentation", "Cloud"], - "popularity_rank": 10, - "version": "0.4.9", + "categories": [ + "Web", + "Search" + ], + "featured_rank": 2, + "version": "3.2.1", "transport": "streamable-http", "server": { - "name": "cloudflare-docs", - "url": "https://docs.mcp.cloudflare.com/mcp" + "name": "exa", + "url": "https://mcp.exa.ai/mcp" } }, { - "id": "microsoft-learn", - "name": "Microsoft Learn", - "description": "Search official Microsoft technical documentation and code samples.", + "id": "firecrawl", + "name": "Firecrawl", + "description": "Search, scrape, map, and extract structured content from websites through Firecrawl's hosted MCP server.", "publisher": { - "name": "Microsoft", - "url": "https://www.microsoft.com" + "name": "Firecrawl", + "url": "https://www.firecrawl.dev" }, - "icon_url": "https://github.com/MicrosoftDocs.png?size=128", - "homepage_url": "https://github.com/MicrosoftDocs/mcp", - "repository_url": "https://github.com/MicrosoftDocs/mcp", + "icon_url": "https://github.com/firecrawl.png?size=128", + "homepage_url": "https://docs.firecrawl.dev/mcp-server", + "repository_url": "https://github.com/firecrawl/firecrawl-mcp-server", "verified": true, - "categories": ["Documentation", "Developer Tools"], - "popularity_rank": 11, - "version": "1.0.0", + "categories": [ + "Web", + "Developer Tools" + ], + "featured_rank": 3, + "version": "3.22.4", "transport": "streamable-http", "server": { - "name": "microsoft-learn", - "url": "https://learn.microsoft.com/api/mcp" + "name": "firecrawl", + "url": "https://mcp.firecrawl.dev/v2/mcp" } }, { - "id": "aws-knowledge", - "name": "AWS Knowledge", - "description": "Search AWS documentation, API references, architecture guidance, and service information.", + "id": "postman", + "name": "Postman", + "description": "Manage Postman workspaces, collections, environments, APIs, and specifications through the official hosted MCP server.", "publisher": { - "name": "Amazon Web Services", - "url": "https://aws.amazon.com" + "name": "Postman", + "url": "https://www.postman.com" }, - "icon_url": "https://github.com/awslabs.png?size=128", - "homepage_url": "https://awslabs.github.io/mcp/servers/aws-knowledge-mcp-server", - "repository_url": "https://github.com/awslabs/mcp", + "icon_url": "https://github.com/postmanlabs.png?size=128", + "homepage_url": "https://learning.postman.com/docs/reference/postman-api/postman-mcp-server/overview", "verified": true, - "categories": ["Documentation", "Cloud"], - "popularity_rank": 12, + "categories": [ + "Developer Tools", + "API" + ], + "featured_rank": 4, "version": "1.0.0", "transport": "streamable-http", - "server": { - "name": "aws-knowledge", - "url": "https://knowledge-mcp.global.api.aws" - } - }, - { - "id": "deepwiki", - "name": "DeepWiki", - "description": "Read public GitHub repositories as generated documentation and ask repository questions.", - "publisher": { - "name": "Cognition", - "url": "https://www.cognition.ai" + "authentication": { + "type": "oauth2", + "credential_kind": "postman_mcp_oauth" }, - "icon_url": "https://deepwiki.com/favicon.ico", - "homepage_url": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", - "verified": true, - "categories": ["Documentation", "Version Control"], - "popularity_rank": 13, - "version": "2.14.3", - "transport": "streamable-http", "server": { - "name": "deepwiki", - "url": "https://mcp.deepwiki.com/mcp" + "name": "postman", + "url": "https://mcp.postman.com/minimal" } }, { - "id": "agent-web", - "name": "Agent Web", - "description": "Read public web pages as clean model-ready content while respecting robots.txt.", + "id": "notion", + "name": "Notion", + "description": "Search, read, create, and update pages and databases in the Notion workspaces you authorize.", "publisher": { - "name": "Foomworks", - "url": "https://github.com/foomworks" + "name": "Notion", + "url": "https://www.notion.so" }, - "icon_url": "https://github.com/foomworks.png?size=128", - "homepage_url": "https://agent-web.foomworks.workers.dev", - "repository_url": "https://github.com/foomworks/agent-web", - "verified": false, - "categories": ["Web", "Research"], - "popularity_rank": 14, - "version": "0.2.1", + "icon_url": "https://github.com/makenotion.png?size=128", + "homepage_url": "https://developers.notion.com/guides/mcp/get-started-with-mcp", + "verified": true, + "categories": [ + "Productivity", + "Knowledge" + ], + "featured_rank": 5, + "version": "1.0.0", "transport": "streamable-http", - "server": { - "name": "agent-web", - "url": "https://agent-web.foomworks.workers.dev/mcp" - } - }, - { - "id": "arxiv", - "name": "arXiv", - "description": "Search arXiv papers, retrieve metadata, and inspect available full text.", - "publisher": { - "name": "cyanheads", - "url": "https://github.com/cyanheads" + "authentication": { + "type": "oauth2", + "credential_kind": "notion_mcp_oauth" }, - "icon_url": "https://github.com/cyanheads.png?size=128", - "homepage_url": "https://github.com/cyanheads/arxiv-mcp-server", - "repository_url": "https://github.com/cyanheads/arxiv-mcp-server", - "verified": false, - "categories": ["Research", "Science"], - "popularity_rank": 15, - "version": "1.2.15", - "transport": "streamable-http", "server": { - "name": "arxiv", - "url": "https://arxiv.caseyjhand.com/mcp" + "name": "notion", + "url": "https://mcp.notion.com/mcp" } }, { - "id": "pubmed", - "name": "PubMed", - "description": "Search biomedical literature and retrieve article metadata, citations, and available full text.", + "id": "sentry", + "name": "Sentry", + "description": "Investigate application errors, issues, traces, and project health in the Sentry organizations you authorize.", "publisher": { - "name": "cyanheads", - "url": "https://github.com/cyanheads" + "name": "Sentry", + "url": "https://sentry.io" }, - "icon_url": "https://github.com/cyanheads.png?size=128", - "homepage_url": "https://github.com/cyanheads/pubmed-mcp-server", - "repository_url": "https://github.com/cyanheads/pubmed-mcp-server", - "verified": false, - "categories": ["Research", "Health"], - "popularity_rank": 16, - "version": "2.9.8", + "icon_url": "https://github.com/getsentry.png?size=128", + "homepage_url": "https://mcp.sentry.dev", + "repository_url": "https://github.com/getsentry/sentry-mcp", + "verified": true, + "categories": [ + "Developer Tools", + "Observability" + ], + "featured_rank": 6, + "version": "1.0.0", "transport": "streamable-http", - "server": { - "name": "pubmed", - "url": "https://pubmed.caseyjhand.com/mcp" - } - }, - { - "id": "us-weather", - "name": "US Weather", - "description": "Get United States forecasts, active alerts, and current observations from public weather data.", - "publisher": { - "name": "cyanheads", - "url": "https://github.com/cyanheads" + "authentication": { + "type": "oauth2", + "credential_kind": "sentry_mcp_oauth" }, - "icon_url": "https://github.com/cyanheads.png?size=128", - "homepage_url": "https://github.com/cyanheads/nws-weather-mcp-server", - "repository_url": "https://github.com/cyanheads/nws-weather-mcp-server", - "verified": false, - "categories": ["Utilities", "Weather"], - "popularity_rank": 17, - "version": "0.7.2", - "transport": "streamable-http", "server": { - "name": "us-weather", - "url": "https://nws.caseyjhand.com/mcp" + "name": "sentry", + "url": "https://mcp.sentry.dev/mcp" } }, { - "id": "mdn-search", - "name": "MDN Search", - "description": "Search MDN Web Docs for browser APIs, JavaScript, CSS, and HTML guidance.", + "id": "linear", + "name": "Linear", + "description": "Search and manage Linear issues, projects, and team workflows in the workspaces you authorize.", "publisher": { - "name": "PipeWorx", - "url": "https://pipeworx.io" + "name": "Linear", + "url": "https://linear.app" }, - "icon_url": "https://github.com/pipeworx-io.png?size=128", - "homepage_url": "https://pipeworx.io/packs/mdn-search", - "repository_url": "https://github.com/pipeworx-io/mcp-mdn-search", - "verified": false, - "categories": ["Documentation", "Web"], - "popularity_rank": 18, - "version": "0.1.0", + "icon_url": "https://linear.app/favicon.ico", + "homepage_url": "https://linear.app/docs/mcp", + "verified": true, + "categories": [ + "Productivity", + "Project Management" + ], + "featured_rank": 7, + "version": "1.0.0", "transport": "streamable-http", - "server": { - "name": "mdn-search", - "url": "https://gateway.pipeworx.io/mdn-search/mcp" - } - }, - { - "id": "npm-registry", - "name": "npm Registry", - "description": "Look up public npm packages, versions, metadata, maintainers, and download information.", - "publisher": { - "name": "PipeWorx", - "url": "https://pipeworx.io" + "authentication": { + "type": "oauth2", + "credential_kind": "linear_mcp_oauth" }, - "icon_url": "https://github.com/pipeworx-io.png?size=128", - "homepage_url": "https://pipeworx.io/packs/npm", - "repository_url": "https://github.com/pipeworx-io/mcp-npm", - "verified": false, - "categories": ["Developer Tools", "Packages"], - "popularity_rank": 19, - "version": "0.1.0", - "transport": "streamable-http", "server": { - "name": "npm-registry", - "url": "https://gateway.pipeworx.io/npm/mcp" + "name": "linear", + "url": "https://mcp.linear.app/mcp" } }, { - "id": "docker-hub", - "name": "Docker Hub", - "description": "Search public Docker Hub repositories, tags, image metadata, and pull statistics.", + "id": "stripe", + "name": "Stripe", + "description": "Inspect and manage Stripe payments, customers, subscriptions, and related account data.", "publisher": { - "name": "PipeWorx", - "url": "https://pipeworx.io" + "name": "Stripe", + "url": "https://stripe.com" }, - "icon_url": "https://github.com/pipeworx-io.png?size=128", - "homepage_url": "https://pipeworx.io/packs/dockerhub", - "repository_url": "https://github.com/pipeworx-io/mcp-dockerhub", - "verified": false, - "categories": ["Developer Tools", "Containers"], - "popularity_rank": 20, - "version": "0.1.0", + "icon_url": "https://github.com/stripe.png?size=128", + "homepage_url": "https://docs.stripe.com/mcp", + "verified": true, + "categories": [ + "Finance", + "Payments" + ], + "featured_rank": 8, + "version": "1.0.0", "transport": "streamable-http", - "server": { - "name": "docker-hub", - "url": "https://gateway.pipeworx.io/dockerhub/mcp" - } - }, - { - "id": "wikipedia", - "name": "Wikipedia", - "description": "Search Wikipedia and retrieve public article summaries and page content.", - "publisher": { - "name": "PipeWorx", - "url": "https://pipeworx.io" + "authentication": { + "type": "oauth2", + "credential_kind": "stripe_mcp_oauth" }, - "icon_url": "https://github.com/pipeworx-io.png?size=128", - "homepage_url": "https://pipeworx.io/packs/wikipedia", - "repository_url": "https://github.com/pipeworx-io/mcp-wikipedia", - "verified": false, - "categories": ["Research", "Knowledge"], - "popularity_rank": 21, - "version": "0.1.0", - "transport": "streamable-http", "server": { - "name": "wikipedia", - "url": "https://gateway.pipeworx.io/wikipedia/mcp" + "name": "stripe", + "url": "https://mcp.stripe.com" } } ] diff --git a/catalog/mcp/catalog.schema.json b/catalog/mcp/catalog.schema.json index 6a05e9b8..fbf02d64 100644 --- a/catalog/mcp/catalog.schema.json +++ b/catalog/mcp/catalog.schema.json @@ -47,6 +47,33 @@ "startup_timeout_sec": { "type": "integer", "minimum": 0, "maximum": 300 } } }, + "authentication": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { "enum": ["none", "oauth2"] }, + "credential_kind": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "client_registration": { "enum": ["dynamic", "approved-client"] } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "oauth2" } } }, + "then": { "required": ["credential_kind"] }, + "else": { + "not": { + "anyOf": [ + { "required": ["credential_kind"] }, + { "required": ["client_registration"] } + ] + } + } + } + ] + }, "item": { "type": "object", "additionalProperties": false, @@ -57,7 +84,7 @@ "publisher", "verified", "categories", - "popularity_rank", + "featured_rank", "version", "transport", "server" @@ -79,9 +106,10 @@ "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "popularity_rank": { "type": "integer", "minimum": 1 }, + "featured_rank": { "type": "integer", "minimum": 1 }, "version": { "type": "string", "minLength": 1 }, "transport": { "enum": ["stdio", "streamable-http"] }, + "authentication": { "$ref": "#/$defs/authentication" }, "server": { "$ref": "#/$defs/server" } }, "allOf": [ diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index d042323c..141f810e 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -63,10 +63,6 @@ PARSAR_PUBLIC_URL=https://parsar. # openssl rand -hex 32 PARSAR_MASTER_KEY= -# Optional trusted JSON endpoint for the MCP Connector Directory. Leave empty -# to use the catalog embedded in the server image. -PARSAR_MCP_CATALOG_URL= - # ----------------------------------------------------------------------------- # Optional Feishu SSO + event subscription # ----------------------------------------------------------------------------- diff --git a/deploy/compose/compose.selfhost.yml b/deploy/compose/compose.selfhost.yml index fa00384e..c4f1597d 100644 --- a/deploy/compose/compose.selfhost.yml +++ b/deploy/compose/compose.selfhost.yml @@ -100,8 +100,6 @@ services: DATABASE_URL: postgres://${PARSAR_PG_USER}:${PARSAR_PG_PASSWORD}@postgres:5432/${PARSAR_PG_DB}?sslmode=disable PARSAR_MASTER_KEY: ${PARSAR_MASTER_KEY:?PARSAR_MASTER_KEY is required - generate with openssl rand -hex 32} PARSAR_PUBLIC_URL: ${PARSAR_PUBLIC_URL:?PARSAR_PUBLIC_URL is required - e.g. https://parsar.your-domain.com} - PARSAR_MCP_CATALOG_URL: ${PARSAR_MCP_CATALOG_URL:-} - # ---------- Listen / runtime path ---------- PARSAR_ADDR: ":8080" PARSAR_DATA_DIR: "/var/lib/parsar" diff --git a/docker-compose.yml b/docker-compose.yml index cbbb0939..3fb9d43f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,6 @@ services: PARSAR_MASTER_KEY: "${PARSAR_MASTER_KEY:-0000000000000000000000000000000000000000000000000000000000000000}" PARSAR_SHARED_RUNTIME_TOKEN: "${PARSAR_SHARED_RUNTIME_TOKEN:-parsar-local-runtime-token-change-me}" PARSAR_AGENT_DAEMON_WS_URL: "ws://parsar-server:8080/agent-daemon/ws" - PARSAR_MCP_CATALOG_URL: "${PARSAR_MCP_CATALOG_URL:-}" volumes: - ${PARSAR_DATA_DIR:-server-data}:/var/lib/parsar healthcheck: diff --git a/docs/deploy/deploy-runbook.md b/docs/deploy/deploy-runbook.md index 413cd630..aa5022d2 100644 --- a/docs/deploy/deploy-runbook.md +++ b/docs/deploy/deploy-runbook.md @@ -98,7 +98,6 @@ the repo**. | Bootstrap token | `PARSAR_BOOTSTRAP_TOKEN` | empty (HTTP bootstrap off) | | Dev auth toggle | `PARSAR_DEV_AUTH` | `false` (must be false in production) | | Runtime profile | `PARSAR_RUNTIME_PROFILE` | `managed` for managed deployments where the platform manages cloud sandboxes | -| MCP catalog override | `PARSAR_MCP_CATALOG_URL` | empty (use the catalog embedded in the server image) | Feishu OAuth / event-related env vars are documented in [feishu-prod.md](./feishu-prod.md). @@ -252,7 +251,7 @@ layer**. To make a deployment truly production-ready you still need: | Smoke — end-to-end AgentRun / audit / usage | Missing `/api/v1/workspaces/{wid}/{agent-runs,audit-records,usage}` and other cookie-session entry points; smoke-core marks this SKIP/TODO | Later phase | | Real audit sink (Kafka / self-hosted storage) | In-memory + Postgres sink for now; the interface is already abstracted | Later phase | | Memory L0-L3 | Not implemented | Later phase | -| Capability marketplace | Workspace-published Skill market and repository-backed stdio / Streamable HTTP MCP Connector Directory are available | — | +| Capability marketplace | Workspace-published Skill market and a repository-backed remote MCP Connector Directory are available | — | **Invariants delivered by this track:** diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c558ac62..55be475c 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -1156,18 +1156,42 @@ definitions: items: type: string type: array + authentication: + type: string categories: items: type: string type: array command: type: string + connected: + type: boolean + connection_checked_at: + type: string + connection_error: + type: string + connection_protocol_version: + type: string + connection_server_name: + type: string + connection_server_version: + type: string + connection_status: + type: string + connection_supported: + type: boolean + connection_tool_count: + type: integer + credential_kind: + type: string description: type: string env: items: type: string type: array + featured_rank: + type: integer homepage_url: type: string icon_url: @@ -1180,8 +1204,6 @@ definitions: type: string name: type: string - popularity_rank: - type: integer publisher: $ref: '#/definitions/mcpcatalog.Publisher' repository_url: @@ -1208,6 +1230,27 @@ definitions: updated_at: type: string type: object + mcpdirectory.oauthConnectionResponse: + properties: + authorized: + type: boolean + checked_at: + type: string + error_code: + type: string + protocol_version: + type: string + server_name: + type: string + server_version: + type: string + status: + type: string + tool_count: + type: integer + verified: + type: boolean + type: object password.errorResponse: properties: code: @@ -7163,6 +7206,129 @@ paths: summary: Import an MCP Connector Directory item tags: - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/callback: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + - description: OAuth state + in: query + name: state + required: true + type: string + - description: OAuth authorization code + in: query + name: code + required: true + type: string + produces: + - application/json + responses: + "302": + description: Redirect to the connector detail page + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: + type: string + type: object + summary: Complete an MCP connector OAuth flow + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/start: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + - description: post-authorization action; currently import + in: query + name: intent + type: string + produces: + - application/json + responses: + "302": + description: Redirect to the provider authorization page + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Start an MCP connector OAuth flow + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/test: + post: + description: Refreshes the token when necessary, then completes MCP initialize + and tools/list without executing a tool. + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcpdirectory.oauthConnectionResponse' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Verify an authorized OAuth MCP connector + tags: + - mcp-directory /api/v1/workspaces/{workspaceID}/members: get: description: Returns members of the workspace. Caller must be a workspace member. diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 0d227418..f518823a 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -47,6 +47,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/feishu" authgithub "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/github" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" authpassword "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/password" "github.com/MiniMax-AI-Dev/parsar/server/internal/bootstrap" "github.com/MiniMax-AI-Dev/parsar/server/internal/config" @@ -706,16 +707,27 @@ func main() { Store: dbStore, SharedRuntimeToken: strings.TrimSpace(envLookup("PARSAR_SHARED_RUNTIME_TOKEN")), } - mcpCatalog := mcpcatalog.New(mcpcatalog.Options{ - RemoteURL: strings.TrimSpace(envLookup(mcpcatalog.EnvCatalogURL)), - }) + mcpCatalog := mcpcatalog.New(mcpcatalog.Options{}) + mcpOAuthSecrets, mcpOAuthSecretsErr := secrets.New(cfg.Secret.MasterKey) + if mcpOAuthSecretsErr != nil { + log.Bg().Warn("MCP connector OAuth disabled", "error", mcpOAuthSecretsErr) + } + publicURL := strings.TrimSpace(cfg.Server.PublicURL) + if publicURL == "" { + publicURL = "http://localhost" + cfg.Server.Addr + } sessionStore := auth.NewPostgresSessionStore(sqlc.New(pool)) authMw := auth.NewMiddleware(sessionStore).WithDevAuth(cfg.Auth.DevAuth) r.Group(func(r chi.Router) { r.Use(authMw.Require) mcpdirectoryapi.RegisterRoutes(r, mcpdirectoryapi.Deps{ - Catalog: mcpCatalog, - Store: dbStore, + Catalog: mcpCatalog, + Store: dbStore, + WorkspaceCredentials: dbStore, + OAuth: mcpoauth.New(nil), + Secrets: mcpOAuthSecrets, + PublicURL: publicURL, + CookieSecure: cfg.Auth.Cookie.Secure, }) runtimeapi.RegisterAdminRoutes(r, runtimeDeps) }) diff --git a/server/internal/api/mcpdirectory/handler.go b/server/internal/api/mcpdirectory/handler.go index 7b68717d..664a6d24 100644 --- a/server/internal/api/mcpdirectory/handler.go +++ b/server/internal/api/mcpdirectory/handler.go @@ -10,12 +10,15 @@ import ( "net/http" "slices" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" ) @@ -29,9 +32,21 @@ type directoryStore interface { ImportCapability(ctx context.Context, input store.ImportCapabilityInput) (store.ImportCapabilityResult, error) } +type workspaceCredentialStore interface { + ListSecrets(ctx context.Context, workspaceID string, limit int32) ([]store.SecretRead, error) + CreateSecret(ctx context.Context, input store.CreateSecretInput, encryptedPayload []byte) (store.SecretRead, error) + GetSecretPayload(ctx context.Context, workspaceID, secretID string) (store.SecretPayload, error) + UpdateSecretPayload(ctx context.Context, workspaceID, secretID string, encryptedPayload []byte) (store.SecretPayload, error) +} + type Deps struct { - Catalog catalogLoader - Store directoryStore + Catalog catalogLoader + Store directoryStore + WorkspaceCredentials workspaceCredentialStore + OAuth *mcpoauth.Client + Secrets *secrets.Service + PublicURL string + CookieSecure bool } type handler struct { @@ -48,9 +63,20 @@ type itemResponse struct { RepositoryURL string `json:"repository_url,omitempty"` Verified bool `json:"verified"` Categories []string `json:"categories"` - PopularityRank int `json:"popularity_rank"` + FeaturedRank int `json:"featured_rank"` Version string `json:"version"` Transport string `json:"transport"` + Authentication string `json:"authentication"` + CredentialKind string `json:"credential_kind,omitempty"` + ConnectionSupported bool `json:"connection_supported"` + Connected bool `json:"connected"` + ConnectionStatus string `json:"connection_status,omitempty"` + ConnectionCheckedAt *time.Time `json:"connection_checked_at,omitempty"` + ConnectionError string `json:"connection_error,omitempty"` + ConnectionProtocol string `json:"connection_protocol_version,omitempty"` + ConnectionServerName string `json:"connection_server_name,omitempty"` + ConnectionServerVer string `json:"connection_server_version,omitempty"` + ConnectionToolCount *int `json:"connection_tool_count,omitempty"` URL string `json:"url,omitempty"` Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` @@ -85,6 +111,9 @@ func RegisterRoutes(r chi.Router, deps Deps) { r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory", h.list) r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}", h.get) r.Post("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import", h.importItem) + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/start", h.oauthStart) + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/callback", h.oauthCallback) + r.Post("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/test", h.oauthTest) } // list godoc @@ -109,9 +138,13 @@ func (h *handler) list(w http.ResponseWriter, r *http.Request) { return } byCatalog := installMap(installs) + connections, ok := h.connectionStates(w, r, workspaceID) + if !ok { + return + } items := make([]itemResponse, 0, len(snapshot.Catalog.Items)) for _, item := range snapshot.Catalog.Items { - items = append(items, summarizeItem(item, byCatalog[item.ID])) + items = append(items, summarizeItem(item, byCatalog[item.ID], connections)) } writeJSON(w, http.StatusOK, listResponse{ Items: items, @@ -145,7 +178,11 @@ func (h *handler) get(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "connector_not_found") return } - response := summarizeItem(item, installMap(installs)[item.ID]) + connections, ok := h.connectionStates(w, r, workspaceID) + if !ok { + return + } + response := summarizeItem(item, installMap(installs)[item.ID], connections) response.URL = item.Server.URL response.Command = item.Server.Command response.Args = append([]string(nil), item.Server.Args...) @@ -187,6 +224,10 @@ func (h *handler) importItem(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, importResponse{Installed: true, CapabilityID: existing.CapabilityID}) return } + if !item.Authentication.ConnectionSupported() { + writeError(w, http.StatusConflict, "connector_connection_unavailable") + return + } payload, err := json.Marshal(sourcePayload{ SourceFormat: "mcp_catalog", @@ -234,6 +275,14 @@ func (h *handler) importItem(w http.ResponseWriter, r *http.Request) { } func (h *handler) authorize(w http.ResponseWriter, r *http.Request, admin bool) (string, bool) { + allowed := []string{"owner", "admin", "member", "viewer"} + if admin { + allowed = []string{"owner", "admin"} + } + return h.authorizeRoles(w, r, allowed...) +} + +func (h *handler) authorizeRoles(w http.ResponseWriter, r *http.Request, allowed ...string) (string, bool) { if h.deps.Catalog == nil || h.deps.Store == nil { writeError(w, http.StatusServiceUnavailable, "mcp_directory_unavailable") return "", false @@ -243,10 +292,6 @@ func (h *handler) authorize(w http.ResponseWriter, r *http.Request, admin bool) writeError(w, http.StatusBadRequest, "invalid_workspace_id") return "", false } - allowed := []string{"owner", "admin", "member", "viewer"} - if admin { - allowed = []string{"owner", "admin"} - } if err := auth.RequireWorkspaceRole(r.Context(), h.deps.Store, workspaceID, allowed...); err != nil { switch { case errors.Is(err, auth.ErrUnauthenticated): @@ -283,12 +328,28 @@ func installMap(installs []store.MCPDirectoryInstall) map[string]store.MCPDirect return result } -func summarizeItem(item mcpcatalog.Item, install store.MCPDirectoryInstall) itemResponse { +type connectionState struct { + Authorized bool + Status string + CheckedAt *time.Time + ErrorCode string + ProtocolVersion string + ServerName string + ServerVersion string + ToolCount *int +} + +func summarizeItem( + item mcpcatalog.Item, + install store.MCPDirectoryInstall, + connections map[string]connectionState, +) itemResponse { var installedCapabilityID *string if install.CapabilityID != "" { id := install.CapabilityID installedCapabilityID = &id } + connection := connections[item.Authentication.CredentialKind] return itemResponse{ ID: item.ID, Name: item.Name, @@ -299,14 +360,92 @@ func summarizeItem(item mcpcatalog.Item, install store.MCPDirectoryInstall) item RepositoryURL: item.RepositoryURL, Verified: item.Verified, Categories: append([]string(nil), item.Categories...), - PopularityRank: item.PopularityRank, + FeaturedRank: item.FeaturedRank, Version: item.Version, Transport: item.Transport, + Authentication: item.Authentication.EffectiveType(), + CredentialKind: item.Authentication.CredentialKind, + ConnectionSupported: item.Authentication.ConnectionSupported(), + Connected: connection.Authorized, + ConnectionStatus: connection.Status, + ConnectionCheckedAt: connection.CheckedAt, + ConnectionError: connection.ErrorCode, + ConnectionProtocol: connection.ProtocolVersion, + ConnectionServerName: connection.ServerName, + ConnectionServerVer: connection.ServerVersion, + ConnectionToolCount: connection.ToolCount, Installed: install.CapabilityID != "", InstalledCapabilityID: installedCapabilityID, } } +func (h *handler) connectionStates(w http.ResponseWriter, r *http.Request, workspaceID string) (map[string]connectionState, bool) { + if h.deps.WorkspaceCredentials == nil { + return map[string]connectionState{}, true + } + workspaceSecrets, err := h.deps.WorkspaceCredentials.ListSecrets(r.Context(), workspaceID, 1000) + if err != nil { + writeError(w, http.StatusInternalServerError, "connector_workspace_connection_state_failed") + return nil, false + } + result := make(map[string]connectionState) + for _, workspaceSecret := range workspaceSecrets { + if workspaceSecret.Kind != "capability_inline" || + workspaceSecret.Status != "active" || + workspaceSecret.AuthType != "oauth2" || + metadataString(workspaceSecret.Metadata, "workspace_id") != workspaceID { + continue + } + credentialKind := metadataString(workspaceSecret.Metadata, "credential_kind_code") + if credentialKind == "" { + continue + } + if _, exists := result[credentialKind]; exists { + continue + } + state := connectionState{Authorized: true, Status: "authorized"} + if h.deps.Secrets != nil { + stored, err := h.deps.WorkspaceCredentials.GetSecretPayload(r.Context(), workspaceID, workspaceSecret.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "connector_workspace_connection_state_failed") + return nil, false + } + payload, err := h.deps.Secrets.Decrypt(stored.EncryptedPayload) + if err != nil { + state.Status = mcpoauth.VerificationUnavailable + state.ErrorCode = "connector_oauth_credential_unreadable" + } else { + applyVerificationState(&state, payload) + } + } + result[credentialKind] = state + } + return result, true +} + +func applyVerificationState(state *connectionState, payload map[string]any) { + verification := mcpoauth.VerificationFromPayload(payload) + if verification.Status == "" { + return + } + state.Status = verification.Status + state.ErrorCode = verification.ErrorCode + state.ProtocolVersion = verification.ProtocolVersion + state.ServerName = verification.ServerName + state.ServerVersion = verification.ServerVersion + if !verification.CheckedAt.IsZero() { + checkedAt := verification.CheckedAt + state.CheckedAt = &checkedAt + } + toolCount := verification.ToolCount + state.ToolCount = &toolCount +} + +func metadataString(metadata map[string]any, key string) string { + value, _ := metadata[key].(string) + return strings.TrimSpace(value) +} + func sortedEnvNames(env map[string]string) []string { result := make([]string, 0, len(env)) for name := range env { diff --git a/server/internal/api/mcpdirectory/handler_test.go b/server/internal/api/mcpdirectory/handler_test.go index 2024b09d..fbee49aa 100644 --- a/server/internal/api/mcpdirectory/handler_test.go +++ b/server/internal/api/mcpdirectory/handler_test.go @@ -28,13 +28,20 @@ type fakeCatalog struct { func (f fakeCatalog) Load(context.Context) (mcpcatalog.Snapshot, error) { return f.snapshot, f.err } type fakeDirectoryStore struct { - role string - roleErr error - installs []store.MCPDirectoryInstall - listErr error - importErr error - concurrentInstall bool - imported *store.ImportCapabilityInput + role string + roleErr error + installs []store.MCPDirectoryInstall + listErr error + importErr error + concurrentInstall bool + imported *store.ImportCapabilityInput + credentials []store.UserCredentialRead + createdCredential *store.CreateUserCredentialInput + updatedCredential *store.UpdateUserCredentialInput + workspaceSecrets []store.SecretPayload + createdSecret *store.CreateSecretInput + createdSecretCount int + updatedSecretID string } func (f *fakeDirectoryStore) GetWorkspaceMemberRole(context.Context, string, string) (string, error) { @@ -149,7 +156,7 @@ func TestDirectoryDetailIncludesStreamableHTTPURL(t *testing.T) { snapshot.Catalog.Items = []mcpcatalog.Item{{ ID: "docs", Name: "Docs", Description: "Search docs.", Publisher: mcpcatalog.Publisher{Name: "Publisher", URL: "https://example.com"}, - Verified: true, Categories: []string{"Documentation"}, PopularityRank: 1, + Verified: true, Categories: []string{"Documentation"}, FeaturedRank: 1, Version: "1.0.0", Transport: "streamable-http", Server: mcpcatalog.Server{Name: "docs", URL: "https://docs.example.com/mcp"}, }} @@ -164,6 +171,27 @@ func TestDirectoryDetailIncludesStreamableHTTPURL(t *testing.T) { } } +func TestDirectoryReportsUnsupportedApprovedClientConnector(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := requestWithSnapshot(t, fs, approvedClientSnapshot(), http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/approved-connector") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response itemResponse + decodeResponse(t, rec, &response) + if response.ConnectionSupported || response.Authentication != "oauth2" { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryRejectsNewImportForApprovedClientConnector(t *testing.T) { + fs := &fakeDirectoryStore{role: "admin"} + rec := requestWithSnapshot(t, fs, approvedClientSnapshot(), http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/approved-connector/import") + if rec.Code != http.StatusConflict || fs.imported != nil { + t.Fatalf("status=%d imported=%v body=%s", rec.Code, fs.imported != nil, rec.Body.String()) + } +} + func TestDirectoryRejectsNonMember(t *testing.T) { fs := &fakeDirectoryStore{roleErr: store.ErrNotMember} rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory") @@ -205,13 +233,30 @@ func testSnapshot() mcpcatalog.Snapshot { Items: []mcpcatalog.Item{{ ID: "filesystem", Name: "Filesystem", Description: "Access configured files.", Publisher: mcpcatalog.Publisher{Name: "MCP", URL: "https://example.com"}, - Verified: true, Categories: []string{"Files"}, PopularityRank: 1, + Verified: true, Categories: []string{"Files"}, FeaturedRank: 1, Version: "1.0.0", Transport: "stdio", Server: mcpcatalog.Server{Name: "filesystem", Command: "npx", Args: []string{"package@1.0.0"}, Env: map[string]string{"ROOT": ""}, StartupTimeoutSec: 30}, }}, }} } +func approvedClientSnapshot() mcpcatalog.Snapshot { + return mcpcatalog.Snapshot{Source: mcpcatalog.SourceBuiltin, Catalog: mcpcatalog.Catalog{ + SchemaVersion: 1, + UpdatedAt: "2026-07-23T04:20:00Z", + Items: []mcpcatalog.Item{{ + ID: "approved-connector", Name: "Approved Connector", Description: "Requires an approved MCP client.", + Publisher: mcpcatalog.Publisher{Name: "Provider", URL: "https://example.com"}, + Verified: true, Categories: []string{"Design"}, FeaturedRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Authentication: mcpcatalog.Authentication{ + Type: "oauth2", CredentialKind: "approved_mcp_oauth", ClientRegistration: mcpcatalog.ClientRegistrationApprovedClient, + }, + Server: mcpcatalog.Server{Name: "approved-connector", URL: "https://mcp.example.com/mcp"}, + }}, + }} +} + func decodeResponse(t *testing.T, rec *httptest.ResponseRecorder, target any) { t.Helper() if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil { diff --git a/server/internal/api/mcpdirectory/oauth.go b/server/internal/api/mcpdirectory/oauth.go new file mode 100644 index 00000000..a497ad13 --- /dev/null +++ b/server/internal/api/mcpdirectory/oauth.go @@ -0,0 +1,465 @@ +package mcpdirectory + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" +) + +const ( + oauthCookieName = "parsar_mcp_oauth" + oauthCookieTTL = 10 * time.Minute + oauthIntentImport = "import" +) + +type oauthCookie struct { + WorkspaceID string `json:"workspace_id"` + CatalogID string `json:"catalog_id"` + UserID string `json:"user_id"` + BaseURL string `json:"base_url"` + Intent string `json:"intent,omitempty"` + Transaction mcpoauth.Transaction `json:"transaction"` +} + +// oauthStart godoc +// +// @Summary Start an MCP connector OAuth flow +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Param intent query string false "post-authorization action; currently import" +// @Success 302 "Redirect to the provider authorization page" +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/start [get] +func (h *handler) oauthStart(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorizeRoles(w, r, "owner", "admin", "member") + if !ok { + return + } + item, ok := h.oauthItem(w, r) + if !ok { + return + } + intent := strings.TrimSpace(r.URL.Query().Get("intent")) + if intent != "" && intent != oauthIntentImport { + writeError(w, http.StatusBadRequest, "connector_oauth_intent_unsupported") + return + } + if h.deps.OAuth == nil || h.deps.Secrets == nil || h.deps.WorkspaceCredentials == nil || strings.TrimSpace(h.deps.PublicURL) == "" { + writeError(w, http.StatusServiceUnavailable, "connector_oauth_unavailable") + return + } + baseURL, err := h.oauthBaseURL(r) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "connector_oauth_public_url_invalid") + return + } + callbackURL, err := h.callbackURL(baseURL, workspaceID, item.ID) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "connector_oauth_public_url_invalid") + return + } + transaction, authorizeURL, err := h.deps.OAuth.Begin(r.Context(), item.Server.URL, callbackURL) + if err != nil { + log.Bg().Warn("mcp oauth start failed", "catalog_id", item.ID, "error", err) + writeError(w, http.StatusBadGateway, "connector_oauth_discovery_failed") + return + } + cookieValue, err := h.encryptOAuthCookie(oauthCookie{ + WorkspaceID: workspaceID, + CatalogID: item.ID, + UserID: auth.UserIDFromContext(r.Context()), + BaseURL: baseURL, + Intent: intent, + Transaction: transaction, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "connector_oauth_state_failed") + return + } + http.SetCookie(w, &http.Cookie{ + Name: oauthCookieName, + Value: cookieValue, + Path: oauthCookiePath(workspaceID, item.ID), + HttpOnly: true, + Secure: h.deps.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(oauthCookieTTL.Seconds()), + }) + log.Bg().Info("mcp oauth authorization started", "catalog_id", item.ID, "callback_origin", baseURL) + http.Redirect(w, r, authorizeURL, http.StatusFound) +} + +// oauthCallback godoc +// +// @Summary Complete an MCP connector OAuth flow +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Param state query string true "OAuth state" +// @Param code query string true "OAuth authorization code" +// @Success 302 "Redirect to the connector detail page" +// @Failure 400 {object} map[string]string +// @Failure 502 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/callback [get] +func (h *handler) oauthCallback(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorizeRoles(w, r, "owner", "admin", "member") + if !ok { + return + } + item, ok := h.oauthItem(w, r) + if !ok { + return + } + if providerError := strings.TrimSpace(r.URL.Query().Get("error")); providerError != "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": providerError}) + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + state := strings.TrimSpace(r.URL.Query().Get("state")) + if code == "" || state == "" { + writeError(w, http.StatusBadRequest, "connector_oauth_missing_code_or_state") + return + } + cookie, err := r.Cookie(oauthCookieName) + if err != nil { + writeError(w, http.StatusBadRequest, "connector_oauth_state_missing") + return + } + context, err := h.decryptOAuthCookie(cookie.Value) + if err != nil || context.WorkspaceID != workspaceID || context.CatalogID != item.ID || context.UserID != auth.UserIDFromContext(r.Context()) || context.Transaction.State != state { + writeError(w, http.StatusBadRequest, "connector_oauth_state_mismatch") + return + } + if time.Since(time.Unix(context.Transaction.IssuedAt, 0)) > oauthCookieTTL { + writeError(w, http.StatusBadRequest, "connector_oauth_state_expired") + return + } + if h.deps.OAuth == nil || h.deps.Secrets == nil || h.deps.WorkspaceCredentials == nil { + writeError(w, http.StatusServiceUnavailable, "connector_oauth_unavailable") + return + } + h.clearOAuthCookie(w, workspaceID, item.ID) + credential, err := h.deps.OAuth.Exchange(r.Context(), context.Transaction, code) + if err != nil { + log.Bg().Warn("mcp oauth token exchange failed", "catalog_id", item.ID, "error", err) + writeError(w, http.StatusBadGateway, "connector_oauth_exchange_failed") + return + } + stored, saveErr := h.saveWorkspaceOAuthCredential(r.Context(), workspaceID, item, credential, auth.UserIDFromContext(r.Context())) + if saveErr != nil { + log.Bg().Error("mcp workspace oauth credential persist failed", "catalog_id", item.ID, "error", saveErr) + writeError(w, http.StatusInternalServerError, "connector_oauth_persist_failed") + return + } + if _, verifyErr := h.verifyStoredWorkspaceOAuthCredential(r.Context(), workspaceID, item, stored); verifyErr != nil { + log.Bg().Warn("mcp workspace oauth post-authorization verification failed", "catalog_id", item.ID, "error", verifyErr) + } + returnBaseURL := context.BaseURL + if strings.TrimSpace(returnBaseURL) == "" { + returnBaseURL = h.deps.PublicURL + } + redirectURL, err := h.directoryRedirectURL(returnBaseURL, workspaceID, item.ID, context.Intent) + if err != nil { + writeError(w, http.StatusInternalServerError, "connector_oauth_redirect_failed") + return + } + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +type oauthConnectionResponse struct { + Authorized bool `json:"authorized"` + Verified bool `json:"verified"` + Status string `json:"status"` + CheckedAt *time.Time `json:"checked_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ProtocolVersion string `json:"protocol_version,omitempty"` + ServerName string `json:"server_name,omitempty"` + ServerVersion string `json:"server_version,omitempty"` + ToolCount *int `json:"tool_count,omitempty"` +} + +// oauthTest godoc +// +// @Summary Verify an authorized OAuth MCP connector +// @Description Refreshes the token when necessary, then completes MCP initialize and tools/list without executing a tool. +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Success 200 {object} oauthConnectionResponse +// @Failure 404 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/oauth/test [post] +func (h *handler) oauthTest(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorizeRoles(w, r, "owner", "admin", "member") + if !ok { + return + } + item, ok := h.oauthItem(w, r) + if !ok { + return + } + if h.deps.OAuth == nil || h.deps.Secrets == nil || h.deps.WorkspaceCredentials == nil { + writeError(w, http.StatusServiceUnavailable, "connector_oauth_unavailable") + return + } + stored, found, err := h.workspaceOAuthCredential(r.Context(), workspaceID, item) + if err != nil { + writeError(w, http.StatusInternalServerError, "connector_oauth_credential_load_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "connector_oauth_not_connected") + return + } + result, err := h.verifyStoredWorkspaceOAuthCredential(r.Context(), workspaceID, item, stored) + if err != nil { + log.Bg().Error("mcp workspace oauth verification failed", "catalog_id", item.ID, "error", err) + writeError(w, http.StatusInternalServerError, "connector_oauth_test_failed") + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) verifyOAuthPayload( + ctx context.Context, + item mcpcatalog.Item, + payload map[string]any, +) (map[string]any, mcpoauth.Verification, error) { + credential, isOAuth, err := mcpoauth.CredentialFromPayload(payload) + if err != nil { + return nil, mcpoauth.Verification{}, err + } + if !isOAuth { + return nil, mcpoauth.Verification{}, errors.New("credential is not an MCP OAuth credential") + } + + checkedAt := time.Now().UTC() + if credential.NeedsRefresh(checkedAt) { + refreshed, refreshErr := h.deps.OAuth.Refresh(ctx, credential) + if refreshErr != nil { + return payload, mcpoauth.Verification{ + Status: mcpoauth.VerificationReconnectRequired, + CheckedAt: checkedAt, + ErrorCode: "connector_oauth_refresh_failed", + }, nil + } + refreshedPayload := refreshed.Payload() + mcpoauth.PreserveMetadata(payload, refreshedPayload) + payload = refreshedPayload + credential = refreshed + } + + probe, probeErr := h.deps.OAuth.Probe(ctx, item.Server.URL, credential.AccessToken) + if probeErr != nil { + status := mcpoauth.VerificationUnavailable + errorCode := "connector_oauth_connection_unavailable" + if errors.Is(probeErr, mcpoauth.ErrUnauthorized) { + status = mcpoauth.VerificationReconnectRequired + errorCode = "connector_oauth_reconnect_required" + } + log.Bg().Warn("mcp oauth connection probe failed", "catalog_id", item.ID, "error", probeErr) + return payload, mcpoauth.Verification{ + Status: status, + CheckedAt: checkedAt, + ErrorCode: errorCode, + }, nil + } + return payload, mcpoauth.Verification{ + Status: mcpoauth.VerificationVerified, + CheckedAt: checkedAt, + ProtocolVersion: probe.ProtocolVersion, + ServerName: probe.ServerName, + ServerVersion: probe.ServerVersion, + ToolCount: probe.ToolCount, + }, nil +} + +func oauthConnectionResult(verification mcpoauth.Verification) oauthConnectionResponse { + result := oauthConnectionResponse{ + Authorized: true, + Verified: verification.Status == mcpoauth.VerificationVerified, + Status: verification.Status, + CheckedAt: &verification.CheckedAt, + ErrorCode: verification.ErrorCode, + ProtocolVersion: verification.ProtocolVersion, + ServerName: verification.ServerName, + ServerVersion: verification.ServerVersion, + } + toolCount := verification.ToolCount + result.ToolCount = &toolCount + return result +} + +func (h *handler) oauthItem(w http.ResponseWriter, r *http.Request) (mcpcatalog.Item, bool) { + snapshot, err := h.deps.Catalog.Load(r.Context()) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "mcp_catalog_unavailable") + return mcpcatalog.Item{}, false + } + item, found := snapshot.Find(chi.URLParam(r, "catalogID")) + if !found { + writeError(w, http.StatusNotFound, "connector_not_found") + return mcpcatalog.Item{}, false + } + if item.Authentication.EffectiveType() != "oauth2" { + writeError(w, http.StatusBadRequest, "connector_does_not_use_oauth") + return mcpcatalog.Item{}, false + } + if !item.Authentication.ConnectionSupported() { + writeError(w, http.StatusConflict, "connector_oauth_approved_client_required") + return mcpcatalog.Item{}, false + } + return item, true +} + +func (h *handler) encryptOAuthCookie(value oauthCookie) (string, error) { + transactionJSON, err := json.Marshal(value.Transaction) + if err != nil { + return "", err + } + encrypted, err := h.deps.Secrets.Encrypt(map[string]any{ + "workspace_id": value.WorkspaceID, + "catalog_id": value.CatalogID, + "user_id": value.UserID, + "base_url": value.BaseURL, + "intent": value.Intent, + "transaction": string(transactionJSON), + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(encrypted), nil +} + +func (h *handler) decryptOAuthCookie(encoded string) (oauthCookie, error) { + encrypted, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return oauthCookie{}, err + } + payload, err := h.deps.Secrets.Decrypt(encrypted) + if err != nil { + return oauthCookie{}, err + } + result := oauthCookie{ + WorkspaceID: stringField(payload, "workspace_id"), + CatalogID: stringField(payload, "catalog_id"), + UserID: stringField(payload, "user_id"), + BaseURL: stringField(payload, "base_url"), + Intent: stringField(payload, "intent"), + } + if err := json.Unmarshal([]byte(stringField(payload, "transaction")), &result.Transaction); err != nil { + return oauthCookie{}, err + } + return result, nil +} + +func (h *handler) callbackURL(baseURL, workspaceID, catalogID string) (string, error) { + return publicURLFor(baseURL, oauthCookiePath(workspaceID, catalogID)+"/callback") +} + +func (h *handler) directoryRedirectURL(baseURL, workspaceID, catalogID, intent string) (string, error) { + redirectURL, err := publicURLFor(baseURL, "/") + if err != nil { + return "", err + } + parsed, err := url.Parse(redirectURL) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("admin", "capabilities") + query.Set("tab", "marketplace") + query.Set("ws", workspaceID) + query.Set("item", "mcp:"+catalogID) + query.Set("connected", catalogID) + if intent == oauthIntentImport { + query.Set("import", catalogID) + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func publicURLFor(baseURL, path string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("invalid public url") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + path + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +// oauthBaseURL keeps production callbacks pinned to PARSAR_PUBLIC_URL. In +// loopback development only, it follows the host the browser actually used +// (localhost, 127.0.0.1, or ::1) so OAuth cookies are not split across hosts. +func (h *handler) oauthBaseURL(r *http.Request) (string, error) { + configured, err := url.Parse(strings.TrimSpace(h.deps.PublicURL)) + if err != nil || configured.Host == "" || (configured.Scheme != "http" && configured.Scheme != "https") { + return "", fmt.Errorf("invalid public url") + } + if !isLoopbackHost(configured.Hostname()) { + return strings.TrimRight(configured.String(), "/"), nil + } + + requestURL, err := url.Parse("http://" + strings.TrimSpace(r.Host)) + if err != nil || requestURL.Host == "" || !isLoopbackHost(requestURL.Hostname()) { + return strings.TrimRight(configured.String(), "/"), nil + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } else if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); forwarded == "http" || forwarded == "https" { + scheme = forwarded + } + return (&url.URL{Scheme: scheme, Host: requestURL.Host, Path: strings.TrimRight(configured.Path, "/")}).String(), nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), "localhost") { + return true + } + ip := net.ParseIP(strings.TrimSpace(host)) + return ip != nil && ip.IsLoopback() +} + +func (h *handler) clearOAuthCookie(w http.ResponseWriter, workspaceID, catalogID string) { + http.SetCookie(w, &http.Cookie{ + Name: oauthCookieName, + Value: "", + Path: oauthCookiePath(workspaceID, catalogID), + HttpOnly: true, + Secure: h.deps.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func oauthCookiePath(workspaceID, catalogID string) string { + return "/api/v1/workspaces/" + url.PathEscape(workspaceID) + "/mcp-directory/" + url.PathEscape(catalogID) + "/oauth" +} + +func stringField(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return value +} diff --git a/server/internal/api/mcpdirectory/oauth_scope.go b/server/internal/api/mcpdirectory/oauth_scope.go new file mode 100644 index 00000000..a78669d7 --- /dev/null +++ b/server/internal/api/mcpdirectory/oauth_scope.go @@ -0,0 +1,111 @@ +package mcpdirectory + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +func (h *handler) saveWorkspaceOAuthCredential( + ctx context.Context, + workspaceID string, + item mcpcatalog.Item, + credential mcpoauth.Credential, + createdBy string, +) (store.SecretPayload, error) { + payload := credential.Payload() + payload["catalog_id"] = item.ID + encrypted, err := h.deps.Secrets.Encrypt(payload) + if err != nil { + return store.SecretPayload{}, err + } + if existing, found, err := h.workspaceOAuthCredentialRead(ctx, workspaceID, item, false); err != nil { + return store.SecretPayload{}, err + } else if found { + return h.deps.WorkspaceCredentials.UpdateSecretPayload(ctx, workspaceID, existing.ID, encrypted) + } + created, err := h.deps.WorkspaceCredentials.CreateSecret(ctx, store.CreateSecretInput{ + WorkspaceID: workspaceID, + Name: item.Name + " OAuth", + Kind: "capability_inline", + Provider: item.ID, + AuthType: "oauth2", + Masked: secrets.MaskPayload(payload), + CreatedBy: createdBy, + CredentialKindCode: item.Authentication.CredentialKind, + Metadata: map[string]any{ + "catalog_id": item.ID, + }, + }, encrypted) + if err != nil { + return store.SecretPayload{}, err + } + return store.SecretPayload{SecretRead: created, EncryptedPayload: encrypted}, nil +} + +func (h *handler) workspaceOAuthCredential( + ctx context.Context, + workspaceID string, + item mcpcatalog.Item, +) (store.SecretPayload, bool, error) { + read, found, err := h.workspaceOAuthCredentialRead(ctx, workspaceID, item, true) + if err != nil || !found { + return store.SecretPayload{}, found, err + } + payload, err := h.deps.WorkspaceCredentials.GetSecretPayload(ctx, workspaceID, read.ID) + return payload, err == nil, err +} + +func (h *handler) workspaceOAuthCredentialRead( + ctx context.Context, + workspaceID string, + item mcpcatalog.Item, + activeOnly bool, +) (store.SecretRead, bool, error) { + workspaceSecrets, err := h.deps.WorkspaceCredentials.ListSecrets(ctx, workspaceID, 1000) + if err != nil { + return store.SecretRead{}, false, err + } + for _, candidate := range workspaceSecrets { + if activeOnly && candidate.Status != "active" { + continue + } + if candidate.Kind != "capability_inline" || + candidate.AuthType != "oauth2" || + metadataString(candidate.Metadata, "workspace_id") != workspaceID || + metadataString(candidate.Metadata, "credential_kind_code") != item.Authentication.CredentialKind || + metadataString(candidate.Metadata, "catalog_id") != item.ID { + continue + } + return candidate, true, nil + } + return store.SecretRead{}, false, nil +} + +func (h *handler) verifyStoredWorkspaceOAuthCredential( + ctx context.Context, + workspaceID string, + item mcpcatalog.Item, + stored store.SecretPayload, +) (oauthConnectionResponse, error) { + payload, err := h.deps.Secrets.Decrypt(stored.EncryptedPayload) + if err != nil { + return oauthConnectionResponse{}, err + } + payload, verification, err := h.verifyOAuthPayload(ctx, item, payload) + if err != nil { + return oauthConnectionResponse{}, err + } + mcpoauth.ApplyVerification(payload, verification) + encrypted, err := h.deps.Secrets.Encrypt(payload) + if err != nil { + return oauthConnectionResponse{}, err + } + if _, err := h.deps.WorkspaceCredentials.UpdateSecretPayload(ctx, workspaceID, stored.ID, encrypted); err != nil { + return oauthConnectionResponse{}, err + } + return oauthConnectionResult(verification), nil +} diff --git a/server/internal/api/mcpdirectory/oauth_test.go b/server/internal/api/mcpdirectory/oauth_test.go new file mode 100644 index 00000000..cfa85834 --- /dev/null +++ b/server/internal/api/mcpdirectory/oauth_test.go @@ -0,0 +1,479 @@ +package mcpdirectory + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +func TestNotionOAuthFlowSharesCredentialWithWorkspace(t *testing.T) { + provider := newOAuthProvider(t) + defer provider.Close() + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + fs := &fakeDirectoryStore{role: "member"} + snapshot := mcpcatalog.Snapshot{Source: mcpcatalog.SourceBuiltin, Catalog: mcpcatalog.Catalog{ + SchemaVersion: 1, + UpdatedAt: "2026-07-22T00:00:00Z", + Items: []mcpcatalog.Item{{ + ID: "notion", Name: "Notion", Description: "Use Notion.", + Publisher: mcpcatalog.Publisher{Name: "Notion", URL: "https://www.notion.so"}, + Verified: true, Categories: []string{"Productivity"}, FeaturedRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Authentication: mcpcatalog.Authentication{ + Type: "oauth2", CredentialKind: "notion_mcp_oauth", + }, + Server: mcpcatalog.Server{Name: "notion", URL: provider.URL + "/mcp"}, + }}, + }} + router := testOAuthRouter(t, fs, snapshot, mcpoauth.New(provider.Client()), secretService) + + start := httptest.NewRecorder() + router.ServeHTTP(start, authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/start?intent=import")) + if start.Code != http.StatusFound { + t.Fatalf("start status=%d body=%s", start.Code, start.Body.String()) + } + authorizeURL, err := url.Parse(start.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + state := authorizeURL.Query().Get("state") + if state == "" || authorizeURL.Query().Get("code_challenge") == "" { + t.Fatalf("authorize url = %s", authorizeURL) + } + cookies := start.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != oauthCookieName || !cookies[0].HttpOnly { + t.Fatalf("cookies = %+v", cookies) + } + decoded, err := (&handler{deps: Deps{Secrets: secretService}}).decryptOAuthCookie(cookies[0].Value) + if err != nil { + t.Fatal(err) + } + if decoded.Intent != oauthIntentImport { + t.Fatalf("intent=%q", decoded.Intent) + } + + callbackRequest := authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/callback?code=code-1&state="+url.QueryEscape(state)) + callbackRequest.AddCookie(cookies[0]) + callback := httptest.NewRecorder() + router.ServeHTTP(callback, callbackRequest) + if callback.Code != http.StatusFound { + t.Fatalf("callback status=%d body=%s", callback.Code, callback.Body.String()) + } + redirectURL, err := url.Parse(callback.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if redirectURL.Query().Get("ws") != testWorkspaceID || redirectURL.Query().Get("item") != "mcp:notion" || redirectURL.Query().Get("connected") != "notion" || redirectURL.Query().Get("import") != "notion" { + t.Fatalf("callback redirect=%s", redirectURL) + } + if fs.createdCredential != nil || fs.createdSecret == nil { + t.Fatalf("user credential=%+v workspace secret=%+v", fs.createdCredential, fs.createdSecret) + } + if fs.createdSecret.WorkspaceID != testWorkspaceID || fs.createdSecret.Kind != "capability_inline" || fs.createdSecret.CredentialKindCode != "notion_mcp_oauth" { + t.Fatalf("created secret=%+v", fs.createdSecret) + } + if len(fs.workspaceSecrets) != 1 { + t.Fatalf("workspace secrets=%+v", fs.workspaceSecrets) + } + payload, err := secretService.Decrypt(fs.workspaceSecrets[0].EncryptedPayload) + if err != nil { + t.Fatal(err) + } + if payload["access_token"] != "notion-access" || payload["refresh_token"] != "notion-refresh" || payload["provider"] != mcpoauth.CredentialProvider || payload["connection_status"] != mcpoauth.VerificationVerified { + t.Fatalf("payload = %+v", payload) + } + if _, exists := payload["credential_scope"]; exists { + t.Fatalf("payload must not persist a credential scope: %+v", payload) + } + + detail := httptest.NewRecorder() + router.ServeHTTP(detail, authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion")) + if detail.Code != http.StatusOK { + t.Fatalf("detail status=%d body=%s", detail.Code, detail.Body.String()) + } + var item itemResponse + decodeResponse(t, detail, &item) + if item.Authentication != "oauth2" || !item.Connected || item.CredentialKind != "notion_mcp_oauth" || item.ConnectionStatus != mcpoauth.VerificationVerified || item.ConnectionToolCount == nil || *item.ConnectionToolCount != 2 { + t.Fatalf("item = %+v", item) + } +} + +func TestOAuthStartRejectsWorkspaceViewer(t *testing.T) { + provider := newOAuthProvider(t) + defer provider.Close() + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + router := testOAuthRouter( + t, + &fakeDirectoryStore{role: "viewer"}, + notionSnapshot(provider.URL+"/mcp"), + mcpoauth.New(provider.Client()), + secretService, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/start")) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestOAuthStartRejectsApprovedClientConnector(t *testing.T) { + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + router := testOAuthRouter( + t, + &fakeDirectoryStore{role: "member"}, + approvedClientSnapshot(), + mcpoauth.New(http.DefaultClient), + secretService, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/approved-connector/oauth/start")) + if recorder.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestOAuthStartRejectsUnsupportedIntent(t *testing.T) { + provider := newOAuthProvider(t) + defer provider.Close() + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + router := testOAuthRouter( + t, + &fakeDirectoryStore{role: "member"}, + notionSnapshot(provider.URL+"/mcp"), + mcpoauth.New(provider.Client()), + secretService, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, authenticatedRequest(http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/start?intent=delete")) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestOAuthStartKeepsLoopbackCallbackOnBrowserHost(t *testing.T) { + provider := newOAuthProvider(t) + defer provider.Close() + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + fs := &fakeDirectoryStore{role: "member"} + router := testOAuthRouter(t, fs, notionSnapshot(provider.URL+"/mcp"), mcpoauth.New(provider.Client()), secretService) + request := httptest.NewRequest( + http.MethodGet, + "http://localhost:18080/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/start", + nil, + ).WithContext(auth.WithUserID(context.Background(), testUserID)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusFound { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + authorizeURL, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + want := "http://localhost:18080/api/v1/workspaces/" + testWorkspaceID + "/mcp-directory/notion/oauth/callback" + if got := authorizeURL.Query().Get("redirect_uri"); got != want { + t.Fatalf("redirect_uri=%q want=%q", got, want) + } + contextCookie, err := routerOAuthCookie(recorder) + if err != nil { + t.Fatal(err) + } + decoded, err := (&handler{deps: Deps{Secrets: secretService}}).decryptOAuthCookie(contextCookie.Value) + if err != nil { + t.Fatal(err) + } + if decoded.BaseURL != "http://localhost:18080" { + t.Fatalf("base_url=%q", decoded.BaseURL) + } +} + +func TestOAuthConnectionTestReportsReconnectRequired(t *testing.T) { + provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer provider.Close() + secretService, err := secrets.New("test-master-key-test-master-key-") + if err != nil { + t.Fatal(err) + } + credential := mcpoauth.Credential{ + AccessToken: "revoked", + RefreshToken: "refresh", + ClientID: "client-1", + TokenEndpointAuthMethod: "none", + TokenEndpoint: provider.URL + "/token", + Resource: provider.URL, + } + payload := credential.Payload() + payload["catalog_id"] = "notion" + encrypted, err := secretService.Encrypt(payload) + if err != nil { + t.Fatal(err) + } + fs := &fakeDirectoryStore{role: "member", workspaceSecrets: []store.SecretPayload{{ + SecretRead: store.SecretRead{ + ID: "00000000-0000-0000-0000-000000000055", + Kind: "capability_inline", + AuthType: "oauth2", + Status: "active", + Metadata: map[string]any{ + "workspace_id": testWorkspaceID, + "catalog_id": "notion", + "credential_kind_code": "notion_mcp_oauth", + }, + }, + EncryptedPayload: encrypted, + }}} + snapshot := notionSnapshot(provider.URL) + router := testOAuthRouter(t, fs, snapshot, mcpoauth.New(provider.Client()), secretService) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, authenticatedRequest(http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/notion/oauth/test")) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var result oauthConnectionResponse + decodeResponse(t, recorder, &result) + if result.Verified || result.Status != mcpoauth.VerificationReconnectRequired || result.ErrorCode != "connector_oauth_reconnect_required" { + t.Fatalf("result = %+v", result) + } +} + +func (f *fakeDirectoryStore) ListUserCredentials(context.Context, string) ([]store.UserCredentialRead, error) { + return append([]store.UserCredentialRead(nil), f.credentials...), nil +} + +func (f *fakeDirectoryStore) GetUserCredentialByUserKind(_ context.Context, userID, kind string) (store.UserCredentialRead, bool, error) { + for _, credential := range f.credentials { + if credential.UserID == userID && credential.Kind == kind { + return credential, true, nil + } + } + return store.UserCredentialRead{}, false, nil +} + +func (f *fakeDirectoryStore) CreateUserCredential(_ context.Context, input store.CreateUserCredentialInput) (store.UserCredentialRead, error) { + f.createdCredential = &input + credential := store.UserCredentialRead{ + ID: "00000000-0000-0000-0000-000000000044", + UserID: input.UserID, + Kind: input.Kind, + DisplayName: input.DisplayName, + Ciphertext: input.EncryptedValue, + } + f.credentials = []store.UserCredentialRead{credential} + return credential, nil +} + +func (f *fakeDirectoryStore) UpdateUserCredential(_ context.Context, input store.UpdateUserCredentialInput) (store.UserCredentialRead, error) { + f.updatedCredential = &input + credential := store.UserCredentialRead{ + ID: input.CredentialID, + UserID: testUserID, + Kind: "notion_mcp_oauth", + Ciphertext: input.EncryptedValue, + } + f.credentials = []store.UserCredentialRead{credential} + return credential, nil +} + +func (f *fakeDirectoryStore) ListSecrets(context.Context, string, int32) ([]store.SecretRead, error) { + result := make([]store.SecretRead, 0, len(f.workspaceSecrets)) + for _, secret := range f.workspaceSecrets { + result = append(result, secret.SecretRead) + } + return result, nil +} + +func (f *fakeDirectoryStore) CreateSecret(_ context.Context, input store.CreateSecretInput, encryptedPayload []byte) (store.SecretRead, error) { + f.createdSecret = &input + f.createdSecretCount++ + metadata := map[string]any{"workspace_id": input.WorkspaceID, "credential_kind_code": input.CredentialKindCode} + for key, value := range input.Metadata { + metadata[key] = value + } + created := store.SecretRead{ + ID: "00000000-0000-0000-0000-000000000055", Name: input.Name, Kind: input.Kind, + Provider: input.Provider, AuthType: input.AuthType, Status: "active", Metadata: metadata, + } + f.workspaceSecrets = []store.SecretPayload{{SecretRead: created, EncryptedPayload: encryptedPayload}} + return created, nil +} + +func (f *fakeDirectoryStore) GetSecretPayload(_ context.Context, _, secretID string) (store.SecretPayload, error) { + for _, secret := range f.workspaceSecrets { + if secret.ID == secretID { + return secret, nil + } + } + return store.SecretPayload{}, store.ErrUnknownSecret +} + +func (f *fakeDirectoryStore) UpdateSecretPayload(_ context.Context, _, secretID string, encryptedPayload []byte) (store.SecretPayload, error) { + f.updatedSecretID = secretID + for index, secret := range f.workspaceSecrets { + if secret.ID == secretID { + secret.Status = "active" + secret.EncryptedPayload = encryptedPayload + f.workspaceSecrets[index] = secret + return secret, nil + } + } + return store.SecretPayload{}, store.ErrUnknownSecret +} + +func testOAuthRouter(t *testing.T, fs *fakeDirectoryStore, snapshot mcpcatalog.Snapshot, oauthClient *mcpoauth.Client, secretService *secrets.Service) http.Handler { + t.Helper() + router := chi.NewRouter() + RegisterRoutes(router, Deps{ + Catalog: fakeCatalog{snapshot: snapshot}, + Store: fs, + WorkspaceCredentials: fs, + OAuth: oauthClient, + Secrets: secretService, + PublicURL: "http://127.0.0.1:18080", + CookieSecure: false, + }) + return router +} + +func authenticatedRequest(method, path string) *http.Request { + request := httptest.NewRequest(method, path, nil) + return request.WithContext(auth.WithUserID(request.Context(), testUserID)) +} + +func routerOAuthCookie(recorder *httptest.ResponseRecorder) (*http.Cookie, error) { + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == oauthCookieName { + return cookie, nil + } + } + return nil, fmt.Errorf("%s cookie missing", oauthCookieName) +} + +func newOAuthProvider(t *testing.T) *httptest.Server { + t.Helper() + var server *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-protected-resource/mcp", func(w http.ResponseWriter, _ *http.Request) { + writeOAuthJSON(t, w, http.StatusOK, map[string]any{ + "resource": server.URL + "/mcp", + "authorization_servers": []string{server.URL}, + }) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + writeOAuthJSON(t, w, http.StatusOK, map[string]any{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + "registration_endpoint": server.URL + "/register", + "code_challenge_methods_supported": []string{"S256"}, + }) + }) + mux.HandleFunc("/register", func(w http.ResponseWriter, _ *http.Request) { + writeOAuthJSON(t, w, http.StatusCreated, map[string]any{ + "client_id": "client-1", + "token_endpoint_auth_method": "none", + }) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("code") != "code-1" || r.Form.Get("code_verifier") == "" { + t.Fatalf("token form = %v", r.Form) + } + writeOAuthJSON(t, w, http.StatusOK, map[string]any{ + "access_token": "notion-access", + "refresh_token": "notion-refresh", + "token_type": "Bearer", + "expires_in": 3600, + }) + }) + mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer notion-access" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var request struct { + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + switch request.Method { + case "initialize": + w.Header().Set("Mcp-Session-Id", "session-1") + writeOAuthJSON(t, w, http.StatusOK, map[string]any{ + "jsonrpc": "2.0", "id": 1, + "result": map[string]any{ + "protocolVersion": "2025-06-18", + "serverInfo": map[string]string{"name": "Notion", "version": "1.0.0"}, + }, + }) + case "notifications/initialized": + w.WriteHeader(http.StatusAccepted) + case "tools/list": + writeOAuthJSON(t, w, http.StatusOK, map[string]any{ + "jsonrpc": "2.0", "id": 2, + "result": map[string]any{"tools": []map[string]string{{"name": "notion-search"}, {"name": "notion-fetch"}}}, + }) + default: + t.Fatalf("unexpected MCP method %q", request.Method) + } + }) + server = httptest.NewTLSServer(mux) + return server +} + +func notionSnapshot(serverURL string) mcpcatalog.Snapshot { + return mcpcatalog.Snapshot{Source: mcpcatalog.SourceBuiltin, Catalog: mcpcatalog.Catalog{ + SchemaVersion: 1, + UpdatedAt: "2026-07-22T00:00:00Z", + Items: []mcpcatalog.Item{{ + ID: "notion", Name: "Notion", Description: "Use Notion.", + Publisher: mcpcatalog.Publisher{Name: "Notion", URL: "https://www.notion.so"}, + Verified: true, Categories: []string{"Productivity"}, FeaturedRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Authentication: mcpcatalog.Authentication{ + Type: "oauth2", CredentialKind: "notion_mcp_oauth", + }, + Server: mcpcatalog.Server{Name: "notion", URL: serverURL}, + }}, + }} +} + +func writeOAuthJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/auth/mcpoauth/client.go b/server/internal/auth/mcpoauth/client.go new file mode 100644 index 00000000..237010bb --- /dev/null +++ b/server/internal/auth/mcpoauth/client.go @@ -0,0 +1,630 @@ +package mcpoauth + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + maxMetadataBytes = 1 << 20 + maxMCPBytes = 4 << 20 + mcpProtocol = "2025-06-18" +) + +var ErrUnauthorized = errors.New("mcp oauth: authorization rejected") + +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +type Client struct { + http Doer + now func() time.Time +} + +type Transaction struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RedirectURI string `json:"redirect_uri"` + Resource string `json:"resource"` + Scope string `json:"scope,omitempty"` + IssuedAt int64 `json:"issued_at"` +} + +type Credential struct { + AccessToken string + RefreshToken string + TokenType string + Scope string + ExpiresAt time.Time + ClientID string + ClientSecret string + TokenEndpointAuthMethod string + TokenEndpoint string + Resource string +} + +type ProbeResult struct { + ProtocolVersion string + ServerName string + ServerVersion string + ToolCount int +} + +type jsonRPCResponse struct { + Result json.RawMessage `json:"result"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type initializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` +} + +type toolsListResult struct { + Tools []json.RawMessage `json:"tools"` +} + +type protectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` +} + +type authorizationServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + TokenEndpointMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` +} + +type registrationResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresIn json.RawMessage `json:"expires_in"` +} + +func New(httpClient Doer) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + return &Client{http: httpClient, now: time.Now} +} + +func (c *Client) Begin(ctx context.Context, resource, redirectURI string) (Transaction, string, error) { + resourceURL, err := requireHTTPSURL(resource) + if err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: resource: %w", err) + } + if _, err := requireHTTPURL(redirectURI); err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: redirect_uri: %w", err) + } + + var protected protectedResourceMetadata + if err := c.getJSON(ctx, protectedResourceMetadataURL(resourceURL), &protected); err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: protected resource discovery: %w", err) + } + if strings.TrimSpace(protected.Resource) != resourceURL.String() { + return Transaction{}, "", fmt.Errorf("mcp oauth: protected resource metadata returned resource %q", protected.Resource) + } + if len(protected.AuthorizationServers) == 0 { + return Transaction{}, "", errors.New("mcp oauth: protected resource metadata has no authorization server") + } + issuer, err := requireHTTPSURL(protected.AuthorizationServers[0]) + if err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: authorization server: %w", err) + } + + metadata, err := c.discoverAuthorizationServer(ctx, issuer) + if err != nil { + return Transaction{}, "", err + } + if strings.TrimRight(metadata.Issuer, "/") != strings.TrimRight(issuer.String(), "/") { + return Transaction{}, "", fmt.Errorf("mcp oauth: authorization server issuer mismatch: %q", metadata.Issuer) + } + if !contains(metadata.CodeChallengeMethodsSupported, "S256") { + return Transaction{}, "", errors.New("mcp oauth: authorization server does not support PKCE S256") + } + if _, err := requireHTTPSURL(metadata.AuthorizationEndpoint); err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: authorization endpoint: %w", err) + } + if _, err := requireHTTPSURL(metadata.TokenEndpoint); err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: token endpoint: %w", err) + } + if _, err := requireHTTPSURL(metadata.RegistrationEndpoint); err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: registration endpoint: %w", err) + } + + registration, err := c.register(ctx, metadata.RegistrationEndpoint, redirectURI) + if err != nil { + return Transaction{}, "", err + } + state, err := randomURLToken(24) + if err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: state: %w", err) + } + verifier, err := randomURLToken(48) + if err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: code verifier: %w", err) + } + challengeBytes := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:]) + + authorizeURL, err := url.Parse(metadata.AuthorizationEndpoint) + if err != nil { + return Transaction{}, "", fmt.Errorf("mcp oauth: parse authorization endpoint: %w", err) + } + query := authorizeURL.Query() + query.Set("response_type", "code") + query.Set("client_id", registration.ClientID) + query.Set("redirect_uri", redirectURI) + query.Set("state", state) + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + query.Set("resource", resourceURL.String()) + scope := strings.Join(protected.ScopesSupported, " ") + if scope != "" { + query.Set("scope", scope) + } + authorizeURL.RawQuery = query.Encode() + + return Transaction{ + State: state, + CodeVerifier: verifier, + ClientID: registration.ClientID, + ClientSecret: registration.ClientSecret, + TokenEndpointAuthMethod: registration.TokenEndpointAuthMethod, + AuthorizationEndpoint: metadata.AuthorizationEndpoint, + TokenEndpoint: metadata.TokenEndpoint, + RedirectURI: redirectURI, + Resource: resourceURL.String(), + Scope: scope, + IssuedAt: c.now().UTC().Unix(), + }, authorizeURL.String(), nil +} + +func (c *Client) discoverAuthorizationServer(ctx context.Context, issuer *url.URL) (authorizationServerMetadata, error) { + var metadata authorizationServerMetadata + oauthURL := authorizationServerMetadataURL(issuer) + if err := c.getJSON(ctx, oauthURL, &metadata); err == nil { + return metadata, nil + } else { + var openIDMetadata authorizationServerMetadata + openIDURL := openIDConfigurationURL(issuer) + if openIDErr := c.getJSON(ctx, openIDURL, &openIDMetadata); openIDErr != nil { + return authorizationServerMetadata{}, fmt.Errorf( + "mcp oauth: authorization server discovery: oauth metadata: %v; openid metadata: %w", + err, + openIDErr, + ) + } + return openIDMetadata, nil + } +} + +func (c *Client) Exchange(ctx context.Context, transaction Transaction, code string) (Credential, error) { + values := url.Values{ + "grant_type": {"authorization_code"}, + "code": {strings.TrimSpace(code)}, + "redirect_uri": {transaction.RedirectURI}, + "client_id": {transaction.ClientID}, + "code_verifier": {transaction.CodeVerifier}, + "resource": {transaction.Resource}, + } + return c.tokenRequest(ctx, transaction.TokenEndpoint, transaction.TokenEndpointAuthMethod, transaction.ClientID, transaction.ClientSecret, values, "") +} + +func (c *Client) Refresh(ctx context.Context, credential Credential) (Credential, error) { + if strings.TrimSpace(credential.RefreshToken) == "" { + return Credential{}, errors.New("mcp oauth: refresh token is empty") + } + values := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {credential.RefreshToken}, + "client_id": {credential.ClientID}, + "resource": {credential.Resource}, + } + return c.tokenRequest(ctx, credential.TokenEndpoint, credential.TokenEndpointAuthMethod, credential.ClientID, credential.ClientSecret, values, credential.RefreshToken) +} + +// Probe verifies that an OAuth access token is accepted by a remote MCP +// server and that the server can complete the MCP lifecycle through +// tools/list. It never executes a tool. +func (c *Client) Probe(ctx context.Context, resource, accessToken string) (ProbeResult, error) { + resourceURL, err := requireHTTPSURL(resource) + if err != nil { + return ProbeResult{}, fmt.Errorf("mcp probe: resource: %w", err) + } + if strings.TrimSpace(accessToken) == "" { + return ProbeResult{}, errors.New("mcp probe: access token is empty") + } + + var initialized initializeResult + headers, err := c.mcpRequest(ctx, resourceURL.String(), accessToken, "", "", map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": mcpProtocol, + "capabilities": map[string]any{}, + "clientInfo": map[string]string{ + "name": "Parsar", + "version": "1.0", + }, + }, + }, &initialized) + if err != nil { + return ProbeResult{}, fmt.Errorf("mcp probe: initialize: %w", err) + } + protocolVersion := strings.TrimSpace(initialized.ProtocolVersion) + if protocolVersion == "" { + return ProbeResult{}, errors.New("mcp probe: initialize returned no protocol version") + } + sessionID := strings.TrimSpace(headers.Get("Mcp-Session-Id")) + + if _, err := c.mcpRequest(ctx, resourceURL.String(), accessToken, sessionID, protocolVersion, map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }, nil); err != nil { + return ProbeResult{}, fmt.Errorf("mcp probe: initialized notification: %w", err) + } + + var tools toolsListResult + if _, err := c.mcpRequest(ctx, resourceURL.String(), accessToken, sessionID, protocolVersion, map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": map[string]any{}, + }, &tools); err != nil { + return ProbeResult{}, fmt.Errorf("mcp probe: tools/list: %w", err) + } + + return ProbeResult{ + ProtocolVersion: protocolVersion, + ServerName: strings.TrimSpace(initialized.ServerInfo.Name), + ServerVersion: strings.TrimSpace(initialized.ServerInfo.Version), + ToolCount: len(tools.Tools), + }, nil +} + +func (c *Client) mcpRequest( + ctx context.Context, + endpoint string, + accessToken string, + sessionID string, + protocolVersion string, + payload map[string]any, + out any, +) (http.Header, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken)) + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Content-Type", "application/json") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + if protocolVersion != "" { + req.Header.Set("MCP-Protocol-Version", protocolVersion) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxMCPBytes)) + return resp.Header, ErrUnauthorized + } + if out == nil && (resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted || resp.StatusCode == http.StatusNoContent) { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxMCPBytes)) + return resp.Header, nil + } + if resp.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(resp.Body, maxMetadataBytes)) + return resp.Header, fmt.Errorf("unexpected HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(message))) + } + if err := decodeMCPResponse(resp, out); err != nil { + return resp.Header, err + } + return resp.Header, nil +} + +func decodeMCPResponse(resp *http.Response, out any) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMCPBytes+1)) + if err != nil { + return err + } + if len(body) > maxMCPBytes { + return errors.New("mcp response exceeds 4 MiB") + } + if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") { + body, err = firstSSEData(body) + if err != nil { + return err + } + } + var envelope jsonRPCResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("decode json-rpc response: %w", err) + } + if envelope.Error != nil { + return fmt.Errorf("json-rpc error %d: %s", envelope.Error.Code, strings.TrimSpace(envelope.Error.Message)) + } + if len(envelope.Result) == 0 { + return errors.New("json-rpc response has no result") + } + if err := json.Unmarshal(envelope.Result, out); err != nil { + return fmt.Errorf("decode json-rpc result: %w", err) + } + return nil +} + +func firstSSEData(body []byte) ([]byte, error) { + scanner := bufio.NewScanner(bytes.NewReader(body)) + scanner.Buffer(make([]byte, 64*1024), maxMCPBytes) + var data strings.Builder + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if data.Len() > 0 { + return []byte(strings.TrimSuffix(data.String(), "\n")), nil + } + continue + } + if strings.HasPrefix(line, "data:") { + data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + data.WriteByte('\n') + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if data.Len() > 0 { + return []byte(strings.TrimSuffix(data.String(), "\n")), nil + } + return nil, errors.New("mcp SSE response has no data event") +} + +func (c *Client) register(ctx context.Context, endpoint, redirectURI string) (registrationResponse, error) { + body := map[string]any{ + "client_name": "Parsar", + "redirect_uris": []string{redirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + } + encoded, err := json.Marshal(body) + if err != nil { + return registrationResponse{}, fmt.Errorf("mcp oauth: encode registration: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(encoded))) + if err != nil { + return registrationResponse{}, fmt.Errorf("mcp oauth: create registration request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + var response registrationResponse + if err := c.doJSON(req, http.StatusCreated, &response); err != nil { + return registrationResponse{}, fmt.Errorf("mcp oauth: dynamic client registration: %w", err) + } + if strings.TrimSpace(response.ClientID) == "" { + return registrationResponse{}, errors.New("mcp oauth: dynamic client registration returned no client_id") + } + if strings.TrimSpace(response.TokenEndpointAuthMethod) == "" { + response.TokenEndpointAuthMethod = "none" + } + switch response.TokenEndpointAuthMethod { + case "none", "client_secret_basic", "client_secret_post": + default: + return registrationResponse{}, fmt.Errorf("mcp oauth: unsupported token endpoint auth method %q", response.TokenEndpointAuthMethod) + } + return response, nil +} + +func (c *Client) tokenRequest(ctx context.Context, endpoint, authMethod, clientID, clientSecret string, values url.Values, fallbackRefreshToken string) (Credential, error) { + if _, err := requireHTTPSURL(endpoint); err != nil { + return Credential{}, fmt.Errorf("mcp oauth: token endpoint: %w", err) + } + if authMethod == "client_secret_post" { + values.Set("client_secret", clientSecret) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode())) + if err != nil { + return Credential{}, fmt.Errorf("mcp oauth: create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if authMethod == "client_secret_basic" { + req.SetBasicAuth(clientID, clientSecret) + } + var response tokenResponse + if err := c.doJSON(req, http.StatusOK, &response); err != nil { + return Credential{}, fmt.Errorf("mcp oauth: token exchange: %w", err) + } + if strings.TrimSpace(response.AccessToken) == "" { + return Credential{}, errors.New("mcp oauth: token response has no access_token") + } + refreshToken := strings.TrimSpace(response.RefreshToken) + if refreshToken == "" { + refreshToken = fallbackRefreshToken + } + expiresIn := parseExpiresIn(response.ExpiresIn) + var expiresAt time.Time + if expiresIn > 0 { + expiresAt = c.now().UTC().Add(expiresIn) + } + return Credential{ + AccessToken: response.AccessToken, + RefreshToken: refreshToken, + TokenType: response.TokenType, + Scope: response.Scope, + ExpiresAt: expiresAt, + ClientID: clientID, + ClientSecret: clientSecret, + TokenEndpointAuthMethod: authMethod, + TokenEndpoint: endpoint, + Resource: values.Get("resource"), + }, nil +} + +func (c *Client) getJSON(ctx context.Context, endpoint string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + return c.doJSON(req, http.StatusOK, out) +} + +func (c *Client) doJSON(req *http.Request, expectedStatus int, out any) error { + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetadataBytes+1)) + if err != nil { + return err + } + if len(body) > maxMetadataBytes { + return errors.New("response exceeds 1 MiB") + } + if resp.StatusCode != expectedStatus { + return fmt.Errorf("unexpected HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode json: %w", err) + } + return nil +} + +func protectedResourceMetadataURL(resource *url.URL) string { + copyURL := *resource + copyURL.RawQuery = "" + copyURL.Fragment = "" + path := strings.TrimPrefix(copyURL.EscapedPath(), "/") + copyURL.Path = "/.well-known/oauth-protected-resource" + copyURL.RawPath = "" + if path != "" { + copyURL.Path += "/" + path + } + return copyURL.String() +} + +func authorizationServerMetadataURL(issuer *url.URL) string { + copyURL := *issuer + copyURL.RawQuery = "" + copyURL.Fragment = "" + issuerPath := strings.TrimPrefix(copyURL.EscapedPath(), "/") + copyURL.Path = "/.well-known/oauth-authorization-server" + copyURL.RawPath = "" + if issuerPath != "" { + copyURL.Path += "/" + issuerPath + } + return copyURL.String() +} + +func openIDConfigurationURL(issuer *url.URL) string { + copyURL := *issuer + copyURL.RawQuery = "" + copyURL.Fragment = "" + issuerPath := strings.TrimPrefix(copyURL.EscapedPath(), "/") + copyURL.Path = "/.well-known/openid-configuration" + copyURL.RawPath = "" + if issuerPath != "" { + copyURL.Path += "/" + issuerPath + } + return copyURL.String() +} + +func requireHTTPSURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.Scheme != "https" || parsed.User != nil { + return nil, errors.New("must be an https URL without embedded credentials") + } + return parsed, nil +} + +func requireHTTPURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return nil, errors.New("must be an http or https URL without embedded credentials") + } + return parsed, nil +} + +func randomURLToken(size int) (string, error) { + buffer := make([]byte, size) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func parseExpiresIn(raw json.RawMessage) time.Duration { + if len(raw) == 0 { + return 0 + } + var seconds int64 + if err := json.Unmarshal(raw, &seconds); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + var value string + if err := json.Unmarshal(raw, &value); err == nil { + seconds, _ = strconv.ParseInt(value, 10, 64) + if seconds > 0 { + return time.Duration(seconds) * time.Second + } + } + return 0 +} diff --git a/server/internal/auth/mcpoauth/client_test.go b/server/internal/auth/mcpoauth/client_test.go new file mode 100644 index 00000000..92e06c18 --- /dev/null +++ b/server/internal/auth/mcpoauth/client_test.go @@ -0,0 +1,253 @@ +package mcpoauth + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestOAuthDiscoveryRegistrationExchangeAndRefresh(t *testing.T) { + var server *httptest.Server + var tokenGrantTypes []string + handler := http.NewServeMux() + handler.HandleFunc("/.well-known/oauth-protected-resource/mcp", func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "resource": server.URL + "/mcp", + "authorization_servers": []string{server.URL}, + "scopes_supported": []string{"openid", "offline_access"}, + }) + }) + handler.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + "registration_endpoint": server.URL + "/register", + "code_challenge_methods_supported": []string{"S256"}, + }) + }) + handler.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["token_endpoint_auth_method"] != "none" { + t.Fatalf("registration auth method = %v", body["token_endpoint_auth_method"]) + } + writeTestJSON(t, w, http.StatusCreated, map[string]any{ + "client_id": "client-1", + "token_endpoint_auth_method": "none", + }) + }) + handler.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + tokenGrantTypes = append(tokenGrantTypes, r.Form.Get("grant_type")) + if r.Form.Get("client_id") != "client-1" || r.Form.Get("resource") != server.URL+"/mcp" { + t.Fatalf("unexpected token form: %v", r.Form) + } + if r.Form.Get("grant_type") == "authorization_code" { + if r.Form.Get("code_verifier") == "" || r.Form.Get("code") != "code-1" { + t.Fatalf("missing PKCE exchange values: %v", r.Form) + } + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "access_token": "access-1", + "refresh_token": "refresh-1", + "token_type": "Bearer", + "expires_in": 3600, + }) + return + } + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "access_token": "access-2", + "token_type": "Bearer", + "expires_in": "7200", + }) + }) + server = httptest.NewTLSServer(handler) + defer server.Close() + + client := New(server.Client()) + now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) + client.now = func() time.Time { return now } + redirectURI := "http://127.0.0.1:18080/oauth/callback" + transaction, authorizeURL, err := client.Begin(t.Context(), server.URL+"/mcp", redirectURI) + if err != nil { + t.Fatal(err) + } + parsedAuthorizeURL, err := url.Parse(authorizeURL) + if err != nil { + t.Fatal(err) + } + query := parsedAuthorizeURL.Query() + if query.Get("client_id") != "client-1" || query.Get("redirect_uri") != redirectURI || query.Get("resource") != server.URL+"/mcp" || query.Get("scope") != "openid offline_access" { + t.Fatalf("unexpected authorize query: %v", query) + } + if query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") == "" || query.Get("state") == "" { + t.Fatalf("missing PKCE authorize query: %v", query) + } + + credential, err := client.Exchange(t.Context(), transaction, "code-1") + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "access-1" || credential.RefreshToken != "refresh-1" || !credential.ExpiresAt.Equal(now.Add(time.Hour)) { + t.Fatalf("unexpected credential: %+v", credential) + } + + refreshed, err := client.Refresh(t.Context(), credential) + if err != nil { + t.Fatal(err) + } + if refreshed.AccessToken != "access-2" || refreshed.RefreshToken != "refresh-1" || !refreshed.ExpiresAt.Equal(now.Add(2*time.Hour)) { + t.Fatalf("unexpected refreshed credential: %+v", refreshed) + } + if strings.Join(tokenGrantTypes, ",") != "authorization_code,refresh_token" { + t.Fatalf("grant types = %v", tokenGrantTypes) + } +} + +func TestOAuthDiscoveryFallsBackToOpenIDConfiguration(t *testing.T) { + var server *httptest.Server + handler := http.NewServeMux() + handler.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "resource": server.URL, + "authorization_servers": []string{server.URL}, + }) + }) + handler.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + }) + handler.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/authorize", + "token_endpoint": server.URL + "/token", + "registration_endpoint": server.URL + "/register", + "code_challenge_methods_supported": []string{"S256"}, + }) + }) + handler.HandleFunc("/register", func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(t, w, http.StatusCreated, map[string]any{ + "client_id": "openid-client", + "token_endpoint_auth_method": "none", + }) + }) + server = httptest.NewTLSServer(handler) + defer server.Close() + + transaction, authorizeURL, err := New(server.Client()).Begin( + t.Context(), + server.URL, + "http://127.0.0.1:18080/oauth/callback", + ) + if err != nil { + t.Fatal(err) + } + if transaction.ClientID != "openid-client" || !strings.HasPrefix(authorizeURL, server.URL+"/authorize?") { + t.Fatalf("transaction=%+v authorizeURL=%q", transaction, authorizeURL) + } +} + +func TestBeginRejectsNonHTTPSResource(t *testing.T) { + _, _, err := New(nil).Begin(t.Context(), "http://example.com/mcp", "http://127.0.0.1/callback") + if err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("error = %v", err) + } +} + +func TestProbeInitializesAndListsTools(t *testing.T) { + var calls []string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer access-1" { + t.Fatalf("authorization = %q", r.Header.Get("Authorization")) + } + var request struct { + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + calls = append(calls, request.Method) + switch request.Method { + case "initialize": + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Mcp-Session-Id", "session-1") + _, _ = w.Write([]byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\",\"serverInfo\":{\"name\":\"Notion\",\"version\":\"1.2.3\"}}}\n\n")) + case "notifications/initialized": + if r.Header.Get("Mcp-Session-Id") != "session-1" { + t.Fatalf("session id = %q", r.Header.Get("Mcp-Session-Id")) + } + w.WriteHeader(http.StatusAccepted) + case "tools/list": + writeTestJSON(t, w, http.StatusOK, map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "result": map[string]any{ + "tools": []map[string]any{{"name": "notion-search"}, {"name": "notion-fetch"}}, + }, + }) + default: + t.Fatalf("unexpected method %q", request.Method) + } + })) + defer server.Close() + + result, err := New(server.Client()).Probe(t.Context(), server.URL, "access-1") + if err != nil { + t.Fatal(err) + } + if result.ServerName != "Notion" || result.ServerVersion != "1.2.3" || result.ToolCount != 2 { + t.Fatalf("result = %+v", result) + } + if strings.Join(calls, ",") != "initialize,notifications/initialized,tools/list" { + t.Fatalf("calls = %v", calls) + } +} + +func TestProbeReportsRejectedAuthorization(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + _, err := New(server.Client()).Probe(t.Context(), server.URL, "revoked") + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("error = %v", err) + } +} + +func TestCredentialPayloadRoundTrip(t *testing.T) { + want := Credential{ + AccessToken: "access", + RefreshToken: "refresh", + TokenType: "Bearer", + ExpiresAt: time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC), + ClientID: "client", + TokenEndpointAuthMethod: "none", + TokenEndpoint: "https://example.com/token", + Resource: "https://example.com/mcp", + } + got, ok, err := CredentialFromPayload(want.Payload()) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || !got.ExpiresAt.Equal(want.ExpiresAt) { + t.Fatalf("got %+v, want %+v", got, want) + } +} + +func writeTestJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/auth/mcpoauth/credential.go b/server/internal/auth/mcpoauth/credential.go new file mode 100644 index 00000000..798f1a6a --- /dev/null +++ b/server/internal/auth/mcpoauth/credential.go @@ -0,0 +1,134 @@ +package mcpoauth + +import ( + "fmt" + "strings" + "time" +) + +const CredentialProvider = "mcp_oauth" + +const ( + VerificationVerified = "verified" + VerificationReconnectRequired = "reconnect_required" + VerificationUnavailable = "unavailable" +) + +type Verification struct { + Status string + CheckedAt time.Time + ErrorCode string + ProtocolVersion string + ServerName string + ServerVersion string + ToolCount int +} + +func (c Credential) Payload() map[string]any { + payload := map[string]any{ + "provider": CredentialProvider, + "access_token": c.AccessToken, + "refresh_token": c.RefreshToken, + "token_type": c.TokenType, + "scope": c.Scope, + "client_id": c.ClientID, + "client_secret": c.ClientSecret, + "token_endpoint_auth_method": c.TokenEndpointAuthMethod, + "token_endpoint": c.TokenEndpoint, + "resource": c.Resource, + } + if !c.ExpiresAt.IsZero() { + payload["expires_at"] = c.ExpiresAt.UTC().Format(time.RFC3339) + } + return payload +} + +func CredentialFromPayload(payload map[string]any) (Credential, bool, error) { + if stringValue(payload, "provider") != CredentialProvider { + return Credential{}, false, nil + } + credential := Credential{ + AccessToken: stringValue(payload, "access_token"), + RefreshToken: stringValue(payload, "refresh_token"), + TokenType: stringValue(payload, "token_type"), + Scope: stringValue(payload, "scope"), + ClientID: stringValue(payload, "client_id"), + ClientSecret: stringValue(payload, "client_secret"), + TokenEndpointAuthMethod: stringValue(payload, "token_endpoint_auth_method"), + TokenEndpoint: stringValue(payload, "token_endpoint"), + Resource: stringValue(payload, "resource"), + } + if raw := stringValue(payload, "expires_at"); raw != "" { + expiresAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + return Credential{}, true, fmt.Errorf("mcp oauth: parse expires_at: %w", err) + } + credential.ExpiresAt = expiresAt + } + if credential.AccessToken == "" || credential.ClientID == "" || credential.TokenEndpoint == "" || credential.Resource == "" { + return Credential{}, true, fmt.Errorf("mcp oauth: stored credential is incomplete") + } + return credential, true, nil +} + +func (c Credential) NeedsRefresh(now time.Time) bool { + return !c.ExpiresAt.IsZero() && !c.ExpiresAt.After(now.UTC().Add(time.Minute)) +} + +func ApplyVerification(payload map[string]any, verification Verification) { + payload["connection_status"] = verification.Status + payload["connection_error"] = verification.ErrorCode + payload["connection_protocol_version"] = verification.ProtocolVersion + payload["connection_server_name"] = verification.ServerName + payload["connection_server_version"] = verification.ServerVersion + payload["connection_tool_count"] = verification.ToolCount + if verification.CheckedAt.IsZero() { + delete(payload, "connection_checked_at") + } else { + payload["connection_checked_at"] = verification.CheckedAt.UTC().Format(time.RFC3339) + } +} + +func VerificationFromPayload(payload map[string]any) Verification { + verification := Verification{ + Status: stringValue(payload, "connection_status"), + ErrorCode: stringValue(payload, "connection_error"), + ProtocolVersion: stringValue(payload, "connection_protocol_version"), + ServerName: stringValue(payload, "connection_server_name"), + ServerVersion: stringValue(payload, "connection_server_version"), + } + if raw := stringValue(payload, "connection_checked_at"); raw != "" { + verification.CheckedAt, _ = time.Parse(time.RFC3339, raw) + } + switch value := payload["connection_tool_count"].(type) { + case int: + verification.ToolCount = value + case float64: + verification.ToolCount = int(value) + } + return verification +} + +// PreserveMetadata keeps non-token connector metadata when a refresh-token +// rotation replaces the OAuth payload. +func PreserveMetadata(source, target map[string]any) { + for _, key := range []string{ + "catalog_id", + "connection_status", + "connection_error", + "connection_protocol_version", + "connection_server_name", + "connection_server_version", + "connection_tool_count", + "connection_checked_at", + } { + if value, ok := source[key]; ok { + target[key] = value + } + } +} + +func stringValue(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} diff --git a/server/internal/capability/canonical/mcp.go b/server/internal/capability/canonical/mcp.go index a32622bf..3295f7a9 100644 --- a/server/internal/capability/canonical/mcp.go +++ b/server/internal/capability/canonical/mcp.go @@ -26,6 +26,7 @@ type MCPServer struct { Name string `json:"name"` Transport string `json:"transport,omitempty"` URL string `json:"url,omitempty"` + Headers map[string]EnvValue `json:"headers,omitempty"` Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]EnvValue `json:"env,omitempty"` @@ -74,6 +75,9 @@ func (s MCPServer) Validate() error { if strings.TrimSpace(s.URL) != "" { return fmt.Errorf("%w: server %q: stdio transport must not set url", ErrInvalidMCP, s.Name) } + if len(s.Headers) > 0 { + return fmt.Errorf("%w: server %q: stdio transport must not set headers", ErrInvalidMCP, s.Name) + } case MCPTransportStreamableHTTP: parsed, err := url.Parse(strings.TrimSpace(s.URL)) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { @@ -93,5 +97,16 @@ func (s MCPServer) Validate() error { return fmt.Errorf("server %q env %q: %w", s.Name, name, err) } } + for name, value := range s.Headers { + if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n") { + return fmt.Errorf("%w: server %q: invalid header name", ErrInvalidMCP, s.Name) + } + if value.Mode == EnvModeInlineSecret { + return fmt.Errorf("%w: server %q header %q: inline_secret is not supported", ErrInvalidMCP, s.Name, name) + } + if err := value.Validate(); err != nil { + return fmt.Errorf("server %q header %q: %w", s.Name, name, err) + } + } return nil } diff --git a/server/internal/capability/canonical/spec.go b/server/internal/capability/canonical/spec.go index cc23f9fe..c4bda2aa 100644 --- a/server/internal/capability/canonical/spec.go +++ b/server/internal/capability/canonical/spec.go @@ -176,6 +176,11 @@ const ( type EnvValue struct { Mode EnvMode `json:"mode"` + // Prefix is prepended only when a secret or credential placeholder is + // materialized. Remote MCP Authorization headers use "Bearer " without + // persisting the token in canonical_spec. + Prefix string `json:"prefix,omitempty"` + // Literal is set iff Mode == EnvModeLiteral. Literal string `json:"literal,omitempty"` @@ -191,8 +196,8 @@ type EnvValue struct { func (v EnvValue) Validate() error { switch v.Mode { case EnvModeLiteral: - if v.SecretID != "" || v.CredentialKindCode != "" { - return fmt.Errorf("%w: literal mode must not set secret_id/credential_kind_code", ErrInvalidEnvValue) + if v.SecretID != "" || v.CredentialKindCode != "" || v.Prefix != "" { + return fmt.Errorf("%w: literal mode must not set secret_id/credential_kind_code/prefix", ErrInvalidEnvValue) } case EnvModeInlineSecret: if strings.TrimSpace(v.SecretID) == "" { diff --git a/server/internal/capability/render/claudecode.go b/server/internal/capability/render/claudecode.go index ed0b04d9..b1760ca2 100644 --- a/server/internal/capability/render/claudecode.go +++ b/server/internal/capability/render/claudecode.go @@ -30,6 +30,7 @@ type claudeCodeMCPDocument struct { type claudeCodeMCPServer struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -76,7 +77,11 @@ func renderClaudeCodeMCP(s *canonical.MCPSpec) (Output, error) { doc := claudeCodeMCPDocument{MCPServers: make(map[string]claudeCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { - doc.MCPServers[srv.Name] = claudeCodeMCPServer{Type: "http", URL: srv.URL} + headers, err := renderEnvMap(srv.Headers) + if err != nil { + return Output{}, fmt.Errorf("claude code render: server %q headers: %w", srv.Name, err) + } + doc.MCPServers[srv.Name] = claudeCodeMCPServer{Type: "http", URL: srv.URL, Headers: headers} continue } env, err := renderEnvMap(srv.Env) diff --git a/server/internal/capability/render/codex.go b/server/internal/capability/render/codex.go index 109d8408..7d9f0e5f 100644 --- a/server/internal/capability/render/codex.go +++ b/server/internal/capability/render/codex.go @@ -38,6 +38,7 @@ type codexMCPDocument struct { type codexMCPServer struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -72,7 +73,11 @@ func renderCodexMCP(s *canonical.MCPSpec) (Output, error) { doc := codexMCPDocument{MCPServers: make(map[string]codexMCPServer, len(s.Servers))} for _, srv := range s.Servers { if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { - doc.MCPServers[srv.Name] = codexMCPServer{Type: "http", URL: srv.URL} + headers, err := renderEnvMap(srv.Headers) + if err != nil { + return Output{}, fmt.Errorf("codex render: server %q headers: %w", srv.Name, err) + } + doc.MCPServers[srv.Name] = codexMCPServer{Type: "http", URL: srv.URL, Headers: headers} continue } env, err := renderEnvMap(srv.Env) diff --git a/server/internal/capability/render/opencode.go b/server/internal/capability/render/opencode.go index a1b5799c..ba61a3cc 100644 --- a/server/internal/capability/render/opencode.go +++ b/server/internal/capability/render/opencode.go @@ -27,6 +27,7 @@ type openCodeMCPDocument struct { type openCodeMCPServer struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -61,7 +62,11 @@ func renderOpenCodeMCP(s *canonical.MCPSpec) (Output, error) { doc := openCodeMCPDocument{MCPServers: make(map[string]openCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { - doc.MCPServers[srv.Name] = openCodeMCPServer{Type: "remote", URL: srv.URL, Enabled: true} + headers, err := renderEnvMap(srv.Headers) + if err != nil { + return Output{}, fmt.Errorf("opencode render: server %q headers: %w", srv.Name, err) + } + doc.MCPServers[srv.Name] = openCodeMCPServer{Type: "remote", URL: srv.URL, Headers: headers, Enabled: true} continue } env, err := renderEnvMap(srv.Env) diff --git a/server/internal/capability/render/placeholders.go b/server/internal/capability/render/placeholders.go index 98ef1a78..4af72aa4 100644 --- a/server/internal/capability/render/placeholders.go +++ b/server/internal/capability/render/placeholders.go @@ -15,9 +15,9 @@ func envValueToString(v canonical.EnvValue) (string, error) { case canonical.EnvModeLiteral: return v.Literal, nil case canonical.EnvModeInlineSecret: - return fmt.Sprintf("${PARSAR_SECRET:%s}", v.SecretID), nil + return v.Prefix + fmt.Sprintf("${PARSAR_SECRET:%s}", v.SecretID), nil case canonical.EnvModeCredentialRef: - return fmt.Sprintf("${PARSAR_CREDENTIAL:%s}", v.CredentialKindCode), nil + return v.Prefix + fmt.Sprintf("${PARSAR_CREDENTIAL:%s}", v.CredentialKindCode), nil default: return "", fmt.Errorf("render: unknown env mode %q", v.Mode) } diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index 46a968a0..508d23e2 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -10,9 +10,11 @@ import ( "strings" "time" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/render" "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" ) @@ -28,6 +30,10 @@ type CapabilityRuntimeStore interface { IsBuiltinCapabilityEnabled(ctx context.Context, agentID, key string) (bool, error) } +type workspaceOAuthCredentialLister interface { + ListSecrets(ctx context.Context, workspaceID string, limit int32) ([]store.SecretRead, error) +} + // OSSPresigner is the narrow surface for the object-storage backend: // given a capability's oss_key, return a short-lived presigned GET URL // the daemon will fetch. Used for both plugin and skill zip downloads. @@ -73,8 +79,9 @@ type ResolvedSkill struct { SHA256 string `json:"sha256"` } -// credentialPlaceholderRe matches ${PARSAR_CREDENTIAL:}. -var credentialPlaceholderRe = regexp.MustCompile(`^\$\{PARSAR_CREDENTIAL:([a-zA-Z0-9_]+)\}$`) +// credentialPlaceholderRe matches one credential placeholder inside a value. +// Prefixes such as "Bearer " are part of the canonical value and stay public. +var credentialPlaceholderRe = regexp.MustCompile(`\$\{PARSAR_CREDENTIAL:([a-zA-Z0-9_]+)\}`) // capabilityAdditions holds the results of resolveCapabilityAdditions. type capabilityAdditions struct { @@ -158,6 +165,10 @@ type CapabilitySystemMessageStore interface { // on this. const CapabilityCredentialMissing = "capability_credential_missing" +// CapabilityUnsupported is emitted when the selected agent runtime cannot +// render a capability kind, such as a Skill bound to a Codex agent. +const CapabilityUnsupported = "capability_unsupported" + // CapabilityVersionUnavailable is the metadata.sub_kind value emitted // when the resolved capability version has no usable storage // breadcrumb (empty oss_key/sha256). This happens after a schema-level @@ -200,12 +211,13 @@ func agentKindToRenderTarget(agentKind string) render.Target { // when a renderer returns render.ErrUnsupported — e.g. an opencode/codex // agent enabling a skill or plugin capability. MissingCredentials is left // empty; emitDisabledCapabilityNotices treats that as a "non-credential" -// disable and posts a generic notice. +// disable and posts a typed notice. func disabledForUnsupportedCapability(cap store.EnabledCapabilityRead) DisabledCapability { return DisabledCapability{ CapabilityID: cap.CapabilityID, CapabilityVersionID: cap.CapabilityVersionID, CapabilityName: cap.Name, + SubKind: CapabilityUnsupported, } } @@ -602,6 +614,17 @@ func (c *Connector) resolveMCPCapability( for name, server := range parsed.MCPServers { if server.URL != "" { entry := map[string]any{"url": server.URL} + headers := make(map[string]string, len(server.Headers)) + for headerName, value := range server.Headers { + resolved, err := substituteCredentialPlaceholders(value, credentialValues) + if err != nil { + return nil, nil, nil, fmt.Errorf("agent_daemon: capability %s mcp server %q header %s: %w", cap.CapabilityID, name, headerName, err) + } + headers[headerName] = resolved + } + if len(headers) > 0 { + entry["headers"] = headers + } if server.Type != "" { entry["type"] = server.Type } @@ -613,17 +636,11 @@ func (c *Connector) resolveMCPCapability( } env := map[string]string{} for key, value := range server.Env { - if match := credentialPlaceholderRe.FindStringSubmatch(value); match != nil { - kind := match[1] - credValue, ok := credentialValues[kind] - if !ok { - return nil, nil, nil, fmt.Errorf("agent_daemon: capability %s mcp server %q env %s references unresolved credential kind %q", - cap.CapabilityID, name, key, kind) - } - env[key] = credValue - continue + resolved, err := substituteCredentialPlaceholders(value, credentialValues) + if err != nil { + return nil, nil, nil, fmt.Errorf("agent_daemon: capability %s mcp server %q env %s: %w", cap.CapabilityID, name, key, err) } - env[key] = value + env[key] = resolved } entry := map[string]any{ "command": server.Command, @@ -709,6 +726,14 @@ func (c *Connector) resolveCredentialValues( return nil, nil, nil, fmt.Errorf("agent_daemon: capability %s requires credentials but secrets service is not configured", cap.CapabilityID) } + // Workspace OAuth credentials are shared automatically. A member completes + // OAuth once, then every Agent in the workspace can use that connector + // without an additional per-Agent binding or per-user authorization. + automaticShared, err := c.workspaceOAuthCredentialIDs(ctx, in.WorkspaceID, cap.RequiredCredentials) + if err != nil { + return nil, nil, nil, fmt.Errorf("agent_daemon: capability %s list workspace OAuth credentials: %w", cap.CapabilityID, err) + } + // First pass: split required kinds into personal vs shared. type sharedTarget struct { Kind string @@ -723,6 +748,10 @@ func (c *Connector) resolveCredentialValues( sharedTargets = append(sharedTargets, sharedTarget{Kind: rc.Kind, SecretID: b.SecretID}) continue } + if secretID := automaticShared[rc.Kind]; secretID != "" { + sharedTargets = append(sharedTargets, sharedTarget{Kind: rc.Kind, SecretID: secretID}) + continue + } personalKinds = append(personalKinds, rc) } @@ -754,6 +783,10 @@ func (c *Connector) resolveCredentialValues( if err != nil { return nil, nil, nil, fmt.Errorf("agent_daemon: decrypt shared credential kind=%s secret_id=%s: %w", st.Kind, st.SecretID, err) } + payload, err = c.refreshSharedOAuthCredentialIfNeeded(ctx, in.WorkspaceID, st.SecretID, payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("agent_daemon: refresh shared credential kind=%s secret_id=%s: %w", st.Kind, st.SecretID, err) + } value := credentialPayloadValue(payload) if value == "" { return nil, nil, nil, fmt.Errorf("agent_daemon: shared credential kind=%s secret_id=%s decrypted payload has no token/api_key value", st.Kind, st.SecretID) @@ -785,6 +818,10 @@ func (c *Connector) resolveCredentialValues( if err != nil { return nil, nil, nil, fmt.Errorf("agent_daemon: decrypt credential kind=%s user_credential_id=%s: %w", kind, cred.ID, err) } + payload, err = c.refreshOAuthCredentialIfNeeded(ctx, cred, payload, credentialCache) + if err != nil { + return nil, nil, nil, fmt.Errorf("agent_daemon: refresh credential kind=%s user_credential_id=%s: %w", kind, cred.ID, err) + } value := credentialPayloadValue(payload) if value == "" { return nil, nil, nil, fmt.Errorf("agent_daemon: credential kind=%s user_credential_id=%s decrypted payload has no token/api_key value", kind, cred.ID) @@ -795,6 +832,144 @@ func (c *Connector) resolveCredentialValues( return values, sharedSecretIDs, missing, nil } +func (c *Connector) workspaceOAuthCredentialIDs( + ctx context.Context, + workspaceID string, + required []store.RequiredCredential, +) (map[string]string, error) { + result := map[string]string{} + if len(required) == 0 || strings.TrimSpace(workspaceID) == "" { + return result, nil + } + lister, ok := c.capabilities.(workspaceOAuthCredentialLister) + if !ok { + return result, nil + } + wanted := make(map[string]struct{}, len(required)) + for _, credential := range required { + wanted[strings.TrimSpace(credential.Kind)] = struct{}{} + } + workspaceSecrets, err := lister.ListSecrets(ctx, workspaceID, 1000) + if err != nil { + return nil, err + } + for _, candidate := range workspaceSecrets { + if candidate.Kind != "capability_inline" || candidate.AuthType != "oauth2" || candidate.Status != "active" { + continue + } + candidateWorkspaceID, _ := candidate.Metadata["workspace_id"].(string) + if strings.TrimSpace(candidateWorkspaceID) != strings.TrimSpace(workspaceID) { + continue + } + kind, _ := candidate.Metadata["credential_kind_code"].(string) + kind = strings.TrimSpace(kind) + if _, needed := wanted[kind]; !needed || result[kind] != "" { + continue + } + catalogID, _ := candidate.Metadata["catalog_id"].(string) + if strings.TrimSpace(catalogID) == "" { + continue + } + result[kind] = candidate.ID + } + return result, nil +} + +type oauthCredentialStore interface { + UpdateUserCredential(ctx context.Context, input store.UpdateUserCredentialInput) (store.UserCredentialRead, error) +} + +type sharedOAuthCredentialStore interface { + UpdateSecretPayload(ctx context.Context, workspaceID, secretID string, encryptedPayload []byte) (store.SecretPayload, error) +} + +func (c *Connector) refreshSharedOAuthCredentialIfNeeded( + ctx context.Context, + workspaceID string, + secretID string, + payload map[string]any, +) (map[string]any, error) { + oauthCredential, isOAuth, err := mcpoauth.CredentialFromPayload(payload) + if err != nil || !isOAuth || !oauthCredential.NeedsRefresh(time.Now()) { + return payload, err + } + credentialStore, ok := c.modelResolver.(sharedOAuthCredentialStore) + if !ok { + return nil, errors.New("shared credential store does not support OAuth token rotation") + } + refreshed, err := mcpoauth.New(nil).Refresh(ctx, oauthCredential) + if err != nil { + return nil, err + } + refreshedPayload := refreshed.Payload() + mcpoauth.PreserveMetadata(payload, refreshedPayload) + encrypted, err := c.secrets.Encrypt(refreshedPayload) + if err != nil { + return nil, fmt.Errorf("encrypt refreshed token: %w", err) + } + if _, err := credentialStore.UpdateSecretPayload(ctx, workspaceID, secretID, encrypted); err != nil { + return nil, fmt.Errorf("persist refreshed token: %w", err) + } + return refreshedPayload, nil +} + +func (c *Connector) refreshOAuthCredentialIfNeeded( + ctx context.Context, + credential store.UserCredentialRead, + payload map[string]any, + credentialCache map[string]store.UserCredentialRead, +) (map[string]any, error) { + oauthCredential, isOAuth, err := mcpoauth.CredentialFromPayload(payload) + if err != nil || !isOAuth || !oauthCredential.NeedsRefresh(time.Now()) { + return payload, err + } + credentialStore, ok := c.capabilities.(oauthCredentialStore) + if !ok { + return nil, errors.New("credential store does not support OAuth token rotation") + } + refreshed, err := mcpoauth.New(nil).Refresh(ctx, oauthCredential) + if err != nil { + return nil, err + } + refreshedPayload := refreshed.Payload() + mcpoauth.PreserveMetadata(payload, refreshedPayload) + encrypted, err := c.secrets.Encrypt(refreshedPayload) + if err != nil { + return nil, fmt.Errorf("encrypt refreshed token: %w", err) + } + updated, err := credentialStore.UpdateUserCredential(ctx, store.UpdateUserCredentialInput{ + CredentialID: credential.ID, + EncryptedValue: encrypted, + KeyVersion: secrets.EnvelopeVersion, + }) + if err != nil { + return nil, fmt.Errorf("persist refreshed token: %w", err) + } + for cacheKey, cached := range credentialCache { + if cached.ID == credential.ID { + credentialCache[cacheKey] = updated + } + } + return refreshedPayload, nil +} + +func substituteCredentialPlaceholders(value string, credentials map[string]string) (string, error) { + matches := credentialPlaceholderRe.FindAllStringSubmatch(value, -1) + if len(matches) == 0 { + return value, nil + } + resolved := value + for _, match := range matches { + kind := match[1] + credential, ok := credentials[kind] + if !ok { + return "", fmt.Errorf("references unresolved credential kind %q", kind) + } + resolved = strings.ReplaceAll(resolved, match[0], credential) + } + return resolved, nil +} + // lookupCredential fetches a user credential by kind, using a cache to // avoid duplicate DB lookups within a single resolution pass. Cache key // is (user_id, kind). @@ -861,6 +1036,7 @@ type claudeCodeMCPDocument struct { type claudeCodeMCPServerEntry struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` diff --git a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go index 169b7c86..c3367f9c 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go @@ -205,7 +205,7 @@ func TestResolveCapabilityAdditions_EmptyAgentKindDefaultsClaudeCode(t *testing. // production buildAgentOptions entry — not just resolveCapabilityAdditions // directly — to confirm that an unsupported-by-agent-kind capability // flows all the way through emitDisabledCapabilityNotices and lands in -// the SystemMessages sink as a CapabilityCredentialMissing notice. +// the SystemMessages sink as a CapabilityUnsupported notice. // // This is the contract the channel layer relies on: a codex agent // enabling a skill capability must produce a user-visible nudge @@ -241,8 +241,8 @@ func TestBuildAgentOptions_CodexSkillSurfacesDisabledNotice(t *testing.T) { t.Fatalf("expected exactly 1 runtime_error notice for the disabled skill, got %d: %+v", len(sm.runtimeErrors), sm.runtimeErrors) } notice := sm.runtimeErrors[0] - if notice.SubKind != CapabilityCredentialMissing { - t.Errorf("SubKind = %q, want %q", notice.SubKind, CapabilityCredentialMissing) + if notice.SubKind != CapabilityUnsupported { + t.Errorf("SubKind = %q, want %q", notice.SubKind, CapabilityUnsupported) } if notice.CapabilityID != "skill-a" { t.Errorf("CapabilityID = %q, want skill-a", notice.CapabilityID) diff --git a/server/internal/connector/agentdaemon/capability_runtime_test.go b/server/internal/connector/agentdaemon/capability_runtime_test.go index f3e75f5c..a80d9aa9 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_test.go @@ -6,8 +6,12 @@ import ( "errors" "io" "log/slog" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth" "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" @@ -22,6 +26,7 @@ type stubCapabilityStore struct { rows []store.EnabledCapabilityRead err error credentials map[string]store.UserCredentialRead // key = "userID:kind" + secrets []store.SecretRead // builtinDisabled maps capability_key -> true when the built-in should // report as OFF. Absence => default ON (mirrors the store's no-row @@ -53,6 +58,10 @@ func (s stubCapabilityStore) IsBuiltinCapabilityEnabled(_ context.Context, _, ke return true, nil } +func (s stubCapabilityStore) ListSecrets(_ context.Context, _ string, _ int32) ([]store.SecretRead, error) { + return append([]store.SecretRead(nil), s.secrets...), nil +} + // --------------------------------------------------------------------------- // test helpers // --------------------------------------------------------------------------- @@ -447,6 +456,190 @@ func TestResolveCapabilityAdditions_MCPStreamableHTTP(t *testing.T) { } } +func TestResolveCapabilityAdditions_MCPStreamableHTTPWithOAuthHeader(t *testing.T) { + svc := testSecretsService(t) + oauthCredential := mcpoauth.Credential{ + AccessToken: "notion-access-token", + RefreshToken: "notion-refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "client-1", + TokenEndpointAuthMethod: "none", + TokenEndpoint: "https://mcp.notion.com/token", + Resource: "https://mcp.notion.com/mcp", + } + ciphertext := encryptPayload(t, svc, oauthCredential.Payload()) + row := newMCPRow(t, "mcp-notion", "Notion", []canonical.MCPServer{{ + Name: "notion", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://mcp.notion.com/mcp", + Headers: map[string]canonical.EnvValue{ + "Authorization": { + Mode: canonical.EnvModeCredentialRef, + Prefix: "Bearer ", + CredentialKindCode: "notion_mcp_oauth", + }, + }, + }}, []store.RequiredCredential{{Kind: "notion_mcp_oauth", Required: true}}) + c := &Connector{ + capabilities: stubCapabilityStore{ + rows: []store.EnabledCapabilityRead{row}, + credentials: map[string]store.UserCredentialRead{ + defaultPromptInput().ConversationInitiatorID + ":notion_mcp_oauth": { + ID: "credential-1", + Kind: "notion_mcp_oauth", + Ciphertext: ciphertext, + }, + }, + }, + secrets: svc, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + server := got.MCPServers["notion"].(map[string]any) + headers := server["headers"].(map[string]string) + if headers["Authorization"] != "Bearer notion-access-token" { + t.Fatalf("Authorization = %q", headers["Authorization"]) + } +} + +func TestResolveCapabilityAdditions_UsesWorkspaceOAuthWithoutAgentBinding(t *testing.T) { + svc := testSecretsService(t) + oauthCredential := mcpoauth.Credential{ + AccessToken: "workspace-notion-token", + RefreshToken: "workspace-notion-refresh", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "client-1", + TokenEndpointAuthMethod: "none", + TokenEndpoint: "https://mcp.notion.com/token", + Resource: "https://mcp.notion.com/mcp", + } + row := newMCPRow(t, "mcp-notion", "Notion", []canonical.MCPServer{{ + Name: "notion", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://mcp.notion.com/mcp", + Headers: map[string]canonical.EnvValue{ + "Authorization": { + Mode: canonical.EnvModeCredentialRef, + Prefix: "Bearer ", + CredentialKindCode: "notion_mcp_oauth", + }, + }, + }}, []store.RequiredCredential{{Kind: "notion_mcp_oauth", Required: true}}) + resolver := &fakeModelResolver{secret: store.SecretPayload{ + SecretRead: store.SecretRead{ID: "secret-1", Status: "active"}, + EncryptedPayload: encryptPayload(t, svc, oauthCredential.Payload()), + }} + c := &Connector{ + capabilities: stubCapabilityStore{ + rows: []store.EnabledCapabilityRead{row}, + secrets: []store.SecretRead{{ + ID: "secret-1", + Kind: "capability_inline", + AuthType: "oauth2", + Status: "active", + Metadata: map[string]any{ + "workspace_id": "ws-1", + "catalog_id": "notion", + "credential_kind_code": "notion_mcp_oauth", + }, + }}, + }, + modelResolver: resolver, + secrets: svc, + log: discardLogger(), + } + in := defaultPromptInput() + in.ConversationInitiatorID = "" + got, err := c.resolveCapabilityAdditions(context.Background(), in, "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + headers := got.MCPServers["notion"].(map[string]any)["headers"].(map[string]string) + if headers["Authorization"] != "Bearer workspace-notion-token" { + t.Fatalf("Authorization=%q", headers["Authorization"]) + } + if len(got.Disabled) != 0 { + t.Fatalf("disabled=%+v", got.Disabled) + } +} + +func TestResolveCapabilityAdditions_RefreshesSharedOAuthCredential(t *testing.T) { + tokenServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "shared-refresh-token" { + t.Fatalf("refresh form=%v", r.Form) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"shared-new-token","refresh_token":"shared-refresh-token","token_type":"Bearer","expires_in":3600}`)) + })) + defer tokenServer.Close() + previousTransport := http.DefaultTransport + http.DefaultTransport = tokenServer.Client().Transport + t.Cleanup(func() { http.DefaultTransport = previousTransport }) + + svc := testSecretsService(t) + expired := mcpoauth.Credential{ + AccessToken: "shared-old-token", + RefreshToken: "shared-refresh-token", + ExpiresAt: time.Now().Add(-time.Minute), + ClientID: "client-1", + TokenEndpointAuthMethod: "none", + TokenEndpoint: tokenServer.URL, + Resource: "https://mcp.notion.com/mcp", + } + resolver := &fakeModelResolver{secret: store.SecretPayload{ + SecretRead: store.SecretRead{ID: "secret-1", Status: "active"}, + EncryptedPayload: encryptPayload(t, svc, expired.Payload()), + }} + row := newMCPRow(t, "mcp-notion", "Notion", []canonical.MCPServer{{ + Name: "notion", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://mcp.notion.com/mcp", + Headers: map[string]canonical.EnvValue{ + "Authorization": { + Mode: canonical.EnvModeCredentialRef, + Prefix: "Bearer ", + CredentialKindCode: "notion_mcp_oauth", + }, + }, + }}, []store.RequiredCredential{{Kind: "notion_mcp_oauth", Required: true}}) + in := defaultPromptInput() + in.AgentConfig = map[string]any{ + "credential_bindings": map[string]any{ + "notion_mcp_oauth": map[string]any{"source": "shared", "secret_id": "secret-1"}, + }, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + modelResolver: resolver, + secrets: svc, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), in, "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + headers := got.MCPServers["notion"].(map[string]any)["headers"].(map[string]string) + if headers["Authorization"] != "Bearer shared-new-token" { + t.Fatalf("Authorization=%q", headers["Authorization"]) + } + if resolver.updatedSecretID != "secret-1" { + t.Fatalf("updated secret=%q", resolver.updatedSecretID) + } + payload, err := svc.Decrypt(resolver.updatedPayload) + if err != nil { + t.Fatal(err) + } + if payload["access_token"] != "shared-new-token" { + t.Fatalf("payload=%+v", payload) + } +} + func TestResolveCapabilityAdditions_MCPWithCredential(t *testing.T) { svc := testSecretsService(t) ciphertext := encryptPayload(t, svc, map[string]any{"token": "ghp_realtoken123"}) diff --git a/server/internal/connector/agentdaemon/model_injection_test.go b/server/internal/connector/agentdaemon/model_injection_test.go index 499bcf39..7d0ed419 100644 --- a/server/internal/connector/agentdaemon/model_injection_test.go +++ b/server/internal/connector/agentdaemon/model_injection_test.go @@ -30,6 +30,8 @@ type fakeModelResolver struct { // resolver is never invoked when an agent has a workspace secret. resolveCalls int resolveUserCalls int + updatedSecretID string + updatedPayload []byte } func (f *fakeModelResolver) ResolveModelRuntime(_ context.Context, _, _ string) (store.ModelRuntime, error) { @@ -67,6 +69,14 @@ func (f *fakeModelResolver) GetSecretPayload(_ context.Context, _, _ string) (st return f.secret, nil } +func (f *fakeModelResolver) UpdateSecretPayload(_ context.Context, _, secretID string, encryptedPayload []byte) (store.SecretPayload, error) { + f.updatedSecretID = secretID + f.updatedPayload = encryptedPayload + f.secret.EncryptedPayload = encryptedPayload + f.secret.Status = "active" + return f.secret, nil +} + func TestStreamPrompt_ManagedAnthropicModelInjection(t *testing.T) { const masterKey = "test-master-key" svc, err := secrets.New(masterKey) diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index a021d26c..ede7e6c0 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -1963,9 +1963,9 @@ order by created_at desc, id desc limit @item_limit; -- name: CreateSecret :one --- Organization-level shared secret. slug is supplied by the caller --- (via generateAutoSlug("secret")); name is the display name and --- may repeat. +-- Shared secret. capability_inline rows may carry metadata.workspace_id; +-- legacy and infrastructure kinds remain organization-wide. slug is supplied +-- by the caller (via generateAutoSlug("secret")); name may repeat. insert into secrets( id, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_by, created_at, updated_at ) @@ -1984,12 +1984,32 @@ where (@kind_filter::text = '' or kind = @kind_filter::text) order by created_at desc, id desc limit @item_limit; +-- name: ListSecretsForWorkspace :many +-- Workspace-scoped capability credentials are stamped in metadata. Legacy +-- secrets without workspace_id remain organization-wide for compatibility. +select id::text, slug, name, kind, provider, auth_type, key_version, status, metadata, created_at, updated_at +from secrets +where (coalesce(metadata->>'workspace_id', '') = '' or metadata->>'workspace_id' = @workspace_id::text) + and deleted_at is null +order by created_at desc, id desc +limit @item_limit; + -- name: GetSecretPayload :one select id::text, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_at, updated_at from secrets where id = @id::uuid and deleted_at is null; +-- name: UpdateSecretPayload :one +update secrets +set encrypted_payload = @encrypted_payload::jsonb, + key_version = @key_version, + status = 'active', + updated_at = @now +where id = @id::uuid + and deleted_at is null +returning id::text, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_at, updated_at; + -- name: ResolveSlackBotSecretByTeam :one -- Resolve the active Slack bot-token secret for a workspace, keyed by the -- Slack team_id stamped in metadata at install time. kind='slack_bot' is a diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index d7b04318..616d3d42 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -2751,9 +2751,9 @@ type CreateSecretRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -// Organization-level shared secret. slug is supplied by the caller -// (via generateAutoSlug("secret")); name is the display name and -// may repeat. +// Shared secret. capability_inline rows may carry metadata.workspace_id; +// legacy and infrastructure kinds remain organization-wide. slug is supplied +// by the caller (via generateAutoSlug("secret")); name may repeat. func (q *Queries) CreateSecret(ctx context.Context, arg CreateSecretParams) (CreateSecretRow, error) { row := q.db.QueryRow(ctx, createSecret, arg.ID, @@ -9025,6 +9025,68 @@ func (q *Queries) ListSecrets(ctx context.Context, arg ListSecretsParams) ([]Lis return items, nil } +const listSecretsForWorkspace = `-- name: ListSecretsForWorkspace :many +select id::text, slug, name, kind, provider, auth_type, key_version, status, metadata, created_at, updated_at +from secrets +where (coalesce(metadata->>'workspace_id', '') = '' or metadata->>'workspace_id' = $1::text) + and deleted_at is null +order by created_at desc, id desc +limit $2 +` + +type ListSecretsForWorkspaceParams struct { + WorkspaceID string `json:"workspace_id"` + ItemLimit int32 `json:"item_limit"` +} + +type ListSecretsForWorkspaceRow struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Kind string `json:"kind"` + Provider string `json:"provider"` + AuthType string `json:"auth_type"` + KeyVersion string `json:"key_version"` + Status string `json:"status"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +// Workspace-scoped capability credentials are stamped in metadata. Legacy +// secrets without workspace_id remain organization-wide for compatibility. +func (q *Queries) ListSecretsForWorkspace(ctx context.Context, arg ListSecretsForWorkspaceParams) ([]ListSecretsForWorkspaceRow, error) { + rows, err := q.db.Query(ctx, listSecretsForWorkspace, arg.WorkspaceID, arg.ItemLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSecretsForWorkspaceRow{} + for rows.Next() { + var i ListSecretsForWorkspaceRow + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Kind, + &i.Provider, + &i.AuthType, + &i.KeyVersion, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listStaleFeishuPermissionInflightCards = `-- name: ListStaleFeishuPermissionInflightCards :many select id::text as conversation_id, workspace_id::text as workspace_id, @@ -12420,6 +12482,64 @@ func (q *Queries) UpdateScheduledTask(ctx context.Context, arg UpdateScheduledTa return i, err } +const updateSecretPayload = `-- name: UpdateSecretPayload :one +update secrets +set encrypted_payload = $1::jsonb, + key_version = $2, + status = 'active', + updated_at = $3 +where id = $4::uuid + and deleted_at is null +returning id::text, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_at, updated_at +` + +type UpdateSecretPayloadParams struct { + EncryptedPayload []byte `json:"encrypted_payload"` + KeyVersion string `json:"key_version"` + Now pgtype.Timestamptz `json:"now"` + ID pgtype.UUID `json:"id"` +} + +type UpdateSecretPayloadRow struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Kind string `json:"kind"` + Provider string `json:"provider"` + AuthType string `json:"auth_type"` + EncryptedPayload []byte `json:"encrypted_payload"` + KeyVersion string `json:"key_version"` + Status string `json:"status"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateSecretPayload(ctx context.Context, arg UpdateSecretPayloadParams) (UpdateSecretPayloadRow, error) { + row := q.db.QueryRow(ctx, updateSecretPayload, + arg.EncryptedPayload, + arg.KeyVersion, + arg.Now, + arg.ID, + ) + var i UpdateSecretPayloadRow + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Kind, + &i.Provider, + &i.AuthType, + &i.EncryptedPayload, + &i.KeyVersion, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const updateUserCredential = `-- name: UpdateUserCredential :one update user_credentials set display_name = $1, diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go index f5f46608..3771f7ae 100644 --- a/server/internal/dev/routes_agents.go +++ b/server/internal/dev/routes_agents.go @@ -319,8 +319,8 @@ func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandbo } // Materialise any inline_new_secrets the user pasted in step 3. - // Each one becomes a capability_inline secret in the org-global - // catalog; its id is then patched into the corresponding + // Each one becomes a workspace-scoped capability_inline secret; + // its id is then patched into the corresponding // credential_bindings entry (or model_credential_binding when // IsModel=true) inside req.Config so CreateAgent persists a // fully-resolved binding map. Failure here is fatal — the agent diff --git a/server/internal/mcpcatalog/catalog_test.go b/server/internal/mcpcatalog/catalog_test.go index 06bda87c..f03cff91 100644 --- a/server/internal/mcpcatalog/catalog_test.go +++ b/server/internal/mcpcatalog/catalog_test.go @@ -3,12 +3,8 @@ package mcpcatalog import ( "context" "encoding/json" - "net/http" - "net/http/httptest" "strings" - "sync/atomic" "testing" - "time" ) func TestBuiltinCatalogLoads(t *testing.T) { @@ -31,98 +27,52 @@ func TestBuiltinCatalogContainsCuratedConnectors(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - want := map[string]string{ - "filesystem": "2026.7.10", - "playwright": "0.0.78", - "context7": "3.2.4", - "fetch": "2026.7.10", - "git": "2026.7.10", - "memory": "2026.7.4", - "time": "2026.7.10", - "sequential-thinking": "2026.7.4", - "everything": "2026.7.4", - "cloudflare-docs": "0.4.9", - "microsoft-learn": "1.0.0", - "aws-knowledge": "1.0.0", - "deepwiki": "2.14.3", - "agent-web": "0.2.1", - "arxiv": "1.2.15", - "pubmed": "2.9.8", - "us-weather": "0.7.2", - "mdn-search": "0.1.0", - "npm-registry": "0.1.0", - "docker-hub": "0.1.0", - "wikipedia": "0.1.0", + want := map[string]struct { + version string + credentialKind string + }{ + "context7": {version: "3.2.3"}, + "exa": {version: "3.2.1"}, + "firecrawl": {version: "3.22.4"}, + "postman": {version: "1.0.0", credentialKind: "postman_mcp_oauth"}, + "notion": {version: "1.0.0", credentialKind: "notion_mcp_oauth"}, + "sentry": {version: "1.0.0", credentialKind: "sentry_mcp_oauth"}, + "linear": {version: "1.0.0", credentialKind: "linear_mcp_oauth"}, + "stripe": {version: "1.0.0", credentialKind: "stripe_mcp_oauth"}, } if len(snapshot.Catalog.Items) != len(want) { t.Fatalf("items=%d, want %d", len(snapshot.Catalog.Items), len(want)) } for _, item := range snapshot.Catalog.Items { - version, ok := want[item.ID] + expected, ok := want[item.ID] if !ok { t.Fatalf("unexpected connector %q", item.ID) } - if item.Version != version { - t.Fatalf("connector %q version=%q, want %q", item.ID, item.Version, version) + if item.Version != expected.version { + t.Fatalf("connector %q version=%q, want %q", item.ID, item.Version, expected.version) + } + if item.Transport != "streamable-http" || !item.Verified { + t.Fatalf("connector %q has unexpected transport or verification: %+v", item.ID, item) + } + if !item.Authentication.ConnectionSupported() { + t.Fatalf("connector %q is listed but cannot be connected", item.ID) + } + if expected.credentialKind != "" { + if item.Authentication.EffectiveType() != "oauth2" || item.Authentication.CredentialKind != expected.credentialKind { + t.Fatalf("connector %q authentication = %+v", item.ID, item.Authentication) + } + spec := item.CanonicalSpec() + authorization := spec.MCP.Servers[0].Headers["Authorization"] + if authorization.Prefix != "Bearer " || authorization.CredentialKindCode != expected.credentialKind { + t.Fatalf("connector %q authorization header = %+v", item.ID, authorization) + } } } } -func TestRemoteCatalogLoadsAndCaches(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - calls.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(validCatalogJSON(t, "remote")) - })) - defer server.Close() - - loader := New(Options{RemoteURL: server.URL, CacheTTL: time.Minute}) - first, err := loader.Load(context.Background()) - if err != nil { - t.Fatalf("first Load: %v", err) - } - second, err := loader.Load(context.Background()) - if err != nil { - t.Fatalf("second Load: %v", err) - } - if first.Source != SourceRemote || second.Source != SourceRemote || calls.Load() != 1 { - t.Fatalf("sources=%q/%q calls=%d", first.Source, second.Source, calls.Load()) - } -} - -func TestRemoteFailureFallsBackToBuiltin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "unavailable", http.StatusBadGateway) - })) - defer server.Close() - snapshot, err := New(Options{RemoteURL: server.URL}).Load(context.Background()) - if err != nil { - t.Fatalf("Load: %v", err) - } - if snapshot.Source != SourceBuiltin { - t.Fatalf("source = %q", snapshot.Source) - } -} - -func TestCatalogLoadFailsClearlyWhenRemoteAndBuiltinAreInvalid(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"schema_version":2}`)) - })) - defer server.Close() - _, err := New(Options{RemoteURL: server.URL, BuiltinJSON: []byte(`not-json`)}).Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "load remote catalog") || !strings.Contains(err.Error(), "load builtin catalog") { - t.Fatalf("error = %v", err) - } -} - -func TestRemoteCatalogResponseSizeIsBounded(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(validCatalogJSON(t, "oversized")) - })) - defer server.Close() - _, err := New(Options{RemoteURL: server.URL, BuiltinJSON: []byte(`not-json`), MaxResponseBytes: 16}).Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "response exceeds") { +func TestCatalogLoadFailsClearlyWhenBuiltinIsInvalid(t *testing.T) { + _, err := New(Options{BuiltinJSON: []byte(`not-json`)}).Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "load builtin catalog") { t.Fatalf("error = %v", err) } } @@ -149,6 +99,22 @@ func TestCatalogValidationRejectsInvalidContent(t *testing.T) { c.Items[0].Transport = "streamable-http" c.Items[0].Server = Server{Name: "remote", URL: "https://example.com/mcp", Command: "npx"} }, "must not set command"}, + {"oauth on stdio", func(c *Catalog) { + c.Items[0].Authentication = Authentication{Type: "oauth2", CredentialKind: "notion_mcp_oauth"} + }, "requires streamable-http"}, + {"oauth missing credential kind", func(c *Catalog) { + c.Items[0].Transport = "streamable-http" + c.Items[0].Server = Server{Name: "remote", URL: "https://example.com/mcp"} + c.Items[0].Authentication = Authentication{Type: "oauth2"} + }, "credential_kind"}, + {"unsupported client registration", func(c *Catalog) { + c.Items[0].Transport = "streamable-http" + c.Items[0].Server = Server{Name: "remote", URL: "https://example.com/mcp"} + c.Items[0].Authentication = Authentication{Type: "oauth2", CredentialKind: "example_mcp_oauth", ClientRegistration: "static"} + }, "client_registration"}, + {"client registration without oauth", func(c *Catalog) { + c.Items[0].Authentication = Authentication{Type: "none", ClientRegistration: ClientRegistrationApprovedClient} + }, "requires oauth2"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -166,30 +132,21 @@ func TestCatalogValidationRejectsInvalidContent(t *testing.T) { } } -func validCatalogJSON(t *testing.T, id string) []byte { - t.Helper() - data, err := json.Marshal(validCatalog(id)) - if err != nil { - t.Fatal(err) - } - return data -} - func validCatalog(id string) Catalog { return Catalog{ SchemaVersion: SchemaVersion, UpdatedAt: "2026-07-22T00:00:00Z", Items: []Item{{ - ID: id, - Name: "Connector", - Description: "A connector used by tests.", - Publisher: Publisher{Name: "Publisher", URL: "https://example.com"}, - RepositoryURL: "https://example.com/repository", - Verified: true, - Categories: []string{"Developer Tools"}, - PopularityRank: 1, - Version: "1.0.0", - Transport: "stdio", + ID: id, + Name: "Connector", + Description: "A connector used by tests.", + Publisher: Publisher{Name: "Publisher", URL: "https://example.com"}, + RepositoryURL: "https://example.com/repository", + Verified: true, + Categories: []string{"Developer Tools"}, + FeaturedRank: 1, + Version: "1.0.0", + Transport: "stdio", Server: Server{ Name: id, Command: "npx", diff --git a/server/internal/mcpcatalog/loader.go b/server/internal/mcpcatalog/loader.go index 41edaa04..1e04fb97 100644 --- a/server/internal/mcpcatalog/loader.go +++ b/server/internal/mcpcatalog/loader.go @@ -3,28 +3,15 @@ package mcpcatalog import ( "context" "fmt" - "io" - "net/http" - "net/url" "strings" - "sync" - "time" mcpcatalogdata "github.com/MiniMax-AI-Dev/parsar/catalog/mcp" ) -const ( - EnvCatalogURL = "PARSAR_MCP_CATALOG_URL" - defaultCacheTTL = 5 * time.Minute - defaultHTTPTimeout = 5 * time.Second - defaultMaxResponseSize = 2 << 20 -) - type Source string const ( SourceBuiltin Source = "builtin" - SourceRemote Source = "remote" ) type Snapshot struct { @@ -33,25 +20,12 @@ type Snapshot struct { } type Options struct { - RemoteURL string - HTTPClient *http.Client - CacheTTL time.Duration - MaxResponseBytes int64 - BuiltinJSON []byte + BuiltinJSON []byte } type Loader struct { - remoteURL *url.URL - remoteConfigErr error - client *http.Client - cacheTTL time.Duration - maxResponseBytes int64 - builtin Catalog - builtinErr error - - mu sync.Mutex - cached Snapshot - expiresAt time.Time + builtin Catalog + builtinErr error } func New(options Options) *Loader { @@ -61,112 +35,17 @@ func New(options Options) *Loader { } builtin, builtinErr := Decode(builtinJSON) - cacheTTL := options.CacheTTL - if cacheTTL <= 0 { - cacheTTL = defaultCacheTTL - } - maxResponseBytes := options.MaxResponseBytes - if maxResponseBytes <= 0 { - maxResponseBytes = defaultMaxResponseSize - } - - client := http.Client{} - if options.HTTPClient != nil { - client = *options.HTTPClient - } - if client.Timeout <= 0 { - client.Timeout = defaultHTTPTimeout - } - previousRedirect := client.CheckRedirect - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= 3 { - return fmt.Errorf("too many catalog redirects") - } - if req.URL.Scheme != "http" && req.URL.Scheme != "https" { - return fmt.Errorf("catalog redirect uses unsupported scheme %q", req.URL.Scheme) - } - if previousRedirect != nil { - return previousRedirect(req, via) - } - return nil - } - - var remoteURL *url.URL - var remoteConfigErr error - if raw := strings.TrimSpace(options.RemoteURL); raw != "" { - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { - remoteConfigErr = fmt.Errorf("%s must be an http or https URL without embedded credentials", EnvCatalogURL) - } else { - remoteURL = parsed - } - } - return &Loader{ - remoteURL: remoteURL, - remoteConfigErr: remoteConfigErr, - client: &client, - cacheTTL: cacheTTL, - maxResponseBytes: maxResponseBytes, - builtin: builtin, - builtinErr: builtinErr, + builtin: builtin, + builtinErr: builtinErr, } } -func (l *Loader) Load(ctx context.Context) (Snapshot, error) { - l.mu.Lock() - defer l.mu.Unlock() - - now := time.Now() - if !l.expiresAt.IsZero() && now.Before(l.expiresAt) { - return l.cached, nil - } - - var remoteErr error - if l.remoteConfigErr != nil { - remoteErr = l.remoteConfigErr - } else if l.remoteURL != nil { - catalog, err := l.loadRemote(ctx) - if err == nil { - l.cached = Snapshot{Catalog: catalog, Source: SourceRemote} - l.expiresAt = now.Add(l.cacheTTL) - return l.cached, nil - } - remoteErr = err - } - - if l.builtinErr == nil { - l.cached = Snapshot{Catalog: l.builtin, Source: SourceBuiltin} - l.expiresAt = now.Add(l.cacheTTL) - return l.cached, nil - } - if remoteErr != nil { - return Snapshot{}, fmt.Errorf("load remote catalog: %v; load builtin catalog: %w", remoteErr, l.builtinErr) - } - return Snapshot{}, fmt.Errorf("load builtin catalog: %w", l.builtinErr) -} - -func (l *Loader) loadRemote(ctx context.Context) (Catalog, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.remoteURL.String(), nil) - if err != nil { - return Catalog{}, fmt.Errorf("build catalog request: %w", err) - } - resp, err := l.client.Do(req) - if err != nil { - return Catalog{}, fmt.Errorf("fetch catalog: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return Catalog{}, fmt.Errorf("fetch catalog: unexpected HTTP status %d", resp.StatusCode) - } - data, err := io.ReadAll(io.LimitReader(resp.Body, l.maxResponseBytes+1)) - if err != nil { - return Catalog{}, fmt.Errorf("read catalog: %w", err) - } - if int64(len(data)) > l.maxResponseBytes { - return Catalog{}, fmt.Errorf("read catalog: response exceeds %d bytes", l.maxResponseBytes) +func (l *Loader) Load(_ context.Context) (Snapshot, error) { + if l.builtinErr != nil { + return Snapshot{}, fmt.Errorf("load builtin catalog: %w", l.builtinErr) } - return Decode(data) + return Snapshot{Catalog: l.builtin, Source: SourceBuiltin}, nil } func (s Snapshot) Find(id string) (Item, bool) { diff --git a/server/internal/mcpcatalog/types.go b/server/internal/mcpcatalog/types.go index 9843639d..6f2875e1 100644 --- a/server/internal/mcpcatalog/types.go +++ b/server/internal/mcpcatalog/types.go @@ -1,11 +1,14 @@ package mcpcatalog -import ( - "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" -) +import "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" const SchemaVersion = 1 +const ( + ClientRegistrationDynamic = "dynamic" + ClientRegistrationApprovedClient = "approved-client" +) + type Catalog struct { SchemaVersion int `json:"schema_version"` UpdatedAt string `json:"updated_at"` @@ -13,19 +16,44 @@ type Catalog struct { } type Item struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Publisher Publisher `json:"publisher"` - IconURL string `json:"icon_url,omitempty"` - HomepageURL string `json:"homepage_url,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Verified bool `json:"verified"` - Categories []string `json:"categories"` - PopularityRank int `json:"popularity_rank"` - Version string `json:"version"` - Transport string `json:"transport"` - Server Server `json:"server"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Publisher Publisher `json:"publisher"` + IconURL string `json:"icon_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Verified bool `json:"verified"` + Categories []string `json:"categories"` + FeaturedRank int `json:"featured_rank"` + Version string `json:"version"` + Transport string `json:"transport"` + Authentication Authentication `json:"authentication,omitempty"` + Server Server `json:"server"` +} + +type Authentication struct { + Type string `json:"type,omitempty"` + CredentialKind string `json:"credential_kind,omitempty"` + ClientRegistration string `json:"client_registration,omitempty"` +} + +func (a Authentication) EffectiveType() string { + if a.Type == "" { + return "none" + } + return a.Type +} + +func (a Authentication) EffectiveClientRegistration() string { + if a.ClientRegistration == "" { + return ClientRegistrationDynamic + } + return a.ClientRegistration +} + +func (a Authentication) ConnectionSupported() bool { + return a.EffectiveType() != "oauth2" || a.EffectiveClientRegistration() == ClientRegistrationDynamic } type Publisher struct { @@ -47,17 +75,27 @@ func (i Item) CanonicalSpec() canonical.Spec { for name := range i.Server.Env { env[name] = canonical.EnvValue{Mode: canonical.EnvModeLiteral} } + server := canonical.MCPServer{ + Name: i.Server.Name, + Transport: i.Transport, + URL: i.Server.URL, + Command: i.Server.Command, + Args: append([]string(nil), i.Server.Args...), + Env: env, + StartupTimeoutSec: i.Server.StartupTimeoutSec, + } + if i.Authentication.EffectiveType() == "oauth2" { + server.Headers = map[string]canonical.EnvValue{ + "Authorization": { + Mode: canonical.EnvModeCredentialRef, + Prefix: "Bearer ", + CredentialKindCode: i.Authentication.CredentialKind, + }, + } + } return canonical.Spec{ SchemaVersion: canonical.SchemaVersionCurrent, Kind: canonical.KindMCP, - MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ - Name: i.Server.Name, - Transport: i.Transport, - URL: i.Server.URL, - Command: i.Server.Command, - Args: append([]string(nil), i.Server.Args...), - Env: env, - StartupTimeoutSec: i.Server.StartupTimeoutSec, - }}}, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{server}}, } } diff --git a/server/internal/mcpcatalog/validate.go b/server/internal/mcpcatalog/validate.go index 19aa3f75..52e80b02 100644 --- a/server/internal/mcpcatalog/validate.go +++ b/server/internal/mcpcatalog/validate.go @@ -82,8 +82,8 @@ func (i Item) Validate() error { return fmt.Errorf("item %q: %w", i.ID, err) } } - if i.PopularityRank < 1 { - return fmt.Errorf("item %q popularity_rank must be positive", i.ID) + if i.FeaturedRank < 1 { + return fmt.Errorf("item %q featured_rank must be positive", i.ID) } if strings.TrimSpace(i.Version) == "" { return fmt.Errorf("item %q version is required", i.ID) @@ -91,6 +91,29 @@ func (i Item) Validate() error { if i.Transport != canonical.MCPTransportStdio && i.Transport != canonical.MCPTransportStreamableHTTP { return fmt.Errorf("item %q transport %q is unsupported", i.ID, i.Transport) } + switch i.Authentication.EffectiveType() { + case "none": + if strings.TrimSpace(i.Authentication.CredentialKind) != "" { + return fmt.Errorf("item %q authentication credential_kind requires oauth2", i.ID) + } + if strings.TrimSpace(i.Authentication.ClientRegistration) != "" { + return fmt.Errorf("item %q authentication client_registration requires oauth2", i.ID) + } + case "oauth2": + if i.Transport != canonical.MCPTransportStreamableHTTP { + return fmt.Errorf("item %q oauth2 authentication requires streamable-http", i.ID) + } + if !envPattern.MatchString(i.Authentication.CredentialKind) { + return fmt.Errorf("item %q authentication credential_kind %q is invalid", i.ID, i.Authentication.CredentialKind) + } + switch i.Authentication.EffectiveClientRegistration() { + case ClientRegistrationDynamic, ClientRegistrationApprovedClient: + default: + return fmt.Errorf("item %q authentication client_registration %q is unsupported", i.ID, i.Authentication.ClientRegistration) + } + default: + return fmt.Errorf("item %q authentication type %q is unsupported", i.ID, i.Authentication.Type) + } categorySeen := make(map[string]struct{}, len(i.Categories)) for _, category := range i.Categories { category = strings.TrimSpace(category) diff --git a/server/internal/store/capability_import.go b/server/internal/store/capability_import.go index 6c25ea8b..94e21905 100644 --- a/server/internal/store/capability_import.go +++ b/server/internal/store/capability_import.go @@ -460,6 +460,7 @@ func commitCapabilityVersionInTx(ctx context.Context, q *sqlc.Queries, p commitV metaJSON, err := json.Marshal(map[string]any{ "origin": "capability_import", "capability_id": p.CapabilityName, + "workspace_id": pgUUIDString(p.WorkspaceID), "server": secret.ServerName, "env_key": secret.EnvKey, }) @@ -587,6 +588,23 @@ func (s *Store) collectAndValidateCredentialRefs(ctx context.Context, spec canon seen[code] = struct{}{} out = append(out, RequiredCredential{Kind: code, Required: true}) } + for headerName, value := range srv.Headers { + if value.Mode != canonical.EnvModeCredentialRef { + continue + } + code := strings.ToLower(strings.TrimSpace(value.CredentialKindCode)) + if code == "" { + return nil, fmt.Errorf("import_capability: server %q header %q: credential_ref missing credential_kind_code", srv.Name, headerName) + } + if _, dup := seen[code]; dup { + continue + } + if _, err := s.GetCredentialKindByCode(ctx, code); err != nil { + return nil, fmt.Errorf("import_capability: server %q header %q references unknown credential_kind %q: %w", srv.Name, headerName, code, err) + } + seen[code] = struct{}{} + out = append(out, RequiredCredential{Kind: code, Required: true}) + } } return out, nil } @@ -706,8 +724,8 @@ func validateMCPSpecPreCommit(m canonical.MCPSpec) error { } switch value.Mode { case canonical.EnvModeLiteral: - if value.SecretID != "" || value.CredentialKindCode != "" { - return fmt.Errorf("server %q env %q: literal mode must not set secret_id/credential_kind_code", srv.Name, name) + if value.SecretID != "" || value.CredentialKindCode != "" || value.Prefix != "" { + return fmt.Errorf("server %q env %q: literal mode must not set secret_id/credential_kind_code/prefix", srv.Name, name) } case canonical.EnvModeInlineSecret: // Empty SecretID is intentional here — the import tx fills it @@ -726,6 +744,17 @@ func validateMCPSpecPreCommit(m canonical.MCPSpec) error { return fmt.Errorf("server %q env %q: unknown env mode %q", srv.Name, name, value.Mode) } } + for name, value := range srv.Headers { + if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n") { + return fmt.Errorf("server %q: invalid header name", srv.Name) + } + if value.Mode == canonical.EnvModeInlineSecret { + return fmt.Errorf("server %q header %q: inline_secret is not supported", srv.Name, name) + } + if err := value.Validate(); err != nil { + return fmt.Errorf("server %q header %q: %w", srv.Name, name, err) + } + } if _, dup := seen[srv.Name]; dup { return fmt.Errorf("duplicate server name %q", srv.Name) } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 05eb10a3..d84da44e 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -699,7 +699,7 @@ type UsageLogRead struct { } type CreateSecretInput struct { - WorkspaceID string // accepted for caller-compat; secrets are org-global + WorkspaceID string Name string Kind string Provider string @@ -711,6 +711,9 @@ type CreateSecretInput struct { // secret to a single credential_kinds.code. Used by the agent-creation // shared-binding picker to filter secrets by the kind they hold. CredentialKindCode string + // Metadata is optional non-secret provenance. Reserved keys such as + // masked, credential_kind_code, and workspace_id are set by Store. + Metadata map[string]any } type SecretRead struct { @@ -5709,13 +5712,18 @@ func (s *Store) ListWorkspaceUsageLogs(ctx context.Context, workspaceID string, func (s *Store) CreateSecret(ctx context.Context, input CreateSecretInput, encryptedPayload []byte) (SecretRead, error) { now := time.Now().UTC() - // Secrets are org-global; WorkspaceID accepted for caller-compat only. - _ = input.WorkspaceID createdBy := nullableUUID(input.CreatedBy) - metaPayload := map[string]any{"masked": strings.TrimSpace(input.Masked)} + metaPayload := make(map[string]any, len(input.Metadata)+3) + for key, value := range input.Metadata { + metaPayload[key] = value + } + metaPayload["masked"] = strings.TrimSpace(input.Masked) if code := strings.TrimSpace(input.CredentialKindCode); code != "" { metaPayload["credential_kind_code"] = code } + if strings.TrimSpace(input.Kind) == "capability_inline" { + metaPayload["workspace_id"] = strings.TrimSpace(input.WorkspaceID) + } metadata, err := json.Marshal(metaPayload) if err != nil { return SecretRead{}, err @@ -5759,10 +5767,24 @@ func (s *Store) CreateSecret(ctx context.Context, input CreateSecretInput, encry return read, nil } -// ListSecrets returns active secrets in the org-global catalog. -// workspaceID is accepted for caller-compat only and ignored. +// ListSecrets returns legacy organization-wide secrets plus capability +// credentials owned by the requested workspace. func (s *Store) ListSecrets(ctx context.Context, workspaceID string, limit int32) ([]SecretRead, error) { - return s.ListSecretsByKind(ctx, "", limit) + if limit <= 0 { + limit = defaultReadLimit + } + rows, err := sqlc.New(s.db).ListSecretsForWorkspace(ctx, sqlc.ListSecretsForWorkspaceParams{ + WorkspaceID: strings.TrimSpace(workspaceID), + ItemLimit: limit, + }) + if err != nil { + return nil, err + } + secrets := make([]SecretRead, 0, len(rows)) + for _, row := range rows { + secrets = append(secrets, secretReadFromWorkspaceListRow(row)) + } + return secrets, nil } func (s *Store) ListSecretsByKind(ctx context.Context, kindFilter string, limit int32) ([]SecretRead, error) { @@ -5782,7 +5804,9 @@ func (s *Store) ListSecretsByKind(ctx context.Context, kindFilter string, limit func (s *Store) DisableSecret(ctx context.Context, workspaceID string, secretID string) (SecretRead, error) { now := time.Now().UTC() - _ = workspaceID + if _, err := s.GetSecretPayload(ctx, workspaceID, secretID); err != nil { + return SecretRead{}, err + } secretUUID, err := uuid(secretID) if err != nil { return SecretRead{}, err @@ -5815,7 +5839,6 @@ func (s *Store) DisableSecret(ctx context.Context, workspaceID string, secretID } func (s *Store) GetSecretPayload(ctx context.Context, workspaceID string, secretID string) (SecretPayload, error) { - _ = workspaceID secretUUID, err := uuid(secretID) if err != nil { return SecretPayload{}, err @@ -5828,9 +5851,49 @@ func (s *Store) GetSecretPayload(ctx context.Context, workspaceID string, secret return SecretPayload{}, err } read := secretReadFromSecretRow(row) + if scopedWorkspaceID := secretWorkspaceID(read.Metadata); scopedWorkspaceID != "" && scopedWorkspaceID != strings.TrimSpace(workspaceID) { + return SecretPayload{}, fmt.Errorf("%w: %s", ErrUnknownSecret, secretID) + } + return SecretPayload{SecretRead: read, EncryptedPayload: row.EncryptedPayload}, nil +} + +// UpdateSecretPayload rotates an existing encrypted secret without changing +// its identity, so Agent credential bindings remain valid after OAuth refresh +// or reconnect. Workspace ownership is enforced before the update. +func (s *Store) UpdateSecretPayload(ctx context.Context, workspaceID, secretID string, encryptedPayload []byte) (SecretPayload, error) { + current, err := s.GetSecretPayload(ctx, workspaceID, secretID) + if err != nil { + return SecretPayload{}, err + } + secretUUID, err := uuid(secretID) + if err != nil { + return SecretPayload{}, err + } + keyVersion := strings.TrimSpace(current.KeyVersion) + if keyVersion == "" { + keyVersion = "v1" + } + row, err := sqlc.New(s.db).UpdateSecretPayload(ctx, sqlc.UpdateSecretPayloadParams{ + ID: secretUUID, + EncryptedPayload: encryptedPayload, + KeyVersion: keyVersion, + Now: timestamptz(time.Now().UTC()), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return SecretPayload{}, fmt.Errorf("%w: %s", ErrUnknownSecret, secretID) + } + return SecretPayload{}, err + } + read := secretReadFromUpdatePayloadRow(row) return SecretPayload{SecretRead: read, EncryptedPayload: row.EncryptedPayload}, nil } +func secretWorkspaceID(metadata map[string]any) string { + workspaceID, _ := metadata["workspace_id"].(string) + return strings.TrimSpace(workspaceID) +} + // SlackBotSecret is a decrypt-ready Slack bot-token secret resolved by Slack // team_id. AppID is the Slack app id from the secret metadata (empty when the // install didn't record one); EncryptedPayload is the AES-GCM envelope the @@ -8282,6 +8345,10 @@ func secretReadFromListRow(row sqlc.ListSecretsRow) SecretRead { return secretRead(row.ID, row.Slug, row.Name, row.Kind, row.Provider, row.AuthType, row.KeyVersion, row.Status, row.Metadata, row.CreatedAt, row.UpdatedAt) } +func secretReadFromWorkspaceListRow(row sqlc.ListSecretsForWorkspaceRow) SecretRead { + return secretRead(row.ID, row.Slug, row.Name, row.Kind, row.Provider, row.AuthType, row.KeyVersion, row.Status, row.Metadata, row.CreatedAt, row.UpdatedAt) +} + func secretReadFromDisableRow(row sqlc.DisableSecretRow) SecretRead { return secretRead(row.ID, row.Slug, row.Name, row.Kind, row.Provider, row.AuthType, row.KeyVersion, row.Status, row.Metadata, row.CreatedAt, row.UpdatedAt) } @@ -8290,6 +8357,10 @@ func secretReadFromSecretRow(row sqlc.GetSecretPayloadRow) SecretRead { return secretRead(row.ID, row.Slug, row.Name, row.Kind, row.Provider, row.AuthType, row.KeyVersion, row.Status, row.Metadata, row.CreatedAt, row.UpdatedAt) } +func secretReadFromUpdatePayloadRow(row sqlc.UpdateSecretPayloadRow) SecretRead { + return secretRead(row.ID, row.Slug, row.Name, row.Kind, row.Provider, row.AuthType, row.KeyVersion, row.Status, row.Metadata, row.CreatedAt, row.UpdatedAt) +} + func secretRead(id, slug, name, kind, provider, authType, keyVersion, status string, metadataJSON []byte, createdAt, updatedAt pgtype.Timestamptz) SecretRead { metadata := decodeJSONMap(metadataJSON) masked, _ := metadata["masked"].(string) diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index 7e4f3b8b..ca5d6ddb 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -94,6 +95,84 @@ func TestWorkspaceRuntimeSettingsReadsCredentialMask(t *testing.T) { } } +func TestCapabilityInlineSecretsAreWorkspaceScoped(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + st := New(db) + ids := mustSeedDevFixture(t, ctx, st) + otherWorkspace, err := st.CreateWorkspace(ctx, CreateWorkspaceInput{ + Name: "Other Workspace", + CreatedBy: ids.UserID, + Now: time.Now().UTC(), + }) + if err != nil { + t.Fatal(err) + } + + scoped, err := st.CreateSecret(ctx, CreateSecretInput{ + WorkspaceID: ids.WorkspaceID, + Name: "Notion OAuth", + Kind: "capability_inline", + Provider: "notion", + AuthType: "oauth2", + CreatedBy: ids.UserID, + CredentialKindCode: "notion_mcp_oauth", + Metadata: map[string]any{"catalog_id": "notion"}, + }, []byte(`{"version":"v1"}`)) + if err != nil { + t.Fatal(err) + } + if got := secretWorkspaceID(scoped.Metadata); got != ids.WorkspaceID { + t.Fatalf("workspace_id=%q want=%q", got, ids.WorkspaceID) + } + + current, err := st.ListSecrets(ctx, ids.WorkspaceID, 100) + if err != nil { + t.Fatal(err) + } + if !containsSecret(current, scoped.ID) { + t.Fatalf("workspace secrets do not contain %s: %+v", scoped.ID, current) + } + other, err := st.ListSecrets(ctx, otherWorkspace.Workspace.ID, 100) + if err != nil { + t.Fatal(err) + } + if containsSecret(other, scoped.ID) { + t.Fatalf("scoped secret leaked into workspace %s", otherWorkspace.Workspace.ID) + } + if _, err := st.GetSecretPayload(ctx, otherWorkspace.Workspace.ID, scoped.ID); !errors.Is(err, ErrUnknownSecret) { + t.Fatalf("GetSecretPayload error=%v want ErrUnknownSecret", err) + } + if _, err := st.UpdateSecretPayload(ctx, otherWorkspace.Workspace.ID, scoped.ID, []byte(`{}`)); !errors.Is(err, ErrUnknownSecret) { + t.Fatalf("UpdateSecretPayload error=%v want ErrUnknownSecret", err) + } + if _, err := st.DisableSecret(ctx, otherWorkspace.Workspace.ID, scoped.ID); !errors.Is(err, ErrUnknownSecret) { + t.Fatalf("DisableSecret error=%v want ErrUnknownSecret", err) + } + + rotated := []byte(`{"version":"v1","rotated":true}`) + updated, err := st.UpdateSecretPayload(ctx, ids.WorkspaceID, scoped.ID, rotated) + if err != nil { + t.Fatal(err) + } + var updatedPayload map[string]any + if err := json.Unmarshal(updated.EncryptedPayload, &updatedPayload); err != nil { + t.Fatal(err) + } + if updatedPayload["rotated"] != true || updatedPayload["version"] != "v1" || updated.ID != scoped.ID { + t.Fatalf("updated=%+v", updated) + } +} + +func containsSecret(secrets []SecretRead, secretID string) bool { + for _, secret := range secrets { + if secret.ID == secretID { + return true + } + } + return false +} + func TestCreateUserCredentialRejectsUnknownKind(t *testing.T) { db := openTestDB(t) ctx := context.Background() diff --git a/server/migrations/000010_notion_mcp_oauth.sql b/server/migrations/000010_notion_mcp_oauth.sql new file mode 100644 index 00000000..2c38c699 --- /dev/null +++ b/server/migrations/000010_notion_mcp_oauth.sql @@ -0,0 +1,18 @@ +-- +goose Up + +INSERT INTO credential_kinds (code, display_name, description, source, built_in) +VALUES + ( + 'notion_mcp_oauth', + 'Notion MCP OAuth', + 'OAuth access for the official Notion remote MCP server', + 'platform_oauth', + TRUE + ) +ON CONFLICT DO NOTHING; + +-- +goose Down + +DELETE FROM credential_kinds +WHERE code = 'notion_mcp_oauth' + AND built_in = TRUE; diff --git a/server/migrations/000011_common_mcp_oauth.sql b/server/migrations/000011_common_mcp_oauth.sql new file mode 100644 index 00000000..05dbd70f --- /dev/null +++ b/server/migrations/000011_common_mcp_oauth.sql @@ -0,0 +1,36 @@ +-- +goose Up + +INSERT INTO credential_kinds (code, display_name, description, source, built_in) +VALUES + ( + 'sentry_mcp_oauth', + 'Sentry MCP OAuth', + 'OAuth access for the official Sentry remote MCP server', + 'platform_oauth', + TRUE + ), + ( + 'linear_mcp_oauth', + 'Linear MCP OAuth', + 'OAuth access for the official Linear remote MCP server', + 'platform_oauth', + TRUE + ), + ( + 'stripe_mcp_oauth', + 'Stripe MCP OAuth', + 'OAuth access for the official Stripe remote MCP server', + 'platform_oauth', + TRUE + ) +ON CONFLICT DO NOTHING; + +-- +goose Down + +DELETE FROM credential_kinds +WHERE code IN ( + 'sentry_mcp_oauth', + 'linear_mcp_oauth', + 'stripe_mcp_oauth' + ) + AND built_in = TRUE; diff --git a/server/migrations/000012_postman_mcp_oauth.sql b/server/migrations/000012_postman_mcp_oauth.sql new file mode 100644 index 00000000..7ba1465f --- /dev/null +++ b/server/migrations/000012_postman_mcp_oauth.sql @@ -0,0 +1,17 @@ +-- +goose Up + +INSERT INTO credential_kinds (code, display_name, description, source, built_in) +VALUES ( + 'postman_mcp_oauth', + 'Postman MCP OAuth', + 'OAuth access for the official Postman remote MCP server', + 'platform_oauth', + TRUE +) +ON CONFLICT DO NOTHING; + +-- +goose Down + +DELETE FROM credential_kinds +WHERE code = 'postman_mcp_oauth' + AND built_in = TRUE; diff --git a/tests/e2e/conversation-runtime-errors.spec.ts b/tests/e2e/conversation-runtime-errors.spec.ts new file mode 100644 index 00000000..815bf043 --- /dev/null +++ b/tests/e2e/conversation-runtime-errors.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/test"; + +import type { ConversationTimelineMessage } from "../../apps/web/src/lib/api-types"; +import { + dedupeCapabilityRuntimeDiagnostics, + isRuntimeErrorMessage, +} from "../../apps/web/src/pages/admin/conversation-runtime-errors"; + +function runtimeErrorMessage( + id: string, + subKind: string, + capabilityID: string, + credentialKind = "", +): ConversationTimelineMessage { + return { + id, + conversation_id: "conversation-1", + sender_type: "system", + kind: "error", + content: subKind, + metadata: { + kind: "runtime_error", + sub_kind: subKind, + capability_id: capabilityID, + credential_kind: credentialKind, + }, + created_at: `2026-07-23T00:00:0${id}.000Z`, + }; +} + +test("recognizes persisted runtime errors stored with kind=error", () => { + expect(isRuntimeErrorMessage("error", { kind: "runtime_error" })).toBe(true); + expect(isRuntimeErrorMessage("error", { error: { source: "runtime" } })).toBe( + true, + ); + expect(isRuntimeErrorMessage("error", { kind: "validation_error" })).toBe( + false, + ); +}); + +test("keeps only the newest identical capability diagnostic", () => { + const first = runtimeErrorMessage( + "1", + "capability_credential_missing", + "notion", + "notion_mcp_oauth", + ); + const unrelated = runtimeErrorMessage( + "2", + "capability_version_unavailable", + "pr-review", + ); + const newest = runtimeErrorMessage( + "3", + "capability_credential_missing", + "notion", + "notion_mcp_oauth", + ); + const historicalUnsupported = runtimeErrorMessage( + "4", + "capability_credential_missing", + "diagram-maker", + ); + const currentUnsupported = runtimeErrorMessage( + "5", + "capability_unsupported", + "diagram-maker", + ); + + expect( + dedupeCapabilityRuntimeDiagnostics([ + first, + unrelated, + newest, + historicalUnsupported, + currentUnsupported, + ]).map((item) => item.id), + ).toEqual(["2", "3", "5"]); +}); diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts index 5ab2fda2..053368ce 100644 --- a/tests/e2e/mcp-directory.spec.ts +++ b/tests/e2e/mcp-directory.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const WORKSPACE_ID = "00000000-0000-0000-0000-000000000011"; const CAPABILITY_ID = "00000000-0000-0000-0000-000000000033"; +const AGENT_ID = "00000000-0000-0000-0000-000000000066"; const directoryItems = [ { @@ -15,9 +16,12 @@ const directoryItems = [ repository_url: "https://example.com/filesystem", verified: true, categories: ["Developer Tools", "Files"], - popularity_rank: 1, + featured_rank: 1, version: "1.0.0", transport: "stdio", + authentication: "none", + connection_supported: true, + connected: false, installed: false, installed_capability_id: null, }, @@ -31,9 +35,12 @@ const directoryItems = [ }, verified: true, categories: ["Data"], - popularity_rank: 2, + featured_rank: 2, version: "1.1.0", transport: "stdio", + authentication: "none", + connection_supported: true, + connected: false, installed: false, installed_capability_id: null, }, @@ -44,9 +51,12 @@ const directoryItems = [ publisher: { name: "Community", url: "https://example.com/community" }, verified: false, categories: ["Utilities"], - popularity_rank: 3, + featured_rank: 3, version: "0.2.0", transport: "stdio", + authentication: "none", + connection_supported: true, + connected: false, installed: false, installed_capability_id: null, }, @@ -57,9 +67,29 @@ const directoryItems = [ publisher: { name: "Cognition", url: "https://www.cognition.ai" }, verified: true, categories: ["Documentation"], - popularity_rank: 4, + featured_rank: 4, version: "2.14.3", transport: "streamable-http", + authentication: "none", + connection_supported: true, + connected: false, + installed: false, + installed_capability_id: null, + }, + { + id: "notion", + name: "Notion", + description: "Search, read, create, and update Notion content.", + publisher: { name: "Notion", url: "https://www.notion.so" }, + verified: true, + categories: ["Productivity"], + featured_rank: 5, + version: "1.0.0", + transport: "streamable-http", + authentication: "oauth2", + credential_kind: "notion_mcp_oauth", + connection_supported: true, + connected: false, installed: false, installed_capability_id: null, }, @@ -72,7 +102,7 @@ test("browse, filter, inspect, and import an MCP connector without affecting Ski await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(5); const search = page.getByPlaceholder("Search capability name / description"); await search.fill("memory"); @@ -85,7 +115,7 @@ test("browse, filter, inspect, and import an MCP connector without affecting Ski await page.getByRole("button", { name: "All categories" }).click(); await page.getByRole("checkbox", { name: "Verified only" }).check(); - await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); await page.getByRole("checkbox", { name: "Verified only" }).uncheck(); await page @@ -123,6 +153,12 @@ test("browse, filter, inspect, and import an MCP connector without affecting Ski ).toHaveCount(0); await page.getByRole("button", { name: "Back to connectors" }).click(); + await expect( + page + .getByTestId("mcp-directory-card") + .filter({ has: page.getByRole("heading", { name: "Filesystem" }) }) + .getByRole("button", { name: "View Capability" }), + ).toBeVisible(); await page.getByRole("tab", { name: "Skill" }).click(); await expect( page.getByRole("heading", { name: "Diagram Maker" }), @@ -148,7 +184,20 @@ test("shows a retryable connector directory error", async ({ page }) => { ), ).toBeVisible(); await page.getByRole("button", { name: "Retry" }).click(); - await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(5); +}); + +test("returns to the directory when a bookmarked connector no longer exists", async ({ + page, +}) => { + await mockApp(page); + await page.goto( + `/?admin=capabilities&tab=marketplace&item=mcp%3Agit&ws=${WORKSPACE_ID}`, + ); + + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(5); + await expect(page).not.toHaveURL(/(?:\?|&)item=/); + await expect(page.getByText("connector_not_found")).toHaveCount(0); }); test("shows a loading state while the connector catalog is pending", async ({ @@ -166,7 +215,7 @@ test("shows a loading state while the connector catalog is pending", async ({ await expect(page.getByTestId("mcp-directory-loading")).toBeVisible(); releaseDirectory?.(); - await expect(page.getByTestId("mcp-directory-card")).toHaveCount(4); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(5); }); test("shows a no-auth streamable HTTP connector endpoint", async ({ page }) => { @@ -186,6 +235,137 @@ test("shows a no-auth streamable HTTP connector endpoint", async ({ page }) => { await expect(dialog.getByRole("textbox")).toHaveCount(0); }); +test("requires Notion OAuth before confirming the connector import", async ({ + page, +}) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await page.getByRole("heading", { name: "Notion" }).click(); + const detail = page.getByTestId("mcp-directory-detail"); + await expect(detail).toContainText("OAuth 2.1 required"); + await expect( + page.getByRole("button", { name: "Connect Notion" }), + ).toBeVisible(); + await expect(page.getByTestId("mcp-oauth-status")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Import", exact: true }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Import", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText( + "Authorize this workspace before importing. You'll return here to confirm the import.", + ); + await expect( + dialog.getByRole("button", { name: "Authorize & continue" }), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Import", exact: true }), + ).toHaveCount(0); +}); + +test("reopens the Notion import confirmation after OAuth", async ({ page }) => { + await mockApp(page, undefined, false, "owner", 0, true); + await page.goto( + `/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}&item=mcp:notion&connected=notion&import=notion`, + ); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Authorized"); + await expect( + dialog.getByRole("button", { name: "Import", exact: true }), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Authorize & continue" }), + ).toHaveCount(0); + + const returnURL = new URL(page.url()); + expect(returnURL.searchParams.get("import")).toBeNull(); + expect(returnURL.searchParams.get("connected")).toBe("notion"); + expect(returnURL.searchParams.get("item")).toBe("mcp:notion"); +}); + +test("opens Notion OAuth in a popup and refreshes the original page on return", async ({ + page, +}) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + await page.getByRole("heading", { name: "Notion" }).click(); + const originalURL = page.url(); + + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect Notion" }).click(); + const popup = await popupPromise; + + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(page).toHaveURL(originalURL); + await expect(page.getByTestId("mcp-directory-detail")).toContainText( + "Authorized, connection not verified", + ); + await expect( + page.getByRole("button", { name: "Test connection" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible(); +}); + +test("members can authorize the shared workspace OAuth connection", async ({ + page, +}) => { + await mockApp(page, undefined, false, "member"); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await page.getByRole("heading", { name: "Notion" }).click(); + await expect( + page.getByRole("button", { name: "Connect Notion" }), + ).toBeVisible(); + await expect(page.getByTestId("mcp-oauth-status")).toHaveCount(0); +}); + +test("shows an authorized connector with a green status", async ({ page }) => { + await mockApp(page, undefined, false, "owner", 0, true); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + const card = page.getByTestId("mcp-directory-card").filter({ + has: page.getByRole("heading", { name: "Notion" }), + }); + await expect(card.getByText("Authorized", { exact: true })).toHaveClass( + /bg-success-subtle/, + ); + + await card.getByRole("heading", { name: "Notion" }).click(); + await expect( + page + .getByTestId("mcp-directory-detail") + .getByText("Authorized", { exact: true }), + ).toHaveClass(/bg-success-subtle/); +}); + +test("verifies an authorized Notion connection without verbose status copy", async ({ + page, +}) => { + await mockApp(page, undefined, false, "owner", 0, true); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await page.getByRole("heading", { name: "Notion" }).click(); + const detail = page.getByTestId("mcp-directory-detail"); + const response = page.waitForResponse( + (candidate) => + candidate.url().includes("/mcp-directory/notion/oauth/test") && + candidate.request().method() === "POST", + ); + await page.getByRole("button", { name: "Test connection" }).click(); + + await expect((await response).ok()).toBe(true); + await expect( + page.getByRole("button", { name: "Connection works" }), + ).toHaveClass(/bg-success-subtle/); + await expect(detail).not.toContainText("Notion connection verified"); + await expect(detail).not.toContainText("available tools"); + await expect(detail).not.toContainText("NOTION · tool"); +}); + test("resolves an imported workspace connector on the Add to Agent path", async ({ page, }) => { @@ -203,11 +383,27 @@ test("resolves an imported workspace connector on the Add to Agent path", async .getByRole("button", { name: "Add to Agent" }) .click(); - await expect(page).toHaveURL( - new RegExp(`admin=agents.*pendingCapability=${CAPABILITY_ID}`), + const originalURL = page.url(); + const addDialog = page.getByRole("dialog"); + await expect( + addDialog.getByRole("heading", { name: "Add Filesystem to an Agent" }), + ).toBeVisible(); + await addDialog.getByRole("radio", { name: "Directory Agent" }).check(); + + const enableRequest = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === + `/api/v1/workspaces/${WORKSPACE_ID}/agents/${AGENT_ID}/capabilities/version-1/enable`, ); + await addDialog.getByRole("button", { name: "Add to Agent" }).click(); + const request = await enableRequest; + + expect(request.postDataJSON()).toEqual({ pinning_mode: "latest" }); + await expect(addDialog).toHaveCount(0); + await expect(page).toHaveURL(originalURL); await expect( - page.getByText('You are preparing to add "Filesystem"', { exact: false }), + page.getByText("Filesystem was added to Directory Agent.", { exact: true }), ).toBeVisible(); }); @@ -227,6 +423,28 @@ test("prefills an imported connector edit from its canonical spec", async ({ await expect(editor).toHaveValue(/"startup_timeout_sec": 30/); }); +test("does not offer publishing a Connector Directory import again", async ({ + page, +}) => { + await mockApp(page, undefined, true); + await page.goto(`/?admin=capabilities&ws=${WORKSPACE_ID}`); + + const row = page.getByRole("row").filter({ hasText: "Filesystem" }); + await row.getByRole("button", { name: "More actions" }).click(); + await expect( + page.getByRole("menuitem", { name: "Publish to market" }), + ).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); + + await page.goto( + `/?admin=capabilities&id=${CAPABILITY_ID}&ws=${WORKSPACE_ID}`, + ); + await expect(page.getByText("Connector Directory item")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Publish to market" }), + ).toHaveCount(0); +}); + test("lets members open a Skill-only capability import", async ({ page }) => { await mockApp(page, undefined, false, "member"); await page.goto(`/?admin=capabilities&ws=${WORKSPACE_ID}`); @@ -244,9 +462,12 @@ async function mockApp( initiallyImported = false, workspaceRole = "owner", versionDelayMs = 0, + notionConnected = false, ) { let imported = initiallyImported; - await page.route("**/api/v1/**", async (route) => { + let notionAuthorized = notionConnected; + let notionStatus = notionAuthorized ? "authorized" : "not_connected"; + await page.context().route("**/api/v1/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; @@ -282,7 +503,78 @@ async function mockApp( offset: 0, }); if (path === `/api/v1/workspaces/${WORKSPACE_ID}/agents`) - return json(route, { agents: [] }); + return json(route, { + agents: [ + { + id: AGENT_ID, + workspace_id: WORKSPACE_ID, + name: "Directory Agent", + slug: "directory-agent", + description: "Agent used by the connector directory test.", + connector_type: "agent_daemon", + status: "active", + runtime: "local", + visibility: "workspace", + created_by_user_id: "user-1", + config: { daemon_mode: "local", agent_kind: "claude_code" }, + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }, + ], + }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/agents/${AGENT_ID}`) + return json(route, { + id: AGENT_ID, + workspace_id: WORKSPACE_ID, + name: "Directory Agent", + slug: "directory-agent", + description: "Agent used by the connector directory test.", + connector_type: "agent_daemon", + status: "active", + runtime: "local", + visibility: "workspace", + config: { daemon_mode: "local", agent_kind: "claude_code" }, + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }); + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/agents/${AGENT_ID}/capabilities` + ) + return json(route, { + workspace_id: WORKSPACE_ID, + agent_id: AGENT_ID, + installed: [], + available: [ + { + id: CAPABILITY_ID, + workspace_id: WORKSPACE_ID, + type: "mcp", + name: "Filesystem", + description: "Read and write files from configured directories.", + visibility: "workspace", + status: "active", + required_credentials: [], + latest_version_id: "version-1", + latest_version: "1.0.0", + creator_id: "user-1", + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + }, + ], + }); + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/agents/${AGENT_ID}/capabilities/version-1/enable` && + request.method() === "POST" + ) + return json(route, {}); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/models`) + return json(route, { models: [] }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/secrets`) + return json(route, { secrets: [] }); + if (path === "/api/v1/me/credentials") + return json(route, { credentials: [] }); if ( path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/marketplace-installs` @@ -296,7 +588,7 @@ async function mockApp( id: CAPABILITY_ID, workspace_id: WORKSPACE_ID, type: "mcp", - name: "Git", + name: "Filesystem", description: "Read, search, and inspect a local Git repository.", visibility: "workspace", status: "active", @@ -421,7 +713,21 @@ async function mockApp( if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory`) { if (directoryOverride && (await directoryOverride(route))) return; return json(route, { - items: directoryItems, + items: directoryItems.map((item) => { + if (item.id === "notion") + return { + ...item, + connected: notionAuthorized, + connection_status: notionStatus, + }; + if (item.id === "filesystem" && imported) + return { + ...item, + installed: true, + installed_capability_id: CAPABILITY_ID, + }; + return item; + }), updated_at: "2026-07-22T00:00:00Z", source: "builtin", }); @@ -447,6 +753,54 @@ async function mockApp( url: "https://mcp.deepwiki.com/mcp", }); } + if ( + path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/notion` && + request.method() === "GET" + ) { + return json(route, { + ...directoryItems[4], + connected: notionAuthorized, + connection_status: notionStatus, + url: "https://mcp.notion.com/mcp", + }); + } + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/notion/oauth/test` && + request.method() === "POST" + ) { + notionStatus = "verified"; + return json(route, { + authorized: true, + verified: true, + status: "verified", + checked_at: "2026-07-22T10:30:00Z", + protocol_version: "2025-06-18", + server_name: "Notion", + server_version: "1.0.0", + tool_count: 2, + }); + } + if ( + path === + `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/notion/oauth/start` && + request.method() === "GET" + ) { + notionAuthorized = true; + notionStatus = "authorized"; + const intent = url.searchParams.get("intent"); + const callback = new URL("/", url.origin); + callback.searchParams.set("admin", "capabilities"); + callback.searchParams.set("tab", "marketplace"); + callback.searchParams.set("ws", WORKSPACE_ID); + callback.searchParams.set("item", "mcp:notion"); + callback.searchParams.set("connected", "notion"); + if (intent === "import") callback.searchParams.set("import", "notion"); + return route.fulfill({ + status: 302, + headers: { location: callback.toString() }, + }); + } if ( path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/filesystem/import` From aad4c88d0ac559f4521d38fff6b9b79444ed86be Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:41:22 +0800 Subject: [PATCH 03/21] feat: add curated MCP connector directory --- Dockerfile | 1 + .../internal/agent/codex/mcp_config.go | 7 + .../internal/agent/codex/mcp_config_test.go | 14 + .../internal/agent/codex/options.go | 10 +- .../internal/agent/opencode/options.go | 73 ++++- .../internal/agent/opencode/options_test.go | 35 ++ apps/web/src/i18n/locales/en-US/admin.json | 54 +++ apps/web/src/i18n/locales/zh-CN/admin.json | 54 +++ apps/web/src/lib/api-marketplace.ts | 91 ++++++ .../admin/capabilities/MarketplaceTab.tsx | 19 +- .../src/pages/admin/capabilities/index.tsx | 2 + .../mcp-directory/ImportMCPDialog.tsx | 104 ++++++ .../mcp-directory/MCPDirectory.tsx | 177 ++++++++++ .../mcp-directory/MCPDirectoryCard.tsx | 51 +++ .../mcp-directory/MCPDirectoryDetail.tsx | 155 +++++++++ .../capabilities/mcp-directory/filters.ts | 26 ++ .../capabilities/mcp-directory/shared.tsx | 43 +++ .../web/src/pages/admin/capabilities/types.ts | 4 +- catalog/mcp/README.md | 25 ++ catalog/mcp/catalog.json | 69 ++++ catalog/mcp/catalog.schema.json | 76 +++++ catalog/mcp/embed.go | 9 + docs/openapi/openapi.yaml | 235 +++++++++++++ server/cmd/server/main.go | 7 + server/internal/api/mcpdirectory/handler.go | 309 ++++++++++++++++++ .../internal/api/mcpdirectory/handler_test.go | 220 +++++++++++++ server/internal/capability/canonical/mcp.go | 48 ++- .../capability/canonical/spec_test.go | 15 + .../internal/capability/render/claudecode.go | 6 + server/internal/capability/render/codex.go | 6 + server/internal/capability/render/opencode.go | 6 + .../capability/render/renderer_test.go | 57 ++++ .../agentdaemon/capability_runtime.go | 18 +- .../agentdaemon/capability_runtime_test.go | 20 ++ server/internal/db/queries/store.sql | 17 + server/internal/db/sqlc/store.sql.go | 44 +++ server/internal/mcpcatalog/catalog_test.go | 80 +++++ server/internal/mcpcatalog/loader.go | 53 +++ server/internal/mcpcatalog/types.go | 51 +++ server/internal/mcpcatalog/validate.go | 121 +++++++ server/internal/store/capability_import.go | 18 +- server/internal/store/mcp_directory.go | 37 +++ server/internal/store/mcp_directory_test.go | 86 +++++ tests/e2e/mcp-directory.spec.ts | 154 +++++++++ 44 files changed, 2688 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts create mode 100644 apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx create mode 100644 catalog/mcp/README.md create mode 100644 catalog/mcp/catalog.json create mode 100644 catalog/mcp/catalog.schema.json create mode 100644 catalog/mcp/embed.go create mode 100644 server/internal/api/mcpdirectory/handler.go create mode 100644 server/internal/api/mcpdirectory/handler_test.go create mode 100644 server/internal/mcpcatalog/catalog_test.go create mode 100644 server/internal/mcpcatalog/loader.go create mode 100644 server/internal/mcpcatalog/types.go create mode 100644 server/internal/mcpcatalog/validate.go create mode 100644 server/internal/store/mcp_directory.go create mode 100644 server/internal/store/mcp_directory_test.go create mode 100644 tests/e2e/mcp-directory.spec.ts diff --git a/Dockerfile b/Dockerfile index e1404ec1..028a6031 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,6 +79,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY internal ./internal +COPY catalog ./catalog COPY server ./server # Build all three binaries in one RUN so the layer represents one diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go index 22437fb9..d66c6259 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -14,6 +14,7 @@ import ( // before spawning the app-server child. type mcpServerConfig struct { Name string + URL string Command string Args []string Env map[string]string @@ -49,6 +50,12 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString("[mcp_servers.") b.WriteString(tomlQuoteString(name)) b.WriteString("]\n") + if srv.URL != "" { + b.WriteString(`url = `) + b.WriteString(tomlQuoteString(srv.URL)) + b.WriteString("\n\n") + continue + } b.WriteString(`command = `) b.WriteString(tomlQuoteString(srv.Command)) b.WriteByte('\n') diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go index c040fd78..8f5cf6e6 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go @@ -61,6 +61,20 @@ func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { } } +func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { + dir := t.TempDir() + servers := map[string]mcpServerConfig{ + "docs": {Name: "docs", URL: "https://docs.example.com/mcp"}, + } + if err := writeCodexMCPConfig(dir, servers); err != nil { + t.Fatalf("write: %v", err) + } + body, _ := os.ReadFile(filepath.Join(dir, "config.toml")) + if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { + t.Fatalf("remote config: %s", body) + } +} + // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the // "fresh entries only" guarantee: callers allocate a brand-new // CODEX_HOME per prompt (BuildSessionPlan does this via allocCodexHome diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index f88ae261..2487dc77 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -342,6 +342,9 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) } srv := mcpServerConfig{Name: name} + if url, ok := entry["url"].(string); ok { + srv.URL = strings.TrimSpace(url) + } if cmd, ok := entry["command"].(string); ok { srv.Command = cmd } @@ -360,8 +363,11 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { } } } - if srv.Command == "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] missing command", name) + if srv.Command == "" && srv.URL == "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) + } + if srv.Command != "" && srv.URL != "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) } out[name] = srv } diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go index de8c6cf6..c1cbb524 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -55,7 +55,14 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, err } - if rawConfig := stringOpt(opts, "opencode_json"); rawConfig != "" { + rawConfig := stringOpt(opts, "opencode_json") + if servers, ok := opts["mcp_servers"]; ok && servers != nil { + rawConfig, err = mergeMCPConfig(rawConfig, servers) + if err != nil { + return result, err + } + } + if rawConfig != "" { configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) if err != nil { return result, err @@ -71,6 +78,70 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, nil } +func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + } + servers, ok := rawServers.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) + } + mcp, _ := config["mcp"].(map[string]any) + if mcp == nil { + mcp = map[string]any{} + } + for name, raw := range servers { + entry, ok := raw.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) + } + enabled := true + if value, ok := entry["enabled"].(bool); ok { + enabled = value + } + if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { + mcp[name] = map[string]any{ + "type": "remote", + "url": strings.TrimSpace(remoteURL), + "enabled": enabled, + } + continue + } + command, ok := entry["command"].(string) + if !ok || strings.TrimSpace(command) == "" { + return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) + } + commandParts := []string{command} + if args, ok := entry["args"].([]any); ok { + for _, arg := range args { + if value, ok := arg.(string); ok { + commandParts = append(commandParts, value) + } + } + } else if args, ok := entry["args"].([]string); ok { + commandParts = append(commandParts, args...) + } + local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} + if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { + local["environment"] = env + } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { + local["environment"] = env + } + mcp[name] = local + } + if len(mcp) > 0 { + config["mcp"] = mcp + } + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) + } + return string(encoded), nil +} + func resolveWorkDir(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go index 9106d9a5..bfe9e4fa 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -1,6 +1,7 @@ package opencode_test import ( + "encoding/json" "os" "path/filepath" "slices" @@ -92,6 +93,40 @@ func TestBuildArgsWritesManagedConfigUnderParsarHome(t *testing.T) { } } +func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ + "opencode_json": `{"provider":{}}`, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, + "docs": map[string]any{"url": "https://docs.example.com/mcp"}, + }, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + mcp := config["mcp"].(map[string]any) + remote := mcp["docs"].(map[string]any) + if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { + t.Fatalf("remote = %+v", remote) + } + local := mcp["local"].(map[string]any) + if local["type"] != "local" { + t.Fatalf("local = %+v", local) + } +} + func TestBuildArgsRejectsBadEnvShape(t *testing.T) { _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) if err == nil || !strings.Contains(err.Error(), "env") { diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 93408a61..e7b92a4f 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -888,6 +888,60 @@ "description": "Backend returned an error." } }, + "mcpDirectory": { + "title": "Connectors", + "description": "Browse curated hosted MCP servers and import their configuration into this workspace.", + "verified": "Verified", + "securityNotice": "Import only saves the configuration and does not run it immediately. The MCP can execute in a Runtime only after you enable it and bind it to an Agent.", + "source": { + "builtin": "Built-in catalog" + }, + "filters": { + "category": "Connector categories", + "allCategories": "All categories", + "verified": "Verified only", + "sort": "Sort connectors" + }, + "sort": { + "featured": "Featured", + "name": "Name" + }, + "actions": { + "import": "Import", + "installed": "Installed", + "back": "Back to connectors", + "viewCapability": "View Capability" + }, + "loadError": { + "title": "Couldn't load the connectors directory", + "description": "Couldn't load the connectors directory. Some connector details may be missing. Retry without leaving the Capability Marketplace." + }, + "empty": { + "title": "No connectors match these filters", + "description": "Try another search term or clear the category and Verified filters." + }, + "detail": { + "loadError": "Failed to load connector details", + "notFound": "Connector not found", + "version": "Version", + "transport": "Transport", + "endpoint": "Remote endpoint", + "authentication": "Authentication", + "noAuthentication": "Not required", + "publisher": "Publisher", + "homepage": "Homepage", + "repository": "Repository", + "openLink": "Open link" + }, + "import": { + "title": "Import {{name}}?", + "description": "Review the connector configuration. No token is required during import, and nothing will run or bind to an Agent.", + "success": "{{name}} was imported as a workspace MCP Capability.", + "failed": "The connector could not be imported.", + "importing": "Importing...", + "cancel": "Cancel" + } + }, "marketplaceDetail": { "badge": "From market", "notFound": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index aee566e4..bbcd627d 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -888,6 +888,60 @@ "description": "后端返回错误。" } }, + "mcpDirectory": { + "title": "连接器", + "description": "浏览经过筛选的托管 MCP 服务,并将配置导入当前工作区。", + "verified": "已验证", + "securityNotice": "导入只会保存配置,不会立即运行。启用并绑定 Agent 后,该 MCP 才可能在 Runtime 中执行。", + "source": { + "builtin": "内置目录" + }, + "filters": { + "category": "连接器分类", + "allCategories": "全部分类", + "verified": "仅已验证", + "sort": "连接器排序" + }, + "sort": { + "featured": "精选优先", + "name": "名称" + }, + "actions": { + "import": "导入", + "installed": "已安装", + "back": "返回连接器列表", + "viewCapability": "查看 Capability" + }, + "loadError": { + "title": "无法加载连接器目录", + "description": "无法加载连接器目录,部分连接器信息可能缺失。你可以直接重试,不会影响 Skill 市场和工作区 Capability。" + }, + "empty": { + "title": "没有符合筛选条件的连接器", + "description": "请更换搜索词,或清除分类和已验证筛选。" + }, + "detail": { + "loadError": "无法加载连接器详情", + "notFound": "未找到该连接器", + "version": "版本", + "transport": "传输方式", + "endpoint": "远程地址", + "authentication": "鉴权", + "noAuthentication": "无需鉴权", + "publisher": "发布者", + "homepage": "主页", + "repository": "代码仓库", + "openLink": "打开链接" + }, + "import": { + "title": "导入 {{name}}?", + "description": "请检查连接器配置。导入时不需要 Token,也不会运行 MCP 或绑定 Agent。", + "success": "已将 {{name}} 导入为工作区 MCP Capability。", + "failed": "无法导入该连接器。", + "importing": "正在导入...", + "cancel": "取消" + } + }, "marketplaceDetail": { "badge": "来自市场", "notFound": { diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index 9dc98ede..d9d12907 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -91,6 +91,37 @@ export interface EnabledMarketplaceAgent { version?: string } +export interface MCPDirectoryItem { + id: string + name: string + description: string + publisher: { name: string; url: string } + icon_url?: string + homepage_url?: string + repository_url?: string + verified: boolean + categories: string[] + featured_rank: number + version: string + transport: "streamable-http" + url?: string + installed: boolean + installed_capability_id: string | null +} + +export interface MCPDirectoryListResponse { + items: MCPDirectoryItem[] + updated_at: string + source: "builtin" +} + +export interface MCPDirectoryImportResponse { + installed: boolean + capability_id: string + created: boolean + capability?: Capability +} + interface MarketplaceListResponse { capabilities?: MarketplaceCapability[] marketplace?: MarketplaceCapability[] @@ -124,6 +155,8 @@ export const KEY_MARKETPLACE_DETAIL = (workspaceID: string, capabilityID: string export const KEY_TARGET_MARKETPLACE_INSTALLS = (workspaceID: string) => ["admin", "targetMarketplaceInstalls", workspaceID] as const export const KEY_INSTALL_COUNT = (workspaceID: string, capabilityID: string) => ["admin", "capabilityInstallCount", workspaceID, capabilityID] as const export const KEY_MARKETPLACE_ENABLED_AGENTS = (workspaceID: string, capabilityID: string) => ["admin", "marketplaceEnabledAgents", workspaceID, capabilityID] as const +export const KEY_MCP_DIRECTORY = (workspaceID: string) => ["admin", "mcpDirectory", workspaceID] as const +export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) => ["admin", "mcpDirectoryDetail", workspaceID, catalogID] as const async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] @@ -173,6 +206,20 @@ async function listEnabledAgents(workspaceID: string | null, capabilityID: strin return items.map(normalizeEnabledAgent) } +async function listMCPDirectory(workspaceID: string | null): Promise { + if (!workspaceID) return { items: [], updated_at: "", source: "builtin" } + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory`) +} + +async function getMCPDirectoryItem(workspaceID: string | null, catalogID: string | null): Promise { + if (!workspaceID || !catalogID) throw new Error("workspace and catalog item are required") + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}`) +} + +async function importMCPDirectoryItem(workspaceID: string, catalogID: string): Promise { + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/import`, { method: "POST" }) +} + function normalizeMarketplaceCapability(item: MarketplaceCapability): MarketplaceCapability { const id = item.id ?? item.capability_id ?? "" return { ...item, id, latest_version: item.latest_version ?? item.latest_published_version, created_at: item.created_at ?? item.latest_version_created_at, updated_at: item.updated_at ?? item.latest_version_created_at } @@ -262,6 +309,50 @@ export function useMarketplaceEnabledAgents(workspaceID: string | null, capabili }) } +export function useMCPDirectory(workspaceID: string | null) { + return useQuery({ + queryKey: KEY_MCP_DIRECTORY(workspaceID ?? "_none"), + queryFn: () => listMCPDirectory(workspaceID), + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useMCPDirectoryDetail(workspaceID: string | null, catalogID: string | null) { + return useQuery({ + queryKey: KEY_MCP_DIRECTORY_DETAIL(workspaceID ?? "_none", catalogID ?? "_none"), + queryFn: () => getMCPDirectoryItem(workspaceID, catalogID), + enabled: !!workspaceID && !!catalogID, + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useImportMCPDirectoryItem(workspaceID: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (catalogID: string) => { + if (!workspaceID) throw new Error("workspace is required") + return importMCPDirectoryItem(workspaceID, catalogID) + }, + retry: noUnreachableRetry, + onSuccess: (result, catalogID) => { + if (!workspaceID) return + qc.setQueryData(KEY_MCP_DIRECTORY(workspaceID), (current) => current ? { + ...current, + items: current.items.map((item) => item.id === catalogID + ? { ...item, installed: true, installed_capability_id: result.capability_id } + : item), + } : current) + qc.setQueryData(KEY_MCP_DIRECTORY_DETAIL(workspaceID, catalogID), (current) => current + ? { ...current, installed: true, installed_capability_id: result.capability_id } + : current) + void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID) }) + void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) + }, + }) +} + function invalidateMarketplace(qc: ReturnType, workspaceID: string | null, capabilityID?: string) { void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_LIST(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: KEY_TARGET_MARKETPLACE_INSTALLS(workspaceID ?? "_none") }) diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index 9a4cc07a..ab258aa6 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -11,16 +11,33 @@ import { marketplaceSourceName, useMarketplaceDetail, useMarketplaceList, type M import { useWorkspaceId } from "../../../lib/workspace" import { requiredCredentialsLabel } from "../capability-ui" import type { Capability } from "../../../lib/api-types" +import { MCPDirectory } from "./mcp-directory/MCPDirectory" interface MarketplaceTabProps { itemID: string | null query: string typeFilter: "mcp" | "skill" + canImport: boolean onSelectItem: (id: string | null) => void onInstall: (capability: MarketplaceCapability) => void + onViewCapability: (capabilityID: string) => void } -export function MarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInstall }: MarketplaceTabProps) { +export function MarketplaceTab(props: MarketplaceTabProps) { + const mcpItemID = props.itemID?.startsWith("mcp:") ? props.itemID.slice(4) : null + if (mcpItemID !== null || (!props.itemID && props.typeFilter === "mcp")) { + return props.onSelectItem(id ? `mcp:${id}` : null)} + onViewCapability={props.onViewCapability} + /> + } + return +} + +function SkillMarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInstall }: MarketplaceTabProps) { const { t, i18n } = useTranslation("admin") const workspaceID = useWorkspaceId() const marketplaceQ = useMarketplaceList(workspaceID) diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 60b2cda8..3690f4b6 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -279,8 +279,10 @@ export function CapabilitiesPage() { itemID={marketplaceItem} query={query} typeFilter={typeFilter} + canImport={isAdmin} onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })} onInstall={goToAgentsForCapability} + onViewCapability={(capabilityID) => navigate("capabilities", { id: capabilityID, tab: null, item: null })} /> ) : err ? ( void + onOpenChange: (open: boolean) => void + onConfirm: () => void +}) { + const { t } = useTranslation("admin") + return ( + + + + + {t("capabilities.mcpDirectory.import.title", { name: item?.name ?? "" })} + + {t("capabilities.mcpDirectory.import.description")} + + {loading ? ( +
+ + +
+ ) : error ? ( + + ) : item ? ( +
+
+

+ {t("capabilities.mcpDirectory.detail.endpoint")} +

+
+                {item.url}
+              
+
+
+

+ {t("capabilities.mcpDirectory.detail.authentication")} +

+

+ {t("capabilities.mcpDirectory.detail.noAuthentication")} +

+
+

+ {t("capabilities.mcpDirectory.securityNotice")} +

+
+ ) : null} + {mutationError ? ( +

+ {mutationError instanceof Error + ? mutationError.message + : t("capabilities.mcpDirectory.import.failed")} +

+ ) : null} + + + + +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx new file mode 100644 index 00000000..c6f88d85 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Check, PackageCheck, Server } from "lucide-react" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import { + useImportMCPDirectoryItem, + useMCPDirectory, + useMCPDirectoryDetail, +} from "../../../../lib/api-marketplace" +import { useWorkspaceId } from "../../../../lib/workspace" +import { DirectoryCard } from "./MCPDirectoryCard" +import { DirectoryDetail } from "./MCPDirectoryDetail" +import { ImportMCPDialog } from "./ImportMCPDialog" +import { filterMCPDirectoryItems, type DirectorySort } from "./filters" + +interface MCPDirectoryProps { + itemID: string | null + query: string + canImport: boolean + onSelectItem: (id: string | null) => void + onViewCapability: (capabilityID: string) => void +} + +export function MCPDirectory({ + itemID, + query, + canImport, + onSelectItem, + onViewCapability, +}: MCPDirectoryProps) { + const { t } = useTranslation("admin") + const workspaceID = useWorkspaceId() + const directoryQ = useMCPDirectory(workspaceID) + const importMut = useImportMCPDirectoryItem(workspaceID) + const [category, setCategory] = useState("") + const [verifiedOnly, setVerifiedOnly] = useState(false) + const [sort, setSort] = useState("featured") + const [confirmID, setConfirmID] = useState(null) + const [success, setSuccess] = useState<{ name: string; capabilityID: string } | null>(null) + const detailID = confirmID ?? itemID + const detailQ = useMCPDirectoryDetail(workspaceID, detailID) + + const items = useMemo(() => directoryQ.data?.items ?? [], [directoryQ.data?.items]) + const categories = useMemo( + () => Array.from(new Set(items.flatMap((item) => item.categories))).sort((left, right) => left.localeCompare(right)), + [items], + ) + const filtered = useMemo( + () => filterMCPDirectoryItems(items, { query, category, verifiedOnly, sort }), + [items, query, category, verifiedOnly, sort], + ) + const selectedSummary = items.find((item) => item.id === itemID) ?? null + const selected = detailQ.data?.id === itemID ? detailQ.data : selectedSummary + const confirmItem = detailQ.data?.id === confirmID ? detailQ.data : items.find((item) => item.id === confirmID) ?? null + + const requestImport = (id: string) => { + if (!canImport) return + importMut.reset() + setConfirmID(id) + } + const closeImportDialog = () => { + importMut.reset() + setConfirmID(null) + } + const confirmImport = () => { + if (!confirmID || !confirmItem || confirmItem.installed) return + importMut.mutate(confirmID, { + onSuccess: (result) => { + setSuccess({ name: confirmItem.name, capabilityID: result.capability_id }) + closeImportDialog() + }, + }) + } + + const importDialog = ( + void detailQ.refetch()} + onOpenChange={(open) => !open && closeImportDialog()} + onConfirm={confirmImport} + /> + ) + + if (itemID) { + return ( + <> + {success ? : null} + onSelectItem(null)} + onRetry={() => void detailQ.refetch()} + onImport={() => requestImport(itemID)} + onViewCapability={onViewCapability} + /> + {importDialog} + + ) + } + + return ( +
+
+
+
+
+ +

{t("capabilities.mcpDirectory.title")}

+
+

{t("capabilities.mcpDirectory.description")}

+
+ {directoryQ.data?.source ? {t(`capabilities.mcpDirectory.source.${directoryQ.data.source}`)} : null} +
+
+
+ setCategory("")}>{t("capabilities.mcpDirectory.filters.allCategories")} + {categories.map((value) => setCategory(value)}>{value})} +
+ + +
+
+ + {success ? : null} + {directoryQ.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => )} +
+ ) : directoryQ.error ? ( + void directoryQ.refetch()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((item) => onSelectItem(item.id)} onImport={() => requestImport(item.id)} onViewCapability={onViewCapability} />)} +
+ )} + {importDialog} +
+ ) +} + +function SuccessBanner({ success, onViewCapability }: { + success: { name: string; capabilityID: string } + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ +

{t("capabilities.mcpDirectory.import.success", { name: success.name })}

+ +
+ ) +} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) { + return +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx new file mode 100644 index 00000000..8cc8a01d --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -0,0 +1,51 @@ +import { ArrowRight, Check } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" +import { ConnectorIcon, VerifiedBadge } from "./shared" + +export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapability }: { + item: MCPDirectoryItem + canImport: boolean + onOpen: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ +
+ {item.installed && item.installed_capability_id ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx new file mode 100644 index 00000000..a3b49974 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx @@ -0,0 +1,155 @@ +import { ArrowLeft, Server, ShieldCheck } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" +import { ConnectorIcon, ExternalLinkRow, Metadata, VerifiedBadge } from "./shared" + +export function DirectoryDetail({ + item, + loading, + error, + canImport, + onBack, + onRetry, + onImport, + onViewCapability, +}: { + item: MCPDirectoryItem | null + loading: boolean + error: unknown + canImport: boolean + onBack: () => void + onRetry: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + if (loading && !item) + return ( +
+ + +
+ ) + if (error) + return ( + + ) + if (!item) + return ( + + {t("capabilities.mcpDirectory.actions.back")} + + } + /> + ) + return ( +
+ +
+
+ +
+
+

{item.name}

+ {item.verified ? : null} + {item.installed ? ( + {t("capabilities.mcpDirectory.actions.installed")} + ) : null} +
+

{item.publisher.name}

+

{item.description}

+
+
+
+ + + +
+
+
+
+

+ {t("capabilities.mcpDirectory.detail.endpoint")} +

+
+                {item.url}
+              
+
+
+
+ + {t("capabilities.mcpDirectory.securityNotice")} +
+
+
+ +
+
+ {item.installed && item.installed_capability_id ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts new file mode 100644 index 00000000..39b838c0 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/filters.ts @@ -0,0 +1,26 @@ +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" + +export type DirectorySort = "featured" | "name" + +interface DirectoryFilters { + query: string + category: string + verifiedOnly: boolean + sort: DirectorySort +} + +export function filterMCPDirectoryItems(items: MCPDirectoryItem[], filters: DirectoryFilters): MCPDirectoryItem[] { + const needle = filters.query.trim().toLocaleLowerCase() + const filtered = items.filter((item) => { + if (filters.category && !item.categories.includes(filters.category)) return false + if (filters.verifiedOnly && !item.verified) return false + if (!needle) return true + return [item.name, item.description, item.publisher.name, ...item.categories] + .join(" ") + .toLocaleLowerCase() + .includes(needle) + }) + return filtered.sort((left, right) => filters.sort === "name" + ? left.name.localeCompare(right.name) + : left.featured_rank - right.featured_rank || left.name.localeCompare(right.name)) +} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx new file mode 100644 index 00000000..c1d4540a --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/shared.tsx @@ -0,0 +1,43 @@ +import { ExternalLink, Server, ShieldCheck } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" + +export function ConnectorIcon({ item, large = false }: { item: MCPDirectoryItem; large?: boolean }) { + const size = large ? "h-14 w-14 rounded-xl" : "h-11 w-11 rounded-lg" + return ( + + {item.icon_url ? : } + + ) +} + +export function VerifiedBadge() { + const { t } = useTranslation("admin") + return {t("capabilities.mcpDirectory.verified")} +} + +export function Metadata({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return

{label}

{value}

+} + +export function ExternalLinkRow({ label, value, href }: { label: string; value: string; href?: string }) { + const safeHref = safeExternalURL(href) + return ( +
+

{label}

+ {safeHref ? {value} :

} +
+ ) +} + +function safeExternalURL(value?: string): string | undefined { + if (!value) return undefined + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined + } catch { + return undefined + } +} diff --git a/apps/web/src/pages/admin/capabilities/types.ts b/apps/web/src/pages/admin/capabilities/types.ts index bd83b0a4..f6eb2365 100644 --- a/apps/web/src/pages/admin/capabilities/types.ts +++ b/apps/web/src/pages/admin/capabilities/types.ts @@ -31,7 +31,9 @@ export interface CanonicalEnvValue { export interface CanonicalMCPServer { name: string - command: string + transport?: "stdio" | "streamable-http" + url?: string + command?: string args?: string[] env?: Record startup_timeout_sec?: number diff --git a/catalog/mcp/README.md b/catalog/mcp/README.md new file mode 100644 index 00000000..ad2446ac --- /dev/null +++ b/catalog/mcp/README.md @@ -0,0 +1,25 @@ +# MCP Connector Directory Catalog + +`catalog.json` is the repository-maintained source for Parsar's built-in MCP +Connector Directory. It contains metadata plus credential-free Streamable HTTP +endpoints. Importing an item saves a workspace capability and never executes it. + +## Updating the catalog + +- Add only MCP servers that can be verified in an official repository or the + official MCP Registry. +- Keep `id` stable and unique. Renaming an item does not require changing its + ID. +- Entries use an HTTPS `url` only. Built-in entries must + complete an MCP initialize request without headers, API keys, OAuth, or other + user credentials before they are added. +- Use only HTTPS URLs without embedded credentials. +- Update `updated_at` whenever catalog content changes. + +Validate changes with the Go tests in `server/internal/mcpcatalog` and the full +repository gate: + +```bash +go test ./server/internal/mcpcatalog +make check +``` diff --git a/catalog/mcp/catalog.json b/catalog/mcp/catalog.json new file mode 100644 index 00000000..1304aeb1 --- /dev/null +++ b/catalog/mcp/catalog.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "updated_at": "2026-07-23T00:00:00Z", + "items": [ + { + "id": "context7", + "name": "Context7", + "description": "Retrieve current library documentation and code examples for coding workflows.", + "publisher": { + "name": "Upstash", + "url": "https://github.com/upstash" + }, + "icon_url": "https://github.com/upstash.png?size=128", + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "verified": true, + "categories": ["Developer Tools", "Documentation"], + "featured_rank": 1, + "version": "1.0.0", + "transport": "streamable-http", + "server": { + "name": "context7", + "url": "https://mcp.context7.com/mcp" + } + }, + { + "id": "exa", + "name": "Exa", + "description": "Search the web and retrieve relevant content through Exa's hosted MCP server.", + "publisher": { + "name": "Exa", + "url": "https://exa.ai" + }, + "icon_url": "https://github.com/exa-labs.png?size=128", + "homepage_url": "https://exa.ai", + "repository_url": "https://github.com/exa-labs/exa-mcp-server", + "verified": true, + "categories": ["Developer Tools", "Search"], + "featured_rank": 2, + "version": "1.0.0", + "transport": "streamable-http", + "server": { + "name": "exa", + "url": "https://mcp.exa.ai/mcp" + } + }, + { + "id": "firecrawl", + "name": "Firecrawl", + "description": "Crawl and extract web content through Firecrawl's hosted MCP server.", + "publisher": { + "name": "Firecrawl", + "url": "https://firecrawl.dev" + }, + "icon_url": "https://github.com/mendableai.png?size=128", + "homepage_url": "https://firecrawl.dev", + "repository_url": "https://github.com/mendableai/firecrawl-mcp-server", + "verified": true, + "categories": ["Developer Tools", "Web"], + "featured_rank": 3, + "version": "1.0.0", + "transport": "streamable-http", + "server": { + "name": "firecrawl", + "url": "https://mcp.firecrawl.dev/v2/mcp" + } + } + ] +} diff --git a/catalog/mcp/catalog.schema.json b/catalog/mcp/catalog.schema.json new file mode 100644 index 00000000..1578588f --- /dev/null +++ b/catalog/mcp/catalog.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/MiniMax-AI-Dev/parsar/catalog/mcp/catalog.schema.json", + "title": "Parsar MCP Connector Directory Catalog", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "updated_at", "items"], + "properties": { + "schema_version": { "const": 1 }, + "updated_at": { "type": "string", "format": "date-time" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/item" } + } + }, + "$defs": { + "httpUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "publisher": { + "type": "object", + "additionalProperties": false, + "required": ["name", "url"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpUrl" } + } + }, + "server": { + "type": "object", + "additionalProperties": false, + "required": ["name", "url"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpUrl" } + } + }, + "item": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "publisher", + "verified", + "categories", + "featured_rank", + "version", + "transport", + "server" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "publisher": { "$ref": "#/$defs/publisher" }, + "icon_url": { "$ref": "#/$defs/httpUrl" }, + "homepage_url": { "$ref": "#/$defs/httpUrl" }, + "repository_url": { "$ref": "#/$defs/httpUrl" }, + "verified": { "type": "boolean" }, + "categories": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "featured_rank": { "type": "integer", "minimum": 1 }, + "version": { "type": "string", "minLength": 1 }, + "transport": { "const": "streamable-http" }, + "server": { "$ref": "#/$defs/server" } + } + } + } +} diff --git a/catalog/mcp/embed.go b/catalog/mcp/embed.go new file mode 100644 index 00000000..11c9d9ad --- /dev/null +++ b/catalog/mcp/embed.go @@ -0,0 +1,9 @@ +package mcpcatalogdata + +import _ "embed" + +//go:embed catalog.json +var CatalogJSON []byte + +//go:embed catalog.schema.json +var CatalogSchemaJSON []byte diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index fe850d59..1a23f31b 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -114,6 +114,9 @@ definitions: description: user id type: string deliveryID: + description: |- + DeliveryID is the caller's stable idempotency base. The agent-daemon + connector adds a unique suffix for each wire attempt before awaiting ack. type: string deviceID: type: string @@ -133,6 +136,7 @@ definitions: cancelled: type: boolean deliveryID: + description: DeliveryID follows PermissionDecision's stable-base semantics. type: string deviceID: type: string @@ -1128,6 +1132,70 @@ definitions: workspace_id: type: string type: object + mcpcatalog.Publisher: + properties: + name: + type: string + url: + type: string + type: object + mcpdirectory.importResponse: + properties: + capability: + $ref: '#/definitions/store.CapabilityRead' + capability_id: + type: string + created: + type: boolean + installed: + type: boolean + type: object + mcpdirectory.itemResponse: + properties: + categories: + items: + type: string + type: array + description: + type: string + featured_rank: + type: integer + homepage_url: + type: string + icon_url: + type: string + id: + type: string + installed: + type: boolean + installed_capability_id: + type: string + name: + type: string + publisher: + $ref: '#/definitions/mcpcatalog.Publisher' + repository_url: + type: string + transport: + type: string + url: + type: string + verified: + type: boolean + version: + type: string + type: object + mcpdirectory.listResponse: + properties: + items: + items: + $ref: '#/definitions/mcpdirectory.itemResponse' + type: array + source: + type: string + updated_at: + type: string + type: object password.errorResponse: properties: code: @@ -1509,6 +1577,43 @@ definitions: workspace_id: type: string type: object + store.CapabilityRead: + properties: + created_at: + type: string + creator_id: + type: string + deleted_at: + type: string + deprecated_at: + type: string + description: + type: string + id: + type: string + latest_version: + type: string + latest_version_created_at: + type: string + latest_version_id: + type: string + name: + type: string + required_credentials: + items: + $ref: '#/definitions/store.RequiredCredential' + type: array + status: + type: string + type: + type: string + updated_at: + type: string + visibility: + type: string + workspace_id: + type: string + type: object store.CredentialKindRead: properties: built_in: @@ -6914,6 +7019,136 @@ paths: summary: Resolve a pending approval or user question tags: - interactions + /api/v1/workspaces/{workspaceID}/mcp-directory: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcpdirectory.listResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: List MCP Connector Directory items + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}: + get: + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcpdirectory.itemResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get an MCP Connector Directory item + tags: + - mcp-directory + /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import: + post: + description: Saves the catalog entry as a private workspace MCP capability. + It does not execute the MCP server or bind it to an agent. + parameters: + - description: workspace id + in: path + name: workspaceID + required: true + type: string + - description: catalog item id + in: path + name: catalogID + required: true + type: string + produces: + - application/json + responses: + "200": + description: already installed + schema: + $ref: '#/definitions/mcpdirectory.importResponse' + "201": + description: imported + schema: + $ref: '#/definitions/mcpdirectory.importResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + summary: Import an MCP Connector Directory item + tags: + - mcp-directory /api/v1/workspaces/{workspaceID}/members: get: description: Returns members of the workspace. Caller must be a workspace member. diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1f50fd4d..61c6c9f3 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -40,6 +40,7 @@ import ( agentdaemongateway "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/server/internal/api" imhistoryapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/imhistoryapi" + mcpdirectoryapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/mcpdirectory" runtimeapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/runtime" specmemapi "github.com/MiniMax-AI-Dev/parsar/server/internal/api/specmem" "github.com/MiniMax-AI-Dev/parsar/server/internal/audit" @@ -66,6 +67,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/inbound/teamsrunner" "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/inflight" "github.com/MiniMax-AI-Dev/parsar/server/internal/interaction" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" "github.com/MiniMax-AI-Dev/parsar/server/internal/otlp" "github.com/MiniMax-AI-Dev/parsar/server/internal/runstream" "github.com/MiniMax-AI-Dev/parsar/server/internal/runtime/scheduler" @@ -704,10 +706,15 @@ func main() { Store: dbStore, SharedRuntimeToken: strings.TrimSpace(envLookup("PARSAR_SHARED_RUNTIME_TOKEN")), } + mcpCatalog := mcpcatalog.New(mcpcatalog.Options{}) sessionStore := auth.NewPostgresSessionStore(sqlc.New(pool)) authMw := auth.NewMiddleware(sessionStore).WithDevAuth(cfg.Auth.DevAuth) r.Group(func(r chi.Router) { r.Use(authMw.Require) + mcpdirectoryapi.RegisterRoutes(r, mcpdirectoryapi.Deps{ + Catalog: mcpCatalog, + Store: dbStore, + }) runtimeapi.RegisterAdminRoutes(r, runtimeDeps) }) runtimeapi.RegisterRunnerRoutes(r, runtimeDeps) diff --git a/server/internal/api/mcpdirectory/handler.go b/server/internal/api/mcpdirectory/handler.go new file mode 100644 index 00000000..e3041df5 --- /dev/null +++ b/server/internal/api/mcpdirectory/handler.go @@ -0,0 +1,309 @@ +// Package mcpdirectory exposes the repository-backed MCP Connector Directory. +// Directory items are imported as ordinary workspace MCP capabilities; this +// package does not execute servers or create agent bindings. +package mcpdirectory + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type catalogLoader interface { + Load(ctx context.Context) (mcpcatalog.Snapshot, error) +} + +type directoryStore interface { + auth.RoleStore + ListMCPDirectoryInstalls(ctx context.Context, workspaceID string) ([]store.MCPDirectoryInstall, error) + ImportCapability(ctx context.Context, input store.ImportCapabilityInput) (store.ImportCapabilityResult, error) +} + +type Deps struct { + Catalog catalogLoader + Store directoryStore +} + +type handler struct { + deps Deps +} + +type itemResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Publisher mcpcatalog.Publisher `json:"publisher"` + IconURL string `json:"icon_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Verified bool `json:"verified"` + Categories []string `json:"categories"` + FeaturedRank int `json:"featured_rank"` + Version string `json:"version"` + Transport string `json:"transport"` + URL string `json:"url,omitempty"` + Installed bool `json:"installed"` + InstalledCapabilityID *string `json:"installed_capability_id"` +} + +type listResponse struct { + Items []itemResponse `json:"items"` + UpdatedAt string `json:"updated_at"` + Source string `json:"source"` +} + +type importResponse struct { + Installed bool `json:"installed"` + CapabilityID string `json:"capability_id"` + Created bool `json:"created"` + Capability *store.CapabilityRead `json:"capability,omitempty"` +} + +type sourcePayload struct { + SourceFormat string `json:"source_format"` + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CatalogSource string `json:"catalog_source"` +} + +func RegisterRoutes(r chi.Router, deps Deps) { + h := &handler{deps: deps} + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory", h.list) + r.Get("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}", h.get) + r.Post("/api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import", h.importItem) +} + +// list godoc +// +// @Summary List MCP Connector Directory items +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Success 200 {object} listResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory [get] +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, false) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + byCatalog := installMap(installs) + items := make([]itemResponse, 0, len(snapshot.Catalog.Items)) + for _, item := range snapshot.Catalog.Items { + items = append(items, summarizeItem(item, byCatalog[item.ID])) + } + writeJSON(w, http.StatusOK, listResponse{ + Items: items, + UpdatedAt: snapshot.Catalog.UpdatedAt, + Source: string(snapshot.Source), + }) +} + +// get godoc +// +// @Summary Get an MCP Connector Directory item +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Success 200 {object} itemResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID} [get] +func (h *handler) get(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, false) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := snapshot.Find(chi.URLParam(r, "catalogID")) + if !found { + writeError(w, http.StatusNotFound, "connector_not_found") + return + } + response := summarizeItem(item, installMap(installs)[item.ID]) + response.URL = item.Server.URL + writeJSON(w, http.StatusOK, response) +} + +// importItem godoc +// +// @Summary Import an MCP Connector Directory item +// @Description Saves the catalog entry as a private workspace MCP capability. It does not execute the MCP server or bind it to an agent. +// @Tags mcp-directory +// @Produce json +// @Param workspaceID path string true "workspace id" +// @Param catalogID path string true "catalog item id" +// @Success 200 {object} importResponse "already installed" +// @Success 201 {object} importResponse "imported" +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/workspaces/{workspaceID}/mcp-directory/{catalogID}/import [post] +func (h *handler) importItem(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := h.authorize(w, r, true) + if !ok { + return + } + snapshot, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := snapshot.Find(chi.URLParam(r, "catalogID")) + if !found { + writeError(w, http.StatusNotFound, "connector_not_found") + return + } + if existing, installed := installMap(installs)[item.ID]; installed { + writeJSON(w, http.StatusOK, importResponse{Installed: true, CapabilityID: existing.CapabilityID}) + return + } + + payload, err := json.Marshal(sourcePayload{ + SourceFormat: "mcp_catalog", + CatalogID: item.ID, + CatalogVersion: item.Version, + CatalogSource: string(snapshot.Source), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "catalog_source_encode_failed") + return + } + result, err := h.deps.Store.ImportCapability(r.Context(), store.ImportCapabilityInput{ + WorkspaceID: workspaceID, + Name: item.Name, + Description: item.Description, + Visibility: "workspace", + Type: "mcp", + CreatorID: auth.UserIDFromContext(r.Context()), + Version: item.Version, + SourcePayload: payload, + Spec: item.CanonicalSpec(), + }) + if err != nil { + if errors.Is(err, store.ErrCapabilityNameTaken) { + // A concurrent identical import can lose the capability name race. + // Re-read provenance before reporting a real name conflict. + if current, listErr := h.deps.Store.ListMCPDirectoryInstalls(r.Context(), workspaceID); listErr == nil { + if existing, installed := installMap(current)[item.ID]; installed { + writeJSON(w, http.StatusOK, importResponse{Installed: true, CapabilityID: existing.CapabilityID}) + return + } + } + writeError(w, http.StatusConflict, "capability_name_conflict") + return + } + writeError(w, http.StatusInternalServerError, "connector_import_failed") + return + } + writeJSON(w, http.StatusCreated, importResponse{ + Installed: true, + CapabilityID: result.Capability.ID, + Created: true, + Capability: &result.Capability, + }) +} + +func (h *handler) authorize(w http.ResponseWriter, r *http.Request, admin bool) (string, bool) { + if h.deps.Catalog == nil || h.deps.Store == nil { + writeError(w, http.StatusServiceUnavailable, "mcp_directory_unavailable") + return "", false + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if _, err := uuid.Parse(workspaceID); err != nil { + writeError(w, http.StatusBadRequest, "invalid_workspace_id") + return "", false + } + allowed := []string{"owner", "admin", "member", "viewer"} + if admin { + allowed = []string{"owner", "admin"} + } + if err := auth.RequireWorkspaceRole(r.Context(), h.deps.Store, workspaceID, allowed...); err != nil { + switch { + case errors.Is(err, auth.ErrUnauthenticated): + writeError(w, http.StatusUnauthorized, "unauthenticated") + case errors.Is(err, auth.ErrForbidden), errors.Is(err, auth.ErrNotMember): + writeError(w, http.StatusForbidden, "forbidden") + default: + writeError(w, http.StatusInternalServerError, "workspace_authorization_failed") + } + return "", false + } + return workspaceID, true +} + +func (h *handler) load(w http.ResponseWriter, r *http.Request, workspaceID string) (mcpcatalog.Snapshot, []store.MCPDirectoryInstall, bool) { + snapshot, err := h.deps.Catalog.Load(r.Context()) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "mcp_catalog_unavailable") + return mcpcatalog.Snapshot{}, nil, false + } + installs, err := h.deps.Store.ListMCPDirectoryInstalls(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "directory_install_state_failed") + return mcpcatalog.Snapshot{}, nil, false + } + return snapshot, installs, true +} + +func installMap(installs []store.MCPDirectoryInstall) map[string]store.MCPDirectoryInstall { + result := make(map[string]store.MCPDirectoryInstall, len(installs)) + for _, install := range installs { + result[install.CatalogID] = install + } + return result +} + +func summarizeItem(item mcpcatalog.Item, install store.MCPDirectoryInstall) itemResponse { + var installedCapabilityID *string + if install.CapabilityID != "" { + id := install.CapabilityID + installedCapabilityID = &id + } + return itemResponse{ + ID: item.ID, + Name: item.Name, + Description: item.Description, + Publisher: item.Publisher, + IconURL: item.IconURL, + HomepageURL: item.HomepageURL, + RepositoryURL: item.RepositoryURL, + Verified: item.Verified, + Categories: append([]string(nil), item.Categories...), + FeaturedRank: item.FeaturedRank, + Version: item.Version, + Transport: item.Transport, + Installed: install.CapabilityID != "", + InstalledCapabilityID: installedCapabilityID, + } +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} diff --git a/server/internal/api/mcpdirectory/handler_test.go b/server/internal/api/mcpdirectory/handler_test.go new file mode 100644 index 00000000..79b93a0b --- /dev/null +++ b/server/internal/api/mcpdirectory/handler_test.go @@ -0,0 +1,220 @@ +package mcpdirectory + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/mcpcatalog" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +const ( + testWorkspaceID = "00000000-0000-0000-0000-000000000011" + testUserID = "00000000-0000-0000-0000-000000000022" + testCapabilityID = "00000000-0000-0000-0000-000000000033" +) + +type fakeCatalog struct { + snapshot mcpcatalog.Snapshot + err error +} + +func (f fakeCatalog) Load(context.Context) (mcpcatalog.Snapshot, error) { return f.snapshot, f.err } + +type fakeDirectoryStore struct { + role string + roleErr error + installs []store.MCPDirectoryInstall + listErr error + importErr error + concurrentInstall bool + imported *store.ImportCapabilityInput +} + +func (f *fakeDirectoryStore) GetWorkspaceMemberRole(context.Context, string, string) (string, error) { + if f.roleErr != nil { + return "", f.roleErr + } + return f.role, nil +} + +func (f *fakeDirectoryStore) ListMCPDirectoryInstalls(context.Context, string) ([]store.MCPDirectoryInstall, error) { + return append([]store.MCPDirectoryInstall(nil), f.installs...), f.listErr +} + +func (f *fakeDirectoryStore) ImportCapability(_ context.Context, input store.ImportCapabilityInput) (store.ImportCapabilityResult, error) { + f.imported = &input + if f.importErr != nil { + if f.concurrentInstall { + f.installs = append(f.installs, store.MCPDirectoryInstall{CatalogID: "context7", CatalogVersion: "1.0.0", CapabilityID: testCapabilityID}) + } + return store.ImportCapabilityResult{}, f.importErr + } + f.installs = append(f.installs, store.MCPDirectoryInstall{CatalogID: "context7", CatalogVersion: "1.0.0", CapabilityID: testCapabilityID}) + return store.ImportCapabilityResult{Capability: store.CapabilityRead{ID: testCapabilityID, Name: input.Name, Type: input.Type}}, nil +} + +func TestDirectoryReadAllowsWorkspaceMember(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response listResponse + decodeResponse(t, rec, &response) + if len(response.Items) != 1 || response.Items[0].ID != "context7" { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryImportRequiresAdmin(t *testing.T) { + for _, role := range []string{"member", "viewer"} { + t.Run(role, func(t *testing.T) { + fs := &fakeDirectoryStore{role: role} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/context7/import") + if rec.Code != http.StatusForbidden || fs.imported != nil { + t.Fatalf("status=%d imported=%v body=%s", rec.Code, fs.imported != nil, rec.Body.String()) + } + }) + } +} + +func TestDirectoryImportUsesServerCatalogAndCreatesNoSecretsOrBindings(t *testing.T) { + for _, role := range []string{"owner", "admin"} { + t.Run(role, func(t *testing.T) { + fs := &fakeDirectoryStore{role: role} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/context7/import") + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + input := fs.imported + if input == nil || input.Type != "mcp" || input.Visibility != "workspace" || input.CreatorID != testUserID { + t.Fatalf("input=%+v", input) + } + if len(input.InlineSecrets) != 0 { + t.Fatalf("inline secrets=%+v", input.InlineSecrets) + } + if input.Spec.MCP == nil || input.Spec.MCP.Servers[0].URL != "https://mcp.context7.com/mcp" { + t.Fatalf("spec=%+v", input.Spec) + } + var source sourcePayload + if err := json.Unmarshal(input.SourcePayload, &source); err != nil { + t.Fatal(err) + } + if source.SourceFormat != "mcp_catalog" || source.CatalogID != "context7" || source.CatalogSource != "builtin" { + t.Fatalf("source=%+v", source) + } + }) + } +} + +func TestDirectoryImportIsIdempotent(t *testing.T) { + fs := &fakeDirectoryStore{role: "admin", installs: []store.MCPDirectoryInstall{{CatalogID: "context7", CapabilityID: testCapabilityID}}} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/context7/import") + if rec.Code != http.StatusOK || fs.imported != nil { + t.Fatalf("status=%d imported=%v body=%s", rec.Code, fs.imported != nil, rec.Body.String()) + } + var response importResponse + decodeResponse(t, rec, &response) + if !response.Installed || response.CapabilityID != testCapabilityID || response.Created { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryImportRecoversConcurrentIdenticalImport(t *testing.T) { + fs := &fakeDirectoryStore{role: "admin", importErr: store.ErrCapabilityNameTaken, concurrentInstall: true} + rec := request(t, fs, http.MethodPost, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/context7/import") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryUnknownCatalogItem(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/unknown") + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryDetailIncludesStreamableHTTPURL(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + snapshot := testSnapshot() + snapshot.Catalog.Items = []mcpcatalog.Item{{ + ID: "docs", Name: "Docs", Description: "Search docs.", + Publisher: mcpcatalog.Publisher{Name: "Publisher", URL: "https://example.com"}, + Verified: true, Categories: []string{"Documentation"}, FeaturedRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Server: mcpcatalog.Server{Name: "docs", URL: "https://docs.example.com/mcp"}, + }} + rec := requestWithSnapshot(t, fs, snapshot, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory/docs") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response itemResponse + decodeResponse(t, rec, &response) + if response.Transport != "streamable-http" || response.URL != "https://docs.example.com/mcp" { + t.Fatalf("response=%+v", response) + } +} + +func TestDirectoryRejectsNonMember(t *testing.T) { + fs := &fakeDirectoryStore{roleErr: store.ErrNotMember} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/mcp-directory") + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDirectoryRejectsInvalidWorkspaceID(t *testing.T) { + fs := &fakeDirectoryStore{role: "member"} + rec := request(t, fs, http.MethodGet, "/api/v1/workspaces/not-a-uuid/mcp-directory") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func request(t *testing.T, fs *fakeDirectoryStore, method, path string) *httptest.ResponseRecorder { + return requestWithSnapshot(t, fs, testSnapshot(), method, path) +} + +func requestWithSnapshot(t *testing.T, fs *fakeDirectoryStore, snapshot mcpcatalog.Snapshot, method, path string) *httptest.ResponseRecorder { + t.Helper() + router := chi.NewRouter() + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), testUserID))) + }) + }) + RegisterRoutes(router, Deps{Catalog: fakeCatalog{snapshot: snapshot}, Store: fs}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func testSnapshot() mcpcatalog.Snapshot { + return mcpcatalog.Snapshot{Source: mcpcatalog.SourceBuiltin, Catalog: mcpcatalog.Catalog{ + SchemaVersion: 1, + UpdatedAt: "2026-07-22T00:00:00Z", + Items: []mcpcatalog.Item{{ + ID: "context7", Name: "Context7", Description: "Search current documentation.", + Publisher: mcpcatalog.Publisher{Name: "MCP", URL: "https://example.com"}, + Verified: true, Categories: []string{"Documentation"}, FeaturedRank: 1, + Version: "1.0.0", Transport: "streamable-http", + Server: mcpcatalog.Server{Name: "context7", URL: "https://mcp.context7.com/mcp"}, + }}, + }} +} + +func decodeResponse(t *testing.T, rec *httptest.ResponseRecorder, target any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } +} diff --git a/server/internal/capability/canonical/mcp.go b/server/internal/capability/canonical/mcp.go index 3b123eb1..a32622bf 100644 --- a/server/internal/capability/canonical/mcp.go +++ b/server/internal/capability/canonical/mcp.go @@ -2,29 +2,43 @@ package canonical import ( "fmt" + "net/url" "strings" ) -// MCPSpec carries one or more MCP stdio servers. HTTP transport is not -// modeled; the import pipeline only accepts stdio. +const ( + MCPTransportStdio = "stdio" + MCPTransportStreamableHTTP = "streamable-http" +) + +// MCPSpec carries one or more MCP servers. Existing specs omit transport and +// therefore continue to resolve as stdio. type MCPSpec struct { Servers []MCPServer `json:"servers"` } -// MCPServer is one launchable MCP stdio server. Command + Args stay separate -// because renderers join them differently (Claude Code accepts string or -// array; OpenCode wants an array). +// MCPServer is either a launchable stdio process or a streamable HTTP URL. +// Command + Args stay separate because renderers join them differently. // // StartupTimeoutSec=0 means "use scaffold default"; preserved because Codex's // TOML uses it explicitly. type MCPServer struct { Name string `json:"name"` - Command string `json:"command"` + Transport string `json:"transport,omitempty"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]EnvValue `json:"env,omitempty"` StartupTimeoutSec int `json:"startup_timeout_sec,omitempty"` } +func (s MCPServer) EffectiveTransport() string { + if strings.TrimSpace(s.Transport) == "" { + return MCPTransportStdio + } + return strings.ToLower(strings.TrimSpace(s.Transport)) +} + // Validate checks structure only — it does NOT resolve cross-table references // (e.g. SecretID existence). Commit-time checks live in the import handler. func (m MCPSpec) Validate() error { @@ -49,12 +63,28 @@ func (s MCPServer) Validate() error { if strings.TrimSpace(s.Name) == "" { return fmt.Errorf("%w: server name is required", ErrInvalidMCP) } - if strings.TrimSpace(s.Command) == "" { - return fmt.Errorf("%w: server %q: command is required", ErrInvalidMCP, s.Name) - } if s.StartupTimeoutSec < 0 { return fmt.Errorf("%w: server %q: startup_timeout_sec must be >= 0", ErrInvalidMCP, s.Name) } + switch s.EffectiveTransport() { + case MCPTransportStdio: + if strings.TrimSpace(s.Command) == "" { + return fmt.Errorf("%w: server %q: command is required", ErrInvalidMCP, s.Name) + } + if strings.TrimSpace(s.URL) != "" { + return fmt.Errorf("%w: server %q: stdio transport must not set url", ErrInvalidMCP, s.Name) + } + case MCPTransportStreamableHTTP: + parsed, err := url.Parse(strings.TrimSpace(s.URL)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return fmt.Errorf("%w: server %q: streamable-http url must be an http or https URL without embedded credentials", ErrInvalidMCP, s.Name) + } + if strings.TrimSpace(s.Command) != "" || len(s.Args) > 0 || len(s.Env) > 0 { + return fmt.Errorf("%w: server %q: streamable-http transport must not set command, args, or env", ErrInvalidMCP, s.Name) + } + default: + return fmt.Errorf("%w: server %q: unsupported transport %q", ErrInvalidMCP, s.Name, s.Transport) + } for name, value := range s.Env { if strings.TrimSpace(name) == "" { return fmt.Errorf("%w: server %q: empty env name", ErrInvalidMCP, s.Name) diff --git a/server/internal/capability/canonical/spec_test.go b/server/internal/capability/canonical/spec_test.go index 05a1fba9..a5dc4cc5 100644 --- a/server/internal/capability/canonical/spec_test.go +++ b/server/internal/capability/canonical/spec_test.go @@ -144,3 +144,18 @@ func TestMCPSpec_ValidateDetectsDuplicateName(t *testing.T) { t.Fatalf("expected duplicate name error, got %v", err) } } + +func TestMCPSpec_ValidateStreamableHTTP(t *testing.T) { + s := MCPSpec{Servers: []MCPServer{{ + Name: "docs", + Transport: MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}} + if err := s.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + s.Servers[0].Command = "npx" + if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "must not set command") { + t.Fatalf("expected remote command rejection, got %v", err) + } +} diff --git a/server/internal/capability/render/claudecode.go b/server/internal/capability/render/claudecode.go index fb76d4db..ed0b04d9 100644 --- a/server/internal/capability/render/claudecode.go +++ b/server/internal/capability/render/claudecode.go @@ -28,6 +28,8 @@ type claudeCodeMCPDocument struct { } type claudeCodeMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -73,6 +75,10 @@ func renderClaudeCodeMCP(s *canonical.MCPSpec) (Output, error) { } doc := claudeCodeMCPDocument{MCPServers: make(map[string]claudeCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = claudeCodeMCPServer{Type: "http", URL: srv.URL} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("claudecode render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/codex.go b/server/internal/capability/render/codex.go index eaf6037c..109d8408 100644 --- a/server/internal/capability/render/codex.go +++ b/server/internal/capability/render/codex.go @@ -36,6 +36,8 @@ type codexMCPDocument struct { } type codexMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -69,6 +71,10 @@ func renderCodexMCP(s *canonical.MCPSpec) (Output, error) { } doc := codexMCPDocument{MCPServers: make(map[string]codexMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = codexMCPServer{Type: "http", URL: srv.URL} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("codex render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/opencode.go b/server/internal/capability/render/opencode.go index a932ea1c..a1b5799c 100644 --- a/server/internal/capability/render/opencode.go +++ b/server/internal/capability/render/opencode.go @@ -25,6 +25,8 @@ type openCodeMCPDocument struct { // Enabled is always true — per-server enable/disable is not modeled in // canonical.Spec; every server in a Spec is wanted. type openCodeMCPServer struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -58,6 +60,10 @@ func renderOpenCodeMCP(s *canonical.MCPSpec) (Output, error) { } doc := openCodeMCPDocument{MCPServers: make(map[string]openCodeMCPServer, len(s.Servers))} for _, srv := range s.Servers { + if srv.EffectiveTransport() == canonical.MCPTransportStreamableHTTP { + doc.MCPServers[srv.Name] = openCodeMCPServer{Type: "remote", URL: srv.URL, Enabled: true} + continue + } env, err := renderEnvMap(srv.Env) if err != nil { return Output{}, fmt.Errorf("opencode render: server %q: %w", srv.Name, err) diff --git a/server/internal/capability/render/renderer_test.go b/server/internal/capability/render/renderer_test.go index 7916a2aa..7f13a6af 100644 --- a/server/internal/capability/render/renderer_test.go +++ b/server/internal/capability/render/renderer_test.go @@ -45,6 +45,18 @@ func skillFixture() canonical.Spec { } } +func remoteMCPFixture() canonical.Spec { + return canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: "docs", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}}, + } +} + // TestFor_KnownTargets catches "added a Target without wiring For()". func TestFor_KnownTargets(t *testing.T) { for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi} { @@ -135,6 +147,21 @@ func TestClaudeCodeRenderer_MCPGolden(t *testing.T) { } } +func TestClaudeCodeRenderer_StreamableHTTP(t *testing.T) { + out, err := claudeCodeRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got claudeCodeMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "http" || srv.URL != "https://docs.example.com/mcp" || srv.Command != "" { + t.Fatalf("server = %+v", srv) + } +} + func TestClaudeCodeRenderer_SkillGolden(t *testing.T) { out, err := claudeCodeRenderer{}.Render(context.Background(), skillFixture()) if err != nil { @@ -212,6 +239,36 @@ func TestCodexRenderer_MCPGolden(t *testing.T) { } } +func TestCodexRenderer_StreamableHTTP(t *testing.T) { + out, err := codexRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got codexMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "http" || srv.URL != "https://docs.example.com/mcp" || srv.Command != "" { + t.Fatalf("server = %+v", srv) + } +} + +func TestOpenCodeRenderer_StreamableHTTP(t *testing.T) { + out, err := openCodeRenderer{}.Render(context.Background(), remoteMCPFixture()) + if err != nil { + t.Fatalf("render: %v", err) + } + var got openCodeMCPDocument + if err := json.Unmarshal(out.Content, &got); err != nil { + t.Fatal(err) + } + srv := got.MCPServers["docs"] + if srv.Type != "remote" || srv.URL != "https://docs.example.com/mcp" || !srv.Enabled { + t.Fatalf("server = %+v", srv) + } +} + // TestCodexRenderer_SkillAndPluginUnsupported pins the soft-degrade // contract — codex must return ErrUnsupported for Skill and Plugin so // the agentdaemon connector skips them with a Disabled notice instead diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index ad874a7e..46a968a0 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -600,6 +600,17 @@ func (c *Connector) resolveMCPCapability( // Build the daemon-consumable map: server_name → config object. result := map[string]any{} for name, server := range parsed.MCPServers { + if server.URL != "" { + entry := map[string]any{"url": server.URL} + if server.Type != "" { + entry["type"] = server.Type + } + if server.Enabled != nil { + entry["enabled"] = *server.Enabled + } + result[name] = entry + continue + } env := map[string]string{} for key, value := range server.Env { if match := credentialPlaceholderRe.FindStringSubmatch(value); match != nil { @@ -673,9 +684,9 @@ func (c *Connector) resolveMCPCapability( // // - values: kind → decrypted plaintext // - sharedSecretIDs: kind → secret_id (only for shared bindings; used -// by audit emits) +// by audit emits) // - missing: kinds the resolver could not fulfil (personal-binding -// kinds whose initiator has not configured the credential) +// kinds whose initiator has not configured the credential) // // A missing entry DOES NOT short-circuit — the caller treats them as // "this MCP must be disabled this turn". Decrypt / payload-shape errors @@ -848,9 +859,12 @@ type claudeCodeMCPDocument struct { } type claudeCodeMCPServerEntry struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // resolveSkillCapability mirrors resolvePluginCapability — skill and diff --git a/server/internal/connector/agentdaemon/capability_runtime_test.go b/server/internal/connector/agentdaemon/capability_runtime_test.go index d25fab12..f3e75f5c 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_test.go @@ -427,6 +427,26 @@ func TestResolveCapabilityAdditions_MCPNoCreds(t *testing.T) { } } +func TestResolveCapabilityAdditions_MCPStreamableHTTP(t *testing.T) { + row := newMCPRow(t, "mcp-http", "docs", []canonical.MCPServer{{ + Name: "docs", + Transport: canonical.MCPTransportStreamableHTTP, + URL: "https://docs.example.com/mcp", + }}, nil) + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + server := got.MCPServers["docs"].(map[string]any) + if server["type"] != "http" || server["url"] != "https://docs.example.com/mcp" { + t.Fatalf("server = %+v", server) + } +} + func TestResolveCapabilityAdditions_MCPWithCredential(t *testing.T) { svc := testSecretsService(t) ciphertext := encryptPayload(t, svc, map[string]any{"token": "ghp_realtoken123"}) diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 882ff2d4..a021d26c 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3572,6 +3572,23 @@ where c.workspace_id = @workspace_id::uuid and c.deleted_at is null order by c.name asc, c.created_at desc; +-- name: ListMCPDirectoryInstalls :many +-- Catalog provenance lives on capability versions rather than the capability +-- row. Keep the newest matching provenance per catalog id so a later catalog +-- re-import can update catalog_version without creating a second install. +select distinct on (cv.source_payload->>'catalog_id') + coalesce(cv.source_payload->>'catalog_id', '')::text as catalog_id, + coalesce(cv.source_payload->>'catalog_version', '')::text as catalog_version, + c.id::text as capability_id +from capability c +join capability_version cv on cv.capability_id = c.id +where c.workspace_id = @workspace_id::uuid + and c.type = 'mcp' + and c.deleted_at is null + and cv.source_payload->>'source_format' = 'mcp_catalog' + and coalesce(cv.source_payload->>'catalog_id', '') <> '' +order by cv.source_payload->>'catalog_id', cv.created_at desc, cv.id desc; + -- name: UpdateCapability :one update capability set name = @name, diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index 7cae5414..d7b04318 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8280,6 +8280,50 @@ func (q *Queries) ListIdleSandboxBindings(ctx context.Context, arg ListIdleSandb return items, nil } +const listMCPDirectoryInstalls = `-- name: ListMCPDirectoryInstalls :many +select distinct on (cv.source_payload->>'catalog_id') + coalesce(cv.source_payload->>'catalog_id', '')::text as catalog_id, + coalesce(cv.source_payload->>'catalog_version', '')::text as catalog_version, + c.id::text as capability_id +from capability c +join capability_version cv on cv.capability_id = c.id +where c.workspace_id = $1::uuid + and c.type = 'mcp' + and c.deleted_at is null + and cv.source_payload->>'source_format' = 'mcp_catalog' + and coalesce(cv.source_payload->>'catalog_id', '') <> '' +order by cv.source_payload->>'catalog_id', cv.created_at desc, cv.id desc +` + +type ListMCPDirectoryInstallsRow struct { + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CapabilityID string `json:"capability_id"` +} + +// Catalog provenance lives on capability versions rather than the capability +// row. Keep the newest matching provenance per catalog id so a later catalog +// re-import can update catalog_version without creating a second install. +func (q *Queries) ListMCPDirectoryInstalls(ctx context.Context, workspaceID pgtype.UUID) ([]ListMCPDirectoryInstallsRow, error) { + rows, err := q.db.Query(ctx, listMCPDirectoryInstalls, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMCPDirectoryInstallsRow{} + for rows.Next() { + var i ListMCPDirectoryInstallsRow + if err := rows.Scan(&i.CatalogID, &i.CatalogVersion, &i.CapabilityID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMarketplaceCapabilities = `-- name: ListMarketplaceCapabilities :many with installed as ( select distinct ac.capability_id diff --git a/server/internal/mcpcatalog/catalog_test.go b/server/internal/mcpcatalog/catalog_test.go new file mode 100644 index 00000000..123f5941 --- /dev/null +++ b/server/internal/mcpcatalog/catalog_test.go @@ -0,0 +1,80 @@ +package mcpcatalog + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestBuiltinCatalogLoads(t *testing.T) { + snapshot, err := New(Options{}).Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if snapshot.Source != SourceBuiltin { + t.Fatalf("source = %q", snapshot.Source) + } + want := []string{"context7", "exa", "firecrawl"} + if len(snapshot.Catalog.Items) != len(want) { + t.Fatalf("items = %d, want %d", len(snapshot.Catalog.Items), len(want)) + } + for index, id := range want { + item := snapshot.Catalog.Items[index] + if item.ID != id { + t.Fatalf("item[%d] = %q, want %q", index, item.ID, id) + } + if err := item.CanonicalSpec().Validate(); err != nil { + t.Fatalf("item %q canonical spec: %v", item.ID, err) + } + } +} + +func TestCatalogValidationRejectsInvalidContent(t *testing.T) { + tests := []struct { + name string + edit func(*Catalog) + want string + }{ + {"schema version", func(c *Catalog) { c.SchemaVersion = 2 }, "schema_version"}, + {"duplicate id", func(c *Catalog) { c.Items = append(c.Items, c.Items[0]) }, "duplicated"}, + {"transport", func(c *Catalog) { c.Items[0].Transport = "stdio" }, "unsupported"}, + {"insecure URL", func(c *Catalog) { c.Items[0].Server.URL = "http://example.com/mcp" }, "https URL"}, + {"embedded credentials", func(c *Catalog) { c.Items[0].Server.URL = "https://token@example.com/mcp" }, "embedded credentials"}, + {"featured rank", func(c *Catalog) { c.Items[0].FeaturedRank = 0 }, "featured_rank"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + catalog := validCatalog() + tc.edit(&catalog) + data, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + _, err = Decode(data) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + }) + } +} + +func validCatalog() Catalog { + return Catalog{ + SchemaVersion: SchemaVersion, + UpdatedAt: "2026-07-23T00:00:00Z", + Items: []Item{{ + ID: "connector", + Name: "Connector", + Description: "A connector used by tests.", + Publisher: Publisher{Name: "Publisher", URL: "https://example.com"}, + RepositoryURL: "https://example.com/repository", + Verified: true, + Categories: []string{"Developer Tools"}, + FeaturedRank: 1, + Version: "1.0.0", + Transport: "streamable-http", + Server: Server{Name: "connector", URL: "https://example.com/mcp"}, + }}, + } +} diff --git a/server/internal/mcpcatalog/loader.go b/server/internal/mcpcatalog/loader.go new file mode 100644 index 00000000..050164ba --- /dev/null +++ b/server/internal/mcpcatalog/loader.go @@ -0,0 +1,53 @@ +package mcpcatalog + +import ( + "context" + "fmt" + "strings" + + mcpcatalogdata "github.com/MiniMax-AI-Dev/parsar/catalog/mcp" +) + +type Source string + +const SourceBuiltin Source = "builtin" + +type Snapshot struct { + Catalog Catalog + Source Source +} + +type Options struct { + BuiltinJSON []byte +} + +type Loader struct { + builtin Catalog + builtinErr error +} + +func New(options Options) *Loader { + builtinJSON := options.BuiltinJSON + if len(builtinJSON) == 0 { + builtinJSON = mcpcatalogdata.CatalogJSON + } + builtin, builtinErr := Decode(builtinJSON) + return &Loader{builtin: builtin, builtinErr: builtinErr} +} + +func (l *Loader) Load(_ context.Context) (Snapshot, error) { + if l.builtinErr != nil { + return Snapshot{}, fmt.Errorf("load builtin catalog: %w", l.builtinErr) + } + return Snapshot{Catalog: l.builtin, Source: SourceBuiltin}, nil +} + +func (s Snapshot) Find(id string) (Item, bool) { + id = strings.TrimSpace(id) + for _, item := range s.Catalog.Items { + if item.ID == id { + return item, true + } + } + return Item{}, false +} diff --git a/server/internal/mcpcatalog/types.go b/server/internal/mcpcatalog/types.go new file mode 100644 index 00000000..edf3a2a6 --- /dev/null +++ b/server/internal/mcpcatalog/types.go @@ -0,0 +1,51 @@ +package mcpcatalog + +import ( + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +const SchemaVersion = 1 + +type Catalog struct { + SchemaVersion int `json:"schema_version"` + UpdatedAt string `json:"updated_at"` + Items []Item `json:"items"` +} + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Publisher Publisher `json:"publisher"` + IconURL string `json:"icon_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Verified bool `json:"verified"` + Categories []string `json:"categories"` + FeaturedRank int `json:"featured_rank"` + Version string `json:"version"` + Transport string `json:"transport"` + Server Server `json:"server"` +} + +type Publisher struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type Server struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func (i Item) CanonicalSpec() canonical.Spec { + return canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: i.Server.Name, + Transport: i.Transport, + URL: i.Server.URL, + }}}, + } +} diff --git a/server/internal/mcpcatalog/validate.go b/server/internal/mcpcatalog/validate.go new file mode 100644 index 00000000..b7a36e57 --- /dev/null +++ b/server/internal/mcpcatalog/validate.go @@ -0,0 +1,121 @@ +package mcpcatalog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "regexp" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +var idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +func Decode(data []byte) (Catalog, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var catalog Catalog + if err := decoder.Decode(&catalog); err != nil { + return Catalog{}, fmt.Errorf("decode catalog: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Catalog{}, fmt.Errorf("decode catalog: trailing JSON data") + } + if err := catalog.Validate(); err != nil { + return Catalog{}, err + } + return catalog, nil +} + +func (c Catalog) Validate() error { + if c.SchemaVersion != SchemaVersion { + return fmt.Errorf("catalog schema_version %d is unsupported", c.SchemaVersion) + } + if _, err := time.Parse(time.RFC3339, strings.TrimSpace(c.UpdatedAt)); err != nil { + return fmt.Errorf("catalog updated_at must be RFC3339: %w", err) + } + seen := make(map[string]struct{}, len(c.Items)) + for index, item := range c.Items { + if err := item.Validate(); err != nil { + return fmt.Errorf("catalog item[%d]: %w", index, err) + } + if _, duplicate := seen[item.ID]; duplicate { + return fmt.Errorf("catalog item id %q is duplicated", item.ID) + } + seen[item.ID] = struct{}{} + } + return nil +} + +func (i Item) Validate() error { + if !idPattern.MatchString(i.ID) { + return fmt.Errorf("id %q must contain only lowercase letters, digits, dots, hyphens, or underscores", i.ID) + } + if strings.TrimSpace(i.Name) == "" || strings.TrimSpace(i.Description) == "" { + return fmt.Errorf("item %q name and description are required", i.ID) + } + if strings.TrimSpace(i.Publisher.Name) == "" { + return fmt.Errorf("item %q publisher name is required", i.ID) + } + for label, value := range map[string]string{ + "publisher.url": i.Publisher.URL, + "server.url": i.Server.URL, + } { + if err := validateHTTPSURL(label, value, true); err != nil { + return fmt.Errorf("item %q: %w", i.ID, err) + } + } + for label, value := range map[string]string{ + "icon_url": i.IconURL, + "homepage_url": i.HomepageURL, + "repository_url": i.RepositoryURL, + } { + if err := validateHTTPSURL(label, value, false); err != nil { + return fmt.Errorf("item %q: %w", i.ID, err) + } + } + if i.FeaturedRank < 1 { + return fmt.Errorf("item %q featured_rank must be positive", i.ID) + } + if strings.TrimSpace(i.Version) == "" { + return fmt.Errorf("item %q version is required", i.ID) + } + if i.Transport != canonical.MCPTransportStreamableHTTP { + return fmt.Errorf("item %q transport %q is unsupported", i.ID, i.Transport) + } + if strings.TrimSpace(i.Server.Name) == "" { + return fmt.Errorf("item %q server name is required", i.ID) + } + seenCategories := make(map[string]struct{}, len(i.Categories)) + for _, category := range i.Categories { + category = strings.TrimSpace(category) + if category == "" { + return fmt.Errorf("item %q has an empty category", i.ID) + } + if _, duplicate := seenCategories[category]; duplicate { + return fmt.Errorf("item %q category %q is duplicated", i.ID, category) + } + seenCategories[category] = struct{}{} + } + return nil +} + +func validateHTTPSURL(label, value string, required bool) error { + value = strings.TrimSpace(value) + if value == "" { + if required { + return fmt.Errorf("%s is required", label) + } + return nil + } + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || parsed.Scheme != "https" || parsed.User != nil { + return fmt.Errorf("%s must be an https URL without embedded credentials", label) + } + return nil +} diff --git a/server/internal/store/capability_import.go b/server/internal/store/capability_import.go index c98a64b0..6c25ea8b 100644 --- a/server/internal/store/capability_import.go +++ b/server/internal/store/capability_import.go @@ -682,12 +682,24 @@ func validateMCPSpecPreCommit(m canonical.MCPSpec) error { if strings.TrimSpace(srv.Name) == "" { return fmt.Errorf("server[%d]: name is required", i) } - if strings.TrimSpace(srv.Command) == "" { - return fmt.Errorf("server %q: command is required", srv.Name) - } if srv.StartupTimeoutSec < 0 { return fmt.Errorf("server %q: startup_timeout_sec must be >= 0", srv.Name) } + switch srv.EffectiveTransport() { + case canonical.MCPTransportStdio: + if strings.TrimSpace(srv.Command) == "" { + return fmt.Errorf("server %q: command is required", srv.Name) + } + if strings.TrimSpace(srv.URL) != "" { + return fmt.Errorf("server %q: stdio transport must not set url", srv.Name) + } + case canonical.MCPTransportStreamableHTTP: + if err := srv.Validate(); err != nil { + return err + } + default: + return fmt.Errorf("server %q: unsupported transport %q", srv.Name, srv.Transport) + } for name, value := range srv.Env { if strings.TrimSpace(name) == "" { return fmt.Errorf("server %q: empty env name", srv.Name) diff --git a/server/internal/store/mcp_directory.go b/server/internal/store/mcp_directory.go new file mode 100644 index 00000000..6361eed7 --- /dev/null +++ b/server/internal/store/mcp_directory.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" +) + +// MCPDirectoryInstall identifies the workspace capability created from one +// MCP Directory catalog item. CatalogVersion is retained for future update +// detection; v1 only reports it. +type MCPDirectoryInstall struct { + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` + CapabilityID string `json:"capability_id"` +} + +func (s *Store) ListMCPDirectoryInstalls(ctx context.Context, workspaceID string) ([]MCPDirectoryInstall, error) { + wid, err := uuid(workspaceID) + if err != nil { + return nil, fmt.Errorf("list mcp directory installs: workspace_id: %w", err) + } + rows, err := sqlc.New(s.db).ListMCPDirectoryInstalls(ctx, wid) + if err != nil { + return nil, fmt.Errorf("list mcp directory installs: %w", err) + } + installs := make([]MCPDirectoryInstall, 0, len(rows)) + for _, row := range rows { + installs = append(installs, MCPDirectoryInstall{ + CatalogID: row.CatalogID, + CatalogVersion: row.CatalogVersion, + CapabilityID: row.CapabilityID, + }) + } + return installs, nil +} diff --git a/server/internal/store/mcp_directory_test.go b/server/internal/store/mcp_directory_test.go new file mode 100644 index 00000000..d0ca7b3b --- /dev/null +++ b/server/internal/store/mcp_directory_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +func TestMCPDirectoryImportPersistsProvenanceWithoutSecretsOrBindings(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + st := New(db) + ids := mustSeedDevFixture(t, ctx, st) + + var secretsBefore int + if err := db.QueryRow(ctx, `select count(*) from secrets`).Scan(&secretsBefore); err != nil { + t.Fatal(err) + } + source := json.RawMessage(`{"source_format":"mcp_catalog","catalog_id":"filesystem","catalog_version":"1.0.0","catalog_source":"builtin"}`) + result, err := st.ImportCapability(ctx, ImportCapabilityInput{ + WorkspaceID: ids.WorkspaceID, + Name: "Directory Filesystem", + Description: "Read and write configured files.", + Visibility: "workspace", + Type: "mcp", + CreatorID: ids.UserID, + Version: "1.0.0", + SourcePayload: source, + Spec: canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{ + Name: "filesystem", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem@1.0.0"}, + Env: map[string]canonical.EnvValue{"FILESYSTEM_ROOT": {Mode: canonical.EnvModeLiteral}}, + StartupTimeoutSec: 30, + }}}, + }, + }) + if err != nil { + t.Fatalf("ImportCapability: %v", err) + } + if result.Capability.Type != "mcp" || result.Capability.Visibility != "workspace" { + t.Fatalf("capability=%+v", result.Capability) + } + if len(result.CreatedSecretIDs) != 0 { + t.Fatalf("created secrets=%v", result.CreatedSecretIDs) + } + + installs, err := st.ListMCPDirectoryInstalls(ctx, ids.WorkspaceID) + if err != nil { + t.Fatalf("ListMCPDirectoryInstalls: %v", err) + } + if len(installs) != 1 || installs[0].CatalogID != "filesystem" || installs[0].CatalogVersion != "1.0.0" || installs[0].CapabilityID != result.Capability.ID { + t.Fatalf("installs=%+v", installs) + } + + var bindings, secretsAfter int + if err := db.QueryRow(ctx, `select count(*) from agent_capabilities where capability_id = $1`, result.Capability.ID).Scan(&bindings); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(ctx, `select count(*) from secrets`).Scan(&secretsAfter); err != nil { + t.Fatal(err) + } + if bindings != 0 { + t.Fatalf("agent bindings=%d, want 0", bindings) + } + if secretsAfter != secretsBefore { + t.Fatalf("secret count changed from %d to %d", secretsBefore, secretsAfter) + } + + var stored json.RawMessage + if err := db.QueryRow(ctx, `select source_payload from capability_version where id = $1`, result.CapabilityVersion.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + var provenance map[string]string + if err := json.Unmarshal(stored, &provenance); err != nil { + t.Fatal(err) + } + if provenance["catalog_id"] != "filesystem" || provenance["catalog_source"] != "builtin" { + t.Fatalf("source_payload=%s", stored) + } +} diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts new file mode 100644 index 00000000..540cfc6f --- /dev/null +++ b/tests/e2e/mcp-directory.spec.ts @@ -0,0 +1,154 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const WORKSPACE_ID = "00000000-0000-0000-0000-000000000011"; +const CAPABILITY_ID = "00000000-0000-0000-0000-000000000033"; + +const directoryItems = [ + connector("context7", "Context7", "Documentation", 1), + connector("exa", "Exa", "Search", 2), + connector("firecrawl", "Firecrawl", "Web", 3), +]; + +test("browses and imports a hosted MCP connector", async ({ page }) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); + + const search = page.getByPlaceholder("Search capability name / description"); + await search.fill("exa"); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(1); + await search.clear(); + + await page.getByRole("button", { name: "Documentation", exact: true }).click(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(1); + await page.getByRole("heading", { name: "Context7" }).click(); + + const detail = page.getByTestId("mcp-directory-detail"); + await expect(detail).toContainText("https://mcp.context7.com/mcp"); + await expect(detail).toContainText("Not required"); + + await page.getByRole("button", { name: "Import", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("https://mcp.context7.com/mcp"); + await expect(dialog.getByRole("textbox")).toHaveCount(0); + await dialog.getByRole("button", { name: "Import", exact: true }).click(); + + const success = page.getByRole("status"); + await expect(success).toContainText("imported as a workspace MCP Capability"); + await expect(success.getByRole("button", { name: "View Capability" })).toBeVisible(); + await expect(success.getByRole("button", { name: "Add to Agent" })).toHaveCount(0); + + await page.getByRole("button", { name: "Back to connectors" }).click(); + await page.getByRole("tab", { name: "Skill" }).click(); + await expect(page.getByRole("heading", { name: "Diagram Maker" })).toBeVisible(); +}); + +test("retries a failed connector directory request", async ({ page }) => { + let directoryCalls = 0; + await mockApp(page, async (route) => { + directoryCalls += 1; + if (directoryCalls !== 1) return false; + await json(route, { error: "mcp_catalog_unavailable" }, 503); + return true; + }); + await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); + + await expect(page.getByText("Couldn't load the connectors directory", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Retry" }).click(); + await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); +}); + +function connector(id: string, name: string, category: string, featuredRank: number) { + return { + id, + name, + description: `${name} hosted MCP connector.`, + publisher: { name, url: `https://${id}.example.com` }, + repository_url: `https://github.com/example/${id}`, + verified: true, + categories: ["Developer Tools", category], + featured_rank: featuredRank, + version: "1.0.0", + transport: "streamable-http", + installed: false, + installed_capability_id: null, + }; +} + +async function mockApp( + page: Page, + directoryOverride?: (route: Route) => Promise, +) { + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (path === "/api/v1/me") + return json(route, { + user_id: "user-1", + email: "admin@example.com", + name: "Admin", + avatar_url: "", + }); + if (path === "/api/v1/me/workspaces") + return json(route, { + user_id: "user-1", + workspaces: [{ + id: WORKSPACE_ID, + name: "Directory Test", + slug: "directory-test", + visibility: "private", + role: "owner", + created_at: "2026-07-23T00:00:00Z", + updated_at: "2026-07-23T00:00:00Z", + }], + }); + if (path === "/api/v1/me/discoverable-workspaces") + return json(route, { user_id: "user-1", workspaces: [], total: 0, limit: 5, offset: 0 }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/agents`) + return json(route, { agents: [] }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities`) + return json(route, { capabilities: [], marketplace_installs: [], total: 0 }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/marketplace-installs`) + return json(route, { capabilities: [] }); + if (path === "/api/v1/capabilities/marketplace") + return json(route, { + capabilities: [{ + id: "00000000-0000-0000-0000-000000000044", + type: "skill", + name: "Diagram Maker", + description: "Create diagrams.", + visibility: "public", + status: "active", + required_credentials: [], + latest_version: "1.0.0", + source_workspace_name: "Public Catalog", + installed: false, + self_published: false, + }], + }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory`) { + if (directoryOverride && (await directoryOverride(route))) return; + return json(route, { + items: directoryItems, + updated_at: "2026-07-23T00:00:00Z", + source: "builtin", + }); + } + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7`) + return json(route, { ...directoryItems[0], url: "https://mcp.context7.com/mcp" }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7/import`) + return json(route, { installed: true, capability_id: CAPABILITY_ID, created: true }, 201); + return json(route, {}); + }); +} + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} From 5b9d341cf195096e87a8be00adad54ea7c7f606c Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 23 Jul 2026 17:35:04 +0800 Subject: [PATCH 04/21] feat: add OAuth for hosted MCP connectors --- .../internal/agent/codex/mcp_config.go | 21 +- .../internal/agent/codex/mcp_config_test.go | 9 +- .../internal/agent/codex/options.go | 10 + .../internal/agent/opencode/options.go | 23 +- .../internal/agent/opencode/options_test.go | 9 +- apps/web/src/i18n/locales/en-US/admin.json | 8 +- apps/web/src/i18n/locales/zh-CN/admin.json | 8 +- apps/web/src/lib/api-marketplace.ts | 12 +- .../mcp-directory/ImportMCPDialog.tsx | 8 +- .../mcp-directory/MCPDirectory.tsx | 50 +- .../mcp-directory/MCPDirectoryCard.tsx | 10 +- .../mcp-directory/MCPDirectoryDetail.tsx | 15 +- catalog/mcp/README.md | 11 +- catalog/mcp/catalog.json | 24 + catalog/mcp/catalog.schema.json | 17 + docs/openapi/openapi.yaml | 82 ++++ server/cmd/server/main.go | 18 +- server/internal/api/mcpdirectory/handler.go | 88 +++- .../internal/api/mcpdirectory/handler_test.go | 53 ++- server/internal/api/mcpdirectory/oauth.go | 288 ++++++++++++ .../internal/api/mcpdirectory/oauth_scope.go | 73 +++ server/internal/auth/mcpoauth/client.go | 429 ++++++++++++++++++ server/internal/auth/mcpoauth/client_test.go | 192 ++++++++ server/internal/auth/mcpoauth/credential.go | 71 +++ server/internal/capability/canonical/mcp.go | 15 + server/internal/capability/canonical/spec.go | 6 +- .../internal/capability/render/claudecode.go | 7 +- server/internal/capability/render/codex.go | 7 +- server/internal/capability/render/opencode.go | 7 +- .../capability/render/placeholders.go | 4 +- .../agentdaemon/capability_runtime.go | 82 +++- .../agentdaemon/capability_runtime_test.go | 54 +++ server/internal/db/queries/store.sql | 18 + server/internal/db/sqlc/store.sql.go | 118 +++++ server/internal/mcpcatalog/catalog_test.go | 11 +- server/internal/mcpcatalog/types.go | 59 ++- server/internal/mcpcatalog/validate.go | 17 +- server/internal/store/capability_import.go | 33 +- server/internal/store/oauth_secret_test.go | 59 +++ server/internal/store/store.go | 82 +++- 40 files changed, 2021 insertions(+), 87 deletions(-) create mode 100644 server/internal/api/mcpdirectory/oauth.go create mode 100644 server/internal/api/mcpdirectory/oauth_scope.go create mode 100644 server/internal/auth/mcpoauth/client.go create mode 100644 server/internal/auth/mcpoauth/client_test.go create mode 100644 server/internal/auth/mcpoauth/credential.go create mode 100644 server/internal/store/oauth_secret_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go index d66c6259..63a65992 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -15,6 +15,7 @@ import ( type mcpServerConfig struct { Name string URL string + Headers map[string]string Command string Args []string Env map[string]string @@ -53,7 +54,25 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e if srv.URL != "" { b.WriteString(`url = `) b.WriteString(tomlQuoteString(srv.URL)) - b.WriteString("\n\n") + b.WriteByte('\n') + if len(srv.Headers) > 0 { + headerKeys := make([]string, 0, len(srv.Headers)) + for key := range srv.Headers { + headerKeys = append(headerKeys, key) + } + sort.Strings(headerKeys) + b.WriteString("http_headers = {") + for index, key := range headerKeys { + if index > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(key)) + b.WriteString(" = ") + b.WriteString(tomlQuoteString(srv.Headers[key])) + } + b.WriteString("}\n") + } + b.WriteByte('\n') continue } b.WriteString(`command = `) diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go index 8f5cf6e6..49973302 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go @@ -64,7 +64,11 @@ func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { dir := t.TempDir() servers := map[string]mcpServerConfig{ - "docs": {Name: "docs", URL: "https://docs.example.com/mcp"}, + "docs": { + Name: "docs", + URL: "https://docs.example.com/mcp", + Headers: map[string]string{"Authorization": "Bearer token"}, + }, } if err := writeCodexMCPConfig(dir, servers); err != nil { t.Fatalf("write: %v", err) @@ -73,6 +77,9 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { t.Fatalf("remote config: %s", body) } + if !strings.Contains(string(body), `http_headers = {"Authorization" = "Bearer token"}`) { + t.Fatalf("remote headers: %s", body) + } } // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 2487dc77..883403c7 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -363,6 +363,16 @@ func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { } } } + if headers, ok := entry["headers"].(map[string]any); ok { + srv.Headers = make(map[string]string, len(headers)) + for key, value := range headers { + if text, ok := value.(string); ok { + srv.Headers[key] = text + } + } + } else if headers, ok := entry["headers"].(map[string]string); ok { + srv.Headers = headers + } if srv.Command == "" && srv.URL == "" { return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) } diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go index c1cbb524..3e9be912 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -103,11 +103,15 @@ func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { enabled = value } if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { - mcp[name] = map[string]any{ + remote := map[string]any{ "type": "remote", "url": strings.TrimSpace(remoteURL), "enabled": enabled, } + if headers := stringMap(entry["headers"]); len(headers) > 0 { + remote["headers"] = headers + } + mcp[name] = remote continue } command, ok := entry["command"].(string) @@ -142,6 +146,23 @@ func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { return string(encoded), nil } +func stringMap(value any) map[string]string { + switch typed := value.(type) { + case map[string]string: + return typed + case map[string]any: + result := make(map[string]string, len(typed)) + for key, raw := range typed { + if text, ok := raw.(string); ok { + result[key] = text + } + } + return result + default: + return nil + } +} + func resolveWorkDir(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go index bfe9e4fa..879d8287 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -100,7 +100,10 @@ func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { "opencode_json": `{"provider":{}}`, "mcp_servers": map[string]any{ "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, - "docs": map[string]any{"url": "https://docs.example.com/mcp"}, + "docs": map[string]any{ + "url": "https://docs.example.com/mcp", + "headers": map[string]any{"Authorization": "Bearer token"}, + }, }, }) if err != nil { @@ -121,6 +124,10 @@ func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { t.Fatalf("remote = %+v", remote) } + headers := remote["headers"].(map[string]any) + if headers["Authorization"] != "Bearer token" { + t.Fatalf("headers = %+v", headers) + } local := mcp["local"].(map[string]any) if local["type"] != "local" { t.Fatalf("local = %+v", local) diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index e7b92a4f..a8f7cb61 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -912,6 +912,12 @@ "back": "Back to connectors", "viewCapability": "View Capability" }, + "oauth": { + "connect": "Connect", + "connected": "Connected", + "required": "OAuth required", + "failed": "Authorization did not complete. Please try again." + }, "loadError": { "title": "Couldn't load the connectors directory", "description": "Couldn't load the connectors directory. Some connector details may be missing. Retry without leaving the Capability Marketplace." @@ -935,7 +941,7 @@ }, "import": { "title": "Import {{name}}?", - "description": "Review the connector configuration. No token is required during import, and nothing will run or bind to an Agent.", + "description": "Review the connector configuration. OAuth connectors must be connected first; importing does not run the MCP or bind it to an Agent.", "success": "{{name}} was imported as a workspace MCP Capability.", "failed": "The connector could not be imported.", "importing": "Importing...", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index bbcd627d..f417e851 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -912,6 +912,12 @@ "back": "返回连接器列表", "viewCapability": "查看 Capability" }, + "oauth": { + "connect": "连接", + "connected": "已连接", + "required": "需要 OAuth 授权", + "failed": "授权未完成,请重试。" + }, "loadError": { "title": "无法加载连接器目录", "description": "无法加载连接器目录,部分连接器信息可能缺失。你可以直接重试,不会影响 Skill 市场和工作区 Capability。" @@ -935,7 +941,7 @@ }, "import": { "title": "导入 {{name}}?", - "description": "请检查连接器配置。导入时不需要 Token,也不会运行 MCP 或绑定 Agent。", + "description": "请检查连接器配置。OAuth 连接器需要先完成授权;导入不会运行 MCP 或绑定 Agent。", "success": "已将 {{name}} 导入为工作区 MCP Capability。", "failed": "无法导入该连接器。", "importing": "正在导入...", diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index d9d12907..e9f21c9a 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -102,9 +102,11 @@ export interface MCPDirectoryItem { verified: boolean categories: string[] featured_rank: number - version: string - transport: "streamable-http" - url?: string + version: string + transport: "streamable-http" + authentication: "none" | "oauth2" + connected: boolean + url?: string installed: boolean installed_capability_id: string | null } @@ -220,6 +222,10 @@ async function importMCPDirectoryItem(workspaceID: string, catalogID: string): P return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/import`, { method: "POST" }) } +export function mcpDirectoryOAuthStartURL(workspaceID: string, catalogID: string): string { + return `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/oauth/start` +} + function normalizeMarketplaceCapability(item: MarketplaceCapability): MarketplaceCapability { const id = item.id ?? item.capability_id ?? "" return { ...item, id, latest_version: item.latest_version ?? item.latest_published_version, created_at: item.created_at ?? item.latest_version_created_at, updated_at: item.updated_at ?? item.latest_version_created_at } diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx index 9051ac33..6a5f5ff9 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/ImportMCPDialog.tsx @@ -70,7 +70,11 @@ export function ImportMCPDialog({ {t("capabilities.mcpDirectory.detail.authentication")}

- {t("capabilities.mcpDirectory.detail.noAuthentication")} + {item.authentication === "oauth2" + ? item.connected + ? t("capabilities.mcpDirectory.oauth.connected") + : t("capabilities.mcpDirectory.oauth.required") + : t("capabilities.mcpDirectory.detail.noAuthentication")}

@@ -91,7 +95,7 @@ export function ImportMCPDialog({

{success ? : null} + {oauthError ?

{t("capabilities.mcpDirectory.oauth.failed")}

: null} {directoryQ.isLoading ? (
{Array.from({ length: 6 }).map((_, index) => )} @@ -150,7 +192,7 @@ export function MCPDirectory({ ) : (
- {filtered.map((item) => onSelectItem(item.id)} onImport={() => requestImport(item.id)} onViewCapability={onViewCapability} />)} + {filtered.map((item) => onSelectItem(item.id)} onImport={() => requestImport(item.id)} onConnect={() => connectOAuth(item.id)} onViewCapability={onViewCapability} />)}
)} {importDialog} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx index 8cc8a01d..122c17d8 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -6,11 +6,12 @@ import { Button } from "../../../../components/ui/button" import type { MCPDirectoryItem } from "../../../../lib/api-marketplace" import { ConnectorIcon, VerifiedBadge } from "./shared" -export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapability }: { +export function DirectoryCard({ item, canImport, onOpen, onImport, onConnect, onViewCapability }: { item: MCPDirectoryItem canImport: boolean onOpen: () => void onImport: () => void + onConnect: () => void onViewCapability: (capabilityID: string) => void }) { const { t } = useTranslation("admin") @@ -27,6 +28,7 @@ export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapabil
{item.publisher.name} {item.verified ? : null} + {item.connected ? {t("capabilities.mcpDirectory.oauth.connected")} : null}
@@ -40,7 +42,11 @@ export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapabil - ) : ( + ) : item.authentication === "oauth2" && !item.connected ? ( + + ) : ( diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx index a3b49974..ad6a61cf 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx @@ -17,6 +17,7 @@ export function DirectoryDetail({ onBack, onRetry, onImport, + onConnect, onViewCapability, }: { item: MCPDirectoryItem | null @@ -26,6 +27,7 @@ export function DirectoryDetail({ onBack: () => void onRetry: () => void onImport: () => void + onConnect: () => void onViewCapability: (capabilityID: string) => void }) { const { t } = useTranslation("admin") @@ -71,6 +73,7 @@ export function DirectoryDetail({ {item.installed ? ( {t("capabilities.mcpDirectory.actions.installed")} ) : null} + {item.connected ? {t("capabilities.mcpDirectory.oauth.connected")} : null}

{item.publisher.name}

{item.description}

@@ -89,7 +92,11 @@ export function DirectoryDetail({ />
@@ -128,7 +135,11 @@ export function DirectoryDetail({
- {item.installed && item.installed_capability_id ? ( + {item.authentication === "oauth2" && !item.connected ? ( + + ) : item.installed && item.installed_capability_id ? ( - ) : item.authentication === "oauth2" && !item.connected ? ( - - ) : ( + ) : ( diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx index ad6a61cf..a3b49974 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryDetail.tsx @@ -17,7 +17,6 @@ export function DirectoryDetail({ onBack, onRetry, onImport, - onConnect, onViewCapability, }: { item: MCPDirectoryItem | null @@ -27,7 +26,6 @@ export function DirectoryDetail({ onBack: () => void onRetry: () => void onImport: () => void - onConnect: () => void onViewCapability: (capabilityID: string) => void }) { const { t } = useTranslation("admin") @@ -73,7 +71,6 @@ export function DirectoryDetail({ {item.installed ? ( {t("capabilities.mcpDirectory.actions.installed")} ) : null} - {item.connected ? {t("capabilities.mcpDirectory.oauth.connected")} : null}

{item.publisher.name}

{item.description}

@@ -92,11 +89,7 @@ export function DirectoryDetail({ />
@@ -135,11 +128,7 @@ export function DirectoryDetail({
- {item.authentication === "oauth2" && !item.connected ? ( - - ) : item.installed && item.installed_capability_id ? ( + {item.installed && item.installed_capability_id ? ( +
+ +
+ + ) +} diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts index 5b4cc59d..b43280e0 100644 --- a/tests/e2e/mcp-directory.spec.ts +++ b/tests/e2e/mcp-directory.spec.ts @@ -15,12 +15,15 @@ test("browses and imports a hosted MCP connector", async ({ page }) => { await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); - await expect(page.getByRole("heading", { name: "Published MCPs" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "My MCP" })).toBeVisible(); + const marketplaceGrid = page.getByTestId("mcp-marketplace-grid"); + await expect(marketplaceGrid.getByRole("heading", { name: "Context7" })).toBeVisible(); + await expect(marketplaceGrid.getByRole("heading", { name: "My MCP" })).toBeVisible(); + await expect(page.getByTestId("marketplace-mcp-card")).toHaveCount(1); const search = page.getByPlaceholder("Search capability name / description"); await search.fill("exa"); await expect(page.getByTestId("mcp-directory-card")).toHaveCount(1); + await expect(page.getByRole("heading", { name: "My MCP" })).toHaveCount(0); await search.clear(); await page.getByRole("button", { name: "Documentation", exact: true }).click(); From 6610e0e0344d033ea1f4d4ac7175d9cf15189d95 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 30 Jul 2026 14:30:30 +0800 Subject: [PATCH 20/21] fix: expose delete action for published capabilities --- .../admin/capabilities/MarketplaceTab.tsx | 56 ++++++++++++++----- .../src/pages/admin/capabilities/index.tsx | 2 + .../mcp-directory/MCPDirectory.tsx | 6 +- .../mcp-directory/MCPDirectoryCard.tsx | 22 +++++--- tests/e2e/mcp-directory.spec.ts | 4 ++ 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index 74f62539..60b25a2f 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, ExternalLink, File, FileText, Folder, FolderOpen, PackageCheck, Server } from "lucide-react" +import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, ExternalLink, File, FileText, Folder, FolderOpen, PackageCheck, Server, Trash2 } from "lucide-react" import { Badge } from "../../../components/ui/badge" import { Button } from "../../../components/ui/button" @@ -18,8 +18,10 @@ interface MarketplaceTabProps { query: string typeFilter: "mcp" | "skill" canImport: boolean + canManage: boolean onSelectItem: (id: string | null) => void onInstall: (capability: MarketplaceCapability) => void + onDelete: (capability: MarketplaceCapability) => void onViewCapability: (capabilityID: string) => void } @@ -33,6 +35,8 @@ export function MarketplaceTab(props: MarketplaceTabProps) { onSelectItem={(id) => props.onSelectItem(id ? `mcp:${id}` : null)} onSelectMarketplaceItem={props.onSelectItem} onInstallMarketplace={props.onInstall} + canManageMarketplace={props.canManage} + onDeleteMarketplace={props.onDelete} onViewCapability={props.onViewCapability} /> } @@ -48,11 +52,13 @@ export function MarketplaceTab(props: MarketplaceTabProps) { onSelectItem={(id) => props.onSelectItem(id ? `mcp:${id}` : null)} onSelectMarketplaceItem={props.onSelectItem} onInstallMarketplace={props.onInstall} + canManageMarketplace={props.canManage} + onDeleteMarketplace={props.onDelete} onViewCapability={props.onViewCapability} /> } -function PublishedMarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInstall }: MarketplaceTabProps) { +function PublishedMarketplaceTab({ itemID, query, typeFilter, canManage, onSelectItem, onInstall, onDelete, onViewCapability }: MarketplaceTabProps) { const { t, i18n } = useTranslation("admin") const workspaceID = useWorkspaceId() const marketplaceQ = useMarketplaceList(workspaceID) @@ -77,8 +83,11 @@ function PublishedMarketplaceTab({ itemID, query, typeFilter, onSelectItem, onIn onSelectItem(null)} onInstall={() => onInstall(selected)} + onDelete={() => onDelete(selected)} + onViewCapability={() => onViewCapability(selected.id)} /> ) } @@ -120,8 +129,11 @@ function PublishedMarketplaceTab({ itemID, query, typeFilter, onSelectItem, onIn key={item.id} capability={item} language={i18n.language} + canManage={canManage} onOpen={() => onSelectItem(item.id)} onInstall={() => onInstall(item)} + onDelete={() => onDelete(item)} + onViewCapability={() => onViewCapability(item.id)} /> ))}
@@ -130,11 +142,14 @@ function PublishedMarketplaceTab({ itemID, query, typeFilter, onSelectItem, onIn ) } -function MarketplaceCard({ capability, language, onOpen, onInstall }: { +function MarketplaceCard({ capability, language, canManage, onOpen, onInstall, onDelete, onViewCapability }: { capability: MarketplaceCapability language: string + canManage: boolean onOpen: () => void onInstall: () => void + onDelete: () => void + onViewCapability: () => void }) { const { t } = useTranslation("admin") const source = marketplaceSourceName(capability) @@ -163,24 +178,32 @@ function MarketplaceCard({ capability, language, onOpen, onInstall }: { {t("capabilities.marketplace.card.credential", { kind: requiredCredentialsLabel(capability.required_credentials, language, t("capabilities.credentials.none")) })}
-
- + {canManage ? : null} + + ) : ( + + + )}
) } -function MarketplaceItemDetail({ capability, language, onBack, onInstall }: { +function MarketplaceItemDetail({ capability, language, canManage, onBack, onInstall, onDelete, onViewCapability }: { capability: MarketplaceCapability language: string + canManage: boolean onBack: () => void onInstall: () => void + onDelete: () => void + onViewCapability: () => void }) { const { t } = useTranslation("admin") const workspaceID = useWorkspaceId() @@ -235,10 +258,15 @@ function MarketplaceItemDetail({ capability, language, onBack, onInstall }: { ) : null}
)} -
- +
+ {capability.self_published ? ( + <> + + {canManage ? : null} + + ) : ( + + )}
diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 3f5ff0c8..07884cc1 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -281,8 +281,10 @@ export function CapabilitiesPage() { query={query} typeFilter={typeFilter} canImport={canImportDirectory} + canManage={isAdmin} onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })} onInstall={goToAgentsForCapability} + onDelete={setDeleteTarget} onViewCapability={(capabilityID) => navigate("capabilities", { id: capabilityID, tab: null, item: null })} /> ) : err ? ( diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx index 00cf8e5f..cec46937 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -27,6 +27,8 @@ interface MCPDirectoryProps { onSelectItem: (id: string | null) => void onSelectMarketplaceItem: (id: string | null) => void onInstallMarketplace: (capability: MarketplaceCapability) => void + canManageMarketplace: boolean + onDeleteMarketplace: (capability: MarketplaceCapability) => void onViewCapability: (capabilityID: string) => void } @@ -37,6 +39,8 @@ export function MCPDirectory({ onSelectItem, onSelectMarketplaceItem, onInstallMarketplace, + canManageMarketplace, + onDeleteMarketplace, onViewCapability, }: MCPDirectoryProps) { const { t } = useTranslation("admin") @@ -184,7 +188,7 @@ export function MCPDirectory({ {cards.map((card) => card.kind === "directory" ? ( onSelectItem(card.item.id)} onImport={() => requestImport(card.item.id)} onViewCapability={onViewCapability} /> ) : ( - onSelectMarketplaceItem(card.item.id)} onInstall={() => onInstallMarketplace(card.item)} /> + onSelectMarketplaceItem(card.item.id)} onInstall={() => onInstallMarketplace(card.item)} onDelete={() => onDeleteMarketplace(card.item)} onViewCapability={() => onViewCapability(card.item.id)} /> ))}
) : null} diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx index c8caf922..6f73afd7 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Check, Server } from "lucide-react" +import { ArrowRight, Check, Server, Trash2 } from "lucide-react" import { useTranslation } from "react-i18next" import { Badge } from "../../../../components/ui/badge" @@ -50,10 +50,13 @@ export function DirectoryCard({ item, canImport, onOpen, onImport, onViewCapabil ) } -export function MarketplaceMCPCard({ capability, onOpen, onInstall }: { +export function MarketplaceMCPCard({ capability, canManage, onOpen, onInstall, onDelete, onViewCapability }: { capability: MarketplaceCapability + canManage: boolean onOpen: () => void onInstall: () => void + onDelete: () => void + onViewCapability: () => void }) { const { t } = useTranslation("admin") const source = marketplaceSourceName(capability) @@ -80,13 +83,18 @@ export function MarketplaceMCPCard({ capability, onOpen, onInstall }: {
- + {canManage ? : null} +
+ ) : ( + + + )} ) diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts index b43280e0..7ea8f0c3 100644 --- a/tests/e2e/mcp-directory.spec.ts +++ b/tests/e2e/mcp-directory.spec.ts @@ -19,6 +19,10 @@ test("browses and imports a hosted MCP connector", async ({ page }) => { await expect(marketplaceGrid.getByRole("heading", { name: "Context7" })).toBeVisible(); await expect(marketplaceGrid.getByRole("heading", { name: "My MCP" })).toBeVisible(); await expect(page.getByTestId("marketplace-mcp-card")).toHaveCount(1); + await marketplaceGrid.getByRole("button", { name: "Delete", exact: true }).click(); + const deleteDialog = page.getByRole("alertdialog", { name: 'Delete capability "My MCP"' }); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole("button", { name: "Cancel", exact: true }).click(); const search = page.getByPlaceholder("Search capability name / description"); await search.fill("exa"); From 9896260b1d35cd8f4fcb4e59c6b6654267c55936 Mon Sep 17 00:00:00 2001 From: kapelame Date: Thu, 30 Jul 2026 14:42:54 +0800 Subject: [PATCH 21/21] style: use neutral close action for published capabilities --- apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx | 6 +++--- .../admin/capabilities/mcp-directory/MCPDirectoryCard.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index 60b25a2f..fa871abc 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, ExternalLink, File, FileText, Folder, FolderOpen, PackageCheck, Server, Trash2 } from "lucide-react" +import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, ExternalLink, File, FileText, Folder, FolderOpen, PackageCheck, Server, X } from "lucide-react" import { Badge } from "../../../components/ui/badge" import { Button } from "../../../components/ui/button" @@ -182,7 +182,7 @@ function MarketplaceCard({ capability, language, canManage, onOpen, onInstall, o {capability.self_published ? ( <> - {canManage ? : null} + {canManage ? : null} ) : ( - {canManage ? : null} + {canManage ? : null} ) : ( diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx index 6f73afd7..dbee5991 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Check, Server, Trash2 } from "lucide-react" +import { ArrowRight, Check, Server, X } from "lucide-react" import { useTranslation } from "react-i18next" import { Badge } from "../../../../components/ui/badge" @@ -86,7 +86,7 @@ export function MarketplaceMCPCard({ capability, canManage, onOpen, onInstall, o {capability.self_published ? (
- {canManage ? : null} + {canManage ? : null}
) : (