Skip to content

πŸ—οΈ How the MCP works ​

Architecture of apps/agri-mcp β€” transports, authentication, impersonation, session state, deployment.

See setup & usage for the reviewer-facing guide. This page is for engineers extending or debugging the MCP layer.

Two transports, one tool surface ​

apps/agri-mcp ships both an MCP stdio binary and an HTTP server. Every tool is registered once in src/tool-registry.ts and reused by both entry points β€” there's no duplication of tool code.

stdio (src/index.ts) ​

  • Reviewer (or Claude Code) spawns node dist/index.js.
  • API key + backend URL come from process env (TELLIA_API_KEY, TELLIA_API_URL).
  • One TelliaClient instance held for the lifetime of the process.
  • tellia_act_as mutates that single client's actAsUserId.
  • Good for local dev (Claude Code, scripts). Doesn't scale to multiple users.

HTTP (src/main.ts + src/mcp.controller.ts + src/mcp.service.ts) ​

  • A NestJS application on :${PORT} (default 3889).
  • Mounts POST/GET/DELETE /mcp per the Streamable HTTP spec.
  • Each MCP client opens an initialize request without a session id β€” the server generates one (randomUUID) and returns it via the Mcp-Session-Id response header. Subsequent requests carry that header.
  • One TelliaClient + McpServer + transport triplet per session id. Sessions are evicted after 30 min idle.
  • Reviewers' Claude Desktop connects via the mcp-remote stdioβ†’HTTP bridge β€” Desktop doesn't natively speak Streamable HTTP yet.

Authentication ​

There is no OAuth. The MCP layer is a thin shell around the backend's existing x-api-key model.

stdio ​

The api-key is read from process.env.TELLIA_API_KEY at startup and attached by TelliaClient to every backend request.

HTTP ​

