Skip to content

Commit 7cc8f42

Browse files
authored
feat: detect nonstandard claude MCP server config (#728)
* feat: detect nonstandard claude configs * code review: implement project wide scanning * formatting * fix project/user scope
1 parent 5e1a806 commit 7cc8f42

4 files changed

Lines changed: 793 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package aitool
2+
3+
import (
4+
"context"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/safedep/vet/pkg/common/logger"
10+
)
11+
12+
type claudeCodeUserConfigDiscoverer struct {
13+
homeDir string
14+
config DiscoveryConfig
15+
}
16+
17+
// NewClaudeCodeUserConfigDiscoverer creates a discoverer for Claude Code's
18+
// user-level MCP config (~/.claude.json) and plugin-installed MCPs
19+
// (~/.claude/plugins/cache/**/.mcp.json). These are distinct from app-level
20+
// settings (~/.claude/settings.json) handled by NewClaudeCodeDiscoverer.
21+
func NewClaudeCodeUserConfigDiscoverer(config DiscoveryConfig) (AIToolReader, error) {
22+
homeDir := config.HomeDir
23+
if homeDir == "" {
24+
var err error
25+
homeDir, err = os.UserHomeDir()
26+
if err != nil {
27+
return nil, err
28+
}
29+
}
30+
return &claudeCodeUserConfigDiscoverer{homeDir: homeDir, config: config}, nil
31+
}
32+
33+
func (d *claudeCodeUserConfigDiscoverer) Name() string { return "Claude Code User Config" }
34+
func (d *claudeCodeUserConfigDiscoverer) App() string { return claudeCodeApp }
35+
36+
func (d *claudeCodeUserConfigDiscoverer) EnumTools(_ context.Context, handler AIToolHandlerFn) error {
37+
if d.config.ScopeEnabled(AIToolScopeSystem) {
38+
if err := d.processUserConfig(handler); err != nil {
39+
return err
40+
}
41+
if err := d.walkPluginCache(handler); err != nil {
42+
return err
43+
}
44+
}
45+
if d.config.ScopeEnabled(AIToolScopeProject) {
46+
if err := d.processAllProjectMCPs(handler); err != nil {
47+
return err
48+
}
49+
}
50+
return nil
51+
}
52+
53+
// processUserConfig reads ~/.claude.json, which is Claude Code's user-level
54+
// MCP registry. It uses the same mcpServers JSON format as settings.json.
55+
func (d *claudeCodeUserConfigDiscoverer) processUserConfig(handler AIToolHandlerFn) error {
56+
path := filepath.Join(d.homeDir, ".claude.json")
57+
cfg, err := parseMCPAppConfig(path)
58+
if err != nil {
59+
logger.Debugf("Claude Code user config not found or unreadable: %s", path)
60+
return nil
61+
}
62+
return emitMCPServers(cfg, path, AIToolScopeSystem, claudeCodeApp, claudeCodeAppDisplay, handler)
63+
}
64+
65+
// processAllProjectMCPs reads ~/.claude.json and emits MCP servers from every
66+
// entry under the "projects" map as project-scoped items (Claude Code "local"
67+
// scope). The ConfigPath is set to the project path so IDs are unique across
68+
// projects even when two projects share a server name.
69+
func (d *claudeCodeUserConfigDiscoverer) processAllProjectMCPs(handler AIToolHandlerFn) error {
70+
path := filepath.Join(d.homeDir, ".claude.json")
71+
cfg, err := parseClaudeUserConfigFile(path)
72+
if err != nil {
73+
logger.Debugf("Claude Code user config not found or unreadable for project MCPs: %s", path)
74+
return nil
75+
}
76+
for projectPath, entry := range cfg.Projects {
77+
if len(entry.MCPServers) == 0 {
78+
continue
79+
}
80+
mcpCfg := projectEntryToMCPConfig(entry)
81+
if err := emitMCPServers(mcpCfg, projectPath, AIToolScopeProject, claudeCodeApp, claudeCodeAppDisplay, handler); err != nil {
82+
return err
83+
}
84+
}
85+
return nil
86+
}
87+
88+
// walkPluginCache walks ~/.claude/plugins/cache/**/.mcp.json and emits MCPs
89+
// from each file found. Plugin publishers use two formats: the standard
90+
// mcpServers-wrapped object, and a bare map of server-name to entry. Both
91+
// are handled via parsePluginMCPConfig.
92+
func (d *claudeCodeUserConfigDiscoverer) walkPluginCache(handler AIToolHandlerFn) error {
93+
cacheDir := filepath.Join(d.homeDir, ".claude", "plugins", "cache")
94+
return filepath.WalkDir(cacheDir, func(path string, entry fs.DirEntry, err error) error {
95+
if err != nil {
96+
return nil // skip inaccessible paths silently
97+
}
98+
if entry.IsDir() || entry.Name() != ".mcp.json" {
99+
return nil
100+
}
101+
cfg, parseErr := parsePluginMCPConfig(path)
102+
if parseErr != nil {
103+
logger.Debugf("Claude Code plugin MCP config unreadable: %s: %v", path, parseErr)
104+
return nil
105+
}
106+
return emitMCPServers(cfg, path, AIToolScopeSystem, claudeCodeApp, claudeCodeAppDisplay, handler)
107+
})
108+
}

0 commit comments

Comments
 (0)