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
16 changes: 13 additions & 3 deletions internal/aiagentconfig/aiagentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ type ConfigFile struct {
Content string `json:"content"`
}

// MCPServer represents a single MCP server entry extracted from configuration.
type MCPServer struct {
Name string `json:"name"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
URL string `json:"url,omitempty"`
EnvKeys []string `json:"env_keys,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Comment thread
jiparis marked this conversation as resolved.
}

// Data is the AI agent configuration payload
type Data struct {
Agent Agent `json:"agent"`
Expand All @@ -68,9 +78,9 @@ type Data struct {
GitContext *GitContext `json:"git_context,omitempty"`
ConfigFiles []ConfigFile `json:"config_files"`
// Future fields for richer analysis
Permissions any `json:"permissions,omitempty"`
MCPServers any `json:"mcp_servers,omitempty"`
Subagents any `json:"subagents,omitempty"`
Permissions any `json:"permissions,omitempty"`
MCPServers []MCPServer `json:"mcp_servers,omitempty"`
Subagents any `json:"subagents,omitempty"`
}

// Evidence represents the complete evidence structure for AI agent config
Expand Down
117 changes: 104 additions & 13 deletions internal/aiagentconfig/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"sort"
"strings"
"time"

"github.com/rs/zerolog/log"
)

// Build reads discovered files and constructs the AI agent config payload.
Expand All @@ -40,25 +42,16 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt

configFiles := make([]ConfigFile, 0, len(discovered))
hashes := make([]string, 0, len(discovered))
// Collect raw content from settings.json files to avoid base64 round-trip during MCP extraction.
var rawSettingsFiles []rawConfigContent

for _, df := range discovered {
relPath := df.Path
absPath := filepath.Join(basePath, relPath)

// Resolve the full path through any symlinks (covers both symlinked
// files and symlinked parent directories like .claude/) and verify
// the resolved path stays within basePath.
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return nil, fmt.Errorf("resolving %s: %w", relPath, err)
}
if err := ensureInsideDir(realPath, realRoot); err != nil {
return nil, fmt.Errorf("reading %s: %w", relPath, err)
}

content, err := os.ReadFile(realPath)
content, realPath, err := safeReadFile(absPath, realRoot)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", relPath, err)
return nil, fmt.Errorf("%s: %w", relPath, err)
}

info, err := os.Stat(realPath)
Expand All @@ -77,19 +70,117 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt
Size: info.Size(),
Content: base64.StdEncoding.EncodeToString(content),
})

// Keep raw bytes for settings.json files to avoid base64 round-trip
if df.Kind == ConfigFileKindConfiguration && filepath.Base(relPath) == "settings.json" {
rawSettingsFiles = append(rawSettingsFiles, rawConfigContent{path: relPath, content: content})
}
}

mcpServers := extractMCPServers(realRoot, rawSettingsFiles)

data := Data{
Agent: Agent{Name: agentName},
ConfigHash: computeCombinedHash(hashes),
CapturedAt: time.Now().UTC().Format(time.RFC3339),
GitContext: gitCtx,
ConfigFiles: configFiles,
MCPServers: mcpServers,
}

return &data, nil
}

// rawConfigContent holds a file's raw bytes alongside its relative path,
// used to pass already-read content to MCP extraction without re-decoding.
type rawConfigContent struct {
path string
content []byte
}

// extractMCPServers collects MCP server definitions from two sources:
// 1. .mcp.json at the root (read directly, not collected in config_files)
// 2. settings.json files already collected (passed as raw bytes)
// Servers are deduplicated by name (first occurrence wins) and sorted.
func extractMCPServers(realRoot string, settingsFiles []rawConfigContent) []MCPServer {
seen := make(map[string]struct{})
var servers []MCPServer

addServers := func(extracted []MCPServer) {
for _, s := range extracted {
if _, ok := seen[s.Name]; !ok {
seen[s.Name] = struct{}{}
servers = append(servers, s)
}
}
}

// Source 1: .mcp.json read directly from disk.
// Resolve symlinks before reading to prevent reading files outside the root.
if extracted, err := readMCPFile(realRoot); err == nil && len(extracted) > 0 {
addServers(extracted)
}

// Source 2: settings.json files (raw bytes from the Build loop)
for _, sf := range settingsFiles {
extracted, err := ExtractMCPServers(sf.content)
if err != nil {
log.Debug().Err(err).Str("path", sf.path).Msg("failed to parse MCP servers from settings")
continue
}
addServers(extracted)
}

if len(servers) == 0 {
return nil
}

sort.Slice(servers, func(i, j int) bool {
return servers[i].Name < servers[j].Name
})

return servers
}

// readMCPFile reads and parses .mcp.json from the given root directory.
// It resolves symlinks and verifies the file stays inside the root before reading.
func readMCPFile(realRoot string) ([]MCPServer, error) {
mcpPath := filepath.Join(realRoot, ".mcp.json")

content, _, err := safeReadFile(mcpPath, realRoot)
if err != nil {
return nil, err
}

servers, err := ExtractMCPServers(content)
if err != nil {
log.Debug().Err(err).Str("path", ".mcp.json").Msg("failed to parse MCP servers")
return nil, err
}

return servers, nil
}

// safeReadFile resolves symlinks, verifies the resolved path stays inside rootDir,
// and reads the file content. Returns the content and the resolved real path.
func safeReadFile(path, rootDir string) ([]byte, string, error) {
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, "", err
}

if err := ensureInsideDir(realPath, rootDir); err != nil {
return nil, "", err
}

