Skip to content

Repository files navigation

graph-mcp

A production-oriented MCP server that lets an LLM query an RDF graph database through a strict, validated QueryPlan IR — never by emitting raw SPARQL strings directly. The server validates, compiles, and executes plans; it also explains what they will do.

The LLM plans. The MCP server validates, compiles, executes, and explains.

Documentation

The full documentation site lives in docs-site/ and is published via GitHub Pages on every push to main.

Audience Where to start
New users User guide — installation, configuration, MCP tools and resources, security
Contributors / maintainers Developer guide — architecture, IR, validator, renderer, evals
Operators Production-readiness checklist and the security/deployment guide
Reference Configuration, Tools, Resources, Validation errors, Eval metrics
ADRs docs-site/docs/adr/

Local docs preview

cd docs-site
npm ci
npm run start    # http://localhost:3000
npm run build    # static site under docs-site/build/

The site is generated by Docusaurus 3. The GitHub Pages deploy is configured in .github/workflows/docs.yml. To enable publishing on a fork, set:

Settings → Pages → Build and deployment → Source → GitHub Actions

Why an IR instead of free-form SPARQL?

Letting an LLM write SPARQL strings is convenient and unsafe: it conflates intent with syntax, hides bugs, and makes safety review impossible. A typed IR lets us:

  • enforce safety — limits, depth, allowlists, no Update, no arbitrary SERVICE — without parsing untrusted text;
  • catch semantic errors deterministically — unbound variables, wrong HAVING shape, BIND rebinds, unbounded property paths;
  • render canonical SPARQL — stable output that diffs cleanly in PRs;
  • measure plan quality — golden cases compare structure, not strings.

The deterministic eval baseline (a hand-coded keyword planner) ships in this repo and exercises the full validator → renderer → executor pipeline against 20 golden cases. Note: that baseline is not an LLM and its case-pass rate should not be read as evidence of LLM planning quality. The new structural, safety, and repair metrics (see "Evaluations" below) are intended to score real LLM planners.

Architecture

User question
  ↓
LLM planner / eval agent
  ↓
Strict QueryPlan IR (Pydantic v2)
  ↓
QueryPlanValidator   ← SecurityPolicy
  ↓
SparqlRenderer       ← deterministic, escaping-aware
  ↓
GraphEndpoint        ← rdflib (local) or HTTP (remote)
  ↓
Structured QueryResult
Layer Module
IR graph_mcp/models/
Validator graph_mcp/compiler/validator.py
Renderer graph_mcp/compiler/renderer.py
Executors graph_mcp/graph/endpoint.py
MCP wiring graph_mcp/server.py, graph_mcp/mcp_tools/
Security graph_mcp/security/policy.py
Evals evals/
RAG evals (experimental) evals_rag/ — see evals_rag/README.md

Installation

python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"           # core + dev tools
pip install -e ".[dev,ai]"        # add the optional PydanticAI planner

Compatibility matrix

Python Pydantic Status
3.11 2.6 – 2.13 CI green
3.12 2.6 – 2.13 CI green
3.13 2.6 – 2.13 CI green; free-threaded build (python3.13t) is not supported (CFFI dependency)

The recursive Pydantic IR is pinned to Pydantic >=2.6,<3. The import path is stress-tested across hash seeds (see tests/test_import_robustness.py), so re-introducing a fragile forward-ref strategy will fail CI before merge.

Configuration

All settings come from environment variables (see .env.example):

