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.
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/ |
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
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 arbitrarySERVICE— without parsing untrusted text; - catch semantic errors deterministically — unbound variables, wrong
HAVINGshape,BINDrebinds, 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.
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 |
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| 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.
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). |
| 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:labelandskos: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.
# stdio (recommended for MCP hosts like Claude Code)
python -m graph_mcp.server
# http transport
python -m graph_mcp.server --transport streamable-httpAdd 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"
}
}
}
}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 100The 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.
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.
# 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.
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 |
# Populate the Qdrant collection that the MCP tool reads.
ontology-vectorizer ingest --input ocean_demo.ttl --ontology-id ocean-demoRequest:
{
"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.
| 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. |
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-checkThe Makefile targets make test, make lint, make typecheck, make all
are convenience wrappers; if you don't have make, run the commands above
directly.
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-6The 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- Add the function name to
ALLOWED_FUNCTIONSingraph_mcp/models/expressions.py. - Update the renderer if it requires a non-default rendering shape.
- Add a test in
tests/test_renderer.py.
- Add the model in
graph_mcp/models/patterns.pyand to thePatternunion. - Update
QueryPlanValidator._validate_patternto handle scope/safety. - Update
SparqlRenderer._render_patternto emit it. - Update
_vars_in_patternin the validator if needed. - Add tests for both validator and renderer.
Inject a richer SchemaProvider into build_server:
from graph_mcp.graph.schema_discovery import SchemaSnapshot, StaticSchemaProvider
schema = StaticSchemaProvider(SchemaSnapshot(...))
server = build_server(schema=schema)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.
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/ADDare rejected via token-level matching (catchesINSERT\nDATA,INSERT\tDATA,Insert\ndata);WITH … DELETEis rejected (WITHitself is treated as forbidden);DESCRIBEis rejected;SERVICE <iri>is permitted only when the exact IRI matches the allowlist;SERVICE ?varandSERVICE prefix:nameare rejected;- the actual query form is inferred from the first query keyword in the
token stream and must match
expected_query_type; - raw
SELECTandCONSTRUCTqueries must include an explicit top-levelLIMITno greater than the effectivemax_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.
- 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 ?gis rejected unless?gis constrained by a siblingVALUESto 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.
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 toeffective_max_rows; - if the plan's
LIMITis greater thaneffective_max_rows, it is capped; - if the plan's
LIMITis 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.
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
DeterministicPlannerinevals/agent.pyis aligned with the bundledgolden_cases.yamlkeywords; its 100 % score on that file is not evidence of LLM planning quality. Theevals/golden_cases_adversarial.yamlfile 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_termsMCP tool directly. The evals agent inevals/agent.pyis 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/CONSTRUCTmust include an explicit top-levelLIMIT; the server will reject otherwise. - Remote
CONSTRUCT. The HTTP endpoint asks fortext/turtle/application/n-triples/application/rdf+xmland parses the response via rdflib. Endpoints that ignore theAcceptheader and return JSON or HTML produce anEndpointError("unsupported CONSTRUCT response content-type")— never a silent empty result. - Schema discovery is best-effort.
SparqlSchemaProviderrecords per-section errors (timeouts, unsupported features) asSchemaDiagnosticentries on the snapshot rather than raising. Inspect them viagraph://schema/status. - Local timeout.
LocalRdflibEndpointruns queries in a worker thread underasyncio.wait_for. The timeout fires and the caller seesEndpointError, 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 viaHttpSparqlEndpointagainst an engine that enforces query budgets. DESCRIBEis 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.