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,48 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from './config';
5
+
6
+ /**
7
+ * Runs once at Strapi init, before bootstrap. Validates config again as a
8
+ * belt-and-suspenders measure (config.validator is the primary gate) and
9
+ * registers RBAC permission actions for the plugin's admin pages.
10
+ */
11
+ export async function register({ strapi }: { strapi: Core.Strapi }): Promise<void> {
12
+ const cfg = getConfig(strapi);
13
+
14
+ if (!cfg.enabled) {
15
+ strapi.log.info('[mcp-server] plugin disabled — skipping registration');
16
+ return;
17
+ }
18
+
19
+ // Three permissions, each gating a distinct slice of the admin API:
20
+ // read → dashboard, settings (read-only), tools list, sidebar entry
21
+ // audit.read → audit log
22
+ // clients.manage → OAuth client CRUD
23
+ // Settings has no "manage" because mutations happen in config/plugins.ts —
24
+ // the admin UI is view-only.
25
+ const actionProvider = strapi.service('admin::permission').actionProvider;
26
+ await actionProvider.registerMany([
27
+ {
28
+ uid: 'read',
29
+ displayName: 'Read MCP dashboard',
30
+ pluginName: 'mcp-server',
31
+ section: 'plugins',
32
+ },
33
+ {
34
+ uid: 'audit.read',
35
+ displayName: 'Read MCP audit log',
36
+ pluginName: 'mcp-server',
37
+ section: 'plugins',
38
+ },
39
+ {
40
+ uid: 'clients.manage',
41
+ displayName: 'Manage OAuth clients',
42
+ pluginName: 'mcp-server',
43
+ section: 'plugins',
44
+ },
45
+ ]);
46
+
47
+ strapi.log.info('[mcp-server] registered RBAC actions and validated config');
48
+ }
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Admin API routes. Every route requires an authenticated admin AND a
5
+ * specific permission registered in `register.ts`. The role UI checkboxes
6
+ * map onto these endpoints 1:1 so toggling them in **Settings →
7
+ * Administration Panel → Roles** has the obvious effect.
8
+ *
9
+ * Permissions:
10
+ * `plugin::mcp-server.read` → dashboard, settings, tools
11
+ * `plugin::mcp-server.audit.read` → audit log
12
+ * `plugin::mcp-server.clients.manage` → OAuth clients CRUD
13
+ *
14
+ * Settings are read-only from the UI (mutations happen in `config/plugins.ts`),
15
+ * so there's no separate "manage settings" permission — exposing a runtime
16
+ * mutation surface would let admins weaken security without an audit trail.
17
+ */
18
+ const requirePermission = (action: string) => [
19
+ 'admin::isAuthenticatedAdmin',
20
+ { name: 'admin::hasPermissions', config: { actions: [action] } },
21
+ ];
22
+
23
+ const readPolicies = requirePermission('plugin::mcp-server.read');
24
+ const auditPolicies = requirePermission('plugin::mcp-server.audit.read');
25
+ const clientPolicies = requirePermission('plugin::mcp-server.clients.manage');
26
+
27
+ export default {
28
+ type: 'admin' as const,
29
+ routes: [
30
+ {
31
+ method: 'GET',
32
+ path: '/dashboard',
33
+ handler: 'dashboard.overview',
34
+ config: { policies: readPolicies },
35
+ },
36
+ {
37
+ method: 'GET',
38
+ path: '/clients',
39
+ handler: 'clients.list',
40
+ config: { policies: clientPolicies },
41
+ },
42
+ {
43
+ method: 'POST',
44
+ path: '/clients',
45
+ handler: 'clients.create',
46
+ config: { policies: clientPolicies },
47
+ },
48
+ {
49
+ method: 'GET',
50
+ path: '/clients/:clientId',
51
+ handler: 'clients.findOne',
52
+ config: { policies: clientPolicies },
53
+ },
54
+ {
55
+ method: 'PUT',
56
+ path: '/clients/:clientId',
57
+ handler: 'clients.update',
58
+ config: { policies: clientPolicies },
59
+ },
60
+ {
61
+ method: 'DELETE',
62
+ path: '/clients/:clientId',
63
+ handler: 'clients.destroy',
64
+ config: { policies: clientPolicies },
65
+ },
66
+ {
67
+ method: 'GET',
68
+ path: '/audit',
69
+ handler: 'audit.list',
70
+ config: { policies: auditPolicies },
71
+ },
72
+ {
73
+ method: 'GET',
74
+ path: '/settings',
75
+ handler: 'settings.get',
76
+ config: { policies: readPolicies },
77
+ },
78
+ {
79
+ method: 'GET',
80
+ path: '/tools',
81
+ handler: 'tools.list',
82
+ config: { policies: readPolicies },
83
+ },
84
+ ],
85
+ };
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ import mcp from './mcp';
4
+ import oauth from './oauth';
5
+ import admin from './admin';
6
+ import proxy from './proxy';
7
+
8
+ export default {
9
+ mcp,
10
+ oauth,
11
+ admin,
12
+ proxy,
13
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const policies = ['plugin::mcp-server.origin', 'plugin::mcp-server.authenticate', 'plugin::mcp-server.rateLimit'];
4
+
5
+ // type: 'admin' + prefix: '' mounts at the host root (no /api prefix). Strapi's
6
+ // admin router itself has prefix '', so admin-typed routes with prefix '' land
7
+ // at `/<path>` exactly. Auth is bypassed per-route via `auth: false`.
8
+ export default {
9
+ type: 'admin' as const,
10
+ prefix: '',
11
+ routes: [
12
+ {
13
+ method: 'POST',
14
+ path: '/mcp',
15
+ handler: 'mcp.handle',
16
+ config: { auth: false, policies },
17
+ },
18
+ {
19
+ method: 'GET',
20
+ path: '/mcp',
21
+ handler: 'mcp.handle',
22
+ config: { auth: false, policies },
23
+ },
24
+ {
25
+ method: 'DELETE',
26
+ path: '/mcp',
27
+ handler: 'mcp.end',
28
+ config: { auth: false, policies },
29
+ },
30
+ ],
31
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const originPolicy = ['plugin::mcp-server.origin'];
4
+
5
+ // type: 'admin' + prefix: '' mounts at the host root — required so the
6
+ // well-known URLs and /oauth/* paths match what AS metadata advertises.
7
+ export default {
8
+ type: 'admin' as const,
9
+ prefix: '',
10
+ routes: [
11
+ {
12
+ method: 'GET',
13
+ path: '/.well-known/oauth-protected-resource',
14
+ handler: 'metadata.protectedResource',
15
+ config: { auth: false, policies: [] },
16
+ },
17
+ {
18
+ method: 'GET',
19
+ path: '/.well-known/oauth-authorization-server',
20
+ handler: 'metadata.authorizationServer',
21
+ config: { auth: false, policies: [] },
22
+ },
23
+ {
24
+ method: 'GET',
25
+ path: '/oauth/jwks',
26
+ handler: 'metadata.jwks',
27
+ config: { auth: false, policies: [] },
28
+ },
29
+ {
30
+ method: 'GET',
31
+ path: '/oauth/authorize',
32
+ handler: 'authorize.start',
33
+ config: { auth: false, policies: originPolicy },
34
+ },
35
+ {
36
+ method: 'POST',
37
+ path: '/oauth/consent',
38
+ handler: 'authorize.consent',
39
+ config: { auth: false, policies: originPolicy },
40
+ },
41
+ {
42
+ method: 'POST',
43
+ path: '/oauth/sso-handoff',
44
+ handler: 'authorize.ssoHandoff',
45
+ config: { auth: false, policies: originPolicy },
46
+ },
47
+ {
48
+ method: 'POST',
49
+ path: '/oauth/token',
50
+ handler: 'token.token',
51
+ config: { auth: false, policies: originPolicy },
52
+ },
53
+ {
54
+ method: 'POST',
55
+ path: '/oauth/revoke',
56
+ handler: 'token.revoke',
57
+ config: { auth: false, policies: originPolicy },
58
+ },
59
+ {
60
+ method: 'POST',
61
+ path: '/oauth/introspect',
62
+ handler: 'introspect.introspect',
63
+ config: { auth: false, policies: [] },
64
+ },
65
+ {
66
+ method: 'POST',
67
+ path: '/oauth/register',
68
+ handler: 'dcr-register.register',
69
+ config: { auth: false, policies: originPolicy },
70
+ },
71
+ // Some MCP clients (Claude Code's SDK is one) fall back to a default
72
+ // `/register` path when DCR metadata is absent. Alias it so the response is
73
+ // a parseable JSON error instead of a plain-text 405 from Koa.
74
+ {
75
+ method: 'POST',
76
+ path: '/register',
77
+ handler: 'dcr-register.register',
78
+ config: { auth: false, policies: originPolicy },
79
+ },
80
+ ],
81
+ };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Internal cluster-peer endpoint. Bypasses origin/auth/rate-limit policies;
4
+ // the proxy controller validates the HMAC on `X-MCP-Proxy-Auth` and dispatches
5
+ // directly into a local session. Mounted at root, not under /api.
6
+ export default {
7
+ type: 'admin' as const,
8
+ prefix: '',
9
+ routes: [
10
+ {
11
+ method: 'POST',
12
+ path: '/__mcp/proxy/:sessionId',
13
+ handler: 'proxy.receive',
14
+ config: { auth: false, policies: [] },
15
+ },
16
+ {
17
+ method: 'GET',
18
+ path: '/__mcp/proxy/:sessionId',
19
+ handler: 'proxy.receive',
20
+ config: { auth: false, policies: [] },
21
+ },
22
+ {
23
+ method: 'DELETE',
24
+ path: '/__mcp/proxy/:sessionId',
25
+ handler: 'proxy.receive',
26
+ config: { auth: false, policies: [] },
27
+ },
28
+ ],
29
+ };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../config';
5
+
6
+ const UID = 'plugin::mcp-server.audit-log';
7
+
8
+ export interface AuditEntry {
9
+ ts: Date;
10
+ principalType: 'admin' | 'system';
11
+ principalId: string;
12
+ sessionId?: string;
13
+ clientId?: string;
14
+ tool: string;
15
+ params?: unknown;
16
+ resultStatus: 'ok' | 'error';
17
+ errorCode?: string;
18
+ ip?: string;
19
+ userAgent?: string;
20
+ durationMs?: number;
21
+ }
22
+
23
+ let queue: AuditEntry[] = [];
24
+
25
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
26
+ const cfg = () => getConfig(strapi);
27
+
28
+ return {
29
+ record(entry: AuditEntry): void {
30
+ const redacted: AuditEntry = {
31
+ ...entry,
32
+ params: redact(entry.params, cfg().audit.redactKeyPatterns),
33
+ };
34
+ queue.push(redacted);
35
+ if (queue.length >= cfg().audit.drainBatchSize) {
36
+ void this.drain();
37
+ }
38
+ },
39
+
40
+ async drain(): Promise<void> {
41
+ if (queue.length === 0) return;
42
+ const batch = queue;
43
+ queue = [];
44
+ try {
45
+ await strapi.db.query(UID).createMany({ data: batch });
46
+ } catch (err) {
47
+ strapi.log.error('[mcp-server] audit write failed; dropping batch', err as Error);
48
+ }
49
+ },
50
+
51
+ async purgeOlderThan(days: number): Promise<number> {
52
+ const cutoff = new Date(Date.now() - days * 86400 * 1000);
53
+ const { count } = await strapi.db.query(UID).deleteMany({ where: { ts: { $lt: cutoff } } });
54
+ return count ?? 0;
55
+ },
56
+
57
+ async recent(limit = 50, filters: Record<string, unknown> = {}): Promise<AuditEntry[]> {
58
+ return strapi.db
59
+ .query(UID)
60
+ .findMany({ where: filters, orderBy: { ts: 'desc' }, limit: Math.min(500, limit) });
61
+ },
62
+
63
+ /**
64
+ * Resolve admin user + OAuth client info for a batch of audit rows. Used
65
+ * by both the audit list and the dashboard's recent-calls panel so they
66
+ * present principal as a human name instead of a raw numeric ID, and so
67
+ * the OAuth client (Claude Code, etc.) is identified by name.
68
+ */
69
+ async enrich(
70
+ rows: Array<{ principalId?: string | null; clientId?: string | null }>
71
+ ): Promise<
72
+ Array<{
73
+ principalAdmin: AdminUserRow | null;
74
+ client: { clientId: string; clientName: string } | null;
75
+ }>
76
+ > {
77
+ const adminIds = Array.from(
78
+ new Set(
79
+ rows
80
+ .map((r) => r.principalId)
81
+ .filter((v): v is string => typeof v === 'string' && v.length > 0)
82
+ .map((s) => Number(s))
83
+ .filter((n) => Number.isFinite(n))
84
+ )
85
+ );
86
+ const clientIds = Array.from(
87
+ new Set(
88
+ rows
89
+ .map((r) => r.clientId)
90
+ .filter((v): v is string => typeof v === 'string' && v.length > 0)
91
+ )
92
+ );
93
+
94
+ const [admins, clients] = await Promise.all([
95
+ adminIds.length
96
+ ? (strapi.db.query('admin::user').findMany({
97
+ where: { id: { $in: adminIds } },
98
+ select: ['id', 'email', 'firstname', 'lastname', 'username'],
99
+ }) as Promise<AdminUserRow[]>)
100
+ : Promise.resolve<AdminUserRow[]>([]),
101
+ clientIds.length
102
+ ? (strapi.db.query('plugin::mcp-server.oauth-client').findMany({
103
+ where: { clientId: { $in: clientIds } },
104
+ select: ['clientId', 'clientName'],
105
+ }) as Promise<Array<{ clientId: string; clientName: string }>>)
106
+ : Promise.resolve<Array<{ clientId: string; clientName: string }>>([]),
107
+ ]);
108
+
109
+ const adminById = new Map(admins.map((a) => [String(a.id), a]));
110
+ const clientById = new Map(clients.map((c) => [c.clientId, c]));
111
+
112
+ return rows.map((r) => ({
113
+ principalAdmin: r.principalId ? adminById.get(r.principalId) ?? null : null,
114
+ client: r.clientId ? clientById.get(r.clientId) ?? null : null,
115
+ }));
116
+ },
117
+
118
+ /** Test/diagnostic only. */
119
+ _peekQueue(): AuditEntry[] {
120
+ return [...queue];
121
+ },
122
+ };
123
+ };
124
+
125
+ export interface AdminUserRow {
126
+ id: number;
127
+ email?: string;
128
+ firstname?: string;
129
+ lastname?: string;
130
+ username?: string;
131
+ }
132
+
133
+ const TRUNCATE_AT = 2048;
134
+
135
+ export function redact(value: unknown, patterns: string[]): unknown {
136
+ const regex = new RegExp(`(${patterns.join('|')})`, 'i');
137
+ return walk(value, regex, 0);
138
+ }
139
+
140
+ function walk(value: unknown, regex: RegExp, depth: number): unknown {
141
+ if (depth > 6) return '[truncated:depth]';
142
+ if (value === null || typeof value !== 'object') {
143
+ if (typeof value === 'string' && value.length > TRUNCATE_AT) {
144
+ return value.slice(0, TRUNCATE_AT) + '…';
145
+ }
146
+ return value;
147
+ }
148
+ if (Array.isArray(value)) return value.slice(0, 50).map((v) => walk(v, regex, depth + 1));
149
+ const out: Record<string, unknown> = {};
150
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
151
+ if (regex.test(k)) {
152
+ out[k] = '[redacted]';
153
+ } else {
154
+ out[k] = walk(v, regex, depth + 1);
155
+ }
156
+ }
157
+ return out;
158
+ }
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../config';
5
+
6
+ let timer: NodeJS.Timeout | null = null;
7
+
8
+ /**
9
+ * Instance liveness via Redis. Every active instance refreshes
10
+ * `mcp:inst:{INSTANCE_ID}` on a short interval (default 10s) with a slightly
11
+ * longer TTL (default 30s) — peers consider an instance dead if its key has
12
+ * disappeared.
13
+ *
14
+ * Used by the session directory to:
15
+ * - Skip proxying to a known-dead owner (return 404 instead of 502).
16
+ * - Garbage-collect stale `mcp:sess:{id}` entries pointing at dead instances.
17
+ */
18
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
19
+ function key(instanceId: string): string {
20
+ return strapi.plugin('mcp-server').service('redis').key('inst', instanceId);
21
+ }
22
+
23
+ async function refresh(): Promise<void> {
24
+ const cfg = getConfig(strapi);
25
+ if (!cfg.redis?.enabled || !cfg.redis.internalAddress) return;
26
+ const r = await strapi.plugin('mcp-server').service('redis').get();
27
+ if (!r) return;
28
+ const id = strapi.plugin('mcp-server').service('instance-id').get();
29
+ const ttlSec = Math.max(2, Math.ceil((cfg.redis.heartbeatTtlMs ?? 30_000) / 1000));
30
+ try {
31
+ await r.set(key(id), String(Date.now()), 'EX', ttlSec);
32
+ } catch (err) {
33
+ strapi.log.warn(`[mcp-server] heartbeat refresh failed: ${(err as Error).message}`);
34
+ }
35
+ }
36
+
37
+ return {
38
+ /** Start the periodic refresh. Safe to call multiple times. */
39
+ async start(): Promise<void> {
40
+ const cfg = getConfig(strapi);
41
+ if (!cfg.redis?.enabled || !cfg.redis.internalAddress) return;
42
+ if (timer) return;
43
+ // Write one immediately so peers can see us right away.
44
+ await refresh();
45
+ const intervalMs = Math.max(1_000, cfg.redis.heartbeatIntervalMs ?? 10_000);
46
+ timer = setInterval(() => {
47
+ void refresh();
48
+ }, intervalMs);
49
+ },
50
+
51
+ stop(): void {
52
+ if (timer) {
53
+ clearInterval(timer);
54
+ timer = null;
55
+ }
56
+ },
57
+
58
+ /**
59
+ * Truthy when the instance's heartbeat key still exists in Redis. Used by
60
+ * the directory to decide whether to attempt a proxy or treat the session
61
+ * as orphaned.
62
+ */
63
+ async isAlive(instanceId: string): Promise<boolean> {
64
+ const cfg = getConfig(strapi);
65
+ if (!cfg.redis?.enabled) return true; // single-instance mode — always alive
66
+ const r = await strapi.plugin('mcp-server').service('redis').get();
67
+ if (!r) return true;
68
+ try {
69
+ const v = await r.get(key(instanceId));
70
+ return v !== null;
71
+ } catch {
72
+ return true; // fail-open on transient Redis hiccup
73
+ }
74
+ },
75
+ };
76
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ import audit from './audit';
4
+ import rateLimiter from './rate-limiter';
5
+ import permissions from './permissions';
6
+ import sessionStore from './session-store';
7
+ import sessionDirectory from './session-directory';
8
+ import mcpServerFactory from './mcp-server';
9
+ import ssoCookie from './sso-cookie';
10
+ import redis from './redis';
11
+ import instanceId from './instance-id';
12
+ import proxyClient from './proxy-client';
13
+ import heartbeat from './heartbeat';
14
+ import signingKeys from './oauth/signing-keys';
15
+ import tokens from './oauth/tokens';
16
+ import consent from './oauth/consent';
17
+ import clients from './oauth/clients';
18
+ import authCodes from './oauth/auth-codes';
19
+
20
+ export default {
21
+ audit,
22
+ 'rate-limiter': rateLimiter,
23
+ permissions,
24
+ 'session-store': sessionStore,
25
+ 'session-directory': sessionDirectory,
26
+ 'mcp-server': mcpServerFactory,
27
+ 'sso-cookie': ssoCookie,
28
+ redis,
29
+ 'instance-id': instanceId,
30
+ 'proxy-client': proxyClient,
31
+ heartbeat,
32
+ 'signing-keys': signingKeys,
33
+ tokens,
34
+ consent,
35
+ clients,
36
+ 'auth-codes': authCodes,
37
+ };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ import { hostname } from 'os';
4
+ import { randomBytes } from 'crypto';
5
+ import type { Core } from '@strapi/strapi';
6
+ import { getConfig } from '../config';
7
+
8
+ let cached: string | null = null;
9
+
10
+ /**
11
+ * Stable per-process identifier. Used as the value of `sess:{id}.instance`
12
+ * in the Redis session directory so peers know who owns a session.
13
+ *
14
+ * Format: `<hostname>-<pid>-<8hex>` so operators can match it to a host
15
+ * when debugging. Override via `config.redis.instanceId` if you'd rather
16
+ * pin it (e.g. from a Kubernetes pod name).
17
+ */
18
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
19
+ get(): string {
20
+ if (cached) return cached;
21
+ const cfg = getConfig(strapi);
22
+ const override = cfg.redis?.instanceId?.trim();
23
+ if (override) {
24
+ cached = override;
25
+ return cached;
26
+ }
27
+ cached = `${hostname()}-${process.pid}-${randomBytes(4).toString('hex')}`;
28
+ return cached;
29
+ },
30
+ });
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { randomUUID } from 'crypto';
7
+ import type { Scope } from './oauth/scopes';
8
+ import type { PrincipalContext } from './permissions';
9
+ import { buildToolsForSession } from './tools';
10
+
11
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
12
+ /**
13
+ * Construct a fresh McpServer + StreamableHTTPServerTransport for a new
14
+ * session. The transport's `sessionIdGenerator` returns a UUID the first
15
+ * time it's called (during `initialize`), then the same id forever — the
16
+ * SDK uses that to issue `Mcp-Session-Id` on the initial response.
17
+ */
18
+ async create(options: {
19
+ principal: PrincipalContext;
20
+ scopes: Scope[];
21
+ clientId: string;
22
+ jti: string;
23
+ }): Promise<{
24
+ sessionId: string;
25
+ transport: StreamableHTTPServerTransport;
26
+ mcpServer: McpServer;
27
+ }> {
28
+ const sessionId = randomUUID();
29
+
30
+ const transport = new StreamableHTTPServerTransport({
31
+ sessionIdGenerator: () => sessionId,
32
+ enableJsonResponse: false,
33
+ });
34
+
35
+ const mcpServer = new McpServer({
36
+ name: 'strapi-mcp-server',
37
+ version: '0.1.0',
38
+ });
39
+
40
+ const tools = buildToolsForSession({
41
+ strapi,
42
+ principal: options.principal,
43
+ scopes: options.scopes,
44
+ });
45
+
46
+ for (const tool of tools) {
47
+ mcpServer.registerTool(
48
+ tool.name,
49
+ {
50
+ description: tool.description,
51
+ // The SDK accepts a Zod schema object literal under `inputSchema`.
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ inputSchema: (tool.inputSchema as any).shape ?? tool.inputSchema,
54
+ },
55
+ async (raw: unknown) => {
56
+ const startedAt = Date.now();
57
+ const auditPayload = {
58
+ ts: new Date(),
59
+ principalType: 'admin' as const,
60
+ principalId: String(options.principal.user.id),
61
+ sessionId,
62
+ clientId: options.clientId,
63
+ tool: tool.name,
64
+ params: raw,
65
+ resultStatus: 'ok' as 'ok' | 'error',
66
+ errorCode: undefined as string | undefined,
67
+ durationMs: 0,
68
+ };
69
+ try {
70
+ const result = await tool.handler.call(tool, raw);
71
+ auditPayload.durationMs = Date.now() - startedAt;
72
+ strapi.plugin('mcp-server').service('audit').record(auditPayload);
73
+ return result;
74
+ } catch (err) {
75
+ const code = (err as Error & { code?: string }).code;
76
+ auditPayload.resultStatus = 'error';
77
+ auditPayload.errorCode = code ?? 'internal';
78
+ auditPayload.durationMs = Date.now() - startedAt;
79
+ strapi.plugin('mcp-server').service('audit').record(auditPayload);
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text' as const,
84
+ text: JSON.stringify({
85
+ error: code ?? 'internal_error',
86
+ message: (err as Error).message,
87
+ }),
88
+ },
89
+ ],
90
+ isError: true,
91
+ };
92
+ }
93
+ }
94
+ );
95
+ }
96
+
97
+ await mcpServer.connect(transport);
98
+ return { sessionId, transport, mcpServer };
99
+ },
100
+ });