# PLAN.md Review & Suggestions
## Executive Summary
The PLAN.md correctly identifies the core architectural goal: embed MCP inside Strapi 5 as a plugin to bypass REST/GraphQL overhead and gain direct Document Service access. The security model (service account + custom authorization layer) is sound. However, **the implementation has critical gaps** that need to be addressed before building further.
This review covers: transport protocol, multi-session architecture, Koa integration, auth model correctness, the existing plugin bugs, and a revised implementation plan.
---
## 1. CRITICAL: SSE Transport Is Deprecated — Use Streamable HTTP
**The plan uses SSE transport throughout. SSE was deprecated in the MCP spec (2025-03-26).**
The current code in `mcp-controller.ts` uses `SSEServerTransport` with separate `GET /sse` and `POST /messages` endpoints. The MCP specification replaced this with **Streamable HTTP transport**, which uses a single endpoint supporting POST/GET/DELETE.
### Why Streamable HTTP is better for this project
| Concern | SSE (deprecated) | Streamable HTTP |
|---------|------------------|-----------------|
| Endpoints | 2 (`/sse` + `/messages`) | 1 (`/mcp`) |
| Load balancing | Requires sticky sessions | Works with standard LB |
| Session management | Manual Map tracking | Built-in via `Mcp-Session-Id` header |
| Stateless mode | Not possible | Supported (new server per request) |
| Infrastructure | Breaks behind many proxies | Standard HTTP |
| SDK support | Legacy, no new features | Active development |
### How to implement with Strapi (Koa)
Strapi uses Koa, but the MCP SDK only provides middleware for Express, Hono, and raw Node.js. For Koa, use `@modelcontextprotocol/node` which works with raw `req`/`res`:
```typescript
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { toFetchRequest, toReqRes } from "@modelcontextprotocol/node";
// In your Koa controller:
async handleMcp(ctx) {
// Koa exposes raw Node.js objects as ctx.req / ctx.res
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const server = createMcpServer(strapi); // factory function
await server.connect(transport);
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
}
```
**Alternative (recommended):** Use `@modelcontextprotocol/node` adapter which wraps `IncomingMessage`/`ServerResponse` — the same objects Koa exposes via `ctx.req`/`ctx.res`.
### SDK version
Upgrade from `1.10.2` (standalone) and `^1.26.0` (plugin) to the latest `1.26.0`. Add `@modelcontextprotocol/node` for Koa compatibility.
### Reference
The `@sensinum/strapi-plugin-mcp` (your benchmark) already uses Streamable HTTP at `/api/mcp/streamable`. Follow the same pattern.
---
## 2. CRITICAL: Duplicate MCP Server Instances
Two files each create a separate `new Server()`:
- `bootstrap.ts` (line 4): Creates server with tools `search_content` + `get_strapi_schema`
- `routes/index.ts` (line 4): Creates a different server with tool `get_articles`
Both attach to `strapi.mcp.server`, so the second one overwrites the first. **Pick one location** — `bootstrap.ts` is the correct place for initialization.
**Fix:** Remove the `Server` creation from `routes/index.ts`. That file should only define route paths.
---
## 3. CRITICAL: SSE Transport Not Stored in Session Map
In `mcp-controller.ts`, the `sse()` handler creates a transport but **never registers it**:
```typescript
const transport = new SSEServerTransport("/mcp-server/messages", ctx.res);
await server.connect(transport);
// BUG: transport is never added to strapi.mcp.transports Map
```
So when `messages()` tries `strapi.mcp.transports.get(sessionId)`, it always returns `undefined`. Every message request will 404.
This becomes moot if you switch to Streamable HTTP (which manages sessions internally), but it's a fundamental bug in the current code.
---
## 4. CRITICAL: Routes Not Wired
The SSE and messages endpoints aren't registered in Strapi's router:
- `routes/content-api/index.ts`: Only defines `GET /` → `controller.index`
- `routes/admin/index.ts`: Empty routes array
- `controllers/index.ts`: Only exports `controller` (the welcome message one), not `mcp-controller`
The `mcp-controller.ts` with `sse()` and `messages()` methods is unreachable. No route points to it.
---
## 5. MCP Server Per-Session vs Shared Instance
The current code creates ONE `Server` instance and tries to `connect()` multiple transports to it. The MCP SDK's `Server.connect()` replaces the previous transport — it's designed for a single connection.
### Options
**Option A — Stateless mode (recommended for simplicity):**
Create a new `McpServer` + `StreamableHTTPServerTransport` for each request. No session state. The transport is short-lived. This is the simplest approach and matches how HTTP/REST works.
```typescript
app.post('/mcp', async (req, res) => {
const server = createMcpServer(strapi);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
```
**Option B — Session-aware mode (recommended for production):**
Maintain a `Map<string, StreamableHTTPServerTransport>` keyed by session ID. Reuse transports for returning clients. Create new ones only for `initialize` requests. This is what `@sensinum/strapi-plugin-mcp` does with its Redis/in-memory session store.
**Recommendation:** Start with Option A, add session management later when needed.
---
## 6. Ownership: Map MCP Client to Strapi User (Revised)
~~The original review recommended a custom `ownerId` field. This has been superseded by a simpler approach.~~
### Problem
The trust-proxy middleware always injects a shared `mcp-service-worker` account. Every record gets `createdBy = mcp-service-worker`. You can't distinguish which real user created what.
### Solution: Derive owner from MCP client identity
Instead of a custom field, **resolve each MCP client to a real Strapi admin user** and execute Document Service calls as that user. Strapi's native `createdBy`/`updatedBy` handles ownership automatically.
```
MCP Client connects
→ sends Authorization header (JWT from IdP or Strapi admin token)
↓
Middleware resolves identity → Strapi admin user (by email)
↓
Stores resolved user on session/transport
↓
Tool handler passes user context to Document Service
↓
Strapi sets createdBy/updatedBy automatically
↓
"Own entries" filtering works natively via createdBy
```
### What changes
**Trust-proxy middleware** — resolve real user instead of shared service account:
```typescript
// Resolve MCP client to Strapi admin user by email from JWT
const token = ctx.get('Authorization')?.replace('Bearer ', '');
const jwt = await verifyAndDecode(token);
const adminUser = await strapi.db.query('admin::user').findOne({
where: { email: jwt.email },
});
ctx.state.user = adminUser;
```
**Session caching** — resolve once per connection, restore per message:
```typescript
// On SSE/Streamable HTTP init:
transport.mcpUser = ctx.state.user;
// On subsequent messages:
ctx.state.user = transport.mcpUser;
```
**Authorization service** — ownership filtering uses native `createdBy`:
```typescript
if (user.role === 'Author' && (action === 'update' || action === 'delete')) {
input.filters = { ...filters, createdBy: user.id };
// Works because user.id IS the Strapi admin user ID now
}
```
### What this eliminates
| Removed | Why |
|---------|-----|
| Custom `ownerId` field | `createdBy` does this natively |
| Service account provisioning | No shared `mcp-service-worker` needed |
| Complex ownership mapping | Direct 1:1 identity = simpler |
### Caveat
Document Service called from plugin code may not auto-set `createdBy`. If not, pass explicitly:
```typescript
await strapi.documents(uid).create({ data: { ...args.data, createdBy: user.id } });
```
Test this early in implementation.
---
## 7. Multi-Tenancy Approach Needs Schema Awareness
The tenant filter injection blindly adds `tenantId`:
```typescript
input.filters = { ...input.filters, tenantId: user.tenant };
```
This fails for content types that don't have a `tenantId` field. It should:
1. Check if the content type schema has a `tenantId` attribute
2. Only inject the filter if it exists
3. Log a warning (or deny access) if a tenant-scoped user accesses a non-tenant content type
```typescript
function injectTenantFilter(input: AuthorizationInput) {
if (!input.user.tenant) return;
const ct = strapi.contentTypes[input.contentType];
if (!ct?.attributes?.tenantId) {
// Content type not tenant-aware — deny or allow based on policy
throw new Error(`Content type ${input.contentType} is not tenant-scoped`);
}
input.filters = { ...input.filters, tenantId: input.user.tenant };
}
```
---
## 8. Trust-Proxy Middleware Uses Wrong User Model
`mcp-trust-proxy.ts` queries `plugin::users-permissions.user`:
```typescript
serviceUser = await strapi.documents('plugin::users-permissions.user').findFirst({
filters: { username: 'mcp-service-worker' },
});
```
The Users & Permissions plugin is for **end-user/public API** authentication. But the plan states:
- "No end-user authentication inside Strapi"
- "MCP always executes as a service account"
- "Use Admin RBAC"
The service account should be an **Admin user** queried from `admin::user`, or (better) the middleware should bypass Strapi auth entirely and use `strapi.documents()` directly without an auth context, since the authorization layer is custom.
### Recommended approach
Since you're building a custom authorization layer and the service account pattern is just to satisfy Strapi internals, the simplest approach is:
```typescript
// In bootstrap: no user lookup needed
// When calling Document Service, just call it directly:
const results = await strapi.documents(contentType).findMany({ filters });
// Document Service doesn't require auth context when called from server code
```
Strapi's Document Service can be called directly from server-side code without authentication context. The trust-proxy middleware and service user pattern are unnecessary complexity.
---
## 9. Auth Services Not Integrated
`auth.service.ts` and `authorization.service.ts` exist but are **never called** from any tool handler, controller, or middleware. They need to be wired into the MCP tool execution pipeline.
### Proposed integration point
With Streamable HTTP transport, auth should happen in the request handler:
```typescript
async handleMcp(ctx) {
const authService = strapi.plugin('mcp-server').service('auth');
const authzService = strapi.plugin('mcp-server').service('authorization');
// 1. Validate internal secret
const secret = ctx.get('X-MCP-Internal-Secret');
if (secret !== process.env.MCP_SECRET_KEY) {
ctx.throw(401, 'Invalid internal secret');
}
// 2. Validate JWT and extract user
const token = authService.extractToken(ctx);
const user = await authService.verifyJWT(token);
// 3. Create MCP server with user context baked into tools
const server = createMcpServer(strapi, user, authzService);
const transport = new StreamableHTTPServerTransport({ ... });
await server.connect(transport);
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
}
```
This way, every tool invocation in that server instance already has the user context.
---
## 10. Tool Porting Strategy Missing
The standalone server has **20 production tools** (get_entries, create_entry, update_entry, etc.) with input validation, LLM-optimized descriptions, audit logging, and authToken support.
The plugin has **2 placeholder tools** (search_content, get_strapi_schema).
The plan doesn't describe how to port these. The good news: Document Service calls are much simpler than REST API calls, so the tools will be shorter. But you still need:
- All 20 tool definitions with schemas
- Input validation (content type UID, entry ID, etc.)
- Audit logging
- Error handling that teaches the LLM
### Recommendation
Create a `tools/` directory with one file per tool group:
```
src/tools/
content.ts # get_entries, get_entry, create_entry, update_entry, delete_entry
single-types.ts # get_single_type, update_single_type, delete_single_type
publish.ts # publish_entry, unpublish_entry, publish_single_type, unpublish_single_type
schema.ts # list_content_types, get_content_type_schema, list_components, get_component_schema
media.ts # upload_media, upload_media_from_path
relations.ts # connect_relation, disconnect_relation
index.ts # registerAllTools(server, strapi, user, authz)
```
Each tool uses `strapi.documents()` directly instead of HTTP:
```typescript
// Before (standalone, REST):
const response = await axios.get(`${STRAPI_URL}/content-manager/collection-types/${contentType}`, { headers, params });
// After (plugin, Document Service):
const results = await strapi.documents(contentType).findMany({ filters, sort, populate, limit, offset });
```
---
## 11. Koa/SSE Compatibility Issue
The current `mcp-controller.ts` does:
```typescript
ctx.body = transport.sessionId;
```
In Koa, setting `ctx.body` finalizes the response. But SSE (and Streamable HTTP) need the response to remain open for streaming. You must **not** set `ctx.body` when streaming. Instead, write directly to `ctx.res` (the raw Node.js response) and mark the response as handled:
```typescript
ctx.respond = false; // Tell Koa not to handle the response
// Let the MCP transport write directly to ctx.res
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
```
---
## 12. Missing: Cleanup on Disconnect
The `destroy.ts` lifecycle hook is empty. When Strapi shuts down, active MCP sessions and transports need cleanup:
```typescript
const destroy = ({ strapi }) => {
const mcp = (strapi as any).mcp;
if (mcp?.transports) {
for (const [id, transport] of mcp.transports) {
transport.close();
}
mcp.transports.clear();
}
if (mcp?.server) {
mcp.server.close();
}
};
```
---
## Revised Implementation Plan
### Phase 0: Fix Foundations (before any feature work)
1. **Upgrade SDK** to `@modelcontextprotocol/sdk@1.26.0` + add `@modelcontextprotocol/node`
2. **Remove duplicate Server creation** from `routes/index.ts`
3. **Switch to Streamable HTTP transport** — single `POST/GET /mcp-server/mcp` endpoint
4. **Wire routes properly** — register the MCP controller in content-api routes
5. **Set `ctx.respond = false`** in the Koa controller for streaming
6. **Add cleanup** in `destroy.ts`
### Phase 1: Core MCP Tools (port from standalone)
7. **Create `tools/` directory** structure
8. **Port 20 production tools** using `strapi.documents()` instead of HTTP
9. **Reuse validation functions** (`validateContentTypeUid`, `validateEntryId`, etc.)
10. **Reuse audit logging** pattern from standalone
11. **Gate dev tools** behind config setting
### Phase 2: Auth Pipeline
12. **Wire auth.service.ts** into the MCP request handler
13. **Wire authorization.service.ts** into each tool execution
14. **Fix ownership** — resolve MCP client to Strapi admin user, use native `createdBy`
15. **Fix tenant filter** — check schema before injecting
16. **Remove trust-proxy middleware** — call Document Service directly from server code
### Phase 3: Production Hardening
17. **Session management** — in-memory Map (dev) and Redis (prod), following `@sensinum` pattern
18. **Rate limiting** — per-session request counting
19. **Structured logging** — port `log.{level}()` system from standalone
20. **Error messages that teach** — port LLM-optimized error patterns from standalone
21. **Zod schemas** — replace JSON Schema with Zod for runtime validation
### Phase 4: Containerization
22. **Dockerfile** — Strapi 5 with the MCP plugin pre-installed
23. **docker-compose.yml** — Strapi + PostgreSQL + Redis (for sessions)
24. **Environment variable documentation** — all config options
---
## Summary of Key Changes vs Current Plan
| Current Plan | Suggested Change | Reason |
|-------------|-----------------|--------|
| SSE transport (`GET /sse` + `POST /messages`) | Streamable HTTP (`POST /mcp`) | SSE deprecated in MCP spec |
| Shared MCP Server instance | Server-per-session or stateless | SDK limitation: one transport per server |
| `users-permissions` service user | Direct Document Service calls | Unnecessary complexity |
| Shared service account + `createdBy` mismatch | Map MCP client → Strapi admin user | Native `createdBy` works when user identity is resolved correctly |
| Blind `tenantId` injection | Schema-aware tenant filtering | Not all content types have `tenantId` |
| Auth services as standalone files | Wired into request handler pipeline | Currently never called |
| 2 placeholder tools | Port all 20 from standalone | Plugin is incomplete without them |
| `ctx.body` for SSE streaming | `ctx.respond = false` | Koa response handling conflict |
---
## 13. Adapting @sensinum/strapi-plugin-mcp Architecture
The [`@sensinum/strapi-plugin-mcp`](https://github.com/VirtusLab-Open-Source/strapi-plugin-mcp) (by VirtusLab) is the most mature open-source MCP plugin for Strapi 5. It already solves many of the infrastructure problems in your current code. Here's what to adopt, what to adapt, and what to build on top.
### What sensinum does well (adopt directly)
#### 1. Transport & Session Management (`transport.utils.ts`)
This is the most valuable piece. Sensinum's `createTransportStore()` provides:
- **LRU cache** for in-memory session storage (dev/single-instance)
- **Redis** for multi-instance session persistence (production)
- **Session regeneration** — when a session exists in Redis but not in-memory (after process restart), it regenerates the transport
- **Automatic cleanup** — `onclose` handlers remove sessions from both LRU and Redis
- **Configurable TTL** — sessions expire after 10 minutes by default
**Adopt this directly.** Replace the manual `Map<string, transport>` in `bootstrap.ts` with this transport store. Dependencies to add: `ioredis`, `lru-cache`.
#### 2. Events Controller Pattern (`events.controller.ts`)
The controller implements the full Streamable HTTP protocol correctly for Koa:
```
POST /streamable → Create session (if initialize request) or handle tool call
GET /streamable → SSE stream for server-to-client notifications
DELETE /streamable → Terminate session
```
Key patterns to adopt:
- **`isInitializeRequest()` check** — only create new transports for init requests
- **`mcp-session-id` header** — standard session management
- **Server-per-session** — `createServer()` is called for each new session AND for regenerated sessions
- **JSON-RPC error responses** — proper error format with `-32000` and `-32603` codes
- **`ctx.req`/`ctx.res` passthrough** — lets MCP SDK handle the raw response, avoiding Koa conflicts
#### 3. Route Configuration (`routes/content-api.ts`)
Simple, correct pattern:
```typescript
{ method: 'POST', path: '/streamable', handler: 'events.postStreamable', config: { auth: false, policies: ['plugin::mcp.ip-allowlist'] } }
```
Note: `auth: false` — sensinum disables Strapi's built-in auth for MCP routes. Your plugin should do the same (since you implement custom JWT auth).
#### 4. Tool Registration Pattern (`common/index.ts`)
The `McpToolDefinition<Args>` interface + `McpToolDefinitionBuilder<Args>` type + `registerTool()` function provide a clean, type-safe way to define tools:
```typescript
export interface McpToolDefinition<Args extends ZodRawShape> {
name: string;
callback: ToolCallback<Args>;
argsSchema?: Args;
description?: string;
annotations?: ToolAnnotations;
}
export type McpToolDefinitionBuilder<Args extends ZodRawShape> = (strapi: Strapi) => McpToolDefinition<Args>;
```
**Adopt this pattern.** It's cleaner than raw `server.tool()` calls and supports Zod schemas natively.
#### 5. Service Architecture
Each tool group is a Strapi service with an `addTools(server: McpServer)` method:
```typescript
// services/content-types/content-types.service.ts
export default ({ strapi }) => ({
addTools: (server: McpServer) => {
registerTool({ server, tool: getContentTypesTool(strapi) });
registerTool({ server, tool: getComponentsTool(strapi) });
// ...
},
});
```
The controller composes them:
```typescript
const createServer = () => {
const server = new McpServer({ name, version });
contentTypesService.addTools(server);
strapiInfoService.addTools(server);
servicesService.addTools(server);
customService.addTools(server);
return server;
};
```
**Adopt this pattern.** It gives you clean separation and allows tool groups to be independently testable.
#### 6. Custom Tool Registration (`custom.service.ts`)
Sensinum allows external code to register custom tools dynamically:
```typescript
const customService = strapi.plugin('mcp').service('custom');
customService.registerTool({ name: 'my-tool', ... });
```
**Adopt this.** It lets users extend the plugin without modifying its source code.
#### 7. IP Allowlist Policy
A simple security policy that restricts MCP access by client IP. Handles IPv4/IPv6 localhost variants correctly.
**Adopt this** as an additional security layer alongside your JWT auth.
#### 8. Logger (`logger.utils.ts`)
Uses Strapi's built-in `strapi.log` with a consistent prefix. Much cleaner than the standalone server's custom `log.{level}()` system.
**Adopt this.** Use `strapi.log` directly since you're inside the Strapi process.
### What sensinum lacks (your additions)
Sensinum is explicitly **dev-only** ("must not be enabled in production"). Your project needs enterprise features that sensinum doesn't provide:
| Feature | Sensinum | Your Plugin (needed) |
|---------|----------|---------------------|
| **CRUD tools** (create/update/delete entries) | No — read-only schema introspection | Yes — full Document Service CRUD |
| **Authentication** | None (`auth: false`, IP allowlist only) | JWT validation + internal secret |
| **Authorization** | None | Custom RBAC (role matrix, ownership, tenancy) |
| **Audit logging** | None | Structured JSON audit trail |
| **Input validation** | Zod schemas on tool args | Zod + content type UID/entry ID validation |
| **Media upload** | No | Yes — via Strapi upload service |
| **Publish/unpublish** | No | Yes — via Document Service |
| **Dev-mode gating** | No | Hide schema management tools in production |
| **LLM-optimized errors** | Basic errors | Errors that teach the LLM what to do next |
| **Multi-tenant** | No | Tenant-scoped data filtering |
### Recommended Plugin Architecture (adapted from sensinum)
```
strapi-plugins/mcp-server/
├── src/
│ ├── index.ts # Plugin entry: register, bootstrap, destroy
│ ├── bootstrap.ts # (empty — server created per-session in controller)
│ ├── register.ts # (empty or register custom content types)
│ ├── destroy.ts # Cleanup transport store
│ │
│ ├── config/
│ │ └── index.ts # Zod-validated plugin config (adapted from sensinum)
│ │ # + JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URI, MCP_SECRET_KEY
│ │
│ ├── common/
│ │ └── index.ts # McpToolDefinition, registerTool (from sensinum)
│ │
│ ├── controllers/
│ │ ├── index.ts # { events: eventsController }
│ │ └── events.controller.ts # Streamable HTTP handler (adapted from sensinum)
│ │ # + JWT auth + internal secret validation
│ │
│ ├── routes/
│ │ ├── index.ts # { 'content-api': { type, routes } }
│ │ └── content-api.ts # POST/GET/DELETE /streamable
│ │
│ ├── policies/
│ │ ├── index.ts
│ │ └── ip-allowlist.ts # From sensinum
│ │
│ ├── middlewares/
│ │ └── index.ts # (remove mcp-trust-proxy — not needed)
│ │
│ ├── services/
│ │ ├── index.ts # Aggregate all services
│ │ ├── auth/
│ │ │ ├── index.ts
│ │ │ └── auth.service.ts # JWT validation via JWKS (existing, enhanced)
│ │ ├── authorization/
│ │ │ ├── index.ts
│ │ │ └── authorization.service.ts # Role matrix + ownership + tenancy (existing, fixed)
│ │ ├── content/
│ │ │ ├── index.ts
│ │ │ ├── content.service.ts # addTools() for CRUD tools
│ │ │ └── tools/
│ │ │ ├── index.ts
│ │ │ ├── get-entries.tool.ts
│ │ │ ├── get-entry.tool.ts
│ │ │ ├── create-entry.tool.ts
│ │ │ ├── update-entry.tool.ts
│ │ │ ├── delete-entry.tool.ts
│ │ │ └── common.ts # Shared validation (UID, ID)
│ │ ├── single-types/
│ │ │ ├── index.ts
│ │ │ ├── single-types.service.ts
│ │ │ └── tools/
│ │ │ ├── get-single-type.tool.ts
│ │ │ ├── update-single-type.tool.ts
│ │ │ └── delete-single-type.tool.ts
│ │ ├── publish/
│ │ │ ├── index.ts
│ │ │ ├── publish.service.ts
│ │ │ └── tools/
│ │ │ ├── publish-entry.tool.ts
│ │ │ └── unpublish-entry.tool.ts
│ │ ├── schema/ # Adapted from sensinum content-types service
│ │ │ ├── index.ts
│ │ │ ├── schema.service.ts
│ │ │ └── tools/
│ │ │ ├── list-content-types.tool.ts
│ │ │ ├── get-content-type-schema.tool.ts
│ │ │ ├── list-components.tool.ts
│ │ │ └── get-component-schema.tool.ts
│ │ ├── media/
│ │ │ ├── index.ts
│ │ │ ├── media.service.ts
│ │ │ └── tools/
│ │ │ └── upload-media.tool.ts
│ │ ├── relations/
│ │ │ ├── index.ts
│ │ │ ├── relations.service.ts
│ │ │ └── tools/
│ │ │ ├── connect-relation.tool.ts
│ │ │ └── disconnect-relation.tool.ts
│ │ ├── strapi-info/ # From sensinum
│ │ │ ├── index.ts
│ │ │ ├── strapi-info.service.ts
│ │ │ └── tools/
│ │ │ └── instance-info.tool.ts
│ │ └── custom/ # From sensinum — extensibility
│ │ ├── index.ts
│ │ └── custom.service.ts
│ │
│ ├── utils/
│ │ ├── index.ts
│ │ ├── logger.utils.ts # From sensinum (uses strapi.log)
│ │ ├── transport.utils.ts # From sensinum (LRU + Redis session store)
│ │ ├── validation.utils.ts # Port from standalone (UID, ID, path validators)
│ │ └── audit.utils.ts # Port from standalone (structured JSON audit logging)
│ │
│ └── content-types/
│ └── index.ts # (empty unless adding custom CTs for audit/config)
│
├── package.json
└── tsconfig.json
```
### Key Adaptation Details
#### Events Controller (adapted from sensinum + your auth)
The main change is adding authentication before creating the MCP server:
```typescript
const eventsController = ({ strapi }: StrapiContext) => {
const authService = strapi.plugin('mcp-server').service('auth');
const authzService = strapi.plugin('mcp-server').service('authorization');
const transportStore = createTransportStore({ strapi, options: pluginConfig.session });
// Factory: creates server with user-scoped authorization
const createServer = (user?: AuthenticatedUser) => {
const server = new McpServer({ name: 'strapi-mcp', version: '1.0.0' });
// Pass user context + authz service to tool registrations
contentService.addTools(server, user, authzService);
singleTypesService.addTools(server, user, authzService);
publishService.addTools(server, user, authzService);
schemaService.addTools(server);
mediaService.addTools(server, user, authzService);
relationsService.addTools(server, user, authzService);
strapiInfoService.addTools(server);
customService.addTools(server);
return server;
};
return {
async postStreamable(ctx: any) {
// 1. Authenticate (optional — skip for unauthenticated mode)
let user: AuthenticatedUser | undefined;
const secret = ctx.get('X-MCP-Internal-Secret');
if (secret) {
if (secret !== process.env.MCP_SECRET_KEY) {
ctx.status = 401;
ctx.body = { jsonrpc: '2.0', error: { code: -32000, message: 'Invalid secret' }, id: null };
return;
}
const token = authService.extractToken(ctx);
user = await authService.verifyJWT(token);
}
// 2. Get or create transport (from sensinum pattern)
const transport = await getTransport(ctx);
if (!transport) return;
// 3. Connect server with user context
const server = createServer(user);
await server.connect(transport);
// 4. Handle request
ctx.respond = false;
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
},
// ... GET and DELETE handlers (same as sensinum)
};
};
```
#### Tool Definitions (new pattern combining sensinum structure + your standalone logic)
Example `get-entries.tool.ts`:
```typescript
import { z } from 'zod';
import { McpToolDefinitionBuilder } from '../../../common';
import { validateContentTypeUid } from '../../../utils/validation.utils';
import { auditLog } from '../../../utils/audit.utils';
export const getEntriesTool: McpToolDefinitionBuilder<{
contentType: z.ZodString;
filters: z.ZodOptional<z.ZodString>;
sort: z.ZodOptional<z.ZodString>;
populate: z.ZodOptional<z.ZodString>;
page: z.ZodOptional<z.ZodNumber>;
pageSize: z.ZodOptional<z.ZodNumber>;
}> = (strapi, user?, authz?) => ({
name: 'get_entries',
description: 'List entries for a content type with optional filtering, sorting, and pagination. ' +
'Use list_content_types first to discover available content type UIDs. ' +
'Returns 403 if the current role lacks read permission.',
argsSchema: {
contentType: z.string().describe('Content type UID, e.g. api::article.article'),
filters: z.string().optional().describe('JSON filters object, e.g. {"title":{"$contains":"hello"}}'),
sort: z.string().optional().describe('Comma-separated sort fields, e.g. "title:asc,createdAt:desc"'),
populate: z.string().optional().describe('Comma-separated relations to populate, e.g. "author,categories"'),
page: z.number().optional().describe('Page number (default: 1)'),
pageSize: z.number().optional().describe('Results per page (default: 25, max: 100)'),
},
callback: async ({ contentType, filters, sort, populate, page, pageSize }) => {
// Validate input
const uid = validateContentTypeUid(contentType);
// Authorize
if (user && authz) {
authz.authorize({ user, contentType: uid, action: 'read', filters: filters ? JSON.parse(filters) : undefined });
}
// Execute via Document Service (no HTTP overhead!)
const results = await strapi.documents(uid).findMany({
filters: filters ? JSON.parse(filters) : undefined,
sort: sort ? sort.split(',') : undefined,
populate: populate ? populate.split(',').reduce((acc, rel) => ({ ...acc, [rel.trim()]: true }), {}) : undefined,
limit: Math.min(pageSize || 25, 100),
offset: ((page || 1) - 1) * Math.min(pageSize || 25, 100),
});
return {
content: [{ type: 'text', text: JSON.stringify({ success: true, data: results, count: results.length }) }],
};
},
});
```
### Dependencies to Add
```json
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"ioredis": "^5.7.0",
"jose": "^6.0.0",
"lru-cache": "^11.2.1",
"zod": "^3.25.42"
}
}
```
### What to Remove
- `bootstrap.ts` — remove the shared `Server` + `transports` Map. Server is created per-session in the controller.
- `routes/index.ts` — remove the duplicate `Server` creation. Replace with proper route definitions.
- `middlewares/mcp-trust-proxy.ts` — remove. Document Service is called directly from server code.
- `controllers/controller.ts` — remove the welcome message controller. Replace with events controller.
- `services/service.ts` — remove the welcome message service.
---
## References
- [MCP Streamable HTTP Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [@modelcontextprotocol/sdk on npm (v1.26.0)](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
- [Why MCP Deprecated SSE](https://blog.fka.dev/blog/2025-06-06-why-mcp-deprecated-sse-and-go-with-streamable-http/)
- [MCP Transport Future (Dec 2025)](http://blog.modelcontextprotocol.io/posts/2025-12-19-mcp-transport-future/)
- [@sensinum/strapi-plugin-mcp (reference implementation)](https://github.com/VirtusLab-Open-Source/strapi-plugin-mcp)
- [MCP + Auth (Auth0)](https://auth0.com/blog/mcp-streamable-http/)
- [OWASP MCP Top 10](https://owasp.org/www-project-mcp-top-10/)