a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
461 lines
14 KiB
Go
461 lines
14 KiB
Go
package languages
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/modules"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// MCPConfigExtractor ingests external MCP-server configuration files —
|
|
// a repo-root `.mcp.json`, a `.cursor/mcp.json` / `.kiro/mcp.json`, a
|
|
// VS Code `mcp.json`, or a `claude_desktop_config.json` — into the graph
|
|
// as first-class nodes so an agent can traverse the supply chain of the
|
|
// MCP servers a project (or a developer's machine) wires up.
|
|
//
|
|
// The canonical shape is:
|
|
//
|
|
// {"mcpServers": {"<name>": {"command":"npx","args":["-y","@scope/pkg"],
|
|
// "env":{"VAR":"val"},"type":"stdio"}}}
|
|
//
|
|
// VS Code uses a top-level "servers" key with the same per-server shape;
|
|
// Kiro adds tolerated extras (disabled, autoApprove). Both are accepted.
|
|
//
|
|
// For each server the extractor emits:
|
|
//
|
|
// - a KindResource node (resource_type "mcp_server"), EdgeDefines from
|
|
// the file.
|
|
// - when the launch command is a package runner, the package it pulls:
|
|
// npx/npm/pnpm/bunx → an npm KindModule, uvx/pipx/uv → a pypi
|
|
// KindModule (both via modules.ModuleNodeID + EdgeDependsOnModule);
|
|
// docker → a KindImage node (image::<ref>:<tag>, reusing the shared
|
|
// Dockerfile/K8s ID scheme) + EdgeDependsOn.
|
|
// - for every declared env var and every "${VAR}" / "$VAR" arg
|
|
// interpolation: a KindConfigKey node with the shared
|
|
// `cfg::env::<NAME>` ID (so it shares identity with code-side
|
|
// os.Getenv("NAME") reads and infra EdgeUsesEnv declarations) +
|
|
// EdgeUsesEnv from the server.
|
|
//
|
|
// No new graph node or edge kinds are introduced — every emitted node
|
|
// reuses KindResource / KindModule / KindImage / KindConfigKey / KindFile
|
|
// and every edge reuses EdgeDefines / EdgeUsesEnv / EdgeDependsOnModule /
|
|
// EdgeDependsOn.
|
|
type MCPConfigExtractor struct{}
|
|
|
|
// NewMCPConfigExtractor constructs an MCPConfigExtractor.
|
|
func NewMCPConfigExtractor() *MCPConfigExtractor { return &MCPConfigExtractor{} }
|
|
|
|
func (e *MCPConfigExtractor) Language() string { return "mcp_config" }
|
|
|
|
// Extensions claims ONLY the specific MCP-config files by name. ".mcp.json"
|
|
// is a compound extension (matched against a file's last-two-segment
|
|
// suffix, e.g. a repo-root `.mcp.json`); "mcp.json" and
|
|
// "claude_desktop_config.json" are exact basenames (matched against the
|
|
// file's basename, e.g. `.cursor/mcp.json`, `.kiro/mcp.json`, or a
|
|
// `~/Library/.../claude_desktop_config.json`). It deliberately does NOT
|
|
// include the bare ".json" extension, so a normal `package.json` /
|
|
// `tsconfig.json` still routes to the generic JSON extractor.
|
|
func (e *MCPConfigExtractor) Extensions() []string {
|
|
return []string{".mcp.json", "mcp.json", "claude_desktop_config.json"}
|
|
}
|
|
|
|
// mcpServerSpec is the tolerant decode target for a single server entry.
|
|
// Unknown keys (disabled, autoApprove, timeout, …) are ignored by
|
|
// encoding/json. Every field is optional.
|
|
type mcpServerSpec struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
Env map[string]string `json:"env"`
|
|
URL string `json:"url"`
|
|
Type string `json:"type"`
|
|
Transport string `json:"transport"`
|
|
}
|
|
|
|
// mcpConfigFile decodes the two accepted top-level layouts. Server order
|
|
// is recovered from the raw object so emission is deterministic.
|
|
type mcpConfigFile struct {
|
|
MCPServers map[string]json.RawMessage `json:"mcpServers"`
|
|
Servers map[string]json.RawMessage `json:"servers"`
|
|
}
|
|
|
|
func (e *MCPConfigExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
result := &parser.ExtractionResult{}
|
|
|
|
lineCount := strings.Count(string(src), "\n") + 1
|
|
base := filepath.Base(filePath)
|
|
fileNode := &graph.Node{
|
|
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
var cfg mcpConfigFile
|
|
if err := json.Unmarshal(src, &cfg); err != nil {
|
|
// Malformed JSON — still record the file node so the path is
|
|
// queryable; an extractor must not fail the whole index for one
|
|
// bad config.
|
|
return result, nil
|
|
}
|
|
|
|
servers := cfg.MCPServers
|
|
if len(servers) == 0 {
|
|
servers = cfg.Servers
|
|
}
|
|
|
|
// Sort server names for deterministic emission order.
|
|
names := make([]string, 0, len(servers))
|
|
for name := range servers {
|
|
names = append(names, name)
|
|
}
|
|
sortStrings(names)
|
|
|
|
// Dedup module / image / env nodes within this file — graph.AddNode
|
|
// is idempotent on ID anyway, but we avoid emitting duplicates.
|
|
seenModule := make(map[string]bool)
|
|
seenImage := make(map[string]bool)
|
|
seenEnv := make(map[string]bool)
|
|
|
|
for _, name := range names {
|
|
var spec mcpServerSpec
|
|
if err := json.Unmarshal(servers[name], &spec); err != nil {
|
|
continue
|
|
}
|
|
|
|
serverID := "mcp::server::" + filePath + "::" + name
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: serverID, Kind: graph.KindResource, Name: name,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
Meta: map[string]any{
|
|
"resource_type": "mcp_server",
|
|
"command": spec.Command,
|
|
"transport": mcpTransport(spec.Type, spec.Transport, spec.URL),
|
|
"source": base,
|
|
},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileNode.ID, To: serverID, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: 1,
|
|
})
|
|
|
|
// Launch command implies a package / image.
|
|
switch strings.ToLower(filepath.Base(spec.Command)) {
|
|
case "npx", "npm", "pnpm", "bunx":
|
|
if pkg := firstNonFlagArg(spec.Args); pkg != "" {
|
|
pkgName, version := splitNpmSpec(pkg)
|
|
modID := modules.ModuleNodeID("npm", pkgName, version)
|
|
if !seenModule[modID] {
|
|
seenModule[modID] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: modID, Kind: graph.KindModule, Name: pkgName,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
Meta: map[string]any{
|
|
"ecosystem": "npm",
|
|
"path": pkgName,
|
|
"version": version,
|
|
},
|
|
})
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: serverID, To: modID, Kind: graph.EdgeDependsOnModule,
|
|
FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
case "uvx", "pipx", "uv":
|
|
if pkg := firstNonFlagArg(spec.Args); pkg != "" {
|
|
pkgName, version := splitPypiSpec(pkg)
|
|
modID := modules.ModuleNodeID("pypi", pkgName, version)
|
|
if !seenModule[modID] {
|
|
seenModule[modID] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: modID, Kind: graph.KindModule, Name: pkgName,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
Meta: map[string]any{
|
|
"ecosystem": "pypi",
|
|
"path": pkgName,
|
|
"version": version,
|
|
},
|
|
})
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: serverID, To: modID, Kind: graph.EdgeDependsOnModule,
|
|
FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
case "docker":
|
|
if img := dockerImageArg(spec.Args); img != "" {
|
|
imgID := imageNodeID(img)
|
|
ref, tag := splitImageRef(img)
|
|
if !seenImage[imgID] {
|
|
seenImage[imgID] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: imgID, Kind: graph.KindImage, Name: img,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
Meta: map[string]any{
|
|
"role": "base",
|
|
"ref": ref,
|
|
"tag": tag,
|
|
},
|
|
})
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: serverID, To: imgID, Kind: graph.EdgeDependsOn,
|
|
FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Declared env vars.
|
|
envNames := make([]string, 0, len(spec.Env))
|
|
for k := range spec.Env {
|
|
envNames = append(envNames, k)
|
|
}
|
|
sortStrings(envNames)
|
|
for _, key := range envNames {
|
|
emitEnv(result, filePath, lineCount, serverID, key, seenEnv)
|
|
}
|
|
|
|
// Env interpolations referenced from args (`${VAR}` / `$VAR`).
|
|
for _, arg := range spec.Args {
|
|
for _, key := range scanEnvInterpolations(arg) {
|
|
emitEnv(result, filePath, lineCount, serverID, key, seenEnv)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// emitEnv emits (once per file) a KindConfigKey node with the shared
|
|
// `cfg::env::<NAME>` ID and (once per server+key) an EdgeUsesEnv from the
|
|
// server.
|
|
func emitEnv(result *parser.ExtractionResult, filePath string, lineCount int, serverID, key string, seen map[string]bool) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
keyID := configKeyEnvID(key)
|
|
if !seen[keyID] {
|
|
seen[keyID] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: keyID, Kind: graph.KindConfigKey, Name: key,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount,
|
|
Language: "mcp_config",
|
|
Meta: map[string]any{
|
|
"source": "env",
|
|
},
|
|
})
|
|
}
|
|
edgeKey := serverID + "\x00" + keyID
|
|
if seen[edgeKey] {
|
|
return
|
|
}
|
|
seen[edgeKey] = true
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: serverID, To: keyID, Kind: graph.EdgeUsesEnv,
|
|
FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
|
|
// mcpTransport derives the transport label from the declared type /
|
|
// transport keyword or the presence of a URL. Defaults to "stdio" — the
|
|
// command-launched local-process transport that the canonical
|
|
// mcpServers shape implies.
|
|
func mcpTransport(typ, transport, url string) string {
|
|
for _, candidate := range []string{transport, typ} {
|
|
switch strings.ToLower(strings.TrimSpace(candidate)) {
|
|
case "stdio":
|
|
return "stdio"
|
|
case "http", "streamable-http", "streamable_http", "streamablehttp":
|
|
return "http"
|
|
case "sse":
|
|
return "sse"
|
|
}
|
|
}
|
|
if strings.TrimSpace(url) != "" {
|
|
return "http"
|
|
}
|
|
return "stdio"
|
|
}
|
|
|
|
// firstNonFlagArg returns the first argument that is not a flag (does not
|
|
// start with "-"). Package runners take leading flags like `-y`, `--yes`,
|
|
// `--from`, so the package spec is the first bare token.
|
|
func firstNonFlagArg(args []string) string {
|
|
for _, a := range args {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" || strings.HasPrefix(a, "-") {
|
|
continue
|
|
}
|
|
return a
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dockerImageArg returns the image reference from a `docker run` arg list:
|
|
// the first non-flag token that is also not the `run` subcommand. Flags
|
|
// that take a separate value token (e.g. `-e KEY=val`, `--env KEY`) would
|
|
// otherwise leak their value as the image, so value-taking flags consume
|
|
// the next token.
|
|
func dockerImageArg(args []string) string {
|
|
skipNext := false
|
|
for _, a := range args {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if skipNext {
|
|
skipNext = false
|
|
continue
|
|
}
|
|
if strings.HasPrefix(a, "-") {
|
|
// Long/short flags that take a separate value token. Inline
|
|
// forms (`--env=X`, `-eX`) carry their value already.
|
|
if !strings.Contains(a, "=") && dockerFlagTakesValue(a) {
|
|
skipNext = true
|
|
}
|
|
continue
|
|
}
|
|
if a == "run" || a == "container" {
|
|
continue
|
|
}
|
|
return a
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dockerFlagTakesValue reports whether a docker-run flag expects a
|
|
// following value token. Conservative: the common value-taking flags
|
|
// used in MCP server configs.
|
|
func dockerFlagTakesValue(flag string) bool {
|
|
switch flag {
|
|
case "-e", "--env", "-v", "--volume", "-p", "--publish",
|
|
"--name", "-w", "--workdir", "--network", "--mount",
|
|
"--env-file", "-u", "--user", "--entrypoint", "--label", "-l":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// splitNpmSpec splits an npm package spec into name and version, scope-
|
|
// aware: `@scope/name@1.2.3` → ("@scope/name", "1.2.3"); `lodash@^4` →
|
|
// ("lodash", "^4"); `pkg` → ("pkg", "").
|
|
func splitNpmSpec(spec string) (name, version string) {
|
|
spec = strings.TrimSpace(spec)
|
|
var atIdx int
|
|
if strings.HasPrefix(spec, "@") {
|
|
// Scoped: skip the leading scope @, find the version @.
|
|
atIdx = strings.Index(spec[1:], "@")
|
|
if atIdx >= 0 {
|
|
atIdx++
|
|
}
|
|
} else {
|
|
atIdx = strings.Index(spec, "@")
|
|
}
|
|
if atIdx <= 0 {
|
|
return spec, ""
|
|
}
|
|
return spec[:atIdx], spec[atIdx+1:]
|
|
}
|
|
|
|
// splitPypiSpec splits a PyPI package spec into name and version. PyPI
|
|
// specs are never scoped, and the version separator is `==` (`pkg==1.2`),
|
|
// `@` (PEP 508 direct refs), or a comparator (`pkg>=1.0`). We keep it
|
|
// simple: split on the first version-introducing delimiter.
|
|
func splitPypiSpec(spec string) (name, version string) {
|
|
spec = strings.TrimSpace(spec)
|
|
if i := strings.Index(spec, "=="); i >= 0 {
|
|
return spec[:i], spec[i+2:]
|
|
}
|
|
if i := strings.IndexAny(spec, "@<>=~!"); i > 0 {
|
|
return spec[:i], strings.TrimLeft(spec[i:], "@<>=~!")
|
|
}
|
|
return spec, ""
|
|
}
|
|
|
|
// scanEnvInterpolations extracts env-var names referenced in a string via
|
|
// `${VAR}` or `$VAR` interpolation. Returns names in order of appearance.
|
|
func scanEnvInterpolations(s string) []string {
|
|
var out []string
|
|
i := 0
|
|
n := len(s)
|
|
for i < n {
|
|
if s[i] != '$' {
|
|
i++
|
|
continue
|
|
}
|
|
i++ // past '$'
|
|
if i >= n {
|
|
break
|
|
}
|
|
if s[i] == '{' {
|
|
i++ // past '{'
|
|
start := i
|
|
for i < n && s[i] != '}' {
|
|
i++
|
|
}
|
|
name := s[start:i]
|
|
if i < n {
|
|
i++ // past '}'
|
|
}
|
|
// Strip default-value / modifier suffixes (`${VAR:-x}`).
|
|
if c := strings.IndexAny(name, ":-/"); c >= 0 {
|
|
name = name[:c]
|
|
}
|
|
if isEnvName(name) {
|
|
out = append(out, name)
|
|
}
|
|
continue
|
|
}
|
|
// `$VAR` — consume a run of identifier chars.
|
|
start := i
|
|
for i < n && isEnvNameChar(s[i]) {
|
|
i++
|
|
}
|
|
name := s[start:i]
|
|
if isEnvName(name) {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isEnvName(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if !isEnvNameChar(s[i]) {
|
|
return false
|
|
}
|
|
}
|
|
// Leading digit is not a valid env-var name.
|
|
return s[0] < '0' || s[0] > '9'
|
|
}
|
|
|
|
func isEnvNameChar(c byte) bool {
|
|
return c == '_' ||
|
|
(c >= 'A' && c <= 'Z') ||
|
|
(c >= 'a' && c <= 'z') ||
|
|
(c >= '0' && c <= '9')
|
|
}
|
|
|
|
// sortStrings sorts a string slice in place (ascending) via insertion
|
|
// sort — kept local so the file doesn't pull `sort` for one call over
|
|
// the tiny server / env-name slices.
|
|
func sortStrings(s []string) {
|
|
for i := 1; i < len(s); i++ {
|
|
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
|
s[j-1], s[j] = s[j], s[j-1]
|
|
}
|
|
}
|
|
}
|
|
|
|
var _ parser.Extractor = (*MCPConfigExtractor)(nil)
|