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
|
@@ -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';
|