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,465 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import {
4
+ Box,
5
+ Button,
6
+ Dialog,
7
+ Flex,
8
+ IconButton,
9
+ Modal,
10
+ Typography,
11
+ Table,
12
+ Thead,
13
+ Tbody,
14
+ Tr,
15
+ Td,
16
+ Th,
17
+ } from '@strapi/design-system';
18
+ import { Pencil, Plus, Trash } from '@strapi/icons';
19
+ import { Filters, Pagination, SearchInput, useQueryParams } from '@strapi/strapi/admin';
20
+ import styled from 'styled-components';
21
+ import { useMcpApi } from '../lib/api';
22
+ import { PageHeader } from '../components/PageHeader';
23
+ import { applyMcpQuery, hasActiveQuery, paginate, type McpListQuery } from '../lib/applyQuery';
24
+
25
+ const CLIENT_FILTERS = [
26
+ {
27
+ name: 'type',
28
+ label: 'Type',
29
+ type: 'enumeration' as const,
30
+ options: [
31
+ { label: 'Confidential', value: 'confidential' },
32
+ { label: 'Public', value: 'public' },
33
+ ],
34
+ },
35
+ {
36
+ name: 'createdAt',
37
+ label: 'Created',
38
+ type: 'date' as const,
39
+ },
40
+ ];
41
+
42
+ interface AdminUser {
43
+ id: number;
44
+ email?: string;
45
+ firstname?: string;
46
+ lastname?: string;
47
+ username?: string;
48
+ }
49
+
50
+ interface ClientRow {
51
+ clientId: string;
52
+ clientName: string;
53
+ isConfidential: boolean;
54
+ redirectUris: string[];
55
+ scopes: string[];
56
+ disabled: boolean;
57
+ lastUsedAt?: string | null;
58
+ createdAt?: string | null;
59
+ ownerAdmin?: AdminUser | null;
60
+ createdByAdmin?: AdminUser | null;
61
+ }
62
+
63
+ function formatAdmin(user: AdminUser | null | undefined): string {
64
+ if (!user) return '—';
65
+ const name = [user.firstname, user.lastname].filter(Boolean).join(' ').trim();
66
+ return name || user.email || user.username || `#${user.id}`;
67
+ }
68
+
69
+ const ClickableTr = styled(Tr)`
70
+ cursor: pointer;
71
+ transition: background-color 100ms ease;
72
+ &:hover td {
73
+ background: ${({ theme }) => theme.colors.neutral100};
74
+ }
75
+ `;
76
+
77
+ function formatLastUsed(value: string | null | undefined): string {
78
+ if (!value) return 'Never';
79
+ const d = new Date(value);
80
+ if (Number.isNaN(d.getTime())) return 'Never';
81
+ return d.toLocaleString();
82
+ }
83
+
84
+ function formatCreated(value: string | null | undefined): string {
85
+ if (!value) return '—';
86
+ const d = new Date(value);
87
+ if (Number.isNaN(d.getTime())) return '—';
88
+ return d.toLocaleString();
89
+ }
90
+
91
+ function DetailRow({
92
+ label,
93
+ children,
94
+ }: {
95
+ label: string;
96
+ children: React.ReactNode;
97
+ }): JSX.Element {
98
+ return (
99
+ <Box paddingBottom={4}>
100
+ <Typography variant="sigma" textColor="neutral600">
101
+ {label}
102
+ </Typography>
103
+ <Box paddingTop={1}>{children}</Box>
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ export function Clients(): JSX.Element {
109
+ const api = useMcpApi();
110
+ const navigate = useNavigate();
111
+ const [clients, setClients] = useState<ClientRow[]>([]);
112
+ const [error, setError] = useState<string | null>(null);
113
+ const [loading, setLoading] = useState(true);
114
+ const [selected, setSelected] = useState<ClientRow | null>(null);
115
+ const [pendingDelete, setPendingDelete] = useState<ClientRow | null>(null);
116
+ const [deleting, setDeleting] = useState(false);
117
+ const [{ query }] = useQueryParams<McpListQuery>();
118
+
119
+ const filtered = useMemo(
120
+ () =>
121
+ applyMcpQuery(clients, query, {
122
+ searchText: (c) =>
123
+ [
124
+ c.clientName,
125
+ c.clientId,
126
+ formatAdmin(c.ownerAdmin),
127
+ formatAdmin(c.createdByAdmin),
128
+ ].join(' '),
129
+ field: (c, name) => {
130
+ if (name === 'type') return c.isConfidential ? 'confidential' : 'public';
131
+ if (name === 'createdAt') return c.createdAt ?? '';
132
+ return '';
133
+ },
134
+ }),
135
+ [clients, query]
136
+ );
137
+ const paged = useMemo(() => paginate(filtered, query), [filtered, query]);
138
+
139
+ const hasFilters = hasActiveQuery(query);
140
+
141
+ function refresh(): Promise<void> {
142
+ return api
143
+ .listClients()
144
+ .then((d: { clients: ClientRow[] }) => setClients(d.clients ?? []))
145
+ .catch((err: Error) => setError(err.message ?? String(err)))
146
+ .finally(() => setLoading(false));
147
+ }
148
+
149
+ useEffect(() => {
150
+ refresh();
151
+ }, []);
152
+
153
+ async function confirmDelete(): Promise<void> {
154
+ if (!pendingDelete) return;
155
+ setDeleting(true);
156
+ try {
157
+ await api.deleteClient(pendingDelete.clientId);
158
+ setPendingDelete(null);
159
+ setSelected(null);
160
+ await refresh();
161
+ } catch (err) {
162
+ setError((err as Error).message);
163
+ } finally {
164
+ setDeleting(false);
165
+ }
166
+ }
167
+
168
+ return (
169
+ <Box>
170
+ <PageHeader
171
+ title="Clients"
172
+ subtitle="OAuth 2.1 clients allowed to obtain MCP access tokens"
173
+ actions={
174
+ <Button startIcon={<Plus />} onClick={() => navigate('new')}>
175
+ New client
176
+ </Button>
177
+ }
178
+ />
179
+
180
+ {error && (
181
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
182
+ <Typography textColor="danger700">Failed: {error}</Typography>
183
+ </Box>
184
+ )}
185
+
186
+ <Flex gap={1} paddingBottom={4} alignItems="flex-start">
187
+ <SearchInput
188
+ label="Search clients"
189
+ placeholder="Search by name, client ID, owner, or creator"
190
+ />
191
+ <Filters.Root options={CLIENT_FILTERS}>
192
+ <Filters.Trigger />
193
+ <Filters.Popover />
194
+ <Filters.List />
195
+ </Filters.Root>
196
+ </Flex>
197
+
198
+ <Box background="neutral0" hasRadius shadow="tableShadow">
199
+ <Table colCount={6} rowCount={paged.rows.length}>
200
+ <Thead>
201
+ <Tr>
202
+ <Th>
203
+ <Typography variant="sigma">Name</Typography>
204
+ </Th>
205
+ <Th>
206
+ <Typography variant="sigma">Type</Typography>
207
+ </Th>
208
+ <Th>
209
+ <Typography variant="sigma">Created by</Typography>
210
+ </Th>
211
+ <Th>
212
+ <Typography variant="sigma">Owner</Typography>
213
+ </Th>
214
+ <Th>
215
+ <Typography variant="sigma">Created</Typography>
216
+ </Th>
217
+ <Th>
218
+ <Typography variant="sigma">&nbsp;</Typography>
219
+ </Th>
220
+ </Tr>
221
+ </Thead>
222
+ <Tbody>
223
+ {paged.rows.map((c) => (
224
+ <ClickableTr
225
+ key={c.clientId}
226
+ onClick={() => setSelected(c)}
227
+ role="button"
228
+ tabIndex={0}
229
+ onKeyDown={(e: React.KeyboardEvent) => {
230
+ if (e.key === 'Enter' || e.key === ' ') {
231
+ e.preventDefault();
232
+ setSelected(c);
233
+ }
234
+ }}
235
+ >
236
+ <Td>
237
+ <Typography variant="omega" fontWeight="semiBold">
238
+ {c.clientName}
239
+ </Typography>
240
+ </Td>
241
+ <Td>
242
+ <Typography variant="omega">
243
+ {c.isConfidential ? 'Confidential' : 'Public'}
244
+ </Typography>
245
+ </Td>
246
+ <Td>
247
+ <Typography variant="omega" textColor="neutral700">
248
+ {formatAdmin(c.createdByAdmin)}
249
+ </Typography>
250
+ </Td>
251
+ <Td>
252
+ <Typography variant="omega" textColor="neutral700">
253
+ {formatAdmin(c.ownerAdmin)}
254
+ </Typography>
255
+ </Td>
256
+ <Td>
257
+ <Typography variant="omega" textColor="neutral700">
258
+ {formatCreated(c.createdAt)}
259
+ </Typography>
260
+ </Td>
261
+ <Td onClick={(e: React.MouseEvent) => e.stopPropagation()}>
262
+ <Flex gap={2} justifyContent="flex-end">
263
+ <IconButton
264
+ label="Edit"
265
+ variant="ghost"
266
+ onClick={(e: React.MouseEvent) => {
267
+ e.stopPropagation();
268
+ navigate(`${c.clientId}/edit`);
269
+ }}
270
+ >
271
+ <Pencil />
272
+ </IconButton>
273
+ <IconButton
274
+ label="Delete"
275
+ variant="ghost"
276
+ onClick={(e: React.MouseEvent) => {
277
+ e.stopPropagation();
278
+ setPendingDelete(c);
279
+ }}
280
+ >
281
+ <Trash />
282
+ </IconButton>
283
+ </Flex>
284
+ </Td>
285
+ </ClickableTr>
286
+ ))}
287
+ {!loading && paged.rows.length === 0 && (
288
+ <Tr>
289
+ <Td colSpan={6}>
290
+ <Box paddingTop={6} paddingBottom={6}>
291
+ <Typography textColor="neutral600">
292
+ {clients.length === 0
293
+ ? 'No OAuth clients yet. Create one, or wait for an MCP client to register via DCR.'
294
+ : hasFilters
295
+ ? 'No clients match the current search or filters.'
296
+ : 'No clients to display.'}
297
+ </Typography>
298
+ </Box>
299
+ </Td>
300
+ </Tr>
301
+ )}
302
+ </Tbody>
303
+ </Table>
304
+ </Box>
305
+
306
+ {paged.total > 0 && (
307
+ <Box paddingTop={4}>
308
+ <Pagination.Root pageCount={paged.pageCount} total={paged.total}>
309
+ <Pagination.PageSize />
310
+ <Pagination.Links />
311
+ </Pagination.Root>
312
+ </Box>
313
+ )}
314
+
315
+ {/* Detail modal */}
316
+ <Modal.Root
317
+ open={selected !== null}
318
+ onOpenChange={(open) => !open && setSelected(null)}
319
+ >
320
+ <Modal.Content>
321
+ <Modal.Header>
322
+ <Modal.Title>{selected?.clientName ?? 'Client'}</Modal.Title>
323
+ </Modal.Header>
324
+ <Modal.Body>
325
+ {selected && (
326
+ <Box paddingTop={2}>
327
+ <DetailRow label="Client ID">
328
+ <Typography variant="omega" textColor="neutral800">
329
+ <code>{selected.clientId}</code>
330
+ </Typography>
331
+ </DetailRow>
332
+ <DetailRow label="Type">
333
+ <Typography variant="omega">
334
+ {selected.isConfidential ? 'Confidential' : 'Public'}
335
+ </Typography>
336
+ </DetailRow>
337
+ <DetailRow label="Created by">
338
+ <Typography variant="omega" textColor="neutral800">
339
+ {formatAdmin(selected.createdByAdmin)}
340
+ {selected.createdByAdmin?.email && (
341
+ <Typography variant="pi" textColor="neutral600">
342
+ {' '}
343
+ ({selected.createdByAdmin.email})
344
+ </Typography>
345
+ )}
346
+ </Typography>
347
+ </DetailRow>
348
+ <DetailRow label="Owner">
349
+ <Typography variant="omega" textColor="neutral800">
350
+ {formatAdmin(selected.ownerAdmin)}
351
+ {selected.ownerAdmin?.email && (
352
+ <Typography variant="pi" textColor="neutral600">
353
+ {' '}
354
+ ({selected.ownerAdmin.email})
355
+ </Typography>
356
+ )}
357
+ </Typography>
358
+ </DetailRow>
359
+ <DetailRow label="Created">
360
+ <Typography variant="omega" textColor="neutral800">
361
+ {formatCreated(selected.createdAt)}
362
+ </Typography>
363
+ </DetailRow>
364
+ <DetailRow label="Last used">
365
+ <Typography variant="omega" textColor="neutral800">
366
+ {formatLastUsed(selected.lastUsedAt)}
367
+ </Typography>
368
+ </DetailRow>
369
+ <DetailRow label="Scopes">
370
+ {selected.scopes.length === 0 ? (
371
+ <Typography variant="omega" textColor="neutral600">
372
+ (none)
373
+ </Typography>
374
+ ) : (
375
+ <Flex direction="column" gap={1} alignItems="flex-start">
376
+ {selected.scopes.map((s) => (
377
+ <Typography key={s} variant="omega">
378
+ <code>{s}</code>
379
+ </Typography>
380
+ ))}
381
+ </Flex>
382
+ )}
383
+ </DetailRow>
384
+ <DetailRow label="Redirect URIs">
385
+ {selected.redirectUris.length === 0 ? (
386
+ <Typography variant="omega" textColor="neutral600">
387
+ (none)
388
+ </Typography>
389
+ ) : (
390
+ <Flex direction="column" gap={1} alignItems="flex-start">
391
+ {selected.redirectUris.map((u) => (
392
+ <Typography key={u} variant="omega" textColor="neutral800">
393
+ <code>{u}</code>
394
+ </Typography>
395
+ ))}
396
+ </Flex>
397
+ )}
398
+ </DetailRow>
399
+ </Box>
400
+ )}
401
+ </Modal.Body>
402
+ <Modal.Footer justifyContent="space-between">
403
+ <Modal.Close>
404
+ <Button variant="tertiary">Cancel</Button>
405
+ </Modal.Close>
406
+ <Flex gap={2}>
407
+ <Button
408
+ variant="danger-light"
409
+ startIcon={<Trash />}
410
+ onClick={() => {
411
+ if (selected) {
412
+ setPendingDelete(selected);
413
+ setSelected(null);
414
+ }
415
+ }}
416
+ >
417
+ Delete
418
+ </Button>
419
+ <Button
420
+ variant="default"
421
+ startIcon={<Pencil />}
422
+ onClick={() => {
423
+ if (selected) {
424
+ const id = selected.clientId;
425
+ setSelected(null);
426
+ navigate(`${id}/edit`);
427
+ }
428
+ }}
429
+ >
430
+ Edit
431
+ </Button>
432
+ </Flex>
433
+ </Modal.Footer>
434
+ </Modal.Content>
435
+ </Modal.Root>
436
+
437
+ {/* Delete confirmation */}
438
+ <Dialog.Root
439
+ open={pendingDelete !== null}
440
+ onOpenChange={(open) => !open && setPendingDelete(null)}
441
+ >
442
+ <Dialog.Content>
443
+ <Dialog.Header>Delete client</Dialog.Header>
444
+ <Dialog.Body>
445
+ <Typography>
446
+ Delete <strong>{pendingDelete?.clientName}</strong>? This revokes the client_id,
447
+ invalidates any active sessions tied to it, and removes its stored refresh
448
+ tokens. This cannot be undone.
449
+ </Typography>
450
+ </Dialog.Body>
451
+ <Dialog.Footer>
452
+ <Dialog.Cancel>
453
+ <Button variant="tertiary">Cancel</Button>
454
+ </Dialog.Cancel>
455
+ <Dialog.Action>
456
+ <Button variant="danger-light" loading={deleting} onClick={confirmDelete}>
457
+ Delete client
458
+ </Button>
459
+ </Dialog.Action>
460
+ </Dialog.Footer>
461
+ </Dialog.Content>
462
+ </Dialog.Root>
463
+ </Box>
464
+ );
465
+ }