Skip to content

MCP tools

The Model Context Protocol server, built into core

Telha Core ships a Model Context Protocol server in-process alongside REST and gRPC: eight tools an agent can call directly, each scoped to the tenant its session authenticated as, with results shaped for a language model's context window rather than a database client's.

Normative sources

.ai/specs/core-engine/2026-07-02-mcp-tools.md, the pinned tool schemas at proto/mcp/tools.json, and the implementation in core-engine/src/mcp/server.rs + core-engine/src/mcp/shape.rs.

Transport and session model

  • Transport: rmcp, streamable HTTP, served in-process next to REST/gRPC (no separate binary).
  • Auth: the same signed-token scheme as gRPC (see gRPC API › Auth), audience mcp. mcp.enabled gates the listener, and it stays down until grpc.token_key is configured, one keyset to rotate, audience separation provides surface isolation.
  • Session binding: the session binds one TenantScope at initialize, via a OnceLock on the per-session handler. Every subsequent tool call inherits that scope. A mid-session request carrying a valid token for a different tenant is rejected with invalid_request, tools accept no tenant parameters, ever.
  • RBAC: which authenticated user may invoke which tool is an app-layer concern (deny-by-default: viewer gets read tools, editor adds createRecord, admin gets all), not enforced by core. Core enforces tenancy only, defense in depth, one wall per responsibility.

Configuration

Key Default Meaning
mcp.enabled , Turns the listener on
mcp.addr , Bind address
mcp.max_rows 50 Row cap applied to every tool's results
mcp.max_field_chars 500 Per-field text truncation cap
mcp.specs_resource on Whether the specs:// resource is exposed
mcp.specs_dir ./.ai/specs Filesystem root for the specs:// resource, when the binary's working directory isn't the repo root

Tool inventory

Tool Wraps Notes
createRecord Records create One record per call, with optional relationships
findRecords Query DSL subset No cursor; limit clamped to mcp.max_rows; expand depth ≤ 2
getHistory Version history Newest first, includes tombstones
getSchema schema_as_of Optionally as of a past transaction time
semanticSearch Vector query Requires label; owns vector search (findRecords does not accept vector)
compareSnapshots Delta engine Flat coordinate params; same executor as POST /v1/compare
generate Plan → generate → verify Draft, citations, per-claim verdicts, traceId
getTrace Trace fetch Claim-grouped shaping for generation traces

All eight are registered and active; this list is snapshot-tested against proto/mcp/tools.json (regenerate with UPDATE_MCP_SCHEMAS=1 cargo test -p telha-core --lib mcp and review the diff, schema evolution must be additive).

Result shaping

Oversized results blow an LLM's context window, so shaping is a server-side responsibility, not a client courtesy. Every tool returns one text block of compact JSON in a uniform envelope:

Envelope shape
{
  "returned": 12,
  "moreAvailable": true,
  "truncatedFields": ["/records/0/properties/notes"],
  "hint": "long text fields were truncated at the field-character cap; fetch complete records via REST GET /v1/records/{id} or narrow the query",
  "records": [ ]
}
  • returned and moreAvailable are always present, so the model always knows what was cut, even when nothing was.
  • truncatedFields (JSON-pointer-ish paths) and hint appear only when something was actually truncated or capped.
  • Per-field text truncation happens at mcp.max_field_chars, char-boundary safe, with a suffix.
  • The full-fetch hint names GET /v1/records/{id} (REST) as the escape hatch for anything shaped away.

There is deliberately no pagination on findRecords, getHistory, getSchema, semanticSearch, or compareSnapshots, narrow the query instead of paging. This is a stance, not an oversight (spec §3: unbounded results with client-side truncation would push a server responsibility onto an LLM client).

Errors

Tenancy failures return MCP errors without partial data. Validation failures on the query-DSL subset echo the query-language pointer format so the model can self-correct:

A findRecords tool-level error
{ "error": "query_invalid", "message": "/expand/0/depth: depth over MCP is limited to 1..=2" }

Other tool-level error codes seen across the suite: not_found, tombstoned, type_collision, invalid_property, invalid_interval, invalid_direction, unimplemented, compare_invalid, generation_unconfigured, model_not_allowed, embedding_unconfigured, unknown_model, embedding_failed, insufficient_evidence, tenant_budget_exceeded, provider_failed, plan_failed.

createRecord

Create one record (a node) with labels, properties, an optional valid-time interval, and optional relationships to existing records.