Variable Default Purpose
GRAPH_MCP_ENDPOINT_URL (empty) Remote SPARQL endpoint. Empty → in-memory rdflib.
GRAPH_MCP_DEFAULT_LIMIT 100 Auto-applied to SELECT queries without a LIMIT.
GRAPH_MCP_MAX_LIMIT 1000 Hard cap on any executed query.
GRAPH_MCP_TIMEOUT_MS 5000 Per-query timeout.
GRAPH_MCP_ALLOWED_GRAPHS (empty) CSV allowlist; empty disables the GRAPH allowlist.
GRAPH_MCP_ALLOWED_SERVICE_ENDPOINTS (empty) CSV allowlist for SERVICE; empty blocks all.
GRAPH_MCP_ENABLE_RAW_SPARQL false Expert-mode raw SPARQL tool.
GRAPH_MCP_MAX_TRIPLE_PATTERNS 200 Plan-size cap.
GRAPH_MCP_MAX_QUERY_DEPTH 8 Nesting cap.
GRAPH_MCP_MAX_PROPERTY_PATH_COMPLEXITY 16 Property-path AST cap.
GRAPH_MCP_ALLOW_UNBOUNDED_PATHS false Permit */+ paths.
GRAPH_MCP_ALLOWED_PATH_PREDICATES (empty) CSV allowlist for property-path predicate IRIs. Empty = anything in ?p etc.
GRAPH_MCP_ALLOW_DEFAULT_PREFIX_OVERRIDE false Permit plans to redefine rdf, rdfs, xsd, owl, skos, dct, foaf.
GRAPH_MCP_LOCAL_GRAPH_FILE (empty) Turtle file to load into the local executor.
GRAPH_MCP_SCHEMA_PROVIDER auto One of static, sparql, auto. See the schema-provider section.
GRAPH_MCP_SCHEMA_CACHE_TTL_SECONDS 300 TTL for the cached schema snapshot.
GRAPH_MCP_SCHEMA_DISCOVERY_TIMEOUT_MS 10000 Per-query timeout for discovery SPARQL.
GRAPH_MCP_SCHEMA_MAX_CLASSES 200 Cap on discovered classes.
GRAPH_MCP_SCHEMA_MAX_PROPERTIES 500 Cap on discovered properties.
GRAPH_MCP_SCHEMA_MAX_INDIVIDUALS 200 Cap on discovered individuals.
GRAPH_MCP_SCHEMA_MAX_NAMED_GRAPHS 200 Cap on discovered named graphs.
GRAPH_MCP_SCHEMA_DISCOVERY_ON_STARTUP true Run an initial schema refresh when the server starts (only when using the SPARQL provider).
GRAPH_MCP_LOG_LEVEL INFO Logging level (logs go to stderr).

Schema-provider modes

Mode Behavior
static Always use StaticSchemaProvider. Resources return only what the host injects via the schema= argument to build_server.
sparql Use SparqlSchemaProvider and require an endpoint. Discovery runs at startup (configurable) and on every refresh_schema tool call. Fail-fast: if neither GRAPH_MCP_ENDPOINT_URL nor GRAPH_MCP_LOCAL_GRAPH_FILE is set, the server raises ConfigurationError instead of silently using an empty in-memory graph.
auto (default) Use SparqlSchemaProvider when GRAPH_MCP_ENDPOINT_URL or GRAPH_MCP_LOCAL_GRAPH_FILE is set; otherwise fall back to static.

The SparqlSchemaProvider discovers:

  • declared classes (rdfs:Class / owl:Class) and instance-observed classes (?s a ?cls);
  • declared properties (rdf:Property, owl:ObjectProperty, owl:DatatypeProperty) and observed predicates;
  • rdfs:label and skos:prefLabel;
  • rdfs:domain / rdfs:range;
  • named graphs (GRAPH ?g);
  • individuals (capped).

Discovery is best-effort: failed sub-queries are recorded in the snapshot's diagnostics list (visible at graph://schema/status) rather than raising. Generated prefixed_name values are filled in from configured prefixes.

Running the server

# stdio (recommended for MCP hosts like Claude Code)
python -m graph_mcp.server

# http transport
python -m graph_mcp.server --transport streamable-http

Connecting from Claude Code (or any MCP client)

Add to your MCP client configuration (the exact path varies per client):

