@j0hanz/filesystem-mcp 1.16.2 → 1.17.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/README.md +18 -14
- package/dist/assets/logo.svg +26 -11
- package/dist/cli.js +1 -1
- package/dist/completions.d.ts +1 -1
- package/dist/completions.js +1 -2
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -3
- package/dist/lib/fs-helpers.d.ts +1 -1
- package/dist/lib/fs-helpers.js +0 -1
- package/dist/lib/logger.d.ts +3 -4
- package/dist/lib/logger.js +1 -1
- package/dist/lib/paths.d.ts +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +11 -11
- package/dist/resources/workflows.js +12 -0
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +1 -1
- package/dist/schemas.d.ts +4 -4
- package/dist/server/bootstrap.d.ts +1 -10
- package/dist/server/bootstrap.js +165 -95
- package/dist/server/roots-manager.d.ts +3 -2
- package/dist/server/roots-manager.js +30 -4
- package/dist/server/task-store.d.ts +2 -3
- package/dist/server/task-store.js +1 -1
- package/dist/tools/apply-patch.d.ts +1 -1
- package/dist/tools/apply-patch.js +16 -12
- package/dist/tools/calculate-hash.d.ts +1 -1
- package/dist/tools/calculate-hash.js +8 -12
- package/dist/tools/contract.d.ts +5 -0
- package/dist/tools/create-directory.d.ts +1 -1
- package/dist/tools/create-directory.js +7 -10
- package/dist/tools/delete-file.d.ts +1 -1
- package/dist/tools/delete-file.js +12 -11
- package/dist/tools/diff-files.d.ts +1 -1
- package/dist/tools/diff-files.js +8 -11
- package/dist/tools/edit-file.d.ts +1 -1
- package/dist/tools/edit-file.js +12 -11
- package/dist/tools/icons.d.ts +15 -0
- package/dist/tools/icons.js +24 -0
- package/dist/tools/list-directory.d.ts +1 -1
- package/dist/tools/list-directory.js +7 -10
- package/dist/tools/move-file.d.ts +1 -1
- package/dist/tools/move-file.js +12 -11
- package/dist/tools/read-multiple.d.ts +1 -1
- package/dist/tools/read-multiple.js +8 -12
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +7 -10
- package/dist/tools/replace-in-files.d.ts +1 -1
- package/dist/tools/replace-in-files.js +11 -13
- package/dist/tools/roots.d.ts +1 -1
- package/dist/tools/roots.js +7 -10
- package/dist/tools/search-content.d.ts +1 -1
- package/dist/tools/search-content.js +8 -13
- package/dist/tools/search-files.d.ts +1 -1
- package/dist/tools/search-files.js +11 -16
- package/dist/tools/shared.d.ts +15 -12
- package/dist/tools/shared.js +84 -56
- package/dist/tools/stat-many.d.ts +1 -1
- package/dist/tools/stat-many.js +8 -12
- package/dist/tools/stat.d.ts +1 -1
- package/dist/tools/stat.js +7 -10
- package/dist/tools/task-support.d.ts +14 -12
- package/dist/tools/task-support.js +98 -92
- package/dist/tools/tree.d.ts +1 -1
- package/dist/tools/tree.js +11 -15
- package/dist/tools/write-file.d.ts +1 -1
- package/dist/tools/write-file.js +12 -11
- package/dist/tools.d.ts +1 -1
- package/package.json +6 -4
package/dist/server/bootstrap.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
3
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
-
import { isInitializeRequest, LATEST_PROTOCOL_VERSION, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
|
2
|
+
import { InMemoryTaskMessageQueue, isInitializeRequest, LATEST_PROTOCOL_VERSION, localhostAllowedHostnames, McpServer, StdioServerTransport, SUPPORTED_PROTOCOL_VERSIONS, validateHostHeader, } from '@modelcontextprotocol/server';
|
|
6
3
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
7
4
|
import { channel } from 'node:diagnostics_channel';
|
|
8
5
|
import { readFile } from 'node:fs/promises';
|
|
9
6
|
import { createServer as createHttpServer, } from 'node:http';
|
|
10
|
-
import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
|
|
7
|
+
import { DEFAULT_LOG_LEVEL, INIT_HANDSHAKE_TIMEOUT_MS, INIT_TIMEOUT_CLOSE, parseEnvInt, } from '../lib/constants.js';
|
|
11
8
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
12
9
|
import { createLoggingState, Logger, logToMcp, SessionContext, } from '../lib/logger.js';
|
|
13
10
|
import { withAllowedDirectoriesState } from '../lib/paths.js';
|
|
@@ -21,28 +18,6 @@ import { registerAllTools } from '../tools.js';
|
|
|
21
18
|
import { withDefaultIcons } from '../tools/shared.js';
|
|
22
19
|
import { RootsManager } from './roots-manager.js';
|
|
23
20
|
import { createTaskStore } from './task-store.js';
|
|
24
|
-
let cachedTaskToolSupport;
|
|
25
|
-
function detectTaskToolSupport() {
|
|
26
|
-
if (cachedTaskToolSupport !== undefined) {
|
|
27
|
-
return cachedTaskToolSupport;
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
// Instantiate a minimal, unconnected probe server to duck-type check for
|
|
31
|
-
// task tool support. The probe has no transport or active connections, so
|
|
32
|
-
// close() only releases in-memory state; fire-and-forget is safe here.
|
|
33
|
-
const probe = new McpServer({
|
|
34
|
-
name: 'filesystem-mcp-capability-probe',
|
|
35
|
-
version: '0.0.0',
|
|
36
|
-
}, { capabilities: { tools: {} } });
|
|
37
|
-
cachedTaskToolSupport =
|
|
38
|
-
typeof probe.experimental.tasks.registerToolTask === 'function';
|
|
39
|
-
probe.close().catch(() => { });
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
cachedTaskToolSupport = false;
|
|
43
|
-
}
|
|
44
|
-
return cachedTaskToolSupport;
|
|
45
|
-
}
|
|
46
21
|
function buildServerCapabilities(options = {}) {
|
|
47
22
|
const capabilities = {
|
|
48
23
|
logging: {},
|
|
@@ -64,13 +39,10 @@ function buildServerCapabilities(options = {}) {
|
|
|
64
39
|
}
|
|
65
40
|
return capabilities;
|
|
66
41
|
}
|
|
67
|
-
function supportsTaskToolRequests() {
|
|
68
|
-
return detectTaskToolSupport();
|
|
69
|
-
}
|
|
70
42
|
// Global map of all active servers by sessionId for routing logs
|
|
71
|
-
|
|
43
|
+
const activeServers = new Map();
|
|
72
44
|
// For stdio (single session without a specific ID)
|
|
73
|
-
|
|
45
|
+
let stdioServer;
|
|
74
46
|
function stringifyData(data) {
|
|
75
47
|
if (!data)
|
|
76
48
|
return '';
|
|
@@ -123,18 +95,18 @@ export async function createServer(options = {}) {
|
|
|
123
95
|
const resourceStore = createInMemoryResourceStore();
|
|
124
96
|
const serverInstructions = buildServerInstructions();
|
|
125
97
|
const localIcon = await getLocalIconInfo();
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
serverConfig.taskMessageQueue = new InMemoryTaskMessageQueue();
|
|
98
|
+
const capabilities = buildServerCapabilities({
|
|
99
|
+
enablePromptListChanged: false,
|
|
100
|
+
enableTaskToolRequests: true,
|
|
101
|
+
});
|
|
102
|
+
if (capabilities.tasks) {
|
|
103
|
+
capabilities.tasks = {
|
|
104
|
+
...capabilities.tasks,
|
|
105
|
+
taskStore: createTaskStore(),
|
|
106
|
+
taskMessageQueue: new InMemoryTaskMessageQueue(),
|
|
107
|
+
};
|
|
137
108
|
}
|
|
109
|
+
const serverConfig = { capabilities };
|
|
138
110
|
if (serverInstructions) {
|
|
139
111
|
serverConfig.instructions =
|
|
140
112
|
'filesystem-mcp: Secure local filesystem MCP server. ' +
|
|
@@ -153,7 +125,7 @@ export async function createServer(options = {}) {
|
|
|
153
125
|
rootsManagers.set(server, rootsManager);
|
|
154
126
|
// Subscribe to Logger channel if not already done, but we need to route based on session or fallback to this server if it's stdio.
|
|
155
127
|
// Wait, in stdio there's only one server. In HTTP there are multiple.
|
|
156
|
-
server.server.setRequestHandler(
|
|
128
|
+
server.server.setRequestHandler('logging/setLevel', (req) => {
|
|
157
129
|
loggingState.minimumLevel = req.params.level;
|
|
158
130
|
Logger.notice(`Log level set to ${req.params.level}`);
|
|
159
131
|
return {};
|
|
@@ -181,7 +153,11 @@ export async function createServer(options = {}) {
|
|
|
181
153
|
export async function startServer(server) {
|
|
182
154
|
const transport = new StdioServerTransport();
|
|
183
155
|
const rootsManager = getRootsManager(server);
|
|
184
|
-
rootsManager.registerHandlers(server
|
|
156
|
+
rootsManager.registerHandlers(server, INIT_TIMEOUT_CLOSE
|
|
157
|
+
? () => {
|
|
158
|
+
void server.close();
|
|
159
|
+
}
|
|
160
|
+
: undefined);
|
|
185
161
|
await rootsManager.recomputeAllowedDirectories();
|
|
186
162
|
await server.connect(transport);
|
|
187
163
|
const sdkOnClose = transport.onclose;
|
|
@@ -241,7 +217,23 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
241
217
|
const rootsManager = getRootsManager(mcpServer);
|
|
242
218
|
rootsManager.registerHandlers(mcpServer);
|
|
243
219
|
await rootsManager.recomputeAllowedDirectories();
|
|
244
|
-
|
|
220
|
+
let cleanedUp = false;
|
|
221
|
+
const cleanup = () => {
|
|
222
|
+
if (cleanedUp)
|
|
223
|
+
return;
|
|
224
|
+
cleanedUp = true;
|
|
225
|
+
const { sessionId } = transport;
|
|
226
|
+
if (sessionId) {
|
|
227
|
+
sessions.delete(sessionId);
|
|
228
|
+
activeServers.delete(sessionId);
|
|
229
|
+
}
|
|
230
|
+
rootsManager.destroy();
|
|
231
|
+
};
|
|
232
|
+
const close = async () => {
|
|
233
|
+
cleanup();
|
|
234
|
+
await mcpServer.close();
|
|
235
|
+
};
|
|
236
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
245
237
|
sessionIdGenerator: () => randomUUID(),
|
|
246
238
|
onsessioninitialized: (sessionId) => {
|
|
247
239
|
sessions.set(sessionId, {
|
|
@@ -249,6 +241,9 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
249
241
|
rootsManager,
|
|
250
242
|
transport,
|
|
251
243
|
negotiatedProtocolVersion,
|
|
244
|
+
createdAt: Date.now(),
|
|
245
|
+
cleanup,
|
|
246
|
+
close,
|
|
252
247
|
});
|
|
253
248
|
activeServers.set(sessionId, {
|
|
254
249
|
server: mcpServer,
|
|
@@ -257,23 +252,16 @@ async function createHttpSession(options, sessions, negotiatedProtocolVersion) {
|
|
|
257
252
|
rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
|
|
258
253
|
},
|
|
259
254
|
});
|
|
260
|
-
transport.onclose =
|
|
261
|
-
const { sessionId } = transport;
|
|
262
|
-
if (sessionId) {
|
|
263
|
-
sessions.delete(sessionId);
|
|
264
|
-
activeServers.delete(sessionId);
|
|
265
|
-
}
|
|
266
|
-
rootsManager.destroy();
|
|
267
|
-
mcpServer.close().catch((err) => {
|
|
268
|
-
Logger.error('[HTTP] Error closing MCP server:', formatUnknownErrorMessage(err));
|
|
269
|
-
});
|
|
270
|
-
};
|
|
255
|
+
transport.onclose = cleanup;
|
|
271
256
|
await mcpServer.connect(transport);
|
|
272
257
|
return {
|
|
273
258
|
server: mcpServer,
|
|
274
259
|
rootsManager,
|
|
275
260
|
transport,
|
|
276
261
|
negotiatedProtocolVersion,
|
|
262
|
+
createdAt: Date.now(),
|
|
263
|
+
cleanup,
|
|
264
|
+
close,
|
|
277
265
|
};
|
|
278
266
|
}
|
|
279
267
|
function sendJsonRpcError(res, status, code, message) {
|
|
@@ -296,6 +284,24 @@ function isAllowedOrigin(origin) {
|
|
|
296
284
|
return true; // Non-browser clients omit Origin.
|
|
297
285
|
return LOCALHOST_ORIGIN_RE.test(origin);
|
|
298
286
|
}
|
|
287
|
+
function normalizeAllowedHostname(host) {
|
|
288
|
+
const trimmed = host.trim().toLowerCase();
|
|
289
|
+
if (trimmed === '::1')
|
|
290
|
+
return '[::1]';
|
|
291
|
+
return trimmed;
|
|
292
|
+
}
|
|
293
|
+
function getAllowedHostnames(httpHost) {
|
|
294
|
+
if (isLoopbackHttpHost(httpHost)) {
|
|
295
|
+
return localhostAllowedHostnames().map(normalizeAllowedHostname);
|
|
296
|
+
}
|
|
297
|
+
const normalizedHost = normalizeAllowedHostname(httpHost);
|
|
298
|
+
if (normalizedHost === '0.0.0.0' ||
|
|
299
|
+
normalizedHost === '::' ||
|
|
300
|
+
normalizedHost === '[::]') {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
return [normalizedHost];
|
|
304
|
+
}
|
|
299
305
|
function getSessionId(req) {
|
|
300
306
|
const rawSessionId = req.headers['mcp-session-id'];
|
|
301
307
|
return typeof rawSessionId === 'string' &&
|
|
@@ -359,6 +365,26 @@ function ensureAuthorizedRequest(req, res) {
|
|
|
359
365
|
writeUnauthorizedResponse(res);
|
|
360
366
|
return false;
|
|
361
367
|
}
|
|
368
|
+
function ensureAllowedOrigin(req, res) {
|
|
369
|
+
const { origin } = req.headers;
|
|
370
|
+
if (isAllowedOrigin(origin)) {
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
sendJsonRpcError(res, 403, JSON_RPC_SERVER_ERROR, 'Forbidden: disallowed origin');
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
function ensureAllowedHostHeader(req, res, httpHost) {
|
|
377
|
+
const allowedHostnames = getAllowedHostnames(httpHost);
|
|
378
|
+
if (!allowedHostnames || allowedHostnames.length === 0) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const hostHeader = typeof req.headers['host'] === 'string' ? req.headers['host'] : undefined;
|
|
382
|
+
const result = validateHostHeader(hostHeader, allowedHostnames);
|
|
383
|
+
if (result.ok)
|
|
384
|
+
return true;
|
|
385
|
+
sendJsonRpcError(res, 403, JSON_RPC_SERVER_ERROR, `Forbidden: ${result.message}`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
362
388
|
function discardRequestBody(req) {
|
|
363
389
|
req.on('error', () => {
|
|
364
390
|
// Best effort drain to avoid corrupting keep-alive pipelines.
|
|
@@ -395,10 +421,49 @@ function assertHttpBindingSecurity(host) {
|
|
|
395
421
|
return;
|
|
396
422
|
throw new Error(`Refusing to bind HTTP server to non-loopback host '${host}' without FILESYSTEM_MCP_API_KEY.`);
|
|
397
423
|
}
|
|
424
|
+
function writeMethodNotAllowedResponse(res) {
|
|
425
|
+
res.writeHead(405, {
|
|
426
|
+
Allow: 'GET, POST, DELETE',
|
|
427
|
+
'Content-Type': 'application/json',
|
|
428
|
+
});
|
|
429
|
+
res.end(JSON.stringify({
|
|
430
|
+
jsonrpc: '2.0',
|
|
431
|
+
error: {
|
|
432
|
+
code: JSON_RPC_SERVER_ERROR,
|
|
433
|
+
message: 'Method Not Allowed',
|
|
434
|
+
},
|
|
435
|
+
id: null,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
function handleHttpRequestError(error, res) {
|
|
439
|
+
if (error instanceof RequestBodyError && !res.headersSent) {
|
|
440
|
+
const rpcCode = error.statusCode === 413
|
|
441
|
+
? JSON_RPC_INVALID_REQUEST
|
|
442
|
+
: JSON_RPC_PARSE_ERROR;
|
|
443
|
+
res.setHeader('Connection', 'close');
|
|
444
|
+
sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
Logger.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
|
|
448
|
+
if (!res.headersSent) {
|
|
449
|
+
sendJsonRpcError(res, 500, JSON_RPC_INTERNAL_ERROR, 'Internal Server Error');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
398
452
|
export async function startHttpServer(port, options) {
|
|
399
453
|
const sessions = new Map();
|
|
400
454
|
const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
|
|
401
455
|
assertHttpBindingSecurity(httpHost);
|
|
456
|
+
let closingSessions;
|
|
457
|
+
async function closeAllSessions() {
|
|
458
|
+
if (closingSessions)
|
|
459
|
+
return closingSessions;
|
|
460
|
+
closingSessions = (async () => {
|
|
461
|
+
const activeSessions = [...sessions.values()];
|
|
462
|
+
sessions.clear();
|
|
463
|
+
await Promise.allSettled(activeSessions.map((session) => session.close()));
|
|
464
|
+
})();
|
|
465
|
+
await closingSessions;
|
|
466
|
+
}
|
|
402
467
|
async function handlePostRequest(req, res, sessionId) {
|
|
403
468
|
if (sessionId) {
|
|
404
469
|
const session = getSessionOrRespondNotFound(sessions, sessionId, res);
|
|
@@ -440,53 +505,48 @@ export async function startHttpServer(port, options) {
|
|
|
440
505
|
return;
|
|
441
506
|
await handleSessionTransportRequest(session, req, res);
|
|
442
507
|
}
|
|
508
|
+
async function dispatchMcpMethod(method, req, res, sessionId) {
|
|
509
|
+
switch (method) {
|
|
510
|
+
case 'POST':
|
|
511
|
+
await handlePostRequest(req, res, sessionId);
|
|
512
|
+
return;
|
|
513
|
+
case 'GET':
|
|
514
|
+
case 'DELETE':
|
|
515
|
+
await handleGetDeleteRequest(req, res, sessionId);
|
|
516
|
+
return;
|
|
517
|
+
default:
|
|
518
|
+
writeMethodNotAllowedResponse(res);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
443
521
|
async function handleMcpRequest(req, res) {
|
|
444
|
-
const { method } = req;
|
|
445
522
|
const sessionId = getSessionId(req);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
523
|
+
if (!ensureAllowedOrigin(req, res))
|
|
524
|
+
return;
|
|
525
|
+
if (!ensureAllowedHostHeader(req, res, httpHost))
|
|
449
526
|
return;
|
|
450
|
-
}
|
|
451
527
|
if (!ensureAuthorizedRequest(req, res))
|
|
452
528
|
return;
|
|
453
529
|
try {
|
|
454
|
-
|
|
455
|
-
await handlePostRequest(req, res, sessionId);
|
|
456
|
-
}
|
|
457
|
-
else if (method === 'GET' || method === 'DELETE') {
|
|
458
|
-
await handleGetDeleteRequest(req, res, sessionId);
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
res.writeHead(405, {
|
|
462
|
-
Allow: 'GET, POST, DELETE',
|
|
463
|
-
'Content-Type': 'application/json',
|
|
464
|
-
});
|
|
465
|
-
res.end(JSON.stringify({
|
|
466
|
-
jsonrpc: '2.0',
|
|
467
|
-
error: {
|
|
468
|
-
code: JSON_RPC_SERVER_ERROR,
|
|
469
|
-
message: 'Method Not Allowed',
|
|
470
|
-
},
|
|
471
|
-
id: null,
|
|
472
|
-
}));
|
|
473
|
-
}
|
|
530
|
+
await dispatchMcpMethod(req.method, req, res, sessionId);
|
|
474
531
|
}
|
|
475
532
|
catch (error) {
|
|
476
|
-
|
|
477
|
-
const rpcCode = error.statusCode === 413
|
|
478
|
-
? JSON_RPC_INVALID_REQUEST
|
|
479
|
-
: JSON_RPC_PARSE_ERROR;
|
|
480
|
-
res.setHeader('Connection', 'close');
|
|
481
|
-
sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
Logger.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
|
|
485
|
-
if (!res.headersSent) {
|
|
486
|
-
sendJsonRpcError(res, 500, JSON_RPC_INTERNAL_ERROR, 'Internal Server Error');
|
|
487
|
-
}
|
|
533
|
+
handleHttpRequestError(error, res);
|
|
488
534
|
}
|
|
489
535
|
}
|
|
536
|
+
const SWEEP_INTERVAL_MS = INIT_HANDSHAKE_TIMEOUT_MS * 2;
|
|
537
|
+
const sweepTimer = setInterval(() => {
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
for (const [sessionId, session] of sessions) {
|
|
540
|
+
if (!session.rootsManager.isInitialized() &&
|
|
541
|
+
now - session.createdAt > INIT_HANDSHAKE_TIMEOUT_MS) {
|
|
542
|
+
Logger.warn(`[HTTP] Evicting stale session ${sessionId}: client never sent notifications/initialized`);
|
|
543
|
+
session.server.close().catch((err) => {
|
|
544
|
+
Logger.error(`[HTTP] Error closing stale session ${sessionId}:`, formatUnknownErrorMessage(err));
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}, SWEEP_INTERVAL_MS);
|
|
549
|
+
sweepTimer.unref();
|
|
490
550
|
const httpServer = createHttpServer((req, res) => {
|
|
491
551
|
const urlPath = (req.url ?? '/').split('?')[0];
|
|
492
552
|
if (urlPath === '/mcp') {
|
|
@@ -499,6 +559,16 @@ export async function startHttpServer(port, options) {
|
|
|
499
559
|
res.end('Not Found');
|
|
500
560
|
}
|
|
501
561
|
});
|
|
562
|
+
httpServer.once('close', () => {
|
|
563
|
+
clearInterval(sweepTimer);
|
|
564
|
+
});
|
|
565
|
+
const originalClose = httpServer.close.bind(httpServer);
|
|
566
|
+
httpServer.close = ((callback) => {
|
|
567
|
+
void closeAllSessions().catch((error) => {
|
|
568
|
+
Logger.error('[HTTP] Error closing sessions before server shutdown:', formatUnknownErrorMessage(error));
|
|
569
|
+
});
|
|
570
|
+
return originalClose(callback);
|
|
571
|
+
});
|
|
502
572
|
return new Promise((resolve, reject) => {
|
|
503
573
|
httpServer.once('error', reject);
|
|
504
574
|
httpServer.listen(port, httpHost, () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { type LoggingState } from '../lib/logger.js';
|
|
3
3
|
import { type AllowedDirectoriesState } from '../lib/paths.js';
|
|
4
4
|
export interface ServerOptions {
|
|
@@ -10,6 +10,7 @@ export declare class RootsManager {
|
|
|
10
10
|
private rootDirectories;
|
|
11
11
|
private allowedDirectoriesState;
|
|
12
12
|
private clientInitialized;
|
|
13
|
+
private initTimer;
|
|
13
14
|
private updatingRoots;
|
|
14
15
|
private pendingRootsUpdate;
|
|
15
16
|
private readonly options;
|
|
@@ -19,7 +20,7 @@ export declare class RootsManager {
|
|
|
19
20
|
destroy(): void;
|
|
20
21
|
getAllowedDirectoriesState(): AllowedDirectoriesState;
|
|
21
22
|
logMissingDirectoriesIfNeeded(server: McpServer): void;
|
|
22
|
-
registerHandlers(server: McpServer): void;
|
|
23
|
+
registerHandlers(server: McpServer, onInitTimeout?: () => void): void;
|
|
23
24
|
recomputeAllowedDirectories(): Promise<void>;
|
|
24
25
|
private scheduleRootsUpdate;
|
|
25
26
|
private logMissingDirectories;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {} from '@modelcontextprotocol/server';
|
|
2
|
+
import { channel } from 'node:diagnostics_channel';
|
|
2
3
|
import { realpath } from 'node:fs/promises';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/abort.js';
|
|
6
|
+
import { INIT_HANDSHAKE_TIMEOUT_MS } from '../lib/constants.js';
|
|
5
7
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
6
8
|
import { Logger, logToMcp } from '../lib/logger.js';
|
|
7
9
|
import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
|
|
8
10
|
import { debounce, isRecord } from '../lib/utils.js';
|
|
9
11
|
const ROOTS_TIMEOUT_MS = 5000;
|
|
10
12
|
const ROOTS_DEBOUNCE_MS = 100;
|
|
13
|
+
const LIFECYCLE_CHANNEL = channel('filesystem-mcp:lifecycle');
|
|
11
14
|
function normalizeCLIDirectories(dirs) {
|
|
12
15
|
const normalized = [];
|
|
13
16
|
for (const dir of dirs) {
|
|
@@ -88,6 +91,7 @@ export class RootsManager {
|
|
|
88
91
|
expanded: [],
|
|
89
92
|
};
|
|
90
93
|
clientInitialized = false;
|
|
94
|
+
initTimer;
|
|
91
95
|
// Guard concurrent root refreshes; if one is already running we queue one
|
|
92
96
|
// replay so the last-known state still gets applied after completion.
|
|
93
97
|
updatingRoots = false;
|
|
@@ -103,6 +107,10 @@ export class RootsManager {
|
|
|
103
107
|
return this.clientInitialized;
|
|
104
108
|
}
|
|
105
109
|
destroy() {
|
|
110
|
+
if (this.initTimer) {
|
|
111
|
+
clearTimeout(this.initTimer);
|
|
112
|
+
this.initTimer = undefined;
|
|
113
|
+
}
|
|
106
114
|
if (this._debouncedUpdate) {
|
|
107
115
|
this._debouncedUpdate.cancel();
|
|
108
116
|
this._debouncedUpdate = undefined;
|
|
@@ -119,16 +127,34 @@ export class RootsManager {
|
|
|
119
127
|
this.logMissingDirectories(server);
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
|
-
registerHandlers(server) {
|
|
123
|
-
server.server.setNotificationHandler(
|
|
130
|
+
registerHandlers(server, onInitTimeout) {
|
|
131
|
+
server.server.setNotificationHandler('notifications/initialized', async () => {
|
|
132
|
+
if (this.initTimer) {
|
|
133
|
+
clearTimeout(this.initTimer);
|
|
134
|
+
this.initTimer = undefined;
|
|
135
|
+
}
|
|
124
136
|
this.clientInitialized = true;
|
|
125
137
|
await this.updateRootsFromClient(server);
|
|
126
138
|
});
|
|
127
|
-
server.server.setNotificationHandler(
|
|
139
|
+
server.server.setNotificationHandler('notifications/roots/list_changed', () => {
|
|
128
140
|
if (!this.clientInitialized)
|
|
129
141
|
return;
|
|
130
142
|
this.scheduleRootsUpdate(server);
|
|
131
143
|
});
|
|
144
|
+
this.initTimer = setTimeout(() => {
|
|
145
|
+
if (!this.clientInitialized) {
|
|
146
|
+
if (LIFECYCLE_CHANNEL.hasSubscribers) {
|
|
147
|
+
LIFECYCLE_CHANNEL.publish({
|
|
148
|
+
phase: 'init_timeout',
|
|
149
|
+
timeoutMs: INIT_HANDSHAKE_TIMEOUT_MS,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
logToMcp(server, 'warning', `Client did not send notifications/initialized within ${String(INIT_HANDSHAKE_TIMEOUT_MS)}ms`, this.loggingState.minimumLevel);
|
|
153
|
+
onInitTimeout?.();
|
|
154
|
+
}
|
|
155
|
+
this.initTimer = undefined;
|
|
156
|
+
}, INIT_HANDSHAKE_TIMEOUT_MS);
|
|
157
|
+
this.initTimer.unref();
|
|
132
158
|
}
|
|
133
159
|
async recomputeAllowedDirectories() {
|
|
134
160
|
const cliAllowedDirs = normalizeCLIDirectories(this.options.cliAllowedDirs ?? []);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { InMemoryTaskStore } from '@modelcontextprotocol/
|
|
2
|
-
import type { Result } from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { InMemoryTaskStore, type Result, type Task } from '@modelcontextprotocol/server';
|
|
3
2
|
export declare class ResultAwareInMemoryTaskStore extends InMemoryTaskStore {
|
|
4
3
|
private readonly cancelledResults;
|
|
5
4
|
getTaskResult(taskId: string, sessionId?: string): Promise<Result>;
|
|
6
5
|
storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void>;
|
|
7
|
-
updateTaskStatus(taskId: string, status: '
|
|
6
|
+
updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void>;
|
|
8
7
|
cleanup(): void;
|
|
9
8
|
}
|
|
10
9
|
export declare function createTaskStore(): ResultAwareInMemoryTaskStore;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InMemoryTaskStore } from '@modelcontextprotocol/
|
|
1
|
+
import { InMemoryTaskStore, } from '@modelcontextprotocol/server';
|
|
2
2
|
import { ErrorCode } from '../lib/errors.js';
|
|
3
3
|
const DEFAULT_CANCELLED_STATUS_MESSAGE = 'Client cancelled task execution.';
|
|
4
4
|
function getTaskKey(taskId, sessionId) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
|
|
3
3
|
export declare const APPLY_PATCH_TOOL: ToolContract;
|
|
4
4
|
export declare function registerApplyPatchTool(server: McpServer, options?: ToolRegistrationOptions): void;
|
|
@@ -8,8 +8,9 @@ import { atomicWriteFile, processInParallel } from '../lib/fs-helpers.js';
|
|
|
8
8
|
import { Logger } from '../lib/logger.js';
|
|
9
9
|
import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
|
|
10
10
|
import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { FILE_EDIT_ICONS } from './icons.js';
|
|
12
|
+
import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, } from './shared.js';
|
|
13
|
+
import { registerStandardTool } from './task-support.js';
|
|
13
14
|
export const APPLY_PATCH_TOOL = {
|
|
14
15
|
name: 'apply_patch',
|
|
15
16
|
title: 'Apply Patch',
|
|
@@ -20,10 +21,11 @@ export const APPLY_PATCH_TOOL = {
|
|
|
20
21
|
inputSchema: ApplyPatchInputSchema,
|
|
21
22
|
outputSchema: ApplyPatchOutputSchema,
|
|
22
23
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
24
|
+
icons: FILE_EDIT_ICONS,
|
|
23
25
|
nuances: [
|
|
24
26
|
'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
|
|
25
27
|
],
|
|
26
|
-
taskSupport: '
|
|
28
|
+
taskSupport: 'optional',
|
|
27
29
|
};
|
|
28
30
|
function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
|
|
29
31
|
if (size <= maxFileSize)
|
|
@@ -187,17 +189,23 @@ async function handleApplyPatch(args, signal) {
|
|
|
187
189
|
});
|
|
188
190
|
}
|
|
189
191
|
export function registerApplyPatchTool(server, options = {}) {
|
|
190
|
-
const handler = (args,
|
|
192
|
+
const handler = (args, ctx) => executeToolWithDiagnostics({
|
|
191
193
|
toolName: 'apply_patch',
|
|
192
|
-
|
|
194
|
+
ctx,
|
|
193
195
|
outputSchema: ApplyPatchOutputSchema,
|
|
194
196
|
timedSignal: {},
|
|
195
197
|
context: { path: args.path },
|
|
196
|
-
run: (signal) =>
|
|
198
|
+
run: async (signal) => {
|
|
199
|
+
const result = await handleApplyPatch(args, signal);
|
|
200
|
+
if (!args.dryRun) {
|
|
201
|
+
const sc = result.structuredContent;
|
|
202
|
+
void ctx.log?.('info', `patch: ${args.path} (+${String(sc.linesAdded ?? 0)}/-${String(sc.linesRemoved ?? 0)})`);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
},
|
|
197
206
|
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
|
|
198
207
|
});
|
|
199
|
-
|
|
200
|
-
guard: options.isInitialized,
|
|
208
|
+
registerStandardTool(server, APPLY_PATCH_TOOL, handler, options, {
|
|
201
209
|
progressMessage: (args) => {
|
|
202
210
|
const name = basename(args.path);
|
|
203
211
|
return args.dryRun ? `🛠 patch: ${name} [dry run]` : `🛠 patch: ${name}`;
|
|
@@ -217,8 +225,4 @@ export function registerApplyPatchTool(server, options = {}) {
|
|
|
217
225
|
return `🛠 patch: ${name} • ${dry}no changes`;
|
|
218
226
|
},
|
|
219
227
|
});
|
|
220
|
-
const validatedHandler = withValidatedArgs(ApplyPatchInputSchema, wrappedHandler);
|
|
221
|
-
if (registerToolTaskIfAvailable(server, 'apply_patch', APPLY_PATCH_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
|
|
222
|
-
return;
|
|
223
|
-
server.registerTool('apply_patch', withDefaultIcons({ ...APPLY_PATCH_TOOL }, options.iconInfo), validatedHandler);
|
|
224
228
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
|
|
3
3
|
export declare const CALCULATE_HASH_TOOL: ToolContract;
|
|
4
4
|
export declare function registerCalculateHashTool(server: McpServer, options?: ToolRegistrationOptions): void;
|
|
@@ -10,8 +10,9 @@ import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations
|
|
|
10
10
|
import { globEntries } from '../lib/file-operations/traversal.js';
|
|
11
11
|
import { validateExistingPath } from '../lib/paths.js';
|
|
12
12
|
import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { FILE_READ_ICONS } from './icons.js';
|
|
14
|
+
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, } from './shared.js';
|
|
15
|
+
import { registerStandardTool } from './task-support.js';
|
|
15
16
|
const WINDOWS_PATH_SEPARATOR = /\\/gu;
|
|
16
17
|
export const CALCULATE_HASH_TOOL = {
|
|
17
18
|
name: 'calculate_hash',
|
|
@@ -20,6 +21,7 @@ export const CALCULATE_HASH_TOOL = {
|
|
|
20
21
|
inputSchema: CalculateHashInputSchema,
|
|
21
22
|
outputSchema: CalculateHashOutputSchema,
|
|
22
23
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
24
|
+
icons: FILE_READ_ICONS,
|
|
23
25
|
nuances: [
|
|
24
26
|
'Directory hashing respects root `.gitignore` and sorts paths for stable output.',
|
|
25
27
|
],
|
|
@@ -143,15 +145,15 @@ async function handleCalculateHash(args, signal, onProgress) {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
export function registerCalculateHashTool(server, options = {}) {
|
|
146
|
-
const handler = (args,
|
|
148
|
+
const handler = (args, ctx) => executeToolWithDiagnostics({
|
|
147
149
|
toolName: 'calculate_hash',
|
|
148
|
-
|
|
150
|
+
ctx,
|
|
149
151
|
outputSchema: CalculateHashOutputSchema,
|
|
150
152
|
timedSignal: {},
|
|
151
153
|
context: { path: args.path },
|
|
152
154
|
run: async (signal) => {
|
|
153
155
|
const baseName = basename(args.path);
|
|
154
|
-
const progress = createToolProgressSession(
|
|
156
|
+
const progress = createToolProgressSession(ctx, `🕮 hash: ${baseName}`);
|
|
155
157
|
const progressWithMessage = ({ current, total, }) => {
|
|
156
158
|
progress.update({
|
|
157
159
|
current,
|
|
@@ -181,11 +183,5 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
181
183
|
},
|
|
182
184
|
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
|
|
183
185
|
});
|
|
184
|
-
|
|
185
|
-
guard: options.isInitialized,
|
|
186
|
-
});
|
|
187
|
-
const validatedHandler = withValidatedArgs(CalculateHashInputSchema, wrappedHandler);
|
|
188
|
-
if (registerToolTaskIfAvailable(server, 'calculate_hash', CALCULATE_HASH_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
|
|
189
|
-
return;
|
|
190
|
-
server.registerTool('calculate_hash', withDefaultIcons({ ...CALCULATE_HASH_TOOL }, options.iconInfo), validatedHandler);
|
|
186
|
+
registerStandardTool(server, CALCULATE_HASH_TOOL, handler, options);
|
|
191
187
|
}
|
package/dist/tools/contract.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Icon } from '@modelcontextprotocol/server';
|
|
1
2
|
import type { ZodType } from 'zod';
|
|
2
3
|
export interface ToolContract {
|
|
3
4
|
/**
|
|
@@ -38,6 +39,10 @@ export interface ToolContract {
|
|
|
38
39
|
* Common pitfalls or warnings for documentation.
|
|
39
40
|
*/
|
|
40
41
|
gotchas?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Optional icons for display in user interfaces.
|
|
44
|
+
*/
|
|
45
|
+
icons?: Icon[];
|
|
41
46
|
/**
|
|
42
47
|
* Task support level for the tool. Defaults to 'forbidden'.
|
|
43
48
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
|
|
3
3
|
export declare const CREATE_DIRECTORY_TOOL: ToolContract;
|
|
4
4
|
export declare function registerCreateDirectoryTool(server: McpServer, options?: ToolRegistrationOptions): void;
|