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.enabledgates the listener, and it stays down untilgrpc.token_keyis configured, one keyset to rotate, audience separation provides surface isolation. - Session binding: the session binds one
TenantScopeatinitialize, via aOnceLockon 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 withinvalid_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:
{
"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": [ ]
}
returnedandmoreAvailableare always present, so the model always knows what was cut, even when nothing was.truncatedFields(JSON-pointer-ish paths) andhintappear 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:
{ "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 |
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 |
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.
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.
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 |
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 |
{
"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 |
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...", "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.
Related¶
- Query language - the DSL
findRecords/semanticSearchcompile into, and its ceilings - REST API - the REST endpoints each tool wraps
- gRPC API - the shared signed-token auth scheme (audience
mcphere) - Generation & traces - the full
/v1/generatecontract thatgenerate/getTracemirror - Error reference - the error taxonomy tool-level errors echo