{
  "mcpServers": {
    "graph-mcp": {
      "command": "python",
      "args": ["-m", "graph_mcp.server"],
      "env": {
        "GRAPH_MCP_LOCAL_GRAPH_FILE": "/absolute/path/to/your.ttl"
      }
    }
  }
}

End-to-end example

A plan, rendered, and executed against the bundled sample graph:

from graph_mcp.models import (
    Iri, Prefix, PrefixedName, Projection, SelectPlan, TriplePattern, Var,
)
from graph_mcp.compiler import QueryPlanValidator, SparqlRenderer
from graph_mcp.graph import LocalRdflibEndpoint
from graph_mcp.security import SecurityPolicy
from graph_mcp.config import Settings

policy   = SecurityPolicy.from_settings(Settings())
validate = QueryPlanValidator(policy)
render   = SparqlRenderer(policy)
endpoint = LocalRdflibEndpoint.from_turtle_file("evals/sample_graph.ttl")

plan = SelectPlan(
    prefixes=[Prefix(prefix="ex", iri="http://example.org/")],
    projection=[Projection(var=Var(name="person"))],
    where=[
        TriplePattern(
            subject=Var(name="person"),
            predicate=PrefixedName(prefix="ex", local="worksFor"),
            object=PrefixedName(prefix="ex", local="Acme"),
        ),
    ],
)
assert validate.validate(plan).ok
print(render.render(plan).sparql)
# PREFIX ex: <http://example.org/>
# ...
# SELECT ?person
# WHERE {
#   ?person ex:worksFor ex:Acme .
# }
# LIMIT 100

Ontology concept discovery (RAG)

The discover_ontology_concepts MCP tool delegates all retrieval logic to the ontology_vectorizer library. graph-mcp does not implement concept embedding, Qdrant queries, reranking, or graph-aware scoring directly — it only owns the MCP boundary.

Architecture

host LLM ──MCP──▶ graph-mcp.discover_ontology_concepts
                          │
                          ▼
                 OntologyConceptRetriever  (ontology_vectorizer.api)
                          │
                ┌─────────┼─────────┬───────────────┐
                ▼         ▼         ▼               ▼
            embedding  Qdrant   reranking   graph-aware scoring
              client   client    client     (parents / groups)

The MCP client never imports ontology_vectorizer.qdrant_store, ontology_vectorizer.retrieval, or any other internal module — only the public facade in ontology_vectorizer.api.

Install

# Editable side-by-side checkouts (typical dev setup):
uv pip install -e ../ontology_vectorizer
uv pip install -e ".[rag]"

Or, with uv's [tool.uv.sources] entry already in pyproject.toml, uv sync --extra rag will pick up the sibling checkout automatically.

Required environment

The vectorizer reads its own variables (not prefixed with GRAPH_MCP_):

Variable Purpose
QDRANT_URL Qdrant base URL
QDRANT_API_KEY Qdrant API key (optional)
QDRANT_COLLECTION_NAME Collection holding ingested concepts
FOUNDRY_API_BASE_URL OpenAI-compatible / Foundry gateway URL
FOUNDRY_API_TOKEN Bearer token for the gateway
FOUNDRY_EMBEDDING_MODEL Embedding model name
FOUNDRY_RERANKER_MODEL Reranker model name (optional; falls back to local lexical)
FOUNDRY_LLM_MODEL LLM model used by enrichment (optional)
ONTOLOGY_VECTORIZER_DEFAULT_ONTOLOGY_ID Default ontology id

The MCP server has its own thin wrapper:

Variable Purpose
GRAPH_MCP_CONCEPTS_ENABLED Master switch (default true)
GRAPH_MCP_CONCEPTS_DEFAULT_ONTOLOGY_ID Used when a request omits ontology_id
GRAPH_MCP_CONCEPTS_DEFAULT_TOP_K Default top_k
GRAPH_MCP_CONCEPTS_INCLUDE_DEPRECATED_BY_DEFAULT Allow deprecated concepts even when the request doesn't ask for them

Ingestion (run once per ontology)

