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..1c4b4dca 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -888,6 +888,57 @@ "description": "Backend returned an error." } }, + "mcpDirectory": { + "title": "Connectors", + "description": "Browse curated connectors and MCP capabilities published by workspaces in one marketplace.", + "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.", + "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..c3370db7 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -888,6 +888,57 @@ "description": "后端返回错误。" } }, + "mcpDirectory": { + "title": "连接器", + "description": "在同一个能力市场中浏览精选连接器和各工作区发布的 MCP 能力。", + "verified": "已验证", + "securityNotice": "导入只会保存配置,不会立即运行。启用并绑定 Agent 后,该 MCP 才可能在 Runtime 中执行。", + "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..715ebf50 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -91,6 +91,33 @@ 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[] +} + +export interface MCPDirectoryImportResponse { + installed: boolean + capability_id: string +} + interface MarketplaceListResponse { capabilities?: MarketplaceCapability[] marketplace?: MarketplaceCapability[] @@ -124,6 +151,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 +202,20 @@ async function listEnabledAgents(workspaceID: string | null, capabilityID: strin return items.map(normalizeEnabledAgent) } +async function listMCPDirectory(workspaceID: string | null): Promise { + if (!workspaceID) return { items: [] } + 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 +305,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..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 } 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" @@ -11,16 +11,54 @@ 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 + canManage: boolean onSelectItem: (id: string | null) => void onInstall: (capability: MarketplaceCapability) => void + onDelete: (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) { + return props.onSelectItem(id ? `mcp:${id}` : null)} + onSelectMarketplaceItem={props.onSelectItem} + onInstallMarketplace={props.onInstall} + canManageMarketplace={props.canManage} + onDeleteMarketplace={props.onDelete} + onViewCapability={props.onViewCapability} + /> + } + + if (props.itemID || props.typeFilter === "skill") { + return + } + + return 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, canManage, onSelectItem, onInstall, onDelete, onViewCapability }: MarketplaceTabProps) { const { t, i18n } = useTranslation("admin") const workspaceID = useWorkspaceId() const marketplaceQ = useMarketplaceList(workspaceID) @@ -45,8 +83,11 @@ export function MarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInst onSelectItem(null)} onInstall={() => onInstall(selected)} + onDelete={() => onDelete(selected)} + onViewCapability={() => onViewCapability(selected.id)} /> ) } @@ -88,8 +129,11 @@ export function MarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInst key={item.id} capability={item} language={i18n.language} + canManage={canManage} onOpen={() => onSelectItem(item.id)} onInstall={() => onInstall(item)} + onDelete={() => onDelete(item)} + onViewCapability={() => onViewCapability(item.id)} /> ))} @@ -98,11 +142,14 @@ export function MarketplaceTab({ itemID, query, typeFilter, onSelectItem, onInst ) } -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) @@ -131,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() @@ -203,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 60b2cda8..07884cc1 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -120,6 +120,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 canImportDirectory = isAdmin || workspaceRole === "member" const marketInstallCountQ = useInstallCount(wid, marketTarget?.capability.id ?? null) const uninstallAgentsQ = useMarketplaceEnabledAgents(wid, uninstallTarget?.id ?? null) @@ -279,8 +280,12 @@ export function CapabilitiesPage() { itemID={marketplaceItem} 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 ? ( 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..cec46937 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -0,0 +1,216 @@ +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Check, PackageCheck, Server } from "lucide-react" + +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 { + marketplaceSourceName, + type MarketplaceCapability, + useImportMCPDirectoryItem, + useMarketplaceList, + useMCPDirectory, + useMCPDirectoryDetail, +} from "../../../../lib/api-marketplace" +import { useWorkspaceId } from "../../../../lib/workspace" +import { DirectoryCard, MarketplaceMCPCard } 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 + onSelectMarketplaceItem: (id: string | null) => void + onInstallMarketplace: (capability: MarketplaceCapability) => void + canManageMarketplace: boolean + onDeleteMarketplace: (capability: MarketplaceCapability) => void + onViewCapability: (capabilityID: string) => void +} + +export function MCPDirectory({ + itemID, + query, + canImport, + onSelectItem, + onSelectMarketplaceItem, + onInstallMarketplace, + canManageMarketplace, + onDeleteMarketplace, + onViewCapability, +}: MCPDirectoryProps) { + const { t } = useTranslation("admin") + const workspaceID = useWorkspaceId() + const directoryQ = useMCPDirectory(workspaceID) + const marketplaceQ = useMarketplaceList(itemID ? null : 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 publishedMCPs = useMemo(() => { + if (category || verifiedOnly) return [] + const installedIDs = new Set(items.flatMap((item) => item.installed_capability_id ? [item.installed_capability_id] : [])) + const needle = query.trim().toLocaleLowerCase() + const matches = (marketplaceQ.data ?? []).filter((item) => { + if (item.type !== "mcp" || installedIDs.has(item.id)) return false + if (!needle) return true + return [item.name, item.description ?? "", marketplaceSourceName(item)].join(" ").toLocaleLowerCase().includes(needle) + }) + return matches.sort((left, right) => left.name.localeCompare(right.name)) + }, [category, items, marketplaceQ.data, query, verifiedOnly]) + const cards = useMemo(() => { + const merged: Array< + | { kind: "directory"; item: (typeof filtered)[number] } + | { kind: "marketplace"; item: MarketplaceCapability } + > = [ + ...filtered.map((item) => ({ kind: "directory" as const, item })), + ...publishedMCPs.map((item) => ({ kind: "marketplace" as const, item })), + ] + return sort === "name" ? merged.sort((left, right) => left.item.name.localeCompare(right.item.name)) : merged + }, [filtered, publishedMCPs, 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")}

