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.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/admin/src/components/PageHeader.tsx +33 -0
- package/admin/src/components/Sidebar.tsx +138 -0
- package/admin/src/index.tsx +54 -0
- package/admin/src/lib/api.ts +27 -0
- package/admin/src/lib/applyQuery.ts +152 -0
- package/admin/src/pages/App.tsx +126 -0
- package/admin/src/pages/AuditLog.tsx +386 -0
- package/admin/src/pages/Clients.tsx +465 -0
- package/admin/src/pages/EditClient.tsx +248 -0
- package/admin/src/pages/HomePage.tsx +378 -0
- package/admin/src/pages/NewClient.tsx +244 -0
- package/admin/src/pages/Settings.tsx +514 -0
- package/admin/src/pages/SsoBridge.tsx +96 -0
- package/admin/src/pages/Tools.tsx +68 -0
- package/admin/src/pluginId.ts +1 -0
- package/admin/src/translations/en.json +8 -0
- package/package.json +105 -0
- package/server/src/bootstrap.ts +118 -0
- package/server/src/config/index.ts +290 -0
- package/server/src/content-types/audit-log/index.ts +3 -0
- package/server/src/content-types/audit-log/schema.json +32 -0
- package/server/src/content-types/index.ts +19 -0
- package/server/src/content-types/oauth-auth-code/index.ts +3 -0
- package/server/src/content-types/oauth-auth-code/schema.json +31 -0
- package/server/src/content-types/oauth-client/index.ts +3 -0
- package/server/src/content-types/oauth-client/schema.json +33 -0
- package/server/src/content-types/oauth-consent/index.ts +3 -0
- package/server/src/content-types/oauth-consent/schema.json +21 -0
- package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
- package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
- package/server/src/content-types/oauth-revocation/index.ts +3 -0
- package/server/src/content-types/oauth-revocation/schema.json +18 -0
- package/server/src/content-types/oauth-signing-key/index.ts +3 -0
- package/server/src/content-types/oauth-signing-key/schema.json +21 -0
- package/server/src/controllers/admin/audit.ts +30 -0
- package/server/src/controllers/admin/clients.ts +148 -0
- package/server/src/controllers/admin/dashboard.ts +28 -0
- package/server/src/controllers/admin/index.ts +15 -0
- package/server/src/controllers/admin/settings.ts +38 -0
- package/server/src/controllers/admin/tools.ts +23 -0
- package/server/src/controllers/index.ts +13 -0
- package/server/src/controllers/mcp.ts +168 -0
- package/server/src/controllers/oauth/authorize.ts +418 -0
- package/server/src/controllers/oauth/index.ts +15 -0
- package/server/src/controllers/oauth/introspect.ts +45 -0
- package/server/src/controllers/oauth/metadata.ts +86 -0
- package/server/src/controllers/oauth/mode-guard.ts +22 -0
- package/server/src/controllers/oauth/register.ts +109 -0
- package/server/src/controllers/oauth/token.ts +206 -0
- package/server/src/controllers/proxy.ts +81 -0
- package/server/src/destroy.ts +28 -0
- package/server/src/index.ts +23 -0
- package/server/src/policies/authenticate.ts +81 -0
- package/server/src/policies/index.ts +13 -0
- package/server/src/policies/origin.ts +50 -0
- package/server/src/policies/rateLimit.ts +27 -0
- package/server/src/policies/scope.ts +32 -0
- package/server/src/register.ts +48 -0
- package/server/src/routes/admin.ts +85 -0
- package/server/src/routes/index.ts +13 -0
- package/server/src/routes/mcp.ts +31 -0
- package/server/src/routes/oauth.ts +81 -0
- package/server/src/routes/proxy.ts +29 -0
- package/server/src/services/audit.ts +158 -0
- package/server/src/services/heartbeat.ts +76 -0
- package/server/src/services/index.ts +37 -0
- package/server/src/services/instance-id.ts +30 -0
- package/server/src/services/mcp-server.ts +100 -0
- package/server/src/services/oauth/audience.ts +26 -0
- package/server/src/services/oauth/auth-codes.ts +78 -0
- package/server/src/services/oauth/clients.ts +386 -0
- package/server/src/services/oauth/consent.ts +38 -0
- package/server/src/services/oauth/errors.ts +32 -0
- package/server/src/services/oauth/pkce.ts +34 -0
- package/server/src/services/oauth/scopes.ts +42 -0
- package/server/src/services/oauth/signing-keys.ts +166 -0
- package/server/src/services/oauth/tokens.ts +324 -0
- package/server/src/services/permissions.ts +87 -0
- package/server/src/services/proxy-client.ts +167 -0
- package/server/src/services/rate-limiter.ts +180 -0
- package/server/src/services/redis.ts +139 -0
- package/server/src/services/session-directory.ts +121 -0
- package/server/src/services/session-store.ts +216 -0
- package/server/src/services/sso-cookie.ts +146 -0
- package/server/src/services/tools/content.ts +284 -0
- package/server/src/services/tools/index.ts +23 -0
- 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
|
+
};
|