From f8e13a92c49574d796f665cde0093d377a574a83 Mon Sep 17 00:00:00 2001 From: Ming-Jer Lee Date: Thu, 27 Aug 2026 15:05:17 -0700 Subject: [PATCH] feat: resolve MCP pipeline source from project config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin hosts spawn the MCP server with no arguments, but --pipeline was required and a missing value killed the process during the MCP handshake — which surfaces in clients as a dead server with no explanation. Add clgraph.mcp.config, which answers "what has this project been configured to index?" in order: --pipeline argument, $CLGRAPH_PIPELINE, clgraph.toml, [tool.clgraph] in pyproject.toml, then a cached .clgraph/pipeline.json. An unconfigured project resolves to None and the server starts anyway: all 14 tools register with their real schemas and return a message naming the fix, so an agent can recover on its own. Deliberately absent: silent directory sniffing. Guessing a SQL directory is only safe when the dialect is also known, and the dialect is now never defaulted. sqlglot parses most of a corpus under the wrong grammar without erroring, so the old bigquery fallback produced lineage graphs that looked right and were not. Pointing at SQL files without a dialect is now an error naming the fix. JSON pipelines are unaffected — they carry their own. Also adds the clgraph-mcp console script so client configs can invoke the server directly rather than through python -m. Breaking: `--pipeline ` without `--dialect` no longer parses as BigQuery. Two CLI tests encoded that default and were updated. --- CHANGELOG.md | 29 ++++ README.md | 51 +++++-- pyproject.toml | 2 + src/clgraph/cli.py | 40 ++++- src/clgraph/mcp/__init__.py | 4 + src/clgraph/mcp/config.py | 267 +++++++++++++++++++++++++++++++++ src/clgraph/mcp/server.py | 108 +++++++++++--- tests/test_mcp.py | 186 ++++++++++++++++++++++- tests/test_mcp_config.py | 286 ++++++++++++++++++++++++++++++++++++ uv.lock | 2 + 10 files changed, 934 insertions(+), 41 deletions(-) create mode 100644 src/clgraph/mcp/config.py create mode 100644 tests/test_mcp_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 79103df..8c14df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `clgraph.mcp.config` - the MCP server now resolves what to index from the + project itself, so plugin hosts can launch it with no arguments. Order: + `--pipeline` argument, `$CLGRAPH_PIPELINE`, `clgraph.toml`, + `[tool.clgraph]` in `pyproject.toml`, then a cached + `.clgraph/pipeline.json`. Public API: `resolve()`, `load_pipeline()`, + `PipelineSource`. +- `clgraph-mcp` console script, so MCP client configs can invoke the server + directly instead of going through `python -m clgraph.mcp`. +- `clgraph.toml` / `[tool.clgraph]` project configuration, with keys + `sql_dir` and `dialect`. + +### Changed + +- **Breaking:** the SQL dialect is no longer defaulted to `bigquery` when + indexing SQL files. `clgraph-mcp --pipeline ./queries/` without + `--dialect` now exits with a message naming the fix, where it previously + parsed as BigQuery. sqlglot parses most of a corpus under the wrong + grammar without erroring, so the old default produced a lineage graph + that looked right and was not. JSON pipelines are unaffected - they + carry their own dialect. +- `create_mcp_server()` and `run_mcp_server()` accept `pipeline=None`. An + unconfigured server still registers every tool with its full schema, and + each returns a message pointing at setup. Previously a missing + `--pipeline` killed the process during the MCP handshake, which surfaces + in clients as a failed server with no explanation. +- `--pipeline` is now optional for `clgraph mcp` and `clgraph-mcp`. + ## [0.0.8] - 2026-08-11 ### Added diff --git a/README.md b/README.md index 1c0158c..3394fe0 100644 --- a/README.md +++ b/README.md @@ -773,6 +773,35 @@ Expose your pipeline's lineage tools to AI assistants via the [Model Context Pro pip install 'clgraph[mcp]' ``` +#### Project Configuration + +The server works out what to index from the project itself, so hosts can +launch it with no arguments. Put this in `clgraph.toml` at your project root: + + +```toml +sql_dir = "queries/" +dialect = "snowflake" +``` + +Or use `[tool.clgraph]` in `pyproject.toml` with the same keys. Resolution +order, first hit wins: + +1. `--pipeline` / `--dialect` arguments +2. `$CLGRAPH_PIPELINE` / `$CLGRAPH_DIALECT` +3. `clgraph.toml` +4. `[tool.clgraph]` in `pyproject.toml` +5. A cached `.clgraph/pipeline.json` + +An unconfigured project still starts a server. Every tool keeps its full +schema and reports that setup has not run yet, so an assistant can tell you +what to do instead of showing a dead connection. + +**The dialect is never guessed.** Pointing clgraph at SQL files without +saying which dialect they are is an error, not a silent fall back to +BigQuery — sqlglot parses most of a corpus under the wrong grammar without +complaining, and the resulting lineage graph is plausible and wrong. + #### Claude Desktop Configuration Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): @@ -782,8 +811,8 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ { "mcpServers": { "clgraph": { - "command": "python", - "args": ["-m", "clgraph.mcp", "--pipeline", "/path/to/your/sql/queries/"] + "command": "clgraph-mcp", + "env": { "CLGRAPH_PROJECT_DIR": "/path/to/your/project" } } } } @@ -794,19 +823,21 @@ Then ask Claude: *"What tables are in my pipeline?"*, *"Where does revenue.total #### CLI Usage ```bash -# stdio transport (default, for Claude Desktop) -python -m clgraph.mcp --pipeline ./queries/ +# stdio transport (default) — reads clgraph.toml +clgraph-mcp -# HTTP transport (for remote MCP clients) -python -m clgraph.mcp --pipeline ./queries/ --transport http +# Point at a directory explicitly (--dialect is required for SQL files) +clgraph-mcp --pipeline ./queries/ --dialect snowflake -# From a JSON pipeline file -python -m clgraph.mcp --pipeline pipeline.json +# HTTP transport (for remote MCP clients) +clgraph-mcp --pipeline ./queries/ --dialect snowflake --transport http -# Specify dialect (default: bigquery) -python -m clgraph.mcp --pipeline ./queries/ --dialect snowflake +# From a JSON pipeline file — it carries its own dialect +clgraph-mcp --pipeline pipeline.json ``` +`python -m clgraph.mcp` and `clgraph mcp` accept the same options. + #### Programmatic Usage diff --git a/pyproject.toml b/pyproject.toml index dcf12c7..e0af866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ dependencies = [ "sqlglot>=28.0.0,<31.0.0", + "tomli>=2.0.0; python_version < '3.11'", "graphviz>=0.20.0", "jinja2>=3.0.0", "typer>=0.12.0", @@ -102,6 +103,7 @@ Issues = "https://github.com/mingjerli/clgraph/issues" [project.scripts] clgraph = "clgraph.cli:app" +clgraph-mcp = "clgraph.mcp.server:main" [tool.hatch.build.targets.wheel] packages = ["src/clgraph"] diff --git a/src/clgraph/cli.py b/src/clgraph/cli.py index 4b63f2a..c276cd5 100644 --- a/src/clgraph/cli.py +++ b/src/clgraph/cli.py @@ -12,6 +12,7 @@ import json from enum import Enum from pathlib import Path +from typing import Optional import typer from typing_extensions import Annotated @@ -257,13 +258,13 @@ def _print_diff_summary(diff_result): @app.command() def mcp( pipeline: Annotated[ - Path, + Optional[Path], typer.Option("--pipeline", "-p", help="Path to SQL files directory or JSON pipeline file"), - ], + ] = None, dialect: Annotated[ - str, - typer.Option(help="SQL dialect"), - ] = "bigquery", + Optional[str], + typer.Option(help="SQL dialect. Required for SQL directories; never inferred."), + ] = None, transport: Annotated[ str, typer.Option(help="Transport type: stdio, http"), @@ -273,15 +274,27 @@ def mcp( typer.Option("--no-llm-tools", help="Exclude LLM-dependent tools"), ] = False, ): - """Start MCP server for LLM integration (Claude Desktop, etc.). + """Start MCP server for LLM integration (Claude Code, Claude Desktop, etc.). + + --pipeline is optional. Without it, the project's own configuration + decides what to index: $CLGRAPH_PIPELINE, then clgraph.toml, then + [tool.clgraph] in pyproject.toml, then a cached .clgraph/pipeline.json. + An unconfigured project still starts a server; its tools report that + setup has not run yet. Requires the mcp extra: pip install clgraph[mcp] """ - if not pipeline.exists(): + from clgraph.mcp.config import load_pipeline, resolve + + if pipeline is not None and not pipeline.exists(): typer.echo(f"Error: path does not exist: {pipeline}", err=True) raise typer.Exit(code=1) - loaded = _load_pipeline(pipeline, dialect) + source = resolve( + Path.cwd(), + cli_path=str(pipeline) if pipeline is not None else None, + cli_dialect=dialect, + ) try: from clgraph.mcp import run_mcp_server @@ -292,6 +305,17 @@ def mcp( ) raise typer.Exit(code=1) from err + if source is None: + typer.echo( + "No clgraph configuration found; starting unconfigured. " + "Run /clgraph:setup or pass --pipeline.", + err=True, + ) + loaded = None + else: + typer.echo(f"Loading pipeline from {source.describe()}", err=True) + loaded = load_pipeline(source) + run_mcp_server( loaded, llm=None, diff --git a/src/clgraph/mcp/__init__.py b/src/clgraph/mcp/__init__.py index ddae385..37dfcb6 100644 --- a/src/clgraph/mcp/__init__.py +++ b/src/clgraph/mcp/__init__.py @@ -21,9 +21,13 @@ } """ +from .config import PipelineSource, load_pipeline, resolve from .server import create_mcp_server, run_mcp_server __all__ = [ "create_mcp_server", "run_mcp_server", + "PipelineSource", + "resolve", + "load_pipeline", ] diff --git a/src/clgraph/mcp/config.py b/src/clgraph/mcp/config.py new file mode 100644 index 0000000..9f1f45f --- /dev/null +++ b/src/clgraph/mcp/config.py @@ -0,0 +1,267 @@ +""" +Pipeline source resolution for the MCP server. + +Plugin hosts spawn the server with no arguments, so it has to work out +what to index from the project itself. This module answers one question: +"what has this project been configured to index?" + +It deliberately does not answer "what does this project probably want +indexed?" Guessing a SQL directory is only safe when the dialect is also +known, and an unconfirmed dialect produces a graph that is plausible and +wrong -- sqlglot parses most of a corpus under the wrong grammar without +complaining. So an unconfigured project resolves to None, and setup asks +the user instead. + +Resolution order, first hit wins: + + 1. --pipeline / --dialect command-line arguments + 2. $CLGRAPH_PIPELINE / $CLGRAPH_DIALECT + 3. clgraph.toml (top-level keys, nearest ancestor) + 4. [tool.clgraph] (in pyproject.toml, nearest ancestor) + 5. .clgraph/pipeline.json (a warm cache with no config beside it) + 6. None +""" + +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional + +try: + # tomllib is stdlib from 3.11; clgraph still supports 3.10, where the + # tomli backport (a conditional dependency) stands in for it. + import tomllib # ty: ignore[unresolved-import] +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # ty: ignore[unresolved-import] + +if TYPE_CHECKING: + from ..pipeline import Pipeline + +logger = logging.getLogger(__name__) + +CONFIG_FILENAME = "clgraph.toml" +PYPROJECT_FILENAME = "pyproject.toml" +CACHE_RELPATH = Path(".clgraph") / "pipeline.json" +DBT_PROJECT_FILENAME = "dbt_project.yml" + +ENV_PIPELINE = "CLGRAPH_PIPELINE" +ENV_DIALECT = "CLGRAPH_DIALECT" +ENV_PROJECT_DIR = "CLGRAPH_PROJECT_DIR" + +KIND_JSON = "json" +KIND_SQL_DIR = "sql_dir" +KIND_DBT = "dbt" + + +@dataclass(frozen=True) +class PipelineSource: + """What this project has been configured to index. + + Attributes: + path: Location of the SQL directory or serialized pipeline. + dialect: SQL dialect, or None if it was never stated. Never + defaulted -- see ``is_complete``. + kind: One of "json", "sql_dir", "dbt". + origin: Where this came from, for error messages and + ``graph_status``: "cli", "env", "clgraph.toml", + "pyproject.toml", or "cache". + """ + + path: Path + dialect: Optional[str] + kind: str + origin: str + + @property + def is_complete(self) -> bool: + """Whether this source can be loaded as-is. + + A serialized pipeline carries its own dialect. A directory of SQL + files does not, and indexing one without a stated dialect is the + silent-wrong-graph failure this module exists to prevent. + """ + return self.kind == KIND_JSON or bool(self.dialect) + + def describe(self) -> str: + """One-line human-readable summary, for logs and status output.""" + dialect = self.dialect or "no dialect set" + return f"{self.path} ({self.kind}, {dialect}, from {self.origin})" + + +def resolve( + cwd: Path, + cli_path: Optional[str] = None, + cli_dialect: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, +) -> Optional[PipelineSource]: + """Resolve what to index, or None if the project is unconfigured. + + Args: + cwd: Directory to resolve from. Config lookup walks up from here. + cli_path: Explicit --pipeline argument, if given. + cli_dialect: Explicit --dialect argument, if given. + env: Environment mapping. Defaults to os.environ. + + Returns: + The configured source, or None if nothing configures this project. + A returned source may still be incomplete -- check ``is_complete``. + """ + env = os.environ if env is None else env + cwd = Path(cwd) + + if cli_path: + return _build(cwd, cli_path, cli_dialect, origin="cli") + + env_path = env.get(ENV_PIPELINE) + if env_path: + return _build(cwd, env_path, env.get(ENV_DIALECT), origin="env") + + from_config = _from_config_files(cwd) + if from_config is not None: + return from_config + + cache = _find_upwards(cwd, CACHE_RELPATH) + if cache is not None: + return PipelineSource(path=cache, dialect=None, kind=KIND_JSON, origin="cache") + + return None + + +# ============================================================================= +# Config files +# ============================================================================= + + +def _from_config_files(cwd: Path) -> Optional[PipelineSource]: + """Read clgraph.toml, then [tool.clgraph] in pyproject.toml.""" + config_file = _find_upwards(cwd, Path(CONFIG_FILENAME)) + if config_file is not None: + table = _read_toml(config_file) + if table: + source = _from_table(table, config_file, origin=CONFIG_FILENAME) + if source is not None: + return source + + pyproject = _find_upwards(cwd, Path(PYPROJECT_FILENAME)) + if pyproject is not None: + data = _read_toml(pyproject) + table = data.get("tool", {}).get("clgraph", {}) + if table: + return _from_table(table, pyproject, origin=PYPROJECT_FILENAME) + + return None + + +def _from_table(table: Dict[str, Any], config_file: Path, origin: str) -> Optional[PipelineSource]: + """Build a source from a parsed config table. + + Paths are resolved against the config file's own directory, not the + cwd -- the server may be spawned from anywhere in the tree. + """ + raw_path = table.get("sql_dir") + if not raw_path: + logger.warning("%s configures clgraph but sets no 'sql_dir'; ignoring it.", config_file) + return None + + return _build(config_file.parent, str(raw_path), table.get("dialect"), origin=origin) + + +def _read_toml(path: Path) -> Dict[str, Any]: + """Parse a TOML file, returning {} if it cannot be read. + + A malformed config must not take the server down: the tools report + "not configured" and point at setup, which rewrites the file. + """ + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as err: + logger.warning("Could not read %s: %s", path, err) + return {} + + +# ============================================================================= +# Paths +# ============================================================================= + + +def _build(base: Path, raw_path: str, dialect: Optional[str], origin: str) -> PipelineSource: + """Assemble a PipelineSource from a raw path string.""" + path = _absolute(base, raw_path) + return PipelineSource( + path=path, + dialect=dialect or None, + kind=_classify(path), + origin=origin, + ) + + +def _absolute(base: Path, raw_path: str) -> Path: + """Resolve raw_path against base, leaving absolute paths alone.""" + candidate = Path(raw_path).expanduser() + if candidate.is_absolute(): + return candidate + return base / candidate + + +def _classify(path: Path) -> str: + """Classify a configured path. Classification, not discovery.""" + if path.suffix == ".json": + return KIND_JSON + if (path / DBT_PROJECT_FILENAME).exists() or (path.parent / DBT_PROJECT_FILENAME).exists(): + return KIND_DBT + return KIND_SQL_DIR + + +def _find_upwards(start: Path, relpath: Path) -> Optional[Path]: + """Find relpath in start or its nearest ancestor.""" + start = Path(start).resolve() + for directory in (start, *start.parents): + candidate = directory / relpath + if candidate.exists(): + return candidate + return None + + +# ============================================================================= +# Loading +# ============================================================================= + + +def load_pipeline(source: PipelineSource) -> "Pipeline": + """Load a resolved source into a Pipeline. + + Args: + source: A source from :func:`resolve`. + + Returns: + The built Pipeline. + + Raises: + SystemExit: If the source names SQL files with no dialect. Parsing + under the wrong grammar mostly succeeds, so the graph comes out + plausible and wrong; refusing is the safe failure. + """ + from ..pipeline import Pipeline + + if source.kind == KIND_JSON: + return Pipeline.from_json_file(str(source.path)) + + dialect = source.dialect + if not dialect: + raise SystemExit( + f"clgraph: no SQL dialect set for {source.path} " + f"(configured via {source.origin}).\n" + f"Pass --dialect, set 'dialect' in clgraph.toml, or run /clgraph:setup.\n" + f"clgraph does not guess: the wrong dialect yields a plausible but " + f"incorrect lineage graph." + ) + + if source.kind == KIND_DBT: + project_dir = ( + source.path if (source.path / DBT_PROJECT_FILENAME).exists() else source.path.parent + ) + return Pipeline.from_dbt_models(str(project_dir), dialect=dialect) + + return Pipeline.from_sql_files(str(source.path), dialect=dialect) diff --git a/src/clgraph/mcp/server.py b/src/clgraph/mcp/server.py index 8eadc12..0b27552 100644 --- a/src/clgraph/mcp/server.py +++ b/src/clgraph/mcp/server.py @@ -9,11 +9,14 @@ import inspect import json import logging +import os +from pathlib import Path from typing import Any, Dict, Optional from ..pipeline import Pipeline from ..tools import BASIC_TOOLS, ToolRegistry, create_tool_registry from ..tools.base import ParameterType +from .config import ENV_PROJECT_DIR, load_pipeline, resolve # FastMCP imports - optional dependency try: @@ -26,6 +29,12 @@ logger = logging.getLogger(__name__) +UNCONFIGURED_MESSAGE = ( + "No lineage graph is configured for this project. " + "Run /clgraph:setup - it finds your SQL, confirms the dialect with you, " + "and writes clgraph.toml." +) + _TYPE_MAP = { ParameterType.STRING: str, ParameterType.INTEGER: int, @@ -36,7 +45,7 @@ def create_mcp_server( - pipeline: Pipeline, + pipeline: Optional[Pipeline] = None, llm=None, include_llm_tools: bool = True, ) -> "FastMCP": @@ -44,7 +53,13 @@ def create_mcp_server( Create a FastMCP server exposing lineage tools. Args: - pipeline: The clgraph Pipeline to expose + pipeline: The clgraph Pipeline to expose, or None when the project + is not configured yet. An unconfigured server still registers + every tool with its full schema, and each one returns + UNCONFIGURED_MESSAGE instead of data. Plugin hosts spawn this + server before setup has run, and a server that fails to start + is invisible to the agent -- it can only recover from an error + it can read. llm: Optional LLM for SQL generation tools include_llm_tools: Whether to include tools that require LLM @@ -69,7 +84,7 @@ def create_mcp_server( if include_llm_tools and llm is not None: registry = create_tool_registry(pipeline, llm) else: - registry = ToolRegistry(pipeline, llm) + registry = ToolRegistry(pipeline, llm) # ty: ignore[invalid-argument-type] registry.register_all(BASIC_TOOLS) # Create FastMCP server @@ -77,9 +92,19 @@ def create_mcp_server( # Register each tool from the registry for tool in registry.all_tools(): - _register_tool(mcp, tool) + _register_tool(mcp, tool, configured=pipeline is not None) + + if pipeline is None: + _register_unconfigured_resources(mcp) + else: + _register_pipeline_resources(mcp, pipeline) + + return mcp + + +def _register_pipeline_resources(mcp: "FastMCP", pipeline: Pipeline) -> None: + """Register the schema, table-list, and per-table resources.""" - # Register resources @mcp.resource("pipeline://schema") def pipeline_schema() -> str: """Full schema of all tables and columns in the pipeline.""" @@ -93,12 +118,45 @@ def table_list() -> str: for table_name in pipeline.table_graph.tables: _register_table_resource(mcp, pipeline, table_name) - return mcp +def _register_unconfigured_resources(mcp: "FastMCP") -> None: + """Register the same top-level resource URIs, each explaining itself. + + Per-table resources are omitted: there are no tables to name yet. + """ -def _register_tool(mcp: "FastMCP", tool) -> None: + @mcp.resource("pipeline://schema") + def pipeline_schema() -> str: + """Full schema of all tables and columns in the pipeline.""" + return _unconfigured_payload() + + @mcp.resource("pipeline://tables") + def table_list() -> str: + """List of all tables in the pipeline.""" + return _unconfigured_payload() + + +def _unconfigured_payload() -> str: + """The response every tool gives before the project is configured.""" + return json.dumps( + { + "success": False, + "data": None, + "message": UNCONFIGURED_MESSAGE, + "error": UNCONFIGURED_MESSAGE, + }, + indent=2, + ) + + +def _register_tool(mcp: "FastMCP", tool, configured: bool = True) -> None: """Register a single BaseTool instance with FastMCP. + When ``configured`` is False the tool is registered with its real + schema but answers with UNCONFIGURED_MESSAGE. Schemas come from + ParameterSpec, which is static metadata, so they are unaffected by + having no pipeline behind them. + FastMCP does NOT support **kwargs handlers — it needs an explicit function signature to generate the JSON schema. We build a proper signature using inspect.Parameter and set it on the handler via @@ -138,6 +196,8 @@ def _register_tool(mcp: "FastMCP", tool) -> None: def make_handler(t, signature): def handler(*args, **kwargs): + if not configured: + return _unconfigured_payload() bound = signature.bind(*args, **kwargs) bound.apply_defaults() try: @@ -272,7 +332,7 @@ def _get_table_info(pipeline: Pipeline, table_name: str) -> str: def run_mcp_server( - pipeline: Pipeline, + pipeline: Optional[Pipeline] = None, llm=None, include_llm_tools: bool = True, transport: str = "stdio", @@ -281,7 +341,8 @@ def run_mcp_server( Run the MCP server (blocking). Args: - pipeline: The clgraph Pipeline to expose + pipeline: The clgraph Pipeline to expose, or None to run + unconfigured (see create_mcp_server) llm: Optional LLM for SQL generation tools include_llm_tools: Whether to include tools that require LLM transport: Transport type ("stdio", "http", "sse", or "streamable-http") @@ -296,19 +357,25 @@ def run_mcp_server( def main(): - """Command-line entry point for MCP server.""" + """Command-line entry point for MCP server. + + --pipeline is optional: plugin hosts spawn this with no arguments and + the project's own configuration decides what to index. See + clgraph.mcp.config for the resolution order. + """ parser = argparse.ArgumentParser(description="Run clgraph MCP server for lineage tools") parser.add_argument( "--pipeline", "-p", - required=True, - help="Path to SQL files directory or JSON pipeline file", + default=None, + help="Path to SQL files directory or JSON pipeline file. " + "Defaults to whatever the project configures.", ) parser.add_argument( "--dialect", "-d", - default="bigquery", - help="SQL dialect (default: bigquery)", + default=None, + help="SQL dialect. Required for SQL directories; never inferred.", ) parser.add_argument( "--no-llm-tools", @@ -325,15 +392,16 @@ def main(): args = parser.parse_args() - # Load pipeline - pipeline_path = args.pipeline + project_dir = Path(os.environ.get(ENV_PROJECT_DIR) or Path.cwd()) + source = resolve(project_dir, cli_path=args.pipeline, cli_dialect=args.dialect) - if pipeline_path.endswith(".json"): - pipeline = Pipeline.from_json_file(pipeline_path) + if source is None: + logger.info("No clgraph configuration found under %s; starting unconfigured.", project_dir) + pipeline = None else: - pipeline = Pipeline.from_sql_files(pipeline_path, dialect=args.dialect) + logger.info("Loading pipeline from %s", source.describe()) + pipeline = load_pipeline(source) - # Run server run_mcp_server( pipeline, llm=None, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b49e6bf..4a689b5 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -582,7 +582,9 @@ def test_cli_json_pipeline_path(self, monkeypatch): patch("clgraph.mcp.server.run_mcp_server") as mock_run, ): main() - mock_from_json.assert_called_once_with("test.json") + # Paths are resolved to absolute: the server may be spawned + # from anywhere, so main() no longer forwards the raw string. + assert mock_from_json.call_args.args[0].endswith("test.json") mock_run.assert_called_once() def test_cli_sql_directory_path(self, monkeypatch): @@ -590,7 +592,13 @@ def test_cli_sql_directory_path(self, monkeypatch): from clgraph.mcp.server import main - monkeypatch.setattr("sys.argv", ["clgraph-mcp", "--pipeline", "./queries/"]) + # --dialect is now required for a SQL directory; there is no + # bigquery fallback. See TestCLIWithoutPipelineArgument for the + # refusal path. + monkeypatch.setattr( + "sys.argv", + ["clgraph-mcp", "--pipeline", "./queries/", "--dialect", "postgres"], + ) mock_pipeline = MagicMock() with ( patch( @@ -600,7 +608,8 @@ def test_cli_sql_directory_path(self, monkeypatch): patch("clgraph.mcp.server.run_mcp_server") as mock_run, ): main() - mock_from_sql.assert_called_once_with("./queries/", dialect="bigquery") + assert mock_from_sql.call_args.args[0].endswith("queries") + assert mock_from_sql.call_args.kwargs["dialect"] == "postgres" mock_run.assert_called_once() def test_cli_transport_option(self, monkeypatch): @@ -652,3 +661,174 @@ def test_cli_no_llm_tools_flag(self, monkeypatch): include_llm_tools=False, transport="stdio", ) + + +# ============================================================================= +# Unconfigured server (A1 — soft-fail startup) +# ============================================================================= + + +class TestUnconfiguredServer: + """A server with no pipeline still boots, and every tool explains why. + + Plugin hosts spawn the server before the project is configured. A + server that refuses to start is invisible to the agent -- it just + shows a dead entry in the client. A server that starts and returns an + actionable message lets the agent recover on its own. + """ + + @requires_fastmcp + def test_create_server_accepts_none(self): + from fastmcp import FastMCP + + from clgraph.mcp import create_mcp_server + + assert isinstance(create_mcp_server(None), FastMCP) + + @requires_fastmcp + def test_all_basic_tools_still_registered(self): + import asyncio + + from clgraph.mcp import create_mcp_server + from clgraph.tools import BASIC_TOOLS + + tools = asyncio.run(create_mcp_server(None).list_tools()) + + assert len(tools) == len(BASIC_TOOLS) + assert "trace_backward" in [t.name for t in tools] + + @requires_fastmcp + def test_tool_schemas_are_intact(self): + """Schemas come from ParameterSpec, so they survive having no data.""" + import asyncio + + from clgraph.mcp import create_mcp_server + + tools = asyncio.run(create_mcp_server(None).list_tools()) + trace = next(t for t in tools if t.name == "trace_backward") + + properties = trace.parameters["properties"] + assert "table" in properties + assert "column" in properties + assert "table" in trace.parameters["required"] + + @requires_fastmcp + def test_tool_call_returns_actionable_message(self): + import asyncio + + from clgraph.mcp import create_mcp_server + + server = create_mcp_server(None) + result = asyncio.run(server.call_tool("trace_backward", {"table": "a", "column": "b"})) + data = json.loads(result.content[0].text) + + assert data["success"] is False + assert "/clgraph:setup" in data["error"] + + @requires_fastmcp + def test_tool_call_does_not_raise(self): + """Every tool, not just the one we happened to pick.""" + import asyncio + + from clgraph.mcp import create_mcp_server + + server = create_mcp_server(None) + for name in ("list_tables", "find_pii_columns", "list_tags"): + result = asyncio.run(server.call_tool(name, {})) + assert json.loads(result.content[0].text)["success"] is False + + @requires_fastmcp + def test_pipeline_resources_explain_instead_of_raising(self): + import asyncio + + from clgraph.mcp import create_mcp_server + + server = create_mcp_server(None) + resources = asyncio.run(server.list_resources()) + uris = [str(r.uri) for r in resources] + + assert "pipeline://schema" in uris + assert "pipeline://tables" in uris + + @requires_fastmcp + def test_no_per_table_resources_when_unconfigured(self): + import asyncio + + from clgraph.mcp import create_mcp_server + + resources = asyncio.run(create_mcp_server(None).list_resources()) + uris = [str(r.uri) for r in resources] + + assert not any(u.startswith("pipeline://tables/") for u in uris) + + +class TestCLIWithoutPipelineArgument: + """main() no longer requires --pipeline, and never guesses a dialect.""" + + def test_unconfigured_project_starts_server_anyway(self, monkeypatch, tmp_path): + from unittest.mock import patch + + from clgraph.mcp.server import main + + monkeypatch.setattr("sys.argv", ["clgraph-mcp"]) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("CLGRAPH_PIPELINE", raising=False) + + with patch("clgraph.mcp.server.run_mcp_server") as mock_run: + main() + mock_run.assert_called_once() + assert mock_run.call_args.args[0] is None + + def test_sql_dir_without_dialect_is_refused(self, monkeypatch, tmp_path): + """The silent-wrong-graph guard: no dialect means no index.""" + from unittest.mock import patch + + from clgraph.mcp.server import main + + (tmp_path / "queries").mkdir() + monkeypatch.setattr("sys.argv", ["clgraph-mcp", "--pipeline", "queries"]) + monkeypatch.chdir(tmp_path) + + with patch("clgraph.mcp.server.Pipeline.from_sql_files") as mock_from_sql: + with pytest.raises(SystemExit): + main() + mock_from_sql.assert_not_called() + + def test_env_var_configures_the_server(self, monkeypatch, tmp_path): + from unittest.mock import MagicMock, patch + + from clgraph.mcp.server import main + + (tmp_path / "queries").mkdir() + monkeypatch.setattr("sys.argv", ["clgraph-mcp"]) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CLGRAPH_PIPELINE", "queries") + monkeypatch.setenv("CLGRAPH_DIALECT", "postgres") + + mock_pipeline = MagicMock() + with ( + patch( + "clgraph.mcp.server.Pipeline.from_sql_files", + return_value=mock_pipeline, + ) as mock_from_sql, + patch("clgraph.mcp.server.run_mcp_server") as mock_run, + ): + main() + assert mock_from_sql.call_args.kwargs["dialect"] == "postgres" + mock_run.assert_called_once() + + +class TestConsoleScript: + """A5 — the plugin's .mcp.json invokes `clgraph-mcp` directly.""" + + def test_pyproject_declares_clgraph_mcp_script(self): + from pathlib import Path + + import tomllib + + pyproject = Path(__file__).parent.parent / "pyproject.toml" + with pyproject.open("rb") as handle: + config = tomllib.load(handle) + + scripts = config["project"]["scripts"] + assert scripts["clgraph-mcp"] == "clgraph.mcp.server:main" diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py new file mode 100644 index 0000000..95bf869 --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,286 @@ +""" +Tests for clgraph.mcp.config — pipeline source resolution. + +The MCP server is spawned by plugin hosts with no arguments, so it has to +work out what to index from the project itself. These tests pin the +resolution order and, just as importantly, pin what resolution refuses to +do: guess. +""" + +import json +from dataclasses import FrozenInstanceError + +import pytest + +from clgraph.mcp.config import PipelineSource, resolve + + +def write(path, content): + """Write text to path, creating parents.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +@pytest.fixture +def project(tmp_path): + """A bare project directory with a SQL folder but no clgraph config.""" + write(tmp_path / "sql" / "users.sql", "CREATE TABLE s.users AS SELECT id FROM r.users") + return tmp_path + + +# ============================================================================= +# Resolution order +# ============================================================================= + + +class TestResolutionOrder: + def test_cli_path_wins_over_everything(self, project): + write(project / "clgraph.toml", 'sql_dir = "from_toml/"\ndialect = "postgres"\n') + env = {"CLGRAPH_PIPELINE": "from_env/", "CLGRAPH_DIALECT": "duckdb"} + + source = resolve(project, cli_path="from_cli/", cli_dialect="snowflake", env=env) + + assert source.origin == "cli" + assert source.path == project / "from_cli" + assert source.dialect == "snowflake" + + def test_env_used_when_no_cli_arg(self, project): + write(project / "clgraph.toml", 'sql_dir = "from_toml/"\ndialect = "postgres"\n') + env = {"CLGRAPH_PIPELINE": "from_env/", "CLGRAPH_DIALECT": "duckdb"} + + source = resolve(project, env=env) + + assert source.origin == "env" + assert source.path == project / "from_env" + assert source.dialect == "duckdb" + + def test_clgraph_toml_beats_pyproject(self, project): + write(project / "clgraph.toml", 'sql_dir = "models/"\ndialect = "snowflake"\n') + write( + project / "pyproject.toml", + '[tool.clgraph]\nsql_dir = "other/"\ndialect = "postgres"\n', + ) + + source = resolve(project, env={}) + + assert source.origin == "clgraph.toml" + assert source.path == project / "models" + assert source.dialect == "snowflake" + + def test_pyproject_used_when_no_clgraph_toml(self, project): + write( + project / "pyproject.toml", + '[tool.clgraph]\nsql_dir = "models/"\ndialect = "postgres"\n', + ) + + source = resolve(project, env={}) + + assert source.origin == "pyproject.toml" + assert source.path == project / "models" + assert source.dialect == "postgres" + + def test_cache_used_when_no_config_at_all(self, project): + write(project / ".clgraph" / "pipeline.json", json.dumps({"queries": {}})) + + source = resolve(project, env={}) + + assert source.origin == "cache" + assert source.kind == "json" + assert source.path == project / ".clgraph" / "pipeline.json" + + def test_config_beats_cache(self, project): + write(project / "clgraph.toml", 'sql_dir = "models/"\ndialect = "snowflake"\n') + write(project / ".clgraph" / "pipeline.json", json.dumps({"queries": {}})) + + source = resolve(project, env={}) + + assert source.origin == "clgraph.toml" + + +# ============================================================================= +# Refusing to guess +# ============================================================================= + + +class TestNoSilentDiscovery: + def test_unconfigured_project_resolves_to_none(self, project): + """A sql/ directory alone is not configuration.""" + assert resolve(project, env={}) is None + + def test_dbt_project_without_config_resolves_to_none(self, project): + """Even an obvious dbt layout is not indexed until setup confirms it.""" + write(project / "dbt_project.yml", "name: analytics\n") + write(project / "models" / "stg_users.sql", "SELECT 1") + + assert resolve(project, env={}) is None + + def test_dialect_is_never_defaulted(self, project): + """A source with no dialect is incomplete, not silently bigquery.""" + write(project / "clgraph.toml", 'sql_dir = "models/"\n') + + source = resolve(project, env={}) + + assert source.dialect is None + assert source.is_complete is False + + def test_json_source_is_complete_without_dialect(self, project): + """A serialized pipeline carries its own dialect.""" + write(project / ".clgraph" / "pipeline.json", json.dumps({"queries": {}})) + + source = resolve(project, env={}) + + assert source.dialect is None + assert source.is_complete is True + + +# ============================================================================= +# Path handling +# ============================================================================= + + +class TestPathResolution: + def test_config_paths_are_relative_to_the_config_file(self, tmp_path): + """Not relative to cwd — the server may be spawned anywhere.""" + root = tmp_path / "repo" + write(root / "clgraph.toml", 'sql_dir = "models/"\ndialect = "snowflake"\n') + nested = root / "a" / "b" + nested.mkdir(parents=True) + + source = resolve(nested, env={}) + + assert source.path == root / "models" + + def test_pyproject_lookup_walks_up_from_nested_cwd(self, tmp_path): + root = tmp_path / "repo" + write( + root / "pyproject.toml", + '[tool.clgraph]\nsql_dir = "models/"\ndialect = "postgres"\n', + ) + nested = root / "src" / "deep" + nested.mkdir(parents=True) + + source = resolve(nested, env={}) + + assert source.origin == "pyproject.toml" + assert source.path == root / "models" + + def test_pyproject_without_clgraph_table_is_skipped(self, project): + write(project / "pyproject.toml", "[tool.ruff]\nline-length = 100\n") + + assert resolve(project, env={}) is None + + def test_absolute_configured_path_is_left_alone(self, tmp_path): + root = tmp_path / "repo" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir(parents=True) + write(root / "clgraph.toml", f'sql_dir = "{elsewhere}"\ndialect = "postgres"\n') + + source = resolve(root, env={}) + + assert source.path == elsewhere + + +# ============================================================================= +# Source kind +# ============================================================================= + + +class TestKindDetection: + def test_json_suffix_is_json_kind(self, project): + write(project / "clgraph.toml", 'sql_dir = "pipeline.json"\n') + + assert resolve(project, env={}).kind == "json" + + def test_plain_directory_is_sql_dir_kind(self, project): + write(project / "clgraph.toml", 'sql_dir = "sql/"\ndialect = "postgres"\n') + + assert resolve(project, env={}).kind == "sql_dir" + + def test_configured_dbt_project_is_dbt_kind(self, project): + """Classifying a path the user chose — not discovering one.""" + write(project / "dbt_project.yml", "name: analytics\n") + write(project / "models" / "stg_users.sql", "SELECT 1") + write(project / "clgraph.toml", 'sql_dir = "models/"\ndialect = "snowflake"\n') + + assert resolve(project, env={}).kind == "dbt" + + +# ============================================================================= +# PipelineSource +# ============================================================================= + + +class TestPipelineSource: + def test_is_frozen(self, tmp_path): + source = PipelineSource(path=tmp_path, dialect="postgres", kind="sql_dir", origin="cli") + + with pytest.raises(FrozenInstanceError): + source.dialect = "snowflake" + + def test_describe_names_origin_and_dialect(self, tmp_path): + source = PipelineSource( + path=tmp_path / "models", + dialect="snowflake", + kind="sql_dir", + origin="clgraph.toml", + ) + + described = source.describe() + + assert "snowflake" in described + assert "clgraph.toml" in described + + +# ============================================================================= +# Loading +# ============================================================================= + + +class TestLoadPipeline: + def test_incomplete_source_refuses_to_load(self, tmp_path): + from clgraph.mcp.config import load_pipeline + + source = PipelineSource( + path=tmp_path / "models", dialect=None, kind="sql_dir", origin="clgraph.toml" + ) + + with pytest.raises(SystemExit) as excinfo: + load_pipeline(source) + + assert "dialect" in str(excinfo.value) + + def test_dbt_source_loads_from_the_project_root(self, project): + """from_dbt_models wants the dbt project, not the models/ dir.""" + from unittest.mock import patch + + from clgraph.mcp.config import load_pipeline + + write(project / "dbt_project.yml", "name: analytics\n") + write(project / "models" / "stg_users.sql", "SELECT 1") + source = PipelineSource( + path=project / "models", + dialect="snowflake", + kind="dbt", + origin="clgraph.toml", + ) + + with patch("clgraph.pipeline.Pipeline.from_dbt_models") as mock_dbt: + load_pipeline(source) + + assert mock_dbt.call_args.args[0] == str(project) + assert mock_dbt.call_args.kwargs["dialect"] == "snowflake" + + def test_sql_dir_source_loads_with_its_dialect(self, project): + from unittest.mock import patch + + from clgraph.mcp.config import load_pipeline + + source = PipelineSource( + path=project / "sql", dialect="postgres", kind="sql_dir", origin="cli" + ) + + with patch("clgraph.pipeline.Pipeline.from_sql_files") as mock_sql: + load_pipeline(source) + + assert mock_sql.call_args.kwargs["dialect"] == "postgres" diff --git a/uv.lock b/uv.lock index 00b86bc..62002c6 100644 --- a/uv.lock +++ b/uv.lock @@ -818,6 +818,7 @@ dependencies = [ { name = "jinja2" }, { name = "rich" }, { name = "sqlglot" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, ] @@ -907,6 +908,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.16.1,<0.17" }, { name = "sqlglot", specifier = ">=28.0.0,<31.0.0" }, { name = "streamlit", marker = "extra == 'examples'", specifier = ">=1.28.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "twine", marker = "extra == 'build'", specifier = ">=4.0.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a0" }, { name = "typer", specifier = ">=0.12.0" },