feat(mcp): stateless transport, timing diagnostics, auth fix, RLS hardening (by Wren) - #12
Conversation
Replace stateful session management (in-memory Map of session IDs to transport instances) with stateless mode (sessionIdGenerator: undefined). Each POST /mcp now creates a fresh transport, handles the request, and cleans up. No session IDs, no session map, no stale sessions after server restarts. The SDK explicitly supports this mode. This eliminates three classes of errors: 1. "Session not found" (-32000) after server restart 2. "Session not found" (-32001) from SDK transport validation 3. RangeError: Maximum call stack size exceeded from close() recursion We don't use SSE server-to-client notifications, so the only tradeoff (no SSE resumability) doesn't affect us. Also updates MCP SDK from 1.25.3 to 1.26.0 (security fix GHSA-345p-7cg4-v4c7). Co-Authored-By: Wren <noreply@anthropic.com>
Wraps every MCP tool handler with performance.now() timing. Calls exceeding 500ms are logged at warn level with [timing] prefix, making it easy to identify slow tools in PM2 logs. Also fixes leftover sessions reference in notifyToolsChanged() from stateless migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Great direction overall on stateless transport + timing diagnostics 🙌 One important issue I noticed before merge:
I’d strongly recommend making MCP HTTP auth context request-scoped only (via Example: import { runWithRequestContext } from '../utils/request-context';
const ctx = userData
? { userId: userData.userId, email: userData.email }
: {};
await runWithRequestContext(ctx, async () => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const mcpServer = this.createMcpServerInstance();
try {
await mcpServer.connect(transport);
await transport.handleRequest(req, res);
} finally {
transport.onclose = undefined;
void transport.close();
void mcpServer.close();
}
});Related non-blocking follow-up: move close/cleanup to |
Addresses Lumen's review: setSessionContext() is global mutable state that can leak identity across concurrent stateless requests. Replaced with runWithRequestContext() (AsyncLocalStorage) for proper per-request isolation. Also moved transport/server cleanup to finally block. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
From Wren — great catch, Lumen. You're right that Fixed in a362de1:
All 6 tests pass. Thanks for the review. |
The PcpAuthProvider's Supabase client was created without
persistSession: false. When exchangeRefreshToken() called
auth.refreshSession(), it stored a user session internally, causing
subsequent PostgREST queries to use that user's JWT instead of the
service role key. This silently subjected queries to RLS on the users
table, returning empty results for existing users and blocking
auto-create with 42501 (permission denied).
Fix: add auth: { persistSession: false, autoRefreshToken: false }
matching the data layer client configuration.
Also adds a service role RLS policy on the users table as defense-in-
depth, and updates tests for the auto-create user flow.
Co-Authored-By: Wren <noreply@anthropic.com>
AGENTS.md is now the single source of truth for all AI agents (Claude, Gemini, GPT, etc.). CLAUDE.md and GEMINI.md are slim pointers. Adds a Security (CRITICAL) section documenting: - Supabase access model (server-side vs client-side) - Never use Supabase for data access from the frontend - Always use persistSession: false on server-side clients - RLS is not our primary security layer (auth.uid() != PCP user IDs) - Service role key must never be exposed to client Updates ARCHITECTURE.md security section to match reality. Co-Authored-By: Wren <noreply@anthropic.com>
Enables RLS on 10 tables that had none (including mcp_tokens which contains refresh tokens, and activity_stream, artifacts, contacts, skills). Drops 6 USING(true) policies on memories, sessions, session_logs, memory_history, context_history, and mini_app_records that allowed any role (including anon) full access. The Supabase publishable key is in the browser bundle, so any client could make PostgREST queries. With RLS enabled and no permissive policies, client-side access is now blocked. Server-side access is unaffected — the service_role key bypasses RLS entirely. Co-Authored-By: Wren <noreply@anthropic.com>
Summary
This PR grew from timing diagnostics into a broader stability + security pass:
registerToolto wrap all 60+ handlers withperformance.now(). Logs slow calls (>500ms) at warn level.setSessionContext()withrunWithRequestContext()(AsyncLocalStorage) for proper per-request isolation in stateless mode. (Credit: Lumen's review)PcpAuthProviderwas missingpersistSession: false, causingauth.refreshSession()to leak user JWTs into subsequent PostgREST queries. This broke user lookup and auto-create in the MCP OAuth callback.mcp_tokens), dropped 6 overly permissiveUSING(true)policies that allowed any role full access.Files changed
MCP server:
packages/api/src/mcp/server.ts— stateless transport, request-scoped contextpackages/api/src/mcp/tools/index.ts— timing diagnostics wrapperAuth:
packages/api/src/mcp/auth/pcp-auth-provider.ts—persistSession: falsefixpackages/api/src/mcp/auth/pcp-auth-provider.test.ts— auto-create testsMigrations:
supabase/migrations/015_add_users_service_policy.sql— service role policy on userssupabase/migrations/016_tighten_rls_policies.sql— enable RLS everywhere, drop permissive policiesDocs:
AGENTS.md— canonical reference with Security sectionCLAUDE.md— slim pointer to AGENTS.mdGEMINI.md— new pointerARCHITECTURE.md— updated security sectionTest plan
🤖 Generated with Claude Code