@thalovant/mcp-server 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.21
4
+
5
+ - Propagate MCP request cancellation through runtime identity acquisition and SDK connection, Ask, Query and event waits in stdio and Streamable HTTP mode. Require Node SDK 0.3.12 for Query cancellation and explicit HTTP admission cleanup failures, including contradictory disconnect acknowledgments.
6
+ - Skip cancelled queued calls without constructing a client or interrupting the active identity owner. Retain admitted work and actual cleanup after caller cancellation, including SDK operations without cancellation support; preserve failed-cleanup poisoning and never replay application writes.
7
+ - Check cancellation during connection setup before action/code sends, raw event publication and inventory discovery. Tool schemas and the 41-tool default catalogue remain compatible.
8
+ - Verify actual MCP cancellation against independent HTTPS Noise peers on both MCP transports, with late queued execution, pending physical write and healthy successor regressions. Add real remote-cleanup refusal cases that prove an identity with unconfirmed HTTP cleanup remains unavailable instead of admitting an overlapping client.
9
+
3
10
  ## 0.1.20
4
11
 
5
12
  - Require HTTPS for credential-bearing API calls, preserving explicit loopback HTTP for local development. Reject API redirects through the SDK. Instantiate runtime clients only after acquiring their identity lease.
package/README.md CHANGED
@@ -419,7 +419,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
419
419
  MCP process. Use a distinct client identity for each independently running MCP
420
420
  server so the hub can keep their sessions separate.
421
421
 
422
- Version 0.1.20 uses `@thalovant/sdk` `^0.3.9` (0.3.9 through versions below 0.4.0). This release enforces
422
+ Version 0.1.21 uses `@thalovant/sdk` `^0.3.12` (0.3.12 through versions below 0.4.0). This release enforces
423
423
  secure effective MQTT URLs and carries a single connection deadline through
424
424
  MQTT setup and HTTP failure cleanup. Runtime tools support
425
425
  HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
@@ -438,7 +438,31 @@ caller times out, a later tool waits up to six seconds for that cleanup before o
438
438
  A queued tool that reaches this deadline fails without executing later or releasing
439
439
  the previous session's identity.
440
440
  An actual cleanup failure marks the identity unavailable to later calls instead
441
- of risking concurrent sessions.
441
+ of risking concurrent sessions. These leases are process-local; separate server
442
+ processes or replicas need distinct runtime identities or external coordination.
443
+
444
+ MCP request cancellation applies to queued runtime tools in both stdio and
445
+ Streamable HTTP mode. A cancelled queued call never constructs a runtime client
446
+ or executes later. Cancellation also reaches runtime connection setup, Ask,
447
+ Query and event waits. Action/code sends, raw event publication and inventory
448
+ queries check cancellation during connection setup, then retain ownership until
449
+ the admitted SDK operation finishes. Cancelling the MCP request cannot undo an
450
+ already admitted operation or its effects. The identity remains reserved through
451
+ that work and actual cleanup; application requests are never automatically replayed.
452
+ Control-plane tools do not gain runtime cancellation semantics in this release.
453
+
454
+ For example, an MCP TypeScript client can cancel a runtime query after ten seconds,
455
+ including time spent waiting for its identity lease:
456
+
457
+ ```typescript
458
+ await mcpClient.callTool({
459
+ name: "thalovant_query",
460
+ arguments: { identityFile: "/secure/identity.json", text: "What is the weather?", timeoutMs: 30000 },
461
+ }, undefined, { signal: AbortSignal.timeout(10000) });
462
+ ```
463
+
464
+ Intent inventory retains its connection setup and per-query/batch budgets plus
465
+ a separate optional fallback probe; it has no single inventory-wide deadline.
442
466
 
443
467
  ## Development
444
468
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { withRuntimeLease } from "./runtime-lease.js";
2
+ import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -520,7 +520,7 @@ function registerThalovantTool(server, name, config, handler) {
520
520
  const start = Date.now();
521
521
  try {
522
522
  await authorizeTool(name, principal);
523
- const result = await requestContext.run({ toolName: name, principal }, () => handler(args, extra));
523
+ const result = await requestContext.run({ toolName: name, principal, signal: extra.signal }, () => handler(args, extra));
524
524
  await auditLog({
525
525
  ts: new Date().toISOString(),
526
526
  event: "mcp.tool",
@@ -670,11 +670,13 @@ async function createRuntimeIdentity(options) {
670
670
  }
671
671
  /** Use one identity lease through the action and actual retained cleanup. */
672
672
  async function withRuntimeClient(options, run) {
673
+ const signal = requestContext.getStore()?.signal;
674
+ throwIfRuntimeCancelled(signal);
673
675
  const identity = await createRuntimeIdentity(options);
674
676
  const key = createHash("sha256").update(JSON.stringify([
675
677
  identity.endpointBase(), identity.accessKey,
676
678
  ])).digest("hex");
677
- return withRuntimeLease(key, () => new ThalovantClient(identity, { protocol: options.protocol ?? "wss" }), run);
679
+ return withRuntimeLease(key, () => new ThalovantClient(identity, { protocol: options.protocol ?? "wss" }), client => run(client, signal), 6000, signal);
678
680
  }
679
681
  function summarizeReply(reply) {
680
682
  const displayItems = reply.displayItems({ maxTextChars: 1_000 });
@@ -1307,8 +1309,8 @@ export function createServer() {
1307
1309
  openWorldHint: true,
1308
1310
  },
1309
1311
  }, async ({ timeoutMs, ...runtime }) => {
1310
- return withRuntimeClient(runtime, async (client) => {
1311
- await client.connect(clampTimeout(timeoutMs));
1312
+ return withRuntimeClient(runtime, async (client, signal) => {
1313
+ await client.connect(clampTimeout(timeoutMs), signal);
1312
1314
  return jsonContent(redactSecrets(client.healthcheck()));
1313
1315
  });
1314
1316
  });
@@ -1323,7 +1325,9 @@ export function createServer() {
1323
1325
  timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS).describe("Per-query/batch timeout; the optional fallback-skill probe is capped at 1500ms."),
1324
1326
  },
1325
1327
  annotations: { readOnlyHint: true, openWorldHint: true },
1326
- }, async ({ languages, describe, fallback, timeoutMs, ...runtime }) => withRuntimeClient(runtime, async (client) => {
1328
+ }, async ({ languages, describe, fallback, timeoutMs, ...runtime }) => withRuntimeClient(runtime, async (client, signal) => {
1329
+ await client.connect(undefined, signal);
1330
+ throwIfRuntimeCancelled(signal);
1327
1331
  const inventory = await client.intents(languages, { describe, fallback, timeoutMs: clampTimeout(timeoutMs) });
1328
1332
  return jsonContent(redactSecrets({
1329
1333
  ...inventory.asObject(),
@@ -1351,9 +1355,10 @@ export function createServer() {
1351
1355
  openWorldHint: true,
1352
1356
  },
1353
1357
  }, async ({ text, timeoutMs, lang, sessionId, requestId, context, replySettleMs, emptyReplyWaitMs, ...runtime }) => {
1354
- return withRuntimeClient(runtime, async (client) => {
1358
+ return withRuntimeClient(runtime, async (client, signal) => {
1355
1359
  const builtContext = context ? buildClientContext({}, context) : undefined;
1356
1360
  const reply = await client.ask(text, {
1361
+ signal,
1357
1362
  timeoutMs: clampTimeout(timeoutMs),
1358
1363
  lang,
1359
1364
  sessionId,
@@ -1381,8 +1386,9 @@ export function createServer() {
1381
1386
  },
1382
1387
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1383
1388
  }, async ({ text, timeoutMs, lang, sessionId, requestId, queryId, context, replySettleMs, ...runtime }) => {
1384
- return withRuntimeClient(runtime, async (client) => {
1389
+ return withRuntimeClient(runtime, async (client, signal) => {
1385
1390
  const reply = await client.query(text, {
1391
+ signal,
1386
1392
  timeoutMs: clampTimeout(timeoutMs), lang, sessionId, requestId, queryId,
1387
1393
  context: context ? buildClientContext({}, context) : undefined, replySettleMs,
1388
1394
  });
@@ -1408,7 +1414,9 @@ export function createServer() {
1408
1414
  openWorldHint: true,
1409
1415
  },
1410
1416
  }, async ({ payload, title, lang, sessionId, requestId, context, ...runtime }) => {
1411
- return withRuntimeClient(runtime, async (client) => {
1417
+ return withRuntimeClient(runtime, async (client, signal) => {
1418
+ await client.connect(undefined, signal);
1419
+ throwIfRuntimeCancelled(signal);
1412
1420
  await client.sendAction(payload, {
1413
1421
  title,
1414
1422
  lang,
@@ -1439,7 +1447,9 @@ export function createServer() {
1439
1447
  openWorldHint: true,
1440
1448
  },
1441
1449
  }, async ({ value, kind, label, lang, sessionId, requestId, context, ...runtime }) => {
1442
- return withRuntimeClient(runtime, async (client) => {
1450
+ return withRuntimeClient(runtime, async (client, signal) => {
1451
+ await client.connect(undefined, signal);
1452
+ throwIfRuntimeCancelled(signal);
1443
1453
  await client.sendCode(value, {
1444
1454
  kind,
1445
1455
  label,
@@ -1467,7 +1477,9 @@ export function createServer() {
1467
1477
  openWorldHint: true,
1468
1478
  },
1469
1479
  }, async ({ eventType, data, context, ...runtime }) => {
1470
- return withRuntimeClient(runtime, async (client) => {
1480
+ return withRuntimeClient(runtime, async (client, signal) => {
1481
+ await client.connect(undefined, signal);
1482
+ throwIfRuntimeCancelled(signal);
1471
1483
  await client.emit(eventType, data, context ? buildClientContext({}, context) : undefined);
1472
1484
  return textContent("Event emitted.");
1473
1485
  });
@@ -1488,8 +1500,9 @@ export function createServer() {
1488
1500
  openWorldHint: true,
1489
1501
  },
1490
1502
  }, async ({ eventName, timeoutMs, sessionId, requestId, context, ...runtime }) => {
1491
- return withRuntimeClient(runtime, async (client) => {
1503
+ return withRuntimeClient(runtime, async (client, signal) => {
1492
1504
  const event = await client.waitForEvent(eventName, {
1505
+ signal,
1493
1506
  timeoutMs: clampTimeout(timeoutMs),
1494
1507
  sessionId,
1495
1508
  requestId,
@@ -3,6 +3,7 @@ interface RuntimeClosable {
3
3
  close(): Promise<void>;
4
4
  waitForClosed(): Promise<void>;
5
5
  }
6
- /** A caller timeout never releases an identity before actual cleanup settles. */
7
- export declare function withRuntimeLease<T, C extends RuntimeClosable>(key: string, createClient: () => C, run: (client: C) => Promise<T>, acquireTimeoutMs?: number): Promise<T>;
6
+ export declare function throwIfRuntimeCancelled(signal?: AbortSignal): void;
7
+ /** Caller cancellation/deadlines never release an identity before owned work and cleanup settle. */
8
+ export declare function withRuntimeLease<T, C extends RuntimeClosable>(key: string, createClient: () => C, run: (client: C) => Promise<T>, acquireTimeoutMs?: number, signal?: AbortSignal): Promise<T>;
8
9
  export {};
@@ -1,6 +1,17 @@
1
1
  const leases = new Map();
2
- /** A caller timeout never releases an identity before actual cleanup settles. */
3
- export async function withRuntimeLease(key, createClient, run, acquireTimeoutMs = 6000) {
2
+ function cancellationError() {
3
+ // MCP cancellation reasons are caller supplied and may contain credentials.
4
+ const error = new Error("Runtime tool request cancelled.");
5
+ error.name = "AbortError";
6
+ return error;
7
+ }
8
+ export function throwIfRuntimeCancelled(signal) {
9
+ if (signal?.aborted)
10
+ throw cancellationError();
11
+ }
12
+ /** Caller cancellation/deadlines never release an identity before owned work and cleanup settle. */
13
+ export async function withRuntimeLease(key, createClient, run, acquireTimeoutMs = 6000, signal) {
14
+ throwIfRuntimeCancelled(signal);
4
15
  if (!Number.isFinite(acquireTimeoutMs) || acquireTimeoutMs <= 0) {
5
16
  throw new Error("Runtime identity acquisition deadline expired.");
6
17
  }
@@ -8,8 +19,7 @@ export async function withRuntimeLease(key, createClient, run, acquireTimeoutMs
8
19
  let release;
9
20
  let fail;
10
21
  const current = new Promise((resolve, reject) => { release = resolve; fail = reject; });
11
- // A failed cleanup poisons this identity's barrier. Observe it even when no
12
- // later caller arrives; later tools fail instead of replacing that session.
22
+ // Observe poisoned barriers even when no later caller arrives.
13
23
  void current.catch(() => undefined);
14
24
  leases.set(key, current);
15
25
  const releaseIdentity = () => {
@@ -20,45 +30,68 @@ export async function withRuntimeLease(key, createClient, run, acquireTimeoutMs
20
30
  const failIdentity = () => {
21
31
  fail(new Error("Previous runtime session cleanup failed; this identity remains unavailable."));
22
32
  };
23
- let timer;
24
- try {
25
- await Promise.race([
26
- previous,
27
- new Promise((_, reject) => {
28
- timer = setTimeout(() => reject(new Error("Runtime identity acquisition deadline expired; previous session cleanup is still pending.")), acquireTimeoutMs);
29
- }),
30
- ]);
31
- }
32
- catch (error) {
33
- // An expired waiter never runs or closes its unused client. Its place in
34
- // the chain remains occupied until the prior owner actually retires.
35
- void previous.then(releaseIdentity, failIdentity);
36
- throw error;
37
- }
38
- finally {
39
- clearTimeout(timer);
40
- }
41
- let client;
42
- try {
43
- client = createClient();
44
- }
45
- catch (error) {
46
- releaseIdentity();
47
- throw error;
48
- }
33
+ let onAbort;
34
+ const cancelled = new Promise((_, reject) => {
35
+ onAbort = () => reject(cancellationError());
36
+ signal?.addEventListener("abort", onAbort, { once: true });
37
+ });
38
+ // The signal may fire during a synchronous client factory or cleanup callback.
39
+ // Observe it even when execution fails before the final race is installed.
40
+ void cancelled.catch(() => undefined);
49
41
  try {
50
- return await run(client);
51
- }
52
- finally {
53
- let closing;
42
+ let timer;
43
+ try {
44
+ await Promise.race([
45
+ previous, cancelled,
46
+ new Promise((_, reject) => {
47
+ timer = setTimeout(() => reject(new Error("Runtime identity acquisition deadline expired; previous session cleanup is still pending.")), acquireTimeoutMs);
48
+ }),
49
+ ]);
50
+ throwIfRuntimeCancelled(signal);
51
+ }
52
+ catch (error) {
53
+ // An abandoned waiter never constructs a client or runs later. Its place
54
+ // in the chain remains occupied until the prior owner actually retires.
55
+ void previous.then(releaseIdentity, failIdentity);
56
+ throw error;
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ let client;
54
62
  try {
55
- closing = client.close();
63
+ client = createClient();
56
64
  }
57
65
  catch (error) {
58
- failIdentity();
66
+ releaseIdentity();
59
67
  throw error;
60
68
  }
61
- void client.waitForClosed().then(releaseIdentity, failIdentity);
62
- await closing;
69
+ const owned = (async () => {
70
+ try {
71
+ throwIfRuntimeCancelled(signal);
72
+ return await run(client);
73
+ }
74
+ finally {
75
+ let closing;
76
+ try {
77
+ closing = client.close();
78
+ // Observe close independently if waitForClosed throws synchronously.
79
+ void closing.catch(() => undefined);
80
+ void client.waitForClosed().then(releaseIdentity, failIdentity);
81
+ }
82
+ catch (error) {
83
+ failIdentity();
84
+ throw error;
85
+ }
86
+ await closing;
87
+ }
88
+ })();
89
+ // SDK methods accepting a signal can retire promptly. Other admitted work
90
+ // must finish naturally before close begins; cancellation never replays it.
91
+ // The race observes both branches, including late work/cleanup rejections.
92
+ return await Promise.race([owned, cancelled]);
93
+ }
94
+ finally {
95
+ signal?.removeEventListener("abort", onAbort);
63
96
  }
64
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Stdio and Streamable HTTP MCP server for Thalovant control-plane and hub runtime APIs.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@modelcontextprotocol/sdk": "^1.30.0",
67
- "@thalovant/sdk": "^0.3.9",
67
+ "@thalovant/sdk": "^0.3.12",
68
68
  "jose": "^6.2.8",
69
69
  "zod": "^3.25.76"
70
70
  },
package/server.json CHANGED
@@ -7,19 +7,19 @@
7
7
  "url": "https://github.com/thalovant/thalovant-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.1.20",
10
+ "version": "0.1.21",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.1.20",
15
+ "version": "0.1.21",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
19
19
  },
20
20
  {
21
21
  "registryType": "oci",
22
- "identifier": "ghcr.io/thalovant/thalovant-mcp:0.1.20",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.1.21",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"