Build an MCP agent¶
Tutorial · 4 of 5
Telha Core ships a Model Context Protocol server in-process: eight tools an agent can call directly, results shaped for a language model's context window. In this tutorial you mint an MCP session token, connect to the server, and drive a minimal loop: find records, generate a verified answer, then fetch its trace.
You will need
- A running server with
mcp.enabledandgrpc.token_keyconfigured -
grpc.token_key(64 hex characters, at least one letter) - An
[llm]provider configured, for thegeneratestep (quickstart step 7) - Records already ingested, reuse the RISK records from Ingest and ask a verified question if you have them, or ingest fresh ones in step 2
Scenario¶
You are wiring an agent (an LLM with tool access) into Telha's memory layer. Rather than hand it raw REST calls, you give it the purpose-built MCP tool surface: findRecords to look things up, generate to answer questions with citations, getTrace to audit what it just said.
1. Mint an MCP session token¶
MCP auth uses the same signed-token scheme as gRPC, audience mcp. Mint one with the operator CLI:
Tokens expire fast
Signed tokens are TTL 300 seconds. For anything longer than a quick manual session, re-mint before each connection rather than caching one, exactly like the connector harness's TELHA_WORKER_TOKEN_CMD pattern described in Connectors.
2. Make sure there is something to find¶
If you have not already ingested the supplier-risk records from the first tutorial, do that now:
curl -sS -X POST http://127.0.0.1:7625/v1/ingest \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{
"format": "json",
"name": "supplier-risks-2026-07.json",
"content": "[{\"label\": \"RISK\", \"title\": \"Supplier delay: Acme Fasteners\", \"severity\": 8, \"status\": \"open\", \"supplier\": \"Acme Fasteners\"}, {\"label\": \"RISK\", \"title\": \"Single-source dependency: Globex Resin\", \"severity\": 7, \"status\": \"open\", \"supplier\": \"Globex Resin\"}]"
}'
3. Connect and call findRecords¶
The MCP server speaks rmcp over streamable HTTP, in-process next to REST/gRPC. Using the TypeScript SDK's token helper to keep the example self-contained:
Once connected, the session binds to one tenant scope at initialize, every subsequent tool call inherits it, no tenant parameters ride on individual calls. Call findRecords:
{
"returned": 2, "moreAvailable": false,
"records": [
{ "id": "9c11...", "labels": ["RISK"], "properties": { "title": "Supplier delay: Acme Fasteners", "severity": 8, "status": "open" }, "validTime": {}, "txTime": {}, "tombstone": false },
{ "id": "a2f0...", "labels": ["RISK"], "properties": { "title": "Single-source dependency: Globex Resin", "severity": 7, "status": "open" }, "validTime": {}, "txTime": {}, "tombstone": false }
]
}
Checkpoint
returned: 2, moreAvailable: false. Every tool result carries returned and moreAvailable, so the agent always knows whether it saw the full picture, and truncatedFields plus a hint appear only when something was actually capped. findRecords clamps expand depth to 2 (the full DSL allows 3) and drops cursor entirely, narrow the query instead of paging, see MCP tools.
4. Call generate¶
Feed the same structured filter into generate to get a grounded, cited answer:
{
"draft": "Two open risks have severity 5 or higher: the Acme Fasteners supplier delay (severity 8) and the Globex Resin single-source dependency (severity 7).",
"traceId": "0197f2a1-...",
"model": "claude-sonnet-4-5",
"planHash": "b3:7f2a...",
"citations": [ {"marker": "S1", "sourceId": "...", "start": 0, "end": 118} ],
"verification": { "status": "verified", "claims": [ { "text": "...", "verdict": "supported", "score": 0.93 } ] },
"usage": { "inputTokens": 540, "outputTokens": 62 }
}
The draft rides back unshaped, it's the product, not a database row, only the verdicts and citations ride alongside it. If recall finds nothing, the tool returns an insufficient_evidence error unless the call sets allowUngrounded: true; if the tenant's token budget is exhausted, tenant_budget_exceeded; if [llm] is unset on the server, generation_unconfigured.
5. Call getTrace¶
Take the traceId from the previous call and pull the claim-grouped trace:
{
"traceId": "0197f2a1-...", "kind": "generation", "verification": "verified",
"question": "Which open risks have severity 5 or higher?",
"draft": "Two open risks have severity 5 or higher: ...",
"models": { "generate": "claude-sonnet-4-5", "embed": "...", "decompose": "claude-sonnet-4-5" },
"counts": { "claims": 2, "supported": 2, "partial": 0, "unsupported": 0 },
"claims": [
{ "text": "The Acme Fasteners supplier delay has severity 8.", "verdict": "supported", "score": 0.93, "citedMarkers": ["S1"],
"evidence": { "sourceId": "...", "sourceVersionTx": 42, "start": 0, "end": 118, "excerpt": "Supplier delay: Acme..." } }
],
"planSummary": { "spans": 2, "budget": 6000, "strandedBudget": 0 }
}
This is getTrace's claim-grouped shaping: each claim's evidence is resolved server-side down to an excerpt, so an agent (or a human reviewing its work) can see exactly which bytes backed each sentence without a second round trip.
6. The minimal loop, end to end¶
sequenceDiagram
autonumber
participant Agent as Agent
participant MCP as Telha MCP server
Agent->>MCP: initialize (signed token, audience "mcp")
MCP-->>Agent: session bound to one TenantScope
Agent->>MCP: findRecords {find, where}
MCP-->>Agent: {returned, moreAvailable, records}
Agent->>MCP: generate {question, find, where}
MCP-->>Agent: {draft, citations, verification, traceId}
Agent->>MCP: getTrace {traceId}
MCP-->>Agent: {claims, evidence, planSummary} A pseudocode loop for an agent runtime:
const found = await mcp.call("findRecords", { find: "RISK", where: { severity: { $gte: 5 } } });
if (found.returned === 0) return "No matching risks found.";
const answer = await mcp.call("generate", {
question: "Which open risks have severity 5 or higher?",
find: "RISK",
where: { status: { $eq: "open" } },
});
const trace = await mcp.call("getTrace", { traceId: answer.traceId });
if (trace.counts.unsupported > 0) {
// surface the caveat to the end user instead of presenting the draft as-is
}
RBAC, briefly¶
Core enforces tenancy only: a mid-session request carrying a valid token for a different tenant is rejected outright, and no tool accepts a tenant parameter. Which authenticated user may invoke which tool is an app-layer concern, deny-by-default in practice: a viewer role typically gets the read tools (findRecords, getHistory, getSchema, semanticSearch, compareSnapshots, getTrace), an editor role adds createRecord, and an admin role gets everything, including generate. This mirrors defense in depth: core owns tenant isolation, the app layer owns the allowlist, one wall per responsibility. See MCP tools › Transport and session model for the enforcement detail.
What you learned¶
- How to mint a short-lived MCP session token (audience
mcp) and why it binds to one tenant for the whole session - How to call
findRecords,generate, andgetTraceand read their envelope shapes - Why every MCP result carries
returned/moreAvailableand, when truncated,truncatedFieldsand ahint - That RBAC (which tool a given user may call) is an app-layer concern layered on top of core's tenancy enforcement
Related¶
- MCP tools - the full eight-tool reference, ceilings, and error taxonomy
- TypeScript SDK -
signTenantToken, transports, and typed errors - Generation & traces - the
/v1/generatecontractgenerate/getTracemirror - Ingest and ask a verified question - the REST-only version of this same loop
- Connect a source system - the next tutorial