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,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
|
+
};
|