content, err := os.ReadFile(realPath)
if err != nil {
return nil, "", err
}

return content, realPath, nil
}

// computeCombinedHash sorts individual hashes, concatenates them, and hashes the result.
func computeCombinedHash(hashes []string) string {
sorted := make([]string, len(hashes))
Expand Down
121 changes: 121 additions & 0 deletions internal/aiagentconfig/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,124 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) {
require.NoError(t, err)
assert.Len(t, data.ConfigFiles, 1)
}

func TestBuildExtractsMCPServers(t *testing.T) {
rootDir := t.TempDir()

require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".mcp.json"), []byte(`{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "my-package"],
"env": {"API_KEY": "secret-value", "HOME": "/home/user"}
}
}
}`), 0o600))

data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)

// MCP servers extracted from .mcp.json
require.Len(t, data.MCPServers, 1)
assert.Equal(t, "my-server", data.MCPServers[0].Name)
assert.Equal(t, "npx", data.MCPServers[0].Command)
assert.Equal(t, []string{"-y", "my-package"}, data.MCPServers[0].Args)
assert.Equal(t, []string{"API_KEY", "HOME"}, data.MCPServers[0].EnvKeys)

// .mcp.json must NOT appear in config_files
for _, cf := range data.ConfigFiles {
assert.NotEqual(t, ".mcp.json", cf.Path, ".mcp.json should not be in config_files")
}
}

func TestBuildMCPServersFromSettings(t *testing.T) {
rootDir := t.TempDir()

require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".claude"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".claude", "settings.json"), []byte(`{
"permissions": {"allow": ["read"]},
"mcpServers": {
"settings-server": {"url": "https://example.com/mcp"}
}
}`), 0o600))

data, err := Build(rootDir, []DiscoveredFile{
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
}, "claude", nil)
require.NoError(t, err)

require.Len(t, data.MCPServers, 1)
assert.Equal(t, "settings-server", data.MCPServers[0].Name)
assert.Equal(t, "https://example.com/mcp", data.MCPServers[0].URL)
}

func TestBuildMCPServersDeduplication(t *testing.T) {
rootDir := t.TempDir()

// .mcp.json defines "shared-server" with command "from-mcp"
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".mcp.json"), []byte(`{
"mcpServers": {
"shared-server": {"command": "from-mcp"},
"mcp-only": {"command": "mcp-cmd"}
}
}`), 0o600))

// settings.json defines "shared-server" with command "from-settings" and a unique server
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".claude"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".claude", "settings.json"), []byte(`{
"mcpServers": {
"shared-server": {"command": "from-settings"},
"settings-only": {"url": "https://example.com"}
}
}`), 0o600))

data, err := Build(rootDir, []DiscoveredFile{
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
}, "claude", nil)
require.NoError(t, err)

require.Len(t, data.MCPServers, 3)

// Sorted by name: mcp-only, settings-only, shared-server
assert.Equal(t, "mcp-only", data.MCPServers[0].Name)
assert.Equal(t, "mcp-cmd", data.MCPServers[0].Command)

assert.Equal(t, "settings-only", data.MCPServers[1].Name)
assert.Equal(t, "https://example.com", data.MCPServers[1].URL)

// shared-server comes from .mcp.json (first occurrence wins)
assert.Equal(t, "shared-server", data.MCPServers[2].Name)
assert.Equal(t, "from-mcp", data.MCPServers[2].Command)
}

func TestBuildNoMCPServersWhenNonePresent(t *testing.T) {
rootDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))

data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)

assert.Nil(t, data.MCPServers)
}

func TestBuildMCPServersIgnoresInvalidJSON(t *testing.T) {
rootDir := t.TempDir()

require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".mcp.json"), []byte(`not valid json`), 0o600))

data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)

// Build succeeds but no MCP servers extracted
assert.Nil(t, data.MCPServers)
// Config files still collected (CLAUDE.md)
require.Len(t, data.ConfigFiles, 1)
}
1 change: 0 additions & 1 deletion internal/aiagentconfig/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ var agents = []agentDef{
// sharedPatterns are file patterns not exclusive to any agent.
// They are included in every agent's evidence when that agent has exclusive files.
var sharedPatterns = []patternDef{
{".mcp.json", ConfigFileKindConfiguration},
{"AGENTS.md", ConfigFileKindInstruction},
}

Expand Down
4 changes: 0 additions & 4 deletions internal/aiagentconfig/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,15 @@ func TestDiscoverAll(t *testing.T) {
files: []string{
"CLAUDE.md",
".cursor/rules/coding.md",
".mcp.json",
"AGENTS.md",
},
expected: map[string][]DiscoveredFile{
"claude": {
{Path: ".mcp.json", Kind: ConfigFileKindConfiguration},
{Path: "AGENTS.md", Kind: ConfigFileKindInstruction},
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
},
"cursor": {
{Path: ".cursor/rules/coding.md", Kind: ConfigFileKindInstruction},
{Path: ".mcp.json", Kind: ConfigFileKindConfiguration},
{Path: "AGENTS.md", Kind: ConfigFileKindInstruction},
},
},
Expand Down Expand Up @@ -168,7 +165,6 @@ func TestDiscoverAll(t *testing.T) {
{Path: ".claude/rules/testing.md", Kind: ConfigFileKindInstruction},
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
{Path: ".claude/skills/search/SKILL.md", Kind: ConfigFileKindSkill},
{Path: ".mcp.json", Kind: ConfigFileKindConfiguration},
{Path: "AGENTS.md", Kind: ConfigFileKindInstruction},
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
},
Expand Down
Loading
Loading