Skip to content

Commit 0c9b6e2

Browse files
authored
Merge pull request #23 from shubham-stepsecurity/sm/feat/mcp
feat: implement project-level MCP configuration discovery and filtering
2 parents 04fb226 + fa126ec commit 0c9b6e2

2 files changed

Lines changed: 282 additions & 6 deletions

File tree

internal/detector/mcp.go

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/base64"
66
"encoding/json"
7+
"path/filepath"
78
"strings"
89

910
"github.com/step-security/dev-machine-guard/internal/executor"
@@ -58,6 +59,15 @@ func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise
5859
})
5960
}
6061

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+
6171
return results
6272
}
6373

@@ -90,9 +100,68 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter
90100
})
91101
}
92102

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+
93121
return results
94122
}
95123

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+
96165
// resolveConfigPath returns the appropriate config path for the current platform.
97166
func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) string {
98167
if d.exec.GOOS() == "windows" && spec.WinConfigPath != "" {
@@ -131,17 +200,67 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []
131200
return out
132201
}
133202

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.
135205
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)
137210
if servers, ok := raw["mcpServers"]; ok {
138-
return map[string]any{"mcpServers": filterServerFields(servers)}
211+
result["mcpServers"] = filterServerFields(servers)
212+
found = true
139213
}
140-
// Try context_servers
214+
// Try context_servers (Zed)
141215
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
143262
}
144-
return nil
263+
return filtered
145264
}
146265

147266
// filterServerFields keeps only command, args, serverUrl, url from each server entry.

internal/detector/mcp_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package detector
22

33
import (
44
"context"
5+
"encoding/json"
56
"testing"
67

78
"github.com/step-security/dev-machine-guard/internal/executor"
@@ -82,6 +83,162 @@ func TestMCPDetector_Enterprise(t *testing.T) {
8283
}
8384
}
8485

