feat: MCP config extractor (.mcp.json, claude_desktop_config.json, mcp.json)
Adds graphify/mcp_ingest.py — extracts MCP server configurations into the knowledge graph. Captures server nodes, NuGet/npm/pip package refs, commands, env var requirements, and inter-server edges. Dispatched by filename before the suffix lookup so generic .json extraction is unaffected. Env values are discarded to prevent secret leakage. File size capped at 1 MiB. 29 tests. Fixes: server_count budget now checked after validity guard so invalid entries don't consume capacity; removed misleading uv run docstring example. Co-Authored-By: adityachaudhary99 <adityachaudhary99@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
adityachaudhary99
Claude Sonnet 4.6
parent
9abaa77c62
commit
2c01a89b28
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Any
|
||||
from .cache import load_cached, save_cached
|
||||
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
|
||||
|
||||
_RECURSION_LIMIT = 10_000
|
||||
|
||||
@@ -8234,6 +8235,11 @@ def _get_extractor(path: Path) -> Any | None:
|
||||
"""Return the correct extractor function for a file, or None if unsupported."""
|
||||
if path.name.endswith(".blade.php"):
|
||||
return extract_blade
|
||||
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
|
||||
# by filename before generic .json dispatch so they get MCP-aware nodes
|
||||
# (servers, commands, packages, env vars) instead of opaque JSON keys.
|
||||
if is_mcp_config_path(path):
|
||||
return extract_mcp_config
|
||||
return _DISPATCH.get(path.suffix)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""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.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": "<reason>"}`` 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. Must match extract._make_id's normalisation rules."""
|
||||
combined = "_".join(p.strip("_.") for p in parts if p)
|
||||
combined = unicodedata.normalize("NFKC", combined)
|
||||
cleaned = re.sub(r"[^\w]+", "_", combined, flags=re.UNICODE)
|
||||
cleaned = re.sub(r"_+", "_", cleaned)
|
||||
return cleaned.strip("_").casefold()
|
||||
|
||||
|
||||
def _file_stem(path: Path) -> str:
|
||||
"""Mirror extract._file_stem: include parent dir name to disambiguate."""
|
||||
parent = path.parent.name
|
||||
if parent and parent not in (".", ""):
|
||||
return f"{parent}.{path.stem}"
|
||||
return path.stem
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/workspace"],
|
||||
"env": {
|
||||
"FILESYSTEM_ROOT": "/tmp/workspace"
|
||||
}
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github@0.6.2"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_PLACEHOLDER_NOT_A_REAL_TOKEN"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-time", "--local-timezone=UTC"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for graphify.mcp_ingest — MCP config file extraction."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from graphify.mcp_ingest import (
|
||||
MCP_CONFIG_FILENAMES,
|
||||
extract_mcp_config,
|
||||
is_mcp_config_path,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _labels(result):
|
||||
return [n["label"] for n in result["nodes"]]
|
||||
|
||||
|
||||
def _node_kinds(result):
|
||||
return {n["metadata"]["mcp_kind"] for n in result["nodes"] if "metadata" in n}
|
||||
|
||||
|
||||
def _relations(result):
|
||||
return {e["relation"] for e in result["edges"]}
|
||||
|
||||
|
||||
def _label_by_kind(result, kind):
|
||||
return [
|
||||
n["label"]
|
||||
for n in result["nodes"]
|
||||
if n.get("metadata", {}).get("mcp_kind") == kind
|
||||
]
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, payload) -> Path:
|
||||
p = tmp_path / name
|
||||
if isinstance(payload, (dict, list)):
|
||||
p.write_text(json.dumps(payload), encoding="utf-8")
|
||||
else:
|
||||
p.write_text(str(payload), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ── Detection ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_mcp_config_path_recognises_known_filenames():
|
||||
for name in (".mcp.json", "claude_desktop_config.json", "mcp.json", "mcp_servers.json"):
|
||||
assert is_mcp_config_path(Path(f"/some/dir/{name}")), name
|
||||
|
||||
|
||||
def test_is_mcp_config_path_rejects_generic_json():
|
||||
assert not is_mcp_config_path(Path("package.json"))
|
||||
assert not is_mcp_config_path(Path("config.json"))
|
||||
assert not is_mcp_config_path(Path("tsconfig.json"))
|
||||
|
||||
|
||||
def test_recognised_filenames_set_is_frozen():
|
||||
# Public contract: the filename set is exposed and stable.
|
||||
assert isinstance(MCP_CONFIG_FILENAMES, frozenset)
|
||||
assert ".mcp.json" in MCP_CONFIG_FILENAMES
|
||||
|
||||
|
||||
# ── Happy path with the bundled fixture ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_fixture_parses_without_error():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
assert "error" not in r, r.get("error")
|
||||
assert r["nodes"]
|
||||
assert r["edges"]
|
||||
|
||||
|
||||
def test_fixture_emits_every_server():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
server_labels = set(_label_by_kind(r, "mcp_server"))
|
||||
assert server_labels == {"filesystem", "fetch", "github", "time"}
|
||||
|
||||
|
||||
def test_fixture_emits_commands_as_global_nodes():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
commands = set(_label_by_kind(r, "mcp_command"))
|
||||
assert commands == {"npx", "uvx"}
|
||||
|
||||
|
||||
def test_fixture_emits_npm_packages():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
packages = set(_label_by_kind(r, "mcp_package"))
|
||||
assert "@modelcontextprotocol/server-filesystem" in packages
|
||||
assert "@modelcontextprotocol/server-github" in packages
|
||||
|
||||
|
||||
def test_fixture_emits_python_packages():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
packages = set(_label_by_kind(r, "mcp_package"))
|
||||
assert "mcp-server-fetch" in packages
|
||||
assert "mcp-server-time" in packages
|
||||
|
||||
|
||||
def test_fixture_strips_version_from_npm_package():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
packages = set(_label_by_kind(r, "mcp_package"))
|
||||
# Source has "@modelcontextprotocol/server-github@0.6.2"
|
||||
assert "@modelcontextprotocol/server-github" in packages
|
||||
assert "@modelcontextprotocol/server-github@0.6.2" not in packages
|
||||
|
||||
|
||||
def test_fixture_emits_env_var_names():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
env_vars = set(_label_by_kind(r, "env_var"))
|
||||
assert "FILESYSTEM_ROOT" in env_vars
|
||||
assert "GITHUB_PERSONAL_ACCESS_TOKEN" in env_vars
|
||||
|
||||
|
||||
def test_env_var_values_never_appear_anywhere():
|
||||
# The fixture has GITHUB_PERSONAL_ACCESS_TOKEN = "ghp_PLACEHOLDER_NOT_A_REAL_TOKEN".
|
||||
# That string must not appear in any node label, edge label, or metadata value.
|
||||
secret = "ghp_PLACEHOLDER_NOT_A_REAL_TOKEN"
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
for n in r["nodes"]:
|
||||
assert secret not in n["label"]
|
||||
for v in n.get("metadata", {}).values():
|
||||
assert secret not in str(v)
|
||||
for e in r["edges"]:
|
||||
for v in e.values():
|
||||
assert secret not in str(v)
|
||||
|
||||
|
||||
def test_filesystem_path_not_persisted_as_node():
|
||||
# `args` contains "/tmp/workspace" — args are intentionally NOT persisted
|
||||
# as nodes/edges to avoid leaking local filesystem paths.
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
for n in r["nodes"]:
|
||||
assert "/tmp/workspace" not in n["label"]
|
||||
|
||||
|
||||
def test_fixture_relations_include_contains_references_requires_env():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
rels = _relations(r)
|
||||
assert "contains" in rels
|
||||
assert "references" in rels
|
||||
assert "requires_env" in rels
|
||||
|
||||
|
||||
def test_no_dangling_edges():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
node_ids = {n["id"] for n in r["nodes"]}
|
||||
for e in r["edges"]:
|
||||
assert e["source"] in node_ids
|
||||
assert e["target"] in node_ids
|
||||
|
||||
|
||||
def test_every_edge_has_confidence_score():
|
||||
r = extract_mcp_config(FIXTURES / "sample.mcp.json")
|
||||
for e in r["edges"]:
|
||||
assert e["confidence"] == "EXTRACTED"
|
||||
assert e["confidence_score"] == 1.0
|
||||
assert e["weight"] == 1.0
|
||||
|
||||
|
||||
# ── Cross-config emergent edges (global node IDs) ────────────────────────────
|
||||
|
||||
|
||||
def test_same_command_collapses_to_one_node_across_configs(tmp_path):
|
||||
# Two configs both use "npx". The mcp_command node should be shared.
|
||||
config_a = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {"a": {"command": "npx", "args": ["@scope/server-a"]}},
|
||||
})
|
||||
(tmp_path / "subdir").mkdir()
|
||||
config_b = _write(tmp_path / "subdir", "claude_desktop_config.json", {
|
||||
"mcpServers": {"b": {"command": "npx", "args": ["@scope/server-b"]}},
|
||||
})
|
||||
r_a = extract_mcp_config(config_a)
|
||||
r_b = extract_mcp_config(config_b)
|
||||
cmd_id_a = next(n["id"] for n in r_a["nodes"] if n["metadata"]["mcp_kind"] == "mcp_command")
|
||||
cmd_id_b = next(n["id"] for n in r_b["nodes"] if n["metadata"]["mcp_kind"] == "mcp_command")
|
||||
assert cmd_id_a == cmd_id_b
|
||||
|
||||
|
||||
def test_same_env_var_collapses_to_one_node_across_configs(tmp_path):
|
||||
# Two configs both require OPENAI_API_KEY. The env_var node ID must be identical.
|
||||
a = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {
|
||||
"x": {"command": "npx", "args": ["@scope/x"], "env": {"OPENAI_API_KEY": "v1"}},
|
||||
},
|
||||
})
|
||||
(tmp_path / "sub").mkdir()
|
||||
b = _write(tmp_path / "sub", "claude_desktop_config.json", {
|
||||
"mcpServers": {
|
||||
"y": {"command": "uvx", "args": ["mcp-server-y"], "env": {"OPENAI_API_KEY": "v2"}},
|
||||
},
|
||||
})
|
||||
r_a = extract_mcp_config(a)
|
||||
r_b = extract_mcp_config(b)
|
||||
env_id_a = next(n["id"] for n in r_a["nodes"] if n["metadata"]["mcp_kind"] == "env_var")
|
||||
env_id_b = next(n["id"] for n in r_b["nodes"] if n["metadata"]["mcp_kind"] == "env_var")
|
||||
assert env_id_a == env_id_b
|
||||
|
||||
|
||||
def test_same_server_name_in_different_dirs_does_not_collide(tmp_path):
|
||||
# Two .mcp.json files in different dirs both declare a "filesystem" server.
|
||||
# The server nodes should NOT collide (stem-scoped via parent dir).
|
||||
(tmp_path / "proj_a").mkdir()
|
||||
(tmp_path / "proj_b").mkdir()
|
||||
a = _write(tmp_path / "proj_a", ".mcp.json", {
|
||||
"mcpServers": {"filesystem": {"command": "npx", "args": ["@scope/a"]}},
|
||||
})
|
||||
b = _write(tmp_path / "proj_b", ".mcp.json", {
|
||||
"mcpServers": {"filesystem": {"command": "npx", "args": ["@scope/b"]}},
|
||||
})
|
||||
r_a = extract_mcp_config(a)
|
||||
r_b = extract_mcp_config(b)
|
||||
srv_a = next(n["id"] for n in r_a["nodes"] if n["metadata"]["mcp_kind"] == "mcp_server")
|
||||
srv_b = next(n["id"] for n in r_b["nodes"] if n["metadata"]["mcp_kind"] == "mcp_server")
|
||||
assert srv_a != srv_b
|
||||
|
||||
|
||||
# ── Error handling ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_missing_mcp_servers_key(tmp_path):
|
||||
p = _write(tmp_path, ".mcp.json", {"unrelated": "shape"})
|
||||
r = extract_mcp_config(p)
|
||||
assert r["nodes"] == []
|
||||
assert r["edges"] == []
|
||||
assert "no mcpServers map" in r.get("error", "")
|
||||
|
||||
|
||||
def test_nested_mcp_servers_shape(tmp_path):
|
||||
# Some tools wrap the map: {"mcp": {"servers": {...}}}
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcp": {"servers": {"x": {"command": "node", "args": ["dist/index.js"]}}},
|
||||
})
|
||||
r = extract_mcp_config(p)
|
||||
assert "error" not in r
|
||||
assert "x" in _label_by_kind(r, "mcp_server")
|
||||
assert "node" in _label_by_kind(r, "mcp_command")
|
||||
|
||||
|
||||
def test_malformed_json_returns_error(tmp_path):
|
||||
p = tmp_path / ".mcp.json"
|
||||
p.write_text("{not valid json", encoding="utf-8")
|
||||
r = extract_mcp_config(p)
|
||||
assert r["nodes"] == []
|
||||
assert r["edges"] == []
|
||||
assert "json error" in r.get("error", "")
|
||||
|
||||
|
||||
def test_oversize_file_skipped(tmp_path):
|
||||
p = tmp_path / ".mcp.json"
|
||||
payload = '{"mcpServers":{"x":{"command":"npx","args":["' + ("a" * 2_000_000) + '"]}}}'
|
||||
p.write_text(payload, encoding="utf-8")
|
||||
r = extract_mcp_config(p)
|
||||
assert "too large" in r.get("error", "")
|
||||
|
||||
|
||||
def test_root_not_an_object(tmp_path):
|
||||
p = tmp_path / ".mcp.json"
|
||||
p.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
r = extract_mcp_config(p)
|
||||
assert "root is not an object" in r.get("error", "")
|
||||
|
||||
|
||||
def test_non_dict_server_entry_skipped(tmp_path):
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {
|
||||
"valid": {"command": "npx", "args": ["@scope/pkg"]},
|
||||
"broken": ["this", "is", "not", "an", "object"],
|
||||
},
|
||||
})
|
||||
r = extract_mcp_config(p)
|
||||
server_labels = _label_by_kind(r, "mcp_server")
|
||||
assert "valid" in server_labels
|
||||
assert "broken" not in server_labels
|
||||
|
||||
|
||||
# ── Edge case: package detection ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_package_detection_skips_flags(tmp_path):
|
||||
# First arg is -y (flag); second is the package. Detection should skip the flag.
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {"x": {"command": "npx", "args": ["-y", "@scope/server-x"]}},
|
||||
})
|
||||
r = extract_mcp_config(p)
|
||||
assert "@scope/server-x" in _label_by_kind(r, "mcp_package")
|
||||
|
||||
|
||||
def test_no_package_detected_for_unknown_arg_shape(tmp_path):
|
||||
# Args don't look like any known package pattern => no package node.
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {"x": {"command": "node", "args": ["./local-script.js", "--verbose"]}},
|
||||
})
|
||||
r = extract_mcp_config(p)
|
||||
assert _label_by_kind(r, "mcp_package") == []
|
||||
|
||||
|
||||
def test_server_without_command_still_emits_server_node(tmp_path):
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {"x": {"args": ["@scope/server-x"]}},
|
||||
})
|
||||
r = extract_mcp_config(p)
|
||||
assert "x" in _label_by_kind(r, "mcp_server")
|
||||
assert _label_by_kind(r, "mcp_command") == []
|
||||
|
||||
|
||||
# ── Integration: dispatch routes filename-matched files to mcp_ingest ────────
|
||||
|
||||
|
||||
def test_dispatch_routes_mcp_filename_to_mcp_extractor(tmp_path):
|
||||
# End-to-end: a .mcp.json file goes through _get_extractor and ends up at
|
||||
# extract_mcp_config, NOT extract_json.
|
||||
from graphify.extract import _get_extractor
|
||||
|
||||
p = _write(tmp_path, ".mcp.json", {
|
||||
"mcpServers": {"x": {"command": "npx", "args": ["@scope/server-x"]}},
|
||||
})
|
||||
extractor = _get_extractor(p)
|
||||
assert extractor is extract_mcp_config
|
||||
|
||||
|
||||
def test_dispatch_does_not_reroute_generic_json(tmp_path):
|
||||
from graphify.extract import _get_extractor, extract_json
|
||||
|
||||
p = _write(tmp_path, "package.json", {"name": "x", "version": "1.0.0"})
|
||||
extractor = _get_extractor(p)
|
||||
assert extractor is extract_json
|
||||
Reference in New Issue
Block a user