Field Type Required
labels array of string yes (at least one)
properties object no
validTime {start, end?} (µs) no (omit for "valid from now, open-ended")
relationships array of {type, node, direction?, properties?} no
{
  "labels": ["RISK"],
  "properties": { "title": "Supplier delay", "severity": 7 },
  "relationships": [{ "type": "OWNED_BY", "node": "3333...-..." }]
}
{
  "returned": 1, "moreAvailable": false,
  "records": [
    { "id": "...", "labels": ["RISK"], "properties": { "title": "Supplier delay", "severity": 7 },
      "validTime": {}, "txTime": {}, "tombstone": false, "relationships": ["<edge-id>"] }
  ]
}

direction on a relationship is "out" (default: new record → node) or "in" (node → new record).

findRecords

Find records by label with optional property predicates, bitemporal coordinates, and graph expansion up to depth 2. Results are capped and long fields truncated; no pagination.

Field Type Required Notes
find string yes Label, e.g. "Contract"
where object no Operator objects, e.g. {"status": {"$eq": "active"}, "total": {"$gte": 100}}
atValidTime µs or RFC3339 no Defaults to now
atTxTime µs or RFC3339 no Defaults to now
expand edge-type strings or {type, direction, depth} objects no depth ≤ 2 (the full DSL allows 3)
limit integer no Server-capped at mcp.max_rows
{ "find": "RISK", "where": { "severity": { "$gte": 7 } }, "expand": ["OWNED_BY"], "limit": 20 }
{
  "returned": 3, "moreAvailable": false,
  "records": [
    { "id": "...", "labels": ["RISK"], "properties": { "severity": 8 }, "validTime": {}, "txTime": {}, "tombstone": false }
  ]
}

When expand produces related nodes, they ride inside the same envelope as an extra row shaped {"related": [{"expandIndex": 0, "partial": false, "nodes": [...]}]}, appended to the records array rather than a separate top-level key, keeping the flat (no-expansion) case flat.

where still requires operator objects, a bare scalar is query_invalid with the same pointer format as REST. cursor is not a parameter here at all: the DSL's cursor is dropped entirely on this tool, not merely capped.

getHistory

Full version history of one record: every valid-time and transaction-time version, newest first, including tombstones.

Field Type Required
id UUID yes
limit integer no (server-capped at mcp.max_rows)
{ "id": "3333...-...", "limit": 20 }
{ "returned": 4, "moreAvailable": false,
  "versions": [ { "id": "...", "labels": ["RISK"], "properties": {}, "validTime": {}, "txTime": {}, "tombstone": false } ] }

not_found if the record has zero versions.

getSchema

Inferred schema per label: property names, observed types, and counts, optionally as of a past transaction time.

Field Type Required
label string no (omit for all labels)
at µs integer no (omit for latest)
{ "label": "RISK" }
{ "returned": 1, "moreAvailable": false,
  "schemas": [ { "label": "RISK", "properties": { "severity": { "type": "Int", "count": 42 } } } ] }

semanticSearch

Semantic (vector) search within a label: supply query text or a pre-computed vector plus the embedding model id. Owns vector search, use findRecords for structured queries (spec OQ-1: one tool per intent, to keep model tool-selection unambiguous).

Field Type Required Notes
label string yes Label to search within
text string exactly one of text/near Server-side embedded
near array of float exactly one of text/near Pre-computed vector
model string yes Embedding model id
k integer no Server-capped at mcp.max_rows
minScore float no Similarity floor
{ "label": "RISK", "text": "supply chain delays", "model": "text-embedding-3-small", "k": 10 }
{ "returned": 5, "moreAvailable": false,
  "records": [ { "id": "...", "labels": ["RISK"], "properties": {}, "validTime": {}, "txTime": {}, "tombstone": false, "score": 0.83 } ] }

If the executor has no configured embedding endpoint, the tool relays the executor's Unimplemented as an unimplemented tool error naming the activating condition, with a hint to use findRecords for structured queries in the meantime.

compareSnapshots

Compare the graph between two bitemporal coordinates: what was added, removed (tombstone vs. validity-lapse tagged), and modified (per-property old → new). Valid times are the world times to compare; transaction times ("as known at") default to now.