86+
func TestExtractMCPServers_ClaudeCodeProjectScoped(t *testing.T) {
87+
det := &MCPDetector{}
88+
89+
// Claude Code ~/.claude.json with project-scoped mcpServers
90+
content := []byte(`{
91+
"numStartups": 10,
92+
"projects": {
93+
"/Users/test/project-a": {
94+
"allowedTools": [],
95+
"mcpServers": {
96+
"notion": {"url": "https://mcp.notion.com/mcp", "headers": {"secret": "redacted"}}
97+
}
98+
},
99+
"/Users/test/project-b": {
100+
"allowedTools": [],
101+
"mcpServers": {
102+
"linear": {"url": "https://mcp.linear.app/mcp"}
103+
}
104+
},
105+
"/Users/test/project-c": {
106+
"allowedTools": []
107+
}
108+
}
109+
}`)
110+
111+
filtered := det.filterMCPContent("claude_code", "/Users/test/.claude.json", content)
112+
113+
// Parse the result to verify structure
114+
var result map[string]any
115+
if err := json.Unmarshal(filtered, &result); err != nil {
116+
t.Fatalf("failed to parse filtered content: %v", err)
117+
}
118+
119+
// Should have projects key
120+
projects, ok := result["projects"].(map[string]any)
121+
if !ok {
122+
t.Fatal("expected projects key in filtered output")
123+
}
124+
125+
// Should only have projects with mcpServers (project-c should be excluded)
126+
if len(projects) != 2 {
127+
t.Errorf("expected 2 projects with mcpServers, got %d", len(projects))
128+
}
129+
130+
// Should not have non-MCP fields like numStartups
131+
if _, ok := result["numStartups"]; ok {
132+
t.Error("non-MCP field numStartups should be filtered out")
133+
}
134+
135+
// Verify server fields are filtered (no headers/secret)
136+
projA, ok := projects["/Users/test/project-a"].(map[string]any)
137+
if !ok {
138+
t.Fatal("expected project-a in output")
139+
}
140+
mcpServers, ok := projA["mcpServers"].(map[string]any)
141+
if !ok {
142+
t.Fatal("expected mcpServers in project-a")
143+
}
144+
notion, ok := mcpServers["notion"].(map[string]any)
145+
if !ok {
146+
t.Fatal("expected notion server in project-a")
147+
}
148+
if _, ok := notion["headers"]; ok {
149+
t.Error("headers should be filtered out from server config")
150+
}
151+
if notion["url"] != "https://mcp.notion.com/mcp" {
152+
t.Errorf("expected notion url, got %v", notion["url"])
153+
}
154+
}
155+
156+
func TestExtractMCPServers_VSCodeFormat(t *testing.T) {
157+
det := &MCPDetector{}
158+
159+
content := []byte(`{
160+
"servers": {
161+
"my-server": {"command": "npx", "args": ["-y", "server"], "env": {"SECRET": "key"}}
162+
}
163+
}`)
164+
165+
filtered := det.filterMCPContent("vscode", "/Users/test/.vscode/mcp.json", content)
166+
167+
var result map[string]any
168+
if err := json.Unmarshal(filtered, &result); err != nil {
169+
t.Fatalf("failed to parse filtered content: %v", err)
170+
}
171+
172+
servers, ok := result["servers"].(map[string]any)
173+
if !ok {
174+
t.Fatal("expected servers key in filtered output")
175+
}
176+
177+
srv, ok := servers["my-server"].(map[string]any)
178+
if !ok {
179+
t.Fatal("expected my-server in output")
180+
}
181+
if srv["command"] != "npx" {
182+
t.Errorf("expected command npx, got %v", srv["command"])
183+
}
184+
if _, ok := srv["env"]; ok {
185+
t.Error("env should be filtered out")
186+
}
187+
}
188+
189+
func TestMCPDetector_DiscoverProjectMCPConfigs(t *testing.T) {
190+
mock := executor.NewMock()
191+
192+
// Set up ~/.claude.json with project paths
193+
claudeJSON := `{
194+
"projects": {
195+
"/Users/testuser/project-a": {"allowedTools": []},
196+
"/Users/testuser/project-b": {"allowedTools": []},
197+
"/Users/testuser/project-c": {"allowedTools": []}
198+
}
199+
}`
200+
mock.SetFile("/Users/testuser/.claude.json", []byte(claudeJSON))
201+
202+
// Only project-a and project-b have .mcp.json files
203+
mock.SetFile("/Users/testuser/project-a/.mcp.json",
204+
[]byte(`{"mcpServers":{"notion":{"url":"https://mcp.notion.com/mcp"}}}`))
205+
mock.SetFile("/Users/testuser/project-b/.mcp.json",
206+
[]byte(`{"mcpServers":{"linear":{"url":"https://mcp.linear.app/mcp"}}}`))
207+
208+
det := NewMCPDetector(mock)
209+
results := det.DetectEnterprise(context.Background())
210+
211+
// Should find: claude.json (global) + 2 project-level .mcp.json
212+
projectMCPCount := 0
213+
for _, r := range results {
214+
if r.ConfigSource == "project_mcp" {
215+
projectMCPCount++
216+
}
217+
}
218+
219+
if projectMCPCount != 2 {
220+
t.Errorf("expected 2 project-level MCP configs, got %d", projectMCPCount)
221+
}
222+
223+
// Verify project paths
224+
foundA := false
225+
foundB := false
226+
for _, r := range results {
227+
if r.ConfigPath == "/Users/testuser/project-a/.mcp.json" {
228+
foundA = true
229+
}
230+
if r.ConfigPath == "/Users/testuser/project-b/.mcp.json" {
231+
foundB = true
232+
}
233+
}
234+
if !foundA {
235+
t.Error("expected project-a .mcp.json to be found")
236+
}
237+
if !foundB {
238+
t.Error("expected project-b .mcp.json to be found")
239+
}
240+
}
241+
85242
func TestMCPDetector_Windows_FindsConfigs(t *testing.T) {
86243
mock := executor.NewMock()
87244
mock.SetGOOS("windows")

0 commit comments

Comments
 (0)