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,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { getConfig } from '../../config';
|
|
6
|
+
import { ALL_SCOPES, parseScope, type Scope } from '../../services/oauth/scopes';
|
|
7
|
+
import { ensureEmbeddedMode } from './mode-guard';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RFC 7591 Dynamic Client Registration.
|
|
11
|
+
*
|
|
12
|
+
* When `oauth.dcr.enabled` is true, any caller can register a client. Safe
|
|
13
|
+
* because (a) redirect_uris are restricted to loopback HTTP or HTTPS by
|
|
14
|
+
* `services/oauth/clients.ts.validateRedirectUris`, and (b) the admin still
|
|
15
|
+
* has to approve the resulting client on the consent screen before any token
|
|
16
|
+
* is issued. When disabled, this endpoint 403s and clients must be created
|
|
17
|
+
* manually by an admin via the Clients page.
|
|
18
|
+
*/
|
|
19
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
20
|
+
async register(ctx: Context): Promise<void> {
|
|
21
|
+
if (!ensureEmbeddedMode(ctx, strapi)) return;
|
|
22
|
+
const cfg = getConfig(strapi);
|
|
23
|
+
if (!cfg.oauth.dcr.enabled) {
|
|
24
|
+
ctx.status = 403;
|
|
25
|
+
ctx.body = { error: 'dcr_disabled' };
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ip = ctx.ip ?? ctx.request.ip;
|
|
30
|
+
const userAgent = ctx.request.header['user-agent'] as string | undefined;
|
|
31
|
+
const wait = await strapi.plugin('mcp-server').service('rate-limiter').checkDcr(ip);
|
|
32
|
+
if (wait > 0) {
|
|
33
|
+
ctx.response.set('Retry-After', String(wait));
|
|
34
|
+
ctx.status = 429;
|
|
35
|
+
ctx.body = { error: 'too_many_requests', error_description: 'DCR rate limit exceeded' };
|
|
36
|
+
strapi.plugin('mcp-server').service('audit').record({
|
|
37
|
+
ts: new Date(),
|
|
38
|
+
principalType: 'system',
|
|
39
|
+
principalId: 'anonymous',
|
|
40
|
+
tool: 'oauth.dcr.register',
|
|
41
|
+
params: { rateLimited: true, retryAfterSec: wait },
|
|
42
|
+
resultStatus: 'error',
|
|
43
|
+
errorCode: 'too_many_requests',
|
|
44
|
+
ip,
|
|
45
|
+
userAgent,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
const body = ((ctx.request as any).body ?? {}) as {
|
|
52
|
+
client_name?: string;
|
|
53
|
+
redirect_uris?: string[];
|
|
54
|
+
scope?: string;
|
|
55
|
+
token_endpoint_auth_method?: string;
|
|
56
|
+
grant_types?: string[];
|
|
57
|
+
};
|
|
58
|
+
if (!body.client_name || !body.redirect_uris) {
|
|
59
|
+
ctx.status = 400;
|
|
60
|
+
ctx.body = { error: 'invalid_client_metadata' };
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const requestedScopes = parseScope(body.scope ?? '');
|
|
64
|
+
const grantedScopes: Scope[] =
|
|
65
|
+
requestedScopes.length > 0 ? requestedScopes : [...ALL_SCOPES];
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const { client, clientSecret } = await strapi
|
|
69
|
+
.plugin('mcp-server')
|
|
70
|
+
.service('clients')
|
|
71
|
+
.create({
|
|
72
|
+
clientName: body.client_name,
|
|
73
|
+
redirectUris: body.redirect_uris,
|
|
74
|
+
scopes: grantedScopes,
|
|
75
|
+
isConfidential: body.token_endpoint_auth_method
|
|
76
|
+
? body.token_endpoint_auth_method !== 'none'
|
|
77
|
+
: false,
|
|
78
|
+
});
|
|
79
|
+
ctx.status = 201;
|
|
80
|
+
ctx.body = {
|
|
81
|
+
client_id: client.clientId,
|
|
82
|
+
client_name: client.clientName,
|
|
83
|
+
redirect_uris: client.redirectUris,
|
|
84
|
+
grant_types: client.grantTypes,
|
|
85
|
+
scope: client.scopes.join(' '),
|
|
86
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
87
|
+
...(clientSecret ? { client_secret: clientSecret } : {}),
|
|
88
|
+
};
|
|
89
|
+
strapi.plugin('mcp-server').service('audit').record({
|
|
90
|
+
ts: new Date(),
|
|
91
|
+
principalType: 'system',
|
|
92
|
+
principalId: 'anonymous',
|
|
93
|
+
clientId: client.clientId,
|
|
94
|
+
tool: 'oauth.dcr.register',
|
|
95
|
+
params: {
|
|
96
|
+
client_name: client.clientName,
|
|
97
|
+
redirect_uris: client.redirectUris,
|
|
98
|
+
scopes: client.scopes,
|
|
99
|
+
},
|
|
100
|
+
resultStatus: 'ok',
|
|
101
|
+
ip,
|
|
102
|
+
userAgent,
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
ctx.status = 400;
|
|
106
|
+
ctx.body = { error: 'invalid_client_metadata', error_description: (err as Error).message };
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { verifyS256 } from '../../services/oauth/pkce';
|
|
6
|
+
import { parseScope, scopeString, isSubsetOf, type Scope } from '../../services/oauth/scopes';
|
|
7
|
+
import { canonicalResourceUrl } from '../../services/oauth/audience';
|
|
8
|
+
import { ensureEmbeddedMode } from './mode-guard';
|
|
9
|
+
|
|
10
|
+
interface TokenRequestBody {
|
|
11
|
+
grant_type?: string;
|
|
12
|
+
code?: string;
|
|
13
|
+
redirect_uri?: string;
|
|
14
|
+
client_id?: string;
|
|
15
|
+
client_secret?: string;
|
|
16
|
+
code_verifier?: string;
|
|
17
|
+
refresh_token?: string;
|
|
18
|
+
resource?: string;
|
|
19
|
+
scope?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function error(
|
|
23
|
+
ctx: Context,
|
|
24
|
+
status: number,
|
|
25
|
+
code: string,
|
|
26
|
+
description?: string
|
|
27
|
+
): void {
|
|
28
|
+
ctx.status = status;
|
|
29
|
+
ctx.set('Cache-Control', 'no-store');
|
|
30
|
+
ctx.body = { error: code, ...(description ? { error_description: description } : {}) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readClientCreds(ctx: Context, body: TokenRequestBody): {
|
|
34
|
+
clientId?: string;
|
|
35
|
+
clientSecret?: string;
|
|
36
|
+
} {
|
|
37
|
+
const header = ctx.request.header.authorization;
|
|
38
|
+
if (header?.startsWith('Basic ')) {
|
|
39
|
+
try {
|
|
40
|
+
const decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8');
|
|
41
|
+
const idx = decoded.indexOf(':');
|
|
42
|
+
if (idx > 0) {
|
|
43
|
+
return {
|
|
44
|
+
clientId: decoded.slice(0, idx),
|
|
45
|
+
clientSecret: decoded.slice(idx + 1),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* fall through */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { clientId: body.client_id, clientSecret: body.client_secret };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
56
|
+
async token(ctx: Context): Promise<void> {
|
|
57
|
+
if (!ensureEmbeddedMode(ctx, strapi)) return;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
const body = ((ctx.request as any).body ?? {}) as TokenRequestBody;
|
|
60
|
+
const grant = body.grant_type;
|
|
61
|
+
|
|
62
|
+
if (grant === 'authorization_code') return handleAuthCode(strapi, ctx, body);
|
|
63
|
+
if (grant === 'refresh_token') return handleRefresh(strapi, ctx, body);
|
|
64
|
+
|
|
65
|
+
return error(ctx, 400, 'unsupported_grant_type', `grant_type=${grant ?? ''} not supported`);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async revoke(ctx: Context): Promise<void> {
|
|
69
|
+
if (!ensureEmbeddedMode(ctx, strapi)) return;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
const body = ((ctx.request as any).body ?? {}) as { token?: string };
|
|
72
|
+
if (!body.token) {
|
|
73
|
+
ctx.status = 400;
|
|
74
|
+
ctx.body = { error: 'invalid_request' };
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await strapi.plugin('mcp-server').service('tokens').revokeRefresh(body.token);
|
|
79
|
+
} catch {
|
|
80
|
+
/* swallow per RFC 7009 */
|
|
81
|
+
}
|
|
82
|
+
ctx.status = 200;
|
|
83
|
+
ctx.body = { ok: true };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function handleAuthCode(
|
|
88
|
+
strapi: Core.Strapi,
|
|
89
|
+
ctx: Context,
|
|
90
|
+
body: TokenRequestBody
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const creds = readClientCreds(ctx, body);
|
|
93
|
+
if (!creds.clientId) return error(ctx, 400, 'invalid_request', 'client_id required');
|
|
94
|
+
if (!body.code) return error(ctx, 400, 'invalid_request', 'code required');
|
|
95
|
+
if (!body.redirect_uri) return error(ctx, 400, 'invalid_request', 'redirect_uri required');
|
|
96
|
+
if (!body.code_verifier) return error(ctx, 400, 'invalid_request', 'code_verifier required');
|
|
97
|
+
if (body.resource !== canonicalResourceUrl(strapi)) {
|
|
98
|
+
return error(ctx, 400, 'invalid_target', 'resource mismatch');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const clientsSvc = strapi.plugin('mcp-server').service('clients');
|
|
102
|
+
const client = await clientsSvc.findActive(creds.clientId);
|
|
103
|
+
if (!client) return error(ctx, 401, 'invalid_client');
|
|
104
|
+
if (!clientsSvc.verifySecret(client, creds.clientSecret)) {
|
|
105
|
+
return error(ctx, 401, 'invalid_client');
|
|
106
|
+
}
|
|
107
|
+
if (!clientsSvc.isAllowedRedirectUri(client, body.redirect_uri)) {
|
|
108
|
+
return error(ctx, 400, 'invalid_grant', 'redirect_uri mismatch');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tokensSvc = strapi.plugin('mcp-server').service('tokens');
|
|
112
|
+
const codesSvc = strapi.plugin('mcp-server').service('auth-codes');
|
|
113
|
+
const consumed = await codesSvc.consume(body.code);
|
|
114
|
+
if (consumed === 'replayed') {
|
|
115
|
+
// Don't reveal which family — but log the incident.
|
|
116
|
+
strapi.log.warn(`[mcp-server] authorization code replay on client=${client.clientId}`);
|
|
117
|
+
return error(ctx, 400, 'invalid_grant', 'code already used');
|
|
118
|
+
}
|
|
119
|
+
if (!consumed) return error(ctx, 400, 'invalid_grant');
|
|
120
|
+
if (consumed.clientId !== client.clientId) return error(ctx, 400, 'invalid_grant');
|
|
121
|
+
if (consumed.redirectUri !== body.redirect_uri) {
|
|
122
|
+
return error(ctx, 400, 'invalid_grant', 'redirect_uri mismatch');
|
|
123
|
+
}
|
|
124
|
+
if (consumed.resource !== body.resource) return error(ctx, 400, 'invalid_target');
|
|
125
|
+
if (!verifyS256(body.code_verifier, consumed.codeChallenge)) {
|
|
126
|
+
return error(ctx, 400, 'invalid_grant', 'PKCE verification failed');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const scopes = parseScope(consumed.scope);
|
|
130
|
+
const minted = await tokensSvc.mint({
|
|
131
|
+
adminUserId: consumed.adminUserId,
|
|
132
|
+
clientId: client.clientId,
|
|
133
|
+
scope: scopes,
|
|
134
|
+
});
|
|
135
|
+
await clientsSvc.touchLastUsed(client.clientId);
|
|
136
|
+
|
|
137
|
+
ctx.set('Cache-Control', 'no-store');
|
|
138
|
+
ctx.body = {
|
|
139
|
+
access_token: minted.accessToken,
|
|
140
|
+
token_type: 'Bearer',
|
|
141
|
+
expires_in: Math.floor(
|
|
142
|
+
(minted.accessTokenExpiresAt.getTime() - Date.now()) / 1000
|
|
143
|
+
),
|
|
144
|
+
refresh_token: minted.refreshToken,
|
|
145
|
+
scope: scopeString(scopes),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleRefresh(
|
|
150
|
+
strapi: Core.Strapi,
|
|
151
|
+
ctx: Context,
|
|
152
|
+
body: TokenRequestBody
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const creds = readClientCreds(ctx, body);
|
|
155
|
+
if (!creds.clientId) return error(ctx, 400, 'invalid_request', 'client_id required');
|
|
156
|
+
if (!body.refresh_token) return error(ctx, 400, 'invalid_request', 'refresh_token required');
|
|
157
|
+
if (body.resource && body.resource !== canonicalResourceUrl(strapi)) {
|
|
158
|
+
return error(ctx, 400, 'invalid_target', 'resource mismatch');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const clientsSvc = strapi.plugin('mcp-server').service('clients');
|
|
162
|
+
const client = await clientsSvc.findActive(creds.clientId);
|
|
163
|
+
if (!client) return error(ctx, 401, 'invalid_client');
|
|
164
|
+
if (!clientsSvc.verifySecret(client, creds.clientSecret)) {
|
|
165
|
+
return error(ctx, 401, 'invalid_client');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const tokensSvc = strapi.plugin('mcp-server').service('tokens');
|
|
169
|
+
const consumed = await tokensSvc.consumeRefresh(body.refresh_token);
|
|
170
|
+
if (!consumed) return error(ctx, 400, 'invalid_grant');
|
|
171
|
+
if (consumed.clientId !== client.clientId) {
|
|
172
|
+
await tokensSvc.revokeFamily(consumed.familyId);
|
|
173
|
+
return error(ctx, 400, 'invalid_grant');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let scopes = parseScope(consumed.scope);
|
|
177
|
+
if (body.scope) {
|
|
178
|
+
const requested = parseScope(body.scope);
|
|
179
|
+
if (!isSubsetOf(requested, scopes as Scope[])) {
|
|
180
|
+
return error(ctx, 400, 'invalid_scope', 'cannot expand scopes on refresh');
|
|
181
|
+
}
|
|
182
|
+
scopes = requested;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const minted = await tokensSvc.mint({
|
|
186
|
+
adminUserId: consumed.adminUserId,
|
|
187
|
+
clientId: client.clientId,
|
|
188
|
+
scope: scopes,
|
|
189
|
+
familyId: consumed.familyId,
|
|
190
|
+
parentJti: consumed.parentJti ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await tokensSvc.markRotated(consumed.id, tokensSvc.hash(minted.refreshToken));
|
|
194
|
+
await clientsSvc.touchLastUsed(client.clientId);
|
|
195
|
+
|
|
196
|
+
ctx.set('Cache-Control', 'no-store');
|
|
197
|
+
ctx.body = {
|
|
198
|
+
access_token: minted.accessToken,
|
|
199
|
+
token_type: 'Bearer',
|
|
200
|
+
expires_in: Math.floor(
|
|
201
|
+
(minted.accessTokenExpiresAt.getTime() - Date.now()) / 1000
|
|
202
|
+
),
|
|
203
|
+
refresh_token: minted.refreshToken,
|
|
204
|
+
scope: scopeString(scopes),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { getConfig } from '../config';
|
|
6
|
+
import { verifySignature } from '../services/proxy-client';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Receives a request forwarded from a peer Strapi instance for a session
|
|
10
|
+
* this instance owns. The forwarder has already verified the original
|
|
11
|
+
* bearer token; trust is established via the HMAC on `X-MCP-Proxy-Auth`.
|
|
12
|
+
*
|
|
13
|
+
* Note: this endpoint is publicly mountable. Security comes from the HMAC
|
|
14
|
+
* (signed with `redis.internalSecret`, which is shared only among cluster
|
|
15
|
+
* peers) and the small attack surface — the only legal action here is to
|
|
16
|
+
* dispatch into a local session's existing transport.
|
|
17
|
+
*/
|
|
18
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
19
|
+
async receive(ctx: Context): Promise<void> {
|
|
20
|
+
const cfg = getConfig(strapi);
|
|
21
|
+
const secret = cfg.redis?.internalSecret;
|
|
22
|
+
if (!secret) {
|
|
23
|
+
ctx.status = 503;
|
|
24
|
+
ctx.body = { error: 'proxy_disabled' };
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sessionId = decodeURIComponent(ctx.params.sessionId as string);
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
const body = (ctx.request as any).body;
|
|
31
|
+
const bodyStr =
|
|
32
|
+
body === undefined || body === null
|
|
33
|
+
? ''
|
|
34
|
+
: typeof body === 'string'
|
|
35
|
+
? body
|
|
36
|
+
: JSON.stringify(body);
|
|
37
|
+
|
|
38
|
+
const ok = verifySignature({
|
|
39
|
+
header: ctx.request.header['x-mcp-proxy-auth'] as string | undefined,
|
|
40
|
+
method: ctx.method,
|
|
41
|
+
sessionId,
|
|
42
|
+
body: bodyStr,
|
|
43
|
+
secret,
|
|
44
|
+
});
|
|
45
|
+
if (!ok) {
|
|
46
|
+
strapi.log.warn(`[mcp-server] proxy receive: bad HMAC for session=${sessionId}`);
|
|
47
|
+
ctx.status = 401;
|
|
48
|
+
ctx.body = { error: 'invalid_proxy_auth' };
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sessionStore = strapi.plugin('mcp-server').service('session-store');
|
|
53
|
+
|
|
54
|
+
if (ctx.method === 'DELETE') {
|
|
55
|
+
await sessionStore.close(sessionId);
|
|
56
|
+
ctx.status = 204;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const session = sessionStore.getLocal(sessionId);
|
|
61
|
+
if (!session) {
|
|
62
|
+
// The directory pointed here but we don't have the session — maybe the
|
|
63
|
+
// process just restarted or the session was swept. Tell the forwarder.
|
|
64
|
+
ctx.status = 404;
|
|
65
|
+
ctx.body = { error: 'session_not_found' };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ctx.respond = false;
|
|
70
|
+
ctx.req.socket.setTimeout(0);
|
|
71
|
+
try {
|
|
72
|
+
await session.transport.handleRequest(ctx.req, ctx.res, body);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
strapi.log.error('[mcp-server] proxy receive dispatch failed', err as Error);
|
|
75
|
+
if (!ctx.res.headersSent) {
|
|
76
|
+
ctx.res.statusCode = 500;
|
|
77
|
+
ctx.res.end(JSON.stringify({ error: 'internal_error' }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
|
|
5
|
+
export async function destroy({ strapi }: { strapi: Core.Strapi }): Promise<void> {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
const rt = (strapi as any).__mcpServerRuntime;
|
|
8
|
+
if (rt?.sweepTimer) clearInterval(rt.sweepTimer);
|
|
9
|
+
if (rt?.auditTimer) clearInterval(rt.auditTimer);
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
strapi.plugin('mcp-server').service('heartbeat').stop();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
strapi.log.warn(`[mcp-server] heartbeat stop: ${(err as Error).message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await strapi.plugin('mcp-server').service('session-store').closeAll();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
strapi.log.error('[mcp-server] failed to close sessions on destroy', err as Error);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await strapi.plugin('mcp-server').service('redis').disconnect();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
strapi.log.warn(`[mcp-server] redis disconnect: ${(err as Error).message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { register } from './register';
|
|
4
|
+
import { bootstrap } from './bootstrap';
|
|
5
|
+
import { destroy } from './destroy';
|
|
6
|
+
import config from './config';
|
|
7
|
+
import contentTypes from './content-types';
|
|
8
|
+
import routes from './routes';
|
|
9
|
+
import controllers from './controllers';
|
|
10
|
+
import services from './services';
|
|
11
|
+
import policies from './policies';
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
register,
|
|
15
|
+
bootstrap,
|
|
16
|
+
destroy,
|
|
17
|
+
config,
|
|
18
|
+
contentTypes,
|
|
19
|
+
routes,
|
|
20
|
+
controllers,
|
|
21
|
+
services,
|
|
22
|
+
policies,
|
|
23
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import { errors } from '@strapi/utils';
|
|
5
|
+
import { bearerChallenge } from '../services/oauth/errors';
|
|
6
|
+
|
|
7
|
+
export interface PolicyCtx {
|
|
8
|
+
request: { header: Record<string, string | undefined> };
|
|
9
|
+
response: { set: (h: string, v: string) => void };
|
|
10
|
+
state: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate the Authorization header, attach { user, permissions, scopes, clientId, jti }
|
|
15
|
+
* to ctx.state.mcpAuth. Failure → 401 with WWW-Authenticate per RFC 6750.
|
|
16
|
+
*
|
|
17
|
+
* Never log the Authorization header — only its presence/absence.
|
|
18
|
+
*/
|
|
19
|
+
export default async (
|
|
20
|
+
ctx: PolicyCtx,
|
|
21
|
+
_cfg: unknown,
|
|
22
|
+
{ strapi }: { strapi: Core.Strapi }
|
|
23
|
+
): Promise<boolean> => {
|
|
24
|
+
const header = ctx.request.header.authorization;
|
|
25
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
26
|
+
ctx.response.set(
|
|
27
|
+
'WWW-Authenticate',
|
|
28
|
+
bearerChallenge(strapi, { error: 'invalid_token', error_description: 'missing bearer token' })
|
|
29
|
+
);
|
|
30
|
+
throw new errors.UnauthorizedError('missing bearer token');
|
|
31
|
+
}
|
|
32
|
+
const token = header.slice(7).trim();
|
|
33
|
+
if (!token) {
|
|
34
|
+
ctx.response.set('WWW-Authenticate', bearerChallenge(strapi, { error: 'invalid_token' }));
|
|
35
|
+
throw new errors.UnauthorizedError('empty bearer token');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tokens = strapi.plugin('mcp-server').service('tokens');
|
|
39
|
+
let claims;
|
|
40
|
+
try {
|
|
41
|
+
claims = await tokens.verifyAccessToken(token);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const message = (err as Error).message;
|
|
44
|
+
ctx.response.set(
|
|
45
|
+
'WWW-Authenticate',
|
|
46
|
+
bearerChallenge(strapi, {
|
|
47
|
+
error: 'invalid_token',
|
|
48
|
+
error_description: message === 'expired' ? 'token expired' : 'invalid token',
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
throw new errors.UnauthorizedError(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const principal = await strapi
|
|
55
|
+
.plugin('mcp-server')
|
|
56
|
+
.service('permissions')
|
|
57
|
+
.loadPrincipal(claims.sub);
|
|
58
|
+
if (!principal) {
|
|
59
|
+
try {
|
|
60
|
+
await tokens.revokeAllForUser(claims.sub);
|
|
61
|
+
await strapi
|
|
62
|
+
.plugin('mcp-server')
|
|
63
|
+
.service('session-store')
|
|
64
|
+
.closeForPrincipal(claims.sub);
|
|
65
|
+
} catch {
|
|
66
|
+
/* non-fatal */
|
|
67
|
+
}
|
|
68
|
+
ctx.response.set('WWW-Authenticate', bearerChallenge(strapi, { error: 'invalid_token' }));
|
|
69
|
+
throw new errors.UnauthorizedError('principal unavailable');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ctx.state.mcpAuth = {
|
|
73
|
+
principal,
|
|
74
|
+
scopes: claims.scope,
|
|
75
|
+
clientId: claims.clientId,
|
|
76
|
+
jti: claims.jti,
|
|
77
|
+
adminUserId: claims.sub,
|
|
78
|
+
exp: claims.exp,
|
|
79
|
+
};
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import { errors } from '@strapi/utils';
|
|
5
|
+
import { getConfig } from '../config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Origin + Host validation. Required for DNS rebinding mitigation.
|
|
9
|
+
* Empty allowlist → reject all (default-deny).
|
|
10
|
+
*
|
|
11
|
+
* Strapi's PolicyContext is built via Object.assign({}, ctx) which only copies
|
|
12
|
+
* own properties — `ctx.set`/`ctx.throw` (on the Koa prototype) get stripped.
|
|
13
|
+
* Use `ctx.response.*` for header/status work and throw Strapi error classes
|
|
14
|
+
* to surface proper HTTP statuses through the framework's error middleware.
|
|
15
|
+
*/
|
|
16
|
+
export default (
|
|
17
|
+
ctx: {
|
|
18
|
+
request: { header: Record<string, string | undefined>; url?: string; method?: string };
|
|
19
|
+
response: { set: (h: string, v: string) => void };
|
|
20
|
+
},
|
|
21
|
+
_cfg: unknown,
|
|
22
|
+
{ strapi }: { strapi: Core.Strapi }
|
|
23
|
+
): boolean => {
|
|
24
|
+
const cfg = getConfig(strapi);
|
|
25
|
+
const origin = ctx.request.header.origin;
|
|
26
|
+
const host = ctx.request.header.host;
|
|
27
|
+
|
|
28
|
+
if (cfg.allowedOrigins.length === 0) {
|
|
29
|
+
throw new errors.ForbiddenError('origin not allowed');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (cfg.allowedOrigins.includes('*')) return true;
|
|
33
|
+
|
|
34
|
+
if (origin) {
|
|
35
|
+
if (!cfg.allowedOrigins.includes(origin)) {
|
|
36
|
+
throw new errors.ForbiddenError('origin not allowed');
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
try {
|
|
40
|
+
const resourceHost = new URL(cfg.resourceUrl).host;
|
|
41
|
+
if (!host || host !== resourceHost) {
|
|
42
|
+
throw new errors.ForbiddenError('host not allowed');
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof errors.ForbiddenError) throw err;
|
|
46
|
+
throw new errors.ForbiddenError('host validation failed');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import { errors } from '@strapi/utils';
|
|
5
|
+
|
|
6
|
+
export interface PolicyCtx {
|
|
7
|
+
state: Record<string, unknown>;
|
|
8
|
+
ip?: string;
|
|
9
|
+
request: { ip?: string };
|
|
10
|
+
response: { set: (h: string, v: string) => void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async (
|
|
14
|
+
ctx: PolicyCtx,
|
|
15
|
+
_cfg: unknown,
|
|
16
|
+
{ strapi }: { strapi: Core.Strapi }
|
|
17
|
+
): Promise<boolean> => {
|
|
18
|
+
const mcpAuth = ctx.state.mcpAuth as { adminUserId?: string } | undefined;
|
|
19
|
+
const principalId = mcpAuth?.adminUserId;
|
|
20
|
+
const ip = ctx.ip ?? ctx.request.ip;
|
|
21
|
+
const wait = await strapi.plugin('mcp-server').service('rate-limiter').check(principalId, ip);
|
|
22
|
+
if (wait > 0) {
|
|
23
|
+
ctx.response.set('Retry-After', String(wait));
|
|
24
|
+
throw new errors.RateLimitError('rate limit exceeded');
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import { errors } from '@strapi/utils';
|
|
5
|
+
import { bearerChallenge } from '../services/oauth/errors';
|
|
6
|
+
import type { Scope } from '../services/oauth/scopes';
|
|
7
|
+
|
|
8
|
+
export interface PolicyCtx {
|
|
9
|
+
state: { mcpAuth?: { scopes?: Scope[] } };
|
|
10
|
+
response: { set: (h: string, v: string) => void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Higher-order policy for admin/audit routes. Returns a policy function whose
|
|
15
|
+
* `config` is the required scope name. Currently unused for /mcp itself
|
|
16
|
+
* (per-tool checks happen inside the tool callback).
|
|
17
|
+
*/
|
|
18
|
+
export default (
|
|
19
|
+
ctx: PolicyCtx,
|
|
20
|
+
config: { scope: Scope },
|
|
21
|
+
{ strapi }: { strapi: Core.Strapi }
|
|
22
|
+
): boolean => {
|
|
23
|
+
const granted = ctx.state.mcpAuth?.scopes ?? [];
|
|
24
|
+
if (!granted.includes(config.scope)) {
|
|
25
|
+
ctx.response.set(
|
|
26
|
+
'WWW-Authenticate',
|
|
27
|
+
bearerChallenge(strapi, { error: 'insufficient_scope', scope: config.scope })
|
|
28
|
+
);
|
|
29
|
+
throw new errors.ForbiddenError('insufficient_scope');
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
};
|