strapi-mcp-server 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/admin/src/components/PageHeader.tsx +33 -0
  4. package/admin/src/components/Sidebar.tsx +138 -0
  5. package/admin/src/index.tsx +54 -0
  6. package/admin/src/lib/api.ts +27 -0
  7. package/admin/src/lib/applyQuery.ts +152 -0
  8. package/admin/src/pages/App.tsx +126 -0
  9. package/admin/src/pages/AuditLog.tsx +386 -0
  10. package/admin/src/pages/Clients.tsx +465 -0
  11. package/admin/src/pages/EditClient.tsx +248 -0
  12. package/admin/src/pages/HomePage.tsx +378 -0
  13. package/admin/src/pages/NewClient.tsx +244 -0
  14. package/admin/src/pages/Settings.tsx +514 -0
  15. package/admin/src/pages/SsoBridge.tsx +96 -0
  16. package/admin/src/pages/Tools.tsx +68 -0
  17. package/admin/src/pluginId.ts +1 -0
  18. package/admin/src/translations/en.json +8 -0
  19. package/package.json +105 -0
  20. package/server/src/bootstrap.ts +118 -0
  21. package/server/src/config/index.ts +290 -0
  22. package/server/src/content-types/audit-log/index.ts +3 -0
  23. package/server/src/content-types/audit-log/schema.json +32 -0
  24. package/server/src/content-types/index.ts +19 -0
  25. package/server/src/content-types/oauth-auth-code/index.ts +3 -0
  26. package/server/src/content-types/oauth-auth-code/schema.json +31 -0
  27. package/server/src/content-types/oauth-client/index.ts +3 -0
  28. package/server/src/content-types/oauth-client/schema.json +33 -0
  29. package/server/src/content-types/oauth-consent/index.ts +3 -0
  30. package/server/src/content-types/oauth-consent/schema.json +21 -0
  31. package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
  32. package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
  33. package/server/src/content-types/oauth-revocation/index.ts +3 -0
  34. package/server/src/content-types/oauth-revocation/schema.json +18 -0
  35. package/server/src/content-types/oauth-signing-key/index.ts +3 -0
  36. package/server/src/content-types/oauth-signing-key/schema.json +21 -0
  37. package/server/src/controllers/admin/audit.ts +30 -0
  38. package/server/src/controllers/admin/clients.ts +148 -0
  39. package/server/src/controllers/admin/dashboard.ts +28 -0
  40. package/server/src/controllers/admin/index.ts +15 -0
  41. package/server/src/controllers/admin/settings.ts +38 -0
  42. package/server/src/controllers/admin/tools.ts +23 -0
  43. package/server/src/controllers/index.ts +13 -0
  44. package/server/src/controllers/mcp.ts +168 -0
  45. package/server/src/controllers/oauth/authorize.ts +418 -0
  46. package/server/src/controllers/oauth/index.ts +15 -0
  47. package/server/src/controllers/oauth/introspect.ts +45 -0
  48. package/server/src/controllers/oauth/metadata.ts +86 -0
  49. package/server/src/controllers/oauth/mode-guard.ts +22 -0
  50. package/server/src/controllers/oauth/register.ts +109 -0
  51. package/server/src/controllers/oauth/token.ts +206 -0
  52. package/server/src/controllers/proxy.ts +81 -0
  53. package/server/src/destroy.ts +28 -0
  54. package/server/src/index.ts +23 -0
  55. package/server/src/policies/authenticate.ts +81 -0
  56. package/server/src/policies/index.ts +13 -0
  57. package/server/src/policies/origin.ts +50 -0
  58. package/server/src/policies/rateLimit.ts +27 -0
  59. package/server/src/policies/scope.ts +32 -0
  60. package/server/src/register.ts +48 -0
  61. package/server/src/routes/admin.ts +85 -0
  62. package/server/src/routes/index.ts +13 -0
  63. package/server/src/routes/mcp.ts +31 -0
  64. package/server/src/routes/oauth.ts +81 -0
  65. package/server/src/routes/proxy.ts +29 -0
  66. package/server/src/services/audit.ts +158 -0
  67. package/server/src/services/heartbeat.ts +76 -0
  68. package/server/src/services/index.ts +37 -0
  69. package/server/src/services/instance-id.ts +30 -0
  70. package/server/src/services/mcp-server.ts +100 -0
  71. package/server/src/services/oauth/audience.ts +26 -0
  72. package/server/src/services/oauth/auth-codes.ts +78 -0
  73. package/server/src/services/oauth/clients.ts +386 -0
  74. package/server/src/services/oauth/consent.ts +38 -0
  75. package/server/src/services/oauth/errors.ts +32 -0
  76. package/server/src/services/oauth/pkce.ts +34 -0
  77. package/server/src/services/oauth/scopes.ts +42 -0
  78. package/server/src/services/oauth/signing-keys.ts +166 -0
  79. package/server/src/services/oauth/tokens.ts +324 -0
  80. package/server/src/services/permissions.ts +87 -0
  81. package/server/src/services/proxy-client.ts +167 -0
  82. package/server/src/services/rate-limiter.ts +180 -0
  83. package/server/src/services/redis.ts +139 -0
  84. package/server/src/services/session-directory.ts +121 -0
  85. package/server/src/services/session-store.ts +216 -0
  86. package/server/src/services/sso-cookie.ts +146 -0
  87. package/server/src/services/tools/content.ts +284 -0
  88. package/server/src/services/tools/index.ts +23 -0
  89. package/server/src/services/tools/media.ts +170 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Temitayo Salaudeen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # strapi-mcp-server
