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
@@ -0,0 +1,514 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Box, Flex, Typography, TextInput, Textarea, Grid } from '@strapi/design-system';
3
+ import { useMcpApi } from '../lib/api';
4
+ import { PageHeader } from '../components/PageHeader';
5
+
6
+ interface McpConfig {
7
+ enabled: boolean;
8
+ resourceUrl: string;
9
+ allowedOrigins: string[];
10
+ oauth: {
11
+ mode: string;
12
+ accessTokenTtlSec: number;
13
+ refreshTokenTtlSec: number;
14
+ authCodeTtlSec: number;
15
+ ssoCookieTtlSec: number;
16
+ dcr: { enabled: boolean; ratelimitPerHour: number };
17
+ consent: { rememberDays: number };
18
+ introspection: { allowedIps: string[] };
19
+ external?: {
20
+ issuer: string;
21
+ jwksUri: string;
22
+ adminLookupClaim?: string;
23
+ enforceScopes?: boolean;
24
+ };
25
+ };
26
+ session: {
27
+ idleTtlMs: number;
28
+ hardTtlMs: number;
29
+ maxPerPrincipal: number;
30
+ maxTotal: number;
31
+ sweepIntervalMs: number;
32
+ };
33
+ rateLimit: {
34
+ perPrincipal: { capacity: number; refillPerSec: number };
35
+ perIp: { capacity: number; refillPerSec: number };
36
+ };
37
+ upload: {
38
+ maxBytes: number;
39
+ mimeAllowlist: string[];
40
+ allowSvg: boolean;
41
+ };
42
+ audit: {
43
+ retentionDays: number;
44
+ redactKeyPatterns: string[];
45
+ drainIntervalMs: number;
46
+ drainBatchSize: number;
47
+ };
48
+ tools: { enabled: Record<string, boolean> };
49
+ redis?: {
50
+ enabled: boolean;
51
+ url: string;
52
+ keyPrefix?: string;
53
+ instanceId?: string;
54
+ internalAddress?: string;
55
+ internalSecret?: string;
56
+ heartbeatIntervalMs?: number;
57
+ heartbeatTtlMs?: number;
58
+ };
59
+ }
60
+
61
+ function Field({
62
+ label,
63
+ value,
64
+ hint,
65
+ }: {
66
+ label: string;
67
+ value: string | number | boolean;
68
+ hint?: string;
69
+ }): JSX.Element {
70
+ return (
71
+ <Box>
72
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
73
+ {label}
74
+ </Typography>
75
+ <Box paddingTop={2}>
76
+ <TextInput name={label} value={String(value)} disabled aria-readonly />
77
+ </Box>
78
+ {hint && (
79
+ <Box paddingTop={1}>
80
+ <Typography variant="pi" textColor="neutral600">
81
+ {hint}
82
+ </Typography>
83
+ </Box>
84
+ )}
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ function MultilineField({
90
+ label,
91
+ value,
92
+ hint,
93
+ rows = 3,
94
+ }: {
95
+ label: string;
96
+ value: string;
97
+ hint?: string;
98
+ rows?: number;
99
+ }): JSX.Element {
100
+ return (
101
+ <Box>
102
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
103
+ {label}
104
+ </Typography>
105
+ <Box paddingTop={2}>
106
+ <Textarea name={label} value={value} disabled rows={rows} />
107
+ </Box>
108
+ {hint && (
109
+ <Box paddingTop={1}>
110
+ <Typography variant="pi" textColor="neutral600">
111
+ {hint}
112
+ </Typography>
113
+ </Box>
114
+ )}
115
+ </Box>
116
+ );
117
+ }
118
+
119
+ function Card({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
120
+ return (
121
+ <Box background="neutral0" padding={6} hasRadius shadow="tableShadow">
122
+ <Box paddingBottom={4}>
123
+ <Typography variant="delta" tag="h2">
124
+ {title}
125
+ </Typography>
126
+ </Box>
127
+ {children}
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ function formatToolsEnabled(record: Record<string, boolean>): string {
133
+ const entries = Object.entries(record);
134
+ if (entries.length === 0) return '(empty — all tools enabled)';
135
+ return entries.map(([name, on]) => `${name} = ${on}`).join('\n');
136
+ }
137
+
138
+ export function Settings(): JSX.Element {
139
+ const api = useMcpApi();
140
+ const [cfg, setCfg] = useState<McpConfig | null>(null);
141
+ const [error, setError] = useState<string | null>(null);
142
+
143
+ useEffect(() => {
144
+ api
145
+ .settings()
146
+ .then(setCfg)
147
+ .catch((err: Error) => setError(err.message ?? String(err)));
148
+ }, []);
149
+
150
+ return (
151
+ <Box>
152
+ <PageHeader title="Settings" />
153
+
154
+ {error && (
155
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
156
+ <Typography textColor="danger700">Failed to load settings: {error}</Typography>
157
+ </Box>
158
+ )}
159
+
160
+ {!cfg && !error && <Typography>Loading…</Typography>}
161
+
162
+ {cfg && (
163
+ <Flex direction="column" gap={6} alignItems="stretch">
164
+ <Box background="neutral150" padding={4} hasRadius>
165
+ <Typography variant="omega" textColor="neutral700">
166
+ These values are configured in <code>config/plugins.js</code>. Secrets
167
+ (Redis password, internal secret) are masked.
168
+ </Typography>
169
+ </Box>
170
+
171
+ <Card title="Server">
172
+ <Grid.Root gap={5}>
173
+ <Grid.Item col={4} s={12} direction="column" alignItems="stretch">
174
+ <Field
175
+ label="Enabled"
176
+ value={cfg.enabled}
177
+ hint="Master switch. When false, /mcp and /oauth/* are unmounted and the plugin does nothing."
178
+ />
179
+ </Grid.Item>
180
+ <Grid.Item col={8} s={12} direction="column" alignItems="stretch">
181
+ <Field
182
+ label="Resource URL"
183
+ value={cfg.resourceUrl}
184
+ hint="Canonical URL clients reach this MCP server at. Used as the JWT aud claim and the OAuth resource indicator. Must match what clients actually connect to."
185
+ />
186
+ </Grid.Item>
187
+ <Grid.Item col={12} direction="column" alignItems="stretch">
188
+ <MultilineField
189
+ label="Allowed origins"
190
+ value={cfg.allowedOrigins.join('\n')}
191
+ hint="One per line — used for DNS-rebinding protection on /mcp and /oauth/*. Requests with an Origin header outside this list are rejected."
192
+ />
193
+ </Grid.Item>
194
+ </Grid.Root>
195
+ </Card>
196
+
197
+ <Card title="OAuth">
198
+ <Grid.Root gap={5}>
199
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
200
+ <Field
201
+ label="Mode"
202
+ value={cfg.oauth.mode}
203
+ hint="embedded = this plugin issues tokens. external = trust JWTs from an outside IdP (Auth0, Keycloak, …)."
204
+ />
205
+ </Grid.Item>
206
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
207
+ <Field
208
+ label="Access token TTL (sec)"
209
+ value={cfg.oauth.accessTokenTtlSec}
210
+ hint="Lifetime of bearer tokens minted at /oauth/token. Short on purpose to limit damage from a leaked token."
211
+ />
212
+ </Grid.Item>
213
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
214
+ <Field
215
+ label="Refresh token TTL (sec)"
216
+ value={cfg.oauth.refreshTokenTtlSec}
217
+ hint="Lifetime of the refresh token that can mint new access tokens. Rotated on every use; reuse invalidates the whole family."
218
+ />
219
+ </Grid.Item>
220
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
221
+ <Field
222
+ label="Auth code TTL (sec)"
223
+ value={cfg.oauth.authCodeTtlSec}
224
+ hint="Lifetime of the one-shot code returned to the redirect URI. Single-use and short to mitigate interception."
225
+ />
226
+ </Grid.Item>
227
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
228
+ <Field
229
+ label="SSO cookie TTL (sec)"
230
+ value={cfg.oauth.ssoCookieTtlSec}
231
+ hint="How long the consent-screen session is remembered after a successful admin login before re-authentication is required."
232
+ />
233
+ </Grid.Item>
234
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
235
+ <Field
236
+ label="DCR enabled"
237
+ value={cfg.oauth.dcr.enabled}
238
+ hint="Dynamic Client Registration — when on, MCP clients can self-register via POST /oauth/register. Admin still approves consent."
239
+ />
240
+ </Grid.Item>
241
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
242
+ <Field
243
+ label="DCR rate limit / hour"
244
+ value={cfg.oauth.dcr.ratelimitPerHour}
245
+ hint="Max self-registrations per source IP per hour. Excess returns 429."
246
+ />
247
+ </Grid.Item>
248
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
249
+ <Field
250
+ label="Consent remember (days)"
251
+ value={cfg.oauth.consent.rememberDays}
252
+ hint="0 = always prompt the admin. N > 0 = skip prompt if the same client + scope set was approved in the last N days."
253
+ />
254
+ </Grid.Item>
255
+ <Grid.Item col={12} direction="column" alignItems="stretch">
256
+ <MultilineField
257
+ label="Introspection allowed IPs"
258
+ value={cfg.oauth.introspection.allowedIps.join('\n')}
259
+ hint="Source IPs allowed to call POST /oauth/introspect. Defaults to loopback only — RFC 7662 is meant for internal RS callers, not the public internet."
260
+ />
261
+ </Grid.Item>
262
+ {cfg.oauth.external && (
263
+ <>
264
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
265
+ <Field
266
+ label="External issuer"
267
+ value={cfg.oauth.external.issuer}
268
+ hint="OIDC issuer URL of the external IdP. Used to verify the iss claim on inbound JWTs."
269
+ />
270
+ </Grid.Item>
271
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
272
+ <Field
273
+ label="External JWKS URI"
274
+ value={cfg.oauth.external.jwksUri}
275
+ hint="Public keys URL used to verify JWT signatures from the external IdP."
276
+ />
277
+ </Grid.Item>
278
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
279
+ <Field
280
+ label="Admin lookup claim"
281
+ value={cfg.oauth.external.adminLookupClaim ?? 'email'}
282
+ hint="JWT claim used to find the matching Strapi admin user. Default: email."
283
+ />
284
+ </Grid.Item>
285
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
286
+ <Field
287
+ label="Enforce scopes"
288
+ value={cfg.oauth.external.enforceScopes ?? false}
289
+ hint="When true, JWTs must carry strapi:* scopes (you must define them in your IdP). When false (default), Strapi RBAC alone gates tool access."
290
+ />
291
+ </Grid.Item>
292
+ </>
293
+ )}
294
+ </Grid.Root>
295
+ </Card>
296
+
297
+ <Card title="Sessions">
298
+ <Grid.Root gap={5}>
299
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
300
+ <Field
301
+ label="Idle TTL (ms)"
302
+ value={cfg.session.idleTtlMs}
303
+ hint="A session is evicted if it sees no traffic for this long."
304
+ />
305
+ </Grid.Item>
306
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
307
+ <Field
308
+ label="Hard TTL (ms)"
309
+ value={cfg.session.hardTtlMs}
310
+ hint="A session is force-closed at this age regardless of activity."
311
+ />
312
+ </Grid.Item>
313
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
314
+ <Field
315
+ label="Max per principal"
316
+ value={cfg.session.maxPerPrincipal}
317
+ hint="Cap on concurrent sessions per admin user. Oldest is evicted past this."
318
+ />
319
+ </Grid.Item>
320
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
321
+ <Field
322
+ label="Max total"
323
+ value={cfg.session.maxTotal}
324
+ hint="Cap on concurrent sessions per Node process. New connects beyond this get 503."
325
+ />
326
+ </Grid.Item>
327
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
328
+ <Field
329
+ label="Sweep interval (ms)"
330
+ value={cfg.session.sweepIntervalMs}
331
+ hint="How often the in-process sweeper scans for expired sessions to evict."
332
+ />
333
+ </Grid.Item>
334
+ </Grid.Root>
335
+ </Card>
336
+
337
+ <Card title="Rate limit">
338
+ <Grid.Root gap={5}>
339
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
340
+ <Field
341
+ label="Per principal — capacity"
342
+ value={cfg.rateLimit.perPrincipal.capacity}
343
+ hint="Token bucket size per admin user. Burst budget before throttling kicks in."
344
+ />
345
+ </Grid.Item>
346
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
347
+ <Field
348
+ label="Per principal — refill / sec"
349
+ value={cfg.rateLimit.perPrincipal.refillPerSec}
350
+ hint="Bucket refill rate per admin user — the steady-state requests-per-second allowance."
351
+ />
352
+ </Grid.Item>
353
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
354
+ <Field
355
+ label="Per IP — capacity"
356
+ value={cfg.rateLimit.perIp.capacity}
357
+ hint="Token bucket size per source IP. Defense layer above the per-principal limit."
358
+ />
359
+ </Grid.Item>
360
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
361
+ <Field
362
+ label="Per IP — refill / sec"
363
+ value={cfg.rateLimit.perIp.refillPerSec}
364
+ hint="Bucket refill rate per source IP."
365
+ />
366
+ </Grid.Item>
367
+ </Grid.Root>
368
+ </Card>
369
+
370
+ <Card title="Uploads">
371
+ <Grid.Root gap={5}>
372
+ <Grid.Item col={4} s={6} xs={12} direction="column" alignItems="stretch">
373
+ <Field
374
+ label="Max bytes"
375
+ value={cfg.upload.maxBytes}
376
+ hint="Largest single file accepted by strapi.media.upload."
377
+ />
378
+ </Grid.Item>
379
+ <Grid.Item col={4} s={6} xs={12} direction="column" alignItems="stretch">
380
+ <Field
381
+ label="Allow SVG"
382
+ value={cfg.upload.allowSvg}
383
+ hint="When false (recommended), SVG uploads are rejected. SVG can carry script tags and is XSS-risky if served in an img element."
384
+ />
385
+ </Grid.Item>
386
+ <Grid.Item col={12} direction="column" alignItems="stretch">
387
+ <MultilineField
388
+ label="MIME allowlist"
389
+ value={cfg.upload.mimeAllowlist.join('\n')}
390
+ hint="Only files whose detected MIME type appears in this list may be uploaded. One per line."
391
+ />
392
+ </Grid.Item>
393
+ </Grid.Root>
394
+ </Card>
395
+
396
+ <Card title="Audit">
397
+ <Grid.Root gap={5}>
398
+ <Grid.Item col={4} s={6} xs={12} direction="column" alignItems="stretch">
399
+ <Field
400
+ label="Retention (days)"
401
+ value={cfg.audit.retentionDays}
402
+ hint="Audit rows older than this are deleted by the nightly cron."
403
+ />
404
+ </Grid.Item>
405
+ <Grid.Item col={4} s={6} xs={12} direction="column" alignItems="stretch">
406
+ <Field
407
+ label="Drain interval (ms)"
408
+ value={cfg.audit.drainIntervalMs}
409
+ hint="How often the in-memory audit queue is flushed to the DB."
410
+ />
411
+ </Grid.Item>
412
+ <Grid.Item col={4} s={6} xs={12} direction="column" alignItems="stretch">
413
+ <Field
414
+ label="Drain batch size"
415
+ value={cfg.audit.drainBatchSize}
416
+ hint="Buffered audit writes also flush when the queue reaches this size, so bursts don't sit in memory."
417
+ />
418
+ </Grid.Item>
419
+ <Grid.Item col={12} direction="column" alignItems="stretch">
420
+ <MultilineField
421
+ label="Redact key patterns"
422
+ value={cfg.audit.redactKeyPatterns.join('\n')}
423
+ hint="Tool-call param keys matching any of these (case-insensitive substring) are stored as [redacted]. One pattern per line."
424
+ />
425
+ </Grid.Item>
426
+ </Grid.Root>
427
+ </Card>
428
+
429
+ <Card title="Tools">
430
+ <Grid.Root gap={5}>
431
+ <Grid.Item col={12} direction="column" alignItems="stretch">
432
+ <MultilineField
433
+ label="Per-tool toggles"
434
+ value={formatToolsEnabled(cfg.tools.enabled)}
435
+ hint="Override individual tool availability. Anything not listed defaults to enabled. Listed with `= false` is hidden from the catalog."
436
+ rows={Math.max(3, Object.keys(cfg.tools.enabled).length + 1)}
437
+ />
438
+ </Grid.Item>
439
+ </Grid.Root>
440
+ </Card>
441
+
442
+ <Card title="Redis (horizontal scale)">
443
+ {!cfg.redis ? (
444
+ <Typography variant="omega" textColor="neutral600">
445
+ Redis is not configured. The plugin runs single-instance with process-local
446
+ state. Add a <code>redis</code> block to plugin config to share rate-limit
447
+ buckets across nodes (and optionally enable session routing).
448
+ </Typography>
449
+ ) : (
450
+ <Grid.Root gap={5}>
451
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="stretch">
452
+ <Field
453
+ label="Enabled"
454
+ value={cfg.redis.enabled}
455
+ hint="When off, process-local state only. When on, rate-limiter buckets are shared across instances."
456
+ />
457
+ </Grid.Item>
458
+ <Grid.Item col={9} s={12} direction="column" alignItems="stretch">
459
+ <Field
460
+ label="URL"
461
+ value={cfg.redis.url}
462
+ hint="Redis connection string. Must start with redis:// or rediss://. Password is masked."
463
+ />
464
+ </Grid.Item>
465
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
466
+ <Field
467
+ label="Key prefix"
468
+ value={cfg.redis.keyPrefix ?? ''}
469
+ hint="Prefix on every Redis key this plugin creates. Helps coexist with other apps on a shared Redis."
470
+ />
471
+ </Grid.Item>
472
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
473
+ <Field
474
+ label="Instance ID"
475
+ value={cfg.redis.instanceId ?? ''}
476
+ hint="This Node process's identifier in the cluster. Auto-generated when blank."
477
+ />
478
+ </Grid.Item>
479
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
480
+ <Field
481
+ label="Internal address"
482
+ value={cfg.redis.internalAddress ?? ''}
483
+ hint="Internal URL peers use to proxy session traffic to this instance. Setting this AND the secret enables cross-instance session routing — any node can serve any session."
484
+ />
485
+ </Grid.Item>
486
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
487
+ <Field
488
+ label="Internal secret"
489
+ value={cfg.redis.internalSecret ?? ''}
490
+ hint="Shared HMAC secret used to authenticate cross-instance proxy calls. Must be at least 32 high-entropy characters. Stored value masked here."
491
+ />
492
+ </Grid.Item>
493
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
494
+ <Field
495
+ label="Heartbeat interval (ms)"
496
+ value={cfg.redis.heartbeatIntervalMs ?? 10000}
497
+ hint="How often this instance refreshes its alive-key. Peers use this to know which instances are reachable."
498
+ />
499
+ </Grid.Item>
500
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
501
+ <Field
502
+ label="Heartbeat TTL (ms)"
503
+ value={cfg.redis.heartbeatTtlMs ?? 30000}
504
+ hint="Lifetime of the heartbeat key. Should be at least 3x the interval to avoid spurious 'instance is dead' detections on transient hiccups."
505
+ />
506
+ </Grid.Item>
507
+ </Grid.Root>
508
+ )}
509
+ </Card>
510
+ </Flex>
511
+ )}
512
+ </Box>
513
+ );
514
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { Box, Typography } from '@strapi/design-system';
4
+
5
+ /**
6
+ * Bridge route used by the OAuth /authorize flow when the user is not yet
7
+ * holding a valid `mcp_admin_sso` cookie. The admin user is already logged
8
+ * into Strapi (this route is admin-private). We obtain an admin JWT and POST
9
+ * it to /oauth/sso-handoff, which sets the SSO cookie and tells us where to
10
+ * redirect next.
11
+ *
12
+ * Never echoes the JWT anywhere visible; never logs it.
13
+ */
14
+
15
+ /**
16
+ * Strapi v5 keeps the admin JWT in one of three places, in priority order:
17
+ * 1. localStorage["jwtToken"] (JSON-stringified) ← persist mode
18
+ * 2. document.cookie "jwtToken=<encoded>" ← non-persist, after first refresh
19
+ * 3. POST /admin/access-token with the httpOnly refresh cookie returns a
20
+ * fresh JWT in the response body.
21
+ * Mirror that lookup, with (3) as the always-works fallback.
22
+ */
23
+ async function getAdminToken(): Promise<string | null> {
24
+ if (typeof window === 'undefined') return null;
25
+
26
+ const fromLs = window.localStorage.getItem('jwtToken');
27
+ if (fromLs) {
28
+ try {
29
+ const parsed = JSON.parse(fromLs);
30
+ if (typeof parsed === 'string' && parsed) {
31
+ return parsed;
32
+ }
33
+ } catch {
34
+ return fromLs;
35
+ }
36
+ }
37
+
38
+ const cookieMatch = document.cookie
39
+ .split(';')
40
+ .map((c) => c.trim().split('='))
41
+ .find(([k]) => k === 'jwtToken');
42
+ if (cookieMatch && cookieMatch[1]) {
43
+ const decoded = decodeURIComponent(cookieMatch[1]);
44
+ if (decoded) {
45
+ return decoded;
46
+ }
47
+ }
48
+
49
+ try {
50
+ const resp = await fetch('/admin/access-token', {
51
+ method: 'POST',
52
+ credentials: 'include',
53
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
54
+ });
55
+ if (!resp.ok) return null;
56
+ const data = (await resp.json()) as { data?: { token?: string } };
57
+ return data?.data?.token ?? null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function SsoBridge(): JSX.Element {
64
+ const [params] = useSearchParams();
65
+ const [error, setError] = useState<string | null>(null);
66
+
67
+ useEffect(() => {
68
+ const next = params.get('next') ?? '/admin';
69
+ (async () => {
70
+ const token = await getAdminToken();
71
+ if (!token) {
72
+ setError('No admin session detected. Please log in and try again.');
73
+ return;
74
+ }
75
+ try {
76
+ const resp = await fetch('/oauth/sso-handoff', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ credentials: 'include',
80
+ body: JSON.stringify({ adminToken: token, next }),
81
+ });
82
+ if (!resp.ok) throw new Error(`handoff failed: ${resp.status}`);
83
+ const data = (await resp.json()) as { next?: string };
84
+ window.location.replace(data.next ?? next);
85
+ } catch (err) {
86
+ setError((err as Error).message);
87
+ }
88
+ })();
89
+ }, [params]);
90
+
91
+ return (
92
+ <Box padding={6}>
93
+ <Typography>{error ?? 'Completing MCP authorization…'}</Typography>
94
+ </Box>
95
+ );
96
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Box, Typography, Table, Thead, Tbody, Tr, Td, Th } from '@strapi/design-system';
3
+ import { useMcpApi } from '../lib/api';
4
+ import { PageHeader } from '../components/PageHeader';
5
+
6
+ interface ToolRow {
7
+ name: string;
8
+ scope: string;
9
+ }
10
+
11
+ export function Tools(): JSX.Element {
12
+ const api = useMcpApi();
13
+ const [tools, setTools] = useState<ToolRow[]>([]);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ useEffect(() => {
17
+ api
18
+ .tools()
19
+ .then((d: { tools: ToolRow[] }) => setTools(d.tools ?? []))
20
+ .catch((err: Error) => setError(err.message ?? String(err)));
21
+ }, []);
22
+
23
+ return (
24
+ <Box>
25
+ <PageHeader
26
+ title="Tools"
27
+ subtitle="MCP tools registered for this server, with the OAuth scope each requires"
28
+ />
29
+
30
+ {error && (
31
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
32
+ <Typography textColor="danger700">Failed to load tools: {error}</Typography>
33
+ </Box>
34
+ )}
35
+
36
+ <Box background="neutral0" hasRadius shadow="tableShadow">
37
+ <Table colCount={2} rowCount={tools.length}>
38
+ <Thead>
39
+ <Tr>
40
+ <Th>
41
+ <Typography variant="sigma">Tool</Typography>
42
+ </Th>
43
+ <Th>
44
+ <Typography variant="sigma">Required scope</Typography>
45
+ </Th>
46
+ </Tr>
47
+ </Thead>
48
+ <Tbody>
49
+ {tools.map((t) => (
50
+ <Tr key={t.name}>
51
+ <Td>
52
+ <Typography variant="omega" fontWeight="semiBold">
53
+ {t.name}
54
+ </Typography>
55
+ </Td>
56
+ <Td>
57
+ <Typography variant="omega" textColor="neutral700">
58
+ {t.scope}
59
+ </Typography>
60
+ </Td>
61
+ </Tr>
62
+ ))}
63
+ </Tbody>
64
+ </Table>
65
+ </Box>
66
+ </Box>
67
+ );
68
+ }
@@ -0,0 +1 @@
1
+ export const PLUGIN_ID = 'mcp-server';