Skip to content

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.enabled and grpc.token_key configured
  • grpc.token_key (64 hex characters, at least one letter)
  • An [llm] provider configured, for the generate step (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:

telha api-key mcp-token --tenant $TENANT --org $ORG
# a short-lived signed token, TTL 300s
export MCP_TOKEN=<the token above>

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:

import { signTenantToken, AUDIENCE_MCP } from "@telha/sdk";

const mcpToken = signTenantToken(process.env.TELHA_GRPC_TOKEN_KEY!, {
  tenantId: process.env.TENANT!,
  organizationId: process.env.ORG!,
  audience: AUDIENCE_MCP,
});
telha api-key mcp-token --tenant $TENANT --org $ORG

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:

{ "find": "RISK", "where": { "severity": { "$gte": 7 } }, "limit": 20 }
{
  "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:

{ "question": "Which open risks have severity 5 or higher?", "find": "RISK", "where": { "status": { "$eq": "open" } } }
{
  "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-..." }
{
  "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, and getTrace and read their envelope shapes
  • Why every MCP result carries returned/moreAvailable and, when truncated, truncatedFields and a hint
  • That RBAC (which tool a given user may call) is an app-layer concern layered on top of core's tenancy enforcement