@comunica/utils-mcp 1.0.0 → 1.1.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.
@@ -1,3 +1,10 @@
1
1
  import type { QueryEngineBase } from '@comunica/actor-init-query';
2
2
  import type { QueryStringContext } from '@comunica/types';
3
- export declare function runCli(queryEngine: QueryEngineBase, version: string, customContext?: Partial<QueryStringContext>): void;
3
+ /**
4
+ * Run the CLI of an MCP server.
5
+ * @param queryEngine The query engine to expose, or a factory creating it.
6
+ * A factory is preferred, as it avoids initializing an engine inside the primary process.
7
+ * @param version The version of the MCP server.
8
+ * @param customContext An optional query context to apply to all queries.
9
+ */
10
+ export declare function runCli(queryEngine: QueryEngineBase | (() => QueryEngineBase), version: string, customContext?: Partial<QueryStringContext>): void;
package/lib/BinHelpers.js CHANGED
@@ -4,6 +4,18 @@ exports.runCli = runCli;
4
4
  const yargs_1 = require("yargs");
5
5
  const helpers_1 = require("yargs/helpers");
6
6
  const SparqlMcpServer_1 = require("./SparqlMcpServer");
7
+ const WorkerPool_1 = require("./WorkerPool");
8
+ // The cluster module only has a default export, which can not be imported
9
+ // as such within this CommonJS package, so it is required instead.
10
+ // eslint-disable-next-line ts/no-require-imports,ts/no-var-requires
11
+ const cluster = require('node:cluster');
12
+ /**
13
+ * Run the CLI of an MCP server.
14
+ * @param queryEngine The query engine to expose, or a factory creating it.
15
+ * A factory is preferred, as it avoids initializing an engine inside the primary process.
16
+ * @param version The version of the MCP server.
17
+ * @param customContext An optional query context to apply to all queries.
18
+ */
7
19
  function runCli(queryEngine, version, customContext) {
8
20
  (async () => {
9
21
  const argv = await (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
@@ -20,17 +32,57 @@ function runCli(queryEngine, version, customContext) {
20
32
  type: 'number',
21
33
  default: 3123,
22
34
  description: 'Port to run the MCP server on (only for http mode)',
35
+ })
36
+ .option('workers', {
37
+ alias: 'w',
38
+ type: 'number',
39
+ default: 1,
40
+ description: 'Number of worker processes to run queries in (only for http mode)',
41
+ })
42
+ .option('timeout', {
43
+ alias: 't',
44
+ type: 'number',
45
+ default: 60_000,
46
+ description: 'Maximum query execution time in milliseconds (0 to disable)',
23
47
  })
24
48
  .example([
25
49
  ['$0 --mode stdio', 'Start MCP server in stdio mode without default sources'],
26
50
  ['$0 --mode http --port 3000', 'Start MCP server in HTTP mode on port 3000'],
51
+ ['$0 --mode http --port 3000 --workers 4', 'Start MCP server with 4 worker processes'],
52
+ ['$0 --mode http --port 3000 --timeout 300000', 'Start with a query timeout of 5 minutes'],
27
53
  ['$0 --mode stdio https://dbpedia.org/sparql', 'Start with a default SPARQL endpoint'],
28
54
  ['$0 --mode stdio https://example.org/data.ttl file@/path/to/local.ttl', 'Start with multiple default sources'],
29
55
  ])
30
56
  .parse();
57
+ // In http mode, queries are executed inside worker processes that are supervised by this primary process.
58
+ // Queries that block the event loop can not be aborted by the worker they run in,
59
+ // as no timeout inside that worker would be able to fire anymore.
60
+ // The primary process detects such workers, and kills and replaces them.
61
+ // In stdio mode, the server is not clustered, as workers can not share a single stdin stream,
62
+ // and restarting a worker would invalidate the MCP session of the connected client.
63
+ if (argv.mode === 'http' && cluster.isPrimary) {
64
+ const workerPool = new WorkerPool_1.WorkerPool({
65
+ workers: Math.max(1, argv.workers),
66
+ unresponsiveTimeout: argv.timeout > 0 ? Math.max(argv.timeout, WorkerPool_1.MIN_UNRESPONSIVE_TIMEOUT) : 0,
67
+ stderr: process.stderr,
68
+ });
69
+ workerPool.start();
70
+ for (const signal of ['SIGINT', 'SIGTERM']) {
71
+ process.on(signal, () => workerPool.stop(signal));
72
+ }
73
+ return;
74
+ }
75
+ // Let the primary process know that this worker is still responsive.
76
+ // This is a no-op in stdio mode, where there is no primary process.
77
+ (0, WorkerPool_1.startHeartbeat)();
31
78
  // Extract positional arguments as default sources
32
79
  const defaultSources = argv._.length > 0 ? argv._.map(String) : undefined;
33
- const server = new SparqlMcpServer_1.SparqlMcpServer(argv.mode, argv.port, queryEngine, version, process.stderr, defaultSources, customContext);
80
+ const server = new SparqlMcpServer_1.SparqlMcpServer(argv.mode, argv.port, typeof queryEngine === 'function' ? queryEngine() : queryEngine, version, process.stderr, defaultSources, customContext, undefined, {
81
+ queryTimeout: argv.timeout,
82
+ // Comunica can not abort a running query, so the only way to reclaim the resources
83
+ // of a timed out query is to let the primary process replace this worker.
84
+ onQueryTimeout: () => (0, WorkerPool_1.requestRecycle)(),
85
+ });
34
86
  server.start().catch((error) => {
35
87
  process.stderr.write(`Server error: ${error.message}\n`);
36
88
  if (error.stack) {
@@ -1 +1 @@
1
- {"version":3,"file":"BinHelpers.js","sourceRoot":"","sources":["BinHelpers.ts"],"names":[],"mappings":";;AAOA,wBAuDC;AA3DD,iCAA0B;AAC1B,2CAAwC;AACxC,uDAAoD;AAEpD,SAAgB,MAAM,CACpB,WAA4B,EAC5B,OAAe,EACf,aAA2C;IAE3C,CAAC,KAAK,IAAG,EAAE;QACT,MAAM,IAAI,GAAG,MAAM,IAAA,eAAK,EAAC,IAAA,iBAAO,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC;aAC5C,KAAK,CAAC,kCAAkC,CAAC;aACzC,MAAM,CAAC,MAAM,EAAE;YACd,KAAK,EAAE,GAAG;YACV,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,CAAE,OAAO,EAAE,MAAM,CAAE;YAC5B,YAAY,EAAE,IAAI;YAClB,WAAW,EAAE,mCAAmC;SACjD,CAAC;aACD,MAAM,CAAC,MAAM,EAAE;YACd,KAAK,EAAE,GAAG;YACV,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,IAAI;YACb,WAAW,EAAE,oDAAoD;SAClE,CAAC;aACD,OAAO,CAAC;YACP,CAAE,iBAAiB,EAAE,wDAAwD,CAAE;YAC/E,CAAE,4BAA4B,EAAE,4CAA4C,CAAE;YAC9E,CAAE,4CAA4C,EAAE,sCAAsC,CAAE;YACxF,CAAE,sEAAsE,EAAE,qCAAqC,CAAE;SAClH,CAAC;aACD,KAAK,EAAE,CAAC;QAEX,kDAAkD;QAClD,MAAM,cAAc,GAAyB,IAAI,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAEhG,MAAM,MAAM,GAAG,IAAI,iCAAe,CACb,IAAI,CAAC,IAAI,EAC5B,IAAI,CAAC,IAAI,EACT,WAAW,EACX,OAAO,EACP,OAAO,CAAC,MAAM,EACd,cAAc,EACd,aAAa,CACd,CAAC;QACF,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;YAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iBAAiB,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;YACzD,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;gBAChB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAK,IAAI,CAAC,CAAC;YAC3C,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;QACnB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,yBAAyB,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;QACjE,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAChB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAK,IAAI,CAAC,CAAC;QAC3C,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["/* eslint-disable unicorn/no-process-exit */\nimport type { QueryEngineBase } from '@comunica/actor-init-query';\nimport type { QueryStringContext } from '@comunica/types';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\nimport { SparqlMcpServer } from './SparqlMcpServer';\n\nexport function runCli(\n queryEngine: QueryEngineBase,\n version: string,\n customContext?: Partial<QueryStringContext>,\n): void {\n (async() => {\n const argv = await yargs(hideBin(process.argv))\n .usage('Usage: $0 [options] [sources...]')\n .option('mode', {\n alias: 'm',\n type: 'string',\n choices: [ 'stdio', 'http' ],\n demandOption: true,\n description: 'Transport mode for the MCP server',\n })\n .option('port', {\n alias: 'p',\n type: 'number',\n default: 3123,\n description: 'Port to run the MCP server on (only for http mode)',\n })\n .example([\n [ '$0 --mode stdio', 'Start MCP server in stdio mode without default sources' ],\n [ '$0 --mode http --port 3000', 'Start MCP server in HTTP mode on port 3000' ],\n [ '$0 --mode stdio https://dbpedia.org/sparql', 'Start with a default SPARQL endpoint' ],\n [ '$0 --mode stdio https://example.org/data.ttl file@/path/to/local.ttl', 'Start with multiple default sources' ],\n ])\n .parse();\n\n // Extract positional arguments as default sources\n const defaultSources: string[] | undefined = argv._.length > 0 ? argv._.map(String) : undefined;\n\n const server = new SparqlMcpServer(\n <'stdio' | 'http'> argv.mode,\n argv.port,\n queryEngine,\n version,\n process.stderr,\n defaultSources,\n customContext,\n );\n server.start().catch((error) => {\n process.stderr.write(`Server error: ${error.message}\\n`);\n if (error.stack) {\n process.stderr.write(`${error.stack}\\n`);\n }\n process.exit(1);\n });\n })().catch((error) => {\n process.stderr.write(`Initialization error: ${error.message}\\n`);\n if (error.stack) {\n process.stderr.write(`${error.stack}\\n`);\n }\n process.exit(1);\n });\n}\n"]}
1
+ {"version":3,"file":"BinHelpers.js","sourceRoot":"","sources":["BinHelpers.ts"],"names":[],"mappings":"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","sourcesContent":["/* eslint-disable unicorn/no-process-exit,import/no-nodejs-modules */\nimport type { Cluster } from 'node:cluster';\nimport type { QueryEngineBase } from '@comunica/actor-init-query';\nimport type { QueryStringContext } from '@comunica/types';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\nimport { SparqlMcpServer } from './SparqlMcpServer';\nimport { MIN_UNRESPONSIVE_TIMEOUT, requestRecycle, startHeartbeat, WorkerPool } from './WorkerPool';\n\n// The cluster module only has a default export, which can not be imported\n// as such within this CommonJS package, so it is required instead.\n// eslint-disable-next-line ts/no-require-imports,ts/no-var-requires\nconst cluster: Cluster = require('node:cluster');\n\n/**\n * Run the CLI of an MCP server.\n * @param queryEngine The query engine to expose, or a factory creating it.\n * A factory is preferred, as it avoids initializing an engine inside the primary process.\n * @param version The version of the MCP server.\n * @param customContext An optional query context to apply to all queries.\n */\nexport function runCli(\n queryEngine: QueryEngineBase | (() => QueryEngineBase),\n version: string,\n customContext?: Partial<QueryStringContext>,\n): void {\n (async() => {\n const argv = await yargs(hideBin(process.argv))\n .usage('Usage: $0 [options] [sources...]')\n .option('mode', {\n alias: 'm',\n type: 'string',\n choices: [ 'stdio', 'http' ],\n demandOption: true,\n description: 'Transport mode for the MCP server',\n })\n .option('port', {\n alias: 'p',\n type: 'number',\n default: 3123,\n description: 'Port to run the MCP server on (only for http mode)',\n })\n .option('workers', {\n alias: 'w',\n type: 'number',\n default: 1,\n description: 'Number of worker processes to run queries in (only for http mode)',\n })\n .option('timeout', {\n alias: 't',\n type: 'number',\n default: 60_000,\n description: 'Maximum query execution time in milliseconds (0 to disable)',\n })\n .example([\n [ '$0 --mode stdio', 'Start MCP server in stdio mode without default sources' ],\n [ '$0 --mode http --port 3000', 'Start MCP server in HTTP mode on port 3000' ],\n [ '$0 --mode http --port 3000 --workers 4', 'Start MCP server with 4 worker processes' ],\n [ '$0 --mode http --port 3000 --timeout 300000', 'Start with a query timeout of 5 minutes' ],\n [ '$0 --mode stdio https://dbpedia.org/sparql', 'Start with a default SPARQL endpoint' ],\n [ '$0 --mode stdio https://example.org/data.ttl file@/path/to/local.ttl', 'Start with multiple default sources' ],\n ])\n .parse();\n\n // In http mode, queries are executed inside worker processes that are supervised by this primary process.\n // Queries that block the event loop can not be aborted by the worker they run in,\n // as no timeout inside that worker would be able to fire anymore.\n // The primary process detects such workers, and kills and replaces them.\n // In stdio mode, the server is not clustered, as workers can not share a single stdin stream,\n // and restarting a worker would invalidate the MCP session of the connected client.\n if (argv.mode === 'http' && cluster.isPrimary) {\n const workerPool = new WorkerPool({\n workers: Math.max(1, argv.workers),\n unresponsiveTimeout: argv.timeout > 0 ? Math.max(argv.timeout, MIN_UNRESPONSIVE_TIMEOUT) : 0,\n stderr: process.stderr,\n });\n workerPool.start();\n for (const signal of <NodeJS.Signals[]> [ 'SIGINT', 'SIGTERM' ]) {\n process.on(signal, () => workerPool.stop(signal));\n }\n return;\n }\n\n // Let the primary process know that this worker is still responsive.\n // This is a no-op in stdio mode, where there is no primary process.\n startHeartbeat();\n\n // Extract positional arguments as default sources\n const defaultSources: string[] | undefined = argv._.length > 0 ? argv._.map(String) : undefined;\n\n const server = new SparqlMcpServer(\n <'stdio' | 'http'> argv.mode,\n argv.port,\n typeof queryEngine === 'function' ? queryEngine() : queryEngine,\n version,\n process.stderr,\n defaultSources,\n customContext,\n undefined,\n {\n queryTimeout: argv.timeout,\n // Comunica can not abort a running query, so the only way to reclaim the resources\n // of a timed out query is to let the primary process replace this worker.\n onQueryTimeout: () => requestRecycle(),\n },\n );\n server.start().catch((error) => {\n process.stderr.write(`Server error: ${error.message}\\n`);\n if (error.stack) {\n process.stderr.write(`${error.stack}\\n`);\n }\n process.exit(1);\n });\n })().catch((error) => {\n process.stderr.write(`Initialization error: ${error.message}\\n`);\n if (error.stack) {\n process.stderr.write(`${error.stack}\\n`);\n }\n process.exit(1);\n });\n}\n"]}
@@ -2,6 +2,19 @@ import type { Writable } from 'node:stream';
2
2
  import type { QueryEngineBase } from '@comunica/actor-init-query';
3
3
  import type { IQuerySourceUnidentifiedExpanded, QueryStringContext } from '@comunica/types';
4
4
  import type { Context, FastMCPSessionAuth } from 'fastmcp';
5
+ export interface ISparqlMcpServerOptions {
6
+ /**
7
+ * The maximum time in milliseconds a query may take before it is aborted. A value of 0 disables the timeout.
8
+ *
9
+ * Note that Comunica offers no way to abort a running query,
10
+ * so timed out queries keep consuming resources in the background until the process is replaced.
11
+ */
12
+ queryTimeout?: number;
13
+ /**
14
+ * Invoked when a query exceeded {@link ISparqlMcpServerOptions.queryTimeout}.
15
+ */
16
+ onQueryTimeout?: () => void;
17
+ }
5
18
  /**
6
19
  * An MCP server for querying over one or more Knowledge Graphs using SPARQL queries.
7
20
  */
@@ -9,12 +22,13 @@ export declare class SparqlMcpServer {
9
22
  private readonly mode;
10
23
  private readonly port;
11
24
  private readonly queryEngine;
25
+ private readonly options;
12
26
  private readonly server;
13
27
  private readonly stderr;
14
28
  private readonly defaultSources?;
15
29
  private readonly customContext?;
16
30
  private queryId;
17
- constructor(mode: 'stdio' | 'http', port: number, queryEngine: QueryEngineBase, version: string, stderr: Writable, defaultSources?: string[], customContext?: Partial<QueryStringContext>, additionalSourcesDescription?: string);
31
+ constructor(mode: 'stdio' | 'http', port: number, queryEngine: QueryEngineBase, version: string, stderr: Writable, defaultSources?: string[], customContext?: Partial<QueryStringContext>, additionalSourcesDescription?: string, options?: ISparqlMcpServerOptions);
18
32
  /**
19
33
  * Start the MCP server in the configured mode (stdio or HTTP stream).
20
34
  */
@@ -48,6 +62,17 @@ export declare class SparqlMcpServer {
48
62
  httpRetryCount?: number;
49
63
  }): Partial<QueryStringContext>;
50
64
  protected registerTools(additionalSourcesDescription?: string): void;
65
+ /**
66
+ * Reject the given promise if it does not settle within the configured query timeout.
67
+ *
68
+ * Note that this can only interrupt queries that leave the event loop free.
69
+ * Queries that block the event loop are handled by the primary process,
70
+ * which kills and restarts unresponsive workers.
71
+ * @param promise The promise wrapping the query execution.
72
+ * @param queryId The query ID for logging.
73
+ * @returns The value of the given promise.
74
+ */
75
+ protected withQueryTimeout<T>(promise: Promise<T>, queryId: number): Promise<T>;
51
76
  /**
52
77
  * Execute a SPARQL query and stream the results back to the client.
53
78
  * This method contains the common logic for executing queries and handling results.
@@ -10,15 +10,17 @@ class SparqlMcpServer {
10
10
  mode;
11
11
  port;
12
12
  queryEngine;
13
+ options;
13
14
  server;
14
15
  stderr;
15
16
  defaultSources;
16
17
  customContext;
17
18
  queryId = 0;
18
- constructor(mode, port, queryEngine, version, stderr, defaultSources, customContext, additionalSourcesDescription) {
19
+ constructor(mode, port, queryEngine, version, stderr, defaultSources, customContext, additionalSourcesDescription, options = {}) {
19
20
  this.mode = mode;
20
21
  this.port = port;
21
22
  this.queryEngine = queryEngine;
23
+ this.options = options;
22
24
  this.stderr = stderr;
23
25
  this.server = new fastmcp_1.FastMCP({
24
26
  name: 'sparql-mcp',
@@ -185,6 +187,38 @@ class SparqlMcpServer {
185
187
  execute: (args, context) => this.executeQuerySparqlRdf(args, context),
186
188
  });
187
189
  }
190
+ /**
191
+ * Reject the given promise if it does not settle within the configured query timeout.
192
+ *
193
+ * Note that this can only interrupt queries that leave the event loop free.
194
+ * Queries that block the event loop are handled by the primary process,
195
+ * which kills and restarts unresponsive workers.
196
+ * @param promise The promise wrapping the query execution.
197
+ * @param queryId The query ID for logging.
198
+ * @returns The value of the given promise.
199
+ */
200
+ async withQueryTimeout(promise, queryId) {
201
+ const queryTimeout = this.options.queryTimeout ?? 0;
202
+ if (queryTimeout <= 0) {
203
+ return promise;
204
+ }
205
+ let timer;
206
+ const timeout = new Promise((resolve, reject) => {
207
+ timer = setTimeout(() => {
208
+ this.stderr.write(`[Query ${queryId}] Timed out after ${queryTimeout}ms\n`);
209
+ reject(new Error(`Query timed out after ${queryTimeout}ms. \
210
+ Consider making the query more selective, restricting it to fewer sources, \
211
+ or increasing the timeout of the MCP server.`));
212
+ this.options.onQueryTimeout?.();
213
+ }, queryTimeout);
214
+ });
215
+ try {
216
+ return await Promise.race([promise, timeout]);
217
+ }
218
+ finally {
219
+ clearTimeout(timer);
220
+ }
221
+ }
188
222
  /**
189
223
  * Execute a SPARQL query and stream the results back to the client.
190
224
  * This method contains the common logic for executing queries and handling results.
@@ -197,27 +231,36 @@ class SparqlMcpServer {
197
231
  */
198
232
  async executeQuery(query, sources, queryId, context, queryContext = {}) {
199
233
  await context.streamContent({ type: 'text', text: `Streaming SPARQL query results hereafter:` });
234
+ // Kept outside of the query execution, so that a timed out query can be cancelled
235
+ let resultStream;
200
236
  try {
201
- const promises = [];
202
- const chunks = [];
203
- // Merge custom context with provided query context
204
- const mergedContext = { sources, ...this.customContext, ...queryContext };
205
- const queryResult = await this.queryEngine.query(query, mergedContext);
206
- const { data } = await this.queryEngine.resultToString(queryResult);
207
- data.on('data', (chunk) => {
208
- chunks.push(chunk);
209
- promises.push(context.streamContent({ type: 'text', text: chunk.toString() }));
210
- });
211
- await new Promise((resolve, reject) => {
212
- data.on('error', reject);
213
- data.on('end', resolve);
214
- });
215
- await Promise.all(promises);
237
+ const executeInner = async () => {
238
+ const promises = [];
239
+ const chunks = [];
240
+ // Merge custom context with provided query context
241
+ const mergedContext = { sources, ...this.customContext, ...queryContext };
242
+ const queryResult = await this.queryEngine.query(query, mergedContext);
243
+ const { data } = await this.queryEngine.resultToString(queryResult);
244
+ resultStream = data;
245
+ data.on('data', (chunk) => {
246
+ chunks.push(chunk);
247
+ promises.push(context.streamContent({ type: 'text', text: chunk.toString() }));
248
+ });
249
+ await new Promise((resolve, reject) => {
250
+ data.on('error', reject);
251
+ data.on('end', resolve);
252
+ });
253
+ await Promise.all(promises);
254
+ return chunks.join('');
255
+ };
256
+ const results = await this.withQueryTimeout(executeInner(), queryId);
216
257
  // Log successful completion
217
258
  this.stderr.write(`[Query ${queryId}] Successfully completed\n`);
218
- return chunks.join('');
259
+ return results;
219
260
  }
220
261
  catch (error) {
262
+ // Make sure that a timed out or failed query stops consuming resources
263
+ resultStream?.destroy();
221
264
  // Log query failure
222
265
  this.stderr.write(`[Query ${queryId}] Failed: ${error.stack}\n`);
223
266
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"SparqlMcpServer.js","sourceRoot":"","sources":["SparqlMcpServer.ts"],"names":[],"mappings":"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","sourcesContent":["/* eslint-disable import/no-nodejs-modules */\nimport type { Writable } from 'node:stream';\nimport type { QueryEngineBase } from '@comunica/actor-init-query';\nimport type { IQuerySourceSerialized, IQuerySourceUnidentifiedExpanded, QueryStringContext } from '@comunica/types';\nimport type { Context, FastMCPSessionAuth } from 'fastmcp';\nimport { FastMCP } from 'fastmcp';\nimport { z } from 'zod';\n\n/**\n * An MCP server for querying over one or more Knowledge Graphs using SPARQL queries.\n */\nexport class SparqlMcpServer {\n private readonly server: FastMCP;\n private readonly stderr: Writable;\n private readonly defaultSources?: IQuerySourceUnidentifiedExpanded[];\n private readonly customContext?: Partial<QueryStringContext>;\n private queryId = 0;\n\n public constructor(\n private readonly mode: 'stdio' | 'http',\n private readonly port: number,\n private readonly queryEngine: QueryEngineBase,\n version: string,\n stderr: Writable,\n defaultSources?: string[],\n customContext?: Partial<QueryStringContext>,\n additionalSourcesDescription?: string,\n ) {\n this.stderr = stderr;\n this.server = new FastMCP({\n name: 'sparql-mcp',\n version: <any> version,\n });\n\n // Parse default sources if provided\n if (defaultSources && defaultSources.length > 0) {\n this.defaultSources = defaultSources.map(source => this.parseSourceString(source));\n }\n\n // Store custom context to be merged with query context\n this.customContext = customContext;\n\n this.registerTools(additionalSourcesDescription);\n }\n\n /**\n * Start the MCP server in the configured mode (stdio or HTTP stream).\n */\n public async start(): Promise<void> {\n if (this.mode === 'stdio') {\n await this.server.start({\n transportType: 'stdio',\n });\n this.stderr.write(`SPARQL MCP Server running in stdio mode\\n`);\n if (this.defaultSources) {\n this.stderr.write(`Default sources: ${this.defaultSources.map(s => s.value).join(', ')}\\n`);\n }\n } else {\n await this.server.start({\n transportType: 'httpStream',\n httpStream: {\n port: this.port,\n stateless: true,\n },\n });\n this.stderr.write(`SPARQL MCP Server listening on port ${this.port}\\n`);\n if (this.defaultSources) {\n this.stderr.write(`Default sources: ${this.defaultSources.map(s => s.value).join(', ')}\\n`);\n }\n }\n }\n\n /**\n * Parse a source string that may contain a type prefix (e.g., 'sparql@https://example.org/sparql').\n * This follows the same syntax as the Comunica CLI for forcing source types.\n * @param sourceString A source URL that may be prefixed with a type annotation.\n * @returns An object with 'value' and optionally 'type' properties.\n */\n protected parseSourceString(sourceString: string): IQuerySourceUnidentifiedExpanded {\n const source: IQuerySourceUnidentifiedExpanded = { value: '' };\n const typeRegex = /^([^:]*)@/u;\n const typeMatches = typeRegex.exec(sourceString);\n if (typeMatches) {\n source.type = typeMatches[1];\n sourceString = sourceString.slice((source.type.length) + 1);\n }\n source.value = sourceString;\n return source;\n }\n\n /**\n * Build a query context from optional parameters.\n * @param options Optional parameters for the query context\n * @param options.queryFormatLanguage The query language (e.g., 'sparql')\n * @param options.queryFormatVersion The query language version (e.g., '1.1')\n * @param options.baseIRI Base IRI for resolving relative IRIs\n * @param options.httpProxy HTTP proxy URL\n * @param options.httpAuth HTTP basic authentication credentials\n * @param options.httpTimeout HTTP request timeout in milliseconds\n * @param options.httpRetryCount Number of HTTP request retries\n * @returns A partial query context object\n */\n protected buildQueryContext(options: {\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n baseIRI?: string;\n httpProxy?: string;\n httpAuth?: string;\n httpTimeout?: number;\n httpRetryCount?: number;\n }): Partial<QueryStringContext> {\n const context: Partial<QueryStringContext> = {};\n\n if (options.queryFormatLanguage !== undefined || options.queryFormatVersion !== undefined) {\n context.queryFormat = {\n language: options.queryFormatLanguage ?? 'sparql',\n version: options.queryFormatVersion ?? '1.1',\n };\n }\n if (options.baseIRI) {\n context.baseIRI = options.baseIRI;\n }\n if (options.httpProxy) {\n const proxyUrl = options.httpProxy;\n context.httpProxyHandler = {\n getProxy: async(request): Promise<any> => ({\n input: proxyUrl,\n init: request.init,\n }),\n };\n }\n if (options.httpAuth) {\n context.httpAuth = options.httpAuth;\n }\n if (options.httpTimeout !== undefined) {\n context.httpTimeout = options.httpTimeout;\n }\n if (options.httpRetryCount !== undefined) {\n context.httpRetryCount = options.httpRetryCount;\n }\n\n return context;\n }\n\n protected registerTools(additionalSourcesDescription?: string): void {\n // Common query format parameters shared between tools\n const queryFormatParams = {\n queryFormatLanguage: z.string().optional()\n .describe(\n 'Query language (e.g., sparql, graphql). ' +\n 'Allows you to specify alternative query languages supported by Comunica',\n ),\n queryFormatVersion: z.string().optional()\n .describe(\n 'Query language version (e.g., 1.0, 1.1, 1.2). ' +\n 'Specifies the version of the query language to use',\n ),\n };\n\n // Build description for query_sparql tool\n let querySparqlDescription = `Execute a SPARQL query over one or more sources. When sending a SELECT query, results are serialized as 'application/sparql-results+json', CONSTRUCT and DESCRIBE results are in 'application/trig', and ASK queries return true or false. Update queries (INSERT/DELETE) can also be passed, which in most cases will only work on private Knowledge Graphs or by passing authentication.`;\n\n if (this.defaultSources) {\n // If default sources are provided, mention them in the description\n const sourceList = this.defaultSources.map(s => s.value).join(', ');\n querySparqlDescription += ` Default sources: ${sourceList}`;\n }\n\n // Build parameters for query_sparql tool\n const querySparqlParams: any = {\n query: z.string().describe('SPARQL query string'),\n };\n\n // Only add sources parameter if no default sources are provided\n if (!this.defaultSources) {\n querySparqlParams.sources = z.array(z.string()).describe(`List of SPARQL endpoint URLs, TPF interface URLs, or Linked Data (RDF) file paths. You can optionally force a source type by prefixing the URL with a type annotation (e.g., 'sparql@https://example.org/sparql', 'file@/path/to/file.ttl', 'hypermedia@https://example.org/'). This is useful when the source type is already known to avoid auto-detection overhead.${additionalSourcesDescription ?? ''}`);\n }\n\n // Add common parameters\n Object.assign(querySparqlParams, {\n ...queryFormatParams,\n baseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the query'),\n httpProxy: z.string().optional().describe('HTTP proxy URL (e.g., http://proxy.example.com:8080)'),\n httpAuth: z.string().optional().describe('HTTP basic authentication in the format username:password'),\n httpTimeout: z.number().optional().describe('HTTP request timeout in milliseconds'),\n httpRetryCount: z.number().optional().describe('Number of HTTP request retries on failure'),\n });\n\n this.server.addTool({\n name: 'query_sparql',\n description: querySparqlDescription,\n parameters: z.object(querySparqlParams),\n annotations: {\n // Signals this tool uses streaming\n streamingHint: true,\n readOnlyHint: true,\n },\n // Type assertion is needed because we dynamically construct the parameters object\n // based on whether default sources are provided. The runtime behavior is type-safe.\n execute: (args, context) => this.executeQuerySparql(<any>args, context),\n });\n\n this.server.addTool({\n name: 'query_sparql_rdf',\n description: `Execute a SPARQL query over a serialized RDF dataset provided as a string. This is useful for querying RDF data that is already available as a string (e.g., Turtle, N-Triples, etc.). When sending a SELECT query, results are serialized as 'application/sparql-results+json', CONSTRUCT and DESCRIBE results are in 'application/trig', and ASK queries return true or false.`,\n parameters: z.object({\n query: z.string().describe('SPARQL query string'),\n value: z.string().describe('Serialized RDF dataset as a string'),\n mediaType: z.string().describe(`Media type of the serialized RDF dataset (e.g., 'text/turtle', 'application/n-triples', 'application/ld+json', 'application/rdf+xml', 'application/n-quads', 'application/trig')`),\n fileBaseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the RDF dataset'),\n baseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the query'),\n ...queryFormatParams,\n }),\n annotations: {\n // Signals this tool uses streaming\n streamingHint: true,\n readOnlyHint: true,\n },\n execute: (args, context) => this.executeQuerySparqlRdf(args, context),\n });\n }\n\n /**\n * Execute a SPARQL query and stream the results back to the client.\n * This method contains the common logic for executing queries and handling results.\n * @param query The SPARQL query string\n * @param sources Array of query sources\n * @param queryId The query ID for logging\n * @param context The MCP context for streaming results\n * @param queryContext Optional query context parameters\n * @returns The query results as a string or an error object\n */\n protected async executeQuery(\n query: string,\n sources: IQuerySourceUnidentifiedExpanded[],\n queryId: number,\n context: Context<FastMCPSessionAuth>,\n queryContext: Partial<QueryStringContext> = {},\n ): Promise<any> {\n await context.streamContent({ type: 'text', text: `Streaming SPARQL query results hereafter:` });\n\n try {\n const promises: Promise<any>[] = [];\n const chunks: string[] = [];\n // Merge custom context with provided query context\n const mergedContext = { sources, ...this.customContext, ...queryContext };\n const queryResult = await this.queryEngine.query(query, mergedContext);\n const { data } = await this.queryEngine.resultToString(queryResult);\n data.on('data', (chunk: string) => {\n chunks.push(chunk);\n promises.push(context.streamContent({ type: 'text', text: chunk.toString() }));\n });\n await new Promise((resolve, reject) => {\n data.on('error', reject);\n data.on('end', resolve);\n });\n await Promise.all(promises);\n\n // Log successful completion\n this.stderr.write(`[Query ${queryId}] Successfully completed\\n`);\n\n return chunks.join('');\n } catch (error: any) {\n // Log query failure\n this.stderr.write(`[Query ${queryId}] Failed: ${error.stack}\\n`);\n\n return {\n isError: true,\n content: [\n {\n type: 'text',\n text: `Query failed: ${error.message}`,\n },\n ],\n };\n }\n }\n\n protected async executeQuerySparql(\n args: {\n query: string;\n sources?: string[];\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n baseIRI?: string;\n httpProxy?: string;\n httpAuth?: string;\n httpTimeout?: number;\n httpRetryCount?: number;\n },\n context: Context<FastMCPSessionAuth>,\n ): Promise<any> {\n const currentQueryId = this.queryId++;\n\n // Use default sources if provided, otherwise use sources from args\n let parsedSources: IQuerySourceUnidentifiedExpanded[];\n if (this.defaultSources) {\n parsedSources = this.defaultSources;\n } else if (args.sources) {\n parsedSources = args.sources.map(sourceString => this.parseSourceString(sourceString));\n } else {\n throw new Error('No sources provided and no default sources configured');\n }\n\n // Build query context from optional parameters\n const queryContext = this.buildQueryContext(args);\n\n // Log query start\n this.stderr.write(`[Query ${currentQueryId}] Starting SPARQL query\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Sources: ${parsedSources.map(s => s.value).join(', ')}\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Query: ${args.query}\\n`);\n\n return this.executeQuery(args.query, parsedSources, currentQueryId, context, queryContext);\n }\n\n protected async executeQuerySparqlRdf(\n args: {\n query: string;\n value: string;\n mediaType: string;\n fileBaseIRI?: string;\n baseIRI?: string;\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n },\n context: Context<FastMCPSessionAuth>,\n ): Promise<any> {\n const currentQueryId = this.queryId++;\n\n // Create a serialized source\n const source: IQuerySourceSerialized = {\n type: 'serialized',\n value: args.value,\n mediaType: args.mediaType,\n ...(args.fileBaseIRI && { baseIRI: args.fileBaseIRI }),\n };\n\n // Build query context from optional parameters\n const queryContext = this.buildQueryContext(args);\n\n // Log query start\n this.stderr.write(`[Query ${currentQueryId}] Starting SPARQL query on serialized RDF (media type: ${args.mediaType})\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Query: ${args.query}\\n`);\n\n return this.executeQuery(args.query, [ source ], currentQueryId, context, queryContext);\n }\n}\n"]}
1
+ {"version":3,"file":"SparqlMcpServer.js","sourceRoot":"","sources":["SparqlMcpServer.ts"],"names":[],"mappings":"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","sourcesContent":["/* eslint-disable import/no-nodejs-modules */\nimport type { Readable, Writable } from 'node:stream';\nimport type { QueryEngineBase } from '@comunica/actor-init-query';\nimport type { IQuerySourceSerialized, IQuerySourceUnidentifiedExpanded, QueryStringContext } from '@comunica/types';\nimport type { Context, FastMCPSessionAuth } from 'fastmcp';\nimport { FastMCP } from 'fastmcp';\nimport { z } from 'zod';\n\nexport interface ISparqlMcpServerOptions {\n /**\n * The maximum time in milliseconds a query may take before it is aborted. A value of 0 disables the timeout.\n *\n * Note that Comunica offers no way to abort a running query,\n * so timed out queries keep consuming resources in the background until the process is replaced.\n */\n queryTimeout?: number;\n /**\n * Invoked when a query exceeded {@link ISparqlMcpServerOptions.queryTimeout}.\n */\n onQueryTimeout?: () => void;\n}\n\n/**\n * An MCP server for querying over one or more Knowledge Graphs using SPARQL queries.\n */\nexport class SparqlMcpServer {\n private readonly server: FastMCP;\n private readonly stderr: Writable;\n private readonly defaultSources?: IQuerySourceUnidentifiedExpanded[];\n private readonly customContext?: Partial<QueryStringContext>;\n private queryId = 0;\n\n public constructor(\n private readonly mode: 'stdio' | 'http',\n private readonly port: number,\n private readonly queryEngine: QueryEngineBase,\n version: string,\n stderr: Writable,\n defaultSources?: string[],\n customContext?: Partial<QueryStringContext>,\n additionalSourcesDescription?: string,\n private readonly options: ISparqlMcpServerOptions = {},\n ) {\n this.stderr = stderr;\n this.server = new FastMCP({\n name: 'sparql-mcp',\n version: <any> version,\n });\n\n // Parse default sources if provided\n if (defaultSources && defaultSources.length > 0) {\n this.defaultSources = defaultSources.map(source => this.parseSourceString(source));\n }\n\n // Store custom context to be merged with query context\n this.customContext = customContext;\n\n this.registerTools(additionalSourcesDescription);\n }\n\n /**\n * Start the MCP server in the configured mode (stdio or HTTP stream).\n */\n public async start(): Promise<void> {\n if (this.mode === 'stdio') {\n await this.server.start({\n transportType: 'stdio',\n });\n this.stderr.write(`SPARQL MCP Server running in stdio mode\\n`);\n if (this.defaultSources) {\n this.stderr.write(`Default sources: ${this.defaultSources.map(s => s.value).join(', ')}\\n`);\n }\n } else {\n await this.server.start({\n transportType: 'httpStream',\n httpStream: {\n port: this.port,\n stateless: true,\n },\n });\n this.stderr.write(`SPARQL MCP Server listening on port ${this.port}\\n`);\n if (this.defaultSources) {\n this.stderr.write(`Default sources: ${this.defaultSources.map(s => s.value).join(', ')}\\n`);\n }\n }\n }\n\n /**\n * Parse a source string that may contain a type prefix (e.g., 'sparql@https://example.org/sparql').\n * This follows the same syntax as the Comunica CLI for forcing source types.\n * @param sourceString A source URL that may be prefixed with a type annotation.\n * @returns An object with 'value' and optionally 'type' properties.\n */\n protected parseSourceString(sourceString: string): IQuerySourceUnidentifiedExpanded {\n const source: IQuerySourceUnidentifiedExpanded = { value: '' };\n const typeRegex = /^([^:]*)@/u;\n const typeMatches = typeRegex.exec(sourceString);\n if (typeMatches) {\n source.type = typeMatches[1];\n sourceString = sourceString.slice((source.type.length) + 1);\n }\n source.value = sourceString;\n return source;\n }\n\n /**\n * Build a query context from optional parameters.\n * @param options Optional parameters for the query context\n * @param options.queryFormatLanguage The query language (e.g., 'sparql')\n * @param options.queryFormatVersion The query language version (e.g., '1.1')\n * @param options.baseIRI Base IRI for resolving relative IRIs\n * @param options.httpProxy HTTP proxy URL\n * @param options.httpAuth HTTP basic authentication credentials\n * @param options.httpTimeout HTTP request timeout in milliseconds\n * @param options.httpRetryCount Number of HTTP request retries\n * @returns A partial query context object\n */\n protected buildQueryContext(options: {\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n baseIRI?: string;\n httpProxy?: string;\n httpAuth?: string;\n httpTimeout?: number;\n httpRetryCount?: number;\n }): Partial<QueryStringContext> {\n const context: Partial<QueryStringContext> = {};\n\n if (options.queryFormatLanguage !== undefined || options.queryFormatVersion !== undefined) {\n context.queryFormat = {\n language: options.queryFormatLanguage ?? 'sparql',\n version: options.queryFormatVersion ?? '1.1',\n };\n }\n if (options.baseIRI) {\n context.baseIRI = options.baseIRI;\n }\n if (options.httpProxy) {\n const proxyUrl = options.httpProxy;\n context.httpProxyHandler = {\n getProxy: async(request): Promise<any> => ({\n input: proxyUrl,\n init: request.init,\n }),\n };\n }\n if (options.httpAuth) {\n context.httpAuth = options.httpAuth;\n }\n if (options.httpTimeout !== undefined) {\n context.httpTimeout = options.httpTimeout;\n }\n if (options.httpRetryCount !== undefined) {\n context.httpRetryCount = options.httpRetryCount;\n }\n\n return context;\n }\n\n protected registerTools(additionalSourcesDescription?: string): void {\n // Common query format parameters shared between tools\n const queryFormatParams = {\n queryFormatLanguage: z.string().optional()\n .describe(\n 'Query language (e.g., sparql, graphql). ' +\n 'Allows you to specify alternative query languages supported by Comunica',\n ),\n queryFormatVersion: z.string().optional()\n .describe(\n 'Query language version (e.g., 1.0, 1.1, 1.2). ' +\n 'Specifies the version of the query language to use',\n ),\n };\n\n // Build description for query_sparql tool\n let querySparqlDescription = `Execute a SPARQL query over one or more sources. When sending a SELECT query, results are serialized as 'application/sparql-results+json', CONSTRUCT and DESCRIBE results are in 'application/trig', and ASK queries return true or false. Update queries (INSERT/DELETE) can also be passed, which in most cases will only work on private Knowledge Graphs or by passing authentication.`;\n\n if (this.defaultSources) {\n // If default sources are provided, mention them in the description\n const sourceList = this.defaultSources.map(s => s.value).join(', ');\n querySparqlDescription += ` Default sources: ${sourceList}`;\n }\n\n // Build parameters for query_sparql tool\n const querySparqlParams: any = {\n query: z.string().describe('SPARQL query string'),\n };\n\n // Only add sources parameter if no default sources are provided\n if (!this.defaultSources) {\n querySparqlParams.sources = z.array(z.string()).describe(`List of SPARQL endpoint URLs, TPF interface URLs, or Linked Data (RDF) file paths. You can optionally force a source type by prefixing the URL with a type annotation (e.g., 'sparql@https://example.org/sparql', 'file@/path/to/file.ttl', 'hypermedia@https://example.org/'). This is useful when the source type is already known to avoid auto-detection overhead.${additionalSourcesDescription ?? ''}`);\n }\n\n // Add common parameters\n Object.assign(querySparqlParams, {\n ...queryFormatParams,\n baseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the query'),\n httpProxy: z.string().optional().describe('HTTP proxy URL (e.g., http://proxy.example.com:8080)'),\n httpAuth: z.string().optional().describe('HTTP basic authentication in the format username:password'),\n httpTimeout: z.number().optional().describe('HTTP request timeout in milliseconds'),\n httpRetryCount: z.number().optional().describe('Number of HTTP request retries on failure'),\n });\n\n this.server.addTool({\n name: 'query_sparql',\n description: querySparqlDescription,\n parameters: z.object(querySparqlParams),\n annotations: {\n // Signals this tool uses streaming\n streamingHint: true,\n readOnlyHint: true,\n },\n // Type assertion is needed because we dynamically construct the parameters object\n // based on whether default sources are provided. The runtime behavior is type-safe.\n execute: (args, context) => this.executeQuerySparql(<any>args, context),\n });\n\n this.server.addTool({\n name: 'query_sparql_rdf',\n description: `Execute a SPARQL query over a serialized RDF dataset provided as a string. This is useful for querying RDF data that is already available as a string (e.g., Turtle, N-Triples, etc.). When sending a SELECT query, results are serialized as 'application/sparql-results+json', CONSTRUCT and DESCRIBE results are in 'application/trig', and ASK queries return true or false.`,\n parameters: z.object({\n query: z.string().describe('SPARQL query string'),\n value: z.string().describe('Serialized RDF dataset as a string'),\n mediaType: z.string().describe(`Media type of the serialized RDF dataset (e.g., 'text/turtle', 'application/n-triples', 'application/ld+json', 'application/rdf+xml', 'application/n-quads', 'application/trig')`),\n fileBaseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the RDF dataset'),\n baseIRI: z.string().optional().describe('Base IRI for resolving relative IRIs in the query'),\n ...queryFormatParams,\n }),\n annotations: {\n // Signals this tool uses streaming\n streamingHint: true,\n readOnlyHint: true,\n },\n execute: (args, context) => this.executeQuerySparqlRdf(args, context),\n });\n }\n\n /**\n * Reject the given promise if it does not settle within the configured query timeout.\n *\n * Note that this can only interrupt queries that leave the event loop free.\n * Queries that block the event loop are handled by the primary process,\n * which kills and restarts unresponsive workers.\n * @param promise The promise wrapping the query execution.\n * @param queryId The query ID for logging.\n * @returns The value of the given promise.\n */\n protected async withQueryTimeout<T>(promise: Promise<T>, queryId: number): Promise<T> {\n const queryTimeout = this.options.queryTimeout ?? 0;\n if (queryTimeout <= 0) {\n return promise;\n }\n\n let timer: NodeJS.Timeout | undefined;\n const timeout = new Promise<never>((resolve, reject) => {\n timer = setTimeout(() => {\n this.stderr.write(`[Query ${queryId}] Timed out after ${queryTimeout}ms\\n`);\n reject(new Error(`Query timed out after ${queryTimeout}ms. \\\nConsider making the query more selective, restricting it to fewer sources, \\\nor increasing the timeout of the MCP server.`));\n this.options.onQueryTimeout?.();\n }, queryTimeout);\n });\n\n try {\n return await Promise.race([ promise, timeout ]);\n } finally {\n clearTimeout(timer);\n }\n }\n\n /**\n * Execute a SPARQL query and stream the results back to the client.\n * This method contains the common logic for executing queries and handling results.\n * @param query The SPARQL query string\n * @param sources Array of query sources\n * @param queryId The query ID for logging\n * @param context The MCP context for streaming results\n * @param queryContext Optional query context parameters\n * @returns The query results as a string or an error object\n */\n protected async executeQuery(\n query: string,\n sources: IQuerySourceUnidentifiedExpanded[],\n queryId: number,\n context: Context<FastMCPSessionAuth>,\n queryContext: Partial<QueryStringContext> = {},\n ): Promise<any> {\n await context.streamContent({ type: 'text', text: `Streaming SPARQL query results hereafter:` });\n\n // Kept outside of the query execution, so that a timed out query can be cancelled\n let resultStream: Readable | undefined;\n\n try {\n const executeInner = async(): Promise<string> => {\n const promises: Promise<any>[] = [];\n const chunks: string[] = [];\n // Merge custom context with provided query context\n const mergedContext = { sources, ...this.customContext, ...queryContext };\n const queryResult = await this.queryEngine.query(query, mergedContext);\n const { data } = await this.queryEngine.resultToString(queryResult);\n resultStream = <Readable> data;\n data.on('data', (chunk: string) => {\n chunks.push(chunk);\n promises.push(context.streamContent({ type: 'text', text: chunk.toString() }));\n });\n await new Promise((resolve, reject) => {\n data.on('error', reject);\n data.on('end', resolve);\n });\n await Promise.all(promises);\n\n return chunks.join('');\n };\n const results = await this.withQueryTimeout(executeInner(), queryId);\n\n // Log successful completion\n this.stderr.write(`[Query ${queryId}] Successfully completed\\n`);\n\n return results;\n } catch (error: any) {\n // Make sure that a timed out or failed query stops consuming resources\n resultStream?.destroy();\n\n // Log query failure\n this.stderr.write(`[Query ${queryId}] Failed: ${error.stack}\\n`);\n\n return {\n isError: true,\n content: [\n {\n type: 'text',\n text: `Query failed: ${error.message}`,\n },\n ],\n };\n }\n }\n\n protected async executeQuerySparql(\n args: {\n query: string;\n sources?: string[];\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n baseIRI?: string;\n httpProxy?: string;\n httpAuth?: string;\n httpTimeout?: number;\n httpRetryCount?: number;\n },\n context: Context<FastMCPSessionAuth>,\n ): Promise<any> {\n const currentQueryId = this.queryId++;\n\n // Use default sources if provided, otherwise use sources from args\n let parsedSources: IQuerySourceUnidentifiedExpanded[];\n if (this.defaultSources) {\n parsedSources = this.defaultSources;\n } else if (args.sources) {\n parsedSources = args.sources.map(sourceString => this.parseSourceString(sourceString));\n } else {\n throw new Error('No sources provided and no default sources configured');\n }\n\n // Build query context from optional parameters\n const queryContext = this.buildQueryContext(args);\n\n // Log query start\n this.stderr.write(`[Query ${currentQueryId}] Starting SPARQL query\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Sources: ${parsedSources.map(s => s.value).join(', ')}\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Query: ${args.query}\\n`);\n\n return this.executeQuery(args.query, parsedSources, currentQueryId, context, queryContext);\n }\n\n protected async executeQuerySparqlRdf(\n args: {\n query: string;\n value: string;\n mediaType: string;\n fileBaseIRI?: string;\n baseIRI?: string;\n queryFormatLanguage?: string;\n queryFormatVersion?: string;\n },\n context: Context<FastMCPSessionAuth>,\n ): Promise<any> {\n const currentQueryId = this.queryId++;\n\n // Create a serialized source\n const source: IQuerySourceSerialized = {\n type: 'serialized',\n value: args.value,\n mediaType: args.mediaType,\n ...(args.fileBaseIRI && { baseIRI: args.fileBaseIRI }),\n };\n\n // Build query context from optional parameters\n const queryContext = this.buildQueryContext(args);\n\n // Log query start\n this.stderr.write(`[Query ${currentQueryId}] Starting SPARQL query on serialized RDF (media type: ${args.mediaType})\\n`);\n this.stderr.write(`[Query ${currentQueryId}] Query: ${args.query}\\n`);\n\n return this.executeQuery(args.query, [ source ], currentQueryId, context, queryContext);\n }\n}\n"]}
@@ -0,0 +1,142 @@
1
+ import type { Cluster, Worker } from 'node:cluster';
2
+ import type { Writable } from 'node:stream';
3
+ /**
4
+ * The message workers periodically send to the primary process to indicate that their event loop is responsive.
5
+ */
6
+ export declare const MESSAGE_HEARTBEAT = "comunica-mcp:heartbeat";
7
+ /**
8
+ * The message workers send to the primary process to ask to be replaced by a fresh worker.
9
+ */
10
+ export declare const MESSAGE_RECYCLE = "comunica-mcp:recycle";
11
+ /**
12
+ * The interval in milliseconds at which workers send a heartbeat to the primary process.
13
+ */
14
+ export declare const HEARTBEAT_INTERVAL = 1000;
15
+ /**
16
+ * The lower bound in milliseconds on the time a worker may block its event loop before it is considered stuck.
17
+ * Workers are never killed faster than this, so that slow startups are not mistaken for hangs.
18
+ */
19
+ export declare const MIN_UNRESPONSIVE_TIMEOUT = 30000;
20
+ export interface IWorkerPoolArgs {
21
+ /**
22
+ * The number of workers that must be kept alive at all times.
23
+ */
24
+ workers: number;
25
+ /**
26
+ * The time in milliseconds a worker may block its event loop before it is killed and restarted.
27
+ * A value of 0 disables this watchdog.
28
+ */
29
+ unresponsiveTimeout: number;
30
+ /**
31
+ * The stream to write log messages to.
32
+ */
33
+ stderr: Writable;
34
+ /**
35
+ * The cluster instance to manage workers with. Defaults to the Node.js cluster module.
36
+ */
37
+ cluster?: Cluster;
38
+ /**
39
+ * The interval in milliseconds at which worker liveness is checked. Defaults to {@link HEARTBEAT_INTERVAL}.
40
+ */
41
+ checkInterval?: number;
42
+ /**
43
+ * The time in milliseconds a worker must stay alive before its startup is considered successful.
44
+ */
45
+ healthyUptime?: number;
46
+ /**
47
+ * The number of consecutive failed startups after which the primary process gives up.
48
+ */
49
+ maxRapidRestarts?: number;
50
+ /**
51
+ * The time in milliseconds workers are given to shut down gracefully before they are killed.
52
+ */
53
+ shutdownGrace?: number;
54
+ /**
55
+ * Invoked when the primary process must terminate with the given exit code.
56
+ */
57
+ exit?: (code: number) => void;
58
+ }
59
+ /**
60
+ * Maintains a pool of worker processes that each run an MCP server, and replaces them when needed.
61
+ *
62
+ * Workers are needed because SPARQL queries can not always be cancelled from within the process that runs them:
63
+ * * Queries that block the event loop make any timeout inside that worker unable to fire in the first place.
64
+ * Such workers stop sending {@link MESSAGE_HEARTBEAT} messages, upon which they are killed and replaced.
65
+ * * Queries that did time out keep consuming CPU and memory in the background, as Comunica offers no way to
66
+ * abort an ongoing query. Workers therefore ask to be replaced via {@link MESSAGE_RECYCLE} after a timeout,
67
+ * which lets them finish their pending responses while a fresh worker takes over.
68
+ */
69
+ export declare class WorkerPool {
70
+ private readonly workers;
71
+ private readonly unresponsiveTimeout;
72
+ private readonly stderr;
73
+ private readonly cluster;
74
+ private readonly checkInterval;
75
+ private readonly healthyUptime;
76
+ private readonly maxRapidRestarts;
77
+ private readonly shutdownGrace;
78
+ private readonly exit;
79
+ private readonly states;
80
+ private rapidRestarts;
81
+ private checkTimer;
82
+ private shuttingDown;
83
+ constructor(args: IWorkerPoolArgs);
84
+ /**
85
+ * Fork the configured number of workers, and start monitoring them.
86
+ */
87
+ start(): void;
88
+ /**
89
+ * Terminate all workers, and exit as soon as they are all gone.
90
+ * @param signal The signal to send to the workers.
91
+ */
92
+ stop(signal: NodeJS.Signals): void;
93
+ /**
94
+ * Get all workers that are currently registered within the cluster.
95
+ */
96
+ protected getWorkers(): Worker[];
97
+ /**
98
+ * Start a new worker process.
99
+ */
100
+ protected fork(): void;
101
+ /**
102
+ * Handle a message that was sent by a worker.
103
+ * @param worker The worker that sent the message.
104
+ * @param message The message that was sent.
105
+ */
106
+ protected onWorkerMessage(worker: Worker, message: unknown): void;
107
+ /**
108
+ * Replace a worker by a fresh one, and let the old worker finish its pending responses in the meantime.
109
+ * @param worker The worker to replace.
110
+ */
111
+ protected recycleWorker(worker: Worker): void;
112
+ /**
113
+ * Restart a worker that died, unless workers keep dying immediately after startup.
114
+ * @param worker The worker that died.
115
+ * @param code The exit code of the worker.
116
+ * @param signal The signal that terminated the worker.
117
+ */
118
+ protected onWorkerExit(worker: Worker, code: number, signal: string | null): void;
119
+ /**
120
+ * Kill all workers of which the event loop has been blocked for too long.
121
+ */
122
+ protected checkWorkers(): void;
123
+ /**
124
+ * Forcefully kill the given workers if they are still alive after the shutdown grace period.
125
+ * @param workers The workers to kill.
126
+ * @param callback An optional callback to invoke afterwards.
127
+ */
128
+ protected killLater(workers: Worker[], callback?: () => void): void;
129
+ }
130
+ /**
131
+ * Periodically inform the primary process that the event loop of this worker is not blocked.
132
+ * This is a no-op when the current process is not a cluster worker.
133
+ * @param interval The interval in milliseconds at which heartbeats are sent.
134
+ * @returns The heartbeat timer, or undefined if the current process is not a cluster worker.
135
+ */
136
+ export declare function startHeartbeat(interval?: number): NodeJS.Timeout | undefined;
137
+ /**
138
+ * Ask the primary process to replace this worker by a fresh one.
139
+ * This is a no-op when the current process is not a cluster worker.
140
+ * @returns If the request was sent.
141
+ */
142
+ export declare function requestRecycle(): boolean;
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorkerPool = exports.MIN_UNRESPONSIVE_TIMEOUT = exports.HEARTBEAT_INTERVAL = exports.MESSAGE_RECYCLE = exports.MESSAGE_HEARTBEAT = void 0;
4
+ exports.startHeartbeat = startHeartbeat;
5
+ exports.requestRecycle = requestRecycle;
6
+ // The cluster module only has a default export, which can not be imported
7
+ // as such within this CommonJS package, so it is required instead.
8
+ // eslint-disable-next-line ts/no-require-imports,ts/no-var-requires
9
+ const nodeCluster = require('node:cluster');
10
+ /**
11
+ * The message workers periodically send to the primary process to indicate that their event loop is responsive.
12
+ */
13
+ exports.MESSAGE_HEARTBEAT = 'comunica-mcp:heartbeat';
14
+ /**
15
+ * The message workers send to the primary process to ask to be replaced by a fresh worker.
16
+ */
17
+ exports.MESSAGE_RECYCLE = 'comunica-mcp:recycle';
18
+ /**
19
+ * The interval in milliseconds at which workers send a heartbeat to the primary process.
20
+ */
21
+ exports.HEARTBEAT_INTERVAL = 1_000;
22
+ /**
23
+ * The lower bound in milliseconds on the time a worker may block its event loop before it is considered stuck.
24
+ * Workers are never killed faster than this, so that slow startups are not mistaken for hangs.
25
+ */
26
+ exports.MIN_UNRESPONSIVE_TIMEOUT = 30_000;
27
+ /**
28
+ * Maintains a pool of worker processes that each run an MCP server, and replaces them when needed.
29
+ *
30
+ * Workers are needed because SPARQL queries can not always be cancelled from within the process that runs them:
31
+ * * Queries that block the event loop make any timeout inside that worker unable to fire in the first place.
32
+ * Such workers stop sending {@link MESSAGE_HEARTBEAT} messages, upon which they are killed and replaced.
33
+ * * Queries that did time out keep consuming CPU and memory in the background, as Comunica offers no way to
34
+ * abort an ongoing query. Workers therefore ask to be replaced via {@link MESSAGE_RECYCLE} after a timeout,
35
+ * which lets them finish their pending responses while a fresh worker takes over.
36
+ */
37
+ class WorkerPool {
38
+ workers;
39
+ unresponsiveTimeout;
40
+ stderr;
41
+ cluster;
42
+ checkInterval;
43
+ healthyUptime;
44
+ maxRapidRestarts;
45
+ shutdownGrace;
46
+ exit;
47
+ states = {};
48
+ rapidRestarts = 0;
49
+ checkTimer;
50
+ shuttingDown = false;
51
+ constructor(args) {
52
+ this.workers = args.workers;
53
+ this.unresponsiveTimeout = args.unresponsiveTimeout;
54
+ this.stderr = args.stderr;
55
+ this.cluster = args.cluster ?? nodeCluster;
56
+ this.checkInterval = args.checkInterval ?? exports.HEARTBEAT_INTERVAL;
57
+ this.healthyUptime = args.healthyUptime ?? 5_000;
58
+ this.maxRapidRestarts = args.maxRapidRestarts ?? 5;
59
+ this.shutdownGrace = args.shutdownGrace ?? 10_000;
60
+ this.exit = args.exit ?? (code => process.exit(code));
61
+ }
62
+ /**
63
+ * Fork the configured number of workers, and start monitoring them.
64
+ */
65
+ start() {
66
+ this.stderr.write(`Primary ${process.pid} started, spawning ${this.workers} worker(s)\n`);
67
+ this.cluster.on('exit', (worker, code, signal) => this.onWorkerExit(worker, code, signal));
68
+ this.cluster.on('message', (worker, message) => this.onWorkerMessage(worker, message));
69
+ for (let i = 0; i < this.workers; i++) {
70
+ this.fork();
71
+ }
72
+ if (this.unresponsiveTimeout > 0) {
73
+ this.checkTimer = setInterval(() => this.checkWorkers(), this.checkInterval);
74
+ }
75
+ }
76
+ /**
77
+ * Terminate all workers, and exit as soon as they are all gone.
78
+ * @param signal The signal to send to the workers.
79
+ */
80
+ stop(signal) {
81
+ if (this.shuttingDown) {
82
+ return;
83
+ }
84
+ this.shuttingDown = true;
85
+ clearInterval(this.checkTimer);
86
+ const workers = this.getWorkers();
87
+ for (const worker of workers) {
88
+ worker.process.kill(signal);
89
+ }
90
+ if (workers.length === 0) {
91
+ this.exit(0);
92
+ return;
93
+ }
94
+ // Make sure we never hang on workers that refuse to shut down
95
+ this.killLater(workers, () => this.exit(0));
96
+ }
97
+ /**
98
+ * Get all workers that are currently registered within the cluster.
99
+ */
100
+ getWorkers() {
101
+ const workers = [];
102
+ for (const worker of Object.values(this.cluster.workers ?? {})) {
103
+ if (worker) {
104
+ workers.push(worker);
105
+ }
106
+ }
107
+ return workers;
108
+ }
109
+ /**
110
+ * Start a new worker process.
111
+ */
112
+ fork() {
113
+ const worker = this.cluster.fork();
114
+ const now = Date.now();
115
+ this.states[worker.id] = { startedAt: now, lastSeen: now, replaced: false, terminated: false };
116
+ }
117
+ /**
118
+ * Handle a message that was sent by a worker.
119
+ * @param worker The worker that sent the message.
120
+ * @param message The message that was sent.
121
+ */
122
+ onWorkerMessage(worker, message) {
123
+ const state = this.states[worker.id];
124
+ if (!state) {
125
+ return;
126
+ }
127
+ if (message === exports.MESSAGE_HEARTBEAT) {
128
+ state.lastSeen = Date.now();
129
+ }
130
+ else if (message === exports.MESSAGE_RECYCLE) {
131
+ this.recycleWorker(worker);
132
+ }
133
+ }
134
+ /**
135
+ * Replace a worker by a fresh one, and let the old worker finish its pending responses in the meantime.
136
+ * @param worker The worker to replace.
137
+ */
138
+ recycleWorker(worker) {
139
+ const state = this.states[worker.id];
140
+ if (state.replaced || this.shuttingDown) {
141
+ return;
142
+ }
143
+ this.stderr.write(`Worker ${worker.process.pid} requested to be replaced. Spawning a fresh worker...\n`);
144
+ // Fork the replacement before stopping the old worker, so that requests keep being handled
145
+ state.replaced = true;
146
+ state.terminated = true;
147
+ this.fork();
148
+ // Stop the old worker from accepting new connections, and kill it once its pending responses were sent.
149
+ // Disconnected workers are removed from the cluster, so they must be killed by reference.
150
+ worker.disconnect();
151
+ this.killLater([worker]);
152
+ }
153
+ /**
154
+ * Restart a worker that died, unless workers keep dying immediately after startup.
155
+ * @param worker The worker that died.
156
+ * @param code The exit code of the worker.
157
+ * @param signal The signal that terminated the worker.
158
+ */
159
+ onWorkerExit(worker, code, signal) {
160
+ const state = this.states[worker.id];
161
+ delete this.states[worker.id];
162
+ if (this.shuttingDown) {
163
+ if (this.getWorkers().length === 0) {
164
+ this.exit(0);
165
+ }
166
+ return;
167
+ }
168
+ // A replacement for this worker was already forked
169
+ if (state?.replaced) {
170
+ this.stderr.write(`Replaced worker ${worker.process.pid} exited (${signal ?? code})\n`);
171
+ return;
172
+ }
173
+ // Workers that die before they could ever become healthy indicate a startup failure,
174
+ // such as a port that is already in use, in which case restarting in a loop is pointless.
175
+ if (state && !state.terminated && Date.now() - state.startedAt < this.healthyUptime) {
176
+ this.rapidRestarts++;
177
+ if (this.rapidRestarts >= this.maxRapidRestarts) {
178
+ this.stderr.write(`Worker ${worker.process.pid} died (${signal ?? code}) during startup, ${this.rapidRestarts} times in a row. Giving up.\n`);
179
+ this.shuttingDown = true;
180
+ clearInterval(this.checkTimer);
181
+ this.exit(1);
182
+ return;
183
+ }
184
+ }
185
+ else {
186
+ this.rapidRestarts = 0;
187
+ }
188
+ this.stderr.write(`Worker ${worker.process.pid} died (${signal ?? code}). Restarting...\n`);
189
+ this.fork();
190
+ }
191
+ /**
192
+ * Kill all workers of which the event loop has been blocked for too long.
193
+ */
194
+ checkWorkers() {
195
+ const now = Date.now();
196
+ for (const worker of this.getWorkers()) {
197
+ const state = this.states[worker.id];
198
+ if (state && !state.terminated && now - state.lastSeen > this.unresponsiveTimeout) {
199
+ this.stderr.write(`Worker ${worker.process.pid} was unresponsive for more than ${this.unresponsiveTimeout}ms. Killing it...\n`);
200
+ state.terminated = true;
201
+ worker.process.kill('SIGKILL');
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Forcefully kill the given workers if they are still alive after the shutdown grace period.
207
+ * @param workers The workers to kill.
208
+ * @param callback An optional callback to invoke afterwards.
209
+ */
210
+ killLater(workers, callback) {
211
+ setTimeout(() => {
212
+ for (const worker of workers) {
213
+ worker.process.kill('SIGKILL');
214
+ }
215
+ callback?.();
216
+ }, this.shutdownGrace).unref();
217
+ }
218
+ }
219
+ exports.WorkerPool = WorkerPool;
220
+ /**
221
+ * Periodically inform the primary process that the event loop of this worker is not blocked.
222
+ * This is a no-op when the current process is not a cluster worker.
223
+ * @param interval The interval in milliseconds at which heartbeats are sent.
224
+ * @returns The heartbeat timer, or undefined if the current process is not a cluster worker.
225
+ */
226
+ function startHeartbeat(interval = exports.HEARTBEAT_INTERVAL) {
227
+ if (!process.send) {
228
+ return;
229
+ }
230
+ const timer = setInterval(() => sendToPrimary(exports.MESSAGE_HEARTBEAT), interval);
231
+ // The heartbeat must never keep the worker process alive by itself
232
+ timer.unref();
233
+ return timer;
234
+ }
235
+ /**
236
+ * Ask the primary process to replace this worker by a fresh one.
237
+ * This is a no-op when the current process is not a cluster worker.
238
+ * @returns If the request was sent.
239
+ */
240
+ function requestRecycle() {
241
+ return sendToPrimary(exports.MESSAGE_RECYCLE);
242
+ }
243
+ /**
244
+ * Send a message to the primary process, if this process is a cluster worker that is still connected to it.
245
+ *
246
+ * Workers stay alive for a while after being disconnected, so that they can finish their pending responses.
247
+ * Any message sent during that period would make the worker crash on an unhandled error event,
248
+ * which is why the connection is checked, and remaining errors are swallowed by passing a callback.
249
+ * @param message The message to send.
250
+ * @returns If the message was sent.
251
+ */
252
+ function sendToPrimary(message) {
253
+ if (!process.send || !process.connected) {
254
+ return false;
255
+ }
256
+ return process.send(message, undefined, undefined, () => {
257
+ // Ignore errors that occur when the IPC channel closes while sending
258
+ });
259
+ }
260
+ //# sourceMappingURL=WorkerPool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WorkerPool.js","sourceRoot":"","sources":["WorkerPool.ts"],"names":[],"mappings":"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","sourcesContent":["/* eslint-disable import/no-nodejs-modules,unicorn/no-process-exit */\nimport type { Cluster, Worker } from 'node:cluster';\nimport type { Writable } from 'node:stream';\n\n// The cluster module only has a default export, which can not be imported\n// as such within this CommonJS package, so it is required instead.\n// eslint-disable-next-line ts/no-require-imports,ts/no-var-requires\nconst nodeCluster: Cluster = require('node:cluster');\n\n/**\n * The message workers periodically send to the primary process to indicate that their event loop is responsive.\n */\nexport const MESSAGE_HEARTBEAT = 'comunica-mcp:heartbeat';\n\n/**\n * The message workers send to the primary process to ask to be replaced by a fresh worker.\n */\nexport const MESSAGE_RECYCLE = 'comunica-mcp:recycle';\n\n/**\n * The interval in milliseconds at which workers send a heartbeat to the primary process.\n */\nexport const HEARTBEAT_INTERVAL = 1_000;\n\n/**\n * The lower bound in milliseconds on the time a worker may block its event loop before it is considered stuck.\n * Workers are never killed faster than this, so that slow startups are not mistaken for hangs.\n */\nexport const MIN_UNRESPONSIVE_TIMEOUT = 30_000;\n\nexport interface IWorkerPoolArgs {\n /**\n * The number of workers that must be kept alive at all times.\n */\n workers: number;\n /**\n * The time in milliseconds a worker may block its event loop before it is killed and restarted.\n * A value of 0 disables this watchdog.\n */\n unresponsiveTimeout: number;\n /**\n * The stream to write log messages to.\n */\n stderr: Writable;\n /**\n * The cluster instance to manage workers with. Defaults to the Node.js cluster module.\n */\n cluster?: Cluster;\n /**\n * The interval in milliseconds at which worker liveness is checked. Defaults to {@link HEARTBEAT_INTERVAL}.\n */\n checkInterval?: number;\n /**\n * The time in milliseconds a worker must stay alive before its startup is considered successful.\n */\n healthyUptime?: number;\n /**\n * The number of consecutive failed startups after which the primary process gives up.\n */\n maxRapidRestarts?: number;\n /**\n * The time in milliseconds workers are given to shut down gracefully before they are killed.\n */\n shutdownGrace?: number;\n /**\n * Invoked when the primary process must terminate with the given exit code.\n */\n exit?: (code: number) => void;\n}\n\n/**\n * The state that the primary process keeps for each of its workers.\n */\ninterface IWorkerState {\n /**\n * The time at which the worker was forked.\n */\n startedAt: number;\n /**\n * The last time at which the worker was known to be responsive.\n */\n lastSeen: number;\n /**\n * If a replacement for this worker was already forked, so that its exit must not trigger another fork.\n */\n replaced: boolean;\n /**\n * If this worker was terminated on purpose.\n */\n terminated: boolean;\n}\n\n/**\n * Maintains a pool of worker processes that each run an MCP server, and replaces them when needed.\n *\n * Workers are needed because SPARQL queries can not always be cancelled from within the process that runs them:\n * * Queries that block the event loop make any timeout inside that worker unable to fire in the first place.\n * Such workers stop sending {@link MESSAGE_HEARTBEAT} messages, upon which they are killed and replaced.\n * * Queries that did time out keep consuming CPU and memory in the background, as Comunica offers no way to\n * abort an ongoing query. Workers therefore ask to be replaced via {@link MESSAGE_RECYCLE} after a timeout,\n * which lets them finish their pending responses while a fresh worker takes over.\n */\nexport class WorkerPool {\n private readonly workers: number;\n private readonly unresponsiveTimeout: number;\n private readonly stderr: Writable;\n private readonly cluster: Cluster;\n private readonly checkInterval: number;\n private readonly healthyUptime: number;\n private readonly maxRapidRestarts: number;\n private readonly shutdownGrace: number;\n private readonly exit: (code: number) => void;\n\n private readonly states: Record<number, IWorkerState> = {};\n private rapidRestarts = 0;\n private checkTimer: NodeJS.Timeout | undefined;\n private shuttingDown = false;\n\n public constructor(args: IWorkerPoolArgs) {\n this.workers = args.workers;\n this.unresponsiveTimeout = args.unresponsiveTimeout;\n this.stderr = args.stderr;\n this.cluster = args.cluster ?? nodeCluster;\n this.checkInterval = args.checkInterval ?? HEARTBEAT_INTERVAL;\n this.healthyUptime = args.healthyUptime ?? 5_000;\n this.maxRapidRestarts = args.maxRapidRestarts ?? 5;\n this.shutdownGrace = args.shutdownGrace ?? 10_000;\n this.exit = args.exit ?? (code => process.exit(code));\n }\n\n /**\n * Fork the configured number of workers, and start monitoring them.\n */\n public start(): void {\n this.stderr.write(`Primary ${process.pid} started, spawning ${this.workers} worker(s)\\n`);\n\n this.cluster.on('exit', (worker, code, signal) => this.onWorkerExit(worker, code, signal));\n this.cluster.on('message', (worker, message) => this.onWorkerMessage(worker, message));\n\n for (let i = 0; i < this.workers; i++) {\n this.fork();\n }\n\n if (this.unresponsiveTimeout > 0) {\n this.checkTimer = setInterval(() => this.checkWorkers(), this.checkInterval);\n }\n }\n\n /**\n * Terminate all workers, and exit as soon as they are all gone.\n * @param signal The signal to send to the workers.\n */\n public stop(signal: NodeJS.Signals): void {\n if (this.shuttingDown) {\n return;\n }\n this.shuttingDown = true;\n clearInterval(this.checkTimer);\n\n const workers = this.getWorkers();\n for (const worker of workers) {\n worker.process.kill(signal);\n }\n if (workers.length === 0) {\n this.exit(0);\n return;\n }\n\n // Make sure we never hang on workers that refuse to shut down\n this.killLater(workers, () => this.exit(0));\n }\n\n /**\n * Get all workers that are currently registered within the cluster.\n */\n protected getWorkers(): Worker[] {\n const workers: Worker[] = [];\n for (const worker of Object.values(this.cluster.workers ?? {})) {\n if (worker) {\n workers.push(worker);\n }\n }\n return workers;\n }\n\n /**\n * Start a new worker process.\n */\n protected fork(): void {\n const worker = this.cluster.fork();\n const now = Date.now();\n this.states[worker.id] = { startedAt: now, lastSeen: now, replaced: false, terminated: false };\n }\n\n /**\n * Handle a message that was sent by a worker.\n * @param worker The worker that sent the message.\n * @param message The message that was sent.\n */\n protected onWorkerMessage(worker: Worker, message: unknown): void {\n const state = this.states[worker.id];\n if (!state) {\n return;\n }\n if (message === MESSAGE_HEARTBEAT) {\n state.lastSeen = Date.now();\n } else if (message === MESSAGE_RECYCLE) {\n this.recycleWorker(worker);\n }\n }\n\n /**\n * Replace a worker by a fresh one, and let the old worker finish its pending responses in the meantime.\n * @param worker The worker to replace.\n */\n protected recycleWorker(worker: Worker): void {\n const state = this.states[worker.id];\n if (state.replaced || this.shuttingDown) {\n return;\n }\n this.stderr.write(`Worker ${worker.process.pid} requested to be replaced. Spawning a fresh worker...\\n`);\n\n // Fork the replacement before stopping the old worker, so that requests keep being handled\n state.replaced = true;\n state.terminated = true;\n this.fork();\n\n // Stop the old worker from accepting new connections, and kill it once its pending responses were sent.\n // Disconnected workers are removed from the cluster, so they must be killed by reference.\n worker.disconnect();\n this.killLater([ worker ]);\n }\n\n /**\n * Restart a worker that died, unless workers keep dying immediately after startup.\n * @param worker The worker that died.\n * @param code The exit code of the worker.\n * @param signal The signal that terminated the worker.\n */\n protected onWorkerExit(worker: Worker, code: number, signal: string | null): void {\n const state = this.states[worker.id];\n delete this.states[worker.id];\n\n if (this.shuttingDown) {\n if (this.getWorkers().length === 0) {\n this.exit(0);\n }\n return;\n }\n\n // A replacement for this worker was already forked\n if (state?.replaced) {\n this.stderr.write(`Replaced worker ${worker.process.pid} exited (${signal ?? code})\\n`);\n return;\n }\n\n // Workers that die before they could ever become healthy indicate a startup failure,\n // such as a port that is already in use, in which case restarting in a loop is pointless.\n if (state && !state.terminated && Date.now() - state.startedAt < this.healthyUptime) {\n this.rapidRestarts++;\n if (this.rapidRestarts >= this.maxRapidRestarts) {\n this.stderr.write(`Worker ${worker.process.pid} died (${signal ?? code}) during startup, ${this.rapidRestarts} times in a row. Giving up.\\n`);\n this.shuttingDown = true;\n clearInterval(this.checkTimer);\n this.exit(1);\n return;\n }\n } else {\n this.rapidRestarts = 0;\n }\n\n this.stderr.write(`Worker ${worker.process.pid} died (${signal ?? code}). Restarting...\\n`);\n this.fork();\n }\n\n /**\n * Kill all workers of which the event loop has been blocked for too long.\n */\n protected checkWorkers(): void {\n const now = Date.now();\n for (const worker of this.getWorkers()) {\n const state = this.states[worker.id];\n if (state && !state.terminated && now - state.lastSeen > this.unresponsiveTimeout) {\n this.stderr.write(`Worker ${worker.process.pid} was unresponsive for more than ${this.unresponsiveTimeout}ms. Killing it...\\n`);\n state.terminated = true;\n worker.process.kill('SIGKILL');\n }\n }\n }\n\n /**\n * Forcefully kill the given workers if they are still alive after the shutdown grace period.\n * @param workers The workers to kill.\n * @param callback An optional callback to invoke afterwards.\n */\n protected killLater(workers: Worker[], callback?: () => void): void {\n setTimeout(() => {\n for (const worker of workers) {\n worker.process.kill('SIGKILL');\n }\n callback?.();\n }, this.shutdownGrace).unref();\n }\n}\n\n/**\n * Periodically inform the primary process that the event loop of this worker is not blocked.\n * This is a no-op when the current process is not a cluster worker.\n * @param interval The interval in milliseconds at which heartbeats are sent.\n * @returns The heartbeat timer, or undefined if the current process is not a cluster worker.\n */\nexport function startHeartbeat(interval = HEARTBEAT_INTERVAL): NodeJS.Timeout | undefined {\n if (!process.send) {\n return;\n }\n const timer = setInterval(() => sendToPrimary(MESSAGE_HEARTBEAT), interval);\n // The heartbeat must never keep the worker process alive by itself\n timer.unref();\n return timer;\n}\n\n/**\n * Ask the primary process to replace this worker by a fresh one.\n * This is a no-op when the current process is not a cluster worker.\n * @returns If the request was sent.\n */\nexport function requestRecycle(): boolean {\n return sendToPrimary(MESSAGE_RECYCLE);\n}\n\n/**\n * Send a message to the primary process, if this process is a cluster worker that is still connected to it.\n *\n * Workers stay alive for a while after being disconnected, so that they can finish their pending responses.\n * Any message sent during that period would make the worker crash on an unhandled error event,\n * which is why the connection is checked, and remaining errors are swallowed by passing a callback.\n * @param message The message to send.\n * @returns If the message was sent.\n */\nfunction sendToPrimary(message: string): boolean {\n if (!process.send || !process.connected) {\n return false;\n }\n return process.send(message, undefined, undefined, () => {\n // Ignore errors that occur when the IPC channel closes while sending\n });\n}\n"]}
package/lib/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './SparqlMcpServer';
2
2
  export * from './BinHelpers';
3
+ export * from './WorkerPool';
package/lib/index.js CHANGED
@@ -16,4 +16,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./SparqlMcpServer"), exports);
18
18
  __exportStar(require("./BinHelpers"), exports);
19
+ __exportStar(require("./WorkerPool"), exports);
19
20
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,oDAAkC;AAClC,+CAA6B","sourcesContent":["export * from './SparqlMcpServer';\nexport * from './BinHelpers';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,oDAAkC;AAClC,+CAA6B;AAC7B,+CAA6B","sourcesContent":["export * from './SparqlMcpServer';\nexport * from './BinHelpers';\nexport * from './WorkerPool';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comunica/utils-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP helpers for Comunica",
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -46,5 +46,5 @@
46
46
  "yargs": "^17.7.2",
47
47
  "zod": "^4.3.6"
48
48
  },
49
- "gitHead": "8a35a73d61cf1066bb1181761884d84d3b932932"
49
+ "gitHead": "4d3c6e5233ec89ceeb45bd2a478652e7f74d5cd2"
50
50
  }