|
4 | 4 | "context" |
5 | 5 | "encoding/base64" |
6 | 6 | "encoding/json" |
| 7 | + "path/filepath" |
7 | 8 | "strings" |
8 | 9 |
|
9 | 10 | "github.com/step-security/dev-machine-guard/internal/executor" |
@@ -58,6 +59,15 @@ func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise |
58 | 59 | }) |
59 | 60 | } |
60 | 61 |
|
| 62 | + // Discover project-level .mcp.json files from known project paths |
| 63 | + for _, projectMCP := range d.discoverProjectMCPConfigs(homeDir) { |
| 64 | + results = append(results, model.MCPConfig{ |
| 65 | + ConfigSource: projectMCP.SourceName, |
| 66 | + ConfigPath: projectMCP.ConfigPath, |
| 67 | + Vendor: projectMCP.Vendor, |
| 68 | + }) |
| 69 | + } |
| 70 | + |
61 | 71 | return results |
62 | 72 | } |
63 | 73 |
|
@@ -90,9 +100,68 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter |
90 | 100 | }) |
91 | 101 | } |
92 | 102 |
|
| 103 | + // Discover project-level .mcp.json files from known project paths |
| 104 | + for _, projectMCP := range d.discoverProjectMCPConfigs(homeDir) { |
| 105 | + content, err := d.exec.ReadFile(projectMCP.ConfigPath) |
| 106 | + if err != nil || len(content) == 0 { |
| 107 | + continue |
| 108 | + } |
| 109 | + |
| 110 | + filteredContent := d.filterMCPContent(projectMCP.SourceName, projectMCP.ConfigPath, content) |
| 111 | + contentBase64 := base64.StdEncoding.EncodeToString(filteredContent) |
| 112 | + |
| 113 | + results = append(results, model.MCPConfigEnterprise{ |
| 114 | + ConfigSource: projectMCP.SourceName, |
| 115 | + ConfigPath: projectMCP.ConfigPath, |
| 116 | + Vendor: projectMCP.Vendor, |
| 117 | + ConfigContentBase64: contentBase64, |
| 118 | + }) |
| 119 | + } |
| 120 | + |
93 | 121 | return results |
94 | 122 | } |
95 | 123 |
|
| 124 | +// discoverProjectMCPConfigs finds project-level .mcp.json files by reading project paths |
| 125 | +// from ~/.claude.json's "projects" section. |
| 126 | +func (d *MCPDetector) discoverProjectMCPConfigs(homeDir string) []mcpConfigSpec { |
| 127 | + claudeJSONPath := expandTilde("~/.claude.json", homeDir) |
| 128 | + |
| 129 | + content, err := d.exec.ReadFile(claudeJSONPath) |
| 130 | + if err != nil || len(content) == 0 { |
| 131 | + return nil |
| 132 | + } |
| 133 | + |
| 134 | + var parsed struct { |
| 135 | + Projects map[string]json.RawMessage `json:"projects"` |
| 136 | + } |
| 137 | + if err := json.Unmarshal(content, &parsed); err != nil || len(parsed.Projects) == 0 { |
| 138 | + return nil |
| 139 | + } |
| 140 | + |
| 141 | + var specs []mcpConfigSpec |
| 142 | + seen := make(map[string]bool) |
| 143 | + |
| 144 | + for projectPath := range parsed.Projects { |
| 145 | + mcpPath := filepath.Join(projectPath, ".mcp.json") |
| 146 | + if seen[mcpPath] { |
| 147 | + continue |
| 148 | + } |
| 149 | + seen[mcpPath] = true |
| 150 | + |
| 151 | + if !d.exec.FileExists(mcpPath) { |
| 152 | + continue |
| 153 | + } |
| 154 | + |
| 155 | + specs = append(specs, mcpConfigSpec{ |
| 156 | + SourceName: "project_mcp", |
| 157 | + ConfigPath: mcpPath, |
| 158 | + Vendor: "Project", |
| 159 | + }) |
| 160 | + } |
| 161 | + |
| 162 | + return specs |
| 163 | +} |
| 164 | + |
96 | 165 | // resolveConfigPath returns the appropriate config path for the current platform. |
97 | 166 | func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) string { |
98 | 167 | if d.exec.GOOS() == "windows" && spec.WinConfigPath != "" { |
@@ -131,17 +200,67 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] |
131 | 200 | return out |
132 | 201 | } |
133 | 202 |
|
134 | | -// extractMCPServers extracts mcpServers/context_servers, keeping only command/args/serverUrl/url. |
| 203 | +// extractMCPServers extracts mcpServers/context_servers/servers, keeping only command/args/serverUrl/url. |
| 204 | +// Also handles Claude Code's project-scoped mcpServers nested under projects → <path> → mcpServers. |
135 | 205 | func (d *MCPDetector) extractMCPServers(raw map[string]json.RawMessage) map[string]any { |
136 | | - // Try mcpServers |
| 206 | + result := make(map[string]any) |
| 207 | + found := false |
| 208 | + |
| 209 | + // Try mcpServers (Cursor, Claude Desktop) |
137 | 210 | if servers, ok := raw["mcpServers"]; ok { |
138 | | - return map[string]any{"mcpServers": filterServerFields(servers)} |
| 211 | + result["mcpServers"] = filterServerFields(servers) |
| 212 | + found = true |
139 | 213 | } |
140 | | - // Try context_servers |
| 214 | + // Try context_servers (Zed) |
141 | 215 | if servers, ok := raw["context_servers"]; ok { |
142 | | - return map[string]any{"context_servers": filterServerFields(servers)} |
| 216 | + result["context_servers"] = filterServerFields(servers) |
| 217 | + found = true |
| 218 | + } |
| 219 | + // Try servers (VS Code mcp.json) |
| 220 | + if servers, ok := raw["servers"]; ok { |
| 221 | + result["servers"] = filterServerFields(servers) |
| 222 | + found = true |
| 223 | + } |
| 224 | + // Try project-scoped mcpServers (Claude Code ~/.claude.json) |
| 225 | + // Structure: { "projects": { "<path>": { "mcpServers": { ... } } } } |
| 226 | + if projectsRaw, ok := raw["projects"]; ok { |
| 227 | + filteredProjects := filterProjectScopedMCPServers(projectsRaw) |
| 228 | + if filteredProjects != nil { |
| 229 | + result["projects"] = filteredProjects |
| 230 | + found = true |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + if !found { |
| 235 | + return nil |
| 236 | + } |
| 237 | + return result |
| 238 | +} |
| 239 | + |
| 240 | +// filterProjectScopedMCPServers extracts mcpServers from each project in the projects map. |
| 241 | +// Returns only projects that have mcpServers, with server fields filtered. |
| 242 | +func filterProjectScopedMCPServers(projectsRaw json.RawMessage) map[string]any { |
| 243 | + var projects map[string]map[string]json.RawMessage |
| 244 | + if err := json.Unmarshal(projectsRaw, &projects); err != nil { |
| 245 | + return nil |
| 246 | + } |
| 247 | + |
| 248 | + filtered := make(map[string]any) |
| 249 | + for path, projectConfig := range projects { |
| 250 | + serversRaw, ok := projectConfig["mcpServers"] |
| 251 | + if !ok { |
| 252 | + continue |
| 253 | + } |
| 254 | + serverFields := filterServerFields(serversRaw) |
| 255 | + if len(serverFields) > 0 { |
| 256 | + filtered[path] = map[string]any{"mcpServers": serverFields} |
| 257 | + } |
| 258 | + } |
| 259 | + |
| 260 | + if len(filtered) == 0 { |
| 261 | + return nil |
143 | 262 | } |
144 | | - return nil |
| 263 | + return filtered |
145 | 264 | } |
146 | 265 |
|
147 | 266 | // filterServerFields keeps only command, args, serverUrl, url from each server entry. |
|
0 commit comments