ποΈ 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
TelliaClientinstance held for the lifetime of the process. tellia_act_asmutates that single client'sactAsUserId.- 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}(default3889). - Mounts
POST/GET/DELETE /mcpper the Streamable HTTP spec. - Each MCP client opens an
initializerequest without a session id β the server generates one (randomUUID) and returns it via theMcp-Session-Idresponse header. Subsequent requests carry that header. - One
TelliaClient + McpServer + transporttriplet per session id. Sessions are evicted after 30 min idle. - Reviewers' Claude Desktop connects via the
mcp-remotestdioβ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:
- 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. - 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):
- Skips if header absent.
- Refuses if
apiKeyMeta.isSuperAdmin !== true(only super-admin keys may impersonate). - Validates the target user id (
Types.ObjectId.isValid). - Loads the user with
userService.adminFindOne(id)β memberships populated, identical shape to a normal session'suserProfile. - Stashes the original principal (
req.impersonation.actorUserId) and swapsreq.userProfileto 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:
// 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:
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.commatches one subdomain label. Empty/unset = browser origins denied.MCP_CORS_ALLOW_LOCALHOSTβtrueallowslocalhost/127.0.0.1/::1regardless ofMCP_CORS_ORIGINS. Defaulttrue. Setfalsein 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:
@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 β
pnpm install
pnpm mcp # tsc --watch + node --watch dist/main.js, reads apps/agri-mcp/.envapps/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):
[[proxies]]
name = "vincent-mcp"
type = "http"
localPort = 3889
subdomain = "vincent-mcp"Railway β
apps/agri-mcp/railway.json builds via Railpack:
pnpm --filter @tellia-solutions/schemas build
pnpm --filter @tellia-solutions/mcp buildStart: pnpm --filter @tellia-solutions/mcp start β node dist/main.js.
Required env vars on the Railway service:
| Env | Value |
|---|---|
TELLIA_API_URL | https://<staging-backend> |
MCP_CORS_ORIGINS | *.tell-ia.com,*.up.railway.app,claude.ai |
MCP_CORS_ALLOW_LOCALHOST | false |
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 watchdoesn't emit decorator metadata. NestJS DI silently fails (this.mcpisundefined). Use the providedpnpm mcpscript (tsc --watch+node --watch dist/main.js).- Two frpc processes can compete for the same subdomain.
pkill -f frpc && pnpm tunnelifvincent-mcpreturns the frp 404 page. - Claude Desktop GUI Connector insists on OAuth for HTTPS URLs. Bypass β edit
claude_desktop_config.jsondirectly with themcp-remoteshim. refIdmust beinternalReferenceId, never_id.describe_workspacesurfaces this requirement; ignoring it produces references that break on re-import.