+
+
+
+
+ setCategory("")}>{t("capabilities.mcpDirectory.filters.allCategories")} + {categories.map((value) => setCategory(value)}>{value})} +
+ + +
+
+ + {success ? : null} + {directoryQ.error ? ( + void directoryQ.refetch()} /> + ) : null} + {marketplaceQ.error ? ( + void marketplaceQ.refetch()} /> + ) : null} + {cards.length === 0 && (directoryQ.isLoading || marketplaceQ.isLoading) ? ( +
+ {Array.from({ length: 6 }).map((_, index) => )} +
+ ) : cards.length === 0 && !directoryQ.error && !marketplaceQ.error ? ( + + ) : cards.length > 0 ? ( +
+ {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)} onDelete={() => onDeleteMarketplace(card.item)} onViewCapability={() => onViewCapability(card.item.id)} /> + ))} +
+ ) : null} + {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..dbee5991 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -0,0 +1,101 @@ +import { ArrowRight, Check, Server, X } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { marketplaceSourceName, type MCPDirectoryItem, type MarketplaceCapability } 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 ? ( + + ) : ( + + )} +
+
+ ) +} + +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) + const count = capability.installed_agent_count ?? capability.enabled_agent_count ?? capability.install_count ?? 0 + return ( +
+ +
+ {capability.self_published ? ( +
+ + {canManage ? : null} +
+ ) : ( + + )} +
+
+ ) +} 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..ce1c20e5 --- /dev/null +++ b/catalog/mcp/embed.go @@ -0,0 +1,6 @@ +package mcpcatalogdata + +import _ "embed" + +//go:embed catalog.json +var CatalogJSON []byte diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index fe850d59..115de85f 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,62 @@ definitions: workspace_id: type: string type: object + mcpcatalog.Publisher: + properties: + name: + type: string + url: + type: string + type: object + mcpdirectory.importResponse: + properties: + capability_id: + type: string + 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 + type: object password.errorResponse: properties: code: @@ -6914,6 +6974,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..03ac54c6 --- /dev/null +++ b/server/internal/api/mcpdirectory/handler.go @@ -0,0 +1,297 @@ +// 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() (mcpcatalog.Catalog, 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"` +} + +type importResponse struct { + Installed bool `json:"installed"` + CapabilityID string `json:"capability_id"` +} + +type sourcePayload struct { + SourceFormat string `json:"source_format"` + CatalogID string `json:"catalog_id"` + CatalogVersion string `json:"catalog_version"` +} + +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) + if !ok { + return + } + catalog, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + byCatalog := installMap(installs) + items := make([]itemResponse, 0, len(catalog.Items)) + for _, item := range catalog.Items { + items = append(items, summarizeItem(item, byCatalog[item.ID])) + } + writeJSON(w, http.StatusOK, listResponse{Items: items}) +} + +// 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) + if !ok { + return + } + catalog, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := catalog.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.authorizeRoles(w, r, "owner", "admin", "member") + if !ok { + return + } + catalog, installs, ok := h.load(w, r, workspaceID) + if !ok { + return + } + item, found := catalog.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, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "catalog_provenance_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, + }) +} + +func (h *handler) authorize(w http.ResponseWriter, r *http.Request) (string, bool) { + return h.authorizeRoles(w, r, "owner", "admin", "member", "viewer") +} + +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 + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if _, err := uuid.Parse(workspaceID); err != nil { + writeError(w, http.StatusBadRequest, "invalid_workspace_id") + return "", false + } + 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.Catalog, []store.MCPDirectoryInstall, bool) { + catalog, err := h.deps.Catalog.Load() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "mcp_catalog_unavailable") + return mcpcatalog.Catalog{}, nil, false + } + installs, err := h.deps.Store.ListMCPDirectoryInstalls(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "directory_install_state_failed") + return mcpcatalog.Catalog{}, nil, false + } + return catalog, 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..31d1fc4e --- /dev/null +++ b/server/internal/api/mcpdirectory/handler_test.go @@ -0,0 +1,216 @@ +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 { + catalog mcpcatalog.Catalog + err error +} + +func (f fakeCatalog) Load() (mcpcatalog.Catalog, error) { return f.catalog, 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", CapabilityID: testCapabilityID}) + } + return store.ImportCapabilityResult{}, f.importErr + } + f.installs = append(f.installs, store.MCPDirectoryInstall{CatalogID: "context7", 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 TestDirectoryImportRejectsViewer(t *testing.T) { + fs := &fakeDirectoryStore{role: "viewer"} + 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", "member"} { + 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.CatalogVersion != "1.0.0" { + 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 { + 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"} + catalog := testCatalog() + 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 := requestWithCatalog(t, fs, catalog, 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 requestWithCatalog(t, fs, testCatalog(), method, path) +} + +func requestWithCatalog(t *testing.T, fs *fakeDirectoryStore, catalog mcpcatalog.Catalog, 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{catalog: catalog}, Store: fs}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func testCatalog() mcpcatalog.Catalog { + return 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..222fc64c 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3572,6 +3572,21 @@ 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. +select distinct on (cv.source_payload->>'catalog_id') + coalesce(cv.source_payload->>'catalog_id', '')::text as catalog_id, + 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..b70c0cc1 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8280,6 +8280,47 @@ 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, + 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"` + CapabilityID string `json:"capability_id"` +} + +// Catalog provenance lives on capability versions rather than the capability +// row. Keep the newest matching provenance per catalog id. +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.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..c0f97809 --- /dev/null +++ b/server/internal/mcpcatalog/catalog_test.go @@ -0,0 +1,76 @@ +package mcpcatalog + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuiltinCatalogLoads(t *testing.T) { + catalog, err := New(Options{}).Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + want := []string{"context7", "exa", "firecrawl"} + if len(catalog.Items) != len(want) { + t.Fatalf("items = %d, want %d", len(catalog.Items), len(want)) + } + for index, id := range want { + item := 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..cd920f37 --- /dev/null +++ b/server/internal/mcpcatalog/loader.go @@ -0,0 +1,43 @@ +package mcpcatalog + +import ( + "fmt" + "strings" + + mcpcatalogdata "github.com/MiniMax-AI-Dev/parsar/catalog/mcp" +) + +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() (Catalog, error) { + if l.builtinErr != nil { + return Catalog{}, fmt.Errorf("load builtin catalog: %w", l.builtinErr) + } + return l.builtin, nil +} + +func (c Catalog) Find(id string) (Item, bool) { + id = strings.TrimSpace(id) + for _, item := range c.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..4a883e7d --- /dev/null +++ b/server/internal/store/mcp_directory.go @@ -0,0 +1,34 @@ +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. +type MCPDirectoryInstall struct { + CatalogID string + CapabilityID string +} + +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, + 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..9c3a7c9e --- /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"}`) + 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].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_version"] != "1.0.0" { + 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..7ea8f0c3 --- /dev/null +++ b/tests/e2e/mcp-directory.spec.ts @@ -0,0 +1,178 @@ +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 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); + 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"); + 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(); + 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, + }, + { + id: "00000000-0000-0000-0000-000000000055", + type: "mcp", + name: "My MCP", + description: "A workspace-published MCP.", + visibility: "public", + status: "active", + required_credentials: [], + latest_version: "1.0.0", + source_workspace_name: "Directory Test", + installed: false, + self_published: true, + }, + ], + }); + 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), + }); +}