# Populate the Qdrant collection that the MCP tool reads.
ontology-vectorizer ingest --input ocean_demo.ttl --ontology-id ocean-demo

Example MCP request / response

Request:

{
  "query": "sea surface temperature",
  "ontology_id": "ocean-demo",
  "top_k": 5,
  "kind_filter": ["skos_concept"]
}

Response:

{
  "query": "sea surface temperature",
  "ontology_id": "ocean-demo",
  "retrieval_strategy": "hybrid_multi_stage_graph_aware",
  "results": [
    {
      "concept_id": "...",
      "iri": "https://example.org/ocean-demo/id/observable-property/sst",
      "compact_id": "var:sst",
      "preferred_label": "sea surface temperature",
      "labels": ["sea surface temperature"],
      "alt_labels": ["SST"],
      "kind": "skos_concept",
      "definition": "Temperature at the ocean surface.",
      "score": 0.94,
      "deprecated": false,
      "parents": ["var:temperature"],
      "group_ids": ["..."],
      "explanation": "exact-label match"
    }
  ]
}

Errors (vectorizer not installed, Qdrant unreachable, missing credentials) are returned as {"error": "..."} rather than raised, so a host LLM can surface them gracefully.

Tools, resources, prompts

MCP tool Purpose
resolve_terms Map natural-language mentions → ranked IRIs (label/alias/local-name match)
validate_query_plan Static check; structured ValidationResult
render_sparql Validates first, then renders canonical SPARQL
query_graph Validate → render → execute (or dry_run=true to stop after rendering)
explain_query_plan Human-readable plan summary
execute_sparql_raw Off by default; gated by GRAPH_MCP_ENABLE_RAW_SPARQL; rejects updates and unauthorized SERVICE
discover_ontology_concepts Delegates to ontology_vectorizer for hybrid concept retrieval (embedding + Qdrant + reranking + graph-aware scoring). Requires pip install graph-mcp[rag].
Resource Body
graph://schema/prefixes Prefix → IRI map
graph://schema/classes Known classes
graph://schema/properties Known properties
graph://schema/named-graphs Known named graphs
graph://schema/individuals Known individuals (capped)
graph://schema/examples Example QueryPlan objects
graph://policy/security Active policy
graph://query-plan/schema JSON Schema of the QueryPlan IR
Prompt Purpose
build_query_plan Tells the host LLM how to plan, not write, SPARQL.

Tests, lint, type-check

The full local verification suite. The CI gate is all five of these passing on every change:

python -c "import graph_mcp.models; print('ok')"   # import smoke
python -m pytest -q                                 # tests (offline)
python -m ruff check .                              # lint
python -m ruff format --check .                     # formatting
python -m mypy src evals                            # type-check

The Makefile targets make test, make lint, make typecheck, make all are convenience wrappers; if you don't have make, run the commands above directly.

Evaluations

The eval harness scores planner output against golden cases. The deterministic baseline runs fully offline; an LLM-backed planner is opt-in.

# Deterministic baseline — no API key, runs offline.
# This baseline is hand-coded; it exists to exercise the validator, renderer,
# executor, and metrics pipeline end-to-end without LLM cost or flakiness.
# Its scores are *not* evidence of LLM planning quality.
python -m evals.runner --planner deterministic

# LLM planner (requires `pip install -e .[ai]` and an API key).
# Schema, output JSON Schema, and golden examples are inserted into the
# system prompt; failed validations are fed back for up to 2 repair attempts.
python -m evals.runner --planner pydantic-ai --model anthropic:claude-sonnet-4-6

The runner emits structural-quality, safety, and repair metrics suitable for comparing real LLM planners (and not for declaring victory based on the keyword baseline):

