Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f1b5e2a
feat: add MCP connector directory
kapelame Jul 22, 2026
4816281
feat: complete MCP connector directory workflows
Jul 23, 2026
aad4c88
feat: add curated MCP connector directory
kapelame Jul 23, 2026
f18e3e1
Merge remote-tracking branch 'origin/feature/mcp-directory' into feat…
Jul 23, 2026
5b9d341
feat: add OAuth for hosted MCP connectors
Jul 23, 2026
99621fb
refactor: trim MCP directory import response
Jul 23, 2026
daf5d33
refactor: reuse MCP directory error schema
Jul 23, 2026
0a13acc
fix: allow members to import MCP connectors
Jul 23, 2026
7bd6524
fix: let workspace members manage agent capabilities
Jul 23, 2026
b649698
refactor: remove unused MCP OAuth fields
Jul 23, 2026
e35829d
feat: bind shared secrets when enabling capabilities
Jul 23, 2026
706b99d
refactor: keep notion oauth migration minimal
Jul 23, 2026
23ad480
refactor: generalize MCP OAuth credential binding
kapelame Jul 23, 2026
804e7dd
refactor: centralize capability credential validation
Jul 24, 2026
9af92d2
refactor: share credential binding picker
Jul 26, 2026
3f05046
refactor: split OAuth and credential binding from directory core
kapelame Jul 29, 2026
bece15c
refactor: simplify MCP directory catalog metadata
Jul 29, 2026
c90c07f
refactor: trim MCP directory install query
Jul 29, 2026
9743a41
Merge remote-tracking branch 'origin/main' into feature/mcp-directory
Jul 30, 2026
dce21be
fix: keep published MCPs visible in marketplace
Jul 30, 2026
6122e05
fix: unify published MCPs with connector directory
Jul 30, 2026
6610e0e
fix: expose delete action for published capabilities
Jul 30, 2026
9896260
style: use neutral close action for published capabilities
Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/mcp_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
14 changes: 14 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/mcp_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions apps/parsar-daemon/internal/agent/codex/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
73 changes: 72 additions & 1 deletion apps/parsar-daemon/internal/agent/opencode/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 == "" {
Expand Down
35 changes: 35 additions & 0 deletions apps/parsar-daemon/internal/agent/opencode/options_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package opencode_test

import (
"encoding/json"
"os"
"path/filepath"
"slices"
Expand Down Expand Up @@ -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") {
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading