Trovella Wiki

MCP Authentication

How MCP requests authenticate via Personal Access Tokens -- the bearer flow, SHA-256 validation, and tenant resolution.

MCP requests authenticate using Personal Access Tokens (PATs) sent as bearer tokens in the Authorization header. This page covers the server-side validation flow. For PAT creation, revocation, hashing, and the security model, see Identity & Access -- Personal Access Tokens.

Authentication Flow

Claude Code (MCP client)
   |
   |  Authorization: Bearer trov_a1b2c3d4...
   v
authenticateRequest(request)               packages/mcp/src/auth.ts
   |
   |  1. Extract Authorization header
   |  2. Verify "Bearer " prefix
   |  3. Verify "trov_" token prefix
   |  4. SHA-256 hash the raw token
   |  5. Query personal_access_token + user tables
   |  6. Check revokedAt is null
   |  7. Check expiresAt is in the future
   |  8. Fire-and-forget: update lastUsedAt
   |
   v
McpAuthContext { userId, userName, userEmail }
   |
   v
resolveOrganizationId(userId)              packages/mcp/src/resolve-org.ts
   |
   |  Query member table for first org
   |
   v
organizationId
   |
   v
withTenantContext(orgId, userId, handler)   Every tool callback

The McpAuthContext Type

The authentication step produces a context object passed to every tool registration function:

interface McpAuthContext {
  userId: string;
  userName: string;
  userEmail: string;
}

This context is not the same as a tRPC context. MCP tools do not go through the tRPC procedure chain. Instead, each tool callback manually calls resolveOrganizationId() and withTenantContext() to establish tenant scope.

Token Validation Details

The authenticateRequest() function in packages/mcp/src/auth.ts performs these checks in order:

  1. Header present -- the Authorization header must exist and start with Bearer
  2. Token prefix -- the raw token must start with trov_ (rejects tokens from other systems)
  3. Hash lookup -- the raw token is SHA-256 hashed using crypto.subtle.digest, then looked up in the personal_access_token table joined to user
  4. Not revoked -- the query filters for revoked_at IS NULL
  5. Not expired -- if expiresAt is set, it must be in the future
  6. Last-used update -- a fire-and-forget UPDATE sets lastUsedAt to the current timestamp (does not block the response)

If any check fails, authenticateRequest() returns null, and the server responds with HTTP 401:

{ "error": "Unauthorized" }

Organization Resolution

After authentication identifies the user, resolveOrganizationId() determines which tenant to scope database operations to. For MVP, this returns the user's first organization membership:

const [row] = await db
  .select({ organizationId: member.organizationId })
  .from(member)
  .where(eq(member.userId, userId))
  .limit(1);

This query uses the bare db client (not withTenantContext) because the member table lookup happens before tenant scope is established. See Data & Storage -- Non-Tenant Queries for why certain queries bypass RLS.

Per-Request Server Model

Unlike typical MCP servers that maintain long-lived connections, Trovella creates a new McpServer instance per HTTP request:

const server = createMcpServer(auth);
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined, // No session tracking
  enableJsonResponse: true,
});

await server.connect(transport);
const response = await transport.handleRequest(request);
await transport.close();
await server.close();

Setting sessionIdGenerator: undefined disables the SDK's built-in session management. All state lives in the database, not in server memory.

Why PATs Instead of Sessions

MCP clients like Claude Code operate outside the browser. They cannot participate in the cookie-based session flow that the web application uses. PATs provide a simpler alternative:

  • No redirect-based OAuth flow required
  • Token is configured once in the MCP client settings
  • Same user identity as browser sessions (same user table row)
  • Tenant resolution works identically after authentication

For the long-term plan to support OAuth 2.1 (for Claude Web/Chat/Work integration), see Identity & Access -- Personal Access Tokens.

On this page