ADR-0033: MCP Surface for Neemias
Status: proposed
Date: 2026-07-31
Deciders: @barateza
Tags: [mcp, agents, oauth, architecture, lgpd]
Context
The Model Context Protocol (MCP) 2026-07-28 specification makes remote, stateless MCP servers practical for Cloudflare Workers. Neemias exposes church ministry data (rosters, attendance, roles) that AI agents could consume productively — helping churches answer questions like "how many kids attended in June?" or "who checked in today?" without building custom reporting dashboards for every use case.
The core insight: model the data as MCP resources (read, cacheable, addressable by URI) separately from tools (actions that mutate state), because that split maps cleanly onto Neemias's existing role system and onto what the 2026-07-28 spec optimizes for.
Scope
This ADR covers the MVP MCP surface — 4 resources and 3 tools, deployed as a new module within the existing Worker. Post-MVP expansion to the full API surface and extraction to a separate Worker are deferred but documented as the upgrade path.
Decision
1. Deployment — Same Worker, separate module (Option C)
The MCP server runs inside the existing Worker at workers/src/mcp/, sharing D1 bindings and auth middleware with the REST API. The Host header routes requests: mcp.neemias.app → MCP handler, api.neemias.app → REST handler. This is the pragmatic MVP choice — zero new infrastructure, zero cold-start overhead, full reuse of existing auth and data access.
Post-MVP upgrade path (Option B): Extract to a dedicated Worker at a later date. The MCP module is self-contained — handlers, middleware, and resource/tool definitions live under workers/src/mcp/ with no imports from REST route handlers. Extraction is a mechanical refactor: copy the module into a new Worker, wire D1 and KV bindings, deploy.
2. Transport — Streamable HTTP only
The single endpoint is POST /mcp. Every request is stateless — no protocol sessions, no Mcp-Session-Id, no Durable Objects. The 2026-07-28 spec removed the initialize handshake; each request carries its protocol version and capabilities in _meta.
The deprecated /sse path exists as a compatibility redirect — it serves the same Streamable HTTP handler, not legacy SSE transport. This defuses support tickets from MCP clients (notably n8n) that still default to /sse URLs.
The handler uses Cloudflare's createMcpHandler from agents/mcp/server. The older McpAgent (Durable Objects-based) is deprecated and feature-frozen — we start with the stateless path from day one.
3. Auth — Hybrid OAuth 2.1 → scoped JWT
MCP clients connect via standard OAuth 2.1 (authorization code + PKCE), provided by Cloudflare's workers-oauth-provider library. The OAuth token wraps a Neemias JWT with an added scopes: string[] claim.
Flow:
- MCP client discovers server via
/.well-known/oauth-protected-resource - Authorization code flow + PKCE via
/authorize→/token - Token endpoint issues a Neemias-format JWT (HS256) with
scopesderived fromPERMISSIONS[primaryRole] - Every
POST /mcpauthenticates independently viaAuthorization: Bearer <jwt> requireScope(scope)middleware gates each tool and resource
Why hybrid: Full OAuth 2.1 for the MCP standard surface, but the existing JWT middleware (requireAuth, requireRole, requirePermission) remains unchanged. Scopes are additive — the scopes array is a 2-line addition to sessionTokens.ts, and the already-built requirePermission() middleware enforces them.
Storage: KV namespace for OAuth tokens (refresh tokens, authorization codes). The workers-oauth-provider library manages this internally.
4. LGPD/ECA — URI-level privacy boundary
Child-level data is separated from aggregate data at the URI level, not as a policy bolted on later:
/roster/childrenreturns aggregate counts — gated byroster:readscope/roster/children/{id}returns individual child records — gated bychildren:readscope (CADASTRO/ADMIN only)
The consent screen highlights children:read with an explicit LGPD warning: "⚠️ Este aplicativo solicita acesso a dados pessoais de crianças. Você tem autoridade legal para conceder este acesso?"
Tool outputs are PII-minimal: neemias_checkin_register returns child_id (not child_name); neemias_child_enroll never echoes parent_phone. Callers that need child names use /roster/children/{id} which requires the children:read scope.
Enforcement is double-gated: scope check at the endpoint level + entity-level scope filter via resolveScope() from packages/permissions/scope.ts (RESPONSAVEL sees only own children, VOLUNTARIO sees only assigned classes).
5. Registry listing — reserved, deferred
The server name io.github.barateza/neemias is reserved (immutable once published). A server.json stub is included in the repo root with metadata. Actual publishing to the MCP Registry via mcp-publisher is deferred to post-MVP. Neemias would be the first MCP server in the church attendance/roster management space.
Consequences
Positive
- MCP clients (Claude Desktop, Cursor, n8n, custom agents) can query Neemias data with standard OAuth
- Zero new infrastructure — same Worker, same D1, same auth middleware
- Stateless design eliminates Durable Object costs and session management complexity
- URI-level LGPD boundary is architectural, not policy-level — harder to circumvent
- Extractable to separate Worker later with zero handler changes
Negative
- KV namespace required for OAuth token storage (new binding)
mcp.neemias.appsubdomain must be configured (DNS + Worker route)- OAuth consent screen adds UX surface not present in the current JWT-only flow
workers-oauth-provideris a new dependency with its own update cadence
Neutral
requireScope()middleware is new but follows the identical pattern of existingrequirePermission()- The
scopesJWT claim increases token size marginally (7 scopes × ~15 chars = ~105 bytes) - Historical
/ssealias adds one route entry but zero maintenance burden