Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 9 additions & 6 deletions internal/detector/mcp_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ func (d *MCPDetector) allConfigLocations(homeDir string, searchDirs []string) []
// discoverWalkedMCPConfigs walks the configured search dirs and the per-user
// IDE dotfile roots, recognizing MCP configs by basename. It never enters
// ~/Library (TCC skipper), skips dependency/cache/build dirs and directory
// symlinks, and is bounded by maxMCPWalkFiles.
// symlinks, and is bounded by maxMCPWalkFiles. Hits inside an agent plugin
// package go through classifyWalkedMCPConfig, which drops catalog templates.
func (d *MCPDetector) discoverWalkedMCPConfigs(searchDirs []string, homeDir string) []mcpConfigSpec {
roots := make([]string, 0, len(searchDirs)+6)
roots = append(roots, searchDirs...)
Expand Down Expand Up @@ -163,11 +164,13 @@ func (d *MCPDetector) discoverWalkedMCPConfigs(searchDirs []string, homeDir stri
c := filepath.Clean(path)
if !seen[c] {
seen[c] = true
specs = append(specs, mcpConfigSpec{
SourceName: "discovered_mcp",
ConfigPath: c,
Vendor: mcpVendorForPath(c),
})
if source, vendor, keep := classifyWalkedMCPConfig(c, root); keep {
specs = append(specs, mcpConfigSpec{
SourceName: source,
ConfigPath: c,
Vendor: vendor,
})
}
}
}
return nil
Expand Down
84 changes: 84 additions & 0 deletions internal/detector/mcp_plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package detector

import (
"os"
"path/filepath"
"runtime"
"strings"
)

// mcpPluginManifest describes one agent's plugin-package layout: the manifest
// directory that marks a package root, and how a config found there is
// reported.
type mcpPluginManifest struct {
dir string
sourceName string
vendor string
}

var mcpPluginManifests = []mcpPluginManifest{
{".claude-plugin", "claude_plugin", "Anthropic"},
{".codex-plugin", "codex_plugin", "OpenAI"},
}

// pluginMCPBasename is the only MCP surface a plugin package declares. Any
// other MCP-shaped file inside a package is vendored from the plugin's own
// repo, not a config the host agent loads.
const pluginMCPBasename = ".mcp.json"

// maxPluginRootLookup bounds the upward search for a package root.
const maxPluginRootLookup = 8

// classifyWalkedMCPConfig decides what a walked hit is. Plugin marketplaces are
// clones of a catalog repo that ship one package — and one template .mcp.json —
// per catalog entry, none of which any agent loads (issue #201). So a
// plugin-scoped config counts only when it is the package's own .mcp.json and
// the package is installed.
func classifyWalkedMCPConfig(path, root string) (sourceName, vendor string, keep bool) {
dir := filepath.Dir(path)
pluginRoot, manifest, isPlugin := pluginPackageRoot(dir, root)
if !isPlugin {
return "discovered_mcp", mcpVendorForPath(path), true
}
if filepath.Base(path) != pluginMCPBasename || dir != pluginRoot {
return "", "", false // vendored inside the plugin payload
}
if !isInstalledPluginPath(pluginRoot) {
return "", "", false // marketplace catalog template
}
return manifest.sourceName, manifest.vendor, true
}

// pluginPackageRoot walks up from dir looking for a plugin manifest, stopping at
// the walk root.
func pluginPackageRoot(dir, root string) (string, mcpPluginManifest, bool) {
cleanRoot := filepath.Clean(root)
for i := 0; i < maxPluginRootLookup; i++ {
for _, m := range mcpPluginManifests {
if info, err := os.Stat(filepath.Join(dir, m.dir, "plugin.json")); err == nil && !info.IsDir() {
return dir, m, true
}
Comment on lines +56 to +60
}
parent := filepath.Dir(dir)
if dir == cleanRoot || parent == dir {
break
}
dir = parent
}
return "", mcpPluginManifest{}, false
}

// isInstalledPluginPath reports whether a plugin package sits in an agent's
// installed-plugin tree (<agent dir>/plugins/cache/...). Both Claude Code and
// Codex install there and keep their catalog clones elsewhere
// (~/.claude/plugins/marketplaces, ~/.codex/.tmp/plugins), so the segment pair
// separates installed packages from catalog entries for either agent without a
// per-vendor path list.
func isInstalledPluginPath(pluginRoot string) bool {
p := pluginRoot
if runtime.GOOS == "windows" {
p = strings.ToLower(p)
}
sep := string(filepath.Separator)
return strings.Contains(p, sep+"plugins"+sep+"cache"+sep)
Comment on lines +79 to +83
}
102 changes: 102 additions & 0 deletions internal/detector/mcp_plugins_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package detector

import (
"path/filepath"
"testing"
)

func gotSpecMap(specs []mcpConfigSpec) map[string]mcpConfigSpec {
m := make(map[string]mcpConfigSpec, len(specs))
for _, s := range specs {
m[s.ConfigPath] = s
}
return m
}

// TestDiscoverWalkedMCPConfigs_PluginPackages: marketplace catalog templates and
// files vendored inside a plugin payload are dropped; an installed plugin's own
// .mcp.json and ordinary configs are kept (issue #201).
func TestDiscoverWalkedMCPConfigs_PluginPackages(t *testing.T) {
root := t.TempDir()

// Claude catalog clone: one package per catalog entry, none installed.
writeFile(t, root, ".claude/plugins/marketplaces/official/external_plugins/terraform/.claude-plugin/plugin.json")
catalogClaude := writeFile(t, root, ".claude/plugins/marketplaces/official/external_plugins/terraform/.mcp.json")

// Codex catalog clone.
writeFile(t, root, ".codex/.tmp/plugins/plugins/linear/.codex-plugin/plugin.json")
catalogCodex := writeFile(t, root, ".codex/.tmp/plugins/plugins/linear/.mcp.json")

// Installed Claude plugin: its own .mcp.json is active, the opencode.json
// vendored from the plugin's repo is not.
writeFile(t, root, ".claude/plugins/cache/official/ponytail/1.0.0/.claude-plugin/plugin.json")
installed := writeFile(t, root, ".claude/plugins/cache/official/ponytail/1.0.0/.mcp.json")
vendored := writeFile(t, root, ".claude/plugins/cache/official/ponytail/1.0.0/opencode.json")

// Ordinary project config, no plugin manifest anywhere above it.
project := writeFile(t, root, "proj/.mcp.json")

d := &MCPDetector{}
got := gotSpecMap(d.discoverWalkedMCPConfigs([]string{root}, ""))

for _, p := range []string{catalogClaude, catalogCodex, vendored} {
if _, ok := got[p]; ok {
t.Errorf("should not have reported %s", p)
}
}
if s, ok := got[installed]; !ok {
t.Errorf("did not find installed plugin config %s", installed)
} else if s.SourceName != "claude_plugin" || s.Vendor != "Anthropic" {
t.Errorf("installed plugin: got source=%q vendor=%q", s.SourceName, s.Vendor)
}
if s, ok := got[project]; !ok {
t.Errorf("did not find project config %s", project)
} else if s.SourceName != "discovered_mcp" {
t.Errorf("project config: got source=%q, want discovered_mcp", s.SourceName)
}
if len(got) != 2 {
t.Errorf("expected 2 configs, got %d: %v", len(got), got)
}
}

// TestDiscoverWalkedMCPConfigs_CodexInstalledPlugin: Codex installs under
// plugins/cache too, so its plugin .mcp.json is reported as codex_plugin.
func TestDiscoverWalkedMCPConfigs_CodexInstalledPlugin(t *testing.T) {
root := t.TempDir()
writeFile(t, root, ".codex/plugins/cache/openai-curated-remote/github/.codex-plugin/plugin.json")
installed := writeFile(t, root, ".codex/plugins/cache/openai-curated-remote/github/.mcp.json")

d := &MCPDetector{}
got := gotSpecMap(d.discoverWalkedMCPConfigs([]string{root}, ""))

s, ok := got[installed]
if !ok {
t.Fatalf("did not find %s: %v", installed, got)
}
if s.SourceName != "codex_plugin" || s.Vendor != "OpenAI" {
t.Errorf("got source=%q vendor=%q", s.SourceName, s.Vendor)
}
}

// TestPluginPackageRoot_NestedAndBounded: a config nested inside a package
// resolves to the package root; the search stops at the walk root.
func TestPluginPackageRoot_NestedAndBounded(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "pkg/.claude-plugin/plugin.json")
nested := writeFile(t, root, "pkg/config/deep/mcp.json")

pluginRoot, manifest, ok := pluginPackageRoot(filepath.Dir(nested), root)
if !ok {
t.Fatal("expected to find package root")
}
if want := filepath.Join(root, "pkg"); pluginRoot != want {
t.Errorf("pluginRoot = %q, want %q", pluginRoot, want)
}
if manifest.sourceName != "claude_plugin" {
t.Errorf("manifest = %q, want claude_plugin", manifest.sourceName)
}

if _, _, ok := pluginPackageRoot(filepath.Join(root, "other"), root); ok {
t.Error("expected no package root outside a plugin package")
}
}
Loading