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,27 @@
1
+ import { useFetchClient } from '@strapi/strapi/admin';
2
+ import { PLUGIN_ID } from '../pluginId';
3
+
4
+ // Strapi mounts plugin admin routes at `/<plugin-name>/<path>` at the host root
5
+ // (no `/api` or `/admin` prefix). useFetchClient handles the admin JWT, base
6
+ // URL, and error normalization for us.
7
+ const base = `/${PLUGIN_ID}`;
8
+
9
+ export function useMcpApi() {
10
+ const { get, post, put, del } = useFetchClient();
11
+ return {
12
+ overview: async () => (await get(`${base}/dashboard`)).data,
13
+ listClients: async () => (await get(`${base}/clients`)).data,
14
+ getClient: async (clientId: string) =>
15
+ (await get(`${base}/clients/${clientId}`)).data,
16
+ createClient: async (body: Record<string, unknown>) =>
17
+ (await post(`${base}/clients`, body)).data,
18
+ updateClient: async (clientId: string, body: Record<string, unknown>) =>
19
+ (await put(`${base}/clients/${clientId}`, body)).data,
20
+ deleteClient: async (clientId: string) =>
21
+ (await del(`${base}/clients/${clientId}`)).data,
22
+ listAudit: async (params: Record<string, string | number> = {}) =>
23
+ (await get(`${base}/audit`, { params })).data,
24
+ settings: async () => (await get(`${base}/settings`)).data,
25
+ tools: async () => (await get(`${base}/tools`)).data,
26
+ };
27
+ }
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Client-side application of Strapi's URL-driven list query (`_q` search +
5
+ * `filters.$and` clauses produced by the native `SearchInput` and `Filters`
6
+ * components). Our admin pages load all rows up front (audit capped at 200,
7
+ * clients typically few), so we filter in-memory rather than round-tripping.
8
+ */
9
+
10
+ type FilterClause = Record<string, Record<string, string>>;
11
+
12
+ export interface McpListQuery {
13
+ _q?: string;
14
+ filters?: { $and?: FilterClause[] };
15
+ /** Strapi's Pagination component writes these to the URL as strings. */
16
+ page?: string | number;
17
+ pageSize?: string | number;
18
+ }
19
+
20
+ export const DEFAULT_PAGE_SIZE = 10;
21
+
22
+ /**
23
+ * Slice an array to the page selected in the URL query. Pairs with Strapi's
24
+ * native `Pagination` component, which reads/writes `page` + `pageSize` URL
25
+ * params on the same query state our filters and search already use. Returns
26
+ * the visible slice plus pageCount/total for `Pagination.Root`.
27
+ */
28
+ export function paginate<T>(
29
+ rows: T[],
30
+ query: McpListQuery
31
+ ): { rows: T[]; pageCount: number; total: number; page: number; pageSize: number } {
32
+ const pageSize = Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE);
33
+ const total = rows.length;
34
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
35
+ const page = Math.min(pageCount, Math.max(1, Number(query.page) || 1));
36
+ const start = (page - 1) * pageSize;
37
+ return { rows: rows.slice(start, start + pageSize), pageCount, total, page, pageSize };
38
+ }
39
+
40
+ /**
41
+ * Day-granular comparison for $gt/$gte/$lt/$lte and date-aware $eq/$ne. We
42
+ * compare only the `YYYY-MM-DD` prefix of each value, so a `date` filter
43
+ * ("May 27") matches a row stored at any time on that day. ISO date prefixes
44
+ * sort lexicographically, so a string compare is correct and timezone-stable.
45
+ * Returns `negative | 0 | positive`, or null when either side isn't a date
46
+ * (so enum filters fall back to exact string matching).
47
+ */
48
+ const DATE_PREFIX_RE = /^(\d{4}-\d{2}-\d{2})/;
49
+
50
+ function datePart(value: string): string | null {
51
+ const m = DATE_PREFIX_RE.exec(value);
52
+ return m ? m[1] : null;
53
+ }
54
+
55
+ function dateCompare(a: string, b: string): number | null {
56
+ const ap = datePart(a);
57
+ const bp = datePart(b);
58
+ if (ap === null || bp === null) return null;
59
+ return ap < bp ? -1 : ap > bp ? 1 : 0;
60
+ }
61
+
62
+ function ordCompare(a: string, b: string): number {
63
+ const d = dateCompare(a, b);
64
+ if (d !== null) return d;
65
+ const an = Number(a);
66
+ const bn = Number(b);
67
+ if (a !== '' && b !== '' && !Number.isNaN(an) && !Number.isNaN(bn)) return an - bn;
68
+ return a < b ? -1 : a > b ? 1 : 0;
69
+ }
70
+
71
+ function matchOp(rowValue: string | null | undefined, op: string, value: string): boolean {
72
+ const rv = rowValue ?? '';
73
+ const rvl = rv.toLowerCase();
74
+ const vl = (value ?? '').toLowerCase();
75
+ switch (op) {
76
+ case '$eq': {
77
+ const d = dateCompare(rv, value);
78
+ return d !== null ? d === 0 : rv === value;
79
+ }
80
+ case '$eqi':
81
+ return rvl === vl;
82
+ case '$ne': {
83
+ const d = dateCompare(rv, value);
84
+ return d !== null ? d !== 0 : rv !== value;
85
+ }
86
+ case '$nei':
87
+ return rvl !== vl;
88
+ case '$null':
89
+ return rv === '';
90
+ case '$notNull':
91
+ return rv !== '';
92
+ case '$gt':
93
+ return rv !== '' && ordCompare(rv, value) > 0;
94
+ case '$gte':
95
+ return rv !== '' && ordCompare(rv, value) >= 0;
96
+ case '$lt':
97
+ return rv !== '' && ordCompare(rv, value) < 0;
98
+ case '$lte':
99
+ return rv !== '' && ordCompare(rv, value) <= 0;
100
+ case '$contains':
101
+ return rv.includes(value);
102
+ case '$containsi':
103
+ return rvl.includes(vl);
104
+ case '$notContains':
105
+ return !rv.includes(value);
106
+ case '$notContainsi':
107
+ return !rvl.includes(vl);
108
+ case '$startsWith':
109
+ return rv.startsWith(value);
110
+ case '$endsWith':
111
+ return rv.endsWith(value);
112
+ default:
113
+ // Unknown operator → don't exclude the row.
114
+ return true;
115
+ }
116
+ }
117
+
118
+ export function applyMcpQuery<T>(
119
+ rows: T[],
120
+ query: McpListQuery,
121
+ opts: {
122
+ /** Concatenated searchable text for the `_q` free-text match. */
123
+ searchText: (row: T) => string;
124
+ /** Resolve a filter field name to the row's comparable string value. */
125
+ field: (row: T, name: string) => string | null | undefined;
126
+ }
127
+ ): T[] {
128
+ let result = rows;
129
+
130
+ const q = (query._q ?? '').trim().toLowerCase();
131
+ if (q) {
132
+ result = result.filter((r) => opts.searchText(r).toLowerCase().includes(q));
133
+ }
134
+
135
+ const and = query.filters?.$and ?? [];
136
+ for (const clause of and) {
137
+ const entries = Object.entries(clause);
138
+ if (entries.length === 0) continue;
139
+ const [fieldName, opObj] = entries[0];
140
+ const opEntries = Object.entries(opObj ?? {});
141
+ if (opEntries.length === 0) continue;
142
+ const [op, value] = opEntries[0];
143
+ result = result.filter((r) => matchOp(opts.field(r, fieldName), op, String(value)));
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ /** True when the query carries any active search text or filter clause. */
150
+ export function hasActiveQuery(query: McpListQuery): boolean {
151
+ return Boolean((query._q ?? '').trim()) || (query.filters?.$and?.length ?? 0) > 0;
152
+ }
@@ -0,0 +1,126 @@
1
+ import { Routes, Route, Navigate } from 'react-router-dom';
2
+ import { Box, Flex } from '@strapi/design-system';
3
+ import { useAuth } from '@strapi/strapi/admin';
4
+ import { Sidebar } from '../components/Sidebar';
5
+ import { HomePage } from './HomePage';
6
+ import { Clients } from './Clients';
7
+ import { NewClient } from './NewClient';
8
+ import { EditClient } from './EditClient';
9
+ import { Tools } from './Tools';
10
+ import { AuditLog } from './AuditLog';
11
+ import { Settings } from './Settings';
12
+ import { SsoBridge } from './SsoBridge';
13
+
14
+ interface UserPermission {
15
+ action: string;
16
+ subject: string | null;
17
+ }
18
+
19
+ const ACTION_READ = 'plugin::mcp-server.read';
20
+ const ACTION_AUDIT = 'plugin::mcp-server.audit.read';
21
+ const ACTION_CLIENTS = 'plugin::mcp-server.clients.manage';
22
+
23
+ function useUserPermissions(): UserPermission[] {
24
+ return (
25
+ useAuth('mcp-server', (s: { permissions?: UserPermission[] }) => s?.permissions) ?? []
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Wrap a route with a permission check. If the user lacks the action, redirect
31
+ * to the plugin's index (which itself redirects to the user's landing page).
32
+ * The Sidebar already hides the menu entry — this catches direct URL access.
33
+ */
34
+ function Protected({
35
+ action,
36
+ children,
37
+ }: {
38
+ action: string;
39
+ children: JSX.Element;
40
+ }): JSX.Element {
41
+ const userPermissions = useUserPermissions();
42
+ const hasAccess = userPermissions.some((p) => p.action === action);
43
+ if (!hasAccess) return <Navigate to="" replace />;
44
+ return children;
45
+ }
46
+
47
+ /**
48
+ * Index route. The plugin's Overview page requires `read`; a user who only
49
+ * has `audit.read` or `clients.manage` should land on the page they CAN see
50
+ * instead of getting a "Failed: Policy Failed" error on the dashboard.
51
+ */
52
+ function IndexRoute(): JSX.Element {
53
+ const userPermissions = useUserPermissions();
54
+ const has = (a: string): boolean => userPermissions.some((p) => p.action === a);
55
+ if (has(ACTION_READ)) return <HomePage />;
56
+ if (has(ACTION_CLIENTS)) return <Navigate to="clients" replace />;
57
+ if (has(ACTION_AUDIT)) return <Navigate to="audit" replace />;
58
+ // No MCP permission at all — Strapi shouldn't have routed them here, but
59
+ // belt-and-suspenders, push them back to the admin home.
60
+ return <Navigate to="/" replace />;
61
+ }
62
+
63
+ export function App(): JSX.Element {
64
+ return (
65
+ <Flex alignItems="stretch" minHeight="100vh">
66
+ <Sidebar />
67
+ <Box flex={1} padding={10} background="neutral100" overflow="auto">
68
+ <Routes>
69
+ <Route index element={<IndexRoute />} />
70
+ <Route
71
+ path="clients"
72
+ element={
73
+ <Protected action={ACTION_CLIENTS}>
74
+ <Clients />
75
+ </Protected>
76
+ }
77
+ />
78
+ <Route
79
+ path="clients/new"
80
+ element={
81
+ <Protected action={ACTION_CLIENTS}>
82
+ <NewClient />
83
+ </Protected>
84
+ }
85
+ />
86
+ <Route
87
+ path="clients/:clientId/edit"
88
+ element={
89
+ <Protected action={ACTION_CLIENTS}>
90
+ <EditClient />
91
+ </Protected>
92
+ }
93
+ />
94
+ <Route
95
+ path="tools"
96
+ element={
97
+ <Protected action={ACTION_READ}>
98
+ <Tools />
99
+ </Protected>
100
+ }
101
+ />
102
+ <Route
103
+ path="audit"
104
+ element={
105
+ <Protected action={ACTION_AUDIT}>
106
+ <AuditLog />
107
+ </Protected>
108
+ }
109
+ />
110
+ <Route
111
+ path="settings"
112
+ element={
113
+ <Protected action={ACTION_READ}>
114
+ <Settings />
115
+ </Protected>
116
+ }
117
+ />
118
+ <Route path="sso-bridge" element={<SsoBridge />} />
119
+ <Route path="*" element={<Navigate to="" replace />} />
120
+ </Routes>
121
+ </Box>
122
+ </Flex>
123
+ );
124
+ }
125
+
126
+ export default App;
@@ -0,0 +1,386 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Badge,
4
+ Box,
5
+ Button,
6
+ Flex,
7
+ IconButton,
8
+ Modal,
9
+ Table,
10
+ Tbody,
11
+ Td,
12
+ Th,
13
+ Thead,
14
+ Tr,
15
+ Typography,
16
+ } from '@strapi/design-system';
17
+ import { Eye } from '@strapi/icons';
18
+ import { Filters, Pagination, SearchInput, useQueryParams } from '@strapi/strapi/admin';
19
+ import { useMcpApi } from '../lib/api';
20
+ import { PageHeader } from '../components/PageHeader';
21
+ import { applyMcpQuery, hasActiveQuery, paginate, type McpListQuery } from '../lib/applyQuery';
22
+
23
+ interface PrincipalAdmin {
24
+ id: number;
25
+ email?: string;
26
+ firstname?: string;
27
+ lastname?: string;
28
+ username?: string;
29
+ }
30
+
31
+ interface AuditClient {
32
+ clientId: string;
33
+ clientName: string;
34
+ }
35
+
36
+ interface Entry {
37
+ ts: string;
38
+ principalType?: string;
39
+ principalId: string;
40
+ principalAdmin?: PrincipalAdmin | null;
41
+ sessionId?: string | null;
42
+ clientId?: string | null;
43
+ client?: AuditClient | null;
44
+ tool: string;
45
+ params?: unknown;
46
+ resultStatus: 'ok' | 'error';
47
+ errorCode?: string | null;
48
+ durationMs?: number | null;
49
+ ip?: string | null;
50
+ userAgent?: string | null;
51
+ }
52
+
53
+ function formatPrincipal(admin: PrincipalAdmin | null | undefined, fallbackId: string): string {
54
+ if (!admin) return fallbackId ? `#${fallbackId}` : '—';
55
+ const name = [admin.firstname, admin.lastname].filter(Boolean).join(' ').trim();
56
+ return name || admin.email || admin.username || `#${admin.id}`;
57
+ }
58
+
59
+ function DetailRow({
60
+ label,
61
+ children,
62
+ }: {
63
+ label: string;
64
+ children: React.ReactNode;
65
+ }): JSX.Element {
66
+ return (
67
+ <Box paddingBottom={4}>
68
+ <Typography variant="sigma" textColor="neutral600">
69
+ {label}
70
+ </Typography>
71
+ <Box paddingTop={1}>{children}</Box>
72
+ </Box>
73
+ );
74
+ }
75
+
76
+ export function AuditLog(): JSX.Element {
77
+ const api = useMcpApi();
78
+ const [entries, setEntries] = useState<Entry[]>([]);
79
+ const [error, setError] = useState<string | null>(null);
80
+ const [loading, setLoading] = useState(true);
81
+ const [selected, setSelected] = useState<Entry | null>(null);
82
+ const [{ query }] = useQueryParams<McpListQuery>();
83
+
84
+ useEffect(() => {
85
+ api
86
+ .listAudit({ limit: 200 })
87
+ .then((d: { entries: Entry[] }) => setEntries(d.entries ?? []))
88
+ .catch((err: Error) => setError(err.message ?? String(err)))
89
+ .finally(() => setLoading(false));
90
+ }, []);
91
+
92
+ // Distinct tools present in the loaded entries, for the Tool filter dropdown.
93
+ const auditFilters = useMemo(
94
+ () => [
95
+ {
96
+ name: 'tool',
97
+ label: 'Tool',
98
+ type: 'enumeration' as const,
99
+ options: Array.from(new Set(entries.map((e) => e.tool).filter(Boolean)))
100
+ .sort()
101
+ .map((t) => ({ label: t, value: t })),
102
+ },
103
+ {
104
+ name: 'resultStatus',
105
+ label: 'Status',
106
+ type: 'enumeration' as const,
107
+ options: [
108
+ { label: 'ok', value: 'ok' },
109
+ { label: 'error', value: 'error' },
110
+ ],
111
+ },
112
+ {
113
+ name: 'ts',
114
+ label: 'Date',
115
+ type: 'date' as const,
116
+ },
117
+ ],
118
+ [entries]
119
+ );
120
+
121
+ const filtered = useMemo(
122
+ () =>
123
+ applyMcpQuery(entries, query, {
124
+ searchText: (e) =>
125
+ [
126
+ e.tool,
127
+ formatPrincipal(e.principalAdmin, e.principalId),
128
+ e.client?.clientName ?? '',
129
+ e.errorCode ?? '',
130
+ ].join(' '),
131
+ field: (e, name) => {
132
+ if (name === 'tool') return e.tool;
133
+ if (name === 'resultStatus') return e.resultStatus;
134
+ if (name === 'ts') return e.ts;
135
+ return '';
136
+ },
137
+ }),
138
+ [entries, query]
139
+ );
140
+ const paged = useMemo(() => paginate(filtered, query), [filtered, query]);
141
+
142
+ const hasFilters = hasActiveQuery(query);
143
+
144
+ return (
145
+ <Box>
146
+ <PageHeader
147
+ title="Audit Log"
148
+ subtitle="Every MCP tool call, recorded with redacted parameters"
149
+ />
150
+
151
+ {error && (
152
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
153
+ <Typography textColor="danger700">Failed to load audit log: {error}</Typography>
154
+ </Box>
155
+ )}
156
+
157
+ <Flex gap={1} paddingBottom={4} alignItems="flex-start">
158
+ <SearchInput
159
+ label="Search audit log"
160
+ placeholder="Search by tool, principal, client, or error"
161
+ />
162
+ <Filters.Root options={auditFilters}>
163
+ <Filters.Trigger />
164
+ <Filters.Popover />
165
+ <Filters.List />
166
+ </Filters.Root>
167
+ </Flex>
168
+
169
+ <Box background="neutral0" hasRadius shadow="tableShadow">
170
+ <Table colCount={6} rowCount={paged.rows.length}>
171
+ <Thead>
172
+ <Tr>
173
+ <Th>
174
+ <Typography variant="sigma">Time</Typography>
175
+ </Th>
176
+ <Th>
177
+ <Typography variant="sigma">Principal</Typography>
178
+ </Th>
179
+ <Th>
180
+ <Typography variant="sigma">Client</Typography>
181
+ </Th>
182
+ <Th>
183
+ <Typography variant="sigma">Tool</Typography>
184
+ </Th>
185
+ <Th>
186
+ <Typography variant="sigma">Status</Typography>
187
+ </Th>
188
+ <Th>
189
+ <Typography variant="sigma">Duration</Typography>
190
+ </Th>
191
+ <Th>
192
+ <Typography variant="sigma">&nbsp;</Typography>
193
+ </Th>
194
+ </Tr>
195
+ </Thead>
196
+ <Tbody>
197
+ {paged.rows.map((e, i) => (
198
+ <Tr key={i}>
199
+ <Td>
200
+ <Typography variant="omega" textColor="neutral700">
201
+ {new Date(e.ts).toLocaleString()}
202
+ </Typography>
203
+ </Td>
204
+ <Td>
205
+ <Typography variant="omega">
206
+ {formatPrincipal(e.principalAdmin, e.principalId)}
207
+ </Typography>
208
+ </Td>
209
+ <Td>
210
+ <Typography variant="omega" textColor="neutral700">
211
+ {e.client?.clientName ?? '—'}
212
+ </Typography>
213
+ </Td>
214
+ <Td>
215
+ <Typography variant="omega" fontWeight="semiBold">
216
+ {e.tool}
217
+ </Typography>
218
+ </Td>
219
+ <Td>
220
+ <Badge
221
+ backgroundColor={e.resultStatus === 'ok' ? 'success100' : 'danger100'}
222
+ >
223
+ {e.resultStatus}
224
+ </Badge>
225
+ </Td>
226
+ <Td>
227
+ <Typography variant="omega" textColor="neutral700">
228
+ {e.durationMs !== undefined && e.durationMs !== null
229
+ ? `${e.durationMs}ms`
230
+ : ''}
231
+ </Typography>
232
+ </Td>
233
+ <Td>
234
+ <Flex justifyContent="flex-end">
235
+ <IconButton
236
+ label="View details"
237
+ variant="ghost"
238
+ onClick={() => setSelected(e)}
239
+ >
240
+ <Eye />
241
+ </IconButton>
242
+ </Flex>
243
+ </Td>
244
+ </Tr>
245
+ ))}
246
+ {!loading && paged.rows.length === 0 && (
247
+ <Tr>
248
+ <Td colSpan={7}>
249
+ <Box paddingTop={6} paddingBottom={6}>
250
+ <Typography textColor="neutral600">
251
+ {entries.length === 0
252
+ ? 'No tool calls recorded yet.'
253
+ : hasFilters
254
+ ? 'No entries match the current search or filters.'
255
+ : 'No entries to display.'}
256
+ </Typography>
257
+ </Box>
258
+ </Td>
259
+ </Tr>
260
+ )}
261
+ </Tbody>
262
+ </Table>
263
+ </Box>
264
+
265
+ {paged.total > 0 && (
266
+ <Box paddingTop={4}>
267
+ <Pagination.Root pageCount={paged.pageCount} total={paged.total}>
268
+ <Pagination.PageSize />
269
+ <Pagination.Links />
270
+ </Pagination.Root>
271
+ </Box>
272
+ )}
273
+
274
+ <Modal.Root
275
+ open={selected !== null}
276
+ onOpenChange={(open) => !open && setSelected(null)}
277
+ >
278
+ <Modal.Content>
279
+ <Modal.Header>
280
+ <Modal.Title>Audit entry</Modal.Title>
281
+ </Modal.Header>
282
+ <Modal.Body>
283
+ {selected && (
284
+ <Box paddingTop={2}>
285
+ <DetailRow label="Time">
286
+ <Typography variant="omega">
287
+ {new Date(selected.ts).toLocaleString()}
288
+ </Typography>
289
+ </DetailRow>
290
+ <DetailRow label="Tool">
291
+ <Typography variant="omega" fontWeight="semiBold">
292
+ {selected.tool}
293
+ </Typography>
294
+ </DetailRow>
295
+ <DetailRow label="Status">
296
+ <Badge
297
+ backgroundColor={
298
+ selected.resultStatus === 'ok' ? 'success100' : 'danger100'
299
+ }
300
+ >
301
+ {selected.resultStatus}
302
+ </Badge>
303
+ {selected.errorCode && (
304
+ <Box paddingTop={2}>
305
+ <Typography variant="omega" textColor="danger700">
306
+ Error: <code>{selected.errorCode}</code>
307
+ </Typography>
308
+ </Box>
309
+ )}
310
+ </DetailRow>
311
+ <DetailRow label="Duration">
312
+ <Typography variant="omega">
313
+ {selected.durationMs !== undefined && selected.durationMs !== null
314
+ ? `${selected.durationMs}ms`
315
+ : '—'}
316
+ </Typography>
317
+ </DetailRow>
318
+ <DetailRow label="Principal">
319
+ <Typography variant="omega">
320
+ {formatPrincipal(selected.principalAdmin, selected.principalId)}
321
+ {selected.principalAdmin?.email && (
322
+ <Typography variant="pi" textColor="neutral600">
323
+ {' '}
324
+ ({selected.principalAdmin.email})
325
+ </Typography>
326
+ )}
327
+ </Typography>
328
+ </DetailRow>
329
+ <DetailRow label="Client">
330
+ <Typography variant="omega">
331
+ {selected.client?.clientName ?? '—'}
332
+ {selected.clientId && (
333
+ <Typography variant="pi" textColor="neutral600">
334
+ {' '}
335
+ (<code>{selected.clientId}</code>)
336
+ </Typography>
337
+ )}
338
+ </Typography>
339
+ </DetailRow>
340
+ <DetailRow label="Session">
341
+ <Typography variant="omega" textColor="neutral700">
342
+ <code>{selected.sessionId ?? '—'}</code>
343
+ </Typography>
344
+ </DetailRow>
345
+ <DetailRow label="IP">
346
+ <Typography variant="omega" textColor="neutral700">
347
+ {selected.ip ?? '—'}
348
+ </Typography>
349
+ </DetailRow>
350
+ {selected.userAgent && (
351
+ <DetailRow label="User-Agent">
352
+ <Typography variant="omega" textColor="neutral700">
353
+ {selected.userAgent}
354
+ </Typography>
355
+ </DetailRow>
356
+ )}
357
+ <DetailRow label="Parameters">
358
+ <Box background="neutral100" padding={3} hasRadius>
359
+ <pre
360
+ style={{
361
+ margin: 0,
362
+ whiteSpace: 'pre-wrap',
363
+ wordBreak: 'break-all',
364
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
365
+ fontSize: 13,
366
+ }}
367
+ >
368
+ {selected.params === undefined || selected.params === null
369
+ ? '(none)'
370
+ : JSON.stringify(selected.params, null, 2)}
371
+ </pre>
372
+ </Box>
373
+ </DetailRow>
374
+ </Box>
375
+ )}
376
+ </Modal.Body>
377
+ <Modal.Footer>
378
+ <Modal.Close>
379
+ <Button variant="tertiary">Close</Button>
380
+ </Modal.Close>
381
+ </Modal.Footer>
382
+ </Modal.Content>
383
+ </Modal.Root>
384
+ </Box>
385
+ );
386
+ }