diff --git a/internal/detector/mcp_discovery.go b/internal/detector/mcp_discovery.go index de77359..fbe1129 100644 --- a/internal/detector/mcp_discovery.go +++ b/internal/detector/mcp_discovery.go @@ -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...) @@ -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 diff --git a/internal/detector/mcp_plugins.go b/internal/detector/mcp_plugins.go new file mode 100644 index 0000000..790de5e --- /dev/null +++ b/internal/detector/mcp_plugins.go @@ -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 + } + } + 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 (/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) +} diff --git a/internal/detector/mcp_plugins_test.go b/internal/detector/mcp_plugins_test.go new file mode 100644 index 0000000..b276737 --- /dev/null +++ b/internal/detector/mcp_plugins_test.go @@ -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") + } +}