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,167 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { createHmac, timingSafeEqual } from 'crypto';
5
+ import { request as httpRequest } from 'http';
6
+ import { request as httpsRequest } from 'https';
7
+ import type { Context } from 'koa';
8
+ import { URL } from 'url';
9
+ import { getConfig } from '../config';
10
+
11
+ const HEADER = 'x-mcp-proxy-auth';
12
+ const SKEW_MS = 30_000;
13
+
14
+ interface SignArgs {
15
+ method: string;
16
+ sessionId: string;
17
+ body: string;
18
+ secret: string;
19
+ ts?: number;
20
+ }
21
+
22
+ function sign({ method, sessionId, body, secret, ts = Date.now() }: SignArgs): {
23
+ header: string;
24
+ ts: number;
25
+ } {
26
+ const bodyHash = body
27
+ ? createHmac('sha256', secret).update(body).digest('hex')
28
+ : '';
29
+ const payload = `${method.toUpperCase()}|${sessionId}|${ts}|${bodyHash}`;
30
+ const mac = createHmac('sha256', secret).update(payload).digest('hex');
31
+ return { header: `t=${ts};s=${mac}`, ts };
32
+ }
33
+
34
+ /**
35
+ * Verify a `X-MCP-Proxy-Auth` header. Returns true on success; the receiver
36
+ * MUST refuse the request on false.
37
+ *
38
+ * Defense-in-depth note: timing-safe compare of MACs, ±30s window on the
39
+ * timestamp to bound replay. The HMAC includes the method, session id, and a
40
+ * hash of the body so a stolen header can't be reused on a different request.
41
+ */
42
+ export function verifySignature(args: {
43
+ header: string | undefined;
44
+ method: string;
45
+ sessionId: string;
46
+ body: string;
47
+ secret: string;
48
+ }): boolean {
49
+ if (!args.header) return false;
50
+ const parts = Object.fromEntries(
51
+ args.header.split(';').map((p) => {
52
+ const i = p.indexOf('=');
53
+ return i < 0 ? [p, ''] : [p.slice(0, i), p.slice(i + 1)];
54
+ })
55
+ );
56
+ const ts = Number(parts.t);
57
+ const provided = parts.s;
58
+ if (!Number.isFinite(ts) || !provided) return false;
59
+ if (Math.abs(Date.now() - ts) > SKEW_MS) return false;
60
+ const { header } = sign({
61
+ method: args.method,
62
+ sessionId: args.sessionId,
63
+ body: args.body,
64
+ secret: args.secret,
65
+ ts,
66
+ });
67
+ const expected = header.split(';')[1].slice(2);
68
+ const a = Buffer.from(expected, 'hex');
69
+ const b = Buffer.from(provided, 'hex');
70
+ if (a.length !== b.length) return false;
71
+ return timingSafeEqual(a, b);
72
+ }
73
+
74
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
75
+ /**
76
+ * Forward the current request to `address` (e.g. `http://10.0.0.5:1337`).
77
+ * Streams the response (including SSE) back to the original client.
78
+ *
79
+ * Throws on transport-level failure (peer unreachable, TLS error). Callers
80
+ * should catch and either respond 502 or invalidate the directory entry.
81
+ */
82
+ async forward(ctx: Context, address: string, sessionId: string): Promise<void> {
83
+ const cfg = getConfig(strapi);
84
+ const secret = cfg.redis?.internalSecret;
85
+ if (!secret) {
86
+ throw new Error('redis.internalSecret is required for cross-instance proxying');
87
+ }
88
+
89
+ const peer = new URL(address);
90
+ peer.pathname = `/__mcp/proxy/${encodeURIComponent(sessionId)}`;
91
+
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ const parsed = (ctx.request as any).body;
94
+ const bodyStr = parsed === undefined || parsed === null ? '' : JSON.stringify(parsed);
95
+ const { header: authHeader } = sign({
96
+ method: ctx.method,
97
+ sessionId,
98
+ body: bodyStr,
99
+ secret,
100
+ });
101
+
102
+ const headers: Record<string, string> = {
103
+ [HEADER]: authHeader,
104
+ // Pass through what the SDK needs.
105
+ accept: (ctx.request.header.accept as string) ?? 'application/json, text/event-stream',
106
+ 'mcp-session-id': sessionId,
107
+ };
108
+ if (bodyStr) {
109
+ headers['content-type'] = (ctx.request.header['content-type'] as string) ?? 'application/json';
110
+ headers['content-length'] = String(Buffer.byteLength(bodyStr));
111
+ }
112
+ // Forward the original Authorization header so audit / debugging can
113
+ // trace it on the owner instance, though the owner trusts the HMAC and
114
+ // does not re-verify the JWT.
115
+ if (ctx.request.header.authorization) {
116
+ headers.authorization = ctx.request.header.authorization as string;
117
+ }
118
+
119
+ const isHttps = peer.protocol === 'https:';
120
+ const reqFn = isHttps ? httpsRequest : httpRequest;
121
+
122
+ return new Promise<void>((resolve, reject) => {
123
+ const upstream = reqFn(
124
+ {
125
+ protocol: peer.protocol,
126
+ hostname: peer.hostname,
127
+ port: peer.port || (isHttps ? 443 : 80),
128
+ method: ctx.method,
129
+ path: peer.pathname,
130
+ headers,
131
+ // SSE: never timeout
132
+ timeout: 0,
133
+ },
134
+ (res) => {
135
+ ctx.respond = false;
136
+ ctx.res.statusCode = res.statusCode ?? 502;
137
+ for (const [k, v] of Object.entries(res.headers)) {
138
+ if (v !== undefined) ctx.res.setHeader(k, v as string | string[]);
139
+ }
140
+ res.on('error', (err) => {
141
+ strapi.log.warn(`[mcp-server] proxy upstream error: ${err.message}`);
142
+ try {
143
+ ctx.res.end();
144
+ } catch {
145
+ /* socket already closed */
146
+ }
147
+ });
148
+ res.pipe(ctx.res);
149
+ res.on('end', () => resolve());
150
+ ctx.req.on('close', () => {
151
+ // Client disconnected — tear down the upstream connection so we
152
+ // don't leak open sockets on the owner instance.
153
+ upstream.destroy();
154
+ });
155
+ }
156
+ );
157
+
158
+ upstream.on('error', (err) => reject(err));
159
+
160
+ if (bodyStr) upstream.write(bodyStr);
161
+ upstream.end();
162
+ });
163
+ },
164
+
165
+ /** Re-export verify helper so the receiver controller can use it. */
166
+ verify: verifySignature,
167
+ });
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig, type RateBucketConfig } from '../config';
5
+ import type { RedisLike } from './redis';
6
+
7
+ interface Bucket {
8
+ tokens: number;
9
+ lastRefill: number;
10
+ }
11
+
12
+ const principalBuckets = new Map<string, Bucket>();
13
+ const ipBuckets = new Map<string, Bucket>();
14
+ const dcrBuckets = new Map<string, Bucket>();
15
+ let lastReap = Date.now();
16
+
17
+ // Token-bucket take, in Lua so refill + decrement are atomic. Returns 0 when
18
+ // the request is allowed, otherwise the integer seconds the caller should
19
+ // wait before retrying (ceil((1 - tokens) / refill)). Bucket state lives in
20
+ // a small hash and is allowed to expire when idle.
21
+ const TAKE_LUA = `
22
+ local key = KEYS[1]
23
+ local capacity = tonumber(ARGV[1])
24
+ local refill = tonumber(ARGV[2])
25
+ local now = tonumber(ARGV[3])
26
+ local idle_ttl = tonumber(ARGV[4])
27
+
28
+ local b = redis.call('HMGET', key, 'tokens', 'lastRefill')
29
+ local tokens = tonumber(b[1])
30
+ local last = tonumber(b[2])
31
+ if tokens == nil then
32
+ tokens = capacity
33
+ last = now
34
+ end
35
+
36
+ local elapsed = (now - last) / 1000.0
37
+ if elapsed < 0 then elapsed = 0 end
38
+ tokens = math.min(capacity, tokens + elapsed * refill)
39
+
40
+ local wait = 0
41
+ if tokens < 1 then
42
+ wait = math.ceil((1 - tokens) / refill)
43
+ if wait < 1 then wait = 1 end
44
+ else
45
+ tokens = tokens - 1
46
+ end
47
+
48
+ redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
49
+ redis.call('PEXPIRE', key, idle_ttl)
50
+ return wait
51
+ `;
52
+
53
+ function takeLocal(map: Map<string, Bucket>, key: string, cfg: RateBucketConfig): number {
54
+ const now = Date.now();
55
+ let b = map.get(key);
56
+ if (!b) {
57
+ b = { tokens: cfg.capacity, lastRefill: now };
58
+ map.set(key, b);
59
+ }
60
+ const elapsed = (now - b.lastRefill) / 1000;
61
+ b.tokens = Math.min(cfg.capacity, b.tokens + elapsed * cfg.refillPerSec);
62
+ b.lastRefill = now;
63
+ if (b.tokens < 1) {
64
+ const wait = (1 - b.tokens) / cfg.refillPerSec;
65
+ return Math.max(1, Math.ceil(wait));
66
+ }
67
+ b.tokens -= 1;
68
+ return 0;
69
+ }
70
+
71
+ async function takeRedis(
72
+ redis: RedisLike,
73
+ key: string,
74
+ cfg: RateBucketConfig
75
+ ): Promise<number> {
76
+ // Idle TTL = 10 minutes — same as the in-memory reaper. Lets idle keys
77
+ // expire on their own rather than holding state forever.
78
+ const idleTtlMs = 10 * 60 * 1000;
79
+ try {
80
+ const result = await redis.eval(
81
+ TAKE_LUA,
82
+ 1,
83
+ key,
84
+ cfg.capacity,
85
+ cfg.refillPerSec,
86
+ Date.now(),
87
+ idleTtlMs
88
+ );
89
+ return typeof result === 'number' ? result : Number(result) || 0;
90
+ } catch {
91
+ // Redis hiccup: fail-open for rate limiting (a brief gap is better than
92
+ // a hard outage). The next request will re-try the script.
93
+ return 0;
94
+ }
95
+ }
96
+
97
+ function reapLocal(): void {
98
+ const now = Date.now();
99
+ if (now - lastReap < 5 * 60 * 1000) return;
100
+ lastReap = now;
101
+ for (const map of [principalBuckets, ipBuckets, dcrBuckets]) {
102
+ for (const [k, b] of map.entries()) {
103
+ if (now - b.lastRefill > 10 * 60 * 1000) map.delete(k);
104
+ }
105
+ }
106
+ }
107
+
108
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
109
+ async function redisClient(): Promise<RedisLike | null> {
110
+ const cfg = getConfig(strapi);
111
+ if (!cfg.redis?.enabled) return null;
112
+ return strapi.plugin('mcp-server').service('redis').get();
113
+ }
114
+
115
+ function redisKey(...parts: string[]): string {
116
+ return strapi.plugin('mcp-server').service('redis').key('rl', ...parts);
117
+ }
118
+
119
+ return {
120
+ /**
121
+ * Check both buckets (principal and IP). Returns 0 when allowed; otherwise
122
+ * the number of seconds the client should wait before retrying (`Retry-After`).
123
+ *
124
+ * When `redis.enabled === true` the buckets are cluster-wide; otherwise
125
+ * each Node process has its own.
126
+ */
127
+ async check(principalId: string | undefined, ip: string | undefined): Promise<number> {
128
+ const cfg = getConfig(strapi);
129
+ const redis = await redisClient();
130
+ if (!redis) {
131
+ reapLocal();
132
+ if (principalId) {
133
+ const wait = takeLocal(principalBuckets, principalId, cfg.rateLimit.perPrincipal);
134
+ if (wait > 0) return wait;
135
+ }
136
+ if (ip) {
137
+ const wait = takeLocal(ipBuckets, ip, cfg.rateLimit.perIp);
138
+ if (wait > 0) return wait;
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ if (principalId) {
144
+ const wait = await takeRedis(redis, redisKey('p', principalId), cfg.rateLimit.perPrincipal);
145
+ if (wait > 0) return wait;
146
+ }
147
+ if (ip) {
148
+ const wait = await takeRedis(redis, redisKey('ip', ip), cfg.rateLimit.perIp);
149
+ if (wait > 0) return wait;
150
+ }
151
+ return 0;
152
+ },
153
+
154
+ /**
155
+ * Per-IP rate limit for `POST /oauth/register`. Separate bucket from the
156
+ * normal per-IP limit because DCR has no principal at request time and
157
+ * we want a different (typically tighter) ceiling on registrations than
158
+ * on tool calls. Driven by `oauth.dcr.ratelimitPerHour`.
159
+ */
160
+ async checkDcr(ip: string | undefined): Promise<number> {
161
+ if (!ip) return 0;
162
+ const cfg = getConfig(strapi);
163
+ const capacity = Math.max(1, cfg.oauth.dcr.ratelimitPerHour);
164
+ const refillPerSec = capacity / 3600;
165
+ const bucketCfg: RateBucketConfig = { capacity, refillPerSec };
166
+ const redis = await redisClient();
167
+ if (!redis) {
168
+ reapLocal();
169
+ return takeLocal(dcrBuckets, ip, bucketCfg);
170
+ }
171
+ return takeRedis(redis, redisKey('dcr', ip), bucketCfg);
172
+ },
173
+
174
+ reset(): void {
175
+ principalBuckets.clear();
176
+ ipBuckets.clear();
177
+ dcrBuckets.clear();
178
+ },
179
+ };
180
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../config';
5
+
6
+ // Loose Redis interface — narrow surface we actually use. Avoids forcing a
7
+ // hard typed dep on ioredis when Redis is disabled.
8
+ export interface RedisLike {
9
+ get(key: string): Promise<string | null>;
10
+ set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
11
+ del(...keys: string[]): Promise<number>;
12
+ eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<unknown>;
13
+ sadd(key: string, ...members: string[]): Promise<number>;
14
+ srem(key: string, ...members: string[]): Promise<number>;
15
+ smembers(key: string): Promise<string[]>;
16
+ scard(key: string): Promise<number>;
17
+ expire(key: string, seconds: number): Promise<number>;
18
+ hset(key: string, field: string, value: string): Promise<number>;
19
+ hgetall(key: string): Promise<Record<string, string>>;
20
+ publish(channel: string, message: string): Promise<number>;
21
+ subscribe(...channels: string[]): Promise<unknown>;
22
+ on(event: string, listener: (...args: unknown[]) => void): unknown;
23
+ quit(): Promise<unknown>;
24
+ status?: string;
25
+ }
26
+
27
+ let client: RedisLike | null = null;
28
+ let initializing: Promise<RedisLike | null> | null = null;
29
+ let subscriber: RedisLike | null = null;
30
+ let initializingSub: Promise<RedisLike | null> | null = null;
31
+
32
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
33
+ /**
34
+ * Return the shared Redis client. Returns null when Redis is disabled in
35
+ * config — callers must handle that case and fall back to local state.
36
+ * Multiple concurrent callers during boot share the same connect promise.
37
+ */
38
+ async get(): Promise<RedisLike | null> {
39
+ const cfg = getConfig(strapi);
40
+ if (!cfg.redis?.enabled) return null;
41
+ if (client) return client;
42
+ if (initializing) return initializing;
43
+ initializing = (async () => {
44
+ try {
45
+ // Dynamic require so single-instance deployments don't need ioredis
46
+ // installed at all. If they enable Redis but skipped install, fail
47
+ // loudly with a useful message.
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const IORedis = require('ioredis');
50
+ const Ctor = IORedis.default ?? IORedis;
51
+ const instance: RedisLike = new Ctor(cfg.redis!.url, {
52
+ lazyConnect: false,
53
+ maxRetriesPerRequest: 3,
54
+ enableReadyCheck: true,
55
+ });
56
+ instance.on('error', (err: unknown) => {
57
+ strapi.log.warn(`[mcp-server] redis error: ${(err as Error).message}`);
58
+ });
59
+ instance.on('connect', () => {
60
+ strapi.log.info('[mcp-server] redis connected');
61
+ });
62
+ client = instance;
63
+ return instance;
64
+ } catch (err) {
65
+ const msg = (err as Error).message;
66
+ strapi.log.error(
67
+ `[mcp-server] redis init failed (${msg}). Install ioredis or set redis.enabled=false.`
68
+ );
69
+ client = null;
70
+ return null;
71
+ } finally {
72
+ initializing = null;
73
+ }
74
+ })();
75
+ return initializing;
76
+ },
77
+
78
+ /**
79
+ * Build a namespaced key. All Redis keys flow through here so that
80
+ * deployments with shared Redis can prefix per-tenant.
81
+ */
82
+ key(...parts: string[]): string {
83
+ const cfg = getConfig(strapi);
84
+ const prefix = cfg.redis?.keyPrefix ?? 'mcp:';
85
+ return prefix + parts.join(':');
86
+ },
87
+
88
+ /**
89
+ * Return a dedicated subscriber connection. ioredis (and any RESP client)
90
+ * cannot issue normal commands once a connection has called SUBSCRIBE — so
91
+ * pub/sub work uses a second client. Lazy-instantiated like the main one.
92
+ */
93
+ async getSubscriber(): Promise<RedisLike | null> {
94
+ const cfg = getConfig(strapi);
95
+ if (!cfg.redis?.enabled) return null;
96
+ if (subscriber) return subscriber;
97
+ if (initializingSub) return initializingSub;
98
+ initializingSub = (async () => {
99
+ try {
100
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
101
+ const IORedis = require('ioredis');
102
+ const Ctor = IORedis.default ?? IORedis;
103
+ const instance: RedisLike = new Ctor(cfg.redis!.url, {
104
+ lazyConnect: false,
105
+ maxRetriesPerRequest: null,
106
+ enableReadyCheck: true,
107
+ });
108
+ instance.on('error', (err: unknown) => {
109
+ strapi.log.warn(`[mcp-server] redis subscriber error: ${(err as Error).message}`);
110
+ });
111
+ subscriber = instance;
112
+ return instance;
113
+ } catch (err) {
114
+ strapi.log.error(
115
+ `[mcp-server] redis subscriber init failed: ${(err as Error).message}`
116
+ );
117
+ subscriber = null;
118
+ return null;
119
+ } finally {
120
+ initializingSub = null;
121
+ }
122
+ })();
123
+ return initializingSub;
124
+ },
125
+
126
+ /** Close the shared client + subscriber. Called from destroy(). */
127
+ async disconnect(): Promise<void> {
128
+ for (const c of [client, subscriber]) {
129
+ if (!c) continue;
130
+ try {
131
+ await c.quit();
132
+ } catch {
133
+ // best-effort
134
+ }
135
+ }
136
+ client = null;
137
+ subscriber = null;
138
+ },
139
+ });
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../config';
5
+ import type { SessionPrincipal } from './session-store';
6
+
7
+ export interface DirectoryEntry {
8
+ instance: string;
9
+ /** Internal-facing URL of the owning instance (e.g. http://10.0.0.5:1337). */
10
+ address: string;
11
+ adminUserId: string;
12
+ clientId: string;
13
+ createdAt: number;
14
+ expiresAt: number;
15
+ }
16
+
17
+ /**
18
+ * Redis-backed mapping from session id to its owning instance. No-op (returns
19
+ * null / 0) when Redis is disabled OR when `redis.internalAddress` is not
20
+ * configured — in that case sessions stay process-local and the cluster must
21
+ * use sticky load balancing.
22
+ */
23
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
24
+ async function client() {
25
+ const cfg = getConfig(strapi);
26
+ if (!cfg.redis?.enabled || !cfg.redis.internalAddress) return null;
27
+ return strapi.plugin('mcp-server').service('redis').get();
28
+ }
29
+
30
+ function key(...parts: string[]): string {
31
+ return strapi.plugin('mcp-server').service('redis').key('sess', ...parts);
32
+ }
33
+
34
+ function principalKey(adminUserId: string, clientId: string): string {
35
+ return `${adminUserId}:${clientId}`;
36
+ }
37
+
38
+ return {
39
+ /**
40
+ * Returns true when this slice's session-routing features are enabled.
41
+ * Callers use this to decide whether to consult Redis or stay local-only.
42
+ */
43
+ async isActive(): Promise<boolean> {
44
+ const r = await client();
45
+ return r !== null;
46
+ },
47
+
48
+ async register(entry: DirectoryEntry & { id: string }): Promise<void> {
49
+ const r = await client();
50
+ if (!r) return;
51
+ const ttlSec = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000));
52
+ const pkey = principalKey(entry.adminUserId, entry.clientId);
53
+ // Multi-step write — Redis client doesn't expose pipelining in our
54
+ // narrow interface so we do small sequential calls. Worth tightening
55
+ // if this shows up in load testing.
56
+ await r.hset(key(entry.id), 'instance', entry.instance);
57
+ await r.hset(key(entry.id), 'address', entry.address);
58
+ await r.hset(key(entry.id), 'adminUserId', entry.adminUserId);
59
+ await r.hset(key(entry.id), 'clientId', entry.clientId);
60
+ await r.hset(key(entry.id), 'createdAt', String(entry.createdAt));
61
+ await r.hset(key(entry.id), 'expiresAt', String(entry.expiresAt));
62
+ await r.expire(key(entry.id), ttlSec);
63
+ await r.sadd(key('idx', pkey), entry.id);
64
+ await r.expire(key('idx', pkey), ttlSec);
65
+ },
66
+
67
+ async lookup(id: string): Promise<DirectoryEntry | null> {
68
+ const r = await client();
69
+ if (!r) return null;
70
+ const h = await r.hgetall(key(id));
71
+ if (!h || !h.instance) return null;
72
+ // Heartbeat check: if the owning instance is no longer publishing
73
+ // heartbeats, the entry is orphaned. Drop it and report not-found so
74
+ // the client gets a clean 404 → re-init instead of a 502 from a
75
+ // failed proxy attempt.
76
+ const alive = await strapi
77
+ .plugin('mcp-server')
78
+ .service('heartbeat')
79
+ .isAlive(h.instance);
80
+ if (!alive) {
81
+ await this.unregister(id, {
82
+ adminUserId: h.adminUserId,
83
+ clientId: h.clientId,
84
+ jti: '',
85
+ });
86
+ return null;
87
+ }
88
+ return {
89
+ instance: h.instance,
90
+ address: h.address,
91
+ adminUserId: h.adminUserId,
92
+ clientId: h.clientId,
93
+ createdAt: Number(h.createdAt) || 0,
94
+ expiresAt: Number(h.expiresAt) || 0,
95
+ };
96
+ },
97
+
98
+ async unregister(id: string, principal?: SessionPrincipal): Promise<void> {
99
+ const r = await client();
100
+ if (!r) return;
101
+ await r.del(key(id));
102
+ if (principal) {
103
+ await r.srem(key('idx', principalKey(principal.adminUserId, principal.clientId)), id);
104
+ }
105
+ },
106
+
107
+ /** Count current sessions for a principal across the cluster. */
108
+ async countForPrincipal(adminUserId: string, clientId: string): Promise<number> {
109
+ const r = await client();
110
+ if (!r) return 0;
111
+ return r.scard(key('idx', principalKey(adminUserId, clientId)));
112
+ },
113
+
114
+ /** Session ids belonging to a principal across the cluster. */
115
+ async sessionsForPrincipal(adminUserId: string, clientId: string): Promise<string[]> {
116
+ const r = await client();
117
+ if (!r) return [];
118
+ return r.smembers(key('idx', principalKey(adminUserId, clientId)));
119
+ },
120
+ };
121
+ };