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,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"> </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
|
+
}
|