Metric Meaning
valid_plan_rate Fraction of generated plans that pass validation
render_success_rate Fraction that also render to SPARQL
execution_success_rate Fraction that also execute against the sample graph
required_feature_recall Hit rate for required pattern kinds + required tokens in rendered SPARQL
forbidden_feature_violation_rate Fraction of forbidden-feature checks that fired
term_resolution_accuracy Fraction of expected schema terms that appeared
structural_plan_score required_feature_recall × (1 − forbidden_feature_violation_rate)
execution_result_accuracy Fraction of executed cases whose row count matched expectations
safety_violation_count Hard-safety failures (e.g. SERVICE used)
validation_error_rate Fraction whose plans the validator rejected
repair_attempted_rate Fraction where the LLM planner needed at least one repair pass
repair_success_rate Of those, fraction that became valid after repair
case_pass_rate Cases that hit zero structural / safety / execution failures

The runner can also produce a JSON+markdown report:

python -m evals.runner --report-dir build/eval_report

Extending

Add a new expression function

  1. Add the function name to ALLOWED_FUNCTIONS in graph_mcp/models/expressions.py.
  2. Update the renderer if it requires a non-default rendering shape.
  3. Add a test in tests/test_renderer.py.

Add a new pattern type

  1. Add the model in graph_mcp/models/patterns.py and to the Pattern union.
  2. Update QueryPlanValidator._validate_pattern to handle scope/safety.
  3. Update SparqlRenderer._render_pattern to emit it.
  4. Update _vars_in_pattern in the validator if needed.
  5. Add tests for both validator and renderer.

Add schema-specific aliases

Inject a richer SchemaProvider into build_server:

from graph_mcp.graph.schema_discovery import SchemaSnapshot, StaticSchemaProvider
schema = StaticSchemaProvider(SchemaSnapshot(...))
server = build_server(schema=schema)

Add new golden eval cases

Append to evals/golden_cases.yaml. The expected block can specify required pattern kinds, required tokens in the rendered SPARQL, forbidden features, and execution expectations.

Enable raw SPARQL safely