Field Type Required Notes
baselineValidTime µs or RFC3339 yes Baseline world time B
baselineTxTime µs or RFC3339 no Defaults to now
comparisonValidTime µs or RFC3339 yes Comparison world time C
comparisonTxTime µs or RFC3339 no Defaults to now
find string no Restrict to one label; with a filter set, edge deltas are skipped
where object no Operator objects, applies to the comparison-side state
limit integer no Server-capped at mcp.max_rows
{
  "baselineValidTime": "2026-01-01T00:00:00Z",
  "comparisonValidTime": "2026-04-01T00:00:00Z",
  "find": "RISK"
}
{
  "counts": { "added": 2, "removed": 1, "modified": 3, "unchanged": 10 },
  "added": [ { "kind": "node", "id": "...", "labels": ["RISK"], "properties": {} } ],
  "removed": [ { "kind": "node", "id": "...", "labels": ["RISK"], "reason": "tombstone" } ],
  "modified": [ { "kind": "node", "id": "...", "labels": ["RISK"], "versions": {}, "props": {} } ],
  "moreAvailable": false
}

Output is tabular, not row-list: three shaped sections (added/removed/modified) each carrying field caps, plus counts up front and moreAvailable with a paginate-or-narrow hint when the delta itself was capped (the tool exposes no cursor, matching findRecords' posture, page via POST /v1/compare with the returned cursor instead).

generate

Generate a grounded, claim-verified answer from records: plan evidence (structured find + optional semantic recall), draft with [S#] citations, verify each claim against its source spans, and persist an auditable trace.

Field Type Required Notes
question string yes The question to answer from the tenant's memory
find string no Structured recall label, e.g. "Contract"
where object no Operator objects, for find
semanticModel string no Registered embedding model; question text embedded server-side
model string no Generation model override; must be operator-allowlisted
allowUngrounded boolean no Proceed with zero evidence instead of failing; default false
{ "question": "Which open risks have severity 5 or higher?", "find": "RISK", "where": { "status": { "$eq": "open" } } }
{
  "draft": "...", "traceId": "0197...", "model": "claude-sonnet-4-5",
  "planHash": "...", "citations": [ ],
  "verification": { "status": "verified", "claims": [ ] },
  "usage": { "inputTokens": 900, "outputTokens": 210 }
}

The draft is delivered unshaped (it is the product, not a database row), only the verdicts and citations ride alongside. insufficient_evidence if recall found nothing and allowUngrounded was not set; tenant_budget_exceeded if the token cap is exhausted; generation_unconfigured if llm.provider is unset.

getTrace

Fetch the execution trace of a previous query or generation: the evidence plan, model lineage, and per-claim verdicts with their supporting span references.

Field Type Required
traceId UUID yes
{ "traceId": "0197..." }
{
  "traceId": "0197...", "kind": "generation", "verification": "verified",
  "question": "Which open risks have severity 5 or higher?",
  "draft": "Alpha owes Beta [S1].",
  "models": { "generate": "...", "embed": "...", "decompose": "..." },
  "counts": { "claims": 2, "supported": 1, "partial": 0, "unsupported": 1 },
  "claims": [
    {
      "text": "Alpha owes Beta.", "verdict": "supported", "score": 0.91, "citedMarkers": ["S1"],
      "evidence": { "sourceId": "...", "sourceVersionTx": 42, "start": 0, "end": 9, "excerpt": "Alpha owes" }
    },
    { "text": "The moon is cheese.", "verdict": "unsupported", "score": 0.05, "citedMarkers": [], "evidence": null }
  ],
  "planSummary": { "spans": 1, "budget": 4096, "strandedBudget": 7 }
}

Generation traces get this claim-grouped presentation (evidence excerpts resolved server-side via the ingestion slice API; field caps applied to the whole body). Query traces (non-generation) keep the generic passthrough envelope: {"returned": 1, "moreAvailable": false, "trace": [{ ... }]}. not_found if no trace exists for this tenant.

The specs:// resource

When mcp.specs_resource is on, every .ai/specs/**/*.md file under mcp.specs_dir is exposed read-only as an MCP resource named specs://<relative/path.md>. This lets the system (and any MCP client, including the app layer's AI assistant) read its own design manuals, the listing itself is the whitelist; there is no path arithmetic on client-supplied URIs.

specs://core-engine/2026-07-02-query-language.md
specs://integration/2026-07-02-grpc-contracts.md
  • Query language - the DSL findRecords/semanticSearch compile into, and its ceilings
  • REST API - the REST endpoints each tool wraps
  • gRPC API - the shared signed-token auth scheme (audience mcp here)
  • Generation & traces - the full /v1/generate contract that generate/getTrace mirror
  • Error reference - the error taxonomy tool-level errors echo