"""mcp_ingest.py — Extract MCP (Model Context Protocol) server configuration files. Reads `.mcp.json` / `claude_desktop_config.json` / `mcp.json` / `mcp_servers.json` and turns the `mcpServers` map into Graphify nodes and edges. Symmetry with `serve.py`: Graphify exposes itself AS an MCP server. This module indexes MCP servers AS a corpus type, completing the loop — an agent that runs graphify with `--mcp` can now query its own configured MCP layer. Entry point: extract_mcp_config(path: Path) -> dict[str, list[dict]] Returns `{"nodes": [...], "edges": [...]}` compatible with Graphify's extraction-result format. Returns `{"nodes": [...], "edges": [...], "error": "..."}` when the file is malformed, too large, or has no `mcpServers` map — the empty result keeps it indistinguishable from "no MCP config here" for downstream callers. Detected filenames (case-sensitive, matched on basename): - .mcp.json (Claude Code project config) - claude_desktop_config.json (Claude Desktop) - mcp.json (generic / per-tool) - mcp_servers.json (alternate naming) Schema emitted: Node kinds: - file the config file itself (label = filename) - mcp_server one per entry under mcpServers - mcp_command executable (npx, uvx, node, python, ...) — global ID - mcp_package npm / pypi package id parsed from args — global ID - env_var env variable NAME only — global ID. VALUES ARE NEVER READ. Edge relations: - contains file -> mcp_server - references mcp_server -> mcp_command - references mcp_server -> mcp_package - requires_env mcp_server -> env_var (new relation; distinguishes env dependencies from generic refs) Security: - Env var VALUES are never read, persisted, labelled, or surfaced. Only env var NAMES become nodes. (`env: {"API_KEY": "sk-..."}` -> node "API_KEY" only.) - File size capped at 1 MiB (matches extract_json). - All labels go through `sanitize_label` (control characters stripped, length capped) before emission. - Args are NOT persisted as nodes/edges to avoid leaking paths or secrets that some servers embed as positional args. Cross-config emergent edges: Because `mcp_command`, `mcp_package`, and `env_var` nodes use global IDs (no per-file stem prefix), the same package or env var across two MCP configs produces shared nodes — naturally surfacing "what configs depend on this thing?" via graph traversal. Server nodes ARE stem-scoped so two configs declaring different servers under the same key (e.g., both have "filesystem") do not collide. """ from __future__ import annotations import json import re import unicodedata from pathlib import Path from typing import Any from graphify.ids import make_id as _shared_make_id from graphify.security import sanitize_label MCP_CONFIG_FILENAMES: frozenset[str] = frozenset({ ".mcp.json", "claude_desktop_config.json", "mcp.json", "mcp_servers.json", }) _MAX_BYTES = 1_048_576 # 1 MiB — same cap as extract_json _MAX_SERVERS_PER_FILE = 200 # generous; flags pathological configs def is_mcp_config_path(path: Path) -> bool: """Return True when ``path`` is a recognised MCP config filename.""" return path.name in MCP_CONFIG_FILENAMES def extract_mcp_config(path: Path) -> dict[str, Any]: """Parse an MCP config file into Graphify nodes and edges. Behaviour matches other extractors in `extract.py`: - returns ``{"nodes": [...], "edges": [...]}`` on success - returns ``{"nodes": [], "edges": [], "error": ""}`` on parse failure, oversize file, or missing ``mcpServers`` map """ try: with path.open("rb") as fh: raw = fh.read(_MAX_BYTES + 1) except OSError as exc: return {"nodes": [], "edges": [], "error": f"mcp_ingest read error: {exc}"} if len(raw) > _MAX_BYTES: return {"nodes": [], "edges": [], "error": "mcp config too large to index"} try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: return {"nodes": [], "edges": [], "error": f"mcp_ingest decode error: {exc}"} try: doc = json.loads(text) except json.JSONDecodeError as exc: return {"nodes": [], "edges": [], "error": f"mcp_ingest json error: {exc}"} if not isinstance(doc, dict): return {"nodes": [], "edges": [], "error": "mcp_ingest: root is not an object"} servers = doc.get("mcpServers") if not isinstance(servers, dict): # Some tools nest the map (e.g., {"mcp": {"servers": {...}}}). Try one # well-known alternate shape but do not search exhaustively. nested = doc.get("mcp") if isinstance(nested, dict): servers = nested.get("servers") if not isinstance(servers, dict): return {"nodes": [], "edges": [], "error": "mcp_ingest: no mcpServers map"} str_path = str(path) file_nid = _make_id(str_path) nodes: list[dict[str, Any]] = [] edges: list[dict[str, Any]] = [] seen_node_ids: set[str] = set() seen_edge_keys: set[tuple[str, str, str]] = set() _add_node( nodes, seen_node_ids, nid=file_nid, label=path.name, kind="mcp_config_file", source_file=str_path, line=1, ) file_stem = _file_stem(path) server_count = 0 for server_name, spec in servers.items(): if not isinstance(server_name, str) or not server_name: continue if not isinstance(spec, dict): # Skip non-object server entries silently — the broken entry is # the user's, not ours. continue if server_count >= _MAX_SERVERS_PER_FILE: break server_count += 1 _emit_server( server_name=server_name, spec=spec, file_nid=file_nid, file_stem=file_stem, source_file=str_path, nodes=nodes, edges=edges, seen_node_ids=seen_node_ids, seen_edge_keys=seen_edge_keys, ) return {"nodes": nodes, "edges": edges} def _emit_server( *, server_name: str, spec: dict[str, Any], file_nid: str, file_stem: str, source_file: str, nodes: list[dict[str, Any]], edges: list[dict[str, Any]], seen_node_ids: set[str], seen_edge_keys: set[tuple[str, str, str]], ) -> None: """Emit nodes/edges for one entry under ``mcpServers``.""" server_nid = _make_id(file_stem, "mcp_server", server_name) _add_node( nodes, seen_node_ids, nid=server_nid, label=server_name, kind="mcp_server", source_file=source_file, line=1, # JSON doesn't expose line numbers without a parser pass ) _add_edge( edges, seen_edge_keys, source=file_nid, target=server_nid, relation="contains", source_file=source_file, line=1, ) command = spec.get("command") if isinstance(command, str) and command.strip(): cmd_label = command.strip() cmd_nid = _make_id("mcp_command", cmd_label) _add_node( nodes, seen_node_ids, nid=cmd_nid, label=cmd_label, kind="mcp_command", source_file=source_file, line=1, ) _add_edge( edges, seen_edge_keys, source=server_nid, target=cmd_nid, relation="references", source_file=source_file, line=1, context="command", ) args = spec.get("args") if isinstance(args, list): package = _detect_package_from_args(args) if package: pkg_nid = _make_id("mcp_package", package) _add_node( nodes, seen_node_ids, nid=pkg_nid, label=package, kind="mcp_package", source_file=source_file, line=1, ) _add_edge( edges, seen_edge_keys, source=server_nid, target=pkg_nid, relation="references", source_file=source_file, line=1, context="package", ) env = spec.get("env") if isinstance(env, dict): # ONLY KEYS. Values may contain secrets and are never read here. for env_name in env.keys(): if not isinstance(env_name, str) or not env_name: continue env_nid = _make_id("env_var", env_name) _add_node( nodes, seen_node_ids, nid=env_nid, label=env_name, kind="env_var", source_file=source_file, line=1, ) _add_edge( edges, seen_edge_keys, source=server_nid, target=env_nid, relation="requires_env", source_file=source_file, line=1, ) # ── Package detection from args ─────────────────────────────────────────────── # Patterns observed in real MCP server configs: # ["-y", "@modelcontextprotocol/server-filesystem", "/data"] (npx) # ["-y", "@org/pkg@1.2.3"] # ["mcp-server-fetch"] (uvx / python) # ["mcp-server-time", "--local-timezone=UTC"] # ["@scoped/some-mcp"] (pnpx) # ["mcp-server-fetch"] (uvx direct) _NPM_PKG_RE = re.compile(r"^@[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*(?:@[\w.\-+]+)?$") _PY_MCP_PKG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*-mcp(?:-[a-z0-9._-]+)?$|^mcp-[a-z0-9][a-z0-9._-]*$") _ARG_FLAG_RE = re.compile(r"^-{1,2}\w") def _detect_package_from_args(args: list[Any]) -> str | None: """Return the first arg that looks like an npm or pypi package id, else None. Skips short flags (-y, --yes) and option arguments (--local-timezone=UTC). """ for raw in args: if not isinstance(raw, str): continue arg = raw.strip() if not arg or _ARG_FLAG_RE.match(arg): continue if _NPM_PKG_RE.match(arg): return _strip_version(arg) if _PY_MCP_PKG_RE.match(arg): return arg return None def _strip_version(pkg: str) -> str: """Drop the ``@version`` suffix from an npm package id, preserving the scope. Scoped: ``@scope/name`` or ``@scope/name@1.2.3`` — there are at most two ``@`` chars; the second is the version separator. Unscoped: ``name`` or ``name@1.2.3``. """ if pkg.startswith("@"): version_at = pkg.find("@", 1) return pkg if version_at == -1 else pkg[:version_at] version_at = pkg.find("@") return pkg if version_at == -1 else pkg[:version_at] # ── Node / edge construction (Graphify schema) ──────────────────────────────── def _add_node( nodes: list[dict[str, Any]], seen: set[str], *, nid: str, label: str, kind: str, source_file: str, line: int, ) -> None: """Append a node if not already present. ``kind`` is metadata, not file_type.""" if not nid or nid in seen: return seen.add(nid) nodes.append({ "id": nid, "label": sanitize_label(label), "file_type": "code", "source_file": source_file, "source_location": f"L{line}", "metadata": {"mcp_kind": kind}, }) def _add_edge( edges: list[dict[str, Any]], seen: set[tuple[str, str, str]], *, source: str, target: str, relation: str, source_file: str, line: int, context: str | None = None, ) -> None: """Append an edge if (source, target, relation) is not already present.""" if not source or not target or source == target: return key = (source, target, relation) if key in seen: return seen.add(key) edge: dict[str, Any] = { "source": source, "target": target, "relation": relation, "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": source_file, "source_location": f"L{line}", "weight": 1.0, } if context: edge["context"] = context edges.append(edge) # ── ID helpers (kept local; mirror extract.py shape) ────────────────────────── def _make_id(*parts: str) -> str: """Build a stable node ID via the single shared recipe (#1378).""" return _shared_make_id(*parts) # Canonical recipe imported directly (no import cycle: extractors.base imports # only graphify.ids), so this can no longer drift from extract._file_stem. from graphify.extractors.base import _file_stem # noqa: E402