@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 +18 -0
- package/README.md +65 -1
- package/SECURITY.md +10 -0
- package/dist/index.js +111 -53
- package/dist/runtime-lease.d.ts +9 -0
- package/dist/runtime-lease.js +97 -0
- package/package.json +3 -3
- package/server.json +3 -3
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
632
|
+
return new ThalovantIdentity(runtimeCredential.identity);
|
|
613
633
|
}
|
|
614
634
|
if (runtimeCredential?.identityFile) {
|
|
615
|
-
return
|
|
635
|
+
return ThalovantIdentity.fromFile(runtimeCredential.identityFile);
|
|
616
636
|
}
|
|
617
637
|
if (runtimeCredential?.configPath || runtimeCredential?.profile) {
|
|
618
|
-
return
|
|
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
|
|
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
|
|
653
|
+
return ThalovantIdentity.fromFile(options.identityFile);
|
|
635
654
|
}
|
|
636
655
|
if (options.configPath || options.profile) {
|
|
637
|
-
return
|
|
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
|
|
665
|
+
return ThalovantIdentity.fromEnv();
|
|
648
666
|
}
|
|
649
|
-
return
|
|
667
|
+
return ThalovantIdentity.fromConfig({
|
|
650
668
|
profile: process.env.THALOVANT_PROFILE,
|
|
651
|
-
protocol,
|
|
652
669
|
});
|
|
653
670
|
}
|
|
654
|
-
|
|
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
|
|
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
|
-
|
|
677
|
+
identity.endpointBase(), identity.accessKey,
|
|
666
678
|
])).digest("hex");
|
|
667
|
-
|
|
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.
|
|
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.
|
|
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": "^
|
|
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.
|
|
10
|
+
"version": "0.1.21",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@thalovant/mcp-server",
|
|
15
|
-
"version": "0.1.
|
|
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.
|
|
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"
|