Admin MCP Server
The template ships an admin MCP server in backend/src/mcp-admin/. It is the operations surface for a deployed app: point an AI agent (Claude Desktop, Cursor, Codex, etc.) at it over the Model Context Protocol and the agent can author blog content, look up users and signup numbers, read email queue and campaign stats, queue email sends, and check a user's billing entitlement.
What is MCP?
MCP (Model Context Protocol) is an open protocol that lets AI agents connect to your app and use its functionality as tools. Instead of a human clicking through admin UI, an agent calls programmatic tools the server has registered.
An MCP server can expose:
- Tools: Functions the agent can call (e.g.
admin_get_blog_posts,admin_create_blog_post). - Resources: Read-only data the agent can browse.
- Prompts: Pre-built prompt templates.
This template uses tools only: thirteen in the default build (see "Default Tools" below).
Architecture
AI Agent (Claude, Cursor, …)
│
│ POST /admin/mcp (Streamable HTTP, stateless)
│ Header: Authorization: Bearer <ADMIN_MCP_API_KEY>
│ (every request, no session id, ever)
│
v
┌────────────────────────────────────────┐
│ McpAdminController │
│ (NestJS controller, @ApiExcludeController) │
│ │
│ - Per-IP rate limit (mcp-rate-limit.ts) │
│ - handleStatelessPost (mcp-stateless.ts): │
│ throwaway McpServer + transport per │
│ request, discarded on close │
│ - GET / DELETE → 405 │
└─────────────┬──────────────────────────┘
│
v
┌────────────────────────────────────────┐
│ McpAdminService │
│ - validateApiKey (timing-safe) │
│ - registerTools │
│ No session store, no per-request │
│ state of its own. │
└─────────────┬──────────────────────────┘
│
v
┌────────────────────────────────────────┐
│ McpAdminToolsService │
│ (+ McpAdminOpsToolsService) │
│ Register tools on each per-request │
│ MCP server instance: │
│ blog: admin_get_blog_posts, │
│ admin_get_blog_post, │
│ admin_create_blog_post, │
│ admin_update_blog_post, │
│ admin_delete_blog_post │
│ users: admin_get_user, │
│ admin_get_users, │
│ admin_get_signup_count │
│ email: admin_get_email_stats, │
│ admin_get_email_campaign, │
│ admin_queue_emails │
│ billing: admin_get_billing_... │
│ ...entitlement │
│ discovery: get_server_info │
└─────────────┬──────────────────────────┘
│
v
┌────────────────────────────────────────┐
│ BlogService, UsersService, │
│ EmailService, EmailStatsService, │
│ BillingService │
└────────────────────────────────────────┘
The controller is marked @ApiExcludeController(), so MCP routes don't appear in the OpenAPI spec or Swagger UI; they're a parallel surface.
Endpoint
All MCP communication goes through one route using stateless Streamable HTTP transport:
| Method | Path | Purpose |
|---|---|---|
POST |
/admin/mcp |
Send tool calls and JSON-RPC messages |
GET |
/admin/mcp |
405: no SSE push channel in stateless mode |
DELETE |
/admin/mcp |
405: no session to tear down |
Authentication
API-key based, on every request. The agent sends Authorization: Bearer <ADMIN_MCP_API_KEY> (or the legacy X-API-KEY header). The server validates the key (timing-safe compare), serves the request, and forgets it ever happened.
The API key (ADMIN_MCP_API_KEY env) is independent from AUTH_SECRET (NextAuth cookies); boot fails fast if either is missing or matches a placeholder pattern.
Agent guidance (initialize instructions)
The MCP initialize result carries an instructions string - the one handshake channel clients are expected to inject into the agent's context. McpAdminService.serverInstructions supplies it: what the server is, the four tool domains, and write etiquette (confirm with the human before queueing email or deleting posts). A server built on mcp-shared that leaves serverInstructions unset falls back to its serverDescription (see mcp-shared/mcp-stateless.ts).
Stateless by design
There are no MCP sessions. Each POST builds a throwaway McpServer + StreamableHTTPServerTransport (sessionIdGenerator: undefined; see mcp-shared/mcp-stateless.ts), handles the one request, and discards both when the response closes. No Mcp-Session-Id is ever issued.
What this buys you:
- Any replica can serve any request: no in-memory session map pinning you to one backend process. Deploys and restarts are invisible to connected agents.
- No stale-session bug class. Clients that cache session ids across server restarts (the long-standing claude-code#27142 pattern) can't break, because there is nothing to go stale.
- No cap/eviction/TTL machinery to tune or to leak.
The trade-off: no server-initiated push (notifications, sampling, elicitation) and the tool registry is rebuilt per request. The default tools are cheap closures, so this costs microseconds. If you later need server push or expensive per-connection setup, that's the point to add a session layer back, not before.
Rate limiting
mcp-admin.controller.ts runs a per-IP rate limiter from mcp-shared/mcp-rate-limit.ts (1200 req/min by default, tunable via MCP_RATE_LIMIT_PER_MIN) before any transport work.
Default Tools
The starter registers thirteen tools across two services: McpAdminToolsService (blog authoring + discovery) and McpAdminOpsToolsService (users, email, billing). Every tool is a thin adapter over an existing domain service.
Blog authoring:
| Tool | Purpose |
|---|---|
admin_get_blog_posts |
Paginated list of all posts (drafts included). |
admin_get_blog_post |
One post by id, full admin detail. |
admin_create_blog_post |
Create a draft (markdown body). |
admin_update_blog_post |
Update fields (set isPublished=true to publish). |
admin_delete_blog_post |
Permanent delete. |
Operations:
| Tool | Purpose |
|---|---|
admin_get_user |
Look up one user by email address. |
admin_get_users |
Paginated user list, newest first, with the all-time total. |
admin_get_signup_count |
Signup count over the last N days (default 7). |
admin_get_email_stats |
Email queue health: overall totals + success rate, per-campaign stats, daily send volume, recent failures. Same data as the admin email dashboard. |
admin_get_email_campaign |
One campaign's stats by campaign id. |
admin_get_billing_entitlement |
A user's entitlement (effective plan, paid access, status) by user id or email. With billing dormant (no Stripe config) it reports that state instead of erroring. |
admin_queue_emails |
Write: queue one or more emails (optionally as a campaign) through EmailService.queueBulk. The description instructs agents to confirm with the human first. |
Discovery:
| Tool | Purpose |
|---|---|
get_server_info |
Shared discovery/introspection tool from mcp-shared/server-info-tool.ts: returns a markdown summary of the server's name, purpose, and registered tools so an agent can orient itself in one call. |
Ghost users: deleted accounts keep their DB row with PII rewritten (UsersService.ghostDelete). The user tools collapse those rows to { id, deleted: true }, so not even the ghost placeholder values reach agent context.
When MCP creates a post, it's attributed to the seeded MCP admin user (MCP_ADMIN_USER_ID from mcp-admin-user.constants.ts, sentinel email mcp-admin@invalid.crabstack.local). Don't rename that email; see the audit's S3 finding for context.
Adding a New Tool
To add a tool, edit McpAdminToolsService (backend/src/mcp-admin/mcp-admin-tools.service.ts, blog + discovery) or McpAdminOpsToolsService (mcp-admin-ops-tools.service.ts, users/email/billing) and call safeTool(...) from registerReadTools or registerWriteTools. Then add the tool to the get_server_info list (registerServerInfo / OPS_SERVER_INFO_TOOLS); a spec fails if the list drifts from the registered surface. safeTool (from mcp-shared/mcp-tools.utils.ts) wraps the raw SDK registration, adds automatic error handling, and takes a ToolAnnotations argument. Use the shared annotation sets (readOnly, creates, updates, deletes) so agents get accurate read-only/destructive hints. Wrap the return payload in textContent(...) for consistent JSON wire shape:
safeTool(
server,
'admin_create_task', // tool name (lowercase + underscores)
'Create Task', // title (human-readable)
'Create a new task', // description (shown to agents)
{ // input schema (Zod raw shape)
title: z.string().describe('Task title'),
assigneeId: z.string().optional().describe('User id to assign to'),
},
creates, // ToolAnnotations: readOnly | creates | updates | deletes
async ({ title, assigneeId }) => {
const task = await this.tasksService.create({ title, assigneeId });
return textContent(task);
},
);
Tips:
- Lowercase + underscores for tool names.
- Use
.describe()on every zod field. Agents read those descriptions when deciding what to pass. - Pick the annotation set that matches the operation:
readOnlyfor reads,creates/updates/deletesfor writes. They set thereadOnlyHint/destructiveHint/idempotentHintflags agents key off. - Return
textContent(payload). It JSON-encodes the payload into the MCP{ content: [{ type: 'text', text }] }shape. - Don't add your own try/catch.
safeToolcatches thrown errors and returns anisError: trueresponse, so you can let service exceptions propagate.
If the new tool needs a service the MCP module doesn't already see, import it via McpAdminModule.imports.
Connecting an Agent
Most MCP clients (Claude Desktop, Cursor, Codex CLI) accept a Streamable HTTP MCP server in their config. The exact JSON varies, but the essentials are:
- URL:
https://your-backend/admin/mcp - Header:
Authorization: Bearer <your ADMIN_MCP_API_KEY>(the legacyX-API-KEYheader also works)
The transport is stateless. The key authenticates every call, and there's no session handshake to get wrong.
Related
- Architecture - Where MCP fits in the module graph
- Auth Flow - Note that MCP auth is independent from NextAuth sessions
- Adding a Module - Backend modules whose services MCP tools can call
- Environment Variables -
ADMIN_MCP_API_KEY