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.
MCP (Model Context Protocol) is an open protocol that lets an AI agent call your app's functionality as tools instead of a human clicking through admin UI. A server can expose tools, resources and prompts; this one uses tools only, fifteen of them (see Default Tools).
Architecture
AI Agent (Claude, Cursor, …)
│ POST /admin/mcp (Streamable HTTP, stateless)
│ Authorization: Bearer <ADMIN_MCP_API_KEY> - every request
v
McpAdminController per-IP rate limit, then handleStatelessPost:
a throwaway McpServer + transport per request,
discarded on close. GET/DELETE → 405
v
McpAdminService validateApiKey (timing-safe) + registerTools.
No session store, no per-request state
v
McpAdminToolsService register the tool surface on each per-request
McpAdminOpsToolsService server instance: blog, users, email, billing,
plus get_server_info
v
BlogService · UsersService · EmailService · EmailStatsService · BillingService
The controller is @ApiExcludeController(), so MCP routes never appear in the OpenAPI spec or Swagger UI. They are 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). In production, boot fails fast if either is missing or matches a placeholder pattern; development warns and continues.
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 pins you to one backend process, so deploys and restarts are invisible to connected agents.
- No stale-session bug class. A client that caches session ids across server restarts cannot break, because there is nothing to go stale.
- No cap, eviction or 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 that costs microseconds. If you later need push or expensive per-connection setup, that is the moment to add a session layer, 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 fifteen 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_abandoned_deletions |
User-deletion queue rows the worker gave up on. A row may have failed during local erasure, Stripe cleanup, or queue bookkeeping. Inspect lastError before deciding how to recover it. Empty is healthy. |
admin_requeue_abandoned_deletions |
Write: requeue every abandoned deletion after the underlying cause is fixed. The description instructs agents to confirm with the human first. |
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 [email protected]). Don't rename that email to one that can receive mail: anyone able to request a magic link for it could then sign in as the MCP admin.
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 - MCP auth is independent from NextAuth sessions
- Adding a Module - Backend modules whose services MCP tools can call
- Environment Variables -
ADMIN_MCP_API_KEY