Raw SPARQL is disabled by default. To enable it, set GRAPH_MCP_ENABLE_RAW_SPARQL=true. Even then, the tool runs every input through a real token-aware scanner (graph_mcp/mcp_tools/sparql_scanner.py) that distinguishes default-state code from string literals, comments, and IRI references. Specifically:

  • comments (#…) start a comment only in default state, so <http://example.org/#fragment> is never mistaken for one;
  • string literals (single, double, triple-quoted) are opaque to keyword detection — "# INSERT DATA" is a string, not an INSERT;
  • INSERT/DELETE/DROP/CLEAR/LOAD/CREATE/COPY/MOVE/ADD are rejected via token-level matching (catches INSERT\nDATA, INSERT\tDATA, Insert\ndata);
  • WITH … DELETE is rejected (WITH itself is treated as forbidden);
  • DESCRIBE is rejected;
  • SERVICE <iri> is permitted only when the exact IRI matches the allowlist; SERVICE ?var and SERVICE prefix:name are rejected;
  • the actual query form is inferred from the first query keyword in the token stream and must match expected_query_type;
  • raw SELECT and CONSTRUCT queries must include an explicit top-level LIMIT no greater than the effective max_rows; otherwise the request is rejected. We never download an unbounded result and truncate afterward.

Raw mode is still not a full SPARQL parser. It is best-effort defence-in-depth around a feature you should generally leave off.

Security model

  • Read-only by default (no SPARQL Update, no arbitrary SERVICE).
  • Validator enforces depth, triple-count, property-path complexity, and limit caps at every level (top-level and subqueries).
  • Named-graph allowlist: when configured, GRAPH ?g is rejected unless ?g is constrained by a sibling VALUES to allowlisted IRIs.
  • SERVICE allowlist applies in both the IR validator and the raw-SPARQL pre-flight check.
  • All log output goes to stderr (stdio transport keeps stdout clean for JSON-RPC).
  • Errors and exceptions never include endpoint credentials.
  • Raw-SPARQL tool is disabled by default and clearly tagged when enabled.
  • EXISTS / NOT EXISTS sub-patterns are recursively validated for SERVICE, unknown prefixes, depth, triple-count, and property-path policy; their inner variables do not leak outward.
  • Aggregate queries: variables outside aggregate expressions in projections, HAVING, and ORDER BY must appear in GROUP BY.
  • Prefixes are declared once at the top of the plan; subquery prefix blocks are rejected.

How max_rows affects rendered LIMIT

query_graph(max_rows=N, dry_run=True) caps the effective row limit before rendering. Specifically:

effective_max_rows = min(max_rows or default_limit, max_limit)

For a top-level SELECT or CONSTRUCT:

  • if the plan has no LIMIT, it is set to effective_max_rows;
  • if the plan's LIMIT is greater than effective_max_rows, it is capped;
  • if the plan's LIMIT is smaller, it is preserved.

This means dry_run=true shows you what will actually be sent to the endpoint, and remote endpoints are never asked to materialize unbounded results before truncation. Subquery LIMITs are capped at policy.max_limit but never have a default injected (changing subquery semantics is unsafe).

For raw SPARQL, the rule is stricter: raw SELECT / CONSTRUCT queries must include an explicit top-level LIMIT no greater than effective_max_rows or the request is rejected.

Known limitations

The following are explicit, current limitations — if any of these is a blocker for you, please open an issue.

  • Deterministic planner is keyword-matching. The hand-coded DeterministicPlanner in evals/agent.py is aligned with the bundled golden_cases.yaml keywords; its 100 % score on that file is not evidence of LLM planning quality. The evals/golden_cases_adversarial.yaml file contains paraphrased, plural/singular variations and clarification traps the keyword baseline cannot answer; that file is the honest benchmark for an LLM planner.
  • LLM eval is opt-in. The PydanticAI planner is enabled by pip install -e .[ai]. Running it requires an API key and is not part of CI.
  • PydanticAI tool-backed term resolution is out of scope for this package. The optional PydanticAI planner currently receives schema context in the prompt but does not yet use live PydanticAI tool calls for term resolution. MCP hosts that need tool-backed resolution can invoke the server's existing resolve_terms MCP tool directly. The evals agent in evals/agent.py is intentionally a thin benchmarking harness — production-grade term-resolution wiring belongs in the host agent, not the MCP server.
  • Raw SPARQL. Disabled by default. When enabled, the pre-flight check is a token-aware scanner — it tracks string/IRI/comment states and rejects update keywords, DESCRIBE, and unallowlisted SERVICE — but it is not a full SPARQL parser. Raw SELECT/CONSTRUCT must include an explicit top-level LIMIT; the server will reject otherwise.
  • Remote CONSTRUCT. The HTTP endpoint asks for text/turtle / application/n-triples / application/rdf+xml and parses the response via rdflib. Endpoints that ignore the Accept header and return JSON or HTML produce an EndpointError("unsupported CONSTRUCT response content-type") — never a silent empty result.
  • Schema discovery is best-effort. SparqlSchemaProvider records per-section errors (timeouts, unsupported features) as SchemaDiagnostic entries on the snapshot rather than raising. Inspect them via graph://schema/status.
  • Local timeout. LocalRdflibEndpoint runs queries in a worker thread under asyncio.wait_for. The timeout fires and the caller sees EndpointError, but rdflib has no first-class cancellation, so a runaway query continues to consume CPU on its worker thread until it finishes. For hard cancellation, query a real SPARQL server via HttpSparqlEndpoint against an engine that enforces query budgets.
  • DESCRIBE is intentionally not in the IR.
  • SPARQL Update is intentionally not in the IR and is rejected by the raw-SPARQL pre-flight when raw mode is enabled.
  • Production claims. This server has not been deployed under production load by the authors. CI covers static checks, the import stress matrix (Python 3.11–3.13 × 11 hash seeds), tests, and the deterministic eval. Real-world load testing, multi-tenant authn, and billing/quota are out of scope.

About

A MCP server that lets an LLM query an RDF graph database through a strict validated `QueryPlan` IR

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages