strapi-mcp-server 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/admin/src/components/PageHeader.tsx +33 -0
- package/admin/src/components/Sidebar.tsx +138 -0
- package/admin/src/index.tsx +54 -0
- package/admin/src/lib/api.ts +27 -0
- package/admin/src/lib/applyQuery.ts +152 -0
- package/admin/src/pages/App.tsx +126 -0
- package/admin/src/pages/AuditLog.tsx +386 -0
- package/admin/src/pages/Clients.tsx +465 -0
- package/admin/src/pages/EditClient.tsx +248 -0
- package/admin/src/pages/HomePage.tsx +378 -0
- package/admin/src/pages/NewClient.tsx +244 -0
- package/admin/src/pages/Settings.tsx +514 -0
- package/admin/src/pages/SsoBridge.tsx +96 -0
- package/admin/src/pages/Tools.tsx +68 -0
- package/admin/src/pluginId.ts +1 -0
- package/admin/src/translations/en.json +8 -0
- package/package.json +105 -0
- package/server/src/bootstrap.ts +118 -0
- package/server/src/config/index.ts +290 -0
- package/server/src/content-types/audit-log/index.ts +3 -0
- package/server/src/content-types/audit-log/schema.json +32 -0
- package/server/src/content-types/index.ts +19 -0
- package/server/src/content-types/oauth-auth-code/index.ts +3 -0
- package/server/src/content-types/oauth-auth-code/schema.json +31 -0
- package/server/src/content-types/oauth-client/index.ts +3 -0
- package/server/src/content-types/oauth-client/schema.json +33 -0
- package/server/src/content-types/oauth-consent/index.ts +3 -0
- package/server/src/content-types/oauth-consent/schema.json +21 -0
- package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
- package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
- package/server/src/content-types/oauth-revocation/index.ts +3 -0
- package/server/src/content-types/oauth-revocation/schema.json +18 -0
- package/server/src/content-types/oauth-signing-key/index.ts +3 -0
- package/server/src/content-types/oauth-signing-key/schema.json +21 -0
- package/server/src/controllers/admin/audit.ts +30 -0
- package/server/src/controllers/admin/clients.ts +148 -0
- package/server/src/controllers/admin/dashboard.ts +28 -0
- package/server/src/controllers/admin/index.ts +15 -0
- package/server/src/controllers/admin/settings.ts +38 -0
- package/server/src/controllers/admin/tools.ts +23 -0
- package/server/src/controllers/index.ts +13 -0
- package/server/src/controllers/mcp.ts +168 -0
- package/server/src/controllers/oauth/authorize.ts +418 -0
- package/server/src/controllers/oauth/index.ts +15 -0
- package/server/src/controllers/oauth/introspect.ts +45 -0
- package/server/src/controllers/oauth/metadata.ts +86 -0
- package/server/src/controllers/oauth/mode-guard.ts +22 -0
- package/server/src/controllers/oauth/register.ts +109 -0
- package/server/src/controllers/oauth/token.ts +206 -0
- package/server/src/controllers/proxy.ts +81 -0
- package/server/src/destroy.ts +28 -0
- package/server/src/index.ts +23 -0
- package/server/src/policies/authenticate.ts +81 -0
- package/server/src/policies/index.ts +13 -0
- package/server/src/policies/origin.ts +50 -0
- package/server/src/policies/rateLimit.ts +27 -0
- package/server/src/policies/scope.ts +32 -0
- package/server/src/register.ts +48 -0
- package/server/src/routes/admin.ts +85 -0
- package/server/src/routes/index.ts +13 -0
- package/server/src/routes/mcp.ts +31 -0
- package/server/src/routes/oauth.ts +81 -0
- package/server/src/routes/proxy.ts +29 -0
- package/server/src/services/audit.ts +158 -0
- package/server/src/services/heartbeat.ts +76 -0
- package/server/src/services/index.ts +37 -0
- package/server/src/services/instance-id.ts +30 -0
- package/server/src/services/mcp-server.ts +100 -0
- package/server/src/services/oauth/audience.ts +26 -0
- package/server/src/services/oauth/auth-codes.ts +78 -0
- package/server/src/services/oauth/clients.ts +386 -0
- package/server/src/services/oauth/consent.ts +38 -0
- package/server/src/services/oauth/errors.ts +32 -0
- package/server/src/services/oauth/pkce.ts +34 -0
- package/server/src/services/oauth/scopes.ts +42 -0
- package/server/src/services/oauth/signing-keys.ts +166 -0
- package/server/src/services/oauth/tokens.ts +324 -0
- package/server/src/services/permissions.ts +87 -0
- package/server/src/services/proxy-client.ts +167 -0
- package/server/src/services/rate-limiter.ts +180 -0
- package/server/src/services/redis.ts +139 -0
- package/server/src/services/session-directory.ts +121 -0
- package/server/src/services/session-store.ts +216 -0
- package/server/src/services/sso-cookie.ts +146 -0
- package/server/src/services/tools/content.ts +284 -0
- package/server/src/services/tools/index.ts +23 -0
- package/server/src/services/tools/media.ts +170 -0
|
@@ -0,0 +1,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
|
+
}
|