diff --git a/internal/detector/mcp.go b/internal/detector/mcp.go index a7379ca..471277d 100644 --- a/internal/detector/mcp.go +++ b/internal/detector/mcp.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "path/filepath" "strings" "github.com/step-security/dev-machine-guard/internal/executor" @@ -58,6 +59,15 @@ func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise }) } + // Discover project-level .mcp.json files from known project paths + for _, projectMCP := range d.discoverProjectMCPConfigs(homeDir) { + results = append(results, model.MCPConfig{ + ConfigSource: projectMCP.SourceName, + ConfigPath: projectMCP.ConfigPath, + Vendor: projectMCP.Vendor, + }) + } + return results } @@ -90,9 +100,68 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter }) } + // Discover project-level .mcp.json files from known project paths + for _, projectMCP := range d.discoverProjectMCPConfigs(homeDir) { + content, err := d.exec.ReadFile(projectMCP.ConfigPath) + if err != nil || len(content) == 0 { + continue + } + + filteredContent := d.filterMCPContent(projectMCP.SourceName, projectMCP.ConfigPath, content) + contentBase64 := base64.StdEncoding.EncodeToString(filteredContent) + + results = append(results, model.MCPConfigEnterprise{ + ConfigSource: projectMCP.SourceName, + ConfigPath: projectMCP.ConfigPath, + Vendor: projectMCP.Vendor, + ConfigContentBase64: contentBase64, + }) + } + return results } +// discoverProjectMCPConfigs finds project-level .mcp.json files by reading project paths +// from ~/.claude.json's "projects" section. +func (d *MCPDetector) discoverProjectMCPConfigs(homeDir string) []mcpConfigSpec { + claudeJSONPath := expandTilde("~/.claude.json", homeDir) + + content, err := d.exec.ReadFile(claudeJSONPath) + if err != nil || len(content) == 0 { + return nil + } + + var parsed struct { + Projects map[string]json.RawMessage `json:"projects"` + } + if err := json.Unmarshal(content, &parsed); err != nil || len(parsed.Projects) == 0 { + return nil + } + + var specs []mcpConfigSpec + seen := make(map[string]bool) + + for projectPath := range parsed.Projects { + mcpPath := filepath.Join(projectPath, ".mcp.json") + if seen[mcpPath] { + continue + } + seen[mcpPath] = true + + if !d.exec.FileExists(mcpPath) { + continue + } + + specs = append(specs, mcpConfigSpec{ + SourceName: "project_mcp", + ConfigPath: mcpPath, + Vendor: "Project", + }) + } + + return specs +} + // resolveConfigPath returns the appropriate config path for the current platform. func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) string { if d.exec.GOOS() == "windows" && spec.WinConfigPath != "" { @@ -131,17 +200,67 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] return out } -// extractMCPServers extracts mcpServers/context_servers, keeping only command/args/serverUrl/url. +// extractMCPServers extracts mcpServers/context_servers/servers, keeping only command/args/serverUrl/url. +// Also handles Claude Code's project-scoped mcpServers nested under projects → → mcpServers. func (d *MCPDetector) extractMCPServers(raw map[string]json.RawMessage) map[string]any { - // Try mcpServers + result := make(map[string]any) + found := false + + // Try mcpServers (Cursor, Claude Desktop) if servers, ok := raw["mcpServers"]; ok { - return map[string]any{"mcpServers": filterServerFields(servers)} + result["mcpServers"] = filterServerFields(servers) + found = true } - // Try context_servers + // Try context_servers (Zed) if servers, ok := raw["context_servers"]; ok { - return map[string]any{"context_servers": filterServerFields(servers)} + result["context_servers"] = filterServerFields(servers) + found = true + } + // Try servers (VS Code mcp.json) + if servers, ok := raw["servers"]; ok { + result["servers"] = filterServerFields(servers) + found = true + } + // Try project-scoped mcpServers (Claude Code ~/.claude.json) + // Structure: { "projects": { "": { "mcpServers": { ... } } } } + if projectsRaw, ok := raw["projects"]; ok { + filteredProjects := filterProjectScopedMCPServers(projectsRaw) + if filteredProjects != nil { + result["projects"] = filteredProjects + found = true + } + } + + if !found { + return nil + } + return result +} + +// filterProjectScopedMCPServers extracts mcpServers from each project in the projects map. +// Returns only projects that have mcpServers, with server fields filtered. +func filterProjectScopedMCPServers(projectsRaw json.RawMessage) map[string]any { + var projects map[string]map[string]json.RawMessage + if err := json.Unmarshal(projectsRaw, &projects); err != nil { + return nil + } + + filtered := make(map[string]any) + for path, projectConfig := range projects { + serversRaw, ok := projectConfig["mcpServers"] + if !ok { + continue + } + serverFields := filterServerFields(serversRaw) + if len(serverFields) > 0 { + filtered[path] = map[string]any{"mcpServers": serverFields} + } + } + + if len(filtered) == 0 { + return nil } - return nil + return filtered } // filterServerFields keeps only command, args, serverUrl, url from each server entry. diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go index f3380b9..2a64059 100644 --- a/internal/detector/mcp_test.go +++ b/internal/detector/mcp_test.go @@ -2,6 +2,7 @@ package detector import ( "context" + "encoding/json" "testing" "github.com/step-security/dev-machine-guard/internal/executor" @@ -82,6 +83,162 @@ func TestMCPDetector_Enterprise(t *testing.T) { } } +func TestExtractMCPServers_ClaudeCodeProjectScoped(t *testing.T) { + det := &MCPDetector{} + + // Claude Code ~/.claude.json with project-scoped mcpServers + content := []byte(`{ + "numStartups": 10, + "projects": { + "/Users/test/project-a": { + "allowedTools": [], + "mcpServers": { + "notion": {"url": "https://mcp.notion.com/mcp", "headers": {"secret": "redacted"}} + } + }, + "/Users/test/project-b": { + "allowedTools": [], + "mcpServers": { + "linear": {"url": "https://mcp.linear.app/mcp"} + } + }, + "/Users/test/project-c": { + "allowedTools": [] + } + } + }`) + + filtered := det.filterMCPContent("claude_code", "/Users/test/.claude.json", content) + + // Parse the result to verify structure + var result map[string]any + if err := json.Unmarshal(filtered, &result); err != nil { + t.Fatalf("failed to parse filtered content: %v", err) + } + + // Should have projects key + projects, ok := result["projects"].(map[string]any) + if !ok { + t.Fatal("expected projects key in filtered output") + } + + // Should only have projects with mcpServers (project-c should be excluded) + if len(projects) != 2 { + t.Errorf("expected 2 projects with mcpServers, got %d", len(projects)) + } + + // Should not have non-MCP fields like numStartups + if _, ok := result["numStartups"]; ok { + t.Error("non-MCP field numStartups should be filtered out") + } + + // Verify server fields are filtered (no headers/secret) + projA, ok := projects["/Users/test/project-a"].(map[string]any) + if !ok { + t.Fatal("expected project-a in output") + } + mcpServers, ok := projA["mcpServers"].(map[string]any) + if !ok { + t.Fatal("expected mcpServers in project-a") + } + notion, ok := mcpServers["notion"].(map[string]any) + if !ok { + t.Fatal("expected notion server in project-a") + } + if _, ok := notion["headers"]; ok { + t.Error("headers should be filtered out from server config") + } + if notion["url"] != "https://mcp.notion.com/mcp" { + t.Errorf("expected notion url, got %v", notion["url"]) + } +} + +func TestExtractMCPServers_VSCodeFormat(t *testing.T) { + det := &MCPDetector{} + + content := []byte(`{ + "servers": { + "my-server": {"command": "npx", "args": ["-y", "server"], "env": {"SECRET": "key"}} + } + }`) + + filtered := det.filterMCPContent("vscode", "/Users/test/.vscode/mcp.json", content) + + var result map[string]any + if err := json.Unmarshal(filtered, &result); err != nil { + t.Fatalf("failed to parse filtered content: %v", err) + } + + servers, ok := result["servers"].(map[string]any) + if !ok { + t.Fatal("expected servers key in filtered output") + } + + srv, ok := servers["my-server"].(map[string]any) + if !ok { + t.Fatal("expected my-server in output") + } + if srv["command"] != "npx" { + t.Errorf("expected command npx, got %v", srv["command"]) + } + if _, ok := srv["env"]; ok { + t.Error("env should be filtered out") + } +} + +func TestMCPDetector_DiscoverProjectMCPConfigs(t *testing.T) { + mock := executor.NewMock() + + // Set up ~/.claude.json with project paths + claudeJSON := `{ + "projects": { + "/Users/testuser/project-a": {"allowedTools": []}, + "/Users/testuser/project-b": {"allowedTools": []}, + "/Users/testuser/project-c": {"allowedTools": []} + } + }` + mock.SetFile("/Users/testuser/.claude.json", []byte(claudeJSON)) + + // Only project-a and project-b have .mcp.json files + mock.SetFile("/Users/testuser/project-a/.mcp.json", + []byte(`{"mcpServers":{"notion":{"url":"https://mcp.notion.com/mcp"}}}`)) + mock.SetFile("/Users/testuser/project-b/.mcp.json", + []byte(`{"mcpServers":{"linear":{"url":"https://mcp.linear.app/mcp"}}}`)) + + det := NewMCPDetector(mock) + results := det.DetectEnterprise(context.Background()) + + // Should find: claude.json (global) + 2 project-level .mcp.json + projectMCPCount := 0 + for _, r := range results { + if r.ConfigSource == "project_mcp" { + projectMCPCount++ + } + } + + if projectMCPCount != 2 { + t.Errorf("expected 2 project-level MCP configs, got %d", projectMCPCount) + } + + // Verify project paths + foundA := false + foundB := false + for _, r := range results { + if r.ConfigPath == "/Users/testuser/project-a/.mcp.json" { + foundA = true + } + if r.ConfigPath == "/Users/testuser/project-b/.mcp.json" { + foundB = true + } + } + if !foundA { + t.Error("expected project-a .mcp.json to be found") + } + if !foundB { + t.Error("expected project-b .mcp.json to be found") + } +} + func TestMCPDetector_Windows_FindsConfigs(t *testing.T) { mock := executor.NewMock() mock.SetGOOS("windows")