2
+
3
+ Expose a Strapi v5 instance as a **Model Context Protocol** (MCP) server. AI clients (Claude Code, Claude web, Cursor, Codex, opencode, Continue, etc.) authenticate via **OAuth 2.1 + PKCE** and then call tools to browse content-types, read entries, create/update drafts, list media, and upload files — all governed by Strapi's existing role-based permissions.
4
+
5
+ > Security posture: default-deny, disabled-by-default, full audit log, short-lived access tokens with rotating refresh tokens, family invalidation on reuse, mandatory PKCE S256, strict redirect-URI allowlists, Origin/Host validation, rate limiting.
6
+
7
+ ## Table of contents
8
+
9
+ - [Quick setup](#quick-setup)
10
+ - [Configuration reference](#configuration-reference)
11
+ - [External AS mode](#external-as-mode)
12
+ - [Endpoints](#endpoints)
13
+ - [Tools](#tools)
14
+ - [Horizontal scale](#horizontal-scale)
15
+
16
+ ## Quick setup
17
+
18
+ By default, clients connect with a pre-registered `client_id` + `client_secret` that you create in the Strapi admin UI. All major AI clients (Claude Code, Claude web, Codex via `mcp-remote`, opencode, Cursor) support this. The `client_secret` protects refresh tokens: if a refresh token ever leaks, it can't be used to mint new access tokens without the secret.
19
+
20
+ If you'd rather skip the manual client creation step, enable Dynamic Client Registration (DCR) so clients self-register on first connect — see [step 3](#3-optional-create-a-confidential-client) for how.
21
+
22
+ ### 1. Install
23
+
24
+ ```sh
25
+ npm install strapi-mcp-server
26
+ ```
27
+
28
+ ### 2. Enable the plugin
29
+
30
+ In `config/plugins.ts` (or `.js`):
31
+
32
+ ```ts
33
+ export default ({ env }) => ({
34
+ 'mcp-server': {
35
+ enabled: true,
36
+ config: {
37
+ enabled: true,
38
+ resourceUrl: env('MCP_RESOURCE_URL', 'http://localhost:1337/mcp'),
39
+ allowedOrigins: env.array('MCP_ALLOWED_ORIGINS', ['http://localhost:1337']),
40
+ },
41
+ },
42
+ });
43
+ ```
44
+
45
+ Restart Strapi.
46
+
47
+ ### 3. (Optional) Create a confidential client
48
+
49
+ Skip this step only if you've enabled DCR (`oauth: { dcr: { enabled: true } }` in step 2's config) and want clients to self-register on first connect. Otherwise, do this once:
50
+
51
+ 1. Open Strapi admin → **MCP Server → Clients → New client**
52
+ 2. **Name**: anything (e.g. `Claude Code — my-laptop`)
53
+ 3. **Redirect URIs**: leave blank — defaults to `http://localhost/callback` and accepts any loopback port (per RFC 8252 §7.3). Only fill in for non-loopback web clients.
54
+ 4. **Confidential**: tick "Generate client secret" → **Save**
55
+ 5. Copy the **Client ID** and **Client Secret** on the next screen. The secret is shown once.
56
+
57
+ You'll plug those values into your AI client in the next step.
58
+
59
+ ### 4. Connect your AI client
60
+
61
+ Each client speaks the same MCP Streamable HTTP transport. Pick the subsection for your client below and paste in the credentials from step 3. If you skipped step 3 (DCR mode), omit the credential block — each example notes which block to drop.
62
+
63
+ #### 4.1 Claude Code
64
+
65
+ ```sh
66
+ claude mcp add --transport http --scope user strapi http://localhost:1337/mcp \
67
+ --client-id <CLIENT_ID> \
68
+ --client-secret
69
+ ```
70
+
71
+ `--client-secret` prompts for the secret (or set `MCP_CLIENT_SECRET` in your env to skip the prompt). To use DCR instead, drop the last two flags.
72
+
73
+ Docs: [Claude Code MCP](https://docs.claude.com/en/docs/claude-code/mcp)
74
+
75
+ #### 4.2 Claude web (claude.ai)
76
+
77
+ Needs a public HTTPS URL — `claude.ai` can't reach `localhost`. Tunnel with `ngrok`, `cloudflared`, or `tailscale funnel`, set `resourceUrl` and `allowedOrigins` to the public hostname, restart Strapi. Then in **claude.ai → Settings → Connectors → Add connector**, paste the URL. For pre-registered credentials, expand **Advanced settings** in the dialog and fill in **OAuth Client ID** + **OAuth Client Secret**.
78
+
79
+ Docs: [Anthropic custom connectors](https://support.anthropic.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp)
80
+
81
+ #### 4.3 Codex CLI
82
+
83
+ `~/.codex/config.toml`. Codex doesn't speak HTTP transports natively — [`mcp-remote`](https://github.com/geelen/mcp-remote) bridges stdio↔HTTP. Pass `--static-oauth-client-info` for pre-registered credentials:
84
+
85
+ ```toml
86
+ [mcp_servers.strapi]
87
+ command = "npx"
88
+ args = [
89
+ "-y",
90
+ "mcp-remote",
91
+ "http://localhost:1337/mcp",
92
+ "--static-oauth-client-info",
93
+ "{\"client_id\":\"<CLIENT_ID>\",\"client_secret\":\"<CLIENT_SECRET>\"}"
94
+ ]
95
+ ```
96
+
97
+ Drop the last two args to use DCR.
98
+
99
+ Docs: [mcp-remote static client info](https://github.com/geelen/mcp-remote#static-oauth-client-information)
100
+
101
+ #### 4.4 opencode
102
+
103
+ `~/.config/opencode/opencode.json`:
104
+
105
+ ```json
106
+ {
107
+ "$schema": "https://opencode.ai/config.json",
108
+ "mcp": {
109
+ "strapi": {
110
+ "type": "remote",
111
+ "url": "http://localhost:1337/mcp",
112
+ "enabled": true,
113
+ "oauth": {
114
+ "clientId": "{env:MCP_CLIENT_ID}",
115
+ "clientSecret": "{env:MCP_CLIENT_SECRET}",
116
+ "scope": "strapi:content:read strapi:content:write strapi:media:read strapi:media:write"
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ Omit the `oauth` block to use DCR.
124
+
125
+ Docs: [opencode MCP OAuth](https://opencode.ai/docs/mcp-servers/#oauth)
126
+
127
+ #### 4.5 Cursor
128
+
129
+ `~/.cursor/mcp.json`:
130
+
131
+ ```json
132
+ {
133
+ "mcpServers": {
134
+ "strapi": {
135
+ "url": "http://localhost:1337/mcp",
136
+ "auth": {
137
+ "CLIENT_ID": "<CLIENT_ID>",
138
+ "CLIENT_SECRET": "<CLIENT_SECRET>",
139
+ "scopes": [
140
+ "strapi:content:read",
141
+ "strapi:content:write",
142
+ "strapi:media:read",
143
+ "strapi:media:write"
144
+ ]
145
+ }
146
+ }
147
+ }
148
+ }
149
+ ```
150
+
151
+ Omit the `auth` block to use DCR.
152
+
153
+ Docs: [Cursor static OAuth](https://cursor.com/docs/mcp#static-oauth-for-remote-servers)
154
+
155
+ ### 5. Authorize
156
+
157
+ Trigger the connection (e.g. for Claude Code: `claude` → `/mcp` → pick **strapi**). A browser opens to your Strapi admin login (if not already signed in), then a consent screen lists the client name + requested scopes. Click Approve. From this point your AI client can call MCP tools against your Strapi.
158
+
159
+ That's it. The rest of this README is reference material.
160
+
161
+ ### Notes that apply to every client
162
+
163
+ - Non-localhost targets require HTTPS, and the URL must be listed in `allowedOrigins`.
164
+ - Access tokens are short-lived (10 min default). Refresh is automatic via the rotating refresh token.
165
+ - To force re-auth, delete the client from **MCP Server → Clients** in the Strapi admin — this revokes its tokens and sessions.
166
+
167
+ ## Configuration reference
168
+
169
+ Every option, its default, and what it controls. All keys go under the plugin's `config: { ... }` block.
170
+
171
+ ### Top-level
172
+
173
+ | Option | Type | Default | Description |
174
+ | ---------------- | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
175
+ | `enabled` | `boolean` | `false` | Master switch. Must be `true` for the plugin to mount routes. |
176
+ | `resourceUrl` | `string` | _required_ | Canonical public URL of `/mcp` (e.g. `https://cms.example.com/mcp`). Used as JWT `aud` and to compute the OAuth issuer. |
177
+ | `allowedOrigins` | `string[]` | _required_ | Origins permitted to call `/mcp` and `/oauth/*` from a browser. CLI clients fall back to a Host check against `resourceUrl`. `'*'` is refused in production. |
178
+
179
+ ### OAuth (`oauth.*`)
180
+
181
+ | Option | Type | Default | Description |
182
+ | --------------------------------- | -------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | `oauth.mode` | `'embedded' \| 'external'` | `'embedded'` | `embedded` runs the OAuth Authorization Server inside the plugin. `external` delegates to an existing IdP — see below. |
184
+ | `oauth.accessTokenTtlSec` | `number` (60–3600) | `600` | Access-token JWT lifetime. |
185
+ | `oauth.refreshTokenTtlSec` | `number` (≥300) | `86400` | Refresh-token lifetime. Tokens rotate on every use; reuse triggers family-wide revocation. |
186
+ | `oauth.authCodeTtlSec` | `number` (10–600) | `60` | Authorization-code lifetime. |
187
+ | `oauth.ssoCookieTtlSec` | `number` | `900` | TTL of the cookie that ties an admin-login session to the OAuth consent screen. |
188
+ | `oauth.dcr.enabled` | `boolean` | `false` | Allow `POST /oauth/register` so MCP clients self-register on first connect. Off by default — admins create clients manually via the Clients page and inject `client_id` + `client_secret` into the AI client. Turn on if you want clients to register themselves. |
189
+ | `oauth.dcr.ratelimitPerHour` | `number` | `60` | Max successful DCR registrations per IP per hour when DCR is enabled. |
190
+ | `oauth.consent.rememberDays` | `number` | `0` | Skip the consent prompt for `rememberDays` once the same admin/client/scope tuple has been approved. `0` = always prompt. |
191
+ | `oauth.introspection.allowedIps` | `string[]` | `['127.0.0.1', '::1']` | IPs allowed to call `POST /oauth/introspect`. Loopback by default. |
192
+ | `oauth.external.issuer` | `string` | — | External AS issuer (required when `mode: 'external'`). Must match the `iss` claim. |
193
+ | `oauth.external.jwksUri` | `string` | — | External AS JWKS URL. |
194
+ | `oauth.external.adminLookupClaim` | `string` | `'email'` | JWT claim used to resolve the user to a Strapi admin. Supports `'email'` or `'username'`. |
195
+ | `oauth.external.enforceScopes` | `boolean` | `false` | Require `strapi:*` scopes in the JWT. Off by default so IdP setup stays portable. |
196
+
197
+ ### Sessions, rate limit, uploads, audit, tools
198
+
199
+ | Option | Type | Default | Description |
200
+ | ------------------------------------- | ---------- | --------------------------- | ----------------------------------------------------------------------------- |
201
+ | `session.idleTtlMs` | `number` | `1_800_000` (30 min) | Evict session after this idle window. |
202
+ | `session.hardTtlMs` | `number` | `86_400_000` (24 h) | Evict session this long after creation, regardless of activity. |
203
+ | `session.maxPerPrincipal` | `number` | `10` | Per-admin cap. Oldest evicted when exceeded. |
204
+ | `session.maxTotal` | `number` | `1000` | Process-wide cap. New `initialize` returns 503 when reached. |
205
+ | `rateLimit.perPrincipal.capacity` | `number` | `60` | Burst per admin. |
206
+ | `rateLimit.perPrincipal.refillPerSec` | `number` | `1` | Steady-state requests/sec per admin. |
207
+ | `rateLimit.perIp.capacity` | `number` | `120` | Burst per IP. |
208
+ | `rateLimit.perIp.refillPerSec` | `number` | `2` | Steady-state requests/sec per IP. |
209
+ | `upload.maxBytes` | `number` | `10_485_760` | Max upload size (10 MB). |
210
+ | `upload.mimeAllowlist` | `string[]` | (png, jpeg, webp, gif, pdf) | Accepted MIME types. |
211
+ | `upload.allowSvg` | `boolean` | `false` | Off because SVGs can carry XSS payloads. |
212
+ | `audit.retentionDays` | `number` | `90` | Daily cron deletes entries older than this. |
213
+ | `audit.redactKeyPatterns` | `string[]` | (password, token, …) | Object keys whose values are replaced with `[redacted]` before being written. |
214
+ | `tools.enabled[<toolName>]` | `boolean` | `true` | Per-tool master switch. See [Tools](#tools). |
215
+
216
+ ### Redis (`redis.*`, optional)
217
+
218
+ For single-instance deployments, leave this section out. For multi-instance, point the plugin at Redis so rate limits, sessions, and revocation events are cluster-wide. See [Horizontal scale](#horizontal-scale) for deployment shapes.
219
+
220
+ | Option | Type | Default | Description |
221
+ | --------------------------- | --------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `redis.enabled` | `boolean` | `false` | When `true`, shared state lives in Redis. |
223
+ | `redis.url` | `string` | _required when enabled_ | `redis://` or `rediss://` connection string. |
224
+ | `redis.keyPrefix` | `string` | `'mcp:'` | Prefix on every Redis key the plugin writes. |
225
+ | `redis.instanceId` | `string` | _auto_ | Override the auto-generated instance id. Set to a K8s pod name when you want logs to match the pod name. |
226
+ | `redis.internalAddress` | `string` | _unset_ | Private URL where peer instances can reach this one (e.g. `http://10.0.0.5:1337`). Setting this enables session routing. |
227
+ | `redis.internalSecret` | `string` | _required with above_ | ≥ 32-char shared secret used to sign cross-instance proxy requests. `openssl rand -hex 32` and inject same value into every instance. |
228
+ | `redis.heartbeatIntervalMs` | `number` | `10000` | How often each instance refreshes its liveness key. |
229
+ | `redis.heartbeatTtlMs` | `number` | `30000` | TTL of the liveness key. Must be > intervalMs. |
230
+
231
+ ## External AS mode
232
+
233
+ Delegate authentication to an existing OAuth 2.1 / OIDC provider (Auth0, Keycloak, Okta, etc.). The plugin acts purely as a resource server: verifies tokens issued by your IdP, runs tools under the matching Strapi admin identity. The embedded `/oauth/*` endpoints are disabled.
234
+
235
+ ### When to use it
236
+
237
+ - Your org has SSO and you want MCP traffic to obey the same policies (MFA, lifecycle, off-boarding).
238
+ - You don't want this plugin storing OAuth state (clients, refresh tokens, signing keys).
239
+
240
+ ### Configuration
241
+
242
+ ```ts
243
+ oauth: {
244
+ mode: 'external',
245
+ external: {
246
+ issuer: env('MCP_EXTERNAL_ISSUER'), // e.g. https://your-tenant.auth0.com/
247
+ jwksUri: env('MCP_EXTERNAL_JWKS_URI'), // e.g. https://your-tenant.auth0.com/.well-known/jwks.json
248
+ adminLookupClaim: 'email', // or 'username'
249
+ },
250
+ },
251
+ ```
252
+
253
+ Boot validator refuses to start without both `issuer` and `jwksUri`.
254
+
255
+ ### How requests are authenticated
256
+
257
+ 1. MCP client calls `/mcp` with `Authorization: Bearer <token>` issued by your IdP.
258
+ 2. Plugin fetches your IdP's JWKS, verifies signature + `iss` + `exp`.
259
+ 3. Reads the configured claim from the JWT (`email` by default) and looks up an active `admin::user` matching that value.
260
+ 4. If found, request proceeds under that admin's RBAC. Otherwise `401 invalid_token`.
261
+
262
+ Provision Strapi admin users ahead of time matching the IdP identities you want to allow.
263
+
264
+ ### Scopes
265
+
266
+ With `enforceScopes: false` (default), a verified JWT is granted the full tool surface — your IdP gates authentication, Strapi RBAC + per-tool toggles gate authorization. Set `true` only if you've defined `strapi:*` scopes as Client Scopes in your IdP.
267
+
268
+ ### Common IdP quirks
269
+
270
+ - **Audience**: external mode doesn't check `aud` by default.
271
+ - **`email` claim**: some IdPs require requesting the `email` scope. Make sure your client asks for it.
272
+ - **Tenant-scoped issuers**: Auth0 includes a trailing slash on `iss`; AWS Cognito doesn't. `external.issuer` must match the JWT's `iss` byte-for-byte.
273
+
274
+ ### Keycloak walkthrough (validated)
275
+
276
+ Quickest local test path. Other IdPs (Auth0, Okta, Entra ID, Cognito) work in principle but aren't documented step-by-step.
277
+
278
+ ```sh
279
+ docker run --name keycloak -p 8080:8080 \
280
+ -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
281
+ quay.io/keycloak/keycloak:latest start-dev
282
+ ```
283
+
284
+ In Keycloak admin (`admin`/`admin` at http://localhost:8080):
285
+
286
+ 1. **Create realm** `mcp-test`. Under **Authentication → Required actions**, toggle every "Default Action" off (avoids "account not fully set up" warnings during testing).
287
+ 2. **Clients → Create client** `mcp-test-client` with **Client authentication ON** (confidential) and **Standard flow ON**. Valid redirect URIs: `http://localhost:33418/callback` (Keycloak requires an exact port here — pin Claude Code's callback to match in step 5). On the **Credentials** tab, copy the **Client secret** — save it.
288
+ 3. **Users → Add user**. Email must match a real Strapi admin's email. **Email verified ON**. **Credentials → Set password** (uncheck Temporary).
289
+ 4. Point the plugin at Keycloak:
290
+
291
+ ```js
292
+ oauth: {
293
+ mode: 'external',
294
+ external: {
295
+ issuer: 'http://localhost:8080/realms/mcp-test',
296
+ jwksUri: 'http://localhost:8080/realms/mcp-test/protocol/openid-connect/certs',
297
+ },
298
+ },
299
+ ```
300
+
301
+ Restart Strapi. `curl http://localhost:1337/.well-known/oauth-protected-resource` should show your Keycloak realm as the authorization_server.
302
+
303
+ 5. Connect Claude Code (Keycloak doesn't allow anonymous DCR; use the pre-registered client):
304
+
305
+ ```sh
306
+ claude mcp add --transport http --scope user strapi http://localhost:1337/mcp \
307
+ --client-id mcp-test-client \
308
+ --client-secret \
309
+ --callback-port 33418
310
+ ```
311
+
312
+ In `~/.claude.json`, pin the scopes on the `strapi` entry to avoid Keycloak rejecting unknown realm scopes:
313
+
314
+ ```json
315
+ "oauth": { "clientId": "mcp-test-client", "callbackPort": 33418, "scopes": "openid email" }
316
+ ```
317
+
318
+ `claude` → `/mcp` → pick **strapi**. Sign in as your Keycloak user, approve consent, done.
319
+
320
+ **Production hardening**: use HTTPS everywhere, re-enable required actions on your realm, use a real DB backend instead of `start-dev`, and tighten realm session and access-token TTLs.
321
+
322
+ ## Endpoints
323
+
324
+ | Path | Purpose |
325
+ | --------------------------------------------- | -------------------------------------------------------------------------- |
326
+ | `POST/GET/DELETE /mcp` | MCP Streamable HTTP transport |
327
+ | `GET /.well-known/oauth-protected-resource` | RFC 9728 |
328
+ | `GET /.well-known/oauth-authorization-server` | RFC 8414 |
329
+ | `GET /oauth/authorize` | PKCE authorization endpoint (S256 only) |
330
+ | `POST /oauth/token` | Token endpoint (`authorization_code` + `refresh_token` grants) |
331
+ | `POST /oauth/revoke` | RFC 7009 |
332
+ | `POST /oauth/introspect` | RFC 7662 (loopback-only by default) |
333
+ | `POST /oauth/register` | RFC 7591 Dynamic Client Registration (only when `oauth.dcr.enabled: true`) |
334
+ | `GET /oauth/jwks` | Public JWKS |
335
+
336
+ The plugin reserves `/mcp`, `/.well-known/oauth-*`, `/oauth/*`, and `/register` at the app root.
337
+
338
+ ## Tools
339
+
340
+ | Tool | Scope |
341
+ | ------------------------------------------ | ---------------------- |
342
+ | `strapi.content.list_types` | `strapi:content:read` |
343
+ | `strapi.content.get_schema` | `strapi:content:read` |
344
+ | `strapi.content.list_entries` | `strapi:content:read` |
345
+ | `strapi.content.get_entry` | `strapi:content:read` |
346
+ | `strapi.content.create_entry` (draft only) | `strapi:content:write` |
347
+ | `strapi.content.update_entry` (draft only) | `strapi:content:write` |
348
+ | `strapi.media.list` | `strapi:media:read` |
349
+ | `strapi.media.upload` | `strapi:media:write` |
350
+
351
+ Delete, publish/unpublish, and user/role management are deliberately omitted. Every tool re-checks its scope and the Strapi RBAC permission at call time.
352
+
353
+ ## Horizontal scale
354
+
355
+ For single-instance deployments, the plugin works as-is. For multi-instance, pick one of three shapes.
356
+
357
+ ### Why session routing isn't trivial
358
+
359
+ An MCP session over Streamable HTTP includes a live TCP connection on the process that handled `initialize`. That connection can't move between processes. So a request with `Mcp-Session-Id: X` must land on the instance that owns X, or be forwarded there.
360
+
361
+ ### Three shapes
362
+
363
+ **1. Single instance.** One Strapi process. Default. Zero infra. Doesn't scale; doesn't survive a process restart.
364
+
365
+ **2. Sticky load balancer.** N Strapi processes behind an LB that hashes on `Mcp-Session-Id` (HAProxy, nginx, envoy — not AWS ALB). Same plugin config as single-instance. Per-instance rate limits, so a single user can burst `N × capacity`.
366
+
367
+ ```nginx
368
+ upstream mcp_backends {
369
+ hash $http_mcp_session_id consistent;
370
+ server strapi-1:1337;
371
+ server strapi-2:1337;
372
+ }
373
+ server {
374
+ location / {
375
+ proxy_pass http://mcp_backends;
376
+ proxy_http_version 1.1;
377
+ proxy_buffering off; # SSE
378
+ proxy_read_timeout 3600s;
379
+ }
380
+ }
381
+ ```
382
+
383
+ **3. Redis-routed.** N processes share a Redis. Each process registers its sessions in a directory; a request that lands on the wrong process is forwarded over HTTP to the owner. Cluster-wide rate limits via Lua-atomic Redis. Cluster-wide revocation via pub/sub. Heartbeats turn dead-owner cases into clean 404 re-init instead of 502.
384
+
385
+ ```ts
386
+ redis: {
387
+ enabled: true,
388
+ url: env('MCP_REDIS_URL'),
389
+ internalAddress: env('MCP_INTERNAL_ADDRESS'), // private URL of THIS instance
390
+ internalSecret: env('MCP_INTERNAL_SECRET'), // openssl rand -hex 32; same across instances
391
+ },
392
+ ```
393
+
394
+ ### Decision matrix
395
+
396
+ | | Single | Sticky LB | Redis-routed |
397
+ | ---------------------------------- | ------ | -------------------------- | ------------ |
398
+ | Strapi processes | 1 | N | N |
399
+ | Extra infrastructure | none | LB with consistent hashing | Redis |
400
+ | AWS ALB compatible | n/a | ❌ | ✅ |
401
+ | Auto-scaling friendly | ❌ | partial | ✅ |
402
+ | Rate limits cluster-wide | n/a | ❌ | ✅ |
403
+ | Revocation propagates cluster-wide | n/a | ❌ | ✅ |
404
+ | Extra hop on cross-instance call | n/a | 0 | ~3-10 ms |
405
+
406
+ ### Operational checklist for Redis-routed deploys
407
+
408
+ - `redis.url` points at a managed/monitored Redis. The plugin treats it as load-bearing — outage breaks routing.
409
+ - `redis.internalSecret` is ≥ 32 chars from `openssl rand -hex 32`, same on every instance, injected via secret manager.
410
+ - `redis.internalAddress` is the **private** address peers can reach (VPC IP, K8s service DNS) — not the public LB hostname.
411
+ - LB forwards `Authorization`, `Mcp-Session-Id`, `Origin`, `Accept`, `Content-Type` unmodified.
412
+ - LB disables response buffering and uses a long read timeout (`proxy_buffering off; proxy_read_timeout 3600s;`).
413
+ - `MCP_RESOURCE_URL` and `MCP_ALLOWED_ORIGINS` are identical on every instance.
414
+ - `heartbeatTtlMs` > 3× `heartbeatIntervalMs` to avoid spurious "dead" detections on transient hiccups.
415
+ - LB drops `/__mcp/proxy/*` from the public internet if possible. (HMAC is the primary gate; network isolation is defense in depth.)
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Box, Flex, Typography } from '@strapi/design-system';
3
+
4
+ interface PageHeaderProps {
5
+ title: string;
6
+ subtitle?: string;
7
+ actions?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Matches the Strapi "Settings → Overview" pattern: large alpha title with
12
+ * the subtitle on its own line directly underneath. Optional right-aligned
13
+ * actions slot for buttons.
14
+ */
15
+ export function PageHeader({ title, subtitle, actions }: PageHeaderProps): JSX.Element {
16
+ return (
17
+ <Flex justifyContent="space-between" alignItems="flex-start" paddingBottom={6}>
18
+ <Box>
19
+ <Typography variant="alpha" tag="h1">
20
+ {title}
21
+ </Typography>
22
+ {subtitle && (
23
+ <Box paddingTop={1}>
24
+ <Typography variant="epsilon" textColor="neutral600">
25
+ {subtitle}
26
+ </Typography>
27
+ </Box>
28
+ )}
29
+ </Box>
30
+ {actions && <Box>{actions}</Box>}
31
+ </Flex>
32
+ );
33
+ }
@@ -0,0 +1,138 @@
1
+ import { NavLink } from 'react-router-dom';
2
+ import styled from 'styled-components';
3
+ import { Box, Divider, Flex, Typography } from '@strapi/design-system';
4
+ import { useAuth } from '@strapi/strapi/admin';
5
+
6
+ interface UserPermission {
7
+ action: string;
8
+ subject: string | null;
9
+ }
10
+
11
+ /**
12
+ * Mirrors `@strapi/admin/src/components/SubNav` (the component Strapi's own
13
+ * settings sidebar is built from) so MCP looks like first-class admin chrome
14
+ * rather than a custom plugin pane.
15
+ *
16
+ * Key bits ported verbatim from Strapi:
17
+ * - neutral0 surface, 23.2rem wide, 5.6rem-tall header
18
+ * - Typography wraps each link label (proper font/line-height inherited)
19
+ * - active state lives on `.active > div`, applying primary100 bg + primary700 text
20
+ */
21
+
22
+ const SidebarRoot = styled.nav`
23
+ flex-shrink: 0;
24
+ width: 23.2rem;
25
+ background: ${({ theme }) => theme.colors.neutral0};
26
+ border-right: 1px solid ${({ theme }) => theme.colors.neutral150};
27
+ min-height: 100vh;
28
+ display: flex;
29
+ flex-direction: column;
30
+ position: sticky;
31
+ top: 0;
32
+ `;
33
+
34
+ const HeaderRow = styled(Flex)`
35
+ flex: 0 0 5.6rem;
36
+ height: 5.6rem;
37
+ `;
38
+
39
+ const ItemLink = styled(NavLink)`
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ text-decoration: none;
44
+ height: 32px;
45
+ color: ${({ theme }) => theme.colors.neutral800};
46
+
47
+ &.active > div {
48
+ background-color: ${({ theme }) => theme.colors.primary100};
49
+ color: ${({ theme }) => theme.colors.primary700};
50
+ font-weight: 500;
51
+ }
52
+
53
+ &:hover.active > div {
54
+ background-color: ${({ theme }) => theme.colors.primary100};
55
+ }
56
+
57
+ &:hover > div {
58
+ background-color: ${({ theme }) => theme.colors.neutral100};
59
+ }
60
+
61
+ &:focus-visible {
62
+ outline-offset: -2px;
63
+ }
64
+ `;
65
+
66
+ interface ItemProps {
67
+ to: string;
68
+ end?: boolean;
69
+ children: React.ReactNode;
70
+ }
71
+
72
+ function Item({ to, end, children }: ItemProps): JSX.Element {
73
+ return (
74
+ <ItemLink to={to} end={end}>
75
+ <Box width="100%" paddingLeft={3} paddingRight={3} borderRadius={1}>
76
+ <Typography tag="div" style={{ lineHeight: '32px' }}>
77
+ {children}
78
+ </Typography>
79
+ </Box>
80
+ </ItemLink>
81
+ );
82
+ }
83
+
84
+ export function Sidebar(): JSX.Element {
85
+ // Read permissions directly from the auth context to sidestep useRBAC's
86
+ // known timing issue (strapi/strapi#24384). The plugin top-level menu link
87
+ // already gates entry on `plugin::mcp-server.read`, so anyone reaching this
88
+ // sidebar has at least Read MCP dashboard.
89
+ const userPermissions =
90
+ (useAuth('Sidebar', (s: { permissions?: UserPermission[] }) => s?.permissions) ?? []);
91
+ const can = (action: string): boolean =>
92
+ userPermissions.some((p) => p.action === action);
93
+ const canRead = can('plugin::mcp-server.read');
94
+ const canManageClients = can('plugin::mcp-server.clients.manage');
95
+ const canReadAudit = can('plugin::mcp-server.audit.read');
96
+
97
+ return (
98
+ <SidebarRoot aria-label="MCP Server navigation">
99
+ <HeaderRow justifyContent="flex-start" alignItems="center" paddingLeft={5} paddingRight={5}>
100
+ <Typography variant="beta" tag="h2">
101
+ MCP Server
102
+ </Typography>
103
+ </HeaderRow>
104
+ <Divider />
105
+ <Box paddingTop={4} paddingBottom={4} paddingLeft={2} paddingRight={2}>
106
+ <Flex tag="ul" direction="column" alignItems="stretch" gap="2px">
107
+ {canRead && (
108
+ <li>
109
+ <Item to="" end>
110
+ Overview
111
+ </Item>
112
+ </li>
113
+ )}
114
+ {canManageClients && (
115
+ <li>
116
+ <Item to="clients">Clients</Item>
117
+ </li>
118
+ )}
119
+ {canRead && (
120
+ <li>
121
+ <Item to="tools">Tools</Item>
122
+ </li>
123
+ )}
124
+ {canReadAudit && (
125
+ <li>
126
+ <Item to="audit">Audit Log</Item>
127
+ </li>
128
+ )}
129
+ {canRead && (
130
+ <li>
131
+ <Item to="settings">Settings</Item>
132
+ </li>
133
+ )}
134
+ </Flex>
135
+ </Box>
136
+ </SidebarRoot>
137
+ );
138
+ }
@@ -0,0 +1,54 @@
1
+ import { Cog as PluginIcon } from '@strapi/icons';
2
+ import { PLUGIN_ID } from './pluginId';
3
+
4
+ const name = 'MCP Server';
5
+
6
+ export default {
7
+ register(app: {
8
+ addMenuLink: (opts: Record<string, unknown>) => void;
9
+ createSettingSection?: (opts: Record<string, unknown>, links: unknown[]) => void;
10
+ registerPlugin: (plugin: Record<string, unknown>) => void;
11
+ }): void {
12
+ app.addMenuLink({
13
+ to: `/plugins/${PLUGIN_ID}`,
14
+ icon: PluginIcon,
15
+ intlLabel: { id: `${PLUGIN_ID}.plugin.name`, defaultMessage: name },
16
+ Component: async () => {
17
+ const { App } = await import('./pages/App');
18
+ return { default: App };
19
+ },
20
+ // Permissions array is OR-matched: any one of these grants the menu
21
+ // entry. An audit-only user still sees the icon (and lands on the
22
+ // Audit Log page).
23
+ permissions: [
24
+ { action: `plugin::${PLUGIN_ID}.read`, subject: null },
25
+ { action: `plugin::${PLUGIN_ID}.audit.read`, subject: null },
26
+ { action: `plugin::${PLUGIN_ID}.clients.manage`, subject: null },
27
+ ],
28
+ });
29
+
30
+ app.registerPlugin({
31
+ id: PLUGIN_ID,
32
+ initializer: () => null,
33
+ isReady: true,
34
+ name,
35
+ });
36
+ },
37
+
38
+ bootstrap(): void {
39
+ /* no-op */
40
+ },
41
+
42
+ async registerTrads({ locales }: { locales: string[] }): Promise<unknown[]> {
43
+ return Promise.all(
44
+ locales.map(async (locale) => {
45
+ try {
46
+ const { default: data } = await import(`./translations/${locale}.json`);
47
+ return { data, locale };
48
+ } catch {
49
+ return { data: {}, locale };
50
+ }
51
+ })
52
+ );
53
+ },
54
+ };