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,166 @@
1
+ 'use strict';
2
+
3
+ import { generateKeyPair, exportJWK, importJWK, type JWK, type KeyLike } from 'jose';
4
+ import {
5
+ createCipheriv,
6
+ createDecipheriv,
7
+ hkdfSync,
8
+ randomBytes,
9
+ } from 'crypto';
10
+ import type { Core } from '@strapi/strapi';
11
+
12
+ const UID = 'plugin::mcp-server.oauth-signing-key';
13
+
14
+ export interface ActiveKey {
15
+ kid: string;
16
+ alg: string;
17
+ publicJwk: JWK;
18
+ privateKey: KeyLike;
19
+ }
20
+
21
+ interface KeyRow {
22
+ id: number;
23
+ kid: string;
24
+ alg: string;
25
+ publicJwk: JWK;
26
+ privateJwkEncrypted: string;
27
+ retiredAt: string | null;
28
+ }
29
+
30
+ let cached: ActiveKey | null = null;
31
+
32
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
33
+ /**
34
+ * Generate-on-first-boot. Idempotent: if an active (non-retired) key exists
35
+ * AND can be decrypted, load and cache it. If a row exists but can't be
36
+ * decrypted (e.g. legacy null/garbled value from a prior boot before this
37
+ * fix), the row is dropped and a new key is minted in its place.
38
+ */
39
+ async ensureActiveKey(): Promise<ActiveKey> {
40
+ if (cached) return cached;
41
+
42
+ const existing = (await strapi.db.query(UID).findOne({
43
+ where: { retiredAt: { $null: true } },
44
+ orderBy: { id: 'desc' },
45
+ })) as KeyRow | null;
46
+
47
+ if (existing) {
48
+ try {
49
+ const decrypted = decryptBlob(strapi, existing.privateJwkEncrypted);
50
+ const jwk = JSON.parse(decrypted) as JWK;
51
+ const privateKey = (await importJWK(jwk, existing.alg)) as KeyLike;
52
+ cached = {
53
+ kid: existing.kid,
54
+ alg: existing.alg,
55
+ publicJwk: { ...existing.publicJwk, kid: existing.kid, alg: existing.alg, use: 'sig' },
56
+ privateKey,
57
+ };
58
+ return cached;
59
+ } catch (err) {
60
+ strapi.log.warn(
61
+ `[mcp-server] existing signing key kid=${existing.kid} could not be decrypted (${
62
+ (err as Error).message
63
+ }); discarding and regenerating`
64
+ );
65
+ await strapi.db.query(UID).delete({ where: { id: existing.id } });
66
+ }
67
+ }
68
+
69
+ const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048 });
70
+ const publicJwk = await exportJWK(publicKey);
71
+ const privateJwk = await exportJWK(privateKey);
72
+ const kid = randomBytes(16).toString('hex');
73
+ publicJwk.kid = kid;
74
+ publicJwk.alg = 'RS256';
75
+ publicJwk.use = 'sig';
76
+
77
+ const encrypted = encryptBlob(strapi, JSON.stringify(privateJwk));
78
+ await strapi.db.query(UID).create({
79
+ data: {
80
+ kid,
81
+ alg: 'RS256',
82
+ publicJwk,
83
+ privateJwkEncrypted: encrypted,
84
+ },
85
+ });
86
+
87
+ cached = { kid, alg: 'RS256', publicJwk, privateKey };
88
+ strapi.log.info(`[mcp-server] minted OAuth signing key kid=${kid}`);
89
+ return cached;
90
+ },
91
+
92
+ async getActiveKey(): Promise<ActiveKey> {
93
+ return cached ?? this.ensureActiveKey();
94
+ },
95
+
96
+ /** JWKS endpoint payload — public keys only, includes retired-but-not-purged. */
97
+ async publicJwks(): Promise<{ keys: JWK[] }> {
98
+ const rows = (await strapi.db.query(UID).findMany({
99
+ orderBy: { id: 'desc' },
100
+ limit: 10,
101
+ })) as KeyRow[];
102
+ return {
103
+ keys: rows.map((r) => ({ ...r.publicJwk, kid: r.kid, alg: r.alg, use: 'sig' })),
104
+ };
105
+ },
106
+
107
+ invalidateCache(): void {
108
+ cached = null;
109
+ },
110
+ });
111
+
112
+ /**
113
+ * Derive a 32-byte AES-256 key from APP_KEYS + ADMIN_JWT_SECRET via HKDF.
114
+ * This is self-contained — we don't rely on Strapi's admin encryption service,
115
+ * which requires extra config and is silently nullable in some setups.
116
+ *
117
+ * Threat model: rotating APP_KEYS invalidates stored signing keys; we handle
118
+ * that by regenerating on decrypt failure (see ensureActiveKey above).
119
+ */
120
+ function deriveKey(strapi: Core.Strapi): Buffer {
121
+ const appKeys = (strapi.config.get('app.keys') as string[] | undefined) ?? [];
122
+ const adminSecret =
123
+ (strapi.config.get('admin.auth.secret') as string | undefined) ?? '';
124
+ const material = `${appKeys.join('|')}|${adminSecret}`;
125
+ if (material === '|') {
126
+ throw new Error(
127
+ '[mcp-server] APP_KEYS and ADMIN_JWT_SECRET must be configured for signing-key encryption'
128
+ );
129
+ }
130
+ return Buffer.from(
131
+ hkdfSync('sha256', material, 'strapi-mcp-server-salt', 'oauth-signing-key:v1', 32)
132
+ );
133
+ }
134
+
135
+ function encryptBlob(strapi: Core.Strapi, plaintext: string): string {
136
+ const key = deriveKey(strapi);
137
+ const iv = randomBytes(12);
138
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
139
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
140
+ const tag = cipher.getAuthTag();
141
+ return `v1:${iv.toString('base64url')}:${tag.toString('base64url')}:${enc.toString('base64url')}`;
142
+ }
143
+
144
+ function decryptBlob(strapi: Core.Strapi, blob: string): string {
145
+ if (!blob || typeof blob !== 'string') {
146
+ throw new Error('encrypted blob is null or non-string');
147
+ }
148
+ const parts = blob.split(':');
149
+ if (parts.length !== 4 || parts[0] !== 'v1') {
150
+ throw new Error('unrecognized blob format');
151
+ }
152
+ const key = deriveKey(strapi);
153
+ const iv = Buffer.from(parts[1], 'base64url');
154
+ const tag = Buffer.from(parts[2], 'base64url');
155
+ const enc = Buffer.from(parts[3], 'base64url');
156
+ // Pin authTagLength to 16 bytes (GCM's full 128-bit tag) so an attacker who
157
+ // can forge the stored blob can't downgrade to a shorter tag and brute-force
158
+ // it. encryptBlob always emits a 16-byte tag via getAuthTag(); reject any
159
+ // blob that doesn't match before handing it to setAuthTag.
160
+ if (tag.length !== 16) {
161
+ throw new Error('GCM auth tag has unexpected length');
162
+ }
163
+ const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
164
+ decipher.setAuthTag(tag);
165
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
166
+ }
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ import {
4
+ SignJWT,
5
+ jwtVerify,
6
+ type JWTPayload,
7
+ createLocalJWKSet,
8
+ createRemoteJWKSet,
9
+ type JWTVerifyGetKey,
10
+ } from 'jose';
11
+ import { createHash, randomBytes } from 'crypto';
12
+ import type { Core } from '@strapi/strapi';
13
+ import { getConfig } from '../../config';
14
+ import { authorizationServerUrl, canonicalResourceUrl } from './audience';
15
+ import { ALL_SCOPES, scopeString, parseScope, type Scope } from './scopes';
16
+
17
+ // Remote-JWKS cache for external AS mode. jose's createRemoteJWKSet has its
18
+ // own internal HTTP cache (~10 min default); we just avoid recreating the
19
+ // callable on every request.
20
+ let externalJwksCache: { uri: string; jwks: JWTVerifyGetKey } | null = null;
21
+ function getExternalJwks(uri: string): JWTVerifyGetKey {
22
+ if (!externalJwksCache || externalJwksCache.uri !== uri) {
23
+ externalJwksCache = { uri, jwks: createRemoteJWKSet(new URL(uri)) };
24
+ }
25
+ return externalJwksCache.jwks;
26
+ }
27
+
28
+ const REFRESH_UID = 'plugin::mcp-server.oauth-refresh-token';
29
+ const REVOKE_UID = 'plugin::mcp-server.oauth-revocation';
30
+
31
+ export interface MintResult {
32
+ accessToken: string;
33
+ refreshToken: string;
34
+ accessTokenExpiresAt: Date;
35
+ refreshTokenExpiresAt: Date;
36
+ jti: string;
37
+ familyId: string;
38
+ }
39
+
40
+ export interface VerifiedClaims {
41
+ sub: string;
42
+ scope: Scope[];
43
+ clientId: string;
44
+ jti: string;
45
+ exp: number;
46
+ }
47
+
48
+ export interface RefreshRow {
49
+ id: number;
50
+ tokenHash: string;
51
+ familyId: string;
52
+ parentJti: string | null;
53
+ clientId: string;
54
+ adminUserId: string;
55
+ scope: string;
56
+ rotatedTo: string | null;
57
+ revoked: boolean;
58
+ expiresAt: string;
59
+ }
60
+
61
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
62
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
63
+
64
+ return {
65
+ /**
66
+ * Mint a fresh access+refresh pair. New family — caller passes a stable
67
+ * familyId if this is a refresh rotation.
68
+ */
69
+ async mint(opts: {
70
+ adminUserId: string;
71
+ clientId: string;
72
+ scope: Scope[];
73
+ familyId?: string;
74
+ parentJti?: string;
75
+ }): Promise<MintResult> {
76
+ const cfg = getConfig(strapi);
77
+ const key = await strapi.plugin('mcp-server').service('signing-keys').getActiveKey();
78
+ const now = Math.floor(Date.now() / 1000);
79
+ const jti = randomBytes(16).toString('hex');
80
+ const issuer = authorizationServerUrl(strapi);
81
+ const audience = canonicalResourceUrl(strapi);
82
+
83
+ const payload: JWTPayload = {
84
+ scope: scopeString(opts.scope),
85
+ client_id: opts.clientId,
86
+ azp: opts.clientId,
87
+ jti,
88
+ };
89
+ const accessToken = await new SignJWT(payload)
90
+ .setProtectedHeader({ alg: key.alg, kid: key.kid, typ: 'at+jwt' })
91
+ .setIssuer(issuer)
92
+ .setSubject(opts.adminUserId)
93
+ .setAudience(audience)
94
+ .setIssuedAt(now)
95
+ .setExpirationTime(now + cfg.oauth.accessTokenTtlSec)
96
+ .sign(key.privateKey);
97
+
98
+ const refreshSecret = randomBytes(32).toString('base64url');
99
+ const familyId = opts.familyId ?? randomBytes(16).toString('hex');
100
+ const refreshExp = new Date((now + cfg.oauth.refreshTokenTtlSec) * 1000);
101
+
102
+ await strapi.db.query(REFRESH_UID).create({
103
+ data: {
104
+ tokenHash: sha256(refreshSecret),
105
+ familyId,
106
+ parentJti: opts.parentJti ?? null,
107
+ clientId: opts.clientId,
108
+ adminUserId: opts.adminUserId,
109
+ scope: scopeString(opts.scope),
110
+ revoked: false,
111
+ rotatedTo: null,
112
+ expiresAt: refreshExp,
113
+ },
114
+ });
115
+
116
+ return {
117
+ accessToken,
118
+ refreshToken: refreshSecret,
119
+ accessTokenExpiresAt: new Date((now + cfg.oauth.accessTokenTtlSec) * 1000),
120
+ refreshTokenExpiresAt: refreshExp,
121
+ jti,
122
+ familyId,
123
+ };
124
+ },
125
+
126
+ /**
127
+ * Verify an access JWT. Two modes, picked from `oauth.mode`:
128
+ *
129
+ * - **embedded** (default): verify against the plugin's own JWKS, check
130
+ * iss/aud/exp + revocation list. `sub` is already a Strapi admin user id.
131
+ * - **external**: verify against the configured external AS's JWKS, check
132
+ * iss/exp. Map the JWT's email-style claim back to a Strapi admin
133
+ * user (the JWT's `sub` is the external identity, NOT a Strapi id, so
134
+ * we resolve it server-side and present a Strapi admin id to callers).
135
+ *
136
+ * Throws Error('invalid_token' | 'expired') on failure.
137
+ */
138
+ async verifyAccessToken(token: string): Promise<VerifiedClaims> {
139
+ const cfg = getConfig(strapi);
140
+ if (cfg.oauth.mode === 'external') {
141
+ return verifyExternal(strapi, token);
142
+ }
143
+ return verifyEmbedded(strapi, token);
144
+ },
145
+
146
+ /**
147
+ * Atomically consume a refresh token: returns the previous row only if it
148
+ * was still rotatable. Reuse (rotatedTo set, or revoked) returns null AND
149
+ * triggers family-wide revocation.
150
+ */
151
+ async consumeRefresh(refreshSecret: string): Promise<RefreshRow | null> {
152
+ const hash = sha256(refreshSecret);
153
+ const row = (await strapi.db.query(REFRESH_UID).findOne({
154
+ where: { tokenHash: hash },
155
+ })) as RefreshRow | null;
156
+ if (!row) return null;
157
+
158
+ if (new Date(row.expiresAt).getTime() < Date.now()) {
159
+ return null;
160
+ }
161
+ if (row.revoked || row.rotatedTo) {
162
+ // Reuse detection: nuke the whole family.
163
+ await this.revokeFamily(row.familyId);
164
+ strapi.log.warn(
165
+ `[mcp-server] refresh-token reuse detected family=${row.familyId} client=${row.clientId} — family revoked`
166
+ );
167
+ return null;
168
+ }
169
+ return row;
170
+ },
171
+
172
+ async markRotated(parentRowId: number, newRefreshHash: string): Promise<void> {
173
+ await strapi.db
174
+ .query(REFRESH_UID)
175
+ .update({ where: { id: parentRowId }, data: { rotatedTo: newRefreshHash } });
176
+ },
177
+
178
+ async revokeFamily(familyId: string): Promise<void> {
179
+ await strapi.db.query(REFRESH_UID).updateMany({
180
+ where: { familyId },
181
+ data: { revoked: true },
182
+ });
183
+ },
184
+
185
+ async revokeRefresh(refreshSecret: string): Promise<void> {
186
+ const hash = sha256(refreshSecret);
187
+ const row = (await strapi.db
188
+ .query(REFRESH_UID)
189
+ .findOne({ where: { tokenHash: hash } })) as RefreshRow | null;
190
+ if (row) await this.revokeFamily(row.familyId);
191
+ },
192
+
193
+ async revokeAccessJti(jti: string, expiresAt: Date): Promise<void> {
194
+ try {
195
+ await strapi.db.query(REVOKE_UID).create({ data: { jti, expiresAt } });
196
+ } catch {
197
+ // unique constraint collision — already revoked, ignore
198
+ }
199
+ },
200
+
201
+ async revokeAllForUser(adminUserId: string): Promise<void> {
202
+ await strapi.db.query(REFRESH_UID).updateMany({
203
+ where: { adminUserId, revoked: false },
204
+ data: { revoked: true },
205
+ });
206
+ },
207
+
208
+ /** Daily purge — drops expired refresh tokens and revocation entries. */
209
+ async purgeExpired(): Promise<void> {
210
+ const now = new Date();
211
+ await strapi.db.query(REFRESH_UID).deleteMany({ where: { expiresAt: { $lt: now } } });
212
+ await strapi.db.query(REVOKE_UID).deleteMany({ where: { expiresAt: { $lt: now } } });
213
+ await strapi.db
214
+ .query('plugin::mcp-server.oauth-auth-code')
215
+ .deleteMany({ where: { expiresAt: { $lt: now } } });
216
+ },
217
+
218
+ hash(s: string): string {
219
+ return sha256(s);
220
+ },
221
+ };
222
+ };
223
+
224
+ async function verifyEmbedded(
225
+ strapi: Core.Strapi,
226
+ token: string
227
+ ): Promise<VerifiedClaims> {
228
+ const sk = strapi.plugin('mcp-server').service('signing-keys');
229
+ const jwks = createLocalJWKSet(await sk.publicJwks());
230
+ const issuer = authorizationServerUrl(strapi);
231
+ const audience = canonicalResourceUrl(strapi);
232
+
233
+ let claims: JWTPayload;
234
+ try {
235
+ const { payload } = await jwtVerify(token, jwks, { issuer, audience });
236
+ claims = payload;
237
+ } catch (err) {
238
+ const code = (err as { code?: string }).code;
239
+ if (code === 'ERR_JWT_EXPIRED') throw new Error('expired');
240
+ throw new Error('invalid_token');
241
+ }
242
+
243
+ const jti = typeof claims.jti === 'string' ? claims.jti : '';
244
+ if (!jti) throw new Error('invalid_token');
245
+
246
+ const revoked = await strapi.db.query(REVOKE_UID).findOne({ where: { jti } });
247
+ if (revoked) throw new Error('invalid_token');
248
+
249
+ const sub = typeof claims.sub === 'string' ? claims.sub : '';
250
+ const clientId = typeof claims.client_id === 'string' ? claims.client_id : '';
251
+ if (!sub || !clientId) throw new Error('invalid_token');
252
+
253
+ return {
254
+ sub,
255
+ scope: parseScope(claims.scope),
256
+ clientId,
257
+ jti,
258
+ exp: typeof claims.exp === 'number' ? claims.exp : 0,
259
+ };
260
+ }
261
+
262
+ async function verifyExternal(
263
+ strapi: Core.Strapi,
264
+ token: string
265
+ ): Promise<VerifiedClaims> {
266
+ const cfg = getConfig(strapi);
267
+ const ext = cfg.oauth.external;
268
+ if (!ext) throw new Error('invalid_token');
269
+ const jwks = getExternalJwks(ext.jwksUri);
270
+
271
+ let claims: JWTPayload;
272
+ try {
273
+ const { payload } = await jwtVerify(token, jwks, {
274
+ issuer: ext.issuer,
275
+ // No audience check in external mode — external AS owns aud.
276
+ });
277
+ claims = payload;
278
+ } catch (err) {
279
+ const code = (err as { code?: string }).code;
280
+ if (code === 'ERR_JWT_EXPIRED') throw new Error('expired');
281
+ throw new Error('invalid_token');
282
+ }
283
+
284
+ const lookupClaim = ext.adminLookupClaim ?? 'email';
285
+ const lookupValue = (claims as Record<string, unknown>)[lookupClaim];
286
+ if (typeof lookupValue !== 'string' || !lookupValue) {
287
+ throw new Error('invalid_token');
288
+ }
289
+ const adminWhere =
290
+ lookupClaim === 'email' ? { email: lookupValue } : { username: lookupValue };
291
+ const admin = (await strapi.db
292
+ .query('admin::user')
293
+ .findOne({ where: adminWhere })) as
294
+ | { id: number; isActive?: boolean; blocked?: boolean }
295
+ | null;
296
+ if (!admin || admin.isActive === false || admin.blocked) {
297
+ throw new Error('invalid_token');
298
+ }
299
+
300
+ const clientId =
301
+ typeof claims.azp === 'string'
302
+ ? claims.azp
303
+ : typeof claims.client_id === 'string'
304
+ ? claims.client_id
305
+ : 'external';
306
+
307
+ // Scope handling in external mode:
308
+ // - enforceScopes: true → require strapi:* scopes in the JWT (operator
309
+ // must define them as Client Scopes in the IdP)
310
+ // - enforceScopes: false (default) → grant the full surface, leaving
311
+ // granular control to Strapi RBAC + per-tool toggles. Keeps setup
312
+ // cross-IdP portable without per-vendor scope registration.
313
+ const scope: Scope[] = ext.enforceScopes
314
+ ? parseScope(claims.scope)
315
+ : [...ALL_SCOPES];
316
+
317
+ return {
318
+ sub: String(admin.id),
319
+ scope,
320
+ clientId,
321
+ jti: typeof claims.jti === 'string' ? claims.jti : '',
322
+ exp: typeof claims.exp === 'number' ? claims.exp : 0,
323
+ };
324
+ }
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+
5
+ const INTERNAL_UID = /^(admin::|strapi::|plugin::users-permissions\.(role|permission)|plugin::i18n\.locale|plugin::upload\.(folder|file)$|plugin::mcp-server\.)/;
6
+
7
+ export interface PrincipalContext {
8
+ user: { id: number | string; isActive?: boolean };
9
+ permissions: unknown[];
10
+ isSuperAdmin: boolean;
11
+ }
12
+
13
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
14
+ /**
15
+ * Load an admin user and their permissions. Used both at JWT verification time
16
+ * (to confirm the principal still exists and is active) and at tool-call time
17
+ * for RBAC enforcement.
18
+ *
19
+ * We bypass `admin::user.findOne(...)` because its `populate: ['roles']` path
20
+ * triggered a Knex "Undefined binding" error in some Strapi installs. Going
21
+ * direct to `strapi.db.query` is more predictable and gives us exactly the
22
+ * shape we need (user with roles relation).
23
+ */
24
+ async loadPrincipal(adminUserId: string | number): Promise<PrincipalContext | null> {
25
+ const id = typeof adminUserId === 'string' ? Number(adminUserId) || adminUserId : adminUserId;
26
+
27
+ const user = await strapi.db.query('admin::user').findOne({
28
+ where: { id },
29
+ populate: { roles: true },
30
+ });
31
+ if (!user || user.isActive === false || user.blocked) return null;
32
+
33
+ const roleSvc = strapi.service('admin::role');
34
+ let isSuperAdmin = false;
35
+ try {
36
+ isSuperAdmin = (await roleSvc.hasSuperAdminRole(user)) === true;
37
+ } catch {
38
+ // Fallback: check role code locally.
39
+ isSuperAdmin =
40
+ Array.isArray(user.roles) &&
41
+ user.roles.some((r: { code?: string }) => r.code === 'strapi-super-admin');
42
+ }
43
+
44
+ const permSvc = strapi.service('admin::permission');
45
+ let permissions: unknown[] = [];
46
+ try {
47
+ permissions = await permSvc.findUserPermissions({ user });
48
+ } catch (err) {
49
+ strapi.log.warn('[mcp-server] findUserPermissions failed', err as Error);
50
+ }
51
+
52
+ return { user, permissions, isSuperAdmin };
53
+ },
54
+
55
+ /**
56
+ * Content-manager-equivalent RBAC check for a UID + action.
57
+ * action: 'read' | 'create' | 'update' | 'delete' | 'publish'
58
+ * Internal UIDs are denied outright regardless of role.
59
+ */
60
+ async canActionOnUid(
61
+ principal: PrincipalContext,
62
+ uid: string,
63
+ action: 'read' | 'create' | 'update' | 'delete' | 'publish'
64
+ ): Promise<boolean> {
65
+ if (INTERNAL_UID.test(uid)) return false;
66
+ if (principal.isSuperAdmin) return true;
67
+
68
+ const actionId = `plugin::content-manager.explorer.${action}`;
69
+ return (principal.permissions as Array<{ action: string; subject: string | null }>).some(
70
+ (p) => p.action === actionId && (p.subject === uid || p.subject === null)
71
+ );
72
+ },
73
+
74
+ isInternalUid(uid: string): boolean {
75
+ return INTERNAL_UID.test(uid);
76
+ },
77
+
78
+ /** Returns allowed UIDs (collectionType + singleType, minus the denylist). */
79
+ listAllowedUids(): string[] {
80
+ const cts = strapi.contentTypes as unknown as Record<string, { kind?: string }>;
81
+ return Object.keys(cts).filter(
82
+ (uid) =>
83
+ !INTERNAL_UID.test(uid) &&
84
+ (cts[uid].kind === 'collectionType' || cts[uid].kind === 'singleType')
85
+ );
86
+ },
87
+ });