@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 +7 -0
- package/README.md +26 -2
- package/dist/index.js +25 -12
- package/dist/runtime-lease.d.ts +3 -2
- package/dist/runtime-lease.js +71 -38
- package/package.json +2 -2
- package/server.json +3 -3
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.
|
|
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,
|
package/dist/runtime-lease.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ interface RuntimeClosable {
|
|
|
3
3
|
close(): Promise<void>;
|
|
4
4
|
waitForClosed(): Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
|
|
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 {};
|
package/dist/runtime-lease.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
const leases = new Map();
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
//
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
63
|
+
client = createClient();
|
|
56
64
|
}
|
|
57
65
|
catch (error) {
|
|
58
|
-
|
|
66
|
+
releaseIdentity();
|
|
59
67
|
throw error;
|
|
60
68
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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
|
},
|
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"
|