From 1f428979858779598cc4228dc848edb2675dff5e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 21 Mar 2026 10:58:32 +0100 Subject: [PATCH] feat(materials): extract structured MCP server info from AI agent config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse .mcp.json and settings.json to populate the mcp_servers field with structured data (server name, command, args, URL, transport type). Environment variable values are stripped for security — only key names are retained. The raw .mcp.json file is no longer collected in config_files to prevent leaking sensitive data like API keys. Closes #2888 Signed-off-by: Jose I. Paris --- internal/aiagentconfig/aiagentconfig.go | 16 +- internal/aiagentconfig/builder.go | 117 +++++++++-- internal/aiagentconfig/builder_test.go | 121 ++++++++++++ internal/aiagentconfig/discover.go | 1 - internal/aiagentconfig/discover_test.go | 4 - internal/aiagentconfig/mcpservers.go | 76 +++++++ internal/aiagentconfig/mcpservers_test.go | 186 ++++++++++++++++++ .../ai-agent-config-0.1.schema.json | 36 +++- 8 files changed, 535 insertions(+), 22 deletions(-) create mode 100644 internal/aiagentconfig/mcpservers.go create mode 100644 internal/aiagentconfig/mcpservers_test.go diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go index d1e10a330..f32e6d760 100644 --- a/internal/aiagentconfig/aiagentconfig.go +++ b/internal/aiagentconfig/aiagentconfig.go @@ -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"` +} + // Data is the AI agent configuration payload type Data struct { Agent Agent `json:"agent"` @@ -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 diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 317c8973c..df69afae1 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -25,6 +25,8 @@ import ( "sort" "strings" "time" + + "github.com/rs/zerolog/log" ) // Build reads discovered files and constructs the AI agent config payload. @@ -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) @@ -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)) diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index d1ff4914b..5e88fd300 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -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) +} diff --git a/internal/aiagentconfig/discover.go b/internal/aiagentconfig/discover.go index fd347ecd4..097c56a37 100644 --- a/internal/aiagentconfig/discover.go +++ b/internal/aiagentconfig/discover.go @@ -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}, } diff --git a/internal/aiagentconfig/discover_test.go b/internal/aiagentconfig/discover_test.go index 920963f70..f22a8260e 100644 --- a/internal/aiagentconfig/discover_test.go +++ b/internal/aiagentconfig/discover_test.go @@ -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}, }, }, @@ -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}, }, diff --git a/internal/aiagentconfig/mcpservers.go b/internal/aiagentconfig/mcpservers.go new file mode 100644 index 000000000..e073744c6 --- /dev/null +++ b/internal/aiagentconfig/mcpservers.go @@ -0,0 +1,76 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aiagentconfig + +import ( + "encoding/json" + "maps" + "slices" + "sort" +) + +// rawMCPConfig represents the top-level structure containing MCP server definitions. +// Both .mcp.json and .claude/settings.json use "mcpServers" as the key. +type rawMCPConfig struct { + MCPServers map[string]rawMCPServerEntry `json:"mcpServers"` +} + +// rawMCPServerEntry is the raw JSON shape of a single MCP server entry. +type rawMCPServerEntry struct { + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + Env map[string]string `json:"env,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +// ExtractMCPServers parses MCP server entries from raw JSON content. +// It handles both .mcp.json format and .claude/settings.json format. +// Environment variable values are stripped; only key names are retained. +// Returns nil without error if the JSON is valid but contains no mcpServers. +func ExtractMCPServers(content []byte) ([]MCPServer, error) { + var raw rawMCPConfig + if err := json.Unmarshal(content, &raw); err != nil { + return nil, err + } + + if len(raw.MCPServers) == 0 { + return nil, nil + } + + servers := make([]MCPServer, 0, len(raw.MCPServers)) + for name, entry := range raw.MCPServers { + srv := MCPServer{ + Name: name, + Command: entry.Command, + Args: entry.Args, + URL: entry.URL, + Disabled: entry.Disabled, + } + + if len(entry.Env) > 0 { + srv.EnvKeys = slices.Sorted(maps.Keys(entry.Env)) + } + + servers = append(servers, srv) + } + + sort.Slice(servers, func(i, j int) bool { + return servers[i].Name < servers[j].Name + }) + + return servers, nil +} diff --git a/internal/aiagentconfig/mcpservers_test.go b/internal/aiagentconfig/mcpservers_test.go new file mode 100644 index 000000000..f9f30d83d --- /dev/null +++ b/internal/aiagentconfig/mcpservers_test.go @@ -0,0 +1,186 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aiagentconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractMCPServers(t *testing.T) { + tests := []struct { + name string + input string + want []MCPServer + wantErr bool + }{ + { + name: "stdio server with command, args, and env", + input: `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": {"HOME": "/home/user", "API_KEY": "sk-secret-123"} + } + } + }`, + want: []MCPServer{ + { + Name: "filesystem", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, + EnvKeys: []string{"API_KEY", "HOME"}, + }, + }, + }, + { + name: "remote URL server", + input: `{ + "mcpServers": { + "remote": { + "url": "https://example.com/mcp" + } + } + }`, + want: []MCPServer{ + {Name: "remote", URL: "https://example.com/mcp"}, + }, + }, + { + name: "disabled server", + input: `{ + "mcpServers": { + "disabled-server": { + "command": "node", + "args": ["server.js"], + "disabled": true + } + } + }`, + want: []MCPServer{ + { + Name: "disabled-server", + Command: "node", + Args: []string{"server.js"}, + Disabled: true, + }, + }, + }, + { + name: "multiple servers sorted by name", + input: `{ + "mcpServers": { + "zeta": {"command": "zeta-cmd"}, + "alpha": {"command": "alpha-cmd"} + } + }`, + want: []MCPServer{ + {Name: "alpha", Command: "alpha-cmd"}, + {Name: "zeta", Command: "zeta-cmd"}, + }, + }, + { + name: "env keys sorted alphabetically", + input: `{ + "mcpServers": { + "srv": { + "command": "cmd", + "env": {"ZEBRA": "z", "APPLE": "a", "MANGO": "m"} + } + } + }`, + want: []MCPServer{ + { + Name: "srv", + Command: "cmd", + EnvKeys: []string{"APPLE", "MANGO", "ZEBRA"}, + }, + }, + }, + { + name: "no mcpServers key", + input: `{"other": "value"}`, + want: nil, + }, + { + name: "empty mcpServers", + input: `{"mcpServers": {}}`, + want: nil, + }, + { + name: "invalid JSON", + input: `not json`, + wantErr: true, + }, + { + name: "settings.json with mcpServers among other keys", + input: `{ + "permissions": {"allow": ["read"]}, + "mcpServers": { + "my-server": {"command": "my-cmd", "args": ["--flag"]} + }, + "theme": "dark" + }`, + want: []MCPServer{ + {Name: "my-server", Command: "my-cmd", Args: []string{"--flag"}}, + }, + }, + { + name: "env values are stripped", + input: `{ + "mcpServers": { + "srv": { + "command": "cmd", + "env": {"SECRET": "super-secret-value", "TOKEN": "bearer-xyz"} + } + } + }`, + want: []MCPServer{ + { + Name: "srv", + Command: "cmd", + EnvKeys: []string{"SECRET", "TOKEN"}, + }, + }, + }, + { + name: "server with no env", + input: `{ + "mcpServers": { + "simple": {"command": "echo"} + } + }`, + want: []MCPServer{ + {Name: "simple", Command: "echo"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExtractMCPServers([]byte(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json index e214953d6..5162ae1f8 100644 --- a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json @@ -92,7 +92,41 @@ "description": "Parsed allow/deny rules from agent settings" }, "mcp_servers": { - "description": "MCP server names, URLs, and commands" + "type": "array", + "description": "Extracted MCP server configurations (env values stripped for security)", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Server name from config map key" + }, + "command": { + "type": "string", + "description": "Command for stdio transport" + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Arguments for the command" + }, + "url": { + "type": "string", + "description": "URL for remote/SSE transport" + }, + "env_keys": { + "type": "array", + "items": { "type": "string" }, + "description": "Environment variable names (values stripped)" + }, + "disabled": { + "type": "boolean", + "description": "Whether the server is disabled" + } + }, + "additionalProperties": false + } }, "subagents": { "description": "Subagent names, allowed tools, model overrides"