@j0hanz/fetch-url-mcp 1.12.0 → 1.12.2
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/README.md +34 -17
- package/dist/http/auth.d.ts.map +1 -1
- package/dist/http/auth.js +61 -20
- package/dist/http/helpers.d.ts +1 -1
- package/dist/http/helpers.d.ts.map +1 -1
- package/dist/http/helpers.js +7 -9
- package/dist/http/native.d.ts.map +1 -1
- package/dist/http/native.js +271 -54
- package/dist/http/rate-limit.d.ts.map +1 -1
- package/dist/http/rate-limit.js +2 -1
- package/dist/index.js +5 -4
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +8 -1
- package/dist/lib/core.d.ts +8 -4
- package/dist/lib/core.d.ts.map +1 -1
- package/dist/lib/core.js +240 -73
- package/dist/lib/fetch-pipeline.d.ts.map +1 -1
- package/dist/lib/fetch-pipeline.js +15 -2
- package/dist/lib/http.d.ts.map +1 -1
- package/dist/lib/http.js +1 -1
- package/dist/lib/mcp-interop.d.ts +15 -3
- package/dist/lib/mcp-interop.d.ts.map +1 -1
- package/dist/lib/mcp-interop.js +92 -23
- package/dist/lib/url.d.ts.map +1 -1
- package/dist/lib/url.js +1 -1
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +2 -2
- package/dist/resources/index.d.ts +4 -0
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/index.js +39 -4
- package/dist/schemas.d.ts +5 -5
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +7 -9
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +20 -11
- package/dist/tasks/execution.d.ts +1 -1
- package/dist/tasks/execution.d.ts.map +1 -1
- package/dist/tasks/execution.js +72 -25
- package/dist/tasks/handlers.d.ts.map +1 -1
- package/dist/tasks/handlers.js +31 -24
- package/dist/tasks/manager.d.ts +5 -2
- package/dist/tasks/manager.d.ts.map +1 -1
- package/dist/tasks/manager.js +58 -19
- package/dist/tasks/owner.d.ts +5 -0
- package/dist/tasks/owner.d.ts.map +1 -1
- package/dist/tasks/owner.js +15 -7
- package/dist/tasks/registry.d.ts +10 -8
- package/dist/tasks/registry.d.ts.map +1 -1
- package/dist/tasks/registry.js +27 -15
- package/dist/tools/fetch-url.d.ts +2 -0
- package/dist/tools/fetch-url.d.ts.map +1 -1
- package/dist/tools/fetch-url.js +76 -21
- package/dist/transform/dom-prep.d.ts.map +1 -1
- package/dist/transform/dom-prep.js +6 -6
- package/dist/transform/transform.d.ts.map +1 -1
- package/dist/transform/transform.js +17 -14
- package/dist/transform/worker-pool.d.ts.map +1 -1
- package/dist/transform/worker-pool.js +43 -3
- package/package.json +2 -2
package/dist/http/native.js
CHANGED
|
@@ -7,10 +7,11 @@ import { hostname } from 'node:os';
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
9
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
-
import { composeCloseHandlers, config, createSessionStore, createSlotTracker, enableHttpMode, ensureSessionCapacity, logError, logInfo, registerMcpSessionServer, reserveSessionSlot, runWithRequestContext, startSessionCleanupLoop, } from '../lib/core.js';
|
|
10
|
+
import { composeCloseHandlers, config, createSessionStore, createSlotTracker, enableHttpMode, ensureSessionCapacity, logDebug, logError, logInfo, logWarn, registerMcpSessionOwnerKey, registerMcpSessionServer, reserveSessionSlot, runWithRequestContext, startSessionCleanupLoop, } from '../lib/core.js';
|
|
11
11
|
import { acceptsEventStream, acceptsJsonAndEventStream, isJsonRpcBatchRequest, isMcpMessageBody, isMcpRequestBody, } from '../lib/mcp-interop.js';
|
|
12
12
|
import { applyHttpServerTuning, drainConnectionsOnShutdown, isObject, toError, } from '../lib/utils.js';
|
|
13
13
|
import { createMcpServerForHttpSession } from '../server.js';
|
|
14
|
+
import { buildAuthenticatedOwnerKey } from '../tasks/owner.js';
|
|
14
15
|
import { applyInsufficientScopeAuthHeaders, applyUnauthorizedAuthHeaders, assertHttpModeConfiguration, authService, buildAuthFingerprint, buildProtectedResourceMetadataDocument, corsPolicy, DEFAULT_MCP_PROTOCOL_VERSION, ensureMcpProtocolVersion, hostOriginPolicy, isInsufficientScopeError, isOAuthMetadataEnabled, isProtectedResourceMetadataPath, SUPPORTED_MCP_PROTOCOL_VERSIONS, } from './auth.js';
|
|
15
16
|
import { disableEventLoopMonitoring, isVerboseHealthRequest, resetEventLoopMonitoring, sendHealthRouteResponse, shouldHandleHealthRoute, } from './health.js';
|
|
16
17
|
import { buildRequestContext, createRequestAbortSignal, createTransportAdapter, DEFAULT_BODY_LIMIT_BYTES, drainRequest, findDuplicateSingleValueHeader, getHeaderValue, getMcpSessionId, isJsonBodyError, jsonBodyReader, registerInboundBlockList, sendEmpty, sendError, sendJson, } from './helpers.js';
|
|
@@ -28,10 +29,9 @@ function resolveRequestedProtocolVersion(body) {
|
|
|
28
29
|
const normalized = value.trim();
|
|
29
30
|
if (normalized.length === 0)
|
|
30
31
|
return DEFAULT_MCP_PROTOCOL_VERSION;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return normalized;
|
|
32
|
+
return SUPPORTED_MCP_PROTOCOL_VERSIONS.has(normalized)
|
|
33
|
+
? normalized
|
|
34
|
+
: DEFAULT_MCP_PROTOCOL_VERSION;
|
|
35
35
|
}
|
|
36
36
|
function resolveProtocolVersionHeader(req) {
|
|
37
37
|
const header = getHeaderValue(req, 'mcp-protocol-version');
|
|
@@ -49,6 +49,36 @@ function isPingRequest(method) {
|
|
|
49
49
|
function isMcpRoute(pathname) {
|
|
50
50
|
return pathname === '/mcp' || pathname === '/mcp/';
|
|
51
51
|
}
|
|
52
|
+
function logGatewayRejection(params) {
|
|
53
|
+
const { message, details, rpcId, ...rest } = params;
|
|
54
|
+
logWarn(message, {
|
|
55
|
+
...rest,
|
|
56
|
+
...(rpcId === null || rpcId === undefined ? {} : { rpcId }),
|
|
57
|
+
...(details ?? {}),
|
|
58
|
+
}, 'http');
|
|
59
|
+
}
|
|
60
|
+
function resolveRequestPath(req) {
|
|
61
|
+
return URL.parse(req.url ?? '', 'http://localhost')?.pathname ?? '/';
|
|
62
|
+
}
|
|
63
|
+
function logRequestCompletion(params) {
|
|
64
|
+
const meta = {
|
|
65
|
+
method: params.method,
|
|
66
|
+
path: params.path,
|
|
67
|
+
statusCode: params.statusCode,
|
|
68
|
+
durationMs: Math.round(params.durationMs),
|
|
69
|
+
requestId: params.requestId,
|
|
70
|
+
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
|
71
|
+
};
|
|
72
|
+
if (params.statusCode >= 500) {
|
|
73
|
+
logError('HTTP request completed with server error', meta, 'http');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (params.statusCode >= 400) {
|
|
77
|
+
logWarn('HTTP request completed with client error', meta, 'http');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
logDebug('HTTP request completed', meta, 'http');
|
|
81
|
+
}
|
|
52
82
|
function createSessionTeardownOptions(mode, context) {
|
|
53
83
|
switch (mode) {
|
|
54
84
|
case 'ended':
|
|
@@ -71,6 +101,14 @@ function createSessionTeardownOptions(mode, context) {
|
|
|
71
101
|
unregisterByServer: true,
|
|
72
102
|
awaitClose: true,
|
|
73
103
|
};
|
|
104
|
+
case 'init-timeout':
|
|
105
|
+
return {
|
|
106
|
+
cancelMessage: 'The task was cancelled because the MCP session did not finish initialization.',
|
|
107
|
+
closeTransportReason: 'session-init-timeout',
|
|
108
|
+
closeServerReason: 'session-init-timeout',
|
|
109
|
+
unregisterByServer: true,
|
|
110
|
+
awaitClose: true,
|
|
111
|
+
};
|
|
74
112
|
}
|
|
75
113
|
}
|
|
76
114
|
class McpSessionGateway {
|
|
@@ -94,11 +132,11 @@ class McpSessionGateway {
|
|
|
94
132
|
sendEmpty(ctx.res, 202);
|
|
95
133
|
return;
|
|
96
134
|
}
|
|
97
|
-
|
|
135
|
+
logDebug('MCP POST received', {
|
|
98
136
|
method: method ?? 'response',
|
|
99
|
-
|
|
137
|
+
rpcId: body.id,
|
|
100
138
|
sessionId,
|
|
101
|
-
});
|
|
139
|
+
}, 'http');
|
|
102
140
|
const transport = await this.getOrCreateTransport(ctx, requestId);
|
|
103
141
|
if (!transport)
|
|
104
142
|
return;
|
|
@@ -113,11 +151,20 @@ class McpSessionGateway {
|
|
|
113
151
|
const { sessionId, session } = sessionState;
|
|
114
152
|
const acceptHeader = getHeaderValue(ctx.req, 'accept');
|
|
115
153
|
if (!acceptsEventStream(acceptHeader)) {
|
|
154
|
+
logGatewayRejection({
|
|
155
|
+
message: 'Rejected MCP GET request',
|
|
156
|
+
method: ctx.method,
|
|
157
|
+
path: ctx.url.pathname,
|
|
158
|
+
reason: 'accept_missing_event_stream',
|
|
159
|
+
status: 406,
|
|
160
|
+
sessionId,
|
|
161
|
+
});
|
|
116
162
|
sendJson(ctx.res, 406, {
|
|
117
|
-
error: '
|
|
163
|
+
error: 'We need you to use "text/event-stream" for this connection.',
|
|
118
164
|
});
|
|
119
165
|
return;
|
|
120
166
|
}
|
|
167
|
+
logDebug('MCP GET received', { sessionId }, 'http');
|
|
121
168
|
this.store.touch(sessionId);
|
|
122
169
|
await session.transport.handleRequest(ctx.req, ctx.res);
|
|
123
170
|
}
|
|
@@ -129,23 +176,47 @@ class McpSessionGateway {
|
|
|
129
176
|
return;
|
|
130
177
|
const { sessionId, session } = sessionState;
|
|
131
178
|
await session.transport.close();
|
|
132
|
-
|
|
179
|
+
logDebug('MCP DELETE received', { sessionId }, 'http');
|
|
180
|
+
this.cleanupSessionRecord(sessionId, createSessionTeardownOptions('ended', 'session-delete'));
|
|
133
181
|
sendJson(ctx.res, 200, { status: 'closed' });
|
|
134
182
|
}
|
|
135
183
|
validatePostRequest(ctx) {
|
|
136
184
|
if (!acceptsJsonAndEventStream(getHeaderValue(ctx.req, 'accept'))) {
|
|
185
|
+
logGatewayRejection({
|
|
186
|
+
message: 'Rejected MCP POST request',
|
|
187
|
+
method: ctx.method,
|
|
188
|
+
path: ctx.url.pathname,
|
|
189
|
+
reason: 'accept_missing_json_or_event_stream',
|
|
190
|
+
status: 406,
|
|
191
|
+
});
|
|
137
192
|
sendJson(ctx.res, 406, {
|
|
138
|
-
error: '
|
|
193
|
+
error: 'We need the request to accept both "application/json" and "text/event-stream".',
|
|
139
194
|
});
|
|
140
195
|
return null;
|
|
141
196
|
}
|
|
142
197
|
const { body } = ctx;
|
|
143
198
|
if (isJsonRpcBatchRequest(body)) {
|
|
144
|
-
|
|
199
|
+
logGatewayRejection({
|
|
200
|
+
message: 'Rejected MCP POST request',
|
|
201
|
+
method: ctx.method,
|
|
202
|
+
path: ctx.url.pathname,
|
|
203
|
+
reason: 'batch_request_not_supported',
|
|
204
|
+
status: 400,
|
|
205
|
+
mcpCode: -32600,
|
|
206
|
+
});
|
|
207
|
+
sendError(ctx.res, -32600, "We don't support batch requests yet. Please send one request at a time.");
|
|
145
208
|
return null;
|
|
146
209
|
}
|
|
147
210
|
if (!isMcpMessageBody(body)) {
|
|
148
|
-
|
|
211
|
+
logGatewayRejection({
|
|
212
|
+
message: 'Rejected MCP POST request',
|
|
213
|
+
method: ctx.method,
|
|
214
|
+
path: ctx.url.pathname,
|
|
215
|
+
reason: 'invalid_request_body',
|
|
216
|
+
status: 400,
|
|
217
|
+
mcpCode: -32600,
|
|
218
|
+
});
|
|
219
|
+
sendError(ctx.res, -32600, "The request body isn't quite right. Please check the format and try again.");
|
|
149
220
|
return null;
|
|
150
221
|
}
|
|
151
222
|
return body;
|
|
@@ -156,7 +227,16 @@ class McpSessionGateway {
|
|
|
156
227
|
const isInitializedMethod = method !== null && isInitializedNotification(method);
|
|
157
228
|
const isInitNotification = isInitializedMethod && body.id === undefined;
|
|
158
229
|
if (isInitializedMethod && !isInitNotification) {
|
|
159
|
-
|
|
230
|
+
logGatewayRejection({
|
|
231
|
+
message: 'Rejected MCP POST request',
|
|
232
|
+
method: ctx.method,
|
|
233
|
+
path: ctx.url.pathname,
|
|
234
|
+
reason: 'initialized_request_must_be_notification',
|
|
235
|
+
status: 400,
|
|
236
|
+
mcpCode: -32600,
|
|
237
|
+
rpcId: requestId,
|
|
238
|
+
});
|
|
239
|
+
sendError(ctx.res, -32600, "The 'notifications/initialized' message must be sent as a notification, without an ID.", 400, requestId);
|
|
160
240
|
return null;
|
|
161
241
|
}
|
|
162
242
|
const sessionState = this.getOptionalAuthenticatedSession(ctx, requestId);
|
|
@@ -187,10 +267,19 @@ class McpSessionGateway {
|
|
|
187
267
|
return false;
|
|
188
268
|
if (!session) {
|
|
189
269
|
if (isInitNotification) {
|
|
190
|
-
|
|
270
|
+
logGatewayRejection({
|
|
271
|
+
message: 'Rejected MCP POST request',
|
|
272
|
+
method: ctx.method,
|
|
273
|
+
path: ctx.url.pathname,
|
|
274
|
+
reason: 'missing_session_id_for_initialized_notification',
|
|
275
|
+
status: 400,
|
|
276
|
+
mcpCode: -32600,
|
|
277
|
+
rpcId: requestId,
|
|
278
|
+
});
|
|
279
|
+
sendError(ctx.res, -32600, "We couldn't find a session ID for your request. Please ensure you have an active session.", 400, requestId);
|
|
191
280
|
return false;
|
|
192
281
|
}
|
|
193
|
-
return
|
|
282
|
+
return true;
|
|
194
283
|
}
|
|
195
284
|
if (!this.ensureSessionProtocolVersion(ctx, session))
|
|
196
285
|
return false;
|
|
@@ -200,7 +289,17 @@ class McpSessionGateway {
|
|
|
200
289
|
return true;
|
|
201
290
|
if (method !== null && isPingRequest(method))
|
|
202
291
|
return true;
|
|
203
|
-
|
|
292
|
+
logGatewayRejection({
|
|
293
|
+
message: 'Rejected MCP request',
|
|
294
|
+
method: ctx.method,
|
|
295
|
+
path: ctx.url.pathname,
|
|
296
|
+
reason: 'session_not_initialized',
|
|
297
|
+
status: 400,
|
|
298
|
+
mcpCode: -32600,
|
|
299
|
+
sessionId,
|
|
300
|
+
rpcId: requestId,
|
|
301
|
+
});
|
|
302
|
+
sendError(ctx.res, -32600, "Your session hasn't been initialized yet. Please wait a moment and try again.", 400, requestId);
|
|
204
303
|
return false;
|
|
205
304
|
}
|
|
206
305
|
async getOrCreateTransport(ctx, requestId) {
|
|
@@ -216,24 +315,54 @@ class McpSessionGateway {
|
|
|
216
315
|
}
|
|
217
316
|
getInitializeProtocolVersion(ctx, requestId) {
|
|
218
317
|
if (!isMcpRequestBody(ctx.body)) {
|
|
219
|
-
|
|
318
|
+
logGatewayRejection({
|
|
319
|
+
message: 'Rejected MCP initialize request',
|
|
320
|
+
method: ctx.method,
|
|
321
|
+
path: ctx.url.pathname,
|
|
322
|
+
reason: 'missing_session_id',
|
|
323
|
+
status: 400,
|
|
324
|
+
mcpCode: -32600,
|
|
325
|
+
rpcId: requestId,
|
|
326
|
+
});
|
|
327
|
+
sendError(ctx.res, -32600, "We couldn't find a session ID for your request. Please ensure you have an active session.", 400, requestId);
|
|
220
328
|
return null;
|
|
221
329
|
}
|
|
222
330
|
if (!isInitializeRequest(ctx.body)) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
: '
|
|
331
|
+
const invalidInitialize = ctx.body.method === 'initialize';
|
|
332
|
+
logGatewayRejection({
|
|
333
|
+
message: 'Rejected MCP initialize request',
|
|
334
|
+
method: ctx.method,
|
|
335
|
+
path: ctx.url.pathname,
|
|
336
|
+
reason: invalidInitialize
|
|
337
|
+
? 'invalid_initialize_request'
|
|
338
|
+
: 'missing_session_id',
|
|
339
|
+
status: 400,
|
|
340
|
+
mcpCode: invalidInitialize ? -32602 : -32600,
|
|
341
|
+
rpcId: requestId,
|
|
342
|
+
});
|
|
343
|
+
sendError(ctx.res, invalidInitialize ? -32602 : -32600, invalidInitialize
|
|
344
|
+
? 'The initialize request format is invalid. Please double-check your parameters.'
|
|
345
|
+
: "We couldn't find a session ID for your request. Please ensure you have an active session.", 400, requestId);
|
|
226
346
|
return null;
|
|
227
347
|
}
|
|
228
348
|
const negotiatedProtocolVersion = resolveRequestedProtocolVersion(ctx.body);
|
|
229
|
-
if (!negotiatedProtocolVersion) {
|
|
230
|
-
sendError(ctx.res, -32602, `Unsupported protocolVersion; supported versions: ${[...SUPPORTED_MCP_PROTOCOL_VERSIONS].join(', ')}`, 400, requestId);
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
349
|
const headerProtocolVersion = resolveProtocolVersionHeader(ctx.req);
|
|
234
350
|
if (headerProtocolVersion &&
|
|
235
351
|
headerProtocolVersion !== negotiatedProtocolVersion) {
|
|
236
|
-
|
|
352
|
+
logGatewayRejection({
|
|
353
|
+
message: 'Rejected MCP initialize request',
|
|
354
|
+
method: ctx.method,
|
|
355
|
+
path: ctx.url.pathname,
|
|
356
|
+
reason: 'protocol_version_mismatch',
|
|
357
|
+
status: 400,
|
|
358
|
+
mcpCode: -32600,
|
|
359
|
+
rpcId: requestId,
|
|
360
|
+
details: {
|
|
361
|
+
headerProtocolVersion,
|
|
362
|
+
negotiatedProtocolVersion,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
sendError(ctx.res, -32600, `There's a mismatch in the protocol version. The header says '${headerProtocolVersion}' but the body says '${negotiatedProtocolVersion}'.`, 400, requestId);
|
|
237
366
|
return null;
|
|
238
367
|
}
|
|
239
368
|
return negotiatedProtocolVersion;
|
|
@@ -261,13 +390,13 @@ class McpSessionGateway {
|
|
|
261
390
|
return null;
|
|
262
391
|
const { sessionId, session } = state;
|
|
263
392
|
if (!sessionId || !session) {
|
|
264
|
-
sendError(ctx.res, -32600, '
|
|
393
|
+
sendError(ctx.res, -32600, "We couldn't find a session ID for your request. Please ensure you have an active session.", 400, requestId);
|
|
265
394
|
return null;
|
|
266
395
|
}
|
|
267
396
|
if (!this.ensureSessionProtocolVersion(ctx, session))
|
|
268
397
|
return null;
|
|
269
398
|
if (options?.requireInitialized && !session.protocolInitialized) {
|
|
270
|
-
sendError(ctx.res, -32600, '
|
|
399
|
+
sendError(ctx.res, -32600, "Your session hasn't been initialized yet. Please wait a moment and try again.", 400, requestId);
|
|
271
400
|
return null;
|
|
272
401
|
}
|
|
273
402
|
return { sessionId, session };
|
|
@@ -275,11 +404,29 @@ class McpSessionGateway {
|
|
|
275
404
|
getAuthenticatedSessionById(sessionId, authFingerprint, res, requestId = null) {
|
|
276
405
|
const session = this.store.get(sessionId);
|
|
277
406
|
if (!session) {
|
|
278
|
-
|
|
407
|
+
logGatewayRejection({
|
|
408
|
+
message: 'Rejected MCP session request',
|
|
409
|
+
path: '/mcp',
|
|
410
|
+
reason: 'session_not_found',
|
|
411
|
+
status: 404,
|
|
412
|
+
mcpCode: -32600,
|
|
413
|
+
sessionId,
|
|
414
|
+
rpcId: requestId,
|
|
415
|
+
});
|
|
416
|
+
sendError(res, -32600, "We couldn't find your session. It might have expired or been closed.", 404, requestId);
|
|
279
417
|
return null;
|
|
280
418
|
}
|
|
281
419
|
if (!authFingerprint || session.authFingerprint !== authFingerprint) {
|
|
282
|
-
|
|
420
|
+
logGatewayRejection({
|
|
421
|
+
message: 'Rejected MCP session request',
|
|
422
|
+
path: '/mcp',
|
|
423
|
+
reason: 'session_auth_mismatch',
|
|
424
|
+
status: 404,
|
|
425
|
+
mcpCode: -32600,
|
|
426
|
+
sessionId,
|
|
427
|
+
rpcId: requestId,
|
|
428
|
+
});
|
|
429
|
+
sendError(res, -32600, "We couldn't find your session. It might have expired or been closed.", 404, requestId);
|
|
283
430
|
return null;
|
|
284
431
|
}
|
|
285
432
|
return session;
|
|
@@ -296,6 +443,7 @@ class McpSessionGateway {
|
|
|
296
443
|
this.clearSessionInitTimeout(sessionId);
|
|
297
444
|
if (sessionId)
|
|
298
445
|
this.store.touch(sessionId);
|
|
446
|
+
logDebug('Session initialized', { sessionId }, 'session');
|
|
299
447
|
}
|
|
300
448
|
createSessionInitTimeout(sessionId, tracker, unpublishedSession) {
|
|
301
449
|
const initTimeout = setTimeout(() => {
|
|
@@ -305,9 +453,11 @@ class McpSessionGateway {
|
|
|
305
453
|
this.clearSessionInitTimeout(sessionId);
|
|
306
454
|
return;
|
|
307
455
|
}
|
|
308
|
-
|
|
456
|
+
logWarn('Session init timeout', { sessionId }, 'session');
|
|
457
|
+
this.cleanupSessionRecord(sessionId, createSessionTeardownOptions('init-timeout'));
|
|
309
458
|
return;
|
|
310
459
|
}
|
|
460
|
+
logWarn('Session init timeout before registration completed', { sessionId }, 'session');
|
|
311
461
|
tracker.releaseSlot();
|
|
312
462
|
void teardownUnregisteredSessionResources(unpublishedSession, 'session-init-timeout');
|
|
313
463
|
}, config.server.sessionInitTimeoutMs);
|
|
@@ -327,6 +477,10 @@ class McpSessionGateway {
|
|
|
327
477
|
await sessionServer.connect(transport);
|
|
328
478
|
}
|
|
329
479
|
catch (err) {
|
|
480
|
+
logWarn('Session transport connect failed', {
|
|
481
|
+
sessionId,
|
|
482
|
+
error: toError(err).message,
|
|
483
|
+
}, 'session');
|
|
330
484
|
clearTimeout(initTimeout);
|
|
331
485
|
tracker.releaseSlot();
|
|
332
486
|
void teardownUnregisteredSessionResources(unpublishedSession, 'session-connect-failed');
|
|
@@ -337,7 +491,20 @@ class McpSessionGateway {
|
|
|
337
491
|
async createNewSession(ctx, requestId, negotiatedProtocolVersion) {
|
|
338
492
|
const authFingerprint = buildAuthFingerprint(ctx.auth);
|
|
339
493
|
if (!authFingerprint) {
|
|
340
|
-
|
|
494
|
+
logError('Session creation failed: missing auth context', {
|
|
495
|
+
path: ctx.url.pathname,
|
|
496
|
+
method: ctx.method,
|
|
497
|
+
}, 'session');
|
|
498
|
+
sendError(ctx.res, -32603, "We're missing some authorization details to process this request.", 500, requestId);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const ownerKey = buildAuthenticatedOwnerKey(ctx.auth);
|
|
502
|
+
if (!ownerKey) {
|
|
503
|
+
logError('Session creation failed: missing task owner context', {
|
|
504
|
+
path: ctx.url.pathname,
|
|
505
|
+
method: ctx.method,
|
|
506
|
+
}, 'session');
|
|
507
|
+
sendError(ctx.res, -32603, "We're missing the owner information needed to authorize this request.", 500, requestId);
|
|
341
508
|
return null;
|
|
342
509
|
}
|
|
343
510
|
if (!this.reserveCapacity(ctx.res, requestId))
|
|
@@ -349,6 +516,7 @@ class McpSessionGateway {
|
|
|
349
516
|
sessionServer = await this.createSessionServer();
|
|
350
517
|
}
|
|
351
518
|
catch (error) {
|
|
519
|
+
logError('Session server creation failed', { sessionId: newSessionId, error: toError(error).message }, 'session');
|
|
352
520
|
tracker.releaseSlot();
|
|
353
521
|
throw error;
|
|
354
522
|
}
|
|
@@ -363,6 +531,7 @@ class McpSessionGateway {
|
|
|
363
531
|
const isConnected = await this.connectTransport(sessionServer, transportImpl, initTimeout, tracker, unpublishedSession, newSessionId);
|
|
364
532
|
tracker.releaseSlot();
|
|
365
533
|
if (!isConnected) {
|
|
534
|
+
logWarn('Session closed before registration completed', { sessionId: newSessionId }, 'session');
|
|
366
535
|
void teardownUnregisteredSessionResources(unpublishedSession, 'session-closed-during-connect');
|
|
367
536
|
return null;
|
|
368
537
|
}
|
|
@@ -376,18 +545,24 @@ class McpSessionGateway {
|
|
|
376
545
|
authFingerprint,
|
|
377
546
|
});
|
|
378
547
|
this.sessionInitTimeouts.set(newSessionId, initTimeout);
|
|
548
|
+
registerMcpSessionOwnerKey(newSessionId, ownerKey);
|
|
379
549
|
registerMcpSessionServer(newSessionId, sessionServer);
|
|
550
|
+
logInfo('Session created', { sessionId: newSessionId, negotiatedProtocolVersion }, 'session');
|
|
380
551
|
transportImpl.onclose = composeCloseHandlers(transportImpl.onclose, () => {
|
|
381
|
-
this.cleanupSessionRecord(newSessionId, 'session-close');
|
|
552
|
+
this.cleanupSessionRecord(newSessionId, createSessionTeardownOptions('ended', 'session-close'));
|
|
382
553
|
});
|
|
383
554
|
return transportImpl;
|
|
384
555
|
}
|
|
385
|
-
cleanupSessionRecord(sessionId,
|
|
556
|
+
cleanupSessionRecord(sessionId, teardownOptions) {
|
|
557
|
+
const context = teardownOptions.closeTransportReason ??
|
|
558
|
+
teardownOptions.closeServerReason ??
|
|
559
|
+
'session';
|
|
560
|
+
logDebug('Session cleanup', { sessionId, context }, 'session');
|
|
386
561
|
this.clearSessionInitTimeout(sessionId);
|
|
387
562
|
const session = this.store.remove(sessionId);
|
|
388
563
|
if (!session)
|
|
389
564
|
return;
|
|
390
|
-
void teardownSessionResources(session,
|
|
565
|
+
void teardownSessionResources(session, teardownOptions);
|
|
391
566
|
}
|
|
392
567
|
clearSessionInitTimeout(sessionId) {
|
|
393
568
|
if (!sessionId)
|
|
@@ -412,12 +587,14 @@ class McpSessionGateway {
|
|
|
412
587
|
},
|
|
413
588
|
});
|
|
414
589
|
if (!allowed) {
|
|
415
|
-
|
|
590
|
+
logWarn('Session capacity exhausted', { maxSessions: config.server.maxSessions }, 'session');
|
|
591
|
+
sendError(res, -32000, 'The server is currently too busy to handle your request. Please try again in a little while.', 503, requestId);
|
|
416
592
|
return false;
|
|
417
593
|
}
|
|
418
594
|
// Double-check: capacity may have changed during the async eviction window above.
|
|
419
595
|
if (!reserveSessionSlot(this.store, config.server.maxSessions)) {
|
|
420
|
-
|
|
596
|
+
logWarn('Session capacity exhausted (post-eviction)', { maxSessions: config.server.maxSessions }, 'session');
|
|
597
|
+
sendError(res, -32000, 'The server is currently too busy to handle your request. Please try again in a little while.', 503, requestId);
|
|
421
598
|
return false;
|
|
422
599
|
}
|
|
423
600
|
return true;
|
|
@@ -472,14 +649,23 @@ class HttpDispatcher {
|
|
|
472
649
|
const handled = await this.handleMcpRoutes(authCtx);
|
|
473
650
|
if (handled)
|
|
474
651
|
return;
|
|
652
|
+
ctx.res.setHeader('Allow', 'DELETE, GET, OPTIONS, POST');
|
|
653
|
+
sendJson(ctx.res, 405, {
|
|
654
|
+
error: "Looks like you tried to use a method that isn't allowed here.",
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
475
657
|
}
|
|
476
|
-
sendJson(ctx.res, 404, {
|
|
658
|
+
sendJson(ctx.res, 404, {
|
|
659
|
+
error: "We couldn't find what you were looking for.",
|
|
660
|
+
});
|
|
477
661
|
}
|
|
478
662
|
catch (err) {
|
|
479
663
|
const error = toError(err);
|
|
480
|
-
logError('Request failed', error);
|
|
664
|
+
logError('Request failed', error, 'http');
|
|
481
665
|
if (!ctx.res.writableEnded) {
|
|
482
|
-
sendJson(ctx.res, 500, {
|
|
666
|
+
sendJson(ctx.res, 500, {
|
|
667
|
+
error: "Something went wrong on our end. We're looking into it!",
|
|
668
|
+
});
|
|
483
669
|
}
|
|
484
670
|
}
|
|
485
671
|
}
|
|
@@ -504,6 +690,7 @@ class HttpDispatcher {
|
|
|
504
690
|
}
|
|
505
691
|
catch (err) {
|
|
506
692
|
const message = err instanceof Error ? err.message : 'Unauthorized';
|
|
693
|
+
logWarn('Authentication failed', { message, method: ctx.method, path: ctx.url.pathname }, 'auth');
|
|
507
694
|
if (isInsufficientScopeError(err)) {
|
|
508
695
|
applyInsufficientScopeAuthHeaders(ctx.req, ctx.res, err.requiredScopes, message);
|
|
509
696
|
sendError(ctx.res, -32000, message, 403);
|
|
@@ -521,21 +708,21 @@ class HttpDispatcher {
|
|
|
521
708
|
const DEFAULT_BODY_ERROR = {
|
|
522
709
|
statusCode: 400,
|
|
523
710
|
mcpCode: -32700,
|
|
524
|
-
mcpMsg: '
|
|
525
|
-
restMsg: '
|
|
711
|
+
mcpMsg: "We couldn't parse the request body. Please ensure it's valid JSON.",
|
|
712
|
+
restMsg: "The request body doesn't seem to be valid JSON. Please check and try again.",
|
|
526
713
|
};
|
|
527
714
|
const BODY_PARSE_ERRORS = {
|
|
528
715
|
'payload-too-large': {
|
|
529
716
|
statusCode: 413,
|
|
530
717
|
mcpCode: -32600,
|
|
531
|
-
mcpMsg: '
|
|
532
|
-
restMsg: '
|
|
718
|
+
mcpMsg: 'The request body is too large. Please send a smaller payload.',
|
|
719
|
+
restMsg: 'That request is a bit too big for us to handle right now.',
|
|
533
720
|
},
|
|
534
721
|
'read-failed': {
|
|
535
722
|
statusCode: 400,
|
|
536
723
|
mcpCode: -32600,
|
|
537
|
-
mcpMsg: '
|
|
538
|
-
restMsg: '
|
|
724
|
+
mcpMsg: 'We ran into an issue reading the request. Please try sending it again.',
|
|
725
|
+
restMsg: "The request body doesn't seem to be valid JSON. Please check and try again.",
|
|
539
726
|
},
|
|
540
727
|
default: DEFAULT_BODY_ERROR,
|
|
541
728
|
};
|
|
@@ -562,6 +749,18 @@ class HttpRequestPipeline {
|
|
|
562
749
|
const requestId = getHeaderValue(rawReq, 'x-request-id') ?? randomUUID();
|
|
563
750
|
const sessionId = getMcpSessionId(rawReq) ?? undefined;
|
|
564
751
|
const { signal, cleanup } = createRequestAbortSignal(rawReq);
|
|
752
|
+
const path = resolveRequestPath(rawReq);
|
|
753
|
+
const startTime = performance.now();
|
|
754
|
+
rawRes.once('finish', () => {
|
|
755
|
+
logRequestCompletion({
|
|
756
|
+
path,
|
|
757
|
+
statusCode: rawRes.statusCode,
|
|
758
|
+
durationMs: performance.now() - startTime,
|
|
759
|
+
requestId,
|
|
760
|
+
...(rawReq.method ? { method: rawReq.method } : {}),
|
|
761
|
+
...(sessionId ? { sessionId } : {}),
|
|
762
|
+
});
|
|
763
|
+
});
|
|
565
764
|
try {
|
|
566
765
|
await runWithRequestContext({
|
|
567
766
|
requestId,
|
|
@@ -588,8 +787,16 @@ class HttpRequestPipeline {
|
|
|
588
787
|
const duplicateHeader = findDuplicateSingleValueHeader(rawReq);
|
|
589
788
|
if (!duplicateHeader)
|
|
590
789
|
return false;
|
|
790
|
+
logGatewayRejection({
|
|
791
|
+
message: 'Rejected HTTP request',
|
|
792
|
+
method: rawReq.method,
|
|
793
|
+
path: resolveRequestPath(rawReq),
|
|
794
|
+
reason: 'duplicate_single_value_header',
|
|
795
|
+
status: 400,
|
|
796
|
+
details: { header: duplicateHeader },
|
|
797
|
+
});
|
|
591
798
|
sendJson(rawRes, 400, {
|
|
592
|
-
error: `
|
|
799
|
+
error: `It seems the '${duplicateHeader}' header was sent multiple times when it should only be sent once.`,
|
|
593
800
|
});
|
|
594
801
|
drainRequest(rawReq);
|
|
595
802
|
return true;
|
|
@@ -611,7 +818,9 @@ class HttpRequestPipeline {
|
|
|
611
818
|
return false;
|
|
612
819
|
}
|
|
613
820
|
if (!this.rateLimiter.check(ctx)) {
|
|
614
|
-
sendJson(ctx.res, 429, {
|
|
821
|
+
sendJson(ctx.res, 429, {
|
|
822
|
+
error: "You're sending requests a bit too quickly. Please slow down and try again.",
|
|
823
|
+
});
|
|
615
824
|
drainRequest(rawReq);
|
|
616
825
|
return false;
|
|
617
826
|
}
|
|
@@ -628,6 +837,12 @@ class HttpRequestPipeline {
|
|
|
628
837
|
}
|
|
629
838
|
catch (error) {
|
|
630
839
|
const bodyErrorKind = isJsonBodyError(error) ? error.kind : null;
|
|
840
|
+
if (bodyErrorKind === 'payload-too-large') {
|
|
841
|
+
logWarn('The request body is too large. Please send a smaller payload.', { method: ctx.method, path: ctx.url.pathname }, 'http');
|
|
842
|
+
}
|
|
843
|
+
else if (bodyErrorKind === 'read-failed' || bodyErrorKind === null) {
|
|
844
|
+
logError('Request body parsing failed', toError(error), 'http');
|
|
845
|
+
}
|
|
631
846
|
sendBodyParseError(ctx, bodyErrorKind, rawReq);
|
|
632
847
|
return false;
|
|
633
848
|
}
|
|
@@ -648,11 +863,13 @@ class HttpRequestPipeline {
|
|
|
648
863
|
// Server bootstrap
|
|
649
864
|
// ---------------------------------------------------------------------------
|
|
650
865
|
function handlePipelineError(error, res) {
|
|
651
|
-
logError('Request pipeline failed', toError(error));
|
|
866
|
+
logError('Request pipeline failed', toError(error), 'http');
|
|
652
867
|
if (res.writableEnded)
|
|
653
868
|
return;
|
|
654
869
|
if (!res.headersSent) {
|
|
655
|
-
sendJson(res, 500, {
|
|
870
|
+
sendJson(res, 500, {
|
|
871
|
+
error: "Something went wrong on our end. We're looking into it!",
|
|
872
|
+
});
|
|
656
873
|
return;
|
|
657
874
|
}
|
|
658
875
|
res.end();
|
|
@@ -694,7 +911,7 @@ function resolveListeningPort(server, fallback) {
|
|
|
694
911
|
function createShutdownHandler(options) {
|
|
695
912
|
const closeBatchSize = 10;
|
|
696
913
|
return async (signal) => {
|
|
697
|
-
logInfo(`Stopping HTTP server (${signal})
|
|
914
|
+
logInfo(`Stopping HTTP server (${signal})...`, undefined, 'http');
|
|
698
915
|
options.rateLimiter.stop();
|
|
699
916
|
options.sessionCleanup.abort();
|
|
700
917
|
drainConnectionsOnShutdown(options.server);
|
|
@@ -707,7 +924,7 @@ function createShutdownHandler(options) {
|
|
|
707
924
|
}));
|
|
708
925
|
for (const r of results) {
|
|
709
926
|
if (r.status === 'rejected') {
|
|
710
|
-
logError('Session teardown failed during shutdown', r.reason instanceof Error ? r.reason : undefined);
|
|
927
|
+
logError('Session teardown failed during shutdown', r.reason instanceof Error ? r.reason : undefined, 'http');
|
|
711
928
|
}
|
|
712
929
|
}
|
|
713
930
|
}
|
|
@@ -722,7 +939,7 @@ export async function startHttpServer() {
|
|
|
722
939
|
const sessionStore = createSessionStore(config.server.sessionTtlMs);
|
|
723
940
|
const sessionCleanup = startSessionCleanupLoop(sessionStore, config.server.sessionTtlMs, {
|
|
724
941
|
onEvictSession: (session) => {
|
|
725
|
-
teardownSessionRegistration(session.server
|
|
942
|
+
teardownSessionRegistration(session.server);
|
|
726
943
|
},
|
|
727
944
|
});
|
|
728
945
|
const mcpGateway = new McpSessionGateway(sessionStore, createMcpServerForHttpSession);
|
|
@@ -743,7 +960,7 @@ export async function startHttpServer() {
|
|
|
743
960
|
arch: process.arch,
|
|
744
961
|
hostname: hostname(),
|
|
745
962
|
nodeVersion: process.version,
|
|
746
|
-
});
|
|
963
|
+
}, 'http');
|
|
747
964
|
return {
|
|
748
965
|
port,
|
|
749
966
|
host: config.server.host,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/http/rate-limit.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,cAAc,EAAY,MAAM,cAAc,CAAC;AAY7D,UAAU,eAAe;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC;IACpC,IAAI,IAAI,IAAI,CAAC;CACd;
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/http/rate-limit.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,cAAc,EAAY,MAAM,cAAc,CAAC;AAY7D,UAAU,eAAe;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC;IACpC,IAAI,IAAI,IAAI,CAAC;CACd;AA2FD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,eAAe,GACvB,oBAAoB,CAEtB"}
|
package/dist/http/rate-limit.js
CHANGED
|
@@ -21,7 +21,7 @@ class RateLimiter {
|
|
|
21
21
|
},
|
|
22
22
|
onError: (err) => {
|
|
23
23
|
if (!isAbortError(err)) {
|
|
24
|
-
logWarn('Rate limit cleanup failed', { error: err });
|
|
24
|
+
logWarn('Rate limit cleanup failed', { error: err }, 'rate-limit');
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
});
|
|
@@ -71,6 +71,7 @@ class RateLimiter {
|
|
|
71
71
|
this.store.set(key, entry);
|
|
72
72
|
}
|
|
73
73
|
if (entry.count > this.options.maxRequests) {
|
|
74
|
+
logWarn('Rate limit exceeded', { ip: key }, 'rate-limit');
|
|
74
75
|
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
75
76
|
ctx.res.setHeader('Retry-After', String(retryAfter));
|
|
76
77
|
sendJson(ctx.res, 429, { error: 'Rate limit exceeded', retryAfter });
|