Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 125 additions & 6 deletions internal/detector/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"path/filepath"
"strings"

"github.com/step-security/dev-machine-guard/internal/executor"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 → <path> → 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": { "<path>": { "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.
Expand Down
157 changes: 157 additions & 0 deletions internal/detector/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package detector

import (
"context"
"encoding/json"
"testing"

"github.com/step-security/dev-machine-guard/internal/executor"
Expand Down Expand Up @@ -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")
Expand Down
Loading