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,216 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { getConfig } from '../config';
7
+ import type { Scope } from './oauth/scopes';
8
+
9
+ export interface SessionPrincipal {
10
+ adminUserId: string;
11
+ clientId: string;
12
+ jti: string;
13
+ }
14
+
15
+ export interface Session {
16
+ id: string;
17
+ transport: StreamableHTTPServerTransport;
18
+ mcpServer: McpServer;
19
+ principal: SessionPrincipal;
20
+ scopes: Scope[];
21
+ createdAt: number;
22
+ lastSeenAt: number;
23
+ }
24
+
25
+ export type Location =
26
+ | { kind: 'local'; session: Session }
27
+ | { kind: 'remote'; instance: string; address: string; principal: SessionPrincipal }
28
+ | undefined;
29
+
30
+ const sessions = new Map<string, Session>();
31
+ const byPrincipal = new Map<string, Set<string>>();
32
+
33
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
34
+ function directory() {
35
+ return strapi.plugin('mcp-server').service('session-directory');
36
+ }
37
+ function instanceId(): string {
38
+ return strapi.plugin('mcp-server').service('instance-id').get();
39
+ }
40
+ function localPrincipalKey(p: SessionPrincipal): string {
41
+ return `${p.adminUserId}:${p.clientId}`;
42
+ }
43
+
44
+ return {
45
+ /**
46
+ * Resolve a session id to either the local in-memory session or a remote
47
+ * directory entry pointing at the owning instance. Returns undefined when
48
+ * the id is unknown both locally and in Redis.
49
+ */
50
+ async locate(id: string): Promise<Location> {
51
+ const local = sessions.get(id);
52
+ if (local) {
53
+ local.lastSeenAt = Date.now();
54
+ return { kind: 'local', session: local };
55
+ }
56
+ const remote = await directory().lookup(id);
57
+ if (!remote) return undefined;
58
+ if (remote.instance === instanceId()) {
59
+ // Stale directory entry — we are the owner but lost it (process
60
+ // restart, sweep, etc.). Drop the entry so clients re-initialize.
61
+ await directory().unregister(id, {
62
+ adminUserId: remote.adminUserId,
63
+ clientId: remote.clientId,
64
+ jti: '',
65
+ });
66
+ return undefined;
67
+ }
68
+ return {
69
+ kind: 'remote',
70
+ instance: remote.instance,
71
+ address: remote.address,
72
+ principal: {
73
+ adminUserId: remote.adminUserId,
74
+ clientId: remote.clientId,
75
+ jti: '',
76
+ },
77
+ };
78
+ },
79
+
80
+ /**
81
+ * Returns false when global or per-principal caps would be exceeded. In
82
+ * single-instance mode caps are local. With Redis routing enabled, the
83
+ * principal cap becomes cluster-wide (queried from the directory).
84
+ */
85
+ async canCreate(principal: SessionPrincipal): Promise<boolean> {
86
+ const cfg = getConfig(strapi);
87
+ if (sessions.size >= cfg.session.maxTotal) return false;
88
+ if (await directory().isActive()) {
89
+ const count = await directory().countForPrincipal(
90
+ principal.adminUserId,
91
+ principal.clientId
92
+ );
93
+ return count < cfg.session.maxPerPrincipal;
94
+ }
95
+ const owned = byPrincipal.get(localPrincipalKey(principal))?.size ?? 0;
96
+ return owned < cfg.session.maxPerPrincipal;
97
+ },
98
+
99
+ async put(session: Session): Promise<void> {
100
+ sessions.set(session.id, session);
101
+ const key = localPrincipalKey(session.principal);
102
+ let set = byPrincipal.get(key);
103
+ if (!set) {
104
+ set = new Set();
105
+ byPrincipal.set(key, set);
106
+ }
107
+ set.add(session.id);
108
+
109
+ if (await directory().isActive()) {
110
+ const cfg = getConfig(strapi);
111
+ const internalAddress = cfg.redis?.internalAddress;
112
+ if (!internalAddress) return; // Caller / config validator should have caught this.
113
+ await directory().register({
114
+ id: session.id,
115
+ instance: instanceId(),
116
+ address: internalAddress,
117
+ adminUserId: session.principal.adminUserId,
118
+ clientId: session.principal.clientId,
119
+ createdAt: session.createdAt,
120
+ expiresAt: session.createdAt + cfg.session.hardTtlMs,
121
+ });
122
+ }
123
+ },
124
+
125
+ async close(id: string): Promise<void> {
126
+ const s = sessions.get(id);
127
+ if (s) {
128
+ sessions.delete(id);
129
+ byPrincipal.get(localPrincipalKey(s.principal))?.delete(id);
130
+ try {
131
+ await s.transport.close();
132
+ } catch (err) {
133
+ strapi.log.warn(`[mcp-server] transport close failed for session=${id}`, err as Error);
134
+ }
135
+ }
136
+ if (await directory().isActive()) {
137
+ await directory().unregister(
138
+ id,
139
+ s
140
+ ? s.principal
141
+ : undefined
142
+ );
143
+ }
144
+ },
145
+
146
+ async closeAll(): Promise<void> {
147
+ const ids = [...sessions.keys()];
148
+ await Promise.all(ids.map((id) => this.close(id)));
149
+ },
150
+
151
+ /** Evict idle and hard-TTL-exceeded sessions; called periodically by bootstrap. */
152
+ sweep(): void {
153
+ const cfg = getConfig(strapi);
154
+ const now = Date.now();
155
+ const toClose: string[] = [];
156
+ for (const [id, s] of sessions.entries()) {
157
+ if (now - s.lastSeenAt > cfg.session.idleTtlMs) toClose.push(id);
158
+ else if (now - s.createdAt > cfg.session.hardTtlMs) toClose.push(id);
159
+ }
160
+ for (const id of toClose) {
161
+ void this.close(id);
162
+ }
163
+ },
164
+
165
+ /**
166
+ * Drop all sessions belonging to an admin user across the cluster.
167
+ *
168
+ * Closes local sessions immediately AND publishes on `mcp:revoke` so peer
169
+ * instances close any sessions they own for the same principal. The
170
+ * principal's tokens get revoked through a separate path (tokens service);
171
+ * this only affects session liveness.
172
+ */
173
+ async closeForPrincipal(adminUserId: string): Promise<void> {
174
+ await this.closeForPrincipalLocal(adminUserId);
175
+ // Broadcast to peers — best-effort, log on failure.
176
+ try {
177
+ const r = await strapi.plugin('mcp-server').service('redis').get();
178
+ if (r) {
179
+ const channel = strapi.plugin('mcp-server').service('redis').key('revoke');
180
+ await r.publish(channel, adminUserId);
181
+ }
182
+ } catch (err) {
183
+ strapi.log.warn(
184
+ `[mcp-server] failed to publish revocation for user=${adminUserId}: ${(err as Error).message}`
185
+ );
186
+ }
187
+ },
188
+
189
+ /**
190
+ * Local-only variant — closes sessions for the principal on THIS instance
191
+ * without re-publishing. The pub/sub subscriber in bootstrap calls this on
192
+ * incoming `mcp:revoke` messages so we don't loop.
193
+ */
194
+ async closeForPrincipalLocal(adminUserId: string): Promise<void> {
195
+ for (const [id, s] of sessions.entries()) {
196
+ if (s.principal.adminUserId === adminUserId) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ await this.close(id);
199
+ }
200
+ }
201
+ },
202
+
203
+ stats(): { total: number; byPrincipal: Record<string, number> } {
204
+ const byP: Record<string, number> = {};
205
+ for (const [k, set] of byPrincipal.entries()) byP[k] = set.size;
206
+ return { total: sessions.size, byPrincipal: byP };
207
+ },
208
+
209
+ /** Local-only lookup. Used by the proxy receive controller. */
210
+ getLocal(id: string): Session | undefined {
211
+ const s = sessions.get(id);
212
+ if (s) s.lastSeenAt = Date.now();
213
+ return s;
214
+ },
215
+ };
216
+ };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
4
+ import type { Core } from '@strapi/strapi';
5
+ import { getConfig } from '../config';
6
+
7
+ const COOKIE_NAME = 'mcp_admin_sso';
8
+ const RESUME_COOKIE_NAME = 'mcp_resume';
9
+ const RESUME_TTL_SEC = 600;
10
+
11
+ interface CookiePayload {
12
+ adminId: string;
13
+ /**
14
+ * Strapi admin session id captured at handoff time. We re-check it against
15
+ * `strapi.sessionManager('admin').isSessionActive` on every verify so that
16
+ * logging out of Strapi admin immediately invalidates our SSO cookie too —
17
+ * otherwise our own TTL would let a logged-out user breeze past /authorize.
18
+ * Older cookies issued before this field was added omit it; we treat those
19
+ * as invalid to fail closed.
20
+ */
21
+ sid?: string;
22
+ exp: number;
23
+ nonce: string;
24
+ }
25
+
26
+ interface ResumePayload {
27
+ url: string;
28
+ exp: number;
29
+ }
30
+
31
+ /**
32
+ * The SSO cookie is HMAC'd with a key derived from the active OAuth signing key
33
+ * (kid + Strapi APP_KEYS). Never uses ADMIN_JWT_SECRET. The cookie body is
34
+ * base64url(JSON) and the signature is the second segment.
35
+ */
36
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
37
+ async function hmacKey(): Promise<string> {
38
+ const key = await strapi.plugin('mcp-server').service('signing-keys').getActiveKey();
39
+ const appKeys = strapi.config.get('app.keys') as string[] | undefined;
40
+ return `${key.kid}.${(appKeys ?? []).join('|')}`;
41
+ }
42
+
43
+ function sign(value: string, key: string): string {
44
+ return createHmac('sha256', key).update(value).digest('base64url');
45
+ }
46
+
47
+ return {
48
+ cookieName(): string {
49
+ return COOKIE_NAME;
50
+ },
51
+
52
+ resumeCookieName(): string {
53
+ return RESUME_COOKIE_NAME;
54
+ },
55
+
56
+ /**
57
+ * Sign a resume URL into a short-lived cookie. Used to survive Strapi's
58
+ * /auth/login redirectTo round-trip, which double-decodes the value and
59
+ * mangles nested OAuth query strings. The cookie is the source of truth;
60
+ * the URL `next` param is best-effort fallback.
61
+ */
62
+ async issueResume(url: string): Promise<{ value: string; maxAgeSec: number }> {
63
+ const payload: ResumePayload = {
64
+ url,
65
+ exp: Math.floor(Date.now() / 1000) + RESUME_TTL_SEC,
66
+ };
67
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
68
+ const sig = sign(body, await hmacKey());
69
+ return { value: `${body}.${sig}`, maxAgeSec: RESUME_TTL_SEC };
70
+ },
71
+
72
+ async verifyResume(cookieValue: string | undefined): Promise<string | null> {
73
+ if (!cookieValue) return null;
74
+ const [body, sig] = cookieValue.split('.');
75
+ if (!body || !sig) return null;
76
+ const expected = sign(body, await hmacKey());
77
+ if (expected.length !== sig.length) return null;
78
+ try {
79
+ if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ try {
84
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as ResumePayload;
85
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
86
+ if (typeof payload.url !== 'string' || !payload.url.startsWith('/')) return null;
87
+ return payload.url;
88
+ } catch {
89
+ return null;
90
+ }
91
+ },
92
+
93
+ async issue(
94
+ adminId: string,
95
+ sessionId: string
96
+ ): Promise<{ value: string; maxAgeSec: number }> {
97
+ const cfg = getConfig(strapi);
98
+ const exp = Math.floor(Date.now() / 1000) + cfg.oauth.ssoCookieTtlSec;
99
+ const payload: CookiePayload = {
100
+ adminId,
101
+ sid: sessionId,
102
+ exp,
103
+ nonce: randomBytes(12).toString('base64url'),
104
+ };
105
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
106
+ const sig = sign(body, await hmacKey());
107
+ return { value: `${body}.${sig}`, maxAgeSec: cfg.oauth.ssoCookieTtlSec };
108
+ },
109
+
110
+ async verify(cookieValue: string | undefined): Promise<string | null> {
111
+ if (!cookieValue) return null;
112
+ const [body, sig] = cookieValue.split('.');
113
+ if (!body || !sig) return null;
114
+ const expected = sign(body, await hmacKey());
115
+ if (expected.length !== sig.length) return null;
116
+ try {
117
+ if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ let payload: CookiePayload;
122
+ try {
123
+ payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as CookiePayload;
124
+ } catch {
125
+ return null;
126
+ }
127
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
128
+ if (typeof payload.adminId !== 'string' || !payload.adminId) return null;
129
+ if (typeof payload.sid !== 'string' || !payload.sid) return null;
130
+ // Re-check the bound Strapi admin session is still active. When the
131
+ // admin logs out, Strapi calls invalidateRefreshToken which deletes the
132
+ // session row; this check then returns false and the cookie is
133
+ // effectively dead even though our own TTL hasn't expired yet.
134
+ try {
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ const sm = strapi.sessionManager('admin' as any);
137
+ const active: boolean = await sm.isSessionActive(payload.sid);
138
+ if (!active) return null;
139
+ } catch {
140
+ // Defensive: if the session manager isn't available, fail closed.
141
+ return null;
142
+ }
143
+ return payload.adminId;
144
+ },
145
+ };
146
+ };
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ import { z } from 'zod';
4
+ import type { Core } from '@strapi/strapi';
5
+ import type { PrincipalContext } from '../permissions';
6
+ import { hasScope, type Scope } from '../oauth/scopes';
7
+
8
+ const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
9
+
10
+ export interface ToolDef {
11
+ name: string;
12
+ description: string;
13
+ scope: Scope;
14
+ inputSchema: z.ZodTypeAny;
15
+ handler: (args: unknown) => Promise<{ content: Array<{ type: 'text'; text: string }> }>;
16
+ }
17
+
18
+ export interface ToolFactoryArgs {
19
+ strapi: Core.Strapi;
20
+ principal: PrincipalContext;
21
+ scopes: Scope[];
22
+ }
23
+
24
+ const populateSchema = z
25
+ .union([z.literal('*'), z.array(z.string().regex(/^[A-Za-z0-9_.]{1,64}$/))])
26
+ .optional();
27
+
28
+ const uidSchema = (strapi: Core.Strapi) =>
29
+ z.string().refine(
30
+ (uid) =>
31
+ uid in (strapi.contentTypes as unknown as Record<string, unknown>) &&
32
+ !strapi
33
+ .plugin('mcp-server')
34
+ .service('permissions')
35
+ .isInternalUid(uid),
36
+ { message: 'unknown or disallowed uid' }
37
+ );
38
+
39
+ export function createContentTools(args: ToolFactoryArgs): ToolDef[] {
40
+ const { strapi, principal, scopes } = args;
41
+ const permSvc = strapi.plugin('mcp-server').service('permissions');
42
+
43
+ function requireScope(s: Scope): void {
44
+ if (!hasScope(scopes, s)) {
45
+ const err = new Error('You do not have permission to perform this action.');
46
+ (err as Error & { code?: string }).code = 'insufficient_scope';
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ async function requirePerm(uid: string, action: 'read' | 'create' | 'update'): Promise<void> {
52
+ const ok = await permSvc.canActionOnUid(principal, uid, action);
53
+ if (!ok) {
54
+ const err = new Error('You do not have permission to access this content.');
55
+ (err as Error & { code?: string }).code = 'forbidden';
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ const json = (value: unknown) => ({
61
+ content: [{ type: 'text' as const, text: JSON.stringify(value) }],
62
+ });
63
+
64
+ return [
65
+ {
66
+ name: 'strapi.content.list_types',
67
+ description: 'List all content-types the caller is allowed to see.',
68
+ scope: 'strapi:content:read',
69
+ inputSchema: z.object({}).strict(),
70
+ async handler() {
71
+ requireScope('strapi:content:read');
72
+ const allowed = permSvc.listAllowedUids() as string[];
73
+ const filtered: Array<Record<string, unknown>> = [];
74
+ for (const uid of allowed) {
75
+ // eslint-disable-next-line no-await-in-loop
76
+ if (!(await permSvc.canActionOnUid(principal, uid, 'read'))) continue;
77
+ const cts = strapi.contentTypes as unknown as Record<
78
+ string,
79
+ {
80
+ kind?: string;
81
+ info?: { displayName?: string; pluralName?: string };
82
+ options?: { draftAndPublish?: boolean };
83
+ }
84
+ >;
85
+ const ct = cts[uid];
86
+ filtered.push({
87
+ uid,
88
+ kind: ct.kind,
89
+ displayName: ct.info?.displayName,
90
+ pluralName: ct.info?.pluralName,
91
+ draftAndPublish: !!ct.options?.draftAndPublish,
92
+ });
93
+ }
94
+ return json({ contentTypes: filtered });
95
+ },
96
+ },
97
+
98
+ {
99
+ name: 'strapi.content.get_schema',
100
+ description: 'Return the attribute schema for a single content-type.',
101
+ scope: 'strapi:content:read',
102
+ inputSchema: z.object({ uid: uidSchema(strapi) }).strict(),
103
+ async handler(raw) {
104
+ requireScope('strapi:content:read');
105
+ const input = z.object({ uid: uidSchema(strapi) }).parse(raw) as { uid: string };
106
+ await requirePerm(input.uid, 'read');
107
+ const cts = strapi.contentTypes as unknown as Record<
108
+ string,
109
+ {
110
+ kind?: string;
111
+ info?: Record<string, unknown>;
112
+ attributes: Record<string, { type: string; component?: string; components?: string[] }>;
113
+ }
114
+ >;
115
+ const ct = cts[input.uid];
116
+ const components = strapi.components as unknown as Record<string, { attributes: unknown }>;
117
+ const referenced: Record<string, unknown> = {};
118
+ for (const attr of Object.values(ct.attributes)) {
119
+ if (attr.type === 'component' && attr.component && components[attr.component]) {
120
+ referenced[attr.component] = components[attr.component].attributes;
121
+ }
122
+ if (attr.type === 'dynamiczone' && Array.isArray(attr.components)) {
123
+ for (const c of attr.components) {
124
+ if (components[c]) referenced[c] = components[c].attributes;
125
+ }
126
+ }
127
+ }
128
+ return json({
129
+ uid: input.uid,
130
+ kind: ct.kind,
131
+ info: ct.info,
132
+ attributes: ct.attributes,
133
+ components: referenced,
134
+ });
135
+ },
136
+ },
137
+
138
+ {
139
+ name: 'strapi.content.list_entries',
140
+ description: 'Paginated list of entries for a content-type. pageSize <= 100.',
141
+ scope: 'strapi:content:read',
142
+ inputSchema: z
143
+ .object({
144
+ uid: uidSchema(strapi),
145
+ filters: z.record(z.any()).optional(),
146
+ pagination: z
147
+ .object({
148
+ page: z.number().int().min(1).max(10000).default(1),
149
+ pageSize: z.number().int().min(1).max(100).default(25),
150
+ })
151
+ .optional(),
152
+ locale: z.string().regex(LOCALE_RE).optional(),
153
+ status: z.enum(['draft', 'published']).default('draft'),
154
+ populate: populateSchema,
155
+ })
156
+ .strict(),
157
+ async handler(raw) {
158
+ requireScope('strapi:content:read');
159
+ const schema = this.inputSchema as z.ZodTypeAny;
160
+ const input = schema.parse(raw) as {
161
+ uid: string;
162
+ filters?: Record<string, unknown>;
163
+ pagination?: { page: number; pageSize: number };
164
+ locale?: string;
165
+ status: 'draft' | 'published';
166
+ populate?: '*' | string[];
167
+ };
168
+ await requirePerm(input.uid, 'read');
169
+
170
+ const page = input.pagination?.page ?? 1;
171
+ const pageSize = Math.min(100, input.pagination?.pageSize ?? 25);
172
+
173
+ const result = await strapi.documents(input.uid as never).findMany({
174
+ filters: (input.filters ?? {}) as never,
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ locale: input.locale as any,
177
+ status: input.status,
178
+ populate: input.populate as never,
179
+ start: (page - 1) * pageSize,
180
+ limit: pageSize,
181
+ });
182
+ return json({ page, pageSize, count: result.length, results: result });
183
+ },
184
+ },
185
+
186
+ {
187
+ name: 'strapi.content.get_entry',
188
+ description: 'Fetch a single entry by documentId.',
189
+ scope: 'strapi:content:read',
190
+ inputSchema: z
191
+ .object({
192
+ uid: uidSchema(strapi),
193
+ documentId: z.string().min(1).max(128),
194
+ locale: z.string().regex(LOCALE_RE).optional(),
195
+ status: z.enum(['draft', 'published']).default('draft'),
196
+ populate: populateSchema,
197
+ })
198
+ .strict(),
199
+ async handler(raw) {
200
+ requireScope('strapi:content:read');
201
+ const schema = this.inputSchema as z.ZodTypeAny;
202
+ const input = schema.parse(raw) as {
203
+ uid: string;
204
+ documentId: string;
205
+ locale?: string;
206
+ status: 'draft' | 'published';
207
+ populate?: '*' | string[];
208
+ };
209
+ await requirePerm(input.uid, 'read');
210
+ const result = await strapi.documents(input.uid as never).findOne({
211
+ documentId: input.documentId,
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ locale: input.locale as any,
214
+ status: input.status,
215
+ populate: input.populate as never,
216
+ });
217
+ return json(result ?? null);
218
+ },
219
+ },
220
+
221
+ {
222
+ name: 'strapi.content.create_entry',
223
+ description: 'Create a draft entry. Publish/unpublish is not exposed.',
224
+ scope: 'strapi:content:write',
225
+ inputSchema: z
226
+ .object({
227
+ uid: uidSchema(strapi),
228
+ data: z.record(z.any()),
229
+ locale: z.string().regex(LOCALE_RE).optional(),
230
+ })
231
+ .strict(),
232
+ async handler(raw) {
233
+ requireScope('strapi:content:write');
234
+ const schema = this.inputSchema as z.ZodTypeAny;
235
+ const input = schema.parse(raw) as {
236
+ uid: string;
237
+ data: Record<string, unknown>;
238
+ locale?: string;
239
+ };
240
+ await requirePerm(input.uid, 'create');
241
+ const result = await strapi.documents(input.uid as never).create({
242
+ data: input.data as never,
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
+ locale: input.locale as any,
245
+ status: 'draft',
246
+ });
247
+ return json(result);
248
+ },
249
+ },
250
+
251
+ {
252
+ name: 'strapi.content.update_entry',
253
+ description: 'Partial update of an existing draft entry. Publish/unpublish not exposed.',
254
+ scope: 'strapi:content:write',
255
+ inputSchema: z
256
+ .object({
257
+ uid: uidSchema(strapi),
258
+ documentId: z.string().min(1).max(128),
259
+ data: z.record(z.any()),
260
+ locale: z.string().regex(LOCALE_RE).optional(),
261
+ })
262
+ .strict(),
263
+ async handler(raw) {
264
+ requireScope('strapi:content:write');
265
+ const schema = this.inputSchema as z.ZodTypeAny;
266
+ const input = schema.parse(raw) as {
267
+ uid: string;
268
+ documentId: string;
269
+ data: Record<string, unknown>;
270
+ locale?: string;
271
+ };
272
+ await requirePerm(input.uid, 'update');
273
+ const result = await strapi.documents(input.uid as never).update({
274
+ documentId: input.documentId,
275
+ data: input.data as never,
276
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
277
+ locale: input.locale as any,
278
+ status: 'draft',
279
+ });
280
+ return json(result);
281
+ },
282
+ },
283
+ ];
284
+ }
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ import type { ToolDef, ToolFactoryArgs } from './content';
4
+ import { createContentTools } from './content';
5
+ import { createMediaTools } from './media';
6
+ import { getConfig } from '../../config';
7
+
8
+ export type { ToolDef, ToolFactoryArgs };
9
+
10
+ /**
11
+ * Build the per-session tool list. Filters by:
12
+ * - granted scopes (a tool whose scope isn't granted is not registered at all)
13
+ * - master-toggle in config.tools.enabled[name] (default: true)
14
+ */
15
+ export function buildToolsForSession(args: ToolFactoryArgs): ToolDef[] {
16
+ const cfg = getConfig(args.strapi);
17
+ const all = [...createContentTools(args), ...createMediaTools(args)];
18
+ return all.filter((t) => {
19
+ if (!args.scopes.includes(t.scope)) return false;
20
+ const toggle = cfg.tools.enabled[t.name];
21
+ return toggle === undefined ? true : toggle;
22
+ });
23
+ }