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:
Safi
2026-05-26 20:16:41 +01:00
co-authored by adityachaudhary99 Claude Sonnet 4.6
parent 9abaa77c62
commit 2c01a89b28
4 changed files with 755 additions and 0 deletions
+6
View File
@@ -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)
+392
View File
@@ -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
+26
View File
@@ -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"]
}
}
}
+331
View File
@@ -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