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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "collectionType",
|
|
3
|
+
"collectionName": "mcp_oauth_consents",
|
|
4
|
+
"info": {
|
|
5
|
+
"singularName": "oauth-consent",
|
|
6
|
+
"pluralName": "oauth-consents",
|
|
7
|
+
"displayName": "MCP OAuth Consent"
|
|
8
|
+
},
|
|
9
|
+
"options": { "draftAndPublish": false },
|
|
10
|
+
"pluginOptions": {
|
|
11
|
+
"content-manager": { "visible": false },
|
|
12
|
+
"content-type-builder": { "visible": false }
|
|
13
|
+
},
|
|
14
|
+
"attributes": {
|
|
15
|
+
"clientId": { "type": "string", "required": true, "maxLength": 128 },
|
|
16
|
+
"adminUserId": { "type": "string", "required": true, "maxLength": 64 },
|
|
17
|
+
"scope": { "type": "string", "required": true, "maxLength": 512 },
|
|
18
|
+
"grantedAt": { "type": "datetime", "required": true },
|
|
19
|
+
"expiresAt": { "type": "datetime", "required": true }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "collectionType",
|
|
3
|
+
"collectionName": "mcp_oauth_refresh_tokens",
|
|
4
|
+
"info": {
|
|
5
|
+
"singularName": "oauth-refresh-token",
|
|
6
|
+
"pluralName": "oauth-refresh-tokens",
|
|
7
|
+
"displayName": "MCP OAuth Refresh Token"
|
|
8
|
+
},
|
|
9
|
+
"options": { "draftAndPublish": false },
|
|
10
|
+
"pluginOptions": {
|
|
11
|
+
"content-manager": { "visible": false },
|
|
12
|
+
"content-type-builder": { "visible": false }
|
|
13
|
+
},
|
|
14
|
+
"attributes": {
|
|
15
|
+
"tokenHash": { "type": "string", "required": true, "maxLength": 128, "private": true },
|
|
16
|
+
"familyId": { "type": "string", "required": true, "maxLength": 64 },
|
|
17
|
+
"parentJti": { "type": "string", "maxLength": 64 },
|
|
18
|
+
"clientId": { "type": "string", "required": true, "maxLength": 128 },
|
|
19
|
+
"adminUserId": { "type": "string", "required": true, "maxLength": 64 },
|
|
20
|
+
"scope": { "type": "string", "required": true, "maxLength": 512 },
|
|
21
|
+
"rotatedTo": { "type": "string", "maxLength": 128 },
|
|
22
|
+
"revoked": { "type": "boolean", "default": false, "required": true },
|
|
23
|
+
"expiresAt": { "type": "datetime", "required": true }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "collectionType",
|
|
3
|
+
"collectionName": "mcp_oauth_revocations",
|
|
4
|
+
"info": {
|
|
5
|
+
"singularName": "oauth-revocation",
|
|
6
|
+
"pluralName": "oauth-revocations",
|
|
7
|
+
"displayName": "MCP OAuth Revoked JTI"
|
|
8
|
+
},
|
|
9
|
+
"options": { "draftAndPublish": false },
|
|
10
|
+
"pluginOptions": {
|
|
11
|
+
"content-manager": { "visible": false },
|
|
12
|
+
"content-type-builder": { "visible": false }
|
|
13
|
+
},
|
|
14
|
+
"attributes": {
|
|
15
|
+
"jti": { "type": "string", "required": true, "unique": true, "maxLength": 64 },
|
|
16
|
+
"expiresAt": { "type": "datetime", "required": true }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "collectionType",
|
|
3
|
+
"collectionName": "mcp_oauth_signing_keys",
|
|
4
|
+
"info": {
|
|
5
|
+
"singularName": "oauth-signing-key",
|
|
6
|
+
"pluralName": "oauth-signing-keys",
|
|
7
|
+
"displayName": "MCP OAuth Signing Key"
|
|
8
|
+
},
|
|
9
|
+
"options": { "draftAndPublish": false },
|
|
10
|
+
"pluginOptions": {
|
|
11
|
+
"content-manager": { "visible": false },
|
|
12
|
+
"content-type-builder": { "visible": false }
|
|
13
|
+
},
|
|
14
|
+
"attributes": {
|
|
15
|
+
"kid": { "type": "string", "required": true, "unique": true, "maxLength": 64 },
|
|
16
|
+
"alg": { "type": "string", "required": true, "default": "RS256", "maxLength": 16 },
|
|
17
|
+
"publicJwk": { "type": "json", "required": true },
|
|
18
|
+
"privateJwkEncrypted": { "type": "text", "required": true, "private": true },
|
|
19
|
+
"retiredAt": { "type": "datetime" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
|
|
6
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
7
|
+
async list(ctx: Context): Promise<void> {
|
|
8
|
+
const query = ctx.query as {
|
|
9
|
+
limit?: string;
|
|
10
|
+
tool?: string;
|
|
11
|
+
principalId?: string;
|
|
12
|
+
status?: string;
|
|
13
|
+
};
|
|
14
|
+
const limit = Math.min(500, Math.max(1, Number(query.limit ?? '50')));
|
|
15
|
+
const filters: Record<string, unknown> = {};
|
|
16
|
+
if (query.tool) filters.tool = query.tool;
|
|
17
|
+
if (query.principalId) filters.principalId = query.principalId;
|
|
18
|
+
if (query.status) filters.resultStatus = query.status;
|
|
19
|
+
const auditSvc = strapi.plugin('mcp-server').service('audit');
|
|
20
|
+
const entries = await auditSvc.recent(limit, filters);
|
|
21
|
+
const enrichments = await auditSvc.enrich(entries);
|
|
22
|
+
ctx.body = {
|
|
23
|
+
entries: entries.map((e: Record<string, unknown>, i: number) => ({
|
|
24
|
+
...e,
|
|
25
|
+
principalAdmin: enrichments[i].principalAdmin,
|
|
26
|
+
client: enrichments[i].client,
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { parseScope } from '../../services/oauth/scopes';
|
|
6
|
+
|
|
7
|
+
interface AdminUserRow {
|
|
8
|
+
id: number;
|
|
9
|
+
email?: string;
|
|
10
|
+
firstname?: string;
|
|
11
|
+
lastname?: string;
|
|
12
|
+
username?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Batch-resolve admin user info for both ownerAdminId (consent grantor) and
|
|
17
|
+
* createdByAdminId (table appearance). One DB query feeds both fields. A
|
|
18
|
+
* deleted admin user just shows null on its column.
|
|
19
|
+
*/
|
|
20
|
+
async function enrichWithUsers(
|
|
21
|
+
strapi: Core.Strapi,
|
|
22
|
+
clients: Array<{ ownerAdminId: string | null; createdByAdminId: string | null }>
|
|
23
|
+
): Promise<Array<{ ownerAdmin: AdminUserRow | null; createdByAdmin: AdminUserRow | null }>> {
|
|
24
|
+
const ids = Array.from(
|
|
25
|
+
new Set(
|
|
26
|
+
clients
|
|
27
|
+
.flatMap((c) => [c.ownerAdminId, c.createdByAdminId])
|
|
28
|
+
.filter((v): v is string => typeof v === 'string' && v.length > 0)
|
|
29
|
+
.map((s) => Number(s))
|
|
30
|
+
.filter((n) => Number.isFinite(n))
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
if (ids.length === 0) {
|
|
34
|
+
return clients.map(() => ({ ownerAdmin: null, createdByAdmin: null }));
|
|
35
|
+
}
|
|
36
|
+
const users = (await strapi.db
|
|
37
|
+
.query('admin::user')
|
|
38
|
+
.findMany({
|
|
39
|
+
where: { id: { $in: ids } },
|
|
40
|
+
select: ['id', 'email', 'firstname', 'lastname', 'username'],
|
|
41
|
+
})) as AdminUserRow[];
|
|
42
|
+
const byId = new Map(users.map((u) => [String(u.id), u]));
|
|
43
|
+
return clients.map((c) => ({
|
|
44
|
+
ownerAdmin: c.ownerAdminId ? byId.get(c.ownerAdminId) ?? null : null,
|
|
45
|
+
createdByAdmin: c.createdByAdminId ? byId.get(c.createdByAdminId) ?? null : null,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
50
|
+
async list(ctx: Context): Promise<void> {
|
|
51
|
+
const clients = await strapi.plugin('mcp-server').service('clients').list();
|
|
52
|
+
const enriched = await enrichWithUsers(strapi, clients);
|
|
53
|
+
ctx.body = {
|
|
54
|
+
clients: clients.map((c: Record<string, unknown>, i: number) => ({
|
|
55
|
+
...c,
|
|
56
|
+
ownerAdmin: enriched[i].ownerAdmin,
|
|
57
|
+
createdByAdmin: enriched[i].createdByAdmin,
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async create(ctx: Context): Promise<void> {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
64
|
+
const body = ((ctx.request as any).body ?? {}) as {
|
|
65
|
+
clientName?: string;
|
|
66
|
+
redirectUris?: string[];
|
|
67
|
+
scopes?: string | string[];
|
|
68
|
+
isConfidential?: boolean;
|
|
69
|
+
};
|
|
70
|
+
if (!body.clientName || !Array.isArray(body.redirectUris)) {
|
|
71
|
+
ctx.status = 400;
|
|
72
|
+
ctx.body = { error: 'missing fields' };
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const adminUser = ctx.state.user as { id: number | string } | undefined;
|
|
76
|
+
const authCredentials =
|
|
77
|
+
(ctx.state.auth as { credentials?: { id?: number | string } } | undefined)?.credentials;
|
|
78
|
+
// Prefer ctx.state.user; fall back to auth.credentials in case the admin
|
|
79
|
+
// strategy populated only the latter.
|
|
80
|
+
const createdByAdminId =
|
|
81
|
+
adminUser?.id !== undefined
|
|
82
|
+
? String(adminUser.id)
|
|
83
|
+
: authCredentials?.id !== undefined
|
|
84
|
+
? String(authCredentials.id)
|
|
85
|
+
: undefined;
|
|
86
|
+
const scopes = parseScope(
|
|
87
|
+
Array.isArray(body.scopes) ? body.scopes.join(' ') : body.scopes ?? ''
|
|
88
|
+
);
|
|
89
|
+
try {
|
|
90
|
+
// ownerAdminId is intentionally not set here — it represents the consent
|
|
91
|
+
// grantor and stays null until the first /oauth/authorize approval.
|
|
92
|
+
const result = await strapi.plugin('mcp-server').service('clients').create({
|
|
93
|
+
clientName: body.clientName,
|
|
94
|
+
redirectUris: body.redirectUris,
|
|
95
|
+
scopes,
|
|
96
|
+
isConfidential: !!body.isConfidential,
|
|
97
|
+
createdByAdminId,
|
|
98
|
+
});
|
|
99
|
+
ctx.body = result; // client_secret returned once
|
|
100
|
+
} catch (err) {
|
|
101
|
+
ctx.status = 400;
|
|
102
|
+
ctx.body = { error: (err as Error).message };
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async update(ctx: Context): Promise<void> {
|
|
107
|
+
const { clientId } = ctx.params;
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
+
const body = ((ctx.request as any).body ?? {}) as Record<string, unknown>;
|
|
110
|
+
const patch: Record<string, unknown> = {};
|
|
111
|
+
if (typeof body.clientName === 'string') patch.clientName = body.clientName;
|
|
112
|
+
if (Array.isArray(body.redirectUris)) patch.redirectUris = body.redirectUris;
|
|
113
|
+
if (typeof body.disabled === 'boolean') patch.disabled = body.disabled;
|
|
114
|
+
if (body.scopes !== undefined) {
|
|
115
|
+
patch.scopes = parseScope(
|
|
116
|
+
Array.isArray(body.scopes) ? body.scopes.join(' ') : String(body.scopes)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const updated = await strapi.plugin('mcp-server').service('clients').update(clientId, patch);
|
|
120
|
+
if (!updated) ctx.throw(404, 'not found');
|
|
121
|
+
ctx.body = updated;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async findOne(ctx: Context): Promise<void> {
|
|
125
|
+
const { clientId } = ctx.params;
|
|
126
|
+
const client = await strapi.plugin('mcp-server').service('clients').findActive(clientId);
|
|
127
|
+
if (!client) {
|
|
128
|
+
// findActive only returns enabled clients; fall back to a raw lookup so
|
|
129
|
+
// the admin can see and re-enable disabled clients.
|
|
130
|
+
const row = await strapi.db
|
|
131
|
+
.query('plugin::mcp-server.oauth-client')
|
|
132
|
+
.findOne({ where: { clientId } });
|
|
133
|
+
if (!row) {
|
|
134
|
+
ctx.throw(404, 'not found');
|
|
135
|
+
}
|
|
136
|
+
ctx.body = row;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
ctx.body = client;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async destroy(ctx: Context): Promise<void> {
|
|
143
|
+
const { clientId } = ctx.params;
|
|
144
|
+
const deleted = await strapi.plugin('mcp-server').service('clients').delete(clientId);
|
|
145
|
+
if (!deleted) ctx.throw(404, 'not found');
|
|
146
|
+
ctx.status = 204;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { getConfig } from '../../config';
|
|
6
|
+
|
|
7
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
8
|
+
async overview(ctx: Context): Promise<void> {
|
|
9
|
+
const cfg = getConfig(strapi);
|
|
10
|
+
const sessionStore = strapi.plugin('mcp-server').service('session-store');
|
|
11
|
+
const audit = strapi.plugin('mcp-server').service('audit');
|
|
12
|
+
const stats = sessionStore.stats();
|
|
13
|
+
const recent = await audit.recent(10);
|
|
14
|
+
const enrichments = await audit.enrich(recent);
|
|
15
|
+
ctx.body = {
|
|
16
|
+
enabled: cfg.enabled,
|
|
17
|
+
resourceUrl: cfg.resourceUrl,
|
|
18
|
+
allowedOrigins: cfg.allowedOrigins,
|
|
19
|
+
sessions: stats,
|
|
20
|
+
recentCalls: recent.map((e: Record<string, unknown>, i: number) => ({
|
|
21
|
+
...e,
|
|
22
|
+
principalAdmin: enrichments[i].principalAdmin,
|
|
23
|
+
client: enrichments[i].client,
|
|
24
|
+
})),
|
|
25
|
+
oauth: { mode: cfg.oauth.mode, dcrEnabled: cfg.oauth.dcr.enabled },
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import dashboard from './dashboard';
|
|
4
|
+
import clients from './clients';
|
|
5
|
+
import audit from './audit';
|
|
6
|
+
import settings from './settings';
|
|
7
|
+
import tools from './tools';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
dashboard,
|
|
11
|
+
clients,
|
|
12
|
+
audit,
|
|
13
|
+
settings,
|
|
14
|
+
tools,
|
|
15
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { getConfig } from '../../config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only view of the merged config. Mutations to security-critical settings
|
|
9
|
+
* happen in config/plugins.ts (env-driven) — exposing a runtime mutation surface
|
|
10
|
+
* would let admins weaken security from the UI without an audit trail.
|
|
11
|
+
*
|
|
12
|
+
* Secrets (redis.internalSecret, password in redis.url) are masked. The UI
|
|
13
|
+
* surfaces whether they're set, not what they're set to.
|
|
14
|
+
*/
|
|
15
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
16
|
+
async get(ctx: Context): Promise<void> {
|
|
17
|
+
const cfg = getConfig(strapi);
|
|
18
|
+
const redis = cfg.redis
|
|
19
|
+
? {
|
|
20
|
+
...cfg.redis,
|
|
21
|
+
url: maskRedisUrl(cfg.redis.url),
|
|
22
|
+
internalSecret: cfg.redis.internalSecret ? '••••••' : '',
|
|
23
|
+
}
|
|
24
|
+
: undefined;
|
|
25
|
+
ctx.body = { ...cfg, redis };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function maskRedisUrl(url: string | undefined): string {
|
|
30
|
+
if (!url) return '';
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
if (u.password) u.password = '••••••';
|
|
34
|
+
return u.toString();
|
|
35
|
+
} catch {
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { ALL_SCOPES } from '../../services/oauth/scopes';
|
|
6
|
+
|
|
7
|
+
const TOOLS = [
|
|
8
|
+
{ name: 'strapi.content.list_types', scope: 'strapi:content:read' as const },
|
|
9
|
+
{ name: 'strapi.content.get_schema', scope: 'strapi:content:read' as const },
|
|
10
|
+
{ name: 'strapi.content.list_entries', scope: 'strapi:content:read' as const },
|
|
11
|
+
{ name: 'strapi.content.get_entry', scope: 'strapi:content:read' as const },
|
|
12
|
+
{ name: 'strapi.content.create_entry', scope: 'strapi:content:write' as const },
|
|
13
|
+
{ name: 'strapi.content.update_entry', scope: 'strapi:content:write' as const },
|
|
14
|
+
{ name: 'strapi.media.list', scope: 'strapi:media:read' as const },
|
|
15
|
+
{ name: 'strapi.media.upload', scope: 'strapi:media:write' as const },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export default ({ strapi: _strapi }: { strapi: Core.Strapi }) => ({
|
|
19
|
+
list(ctx: Context): void {
|
|
20
|
+
void _strapi;
|
|
21
|
+
ctx.body = { tools: TOOLS, scopes: ALL_SCOPES };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
import type { Context } from 'koa';
|
|
5
|
+
import { bearerChallenge } from '../services/oauth/errors';
|
|
6
|
+
|
|
7
|
+
const SESSION_HEADER = 'mcp-session-id';
|
|
8
|
+
|
|
9
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
10
|
+
/**
|
|
11
|
+
* Single handler for POST + GET on /mcp. POST initialize creates a new
|
|
12
|
+
* session; subsequent POSTs and GETs lookup by Mcp-Session-Id.
|
|
13
|
+
*
|
|
14
|
+
* When Redis-backed session routing is enabled and the session lives on
|
|
15
|
+
* a different instance, this request is proxied to the owning instance
|
|
16
|
+
* over HTTP. The original client never sees the difference.
|
|
17
|
+
*/
|
|
18
|
+
async handle(ctx: Context): Promise<void> {
|
|
19
|
+
const mcpAuth = ctx.state.mcpAuth as {
|
|
20
|
+
principal: { user: { id: string | number }; permissions: unknown[]; isSuperAdmin: boolean };
|
|
21
|
+
scopes: string[];
|
|
22
|
+
clientId: string;
|
|
23
|
+
jti: string;
|
|
24
|
+
adminUserId: string;
|
|
25
|
+
};
|
|
26
|
+
if (!mcpAuth) {
|
|
27
|
+
ctx.throw(401, 'missing auth context');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessionStore = strapi.plugin('mcp-server').service('session-store');
|
|
31
|
+
const sessionIdHeader = (ctx.request.header[SESSION_HEADER] as string | undefined)?.trim();
|
|
32
|
+
|
|
33
|
+
if (sessionIdHeader) {
|
|
34
|
+
const location = await sessionStore.locate(sessionIdHeader);
|
|
35
|
+
if (!location) {
|
|
36
|
+
ctx.status = 404;
|
|
37
|
+
ctx.body = { error: 'session_not_found' };
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (location.principal && location.principal.adminUserId !== mcpAuth.adminUserId) {
|
|
41
|
+
// Token swap mid-session — refuse rather than allow privilege transfer.
|
|
42
|
+
ctx.set(
|
|
43
|
+
'WWW-Authenticate',
|
|
44
|
+
bearerChallenge(strapi, {
|
|
45
|
+
error: 'invalid_token',
|
|
46
|
+
error_description: 'session principal mismatch',
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
ctx.throw(401, 'session principal mismatch');
|
|
50
|
+
}
|
|
51
|
+
if (location.kind === 'remote') {
|
|
52
|
+
try {
|
|
53
|
+
await strapi
|
|
54
|
+
.plugin('mcp-server')
|
|
55
|
+
.service('proxy-client')
|
|
56
|
+
.forward(ctx, location.address, sessionIdHeader);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
strapi.log.warn(
|
|
59
|
+
`[mcp-server] proxy to ${location.address} failed: ${(err as Error).message}`
|
|
60
|
+
);
|
|
61
|
+
if (!ctx.res.headersSent) {
|
|
62
|
+
ctx.res.statusCode = 502;
|
|
63
|
+
ctx.res.end(JSON.stringify({ error: 'session_owner_unreachable' }));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (location.session.principal.adminUserId !== mcpAuth.adminUserId) {
|
|
69
|
+
ctx.set(
|
|
70
|
+
'WWW-Authenticate',
|
|
71
|
+
bearerChallenge(strapi, {
|
|
72
|
+
error: 'invalid_token',
|
|
73
|
+
error_description: 'session principal mismatch',
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
ctx.throw(401, 'session principal mismatch');
|
|
77
|
+
}
|
|
78
|
+
await dispatchLocal(strapi, ctx, location.session.transport);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ctx.method !== 'POST') {
|
|
83
|
+
ctx.status = 400;
|
|
84
|
+
ctx.body = { error: 'missing_session_id' };
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const principal = {
|
|
89
|
+
adminUserId: mcpAuth.adminUserId,
|
|
90
|
+
clientId: mcpAuth.clientId,
|
|
91
|
+
jti: mcpAuth.jti,
|
|
92
|
+
};
|
|
93
|
+
if (!(await sessionStore.canCreate(principal))) {
|
|
94
|
+
ctx.status = 503;
|
|
95
|
+
ctx.body = { error: 'session_capacity_reached' };
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const factory = strapi.plugin('mcp-server').service('mcp-server');
|
|
100
|
+
const created = await factory.create({
|
|
101
|
+
principal: mcpAuth.principal,
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
+
scopes: mcpAuth.scopes as any,
|
|
104
|
+
clientId: mcpAuth.clientId,
|
|
105
|
+
jti: mcpAuth.jti,
|
|
106
|
+
});
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const session = {
|
|
109
|
+
id: created.sessionId,
|
|
110
|
+
transport: created.transport,
|
|
111
|
+
mcpServer: created.mcpServer,
|
|
112
|
+
principal,
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
scopes: mcpAuth.scopes as any,
|
|
115
|
+
createdAt: now,
|
|
116
|
+
lastSeenAt: now,
|
|
117
|
+
};
|
|
118
|
+
await sessionStore.put(session);
|
|
119
|
+
await dispatchLocal(strapi, ctx, session.transport);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async end(ctx: Context): Promise<void> {
|
|
123
|
+
const id = (ctx.request.header[SESSION_HEADER] as string | undefined)?.trim();
|
|
124
|
+
if (!id) {
|
|
125
|
+
ctx.status = 400;
|
|
126
|
+
ctx.body = { error: 'missing_session_id' };
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const sessionStore = strapi.plugin('mcp-server').service('session-store');
|
|
130
|
+
// Owner instance handles close; for remote sessions, proxy the DELETE.
|
|
131
|
+
const location = await sessionStore.locate(id);
|
|
132
|
+
if (location?.kind === 'remote') {
|
|
133
|
+
try {
|
|
134
|
+
await strapi
|
|
135
|
+
.plugin('mcp-server')
|
|
136
|
+
.service('proxy-client')
|
|
137
|
+
.forward(ctx, location.address, id);
|
|
138
|
+
} catch {
|
|
139
|
+
// best-effort — owner may already be gone; consider closed
|
|
140
|
+
ctx.status = 204;
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await sessionStore.close(id);
|
|
145
|
+
ctx.status = 204;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
async function dispatchLocal(
|
|
150
|
+
strapi: Core.Strapi,
|
|
151
|
+
ctx: Context,
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
transport: any
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
ctx.respond = false;
|
|
156
|
+
ctx.req.socket.setTimeout(0);
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
const body = (ctx.request as any).body;
|
|
159
|
+
try {
|
|
160
|
+
await transport.handleRequest(ctx.req, ctx.res, body);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
strapi.log.error('[mcp-server] transport.handleRequest failed', err as Error);
|
|
163
|
+
if (!ctx.res.headersSent) {
|
|
164
|
+
ctx.res.statusCode = 500;
|
|
165
|
+
ctx.res.end(JSON.stringify({ error: 'internal_error' }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|