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,8 @@
1
+ {
2
+ "plugin.name": "MCP Server",
3
+ "page.dashboard": "Dashboard",
4
+ "page.clients": "OAuth Clients",
5
+ "page.tools": "Tools",
6
+ "page.audit": "Audit Log",
7
+ "page.settings": "Settings"
8
+ }
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "strapi-mcp-server",
3
+ "version": "0.1.1",
4
+ "description": "Expose Strapi v5 as a Model Context Protocol server with OAuth 2.1 + PKCE.",
5
+ "keywords": [
6
+ "strapi",
7
+ "strapi-plugin",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "oauth",
11
+ "ai",
12
+ "llm"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Temitayo Salaudeen",
16
+ "type": "commonjs",
17
+ "exports": {
18
+ "./strapi-admin": {
19
+ "source": "./admin/src/index.tsx",
20
+ "import": "./dist/admin/index.mjs",
21
+ "require": "./dist/admin/index.js",
22
+ "default": "./dist/admin/index.js"
23
+ },
24
+ "./strapi-server": {
25
+ "source": "./server/src/index.ts",
26
+ "import": "./dist/server/index.mjs",
27
+ "require": "./dist/server/index.js",
28
+ "default": "./dist/server/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "admin",
35
+ "server"
36
+ ],
37
+ "workspaces": [
38
+ "__tests__/fixtures/test-app"
39
+ ],
40
+ "scripts": {
41
+ "build": "strapi-plugin build",
42
+ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
43
+ "test": "jest --selectProjects unit",
44
+ "test:unit": "jest --selectProjects unit",
45
+ "test:integration": "jest --selectProjects integration",
46
+ "test:all": "jest",
47
+ "test-app": "npm --workspace=strapi-mcp-server-test-app run develop",
48
+ "test-app:build": "npm --workspace=strapi-mcp-server-test-app run build",
49
+ "test-app:reset": "rm -f __tests__/fixtures/test-app/database.sqlite",
50
+ "dev": "concurrently -n plugin,test-app -c blue,green \"npm run watch\" \"npm run test-app\"",
51
+ "inspect": "npx @modelcontextprotocol/inspector",
52
+ "verify": "strapi-plugin verify",
53
+ "watch": "strapi-plugin watch",
54
+ "watch:link": "strapi-plugin watch:link",
55
+ "prepare": "husky"
56
+ },
57
+ "lint-staged": {
58
+ "*.{ts,tsx,js,jsx,json,md}": "prettier --write"
59
+ },
60
+ "dependencies": {
61
+ "@modelcontextprotocol/sdk": "^1.0.0",
62
+ "@strapi/design-system": "^2.0.0",
63
+ "@strapi/icons": "^2.0.0",
64
+ "ioredis": "^5.4.1",
65
+ "jose": "^5.9.6",
66
+ "react-intl": "^6.6.2",
67
+ "zod": "^3.23.8"
68
+ },
69
+ "devDependencies": {
70
+ "@strapi/sdk-plugin": "^5.3.2",
71
+ "@strapi/strapi": "^5.0.0",
72
+ "@strapi/typescript-utils": "^5.0.0",
73
+ "@types/jest": "^29.5.13",
74
+ "@types/koa": "^2.15.0",
75
+ "@types/node": "^20.16.10",
76
+ "@types/react": "^18.3.11",
77
+ "@types/react-dom": "^18.3.0",
78
+ "concurrently": "^9.2.1",
79
+ "husky": "^9.1.7",
80
+ "jest": "^30.3.0",
81
+ "lint-staged": "^16.4.0",
82
+ "prettier": "^3.8.2",
83
+ "react": "^18.0.0",
84
+ "react-dom": "^18.0.0",
85
+ "react-router-dom": "^6.30.0",
86
+ "styled-components": "^6.0.0",
87
+ "supertest": "^7.0.0",
88
+ "ts-jest": "^29.4.11",
89
+ "typescript": "^5.6.2"
90
+ },
91
+ "peerDependencies": {
92
+ "@strapi/strapi": "^5.0.0",
93
+ "@strapi/utils": "^5.0.0",
94
+ "react": "^17.0.0 || ^18.0.0",
95
+ "react-dom": "^17.0.0 || ^18.0.0",
96
+ "react-router-dom": "^6.30.0",
97
+ "styled-components": "^6.0.0"
98
+ },
99
+ "strapi": {
100
+ "kind": "plugin",
101
+ "name": "mcp-server",
102
+ "displayName": "MCP Server",
103
+ "description": "Expose Strapi as a Model Context Protocol server with OAuth 2.1 + PKCE."
104
+ }
105
+ }
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from './config';
5
+
6
+ interface PluginRuntime {
7
+ sweepTimer?: NodeJS.Timeout;
8
+ auditTimer?: NodeJS.Timeout;
9
+ }
10
+
11
+ /**
12
+ * Stash runtime handles on the strapi instance so destroy() can clear them.
13
+ * Strapi's strapi.plugin('mcp-server') namespace would also work but is harder
14
+ * to type cleanly.
15
+ */
16
+ function runtime(strapi: Core.Strapi): PluginRuntime {
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ const anyStrapi = strapi as any;
19
+ if (!anyStrapi.__mcpServerRuntime) anyStrapi.__mcpServerRuntime = {};
20
+ return anyStrapi.__mcpServerRuntime as PluginRuntime;
21
+ }
22
+
23
+ export async function bootstrap({ strapi }: { strapi: Core.Strapi }): Promise<void> {
24
+ const cfg = getConfig(strapi);
25
+ if (!cfg.enabled) return;
26
+
27
+ const rt = runtime(strapi);
28
+
29
+ // OAuth signing keys must exist before any token is issued.
30
+ await strapi.plugin('mcp-server').service('signing-keys').ensureActiveKey();
31
+
32
+ // Eagerly connect to Redis (if enabled) so configuration errors surface at
33
+ // boot instead of on the first request that triggers a rate-limit check.
34
+ if (cfg.redis?.enabled) {
35
+ const r = await strapi.plugin('mcp-server').service('redis').get();
36
+ if (!r) {
37
+ strapi.log.warn('[mcp-server] redis enabled but client unavailable — falling back to in-memory rate limiting');
38
+ } else if (cfg.redis.internalAddress) {
39
+ const id = strapi.plugin('mcp-server').service('instance-id').get();
40
+ strapi.log.info(
41
+ `[mcp-server] cluster instance id=${id} internal=${cfg.redis.internalAddress}`
42
+ );
43
+
44
+ // Start the heartbeat ticker so peers know we're alive.
45
+ await strapi.plugin('mcp-server').service('heartbeat').start();
46
+
47
+ // Subscribe to cluster-wide revocation events. Single channel keyed
48
+ // `mcp:revoke`; payload is the adminUserId whose sessions should die.
49
+ const sub = await strapi.plugin('mcp-server').service('redis').getSubscriber();
50
+ if (sub) {
51
+ const channel = strapi.plugin('mcp-server').service('redis').key('revoke');
52
+ try {
53
+ await sub.subscribe(channel);
54
+ sub.on('message', (...args: unknown[]) => {
55
+ const ch = args[0] as string;
56
+ const msg = args[1] as string;
57
+ if (ch !== channel || !msg) return;
58
+ void strapi
59
+ .plugin('mcp-server')
60
+ .service('session-store')
61
+ .closeForPrincipalLocal(msg)
62
+ .catch((err: Error) =>
63
+ strapi.log.warn(
64
+ `[mcp-server] revocation handler failed for user=${msg}: ${err.message}`
65
+ )
66
+ );
67
+ });
68
+ strapi.log.info(`[mcp-server] subscribed to revocation channel ${channel}`);
69
+ } catch (err) {
70
+ strapi.log.warn(
71
+ `[mcp-server] failed to subscribe revocation channel: ${(err as Error).message}`
72
+ );
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Periodic session eviction (idle/hard TTLs).
79
+ const sessionStore = strapi.plugin('mcp-server').service('session-store');
80
+ rt.sweepTimer = setInterval(() => {
81
+ try {
82
+ sessionStore.sweep();
83
+ } catch (err) {
84
+ strapi.log.error('[mcp-server] session sweep failed', err as Error);
85
+ }
86
+ }, cfg.session.sweepIntervalMs);
87
+
88
+ // Audit log drainer (buffered async writes).
89
+ const audit = strapi.plugin('mcp-server').service('audit');
90
+ rt.auditTimer = setInterval(() => {
91
+ audit.drain().catch((err: Error) => strapi.log.error('[mcp-server] audit drain', err));
92
+ }, cfg.audit.drainIntervalMs);
93
+
94
+ // Daily cron: purge expired OAuth artifacts and old audit-log entries.
95
+ // Strapi v5 cron format expects a record keyed by cron expression or task name.
96
+ strapi.cron.add({
97
+ mcpServerNightlyCleanup: {
98
+ task: async ({ strapi: s }: { strapi: Core.Strapi }) => {
99
+ try {
100
+ await s.plugin('mcp-server').service('audit').purgeOlderThan(cfg.audit.retentionDays);
101
+ await s.plugin('mcp-server').service('tokens').purgeExpired();
102
+ // Drop DCR clients that never reached consent (no owner, no related
103
+ // codes/tokens/consents) and are older than 1h — a backstop for the
104
+ // immediate sweep at consent-grant time, in case a connect attempt
105
+ // is abandoned before consent.
106
+ await s.plugin('mcp-server').service('clients').purgeOrphans(60 * 60 * 1000);
107
+ } catch (err) {
108
+ s.log.error('[mcp-server] nightly cleanup failed', err as Error);
109
+ }
110
+ },
111
+ options: { rule: '0 3 * * *' },
112
+ },
113
+ });
114
+
115
+ strapi.log.info(
116
+ `[mcp-server] bootstrap complete (resource=${cfg.resourceUrl}, origins=${cfg.allowedOrigins.length})`
117
+ );
118
+ }
@@ -0,0 +1,290 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+
5
+ export interface RateBucketConfig {
6
+ capacity: number;
7
+ refillPerSec: number;
8
+ }
9
+
10
+ export interface McpConfig {
11
+ enabled: boolean;
12
+ resourceUrl: string;
13
+ allowedOrigins: string[];
14
+ oauth: {
15
+ mode: 'embedded' | 'external';
16
+ accessTokenTtlSec: number;
17
+ refreshTokenTtlSec: number;
18
+ authCodeTtlSec: number;
19
+ ssoCookieTtlSec: number;
20
+ dcr: {
21
+ enabled: boolean;
22
+ ratelimitPerHour: number;
23
+ };
24
+ consent: { rememberDays: number };
25
+ introspection: { allowedIps: string[] };
26
+ external?: {
27
+ issuer: string;
28
+ jwksUri: string;
29
+ /** JWT claim used to look up the matching Strapi admin user. Default: 'email'. */
30
+ adminLookupClaim?: string;
31
+ /**
32
+ * When `false` (default), external mode treats a verified JWT as fully
33
+ * authorized — the IdP gates auth, and granular permissions come from
34
+ * Strapi RBAC + per-tool toggles. `strapi:*` scopes are NOT advertised
35
+ * to clients and NOT required on the JWT.
36
+ *
37
+ * Set `true` to require the JWT's `scope` claim to contain `strapi:*`
38
+ * scopes (you must define them as Client Scopes in your IdP).
39
+ */
40
+ enforceScopes?: boolean;
41
+ };
42
+ };
43
+ session: {
44
+ idleTtlMs: number;
45
+ hardTtlMs: number;
46
+ maxPerPrincipal: number;
47
+ maxTotal: number;
48
+ sweepIntervalMs: number;
49
+ };
50
+ rateLimit: {
51
+ perPrincipal: RateBucketConfig;
52
+ perIp: RateBucketConfig;
53
+ };
54
+ upload: {
55
+ maxBytes: number;
56
+ mimeAllowlist: string[];
57
+ allowSvg: boolean;
58
+ };
59
+ audit: {
60
+ retentionDays: number;
61
+ redactKeyPatterns: string[];
62
+ drainIntervalMs: number;
63
+ drainBatchSize: number;
64
+ };
65
+ tools: { enabled: Record<string, boolean> };
66
+ /**
67
+ * Optional Redis backend for horizontal scale. When `enabled: false`
68
+ * (default), the plugin uses process-local state and is single-instance.
69
+ *
70
+ * Two opt-in tiers:
71
+ * - `enabled: true` alone shares only the rate limiter buckets across
72
+ * instances. Sessions stay process-local — sticky LB is still required.
73
+ * - `enabled: true` + `internalAddress` + `internalSecret` adds session
74
+ * routing: any instance can serve any session by proxying to the owner.
75
+ */
76
+ redis?: {
77
+ enabled: boolean;
78
+ url: string;
79
+ keyPrefix?: string;
80
+ /** Override the auto-generated instance id (default: `${host}-${pid}-${rand}`). */
81
+ instanceId?: string;
82
+ /**
83
+ * Internal-facing URL of this instance (e.g. `http://10.0.0.5:1337`).
84
+ * Peers use this address to proxy requests for sessions this instance owns.
85
+ * When unset, session routing is disabled and Redis is only used for rate
86
+ * limiting.
87
+ */
88
+ internalAddress?: string;
89
+ /**
90
+ * Shared secret used to sign cross-instance proxy requests. Required when
91
+ * `internalAddress` is set. Must be at least 32 characters of high-entropy
92
+ * randomness — peers that don't share this secret cannot reach any
93
+ * session on this instance.
94
+ */
95
+ internalSecret?: string;
96
+ /** How often each instance refreshes its heartbeat key. Default 10s. */
97
+ heartbeatIntervalMs?: number;
98
+ /** TTL of the heartbeat key. Must be > intervalMs. Default 30s. */
99
+ heartbeatTtlMs?: number;
100
+ };
101
+ }
102
+
103
+ const defaultConfig: McpConfig = {
104
+ enabled: false,
105
+ resourceUrl: '',
106
+ allowedOrigins: [],
107
+ oauth: {
108
+ mode: 'embedded',
109
+ accessTokenTtlSec: 600,
110
+ refreshTokenTtlSec: 86400,
111
+ authCodeTtlSec: 60,
112
+ ssoCookieTtlSec: 900,
113
+ // DCR off by default — admins create clients via the Clients page in the
114
+ // admin UI and inject `client_id` + `client_secret` into the AI client's
115
+ // config. All major MCP clients (Claude Code, Claude web, Codex via
116
+ // mcp-remote, opencode, Cursor) support pre-registered credentials, so DCR
117
+ // is an opt-in convenience for self-registration rather than the default.
118
+ // Set `enabled: true` to allow self-registration via `/oauth/register`
119
+ // (still rate-limited per IP and audited; the admin consent screen is the
120
+ // real security gate either way).
121
+ dcr: { enabled: false, ratelimitPerHour: 60 },
122
+ consent: { rememberDays: 0 },
123
+ introspection: { allowedIps: ['127.0.0.1', '::1'] },
124
+ },
125
+ session: {
126
+ idleTtlMs: 30 * 60 * 1000,
127
+ hardTtlMs: 24 * 60 * 60 * 1000,
128
+ maxPerPrincipal: 10,
129
+ maxTotal: 1000,
130
+ sweepIntervalMs: 60 * 1000,
131
+ },
132
+ rateLimit: {
133
+ perPrincipal: { capacity: 60, refillPerSec: 1 },
134
+ perIp: { capacity: 120, refillPerSec: 2 },
135
+ },
136
+ upload: {
137
+ maxBytes: 10 * 1024 * 1024,
138
+ mimeAllowlist: ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf'],
139
+ allowSvg: false,
140
+ },
141
+ audit: {
142
+ retentionDays: 90,
143
+ redactKeyPatterns: ['password', 'token', 'secret', 'authorization', 'cookie', 'apikey'],
144
+ drainIntervalMs: 2000,
145
+ drainBatchSize: 50,
146
+ },
147
+ tools: { enabled: {} },
148
+ };
149
+
150
+ /**
151
+ * Validate the merged plugin configuration. Throws on hard misconfiguration —
152
+ * Strapi will refuse to boot the plugin.
153
+ *
154
+ * Why: every check here is load-bearing for security. Don't soften without
155
+ * thinking through the threat model.
156
+ */
157
+ function validator(config: McpConfig): void {
158
+ if (!config) throw new Error('[mcp-server] config is missing');
159
+ if (typeof config.enabled !== 'boolean') {
160
+ throw new Error('[mcp-server] config.enabled must be a boolean');
161
+ }
162
+ if (!config.enabled) return;
163
+
164
+ if (!config.resourceUrl || typeof config.resourceUrl !== 'string') {
165
+ throw new Error('[mcp-server] config.resourceUrl is required when enabled');
166
+ }
167
+ try {
168
+ // throws on invalid URL
169
+ // eslint-disable-next-line no-new
170
+ new URL(config.resourceUrl);
171
+ } catch {
172
+ throw new Error('[mcp-server] config.resourceUrl is not a valid URL');
173
+ }
174
+
175
+ if (!Array.isArray(config.allowedOrigins) || config.allowedOrigins.length === 0) {
176
+ throw new Error('[mcp-server] config.allowedOrigins must be a non-empty array');
177
+ }
178
+ const env = process.env.NODE_ENV;
179
+ const hasWildcard = config.allowedOrigins.includes('*');
180
+ if (env === 'production' && hasWildcard) {
181
+ throw new Error('[mcp-server] allowedOrigins cannot include "*" in production');
182
+ }
183
+
184
+ const resourceIsHttp = config.resourceUrl.startsWith('http://');
185
+ if (resourceIsHttp) {
186
+ const nonLoopback = config.allowedOrigins.some((o) => {
187
+ if (o === '*') return true;
188
+ try {
189
+ const u = new URL(o);
190
+ const h = u.hostname.toLowerCase();
191
+ return h !== 'localhost' && h !== '127.0.0.1' && h !== '::1';
192
+ } catch {
193
+ return false;
194
+ }
195
+ });
196
+ if (nonLoopback) {
197
+ throw new Error(
198
+ '[mcp-server] resourceUrl uses http:// but allowedOrigins contains non-loopback hosts — refuse to start'
199
+ );
200
+ }
201
+ }
202
+
203
+ if (config.oauth.accessTokenTtlSec < 60 || config.oauth.accessTokenTtlSec > 3600) {
204
+ throw new Error('[mcp-server] oauth.accessTokenTtlSec must be between 60 and 3600');
205
+ }
206
+ if (config.oauth.refreshTokenTtlSec < 300) {
207
+ throw new Error('[mcp-server] oauth.refreshTokenTtlSec must be >= 300');
208
+ }
209
+ if (config.oauth.authCodeTtlSec < 10 || config.oauth.authCodeTtlSec > 600) {
210
+ throw new Error('[mcp-server] oauth.authCodeTtlSec must be between 10 and 600');
211
+ }
212
+
213
+ if (config.redis?.enabled) {
214
+ if (!config.redis.url || typeof config.redis.url !== 'string') {
215
+ throw new Error('[mcp-server] redis.url is required when redis.enabled is true');
216
+ }
217
+ try {
218
+ // eslint-disable-next-line no-new
219
+ new URL(config.redis.url);
220
+ } catch {
221
+ throw new Error('[mcp-server] redis.url is not a valid URL (expected redis:// or rediss://)');
222
+ }
223
+ if (
224
+ !config.redis.url.startsWith('redis://') &&
225
+ !config.redis.url.startsWith('rediss://')
226
+ ) {
227
+ throw new Error('[mcp-server] redis.url must start with redis:// or rediss://');
228
+ }
229
+
230
+ if (config.redis.internalAddress || config.redis.internalSecret) {
231
+ if (!config.redis.internalAddress) {
232
+ throw new Error(
233
+ '[mcp-server] redis.internalAddress is required when redis.internalSecret is set'
234
+ );
235
+ }
236
+ if (!config.redis.internalSecret) {
237
+ throw new Error(
238
+ '[mcp-server] redis.internalSecret is required when redis.internalAddress is set'
239
+ );
240
+ }
241
+ try {
242
+ const u = new URL(config.redis.internalAddress);
243
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
244
+ throw new Error('protocol');
245
+ }
246
+ } catch {
247
+ throw new Error('[mcp-server] redis.internalAddress must be a valid http(s) URL');
248
+ }
249
+ if (config.redis.internalSecret.length < 32) {
250
+ throw new Error(
251
+ '[mcp-server] redis.internalSecret must be at least 32 characters of random data'
252
+ );
253
+ }
254
+ }
255
+ }
256
+
257
+ if (config.oauth.mode === 'external') {
258
+ if (!config.oauth.external) {
259
+ throw new Error('[mcp-server] oauth.mode is "external" but oauth.external is missing');
260
+ }
261
+ if (!config.oauth.external.issuer || !config.oauth.external.jwksUri) {
262
+ throw new Error(
263
+ '[mcp-server] oauth.external.issuer and oauth.external.jwksUri are required when oauth.mode is "external"'
264
+ );
265
+ }
266
+ try {
267
+ // eslint-disable-next-line no-new
268
+ new URL(config.oauth.external.issuer);
269
+ // eslint-disable-next-line no-new
270
+ new URL(config.oauth.external.jwksUri);
271
+ } catch {
272
+ throw new Error('[mcp-server] oauth.external.issuer / jwksUri must be valid URLs');
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Strapi reads `default` and `validator` from this module. The merged
279
+ * runtime config is then accessible via `strapi.config.get('plugin::mcp-server')`.
280
+ */
281
+ export default {
282
+ default: defaultConfig,
283
+ validator(config: McpConfig) {
284
+ validator(config);
285
+ },
286
+ };
287
+
288
+ export function getConfig(strapi: Core.Strapi): McpConfig {
289
+ return strapi.config.get('plugin::mcp-server') as McpConfig;
290
+ }
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -0,0 +1,32 @@
1
+ {
2
+ "kind": "collectionType",
3
+ "collectionName": "mcp_audit_log",
4
+ "info": {
5
+ "singularName": "audit-log",
6
+ "pluralName": "audit-logs",
7
+ "displayName": "MCP Audit Log",
8
+ "description": "Append-only log of every MCP tool call."
9
+ },
10
+ "options": {
11
+ "draftAndPublish": false,
12
+ "comment": ""
13
+ },
14
+ "pluginOptions": {
15
+ "content-manager": { "visible": false },
16
+ "content-type-builder": { "visible": false }
17
+ },
18
+ "attributes": {
19
+ "ts": { "type": "datetime", "required": true },
20
+ "principalType": { "type": "string", "required": true, "maxLength": 32 },
21
+ "principalId": { "type": "string", "required": true, "maxLength": 128 },
22
+ "sessionId": { "type": "string", "maxLength": 128 },
23
+ "clientId": { "type": "string", "maxLength": 128 },
24
+ "tool": { "type": "string", "required": true, "maxLength": 128 },
25
+ "params": { "type": "json" },
26
+ "resultStatus": { "type": "enumeration", "enum": ["ok", "error"], "required": true },
27
+ "errorCode": { "type": "string", "maxLength": 64 },
28
+ "ip": { "type": "string", "maxLength": 64 },
29
+ "userAgent": { "type": "string", "maxLength": 512 },
30
+ "durationMs": { "type": "integer" }
31
+ }
32
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ import auditLog from './audit-log';
4
+ import oauthClient from './oauth-client';
5
+ import oauthAuthCode from './oauth-auth-code';
6
+ import oauthRefreshToken from './oauth-refresh-token';
7
+ import oauthRevocation from './oauth-revocation';
8
+ import oauthConsent from './oauth-consent';
9
+ import oauthSigningKey from './oauth-signing-key';
10
+
11
+ export default {
12
+ 'audit-log': auditLog,
13
+ 'oauth-client': oauthClient,
14
+ 'oauth-auth-code': oauthAuthCode,
15
+ 'oauth-refresh-token': oauthRefreshToken,
16
+ 'oauth-revocation': oauthRevocation,
17
+ 'oauth-consent': oauthConsent,
18
+ 'oauth-signing-key': oauthSigningKey,
19
+ };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -0,0 +1,31 @@
1
+ {
2
+ "kind": "collectionType",
3
+ "collectionName": "mcp_oauth_auth_codes",
4
+ "info": {
5
+ "singularName": "oauth-auth-code",
6
+ "pluralName": "oauth-auth-codes",
7
+ "displayName": "MCP OAuth Auth Code"
8
+ },
9
+ "options": { "draftAndPublish": false },
10
+ "pluginOptions": {
11
+ "content-manager": { "visible": false },
12
+ "content-type-builder": { "visible": false }
13
+ },
14
+ "attributes": {
15
+ "codeHash": { "type": "string", "required": true, "maxLength": 128, "private": true },
16
+ "clientId": { "type": "string", "required": true, "maxLength": 128 },
17
+ "adminUserId": { "type": "string", "required": true, "maxLength": 64 },
18
+ "scope": { "type": "string", "required": true, "maxLength": 512 },
19
+ "redirectUri": { "type": "string", "required": true, "maxLength": 2048 },
20
+ "codeChallenge": { "type": "string", "required": true, "maxLength": 256 },
21
+ "codeChallengeMethod": {
22
+ "type": "enumeration",
23
+ "enum": ["S256"],
24
+ "required": true,
25
+ "default": "S256"
26
+ },
27
+ "resource": { "type": "string", "required": true, "maxLength": 2048 },
28
+ "used": { "type": "boolean", "default": false, "required": true },
29
+ "expiresAt": { "type": "datetime", "required": true }
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -0,0 +1,33 @@
1
+ {
2
+ "kind": "collectionType",
3
+ "collectionName": "mcp_oauth_clients",
4
+ "info": {
5
+ "singularName": "oauth-client",
6
+ "pluralName": "oauth-clients",
7
+ "displayName": "MCP OAuth Client"
8
+ },
9
+ "options": { "draftAndPublish": false },
10
+ "pluginOptions": {
11
+ "content-manager": { "visible": false },
12
+ "content-type-builder": { "visible": false }
13
+ },
14
+ "attributes": {
15
+ "clientId": { "type": "uid", "required": true, "targetField": "clientName" },
16
+ "clientName": { "type": "string", "required": true, "maxLength": 200 },
17
+ "clientSecretHash": { "type": "string", "private": true, "maxLength": 128 },
18
+ "isConfidential": { "type": "boolean", "default": false, "required": true },
19
+ "redirectUris": { "type": "json", "required": true },
20
+ "grantTypes": { "type": "json", "required": true },
21
+ "scopes": { "type": "json", "required": true },
22
+ "tokenEndpointAuthMethod": {
23
+ "type": "enumeration",
24
+ "enum": ["none", "client_secret_basic", "client_secret_post"],
25
+ "required": true,
26
+ "default": "none"
27
+ },
28
+ "ownerAdminId": { "type": "string", "maxLength": 64 },
29
+ "createdByAdminId": { "type": "string", "maxLength": 64 },
30
+ "lastUsedAt": { "type": "datetime" },
31
+ "disabled": { "type": "boolean", "default": false, "required": true }
32
+ }
33
+ }
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };