@winstonsayno/mcp-gateway 0.4.0 → 1.0.0
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/CHANGELOG.md +35 -0
- package/README.md +225 -19
- package/dashboard/index.html +79 -22
- package/dist/auth/middleware.d.ts +21 -2
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +63 -13
- package/dist/auth/middleware.js.map +1 -1
- package/dist/auth/ratelimit.d.ts +16 -1
- package/dist/auth/ratelimit.d.ts.map +1 -1
- package/dist/auth/ratelimit.js +15 -8
- package/dist/auth/ratelimit.js.map +1 -1
- package/dist/auth/scopes.d.ts +40 -0
- package/dist/auth/scopes.d.ts.map +1 -0
- package/dist/auth/scopes.js +76 -0
- package/dist/auth/scopes.js.map +1 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +91 -10
- package/dist/config/loader.js.map +1 -1
- package/dist/gateway/api.d.ts +12 -0
- package/dist/gateway/api.d.ts.map +1 -1
- package/dist/gateway/api.js +264 -11
- package/dist/gateway/api.js.map +1 -1
- package/dist/gateway/index.d.ts +4 -0
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js +54 -2
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/supervisor.d.ts +1 -0
- package/dist/gateway/supervisor.d.ts.map +1 -1
- package/dist/gateway/supervisor.js +12 -1
- package/dist/gateway/supervisor.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/catalog.d.ts +30 -0
- package/dist/mcp/catalog.d.ts.map +1 -0
- package/dist/mcp/catalog.js +78 -0
- package/dist/mcp/catalog.js.map +1 -0
- package/dist/mcp/endpoint.d.ts +132 -0
- package/dist/mcp/endpoint.d.ts.map +1 -0
- package/dist/mcp/endpoint.js +714 -0
- package/dist/mcp/endpoint.js.map +1 -0
- package/dist/mcp/llm-schemas.d.ts +36 -0
- package/dist/mcp/llm-schemas.d.ts.map +1 -0
- package/dist/mcp/llm-schemas.js +69 -0
- package/dist/mcp/llm-schemas.js.map +1 -0
- package/dist/mcp/naming.d.ts +53 -0
- package/dist/mcp/naming.d.ts.map +1 -0
- package/dist/mcp/naming.js +71 -0
- package/dist/mcp/naming.js.map +1 -0
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.d.ts.map +1 -1
- package/dist/middleware/cors.js +25 -19
- package/dist/middleware/cors.js.map +1 -1
- package/dist/monitor/audit.d.ts +60 -0
- package/dist/monitor/audit.d.ts.map +1 -0
- package/dist/monitor/audit.js +155 -0
- package/dist/monitor/audit.js.map +1 -0
- package/dist/monitor/index.d.ts +19 -0
- package/dist/monitor/index.d.ts.map +1 -1
- package/dist/monitor/index.js +88 -0
- package/dist/monitor/index.js.map +1 -1
- package/dist/proxy/index.d.ts +25 -2
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +171 -16
- package/dist/proxy/index.js.map +1 -1
- package/dist/registry/index.d.ts +8 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +22 -0
- package/dist/registry/index.js.map +1 -1
- package/dist/utils/types.d.ts +108 -2
- package/dist/utils/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downstream MCP endpoint — MCP Streamable HTTP server (spec 2025-06-18,
|
|
3
|
+
* 2025-03-26 accepted) that aggregates every upstream server behind one URL.
|
|
4
|
+
*
|
|
5
|
+
* - `POST <path>`: JSON-RPC requests / notifications / responses (batches
|
|
6
|
+
* accepted for 2025-03-26 clients). Answers with `application/json`.
|
|
7
|
+
* - `GET <path>`: server→client SSE stream for notifications
|
|
8
|
+
* (`notifications/tools/list_changed`).
|
|
9
|
+
* - `DELETE <path>`: ends the session.
|
|
10
|
+
*
|
|
11
|
+
* Sessions (`Mcp-Session-Id`) are bound to the authenticated client: a
|
|
12
|
+
* session id presented with another key is treated as unknown. Auth, rate
|
|
13
|
+
* limiting (per `tools/call`), `maxConcurrency`, timeouts, metrics and the
|
|
14
|
+
* request log are shared with the REST API.
|
|
15
|
+
*
|
|
16
|
+
* @module mcp/endpoint
|
|
17
|
+
*/
|
|
18
|
+
import express from 'express';
|
|
19
|
+
import { randomUUID, createHash } from 'crypto';
|
|
20
|
+
import { ERR_CANCELLED, ERR_NOT_CONNECTED, ERR_TIMEOUT } from '../proxy/index.js';
|
|
21
|
+
import { setRateLimitHeaders } from '../auth/ratelimit.js';
|
|
22
|
+
import { originAllowed } from '../middleware/cors.js';
|
|
23
|
+
import { filterToolsByScope, isToolInScope } from '../auth/scopes.js';
|
|
24
|
+
import { logger } from '../utils/logger.js';
|
|
25
|
+
import { VERSION } from '../utils/version.js';
|
|
26
|
+
import { buildToolIndex, toMcpTool } from './naming.js';
|
|
27
|
+
import { buildPromptIndex, dedupeResources, routeResource, toMcpPrompt, toMcpResource, toMcpResourceTemplate, } from './catalog.js';
|
|
28
|
+
import { isServerInScope } from '../auth/scopes.js';
|
|
29
|
+
/** Protocol versions the endpoint speaks, newest first. */
|
|
30
|
+
export const DOWNSTREAM_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'];
|
|
31
|
+
export const LATEST_PROTOCOL_VERSION = DOWNSTREAM_PROTOCOL_VERSIONS[0];
|
|
32
|
+
// JSON-RPC error codes
|
|
33
|
+
export const JSONRPC_PARSE_ERROR = -32700;
|
|
34
|
+
export const JSONRPC_INVALID_REQUEST = -32600;
|
|
35
|
+
export const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
36
|
+
export const JSONRPC_INVALID_PARAMS = -32602;
|
|
37
|
+
export const JSONRPC_INTERNAL_ERROR = -32603;
|
|
38
|
+
/** Gateway rate limit hit on `tools/call` (`data.retryAfter` in seconds). */
|
|
39
|
+
export const ERR_RATE_LIMITED = -32029;
|
|
40
|
+
/** The caller's scopes do not allow this server / tool. */
|
|
41
|
+
export const ERR_FORBIDDEN = -32003;
|
|
42
|
+
export const DEFAULT_MCP_CONFIG = {
|
|
43
|
+
enabled: true,
|
|
44
|
+
path: '/mcp',
|
|
45
|
+
toolNaming: 'auto',
|
|
46
|
+
pageSize: 500,
|
|
47
|
+
sessionIdleTimeoutSeconds: 1800,
|
|
48
|
+
maxSessions: 1000,
|
|
49
|
+
};
|
|
50
|
+
const identityOf = (req) => ({
|
|
51
|
+
clientId: req.clientId,
|
|
52
|
+
scope: req.scope,
|
|
53
|
+
});
|
|
54
|
+
const SSE_KEEPALIVE_MS = 25_000;
|
|
55
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
56
|
+
const idKey = (id) => `${typeof id}:${String(id)}`;
|
|
57
|
+
function rpcError(id, error) {
|
|
58
|
+
return { jsonrpc: '2.0', id: id ?? null, error };
|
|
59
|
+
}
|
|
60
|
+
function encodeCursor(offset) {
|
|
61
|
+
return Buffer.from(JSON.stringify({ o: offset }), 'utf8').toString('base64url');
|
|
62
|
+
}
|
|
63
|
+
function decodeCursor(cursor) {
|
|
64
|
+
if (typeof cursor !== 'string')
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const v = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
68
|
+
return typeof v.o === 'number' && Number.isSafeInteger(v.o) && v.o >= 0 ? v.o : undefined;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function acceptsEventStream(req) {
|
|
75
|
+
const a = String(req.headers.accept ?? '');
|
|
76
|
+
return a.includes('text/event-stream') || a.includes('*/*');
|
|
77
|
+
}
|
|
78
|
+
export class McpEndpoint {
|
|
79
|
+
deps;
|
|
80
|
+
cfg;
|
|
81
|
+
sessions = new Map();
|
|
82
|
+
sweepTimer;
|
|
83
|
+
keepaliveTimer;
|
|
84
|
+
notifyTimer;
|
|
85
|
+
closed = false;
|
|
86
|
+
onRegistryChange = () => this.scheduleListChanged();
|
|
87
|
+
constructor(config, deps) {
|
|
88
|
+
this.deps = deps;
|
|
89
|
+
this.cfg = { ...DEFAULT_MCP_CONFIG, ...stripUndefined(config ?? {}) };
|
|
90
|
+
deps.registry.on('tools-updated', this.onRegistryChange);
|
|
91
|
+
deps.registry.on('unregistered', this.onRegistryChange);
|
|
92
|
+
deps.registry.on('catalog-updated', this.onRegistryChange);
|
|
93
|
+
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
|
|
94
|
+
this.sweepTimer.unref();
|
|
95
|
+
this.keepaliveTimer = setInterval(() => this.keepalive(), SSE_KEEPALIVE_MS);
|
|
96
|
+
this.keepaliveTimer.unref();
|
|
97
|
+
}
|
|
98
|
+
get path() {
|
|
99
|
+
return this.cfg.path;
|
|
100
|
+
}
|
|
101
|
+
/** Apply hot-reloadable settings (everything except `enabled` and `path`). */
|
|
102
|
+
update(config) {
|
|
103
|
+
const next = { ...DEFAULT_MCP_CONFIG, ...stripUndefined(config ?? {}) };
|
|
104
|
+
if (next.enabled !== this.cfg.enabled || next.path !== this.cfg.path) {
|
|
105
|
+
logger.warn('Config "mcp.enabled" / "mcp.path" changed — restart required for it to take effect');
|
|
106
|
+
}
|
|
107
|
+
const namingChanged = next.toolNaming !== this.cfg.toolNaming;
|
|
108
|
+
this.cfg = { ...next, enabled: this.cfg.enabled, path: this.cfg.path };
|
|
109
|
+
if (namingChanged)
|
|
110
|
+
this.scheduleListChanged();
|
|
111
|
+
}
|
|
112
|
+
/** Express router serving POST / GET / DELETE on the configured path. */
|
|
113
|
+
router() {
|
|
114
|
+
const r = express.Router();
|
|
115
|
+
const json = express.json({ limit: '10mb', type: ['application/json', 'application/*+json'] });
|
|
116
|
+
const path = this.cfg.path;
|
|
117
|
+
const guard = (req, res, next) => this.checkOrigin(req, res, next);
|
|
118
|
+
r.post(path, guard, this.deps.authenticate, json, (req, res, next) => {
|
|
119
|
+
this.handlePost(req, res).catch(next);
|
|
120
|
+
});
|
|
121
|
+
r.get(path, guard, this.deps.authenticate, (req, res) => this.handleGet(req, res));
|
|
122
|
+
r.delete(path, guard, this.deps.authenticate, (req, res) => this.handleDelete(req, res));
|
|
123
|
+
r.all(path, (_req, res) => {
|
|
124
|
+
res.set('Allow', 'GET, POST, DELETE').status(405).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Method not allowed' }));
|
|
125
|
+
});
|
|
126
|
+
// Malformed JSON bodies become JSON-RPC parse errors instead of REST error envelopes.
|
|
127
|
+
r.use(path, (err, _req, res, next) => {
|
|
128
|
+
if (res.headersSent)
|
|
129
|
+
return next(err);
|
|
130
|
+
const status = err?.status;
|
|
131
|
+
if (status === 413) {
|
|
132
|
+
res.status(413).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Payload too large' }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (status && status >= 400 && status < 500) {
|
|
136
|
+
res.status(400).json(rpcError(null, { code: JSONRPC_PARSE_ERROR, message: 'Parse error' }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
logger.error(`MCP endpoint error: ${err instanceof Error ? err.message : String(err)}`);
|
|
140
|
+
res.status(500).json(rpcError(null, { code: JSONRPC_INTERNAL_ERROR, message: 'Internal error' }));
|
|
141
|
+
});
|
|
142
|
+
return r;
|
|
143
|
+
}
|
|
144
|
+
/** Close every stream and session (gateway shutdown). */
|
|
145
|
+
close() {
|
|
146
|
+
this.closed = true;
|
|
147
|
+
clearInterval(this.sweepTimer);
|
|
148
|
+
clearInterval(this.keepaliveTimer);
|
|
149
|
+
clearTimeout(this.notifyTimer);
|
|
150
|
+
this.deps.registry.off('tools-updated', this.onRegistryChange);
|
|
151
|
+
this.deps.registry.off('unregistered', this.onRegistryChange);
|
|
152
|
+
this.deps.registry.off('catalog-updated', this.onRegistryChange);
|
|
153
|
+
for (const s of [...this.sessions.values()])
|
|
154
|
+
this.endSession(s);
|
|
155
|
+
}
|
|
156
|
+
getSessions() {
|
|
157
|
+
return [...this.sessions.values()].map((s) => ({
|
|
158
|
+
id: s.id,
|
|
159
|
+
clientId: s.clientId,
|
|
160
|
+
protocolVersion: s.protocolVersion,
|
|
161
|
+
clientInfo: s.clientInfo,
|
|
162
|
+
createdAt: s.createdAt,
|
|
163
|
+
lastSeen: new Date(s.lastSeen),
|
|
164
|
+
streams: s.streams.length,
|
|
165
|
+
inflight: s.inflight.size,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
// ─── HTTP handlers ──────────────────────────────────────────────────────────
|
|
169
|
+
checkOrigin(req, res, next) {
|
|
170
|
+
// DNS-rebinding protection: browsers always send Origin on cross-origin
|
|
171
|
+
// requests; non-browser clients usually send none.
|
|
172
|
+
const origin = req.headers.origin;
|
|
173
|
+
if (origin) {
|
|
174
|
+
const allowed = this.cfg.allowedOrigins ?? this.deps.corsOrigins() ?? ['*'];
|
|
175
|
+
if (!originAllowed(allowed, origin)) {
|
|
176
|
+
res.status(403).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Origin not allowed' }));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
next();
|
|
181
|
+
}
|
|
182
|
+
async handlePost(req, res) {
|
|
183
|
+
const body = req.body;
|
|
184
|
+
const batch = Array.isArray(body);
|
|
185
|
+
const messages = batch ? body : [body];
|
|
186
|
+
if (body === undefined || (batch && messages.length === 0)) {
|
|
187
|
+
res.status(400).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Invalid Request: expected a JSON-RPC message' }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const m of messages) {
|
|
191
|
+
if (!isObject(m) || m.jsonrpc !== '2.0' || (m.method !== undefined && typeof m.method !== 'string')) {
|
|
192
|
+
res.status(400).json(rpcError(isObject(m) ? m.id : null, { code: JSONRPC_INVALID_REQUEST, message: 'Invalid Request' }));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const msgs = messages;
|
|
197
|
+
// ── initialize: creates the session ──
|
|
198
|
+
const init = msgs.find((m) => m.method === 'initialize');
|
|
199
|
+
if (init) {
|
|
200
|
+
if (msgs.length > 1) {
|
|
201
|
+
res.status(400).json(rpcError(init.id, { code: JSONRPC_INVALID_REQUEST, message: 'initialize must not be batched' }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (init.id === undefined || init.id === null) {
|
|
205
|
+
res.status(400).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'initialize must be a request' }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.initialize(req, res, init);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const session = this.resolveSession(req, res);
|
|
212
|
+
if (!session)
|
|
213
|
+
return;
|
|
214
|
+
const requests = [];
|
|
215
|
+
for (const m of msgs) {
|
|
216
|
+
if (m.method === undefined)
|
|
217
|
+
continue; // a response to a server request: we send none
|
|
218
|
+
if (m.id === undefined || m.id === null)
|
|
219
|
+
this.handleNotification(session, m);
|
|
220
|
+
else
|
|
221
|
+
requests.push(m);
|
|
222
|
+
}
|
|
223
|
+
if (requests.length === 0) {
|
|
224
|
+
res.status(202).end();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Abort in-flight upstream calls if the client goes away.
|
|
228
|
+
const controllers = [];
|
|
229
|
+
res.on('close', () => {
|
|
230
|
+
if (!res.writableFinished)
|
|
231
|
+
for (const c of controllers)
|
|
232
|
+
c.abort();
|
|
233
|
+
});
|
|
234
|
+
const replies = await Promise.all(requests.map((m) => {
|
|
235
|
+
const ctrl = new AbortController();
|
|
236
|
+
controllers.push(ctrl);
|
|
237
|
+
const key = idKey(m.id);
|
|
238
|
+
session.inflight.set(key, ctrl);
|
|
239
|
+
return this.handleRequest(session, req, res, m, ctrl.signal, !batch)
|
|
240
|
+
.catch((err) => {
|
|
241
|
+
logger.error(`MCP ${m.method} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
|
+
return rpcError(m.id, { code: JSONRPC_INTERNAL_ERROR, message: 'Internal error' });
|
|
243
|
+
})
|
|
244
|
+
.finally(() => {
|
|
245
|
+
if (session.inflight.get(key) === ctrl)
|
|
246
|
+
session.inflight.delete(key);
|
|
247
|
+
});
|
|
248
|
+
}));
|
|
249
|
+
if (res.headersSent || res.destroyed)
|
|
250
|
+
return;
|
|
251
|
+
res.status(200).json(batch ? replies : replies[0]);
|
|
252
|
+
}
|
|
253
|
+
initialize(req, res, msg) {
|
|
254
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
255
|
+
const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined;
|
|
256
|
+
const protocolVersion = requested && DOWNSTREAM_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION;
|
|
257
|
+
if (this.sessions.size >= this.cfg.maxSessions && !this.evictOne()) {
|
|
258
|
+
res.status(503).json(rpcError(msg.id, { code: JSONRPC_INTERNAL_ERROR, message: 'Too many sessions' }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const clientInfo = isObject(params.clientInfo)
|
|
262
|
+
? {
|
|
263
|
+
name: typeof params.clientInfo.name === 'string' ? params.clientInfo.name : undefined,
|
|
264
|
+
version: typeof params.clientInfo.version === 'string' ? params.clientInfo.version : undefined,
|
|
265
|
+
}
|
|
266
|
+
: undefined;
|
|
267
|
+
const session = {
|
|
268
|
+
id: randomUUID(),
|
|
269
|
+
clientId: req.clientId,
|
|
270
|
+
protocolVersion,
|
|
271
|
+
clientInfo,
|
|
272
|
+
createdAt: new Date(),
|
|
273
|
+
lastSeen: Date.now(),
|
|
274
|
+
streams: [],
|
|
275
|
+
inflight: new Map(),
|
|
276
|
+
eventSeq: 0,
|
|
277
|
+
auth: identityOf(req),
|
|
278
|
+
};
|
|
279
|
+
session.toolsFingerprint = this.fingerprint(this.toolIndex(session.auth));
|
|
280
|
+
session.resourcesFingerprint = this.resourcesFingerprint(session.auth);
|
|
281
|
+
session.promptsFingerprint = this.promptsFingerprint(session.auth);
|
|
282
|
+
this.sessions.set(session.id, session);
|
|
283
|
+
logger.info(`MCP session ${session.id.slice(0, 8)} opened by ${clientInfo?.name ?? 'unknown client'}` +
|
|
284
|
+
` (protocol ${protocolVersion}${session.clientId ? `, ${session.clientId}` : ''})`);
|
|
285
|
+
const result = {
|
|
286
|
+
protocolVersion,
|
|
287
|
+
capabilities: {
|
|
288
|
+
tools: { listChanged: true },
|
|
289
|
+
resources: { listChanged: true },
|
|
290
|
+
prompts: { listChanged: true },
|
|
291
|
+
},
|
|
292
|
+
serverInfo: { name: 'mcp-gateway', title: 'mcp-gateway', version: VERSION },
|
|
293
|
+
};
|
|
294
|
+
if (this.cfg.instructions)
|
|
295
|
+
result.instructions = this.cfg.instructions;
|
|
296
|
+
res.set('Mcp-Session-Id', session.id).status(200).json({ jsonrpc: '2.0', id: msg.id, result });
|
|
297
|
+
}
|
|
298
|
+
/** Validate session + protocol headers; on failure the response is already sent. */
|
|
299
|
+
resolveSession(req, res) {
|
|
300
|
+
const id = req.headers['mcp-session-id'];
|
|
301
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
302
|
+
res.status(400).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Bad Request: Mcp-Session-Id header is required' }));
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const session = this.sessions.get(id);
|
|
306
|
+
// A session presented by a different client is treated as unknown.
|
|
307
|
+
if (!session || session.clientId !== req.clientId) {
|
|
308
|
+
res.status(404).json(rpcError(null, { code: -32001, message: 'Session not found' }));
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
const version = req.headers['mcp-protocol-version'];
|
|
312
|
+
if (typeof version === 'string' && !DOWNSTREAM_PROTOCOL_VERSIONS.includes(version)) {
|
|
313
|
+
res.status(400).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: `Bad Request: unsupported MCP-Protocol-Version "${version}"` }));
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
session.lastSeen = Date.now();
|
|
317
|
+
session.auth = identityOf(req);
|
|
318
|
+
return session;
|
|
319
|
+
}
|
|
320
|
+
handleGet(req, res) {
|
|
321
|
+
if (!acceptsEventStream(req)) {
|
|
322
|
+
res.status(406).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Not Acceptable: client must accept text/event-stream' }));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const session = this.resolveSession(req, res);
|
|
326
|
+
if (!session)
|
|
327
|
+
return;
|
|
328
|
+
res.status(200).set({
|
|
329
|
+
'Content-Type': 'text/event-stream',
|
|
330
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
331
|
+
Connection: 'keep-alive',
|
|
332
|
+
'X-Accel-Buffering': 'no',
|
|
333
|
+
'Mcp-Session-Id': session.id,
|
|
334
|
+
});
|
|
335
|
+
res.flushHeaders();
|
|
336
|
+
res.write(': connected\n\n');
|
|
337
|
+
session.streams.push(res);
|
|
338
|
+
req.socket.setTimeout(0);
|
|
339
|
+
res.on('close', () => {
|
|
340
|
+
session.streams = session.streams.filter((s) => s !== res);
|
|
341
|
+
session.lastSeen = Date.now();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
handleDelete(req, res) {
|
|
345
|
+
const session = this.resolveSession(req, res);
|
|
346
|
+
if (!session)
|
|
347
|
+
return;
|
|
348
|
+
this.endSession(session);
|
|
349
|
+
logger.info(`MCP session ${session.id.slice(0, 8)} closed by client`);
|
|
350
|
+
res.status(204).end();
|
|
351
|
+
}
|
|
352
|
+
// ─── JSON-RPC ───────────────────────────────────────────────────────────────
|
|
353
|
+
handleNotification(session, msg) {
|
|
354
|
+
if (msg.method === 'notifications/cancelled') {
|
|
355
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
356
|
+
const rid = params.requestId;
|
|
357
|
+
if (typeof rid === 'string' || typeof rid === 'number') {
|
|
358
|
+
session.inflight.get(idKey(rid))?.abort();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// notifications/initialized and others need no action.
|
|
362
|
+
}
|
|
363
|
+
async handleRequest(session, req, res, msg, signal, single) {
|
|
364
|
+
const id = msg.id;
|
|
365
|
+
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
366
|
+
switch (msg.method) {
|
|
367
|
+
case 'ping':
|
|
368
|
+
return ok({});
|
|
369
|
+
case 'initialize':
|
|
370
|
+
return rpcError(id, { code: JSONRPC_INVALID_REQUEST, message: 'Session already initialized' });
|
|
371
|
+
case 'tools/list':
|
|
372
|
+
return this.listTools(session, msg);
|
|
373
|
+
case 'tools/call':
|
|
374
|
+
return this.callTool(session, req, res, msg, signal, single);
|
|
375
|
+
case 'resources/list': {
|
|
376
|
+
const list = this.resources(session.auth);
|
|
377
|
+
if (isFirstPage(msg))
|
|
378
|
+
session.resourcesFingerprint = hashList(list);
|
|
379
|
+
return this.paginate(msg, list.map(toMcpResource), 'resources');
|
|
380
|
+
}
|
|
381
|
+
case 'resources/templates/list':
|
|
382
|
+
return this.paginate(msg, this.templates(session.auth).map(toMcpResourceTemplate), 'resourceTemplates');
|
|
383
|
+
case 'resources/read':
|
|
384
|
+
return this.readResource(session, req, res, msg, signal, single);
|
|
385
|
+
case 'prompts/list': {
|
|
386
|
+
const index = this.promptIndex(session.auth);
|
|
387
|
+
if (isFirstPage(msg))
|
|
388
|
+
session.promptsFingerprint = this.promptsFingerprint(session.auth);
|
|
389
|
+
return this.paginate(msg, index.list.map(({ name, item }) => toMcpPrompt(name, item)), 'prompts');
|
|
390
|
+
}
|
|
391
|
+
case 'prompts/get':
|
|
392
|
+
return this.getPrompt(session, req, res, msg, signal, single);
|
|
393
|
+
default:
|
|
394
|
+
return rpcError(id, { code: JSONRPC_METHOD_NOT_FOUND, message: `Method not found: ${String(msg.method)}` });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Tools visible to a client (server filters + its scope), with exposed names. */
|
|
398
|
+
toolIndex(identity) {
|
|
399
|
+
const tools = filterToolsByScope(identity.scope, this.deps.registry.getAllTools());
|
|
400
|
+
return buildToolIndex(tools, this.cfg.toolNaming);
|
|
401
|
+
}
|
|
402
|
+
resources(identity) {
|
|
403
|
+
return dedupeResources(this.deps.registry.getAllResources().filter((r) => isServerInScope(identity.scope, r.serverId)));
|
|
404
|
+
}
|
|
405
|
+
templates(identity) {
|
|
406
|
+
return this.deps.registry
|
|
407
|
+
.getAllResourceTemplates()
|
|
408
|
+
.filter((t) => isServerInScope(identity.scope, t.serverId))
|
|
409
|
+
.sort((a, b) => (a.serverId < b.serverId ? -1 : a.serverId > b.serverId ? 1 : 0));
|
|
410
|
+
}
|
|
411
|
+
promptIndex(identity) {
|
|
412
|
+
const prompts = this.deps.registry.getAllPrompts().filter((p) => isServerInScope(identity.scope, p.serverId));
|
|
413
|
+
return buildPromptIndex(prompts, this.cfg.toolNaming);
|
|
414
|
+
}
|
|
415
|
+
resourcesFingerprint(identity) {
|
|
416
|
+
return hashList([...this.resources(identity), ...this.templates(identity)]);
|
|
417
|
+
}
|
|
418
|
+
promptsFingerprint(identity) {
|
|
419
|
+
return hashList(this.promptIndex(identity).list.map(({ name, item }) => toMcpPrompt(name, item)));
|
|
420
|
+
}
|
|
421
|
+
/** Cursor pagination over a list (same scheme as tools/list). */
|
|
422
|
+
paginate(msg, items, key) {
|
|
423
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
424
|
+
let offset = 0;
|
|
425
|
+
if (params.cursor !== undefined) {
|
|
426
|
+
const o = decodeCursor(params.cursor);
|
|
427
|
+
if (o === undefined)
|
|
428
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: 'Invalid cursor' });
|
|
429
|
+
offset = o;
|
|
430
|
+
}
|
|
431
|
+
const page = items.slice(offset, offset + this.cfg.pageSize);
|
|
432
|
+
const result = { [key]: page };
|
|
433
|
+
const next = offset + page.length;
|
|
434
|
+
if (next < items.length)
|
|
435
|
+
result.nextCursor = encodeCursor(next);
|
|
436
|
+
return { jsonrpc: '2.0', id: msg.id, result };
|
|
437
|
+
}
|
|
438
|
+
/** Forward resources/read or prompts/get with shared rate limit, metrics and error mapping. */
|
|
439
|
+
async forward(req, res, msg, serverId, method, params, kind, label, signal, single) {
|
|
440
|
+
const id = msg.id;
|
|
441
|
+
const limited = this.applyRateLimit(req, res, id, single);
|
|
442
|
+
if (limited)
|
|
443
|
+
return limited;
|
|
444
|
+
const server = this.deps.registry.getServer(serverId);
|
|
445
|
+
if (!server || !this.deps.proxy.isConnected(serverId)) {
|
|
446
|
+
return rpcError(id, { code: ERR_NOT_CONNECTED, message: `Server "${serverId}" is not connected` });
|
|
447
|
+
}
|
|
448
|
+
const result = await this.deps.proxy.request(serverId, method, params, server.timeout, { signal });
|
|
449
|
+
this.deps.metrics.record({
|
|
450
|
+
serverId,
|
|
451
|
+
toolName: label,
|
|
452
|
+
durationMs: result.durationMs,
|
|
453
|
+
success: result.success,
|
|
454
|
+
errorMessage: result.error?.message,
|
|
455
|
+
clientId: req.clientId,
|
|
456
|
+
via: 'mcp',
|
|
457
|
+
kind,
|
|
458
|
+
});
|
|
459
|
+
if (this.deps.requestLog()) {
|
|
460
|
+
logger.info(`${method} ${label} → ${serverId} ${result.success ? 'ok' : 'failed'} ${result.durationMs}ms (mcp)`);
|
|
461
|
+
}
|
|
462
|
+
if (result.success)
|
|
463
|
+
return { jsonrpc: '2.0', id, result: result.result ?? {} };
|
|
464
|
+
const err = result.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Unknown error' };
|
|
465
|
+
return rpcError(id, err.code === ERR_CANCELLED ? { code: ERR_CANCELLED, message: 'Request cancelled' } : err);
|
|
466
|
+
}
|
|
467
|
+
readResource(session, req, res, msg, signal, single) {
|
|
468
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
469
|
+
const uri = params.uri;
|
|
470
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
471
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"uri" must be a non-empty string' });
|
|
472
|
+
}
|
|
473
|
+
const scope = session.auth.scope;
|
|
474
|
+
const serverId = routeResource(uri, this.resources(session.auth), this.templates(session.auth), this.deps.registry
|
|
475
|
+
.getEnabledServers()
|
|
476
|
+
.map((s) => s.id)
|
|
477
|
+
.filter((id) => isServerInScope(scope, id) && this.deps.proxy.hasCapability(id, 'resources')));
|
|
478
|
+
if (!serverId)
|
|
479
|
+
return rpcError(msg.id, { code: -32002, message: 'Resource not found', data: { uri } });
|
|
480
|
+
return this.forward(req, res, msg, serverId, 'resources/read', { uri }, 'resource', uri, signal, single);
|
|
481
|
+
}
|
|
482
|
+
getPrompt(session, req, res, msg, signal, single) {
|
|
483
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
484
|
+
const name = params.name;
|
|
485
|
+
const args = params.arguments ?? {};
|
|
486
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
487
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"name" must be a non-empty string' });
|
|
488
|
+
}
|
|
489
|
+
if (!isObject(args))
|
|
490
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
491
|
+
const prompt = this.promptIndex(session.auth).byName.get(name);
|
|
492
|
+
if (!prompt)
|
|
493
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown prompt: ${name}` });
|
|
494
|
+
return this.forward(req, res, msg, prompt.serverId, 'prompts/get', { name: prompt.name, arguments: args }, 'prompt', prompt.name, signal, single);
|
|
495
|
+
}
|
|
496
|
+
/** Count one call against the client's rate limit; returns an error reply when exceeded. */
|
|
497
|
+
applyRateLimit(req, res, id, single) {
|
|
498
|
+
const decision = this.deps.takeRateLimit(req);
|
|
499
|
+
if (!decision)
|
|
500
|
+
return undefined;
|
|
501
|
+
if (single && !res.headersSent)
|
|
502
|
+
setRateLimitHeaders(res, decision.limit, decision.remaining, decision.resetAt);
|
|
503
|
+
if (decision.allowed)
|
|
504
|
+
return undefined;
|
|
505
|
+
if (single && !res.headersSent)
|
|
506
|
+
res.set('Retry-After', String(decision.retryAfter));
|
|
507
|
+
return rpcError(id, {
|
|
508
|
+
code: ERR_RATE_LIMITED,
|
|
509
|
+
message: `Rate limit exceeded; retry after ${decision.retryAfter}s`,
|
|
510
|
+
data: { retryAfter: decision.retryAfter },
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
fingerprint(index) {
|
|
514
|
+
const h = createHash('sha256');
|
|
515
|
+
for (const t of index.list)
|
|
516
|
+
h.update(JSON.stringify(toMcpTool(t))).update('\n');
|
|
517
|
+
return h.digest('hex');
|
|
518
|
+
}
|
|
519
|
+
listTools(session, msg) {
|
|
520
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
521
|
+
let offset = 0;
|
|
522
|
+
if (params.cursor !== undefined) {
|
|
523
|
+
const o = decodeCursor(params.cursor);
|
|
524
|
+
if (o === undefined) {
|
|
525
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: 'Invalid cursor' });
|
|
526
|
+
}
|
|
527
|
+
offset = o;
|
|
528
|
+
}
|
|
529
|
+
const index = this.toolIndex(session.auth);
|
|
530
|
+
if (offset === 0)
|
|
531
|
+
session.toolsFingerprint = this.fingerprint(index);
|
|
532
|
+
const page = index.list.slice(offset, offset + this.cfg.pageSize);
|
|
533
|
+
const result = { tools: page.map(toMcpTool) };
|
|
534
|
+
const next = offset + page.length;
|
|
535
|
+
if (next < index.list.length)
|
|
536
|
+
result.nextCursor = encodeCursor(next);
|
|
537
|
+
return { jsonrpc: '2.0', id: msg.id, result };
|
|
538
|
+
}
|
|
539
|
+
async callTool(session, req, res, msg, signal, single) {
|
|
540
|
+
const id = msg.id;
|
|
541
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
542
|
+
const name = params.name;
|
|
543
|
+
const args = params.arguments ?? {};
|
|
544
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
545
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"name" must be a non-empty string' });
|
|
546
|
+
}
|
|
547
|
+
if (!isObject(args)) {
|
|
548
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
549
|
+
}
|
|
550
|
+
const tool = this.toolIndex(session.auth).byName.get(name);
|
|
551
|
+
if (!tool) {
|
|
552
|
+
// A tool that exists but is outside the client's scope is refused with
|
|
553
|
+
// ERR_FORBIDDEN (the REST API's 403); it never appears in tools/list.
|
|
554
|
+
if (session.auth.scope) {
|
|
555
|
+
const unscoped = buildToolIndex(this.deps.registry.getAllTools(), this.cfg.toolNaming).byName.get(name);
|
|
556
|
+
const anyServer = this.deps.registry.findTools(name);
|
|
557
|
+
const target = unscoped ?? (anyServer.length > 0 ? anyServer[0] : undefined);
|
|
558
|
+
if (target && !isToolInScope(session.auth.scope, target.serverId, target.name)) {
|
|
559
|
+
return rpcError(id, { code: ERR_FORBIDDEN, message: `Forbidden: tool "${name}" is not allowed for this client` });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown tool: ${name}` });
|
|
563
|
+
}
|
|
564
|
+
const { serverId } = tool;
|
|
565
|
+
if (!this.deps.registry.isToolExposed(serverId, tool.name)) {
|
|
566
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown tool: ${name}` });
|
|
567
|
+
}
|
|
568
|
+
const limited = this.applyRateLimit(req, res, id, single);
|
|
569
|
+
if (limited)
|
|
570
|
+
return limited;
|
|
571
|
+
const server = this.deps.registry.getServer(serverId);
|
|
572
|
+
const toolError = (text) => ({
|
|
573
|
+
jsonrpc: '2.0',
|
|
574
|
+
id,
|
|
575
|
+
result: { content: [{ type: 'text', text }], isError: true },
|
|
576
|
+
});
|
|
577
|
+
if (!server || !this.deps.proxy.isConnected(serverId)) {
|
|
578
|
+
const status = this.deps.registry.getHealth(serverId)?.status;
|
|
579
|
+
return toolError(`Server "${serverId}" is not connected${status ? ` (${status})` : ''}; try again later.`);
|
|
580
|
+
}
|
|
581
|
+
const result = await this.deps.proxy.callTool(serverId, tool.name, args, server.timeout, { signal });
|
|
582
|
+
this.deps.metrics.record({
|
|
583
|
+
serverId,
|
|
584
|
+
toolName: tool.name,
|
|
585
|
+
durationMs: result.durationMs,
|
|
586
|
+
success: result.success,
|
|
587
|
+
errorMessage: result.error?.message,
|
|
588
|
+
clientId: req.clientId,
|
|
589
|
+
via: 'mcp',
|
|
590
|
+
});
|
|
591
|
+
if (this.deps.requestLog()) {
|
|
592
|
+
logger.info(`${tool.name} → ${serverId} ${result.success ? 'ok' : 'failed'} ${result.durationMs}ms (mcp)`);
|
|
593
|
+
}
|
|
594
|
+
if (result.success)
|
|
595
|
+
return { jsonrpc: '2.0', id, result: result.result ?? { content: [] } };
|
|
596
|
+
const err = result.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Unknown error' };
|
|
597
|
+
switch (err.code) {
|
|
598
|
+
case ERR_CANCELLED:
|
|
599
|
+
return rpcError(id, { code: ERR_CANCELLED, message: 'Request cancelled' });
|
|
600
|
+
case ERR_TIMEOUT:
|
|
601
|
+
return toolError(`Tool "${name}" timed out after ${result.durationMs}ms`);
|
|
602
|
+
case ERR_NOT_CONNECTED:
|
|
603
|
+
return toolError(`Server "${serverId}" is not connected: ${err.message}`);
|
|
604
|
+
default:
|
|
605
|
+
// Upstream JSON-RPC error: forward unchanged.
|
|
606
|
+
return rpcError(id, err);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// ─── Notifications / housekeeping ───────────────────────────────────────────
|
|
610
|
+
scheduleListChanged() {
|
|
611
|
+
if (this.closed)
|
|
612
|
+
return;
|
|
613
|
+
clearTimeout(this.notifyTimer);
|
|
614
|
+
this.notifyTimer = setTimeout(() => this.sendListChanged(), 50);
|
|
615
|
+
this.notifyTimer.unref();
|
|
616
|
+
}
|
|
617
|
+
/** Re-evaluate every session's scope (after an auth hot reload) and notify changes. */
|
|
618
|
+
refreshClients() {
|
|
619
|
+
this.scheduleListChanged();
|
|
620
|
+
}
|
|
621
|
+
sendListChanged() {
|
|
622
|
+
for (const s of [...this.sessions.values()]) {
|
|
623
|
+
const current = this.deps.resolveClient?.(s.auth.clientId);
|
|
624
|
+
if (current && !current.known) {
|
|
625
|
+
// The key that opened this session is gone.
|
|
626
|
+
logger.info(`MCP session ${s.id.slice(0, 8)} ended: its credentials were removed`);
|
|
627
|
+
this.endSession(s);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (current)
|
|
631
|
+
s.auth = { ...s.auth, scope: current.scope };
|
|
632
|
+
if (s.streams.length === 0)
|
|
633
|
+
continue;
|
|
634
|
+
const fp = this.fingerprint(this.toolIndex(s.auth));
|
|
635
|
+
if (fp !== s.toolsFingerprint) {
|
|
636
|
+
s.toolsFingerprint = fp;
|
|
637
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
|
638
|
+
}
|
|
639
|
+
const rfp = this.resourcesFingerprint(s.auth);
|
|
640
|
+
if (rfp !== s.resourcesFingerprint) {
|
|
641
|
+
s.resourcesFingerprint = rfp;
|
|
642
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/resources/list_changed' });
|
|
643
|
+
}
|
|
644
|
+
const pfp = this.promptsFingerprint(s.auth);
|
|
645
|
+
if (pfp !== s.promptsFingerprint) {
|
|
646
|
+
s.promptsFingerprint = pfp;
|
|
647
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/prompts/list_changed' });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Send a message on the session's most recent stream. */
|
|
652
|
+
send(session, msg) {
|
|
653
|
+
const stream = session.streams[session.streams.length - 1];
|
|
654
|
+
if (!stream)
|
|
655
|
+
return false;
|
|
656
|
+
session.eventSeq++;
|
|
657
|
+
stream.write(`id: ${session.eventSeq}\nevent: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
keepalive() {
|
|
661
|
+
for (const s of this.sessions.values())
|
|
662
|
+
for (const st of s.streams)
|
|
663
|
+
st.write(': keepalive\n\n');
|
|
664
|
+
}
|
|
665
|
+
sweep(now = Date.now()) {
|
|
666
|
+
const ttl = this.cfg.sessionIdleTimeoutSeconds * 1000;
|
|
667
|
+
for (const s of [...this.sessions.values()]) {
|
|
668
|
+
if (s.streams.length === 0 && s.inflight.size === 0 && now - s.lastSeen > ttl) {
|
|
669
|
+
logger.debug(`MCP session ${s.id.slice(0, 8)} expired`);
|
|
670
|
+
this.endSession(s);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** Evict the least recently used idle session; false if none is idle. */
|
|
675
|
+
evictOne() {
|
|
676
|
+
let victim;
|
|
677
|
+
for (const s of this.sessions.values()) {
|
|
678
|
+
if (s.streams.length > 0 || s.inflight.size > 0)
|
|
679
|
+
continue;
|
|
680
|
+
if (!victim || s.lastSeen < victim.lastSeen)
|
|
681
|
+
victim = s;
|
|
682
|
+
}
|
|
683
|
+
if (!victim)
|
|
684
|
+
return false;
|
|
685
|
+
this.endSession(victim);
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
endSession(session) {
|
|
689
|
+
this.sessions.delete(session.id);
|
|
690
|
+
for (const c of session.inflight.values())
|
|
691
|
+
c.abort();
|
|
692
|
+
session.inflight.clear();
|
|
693
|
+
for (const st of session.streams)
|
|
694
|
+
st.end();
|
|
695
|
+
session.streams = [];
|
|
696
|
+
}
|
|
697
|
+
/** Test hook: expire idle sessions now. */
|
|
698
|
+
sweepNow(now) {
|
|
699
|
+
this.sweep(now);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function isFirstPage(msg) {
|
|
703
|
+
return !isObject(msg.params) || msg.params.cursor === undefined;
|
|
704
|
+
}
|
|
705
|
+
function hashList(items) {
|
|
706
|
+
const h = createHash('sha256');
|
|
707
|
+
for (const i of items)
|
|
708
|
+
h.update(JSON.stringify(i)).update('\n');
|
|
709
|
+
return h.digest('hex');
|
|
710
|
+
}
|
|
711
|
+
function stripUndefined(o) {
|
|
712
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
713
|
+
}
|
|
714
|
+
//# sourceMappingURL=endpoint.js.map
|