All docs

MCP server

MCP server

Process: apps/mcp (port 3012, separate container). Transport: Streamable HTTP via @modelcontextprotocol/sdk@^1.29. Single endpoint: POST | GET | DELETE /mcp — all three dispatch to StreamableHTTPServerTransport.handleRequest.

Lifecycle

[ client ] ── POST /mcp { initialize } ────▶ mint sessionId, attach McpServer
                                              │
[ client ] ── POST /mcp { tools/call } ────▶ existing transport, dispatch
   (mcp-session-id: <id>)                     │
[ client ] ── GET  /mcp (SSE) ──────────────▶ same transport, stream events
[ client ] ── DELETE /mcp ──────────────────▶ explicit session close

The Express layer keeps a Map<sessionId, { transport, server, auth }> so follow-up requests are O(1). On onclose the entry is dropped; on SIGTERM the boot loop in index.ts calls closeAllMcpSessions() to drain in-flight work before stopping workers.

Middleware stack

/mcp is wrapped in two guards (auth lives in task 25):

  1. `mcpHostGuard` — DNS-rebinding protection. Reject if the Host header

isn't in MCP_ALLOWED_HOSTS (default 127.0.0.1,localhost,mcp,mcp.drobek.app).

  1. `mcpAuthMiddleware` — task-25 seam. Today it just rejects missing

Authorization in production (with DEV_AUTH_BYPASS escape hatch) and attaches a stub AuthContext. Task 25 will parse Bearer drk_at_… tokens.

Tool registry

Tools register through apps/mcp/src/tools/registry.ts:

import { defineTool } from './tools/registry.js';

defineTool({
  name: 'get_next_actionable',
  description: '…',
  inputSchema: { /* zod */ },
  handler: async (args, ctx) => ({ content: { … } }),
});

The wrapper emits a structured log line per call:

{
  "tool": "get_next_actionable",
  "agent_id": "uuid",
  "org_id": "uuid",
  "request_id": "x-request-id",
  "duration_ms": 12,
  "status": "ok"
}

Tools are added in tasks 26 (planning), 27 (execution), 28 (knowledge), 29a (comments). The scaffold ships with an empty registry.

Worker collocation

The MCP process boots BullMQ workers (embeddings, verification, webhooksOut, emails, notifications) by default. Disable with MCP_DISABLE_WORKERS=true for dev iteration or test isolation. They share the same db and ioredis clients as the HTTP handlers — one pool, one connection.

Graceful shutdown

SIGTERM/SIGINT triggers a 30s drain budget:

  1. server.close() — refuse new HTTP connections, finish in-flight.
  2. closeAllMcpSessions() — close every StreamableHTTPServerTransport.
  3. stopWorkers()await worker.close() for each BullMQ worker.
  4. process.exit(0). After 30s a hard kill (exit(1)) fires unconditionally.

Scale-out (later)

The session Map is single-process. For multi-instance MCP (task 48+) replace it with a Redis-backed store and have nginx pin sessions by the mcp-session-id header. The SDK contract is the same — only the store moves.