@thalovant/mcp-server 0.1.19 → 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,23 @@
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
+
10
+ ## 0.1.20
11
+
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.
13
+ - Bind server-managed Thalovant tokens and password login to their configured API origin. Reject cross-origin tool overrides before any outbound request, including credentials for remote principals; retain configured custom origins and anonymous public discovery.
14
+ - Add `thalovant_query` for routed HiveMind queries, preserving query/request/session correlation and runtime identity cleanup; hide it in read-only mode. The default catalogue now contains 41 tools.
15
+ - Add read-only `thalovant_intent_inventory` and `thalovant_get_operation` tools for runtime discovery and asynchronous provisioning status. Inventory preserves fallback knowledge and conservative answerability.
16
+ - Require Node SDK 0.3.9 for aligned intent discovery, request correlation, whole-operation reply deadlines, fixed Ask settlement windows, cancellation and retained cleanup ownership, and credential-safe control-plane redirect handling.
17
+ - Retain the runtime identity lease until actual cleanup, even after a caller close timeout; failed cleanup blocks later identity reuse with an explicit error. Bound queued lease acquisition to six seconds without releasing pending ownership or executing expired callers.
18
+ - Upgrade the test runner to Vitest 4.1.11 to remove GHSA-82fw-gwwq-j7x9.
19
+ - Test Node 20, 22 and 24; add per-principal/shared credential regressions and exercise the inventory tool over a real HTTPS Noise session.
20
+
3
21
  ## 0.1.19
4
22
 
5
23
  - Require Node SDK 0.3.5 or newer so runtime tools receive secure MQTT URL enforcement and bounded MQTT setup and HTTP failure cleanup.
package/README.md CHANGED
@@ -93,6 +93,13 @@ npm start
93
93
 
94
94
  If neither a token nor email/password is configured, authenticated control-plane tools fail with a clear error naming the supported options.
95
95
 
96
+ Configured API tokens and login credentials are bound to the origin of their
97
+ configured `apiUrl` or `THALOVANT_API_URL` (default `https://api.thalovant.com`).
98
+ A tool argument cannot redirect those credentials to another origin. Equivalent
99
+ URL spellings and paths on the same origin are allowed; custom origins must be
100
+ configured alongside their credentials. Anonymous public discovery may still
101
+ select a custom API URL.
102
+
96
103
  ## Local Stdio
97
104
 
98
105
  The server speaks MCP over stdio and does not write logs to stdout.
@@ -305,8 +312,10 @@ Read-only:
305
312
  - `thalovant_get_public_hub`
306
313
  - `thalovant_list_hubs`
307
314
  - `thalovant_get_hub`
315
+ - `thalovant_get_operation`
308
316
  - `thalovant_identity_status`
309
317
  - `thalovant_healthcheck`
318
+ - `thalovant_intent_inventory`
310
319
  - `thalovant_wait_for_event`
311
320
  - `thalovant_get_analytics_overview`
312
321
  - `thalovant_list_memory_items`
@@ -327,6 +336,7 @@ Writes or hub events:
327
336
 
328
337
  - `thalovant_create_client_identity`
329
338
  - `thalovant_ask`
339
+ - `thalovant_query`
330
340
  - `thalovant_send_action`
331
341
  - `thalovant_send_code`
332
342
  - `thalovant_emit_event`
@@ -355,6 +365,16 @@ Destructive, **not registered unless explicitly enabled** (see [Destructive Tool
355
365
 
356
366
  Tool outputs redact credential-shaped fields. `thalovant_create_client_identity` does not return secret identity material; pass `savePath` when you want the full identity written to a local file with mode `0600`. `savePath` is confined to the server's identity directory (`THALOVANT_MCP_IDENTITY_DIR`, default `<config-dir>/thalovant/identities`): pass a plain filename, since absolute paths outside that directory and `..` traversal are rejected, so a model cannot drop a credential file into a git working tree or synced folder. `thalovant_config_status` reports the active `identityDir`.
357
367
 
368
+ `thalovant_intent_inventory` uses the runtime identity and accepts `languages`,
369
+ `describe`, `fallback`, and a per-query/batch `timeoutMs`. It returns registered
370
+ intents and examples, fallback skills, `fallbacks_known`, and `may_answer` by
371
+ requested language. A missing or denied optional fallback-skill query remains
372
+ unknown, rather than being reported as a known empty list. The optional probe
373
+ adds at most 1500ms. A silent listing can use engine manifests; disable this with
374
+ `fallback: false`. Both new tools are available in read-only mode and respect
375
+ per-principal policy. `thalovant_get_operation` reads an operation ID returned by
376
+ provisioning without replaying the write.
377
+
358
378
  ## Destructive Tools
359
379
 
360
380
  `thalovant_delete_hub` and `thalovant_delete_runtime_group` are **disabled by default**. They are not merely blocked when called — they are never registered, so they do not appear in `tools/list` and a model cannot see or attempt them.
@@ -399,7 +419,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
399
419
  MCP process. Use a distinct client identity for each independently running MCP
400
420
  server so the hub can keep their sessions separate.
401
421
 
402
- Version 0.1.19 uses `@thalovant/sdk` 0.3.5 or newer. 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
403
423
  secure effective MQTT URLs and carries a single connection deadline through
404
424
  MQTT setup and HTTP failure cleanup. Runtime tools support
405
425
  HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
@@ -413,6 +433,37 @@ failure preserves those pins; replacing a server key requires an explicit,
413
433
  verified trust change. HTTPS identities must advertise the hub's HTTPS plugin,
414
434
  and MQTT requires the identity's broker credentials and topic prefix.
415
435
 
436
+ Runtime tools retain their identity lease through actual cleanup. If the close
437
+ caller times out, a later tool waits up to six seconds for that cleanup before opening a session.
438
+ A queued tool that reaches this deadline fails without executing later or releasing
439
+ the previous session's identity.
440
+ An actual cleanup failure marks the identity unavailable to later calls instead
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.
466
+
416
467
  ## Development
417
468
 
418
469
  ```bash
@@ -429,3 +480,16 @@ npm pack --dry-run
429
480
  ## License
430
481
 
431
482
  MIT. This is the right default for a public integration server: it is permissive, compatible with the MIT Thalovant Node SDK and MCP TypeScript SDK, and does not force downstream agent or enterprise users into a reciprocal licensing model.
483
+
484
+ Credential-bearing control-plane calls require HTTPS. Explicit loopback HTTP
485
+ (`localhost`, `127.0.0.1`, `[::1]`) remains supported for local development.
486
+ API redirects are rejected so password-login bodies cannot be forwarded.
487
+ Runtime clients are constructed only after acquiring their identity lease.
488
+
489
+ `thalovant_query` sends a routed HiveMind query; the hub may cascade it according
490
+ to its routing policy. It accepts `text`, `timeoutMs`, `lang`, `sessionId`,
491
+ `requestId`, `queryId`, `context` and `replySettleMs`, plus the runtime identity
492
+ options, and returns the same normalized reply shape as `thalovant_ask`. A query
493
+ may trigger actions, so read-only mode hides it. Conversation workflows use
494
+ `sessionId` with ask/query and the existing event-wait tool; MCP does not expose
495
+ one tool for every SDK conversation or event-listener method.
package/SECURITY.md CHANGED
@@ -20,3 +20,13 @@ Do not open public issues for vulnerabilities. Email security reports to `hello@
20
20
  - Keep `THALOVANT_ALLOW_SHARED_CREDENTIALS=false` for multi-user deployments.
21
21
  - Set exact `MCP_HTTP_ALLOWED_HOSTS` and `MCP_HTTP_ALLOWED_ORIGINS`.
22
22
  - Send audit logs to your log pipeline with `MCP_AUDIT_LOG=stderr` or `MCP_AUDIT_LOG_FILE`.
23
+
24
+
25
+ Server-managed Thalovant tokens and password login are bound to their configured
26
+ control-plane origin. Tool-provided `apiUrl` overrides with a different origin
27
+ are rejected before any request is sent. Configure a custom API origin together
28
+ with its credentials; per-principal credentials follow the same rule.
29
+
30
+ Control-plane credentials require HTTPS, with an explicit loopback HTTP exception
31
+ for local development (`localhost`, `127.0.0.1`, `[::1]`). API redirects are rejected,
32
+ including redirects that would preserve password-login request bodies.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -77,7 +78,7 @@ const controlPlaneSchema = {
77
78
  .string()
78
79
  .url()
79
80
  .optional()
80
- .describe("Control-plane base URL. Defaults to https://api.thalovant.com."),
81
+ .describe("Control-plane base URL. Configured credentials may only be used on their configured origin; defaults to https://api.thalovant.com."),
81
82
  };
82
83
  const contextSchema = z
83
84
  .object({
@@ -519,7 +520,7 @@ function registerThalovantTool(server, name, config, handler) {
519
520
  const start = Date.now();
520
521
  try {
521
522
  await authorizeTool(name, principal);
522
- 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));
523
524
  await auditLog({
524
525
  ts: new Date().toISOString(),
525
526
  event: "mcp.tool",
@@ -567,20 +568,40 @@ function redactSecrets(value) {
567
568
  }
568
569
  return output;
569
570
  }
571
+ /** Keep server-managed credentials on their configured control-plane origin. */
570
572
  async function createControlPlane(options) {
571
573
  const principal = currentPrincipal();
572
574
  const credential = await credentialForPrincipal(principal);
573
575
  const canUseShared = allowSharedThalovantCredentials(principal);
574
- const apiUrl = options.apiUrl ?? credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
576
+ const configuredApiUrl = credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
577
+ const apiUrl = options.apiUrl ?? configuredApiUrl;
575
578
  const accessToken = credential?.control?.accessToken ??
576
579
  (canUseShared ? process.env.THALOVANT_API_TOKEN ?? process.env.THALOVANT_ACCESS_TOKEN : undefined);
580
+ const email = canUseShared ? process.env.THALOVANT_EMAIL : undefined;
581
+ const password = canUseShared ? process.env.THALOVANT_PASSWORD : undefined;
582
+ if (accessToken || (email && password)) {
583
+ let endpoint;
584
+ let configuredEndpoint;
585
+ try {
586
+ endpoint = new URL(apiUrl);
587
+ configuredEndpoint = new URL(configuredApiUrl);
588
+ }
589
+ catch {
590
+ throw new Error("The configured control-plane API URL is invalid.");
591
+ }
592
+ if (endpoint.origin !== configuredEndpoint.origin) {
593
+ throw new Error("apiUrl must use the configured Thalovant credential origin.");
594
+ }
595
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname);
596
+ if (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) {
597
+ throw new Error("Credential-bearing control-plane requests require HTTPS (except explicit loopback HTTP).");
598
+ }
599
+ }
577
600
  const api = new ThalovantControlPlane(apiUrl, {
578
601
  accessToken,
579
602
  userAgent: `thalovant-mcp/${VERSION}`,
580
603
  });
581
604
  if (!accessToken && canUseShared) {
582
- const email = process.env.THALOVANT_EMAIL;
583
- const password = process.env.THALOVANT_PASSWORD;
584
605
  if (email && password) {
585
606
  await api.login(email, password, { scope: options.loginScope ?? process.env.THALOVANT_SCOPE });
586
607
  }
@@ -601,28 +622,26 @@ function controlPlaneAuthModeFromEnv() {
601
622
  return "email-password";
602
623
  return "none";
603
624
  }
604
- async function createRuntimeClient(options) {
605
- const protocol = options.protocol ?? "wss";
625
+ async function createRuntimeIdentity(options) {
606
626
  const principal = currentPrincipal();
607
627
  const credential = await credentialForPrincipal(principal);
608
628
  const runtimeCredential = credential?.runtime;
609
629
  const canUseShared = allowSharedThalovantCredentials(principal);
610
630
  const canUseToolPaths = canUseShared || parseBool(process.env.MCP_HTTP_ALLOW_CLIENT_CREDENTIAL_PATHS, false);
611
631
  if (runtimeCredential?.identity) {
612
- return new ThalovantClient(new ThalovantIdentity(runtimeCredential.identity), { protocol });
632
+ return new ThalovantIdentity(runtimeCredential.identity);
613
633
  }
614
634
  if (runtimeCredential?.identityFile) {
615
- return ThalovantClient.fromIdentityFile(runtimeCredential.identityFile, { protocol });
635
+ return ThalovantIdentity.fromFile(runtimeCredential.identityFile);
616
636
  }
617
637
  if (runtimeCredential?.configPath || runtimeCredential?.profile) {
618
- return ThalovantClient.fromConfig({
638
+ return ThalovantIdentity.fromConfig({
619
639
  path: runtimeCredential.configPath,
620
640
  profile: runtimeCredential.profile,
621
- protocol,
622
641
  });
623
642
  }
624
643
  if (runtimeCredential?.fromEnv && canUseShared) {
625
- return ThalovantClient.fromEnv({ protocol });
644
+ return ThalovantIdentity.fromEnv();
626
645
  }
627
646
  if (isRemotePrincipal(principal) && !canUseShared && !canUseToolPaths) {
628
647
  throw new Error("No per-principal Thalovant runtime identity is configured for this authenticated MCP principal.");
@@ -631,57 +650,33 @@ async function createRuntimeClient(options) {
631
650
  throw new Error("Runtime identity path/config arguments are disabled for remote MCP principals.");
632
651
  }
633
652
  if (options.identityFile) {
634
- return ThalovantClient.fromIdentityFile(options.identityFile, { protocol });
653
+ return ThalovantIdentity.fromFile(options.identityFile);
635
654
  }
636
655
  if (options.configPath || options.profile) {
637
- return ThalovantClient.fromConfig({
656
+ return ThalovantIdentity.fromConfig({
638
657
  path: options.configPath,
639
658
  profile: options.profile ?? process.env.THALOVANT_PROFILE,
640
- protocol,
641
659
  });
642
660
  }
643
661
  if (!canUseShared) {
644
662
  throw new Error("No per-principal Thalovant runtime identity is configured for this authenticated MCP principal.");
645
663
  }
646
664
  if (options.fromEnv || process.env.THALOVANT_ACCESS_KEY) {
647
- return ThalovantClient.fromEnv({ protocol });
665
+ return ThalovantIdentity.fromEnv();
648
666
  }
649
- return ThalovantClient.fromConfig({
667
+ return ThalovantIdentity.fromConfig({
650
668
  profile: process.env.THALOVANT_PROFILE,
651
- protocol,
652
669
  });
653
670
  }
654
- // The hub HTTP/MQTT queues and Noise sessions are keyed by client identity.
655
- // A second tool call must finish after the first closes that identity's session.
656
- const runtimeLeases = new Map();
657
- /**
658
- * Hold an identity lease through both the tool action and client cleanup.
659
- * This keeps HTTP/MQTT admissions from replacing another call's Noise session;
660
- * a failed action still closes its client before the next lease starts.
661
- */
671
+ /** Use one identity lease through the action and actual retained cleanup. */
662
672
  async function withRuntimeClient(options, run) {
663
- const client = await createRuntimeClient(options);
673
+ const signal = requestContext.getStore()?.signal;
674
+ throwIfRuntimeCancelled(signal);
675
+ const identity = await createRuntimeIdentity(options);
664
676
  const key = createHash("sha256").update(JSON.stringify([
665
- client.identity.endpointBase(), client.identity.accessKey,
677
+ identity.endpointBase(), identity.accessKey,
666
678
  ])).digest("hex");
667
- const previous = runtimeLeases.get(key) ?? Promise.resolve();
668
- let release;
669
- const current = new Promise(resolve => { release = resolve; });
670
- runtimeLeases.set(key, current);
671
- await previous;
672
- try {
673
- return await run(client);
674
- }
675
- finally {
676
- try {
677
- await client.close();
678
- }
679
- finally {
680
- release();
681
- if (runtimeLeases.get(key) === current)
682
- runtimeLeases.delete(key);
683
- }
684
- }
679
+ return withRuntimeLease(key, () => new ThalovantClient(identity, { protocol: options.protocol ?? "wss" }), client => run(client, signal), 6000, signal);
685
680
  }
686
681
  function summarizeReply(reply) {
687
682
  const displayItems = reply.displayItems({ maxTextChars: 1_000 });
@@ -1214,6 +1209,16 @@ export function createServer() {
1214
1209
  ensureAuthenticated(api);
1215
1210
  return jsonContent(redactSecrets(await api.getHub(hubId)));
1216
1211
  });
1212
+ registerThalovantTool(server, "thalovant_get_operation", {
1213
+ title: "Get Provisioning Operation",
1214
+ description: "Read the status of an asynchronous control-plane operation returned by provisioning.",
1215
+ inputSchema: { ...controlPlaneSchema, operationId: z.string().min(1).max(500) },
1216
+ annotations: { readOnlyHint: true, openWorldHint: true },
1217
+ }, async ({ operationId, ...auth }) => {
1218
+ const api = await createControlPlane(auth);
1219
+ ensureAuthenticated(api);
1220
+ return jsonContent(redactSecrets(await api.getOperation(operationId)));
1221
+ });
1217
1222
  registerThalovantTool(server, "thalovant_create_client_identity", {
1218
1223
  title: "Create Client Identity",
1219
1224
  description: "Create a Thalovant client identity for a hub. The identity is secret; output is redacted unless savePath is used.",
@@ -1304,11 +1309,31 @@ export function createServer() {
1304
1309
  openWorldHint: true,
1305
1310
  },
1306
1311
  }, async ({ timeoutMs, ...runtime }) => {
1307
- return withRuntimeClient(runtime, async (client) => {
1308
- await client.connect(clampTimeout(timeoutMs));
1312
+ return withRuntimeClient(runtime, async (client, signal) => {
1313
+ await client.connect(clampTimeout(timeoutMs), signal);
1309
1314
  return jsonContent(redactSecrets(client.healthcheck()));
1310
1315
  });
1311
1316
  });
1317
+ registerThalovantTool(server, "thalovant_intent_inventory", {
1318
+ title: "Runtime Intent Inventory",
1319
+ description: "Discover a hub's registered intents, language examples and fallback skills using its runtime identity. Unknown fallback support remains distinct from a known empty list; answerability is a conservative hint.",
1320
+ inputSchema: {
1321
+ ...runtimeAuthSchema,
1322
+ languages: z.array(z.string().trim().min(1).max(100)).min(1).max(20).default(["en-us"]),
1323
+ describe: z.boolean().default(true),
1324
+ fallback: z.boolean().default(true).describe("Try engine manifests if the intent listing is refused or unanswered."),
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."),
1326
+ },
1327
+ annotations: { readOnlyHint: true, openWorldHint: true },
1328
+ }, async ({ languages, describe, fallback, timeoutMs, ...runtime }) => withRuntimeClient(runtime, async (client, signal) => {
1329
+ await client.connect(undefined, signal);
1330
+ throwIfRuntimeCancelled(signal);
1331
+ const inventory = await client.intents(languages, { describe, fallback, timeoutMs: clampTimeout(timeoutMs) });
1332
+ return jsonContent(redactSecrets({
1333
+ ...inventory.asObject(),
1334
+ may_answer: Object.fromEntries(inventory.languages.map(lang => [lang, inventory.mayAnswer(lang)])),
1335
+ }));
1336
+ }));
1312
1337
  registerThalovantTool(server, "thalovant_ask", {
1313
1338
  title: "Ask Hub",
1314
1339
  description: "Send one text request to a Thalovant hub using a saved identity and return the normalized reply.",
@@ -1330,9 +1355,10 @@ export function createServer() {
1330
1355
  openWorldHint: true,
1331
1356
  },
1332
1357
  }, async ({ text, timeoutMs, lang, sessionId, requestId, context, replySettleMs, emptyReplyWaitMs, ...runtime }) => {
1333
- return withRuntimeClient(runtime, async (client) => {
1358
+ return withRuntimeClient(runtime, async (client, signal) => {
1334
1359
  const builtContext = context ? buildClientContext({}, context) : undefined;
1335
1360
  const reply = await client.ask(text, {
1361
+ signal,
1336
1362
  timeoutMs: clampTimeout(timeoutMs),
1337
1363
  lang,
1338
1364
  sessionId,
@@ -1344,6 +1370,31 @@ export function createServer() {
1344
1370
  return jsonContent(redactSecrets(summarizeReply(reply)));
1345
1371
  });
1346
1372
  });
1373
+ registerThalovantTool(server, "thalovant_query", {
1374
+ title: "Query Hub",
1375
+ description: "Send a routed HiveMind query using a saved identity and return the normalized reply. The hub may cascade the query according to its routing policy; a query can trigger actions.",
1376
+ inputSchema: {
1377
+ ...runtimeAuthSchema,
1378
+ text: z.string().trim().min(1).max(20_000),
1379
+ timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS),
1380
+ lang: z.string().min(1).optional(),
1381
+ sessionId: z.string().min(1).optional(),
1382
+ requestId: z.string().min(1).optional(),
1383
+ queryId: z.string().min(1).optional(),
1384
+ context: contextSchema,
1385
+ replySettleMs: z.number().int().min(0).max(10_000).optional(),
1386
+ },
1387
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1388
+ }, async ({ text, timeoutMs, lang, sessionId, requestId, queryId, context, replySettleMs, ...runtime }) => {
1389
+ return withRuntimeClient(runtime, async (client, signal) => {
1390
+ const reply = await client.query(text, {
1391
+ signal,
1392
+ timeoutMs: clampTimeout(timeoutMs), lang, sessionId, requestId, queryId,
1393
+ context: context ? buildClientContext({}, context) : undefined, replySettleMs,
1394
+ });
1395
+ return jsonContent(redactSecrets(summarizeReply(reply)));
1396
+ });
1397
+ });
1347
1398
  registerThalovantTool(server, "thalovant_send_action", {
1348
1399
  title: "Send Action",
1349
1400
  description: "Send a structured action payload to a Thalovant hub.",
@@ -1363,7 +1414,9 @@ export function createServer() {
1363
1414
  openWorldHint: true,
1364
1415
  },
1365
1416
  }, async ({ payload, title, lang, sessionId, requestId, context, ...runtime }) => {
1366
- return withRuntimeClient(runtime, async (client) => {
1417
+ return withRuntimeClient(runtime, async (client, signal) => {
1418
+ await client.connect(undefined, signal);
1419
+ throwIfRuntimeCancelled(signal);
1367
1420
  await client.sendAction(payload, {
1368
1421
  title,
1369
1422
  lang,
@@ -1394,7 +1447,9 @@ export function createServer() {
1394
1447
  openWorldHint: true,
1395
1448
  },
1396
1449
  }, async ({ value, kind, label, lang, sessionId, requestId, context, ...runtime }) => {
1397
- return withRuntimeClient(runtime, async (client) => {
1450
+ return withRuntimeClient(runtime, async (client, signal) => {
1451
+ await client.connect(undefined, signal);
1452
+ throwIfRuntimeCancelled(signal);
1398
1453
  await client.sendCode(value, {
1399
1454
  kind,
1400
1455
  label,
@@ -1422,7 +1477,9 @@ export function createServer() {
1422
1477
  openWorldHint: true,
1423
1478
  },
1424
1479
  }, async ({ eventType, data, context, ...runtime }) => {
1425
- return withRuntimeClient(runtime, async (client) => {
1480
+ return withRuntimeClient(runtime, async (client, signal) => {
1481
+ await client.connect(undefined, signal);
1482
+ throwIfRuntimeCancelled(signal);
1426
1483
  await client.emit(eventType, data, context ? buildClientContext({}, context) : undefined);
1427
1484
  return textContent("Event emitted.");
1428
1485
  });
@@ -1443,8 +1500,9 @@ export function createServer() {
1443
1500
  openWorldHint: true,
1444
1501
  },
1445
1502
  }, async ({ eventName, timeoutMs, sessionId, requestId, context, ...runtime }) => {
1446
- return withRuntimeClient(runtime, async (client) => {
1503
+ return withRuntimeClient(runtime, async (client, signal) => {
1447
1504
  const event = await client.waitForEvent(eventName, {
1505
+ signal,
1448
1506
  timeoutMs: clampTimeout(timeoutMs),
1449
1507
  sessionId,
1450
1508
  requestId,
@@ -0,0 +1,9 @@
1
+ /** Runtime identities admit one Noise session at a time across MCP tool calls. */
2
+ interface RuntimeClosable {
3
+ close(): Promise<void>;
4
+ waitForClosed(): Promise<void>;
5
+ }
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>;
9
+ export {};
@@ -0,0 +1,97 @@
1
+ const leases = new Map();
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);
15
+ if (!Number.isFinite(acquireTimeoutMs) || acquireTimeoutMs <= 0) {
16
+ throw new Error("Runtime identity acquisition deadline expired.");
17
+ }
18
+ const previous = leases.get(key) ?? Promise.resolve();
19
+ let release;
20
+ let fail;
21
+ const current = new Promise((resolve, reject) => { release = resolve; fail = reject; });
22
+ // Observe poisoned barriers even when no later caller arrives.
23
+ void current.catch(() => undefined);
24
+ leases.set(key, current);
25
+ const releaseIdentity = () => {
26
+ release();
27
+ if (leases.get(key) === current)
28
+ leases.delete(key);
29
+ };
30
+ const failIdentity = () => {
31
+ fail(new Error("Previous runtime session cleanup failed; this identity remains unavailable."));
32
+ };
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);
41
+ try {
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;
62
+ try {
63
+ client = createClient();
64
+ }
65
+ catch (error) {
66
+ releaseIdentity();
67
+ throw error;
68
+ }
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);
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.1.19",
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.5",
67
+ "@thalovant/sdk": "^0.3.12",
68
68
  "jose": "^6.2.8",
69
69
  "zod": "^3.25.76"
70
70
  },
@@ -74,6 +74,6 @@
74
74
  "devDependencies": {
75
75
  "@types/node": "^22.18.12",
76
76
  "typescript": "^5.9.3",
77
- "vitest": "^3.2.4"
77
+ "vitest": "^4.1.11"
78
78
  }
79
79
  }
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.19",
10
+ "version": "0.1.21",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.1.19",
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.19",
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"