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,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,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -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,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -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,3 @@
1
+ 'use strict';
2
+ import schema from './schema.json';
3
+ export default { schema };
@@ -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,13 @@
1
+ 'use strict';
2
+
3
+ import mcp from './mcp';
4
+ import oauth from './oauth';
5
+ import admin from './admin';
6
+ import proxy from './proxy';
7
+
8
+ export default {
9
+ mcp,
10
+ proxy,
11
+ ...oauth,
12
+ ...admin,
13
+ };
@@ -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
+ }