Skip to content

Per-user MCP API key authentication #98

@TomMaSS

Description

@TomMaSS

Goal

Add per-user API key authentication to the MCP HTTP transport. Each authenticated user generates personal API keys through the UI. MCP requests authenticated with a user key are attributed to that user in revisions, audit events, and MCP activity logs.

Current State

  • mcp_server/server.py — 1596 lines, FastMCP with 31 tools, zero auth on HTTP transport
  • MCP HTTP endpoint: POST /mcp/ (streamable-http)
  • Stdio transport: local subprocess (no auth needed)
  • All MCP writes currently record user_id = NULL / created_by = NULL
  • Attribution columns: section_revisions.created_by, section_comments.created_by, chat_messages.created_by, mcp_activity.user_id, audit_events.user_id

Critical Constraints

  1. Do NOT rewrite or refactor the 31 MCP tools. Auth added at transport/middleware layer only.
  2. Stdio transport remains unauthenticated (local subprocess, OS-authenticated).
  3. Backward compatible. MCP_AUTH_REQUIRED=false (default) preserves current behavior.
  4. Idempotent migrationsIF NOT EXISTS, DO $$ BEGIN ... END $$.

Part 1: Database Schema

Create db/14_mcp_api_keys.sql:

CREATE TABLE IF NOT EXISTS mcp_api_keys (
    id            UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id       TEXT        NOT NULL,
    key_hash      TEXT        NOT NULL,     -- SHA-256 hex digest
    key_prefix    TEXT        NOT NULL,     -- first 8 chars: "sk-prd-a1b2c3d4..."
    name          TEXT        NOT NULL DEFAULT 'Default',
    last_used_at  TIMESTAMPTZ,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at    TIMESTAMPTZ              -- soft delete
);
CREATE INDEX IF NOT EXISTS idx_mcp_api_keys_hash ON mcp_api_keys(key_hash) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mcp_api_keys_user ON mcp_api_keys(user_id);

Key format: sk-prd-{32 random hex chars} (e.g., sk-prd-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6). Only SHA-256 hash stored in DB. Full key returned once at creation.

Part 2: MCP Server Auth Middleware

Env vars: MCP_AUTH_REQUIRED (bool, default false), MCP_API_KEY (optional master key fallback)

Auth logic: Starlette middleware or FastMCP handler wrapper on /mcp/ POST:

  1. Extract Authorization: Bearer <key> header
  2. If missing + auth required -> 401 JSON-RPC error
  3. Check master key (MCP_API_KEY env) -> authenticated, no user attribution
  4. Hash key with SHA-256, look up in mcp_api_keys where revoked_at IS NULL
  5. If found -> authenticated with user_id; update last_used_at
  6. If not found -> 401

User propagation via contextvars:

mcp_request_user_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
    "mcp_request_user_id", default=None
)

Set in middleware after auth. In tool INSERT statements, replace hardcoded NULL with mcp_request_user_id.get(None) for: section_revisions.created_by, section_comments.created_by, comment_replies author, mcp_activity.user_id, audit_events.user_id.

Stdio transport: contextvar stays at default None — current behavior preserved.

Part 3: API Endpoints for Key Management

Method Path Auth Description
POST /api/mcp-keys user Generate key (returns full key once). Max 5 active per user
GET /api/mcp-keys user List current user keys (prefix only)
DELETE /api/mcp-keys/{key_id} user Revoke own key (soft delete)
GET /api/admin/mcp-keys admin List all org keys with user info
DELETE /api/admin/mcp-keys/{key_id} admin Revoke any key

Part 4: Frontend UI

frontend/src/components/mcp-key-manager.tsx:

  • "MCP API Keys" section in user settings (or new /app/settings page)
  • Key list table: Name, Key prefix, Last Used, Created, Status, Revoke button
  • "+ Generate New Key" dialog: name input -> generate -> show full key with copy button + warning "Copy now, shown once"
  • Revoke confirmation dialog (destructive)
  • Admin view: additional "All Organization Keys" section with User column

Types in frontend/src/lib/types.ts:

export interface McpApiKey {
  id: string;
  key_prefix: string;
  name: string;
  last_used_at: string | null;
  created_at: string;
  is_active: boolean;
}
export interface McpApiKeyCreateResponse {
  id: string;
  key: string;
  key_prefix: string;
  name: string;
  created_at: string;
}

Part 5: Configuration and Docs

  • Update README: MCP Authentication section with Claude Code config example
  • Update docker-compose.yml: add MCP_AUTH_REQUIRED, MCP_API_KEY to mcp-server env
  • Update .env.example

Part 6: Tests

API tests (tests/test_mcp_keys.py): generate key, list keys, revoke key, max 5 limit, revoked key frees slot

MCP auth tests (tests/test_mcp_auth.py): no auth when required -> 401, valid key passes, revoked key rejected, invalid key rejected, user attribution with key, no-auth mode allows anonymous, last_used_at updated

Validation Criteria

  • Dev mode (MCP_AUTH_REQUIRED=false): identical to current behavior, all existing tests pass
  • Auth mode (MCP_AUTH_REQUIRED=true): 401 without valid Bearer header
  • Valid per-user key authenticates and resolves to user_id
  • Section revisions via authenticated MCP have created_by set
  • Key lifecycle: generate -> use -> revoke works end-to-end
  • Master key fallback (MCP_API_KEY env) works without user attribution
  • Stdio transport completely unaffected
  • UI: generate, view, copy, revoke keys
  • Max 5 active keys per user enforced
  • Full key returned only at creation; DB stores SHA-256 hash only

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:authAuthentication, RBAC, members, Better Autharea:frontendNext.js dashboard, UI components, shadcn/uiarea:mcpMCP server, 32 tools, FastMCPtype:featureNew feature

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions