diff --git a/graphify/extract.py b/graphify/extract.py index 87dde9a..a705c65 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/graphify/mcp_ingest.py b/graphify/mcp_ingest.py new file mode 100644 index 0000000..1879dcc --- /dev/null +++ b/graphify/mcp_ingest.py @@ -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": ""}`` 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 diff --git a/tests/fixtures/sample.mcp.json b/tests/fixtures/sample.mcp.json new file mode 100644 index 0000000..ba690f1 --- /dev/null +++ b/tests/fixtures/sample.mcp.json @@ -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"] + } + } +} diff --git a/tests/test_mcp_ingest.py b/tests/test_mcp_ingest.py new file mode 100644 index 0000000..8e46b17 --- /dev/null +++ b/tests/test_mcp_ingest.py @@ -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