Each MCP client request carries x-api-key: tia_… in HTTP headers (configured in Claude Desktop's claude_desktop_config.json under headers). On the first request of a session (initialize), the controller binds that key to the session β€” it constructs the per-session TelliaClient(apiKey). Every subsequent request on the same Mcp-Session-Id is checked: the header must hash-match the bound key, or the session is rejected with 401.

This prevents an attacker who sniffs a Mcp-Session-Id from hijacking it without also knowing the original api-key.

The raw api-key is never persisted or logged β€” only an in-memory length:rolling-hash fingerprint is kept on the session object for the comparison.

Impersonation ​

Super-admin keys have memberships: [], so they can't write into a tenant without spoofing identity. Instead of letting the super-admin author records as itself in a foreign company, the MCP supports impersonation β€” the super-admin acts as a specific company user for the duration of a session.

Why not just bypass company scope for super-admin? ​

Two problems with bypassing:

  1. Author leak β€” writes would be stamped userId = super-admin-service-user, which belongs to no real company. Company C's logs would show a stranger as the author.
  2. No audit value β€” losing the company-user attribution means we can't show "user X validated this record" in the call-review UI.

Impersonation re-uses the entire company-scoping machinery: the impersonated user has real memberships, so buildAccessQuery and CompanyAwareService scope correctly, and userId on every authored document is a real company member.

How it works ​

The MCP server attaches x-mcp-act-as: <userId> on every backend request when the session has called tellia_act_as. The backend's McpImpersonationGuard (apps/agri-backend/src/mcp/mcp-impersonation.guard.ts):

  1. Skips if header absent.
  2. Refuses if apiKeyMeta.isSuperAdmin !== true (only super-admin keys may impersonate).
  3. Validates the target user id (Types.ObjectId.isValid).
  4. Loads the user with userService.adminFindOne(id) β€” memberships populated, identical shape to a normal session's userProfile.
  5. Stashes the original principal (req.impersonation.actorUserId) and swaps req.userProfile to the impersonated user.

From the controller down, nothing else changes. Same company scoping, same authorship.

Audit trail (dual attribution) ​

The api-key audit interceptor records both identities so we never lose the actor:

ts
// apps/agri-backend/src/api-keys/api-key-audit.interceptor.ts
{
  userId: req.impersonation?.actorUserId ?? req.userProfile?._id,  // the key's bound user
  impersonatedUserId: req.impersonation?.impersonatedUserId,        // the effective user
  apiKeyId, apiKeyName, isSuperAdmin,                                // the key
  method, path, statusCode, durationMs,
}

Equivalent to RFC 8693's actor / subject split β€” the action is "company user X did Y, on behalf of super-admin key Z".

Session lifecycle (HTTP only) ​

Sessions are in-memory only. A pod restart drops all sessions; clients re-initialize on the next request. Acceptable trade-off for the small expected concurrent-user count of the staging deployment. Persistent session storage (Redis) is a future option if we need horizontal scale.

Tool registry ​

src/tool-registry.ts defines every tool once and exposes:

ts
export function registerAllTools(server: McpServer, client: TelliaClient): void;

Both src/index.ts (stdio) and src/mcp.service.ts (HTTP, per session) call this. Adding a tool is one place: define the input schema (Zod) + handler in src/tools/*.ts, then add a defineTool({…}) entry to allTools. No registration changes elsewhere.

Tool handlers receive (client: TelliaClient, input) β€” they're transport-agnostic. Each call invokes client.request(path, { method, body, query }), which attaches x-api-key and optional x-mcp-act-as, then forwards to the backend.

CORS ​

src/main.ts reads two env vars:

  • MCP_CORS_ORIGINS β€” comma-separated hostname patterns. *.tell-ia.com matches one subdomain label. Empty/unset = browser origins denied.
  • MCP_CORS_ALLOW_LOCALHOST β€” true allows localhost / 127.0.0.1 / ::1 regardless of MCP_CORS_ORIGINS. Default true. Set false in production.

Native MCP clients (Claude Desktop via mcp-remote) send no Origin header, so they bypass CORS entirely. The CORS layer only matters for hypothetical browser-based MCP clients.

Backend surface ​

The MCP server proxies to /mcp/* controllers on agri-backend. Every controller has both:

ts
@UseGuards(RequireApiKeyGuard, McpImpersonationGuard)
@ApiKeyAllowed()

So the routes are reachable only by api-key auth, never via Firebase login, and the impersonation guard runs after the global ApiKeyGuard. Domain services (workspaceService, parseResultService, fieldsService, adminDraftsService) are reused unchanged β€” the MCP controllers are just adapters.

The review surface (mcp-drafts.controller.ts + mcp-records.controller.ts:PUT /review) is company-scoped via the impersonated user's memberships β€” a super-admin key without act_as cannot list drafts or push verdicts. This matches Path B in the design discussion: verdicts are authored as the real company user, not as the cross-tenant admin.

Deployment ​

Local ​

bash
pnpm install
pnpm mcp        # tsc --watch + node --watch dist/main.js, reads apps/agri-mcp/.env

apps/agri-mcp/.env (gitignored) carries TELLIA_API_URL, PORT, MCP_CORS_ORIGINS, MCP_CORS_ALLOW_LOCALHOST. Copy from .env.example on first checkout.

For exposing your local MCP to Claude Desktop, add an frpc proxy on the same port (see Dev Tunnels):

toml
[[proxies]]
name = "vincent-mcp"
type = "http"
localPort = 3889
subdomain = "vincent-mcp"

Railway ​

apps/agri-mcp/railway.json builds via Railpack:

bash
pnpm --filter @tellia-solutions/schemas build
pnpm --filter @tellia-solutions/mcp build

Start: pnpm --filter @tellia-solutions/mcp start β†’ node dist/main.js.

Required env vars on the Railway service:

EnvValue
TELLIA_API_URLhttps://<staging-backend>
MCP_CORS_ORIGINS*.tell-ia.com,*.up.railway.app,claude.ai
MCP_CORS_ALLOW_LOCALHOSTfalse
PORT(auto-set by Railway)

The default *.up.railway.app domain works; attach a custom subdomain (e.g. mcp-staging.tell-ia.com) when stabilising.

Gotchas ​

  • tsx watch doesn't emit decorator metadata. NestJS DI silently fails (this.mcp is undefined). Use the provided pnpm mcp script (tsc --watch + node --watch dist/main.js).
  • Two frpc processes can compete for the same subdomain. pkill -f frpc && pnpm tunnel if vincent-mcp returns the frp 404 page.
  • Claude Desktop GUI Connector insists on OAuth for HTTPS URLs. Bypass β€” edit claude_desktop_config.json directly with the mcp-remote shim.
  • refId must be internalReferenceId, never _id. describe_workspace surfaces this requirement; ignoring it produces references that break on re-import.