@thalovant/mcp-server 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +41 -3
- package/dist/index.js +91 -32
- package/dist/reply-output.d.ts +45 -0
- package/dist/reply-output.js +42 -0
- package/examples/intent-listing.json +15 -0
- package/package.json +2 -2
- package/server.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-12
|
|
4
|
+
|
|
5
|
+
- Add locale-aware sentence inventory examples with SDK 0.6.0, canonical slot defaults and unique rendered limits.
|
|
6
|
+
- Handle SIGTERM/SIGINT explicitly and close MCP sessions and HTTP connections when running as PID 1.
|
|
7
|
+
- Align the Dockerfile version label with the package and registry metadata.
|
|
8
|
+
|
|
9
|
+
## 0.3.0 — 2026-09-12
|
|
10
|
+
|
|
11
|
+
- Use Node SDK 0.5.0 request hints, bounded embedded audio replies, and speakable intent examples. Keep embedded audio out of model text; offer explicit audio/resource content blocks through includeAudio.
|
|
12
|
+
- Default runtime configuration tools to revision-guarded merging with at most three attempts and only HTTP 412 retries. Preserve explicit replacement with merge: false. Guarded merging requires hubs:read and paid hubs:write.
|
|
13
|
+
- Cover actual encrypted runtime hints/audio and default conditional-write conflict handling through MCP tool calls. Preserve the existing tool catalog and align npm/OCI/registry versions.
|
|
14
|
+
|
|
3
15
|
## 0.2.0 — 2026-09-12
|
|
4
16
|
|
|
5
17
|
- Add a read-only hub skill history tool using Node SDK 0.4.0. The catalog now has 46 default tools, 23 in read-only mode, and 48 with destructive tools enabled.
|
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Minimum scopes for the full control-plane tool surface:
|
|
|
56
56
|
|
|
57
57
|
| Scope | Used by |
|
|
58
58
|
|-------|---------|
|
|
59
|
-
| `hubs:read` | `thalovant_list_hubs`, `thalovant_get_hub`, `thalovant_get_analytics_overview`, `thalovant_list_marketplace_skills`, `thalovant_list_runtime_groups`, `thalovant_get_runtime_group`, `thalovant_get_runtime_group_config`, and the hub lookup inside `thalovant_create_client_identity` |
|
|
59
|
+
| `hubs:read` | `thalovant_list_hubs`, `thalovant_get_hub`, `thalovant_get_analytics_overview`, `thalovant_list_marketplace_skills`, `thalovant_list_runtime_groups`, `thalovant_get_runtime_group`, `thalovant_get_runtime_group_config`, the guarded merge read in `thalovant_update_runtime_group_config`, and the hub lookup inside `thalovant_create_client_identity` |
|
|
60
60
|
| `hubs:inspect` | `thalovant_get_hub_runtime_capabilities`, `thalovant_list_runtime_group_marketplace`, `thalovant_list_runtime_group_inventory`, `thalovant_list_hub_skills`, `thalovant_list_hub_skill_history` |
|
|
61
61
|
| `hubs:write` | All hub and runtime-group provisioning: `thalovant_create_hub`, `thalovant_update_hub`, `thalovant_release_hub`, `thalovant_create_runtime_group`, `thalovant_update_runtime_group`, `thalovant_update_runtime_group_config`, `thalovant_release_runtime_group`, `thalovant_install_runtime_group_skill`, `thalovant_uninstall_runtime_group_skill`, the per-hub `thalovant_install_hub_skill`, `thalovant_update_hub_skill`, `thalovant_remove_hub_skill`, the hub rating tools, and the opt-in delete tools |
|
|
62
62
|
| `clients:write` | `thalovant_create_client_identity` (`POST /v1/clients`) |
|
|
@@ -384,7 +384,7 @@ Destructive, **not registered unless explicitly enabled** (see [Destructive Tool
|
|
|
384
384
|
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`.
|
|
385
385
|
|
|
386
386
|
`thalovant_intent_inventory` uses the runtime identity and accepts `languages`,
|
|
387
|
-
`describe`, `fallback`, and a per-query/batch `timeoutMs`. It returns registered
|
|
387
|
+
`describe`, `fallback`, `speakable`, `sentence`, `slots`, `exampleLimit`, and a per-query/batch `timeoutMs`. It returns registered
|
|
388
388
|
intents and examples, fallback skills, `fallbacks_known`, and `may_answer` by
|
|
389
389
|
requested language. A missing or denied optional fallback-skill query remains
|
|
390
390
|
unknown, rather than being reported as a known empty list. The optional probe
|
|
@@ -393,6 +393,33 @@ adds at most 1500ms. A silent listing can use engine manifests; disable this wit
|
|
|
393
393
|
per-principal policy. `thalovant_get_operation` reads an operation ID returned by
|
|
394
394
|
provisioning without replaying the write.
|
|
395
395
|
|
|
396
|
+
## Request hints, embedded audio, and configuration merging
|
|
397
|
+
|
|
398
|
+
`thalovant_ask` accepts `sttLang`, an ordered `pipeline`, and `location`
|
|
399
|
+
with required `city` and optional `region`, `country`, `timezone`, `latitude`,
|
|
400
|
+
and `longitude`. Hints apply to the current request. Omit unused hints;
|
|
401
|
+
an empty pipeline is ignored, and blank language or city strings are rejected.
|
|
402
|
+
|
|
403
|
+
Ask and Query return language, ordered event metadata, `hasAudio` and
|
|
404
|
+
`droppedMedia`. Embedded hex stays out of text output. Opt into separate MCP
|
|
405
|
+
audio blocks with `includeAudio: true`; unknown formats become embedded binary
|
|
406
|
+
resource blocks. Clip and reply limits are 4 MiB and 16 MiB respectively,
|
|
407
|
+
checked by the SDK before retention. The client owns playback. No skill-supplied
|
|
408
|
+
path or URL is fetched. Inventory `speakable: true` renders examples without
|
|
409
|
+
changing the raw patterns; `slots` supplies illustrative slot values, and
|
|
410
|
+
`exampleLimit` limits examples per language to 1–20 (default 2).
|
|
411
|
+
|
|
412
|
+
Configuration updates default to `merge: true`: GET the revision, deep-merge
|
|
413
|
+
the caller's delta and PUT with `expected_revision`. Only HTTP 412 retries,
|
|
414
|
+
with a fresh read and at most three total attempts. This requires `hubs:read`,
|
|
415
|
+
`hubs:write`, and a paid plan. An older API without revision support fails before
|
|
416
|
+
writing. There is no unconditional fallback. Supplied personas replace;
|
|
417
|
+
omitted personas stay unchanged. For an intentional full replacement, pass
|
|
418
|
+
`merge: false`, requiring `hubs:write`, a paid plan, and coordination with other writers.
|
|
419
|
+
Guarded merges reject integers outside JavaScript's safe integer range in the
|
|
420
|
+
delta or stored configuration before writing. Store large identifiers as strings
|
|
421
|
+
or use an SDK that preserves large JSON integers.
|
|
422
|
+
|
|
396
423
|
## Destructive Tools
|
|
397
424
|
|
|
398
425
|
`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.
|
|
@@ -437,7 +464,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
|
|
|
437
464
|
MCP process. Use a distinct client identity for each independently running MCP
|
|
438
465
|
server so the hub can keep their sessions separate.
|
|
439
466
|
|
|
440
|
-
Version 0.
|
|
467
|
+
Version 0.4.0 requires `@thalovant/sdk` `^0.6.0` (0.6.0 through versions below 0.7.0). This release enforces
|
|
441
468
|
secure effective MQTT URLs and carries a single connection deadline through
|
|
442
469
|
MQTT setup and HTTP failure cleanup. Runtime tools support
|
|
443
470
|
HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
|
|
@@ -530,3 +557,14 @@ never repeats an accepted mutation and starts no new read after its deadline;
|
|
|
530
557
|
an already-running HTTP request retains its normal request timeout.
|
|
531
558
|
|
|
532
559
|
Read history with `thalovant_list_hub_skill_history` (`hubId`, optional `limit`). This tool remains available in read-only mode.
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
Inventory `sentence: true` implies speakable rendering and capitalizes/punctuates
|
|
563
|
+
examples using bundled thalovant-languages 0.1.1 rules. Regional locale matching
|
|
564
|
+
follows OVOS distances; explicit `slots` override locale sample values. Complete
|
|
565
|
+
phrases rank first, and `exampleLimit` counts unique nonempty rendered results.
|
|
566
|
+
Raw intent definitions remain in the response; unknown locales retain bare text.
|
|
567
|
+
|
|
568
|
+
The CLI handles SIGTERM and SIGINT in stdio and HTTP modes, including when it
|
|
569
|
+
runs as the container's PID 1. It closes sessions and HTTP connections before a
|
|
570
|
+
successful exit, with a ten-second failure deadline for stalled cleanup.
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runtimeReplyContent } from "./reply-output.js";
|
|
2
3
|
import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
7
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
-
import { DEFAULT_CONTROL_API_URL, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, } from "@thalovant/sdk";
|
|
8
|
+
import { DEFAULT_CONTROL_API_URL, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, buildLocation, } from "@thalovant/sdk";
|
|
8
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
10
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
11
|
import { createServer as createHttpServer } from "node:http";
|
|
@@ -718,21 +719,6 @@ async function withRuntimeClient(options, run) {
|
|
|
718
719
|
])).digest("hex");
|
|
719
720
|
return withRuntimeLease(key, () => new ThalovantClient(identity, { protocol: options.protocol ?? "wss" }), client => run(client, signal), 6000, signal);
|
|
720
721
|
}
|
|
721
|
-
function summarizeReply(reply) {
|
|
722
|
-
const displayItems = reply.displayItems({ maxTextChars: 1_000 });
|
|
723
|
-
return {
|
|
724
|
-
text: reply.text,
|
|
725
|
-
displayText: reply.displayText,
|
|
726
|
-
utterances: reply.utterances,
|
|
727
|
-
handled: reply.handled,
|
|
728
|
-
ok: reply.ok,
|
|
729
|
-
sessionId: reply.sessionId,
|
|
730
|
-
requestId: reply.requestId,
|
|
731
|
-
displayItems,
|
|
732
|
-
events: reply.events.map((event) => redactSecrets(event.asObject())),
|
|
733
|
-
failureEvent: reply.failureEvent ? redactSecrets(reply.failureEvent.asObject()) : undefined,
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
722
|
/**
|
|
737
723
|
* Base directory that saved client-identity files are confined to.
|
|
738
724
|
*
|
|
@@ -1360,17 +1346,24 @@ export function createServer() {
|
|
|
1360
1346
|
inputSchema: {
|
|
1361
1347
|
...runtimeAuthSchema,
|
|
1362
1348
|
languages: z.array(z.string().trim().min(1).max(100)).min(1).max(20).default(["en-us"]),
|
|
1349
|
+
speakable: z.boolean().default(false).describe("Render intent patterns with locale slot examples; preserve complete-phrase priority."),
|
|
1350
|
+
sentence: z.boolean().default(false).describe("Capitalize and punctuate examples using the selected locale. Implies speakable rendering."),
|
|
1351
|
+
slots: z.record(z.string().max(500)).optional().describe("Example values for named intent slots."),
|
|
1352
|
+
exampleLimit: z.number().int().min(1).max(20).default(2),
|
|
1363
1353
|
describe: z.boolean().default(true),
|
|
1364
1354
|
fallback: z.boolean().default(true).describe("Try engine manifests if the intent listing is refused or unanswered."),
|
|
1365
1355
|
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."),
|
|
1366
1356
|
},
|
|
1367
1357
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1368
|
-
}, async ({ languages, describe, fallback, timeoutMs, ...runtime }) => withRuntimeClient(runtime, async (client, signal) => {
|
|
1358
|
+
}, async ({ languages, describe, fallback, timeoutMs, speakable, sentence, slots, exampleLimit, ...runtime }) => withRuntimeClient(runtime, async (client, signal) => {
|
|
1369
1359
|
await client.connect(undefined, signal);
|
|
1370
1360
|
throwIfRuntimeCancelled(signal);
|
|
1371
1361
|
const inventory = await client.intents(languages, { describe, fallback, timeoutMs: clampTimeout(timeoutMs) });
|
|
1372
1362
|
return jsonContent(redactSecrets({
|
|
1373
1363
|
...inventory.asObject(),
|
|
1364
|
+
examples: inventory.intents.map(intent => ({ id: intent.id,
|
|
1365
|
+
languages: Object.fromEntries(inventory.languages.map(lang => [lang, intent.examples(lang, exampleLimit, { speakable, sentence, slots })])),
|
|
1366
|
+
})),
|
|
1374
1367
|
may_answer: Object.fromEntries(inventory.languages.map(lang => [lang, inventory.mayAnswer(lang)])),
|
|
1375
1368
|
}));
|
|
1376
1369
|
}));
|
|
@@ -1381,6 +1374,12 @@ export function createServer() {
|
|
|
1381
1374
|
...runtimeAuthSchema,
|
|
1382
1375
|
text: z.string().min(1).max(20_000),
|
|
1383
1376
|
timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS),
|
|
1377
|
+
sttLang: z.string().trim().min(1).max(100).optional().describe("Language recognized by speech-to-text; the hub validates this hint."),
|
|
1378
|
+
pipeline: z.array(z.string().trim().min(1).max(200)).max(32).optional(),
|
|
1379
|
+
location: z.object({ city: z.string().trim().min(1).max(500), region: z.string().max(500).optional(), country: z.string().max(100).optional(),
|
|
1380
|
+
latitude: z.number().finite().min(-90).max(90).optional(), longitude: z.number().finite().min(-180).max(180).optional(), timezone: z.string().max(200).optional(),
|
|
1381
|
+
}).optional().describe("Request location, preferred over the hub's configured city."),
|
|
1382
|
+
includeAudio: z.boolean().default(false).describe("Include bounded embedded skill clips as MCP audio/resources; default returns metadata without placing audio hex in text."),
|
|
1384
1383
|
lang: z.string().min(1).optional(),
|
|
1385
1384
|
sessionId: z.string().min(1).optional(),
|
|
1386
1385
|
requestId: z.string().min(1).optional(),
|
|
@@ -1394,7 +1393,7 @@ export function createServer() {
|
|
|
1394
1393
|
idempotentHint: false,
|
|
1395
1394
|
openWorldHint: true,
|
|
1396
1395
|
},
|
|
1397
|
-
}, async ({ text, timeoutMs, lang, sessionId, requestId, context, replySettleMs, emptyReplyWaitMs, ...runtime }) => {
|
|
1396
|
+
}, async ({ text, timeoutMs, lang, sessionId, requestId, context, replySettleMs, emptyReplyWaitMs, sttLang, pipeline, location, includeAudio, ...runtime }) => {
|
|
1398
1397
|
return withRuntimeClient(runtime, async (client, signal) => {
|
|
1399
1398
|
const builtContext = context ? buildClientContext({}, context) : undefined;
|
|
1400
1399
|
const reply = await client.ask(text, {
|
|
@@ -1404,10 +1403,12 @@ export function createServer() {
|
|
|
1404
1403
|
sessionId,
|
|
1405
1404
|
requestId,
|
|
1406
1405
|
context: builtContext,
|
|
1406
|
+
sttLang, pipeline, location: location ? buildLocation(location) : undefined,
|
|
1407
1407
|
replySettleMs,
|
|
1408
1408
|
emptyReplyWaitMs,
|
|
1409
1409
|
});
|
|
1410
|
-
|
|
1410
|
+
const output = runtimeReplyContent(reply, includeAudio);
|
|
1411
|
+
return { content: [...jsonContent(redactSecrets(output.summary)).content, ...output.clips] };
|
|
1411
1412
|
});
|
|
1412
1413
|
});
|
|
1413
1414
|
registerThalovantTool(server, "thalovant_query", {
|
|
@@ -1417,6 +1418,7 @@ export function createServer() {
|
|
|
1417
1418
|
...runtimeAuthSchema,
|
|
1418
1419
|
text: z.string().trim().min(1).max(20_000),
|
|
1419
1420
|
timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS),
|
|
1421
|
+
includeAudio: z.boolean().default(false).describe("Include bounded embedded skill clips as MCP audio/resources."),
|
|
1420
1422
|
lang: z.string().min(1).optional(),
|
|
1421
1423
|
sessionId: z.string().min(1).optional(),
|
|
1422
1424
|
requestId: z.string().min(1).optional(),
|
|
@@ -1425,14 +1427,15 @@ export function createServer() {
|
|
|
1425
1427
|
replySettleMs: z.number().int().min(0).max(10_000).optional(),
|
|
1426
1428
|
},
|
|
1427
1429
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1428
|
-
}, async ({ text, timeoutMs, lang, sessionId, requestId, queryId, context, replySettleMs, ...runtime }) => {
|
|
1430
|
+
}, async ({ text, timeoutMs, lang, sessionId, requestId, queryId, context, replySettleMs, includeAudio, ...runtime }) => {
|
|
1429
1431
|
return withRuntimeClient(runtime, async (client, signal) => {
|
|
1430
1432
|
const reply = await client.query(text, {
|
|
1431
1433
|
signal,
|
|
1432
1434
|
timeoutMs: clampTimeout(timeoutMs), lang, sessionId, requestId, queryId,
|
|
1433
1435
|
context: context ? buildClientContext({}, context) : undefined, replySettleMs,
|
|
1434
1436
|
});
|
|
1435
|
-
|
|
1437
|
+
const output = runtimeReplyContent(reply, includeAudio);
|
|
1438
|
+
return { content: [...jsonContent(redactSecrets(output.summary)).content, ...output.clips] };
|
|
1436
1439
|
});
|
|
1437
1440
|
});
|
|
1438
1441
|
registerThalovantTool(server, "thalovant_send_action", {
|
|
@@ -2030,7 +2033,7 @@ export function createServer() {
|
|
|
2030
2033
|
});
|
|
2031
2034
|
registerThalovantTool(server, "thalovant_get_runtime_group_config", {
|
|
2032
2035
|
title: "Get Runtime Group Config",
|
|
2033
|
-
description: "Read a Thalovant runtime group's runtime configuration and personas. Requires the hubs:read scope.
|
|
2036
|
+
description: "Read a Thalovant runtime group's runtime configuration and personas. Requires the hubs:read scope. The revision returned by this call is used by safe configuration merges.",
|
|
2034
2037
|
inputSchema: {
|
|
2035
2038
|
...controlPlaneSchema,
|
|
2036
2039
|
runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
|
|
@@ -2046,23 +2049,24 @@ export function createServer() {
|
|
|
2046
2049
|
});
|
|
2047
2050
|
registerThalovantTool(server, "thalovant_update_runtime_group_config", {
|
|
2048
2051
|
title: "Update Runtime Group Config",
|
|
2049
|
-
description: "Merge runtime configuration into a Thalovant runtime group. The
|
|
2052
|
+
description: "Merge runtime configuration into a Thalovant runtime group. The SDK reads and deep-merges config, then writes with a revision precondition. Only explicit HTTP 412 conflicts retry, at most three attempts; older APIs fail before a write. Set merge=false for explicit unconditional replacement. personas, when provided, is REPLACED wholesale rather than merged; omit it to leave stored personas untouched. Requires hubs:read and paid hubs:write for merging; replacement needs paid hubs:write.",
|
|
2050
2053
|
inputSchema: {
|
|
2051
2054
|
...controlPlaneSchema,
|
|
2052
2055
|
runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
|
|
2056
|
+
merge: z.boolean().default(true).describe("False explicitly replaces the full configuration without a revision precondition."),
|
|
2053
2057
|
config: jsonRecordSchema.describe("Configuration object merged into the stored configuration. Required."),
|
|
2054
2058
|
personas: jsonRecordSchema.optional().describe("Replaces the stored personas outright. Left untouched when omitted."),
|
|
2055
2059
|
},
|
|
2056
2060
|
annotations: {
|
|
2057
2061
|
readOnlyHint: false,
|
|
2058
|
-
destructiveHint:
|
|
2062
|
+
destructiveHint: true,
|
|
2059
2063
|
idempotentHint: false,
|
|
2060
2064
|
openWorldHint: true,
|
|
2061
2065
|
},
|
|
2062
|
-
}, async ({ runtimeGroupId, config, personas, ...auth }) => {
|
|
2066
|
+
}, async ({ runtimeGroupId, config, personas, merge, ...auth }) => {
|
|
2063
2067
|
const api = await createControlPlane(auth);
|
|
2064
2068
|
ensureAuthenticated(api);
|
|
2065
|
-
return jsonContent(redactSecrets(await callControlPlane("write", () => api.updateRuntimeGroupConfig(runtimeGroupId, config, { personas }))));
|
|
2069
|
+
return jsonContent(redactSecrets(await callControlPlane("write", () => api.updateRuntimeGroupConfig(runtimeGroupId, config, { personas, merge }))));
|
|
2066
2070
|
});
|
|
2067
2071
|
registerThalovantTool(server, "thalovant_release_runtime_group", {
|
|
2068
2072
|
title: "Release Runtime Group",
|
|
@@ -2319,8 +2323,13 @@ export async function startHttpServer(config = getHttpConfig()) {
|
|
|
2319
2323
|
}
|
|
2320
2324
|
}, Math.min(config.sessionTtlMs, 60_000));
|
|
2321
2325
|
cleanupTimer.unref();
|
|
2326
|
+
let closing;
|
|
2322
2327
|
const httpServer = createHttpServer(async (req, res) => {
|
|
2323
2328
|
setSecurityHeaders(res);
|
|
2329
|
+
if (closing) {
|
|
2330
|
+
sendJson(res, 503, { error: "Server is shutting down." });
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2324
2333
|
try {
|
|
2325
2334
|
if (!validateHost(req, config)) {
|
|
2326
2335
|
sendJson(res, 403, { error: "Forbidden host." });
|
|
@@ -2363,11 +2372,19 @@ export async function startHttpServer(config = getHttpConfig()) {
|
|
|
2363
2372
|
return;
|
|
2364
2373
|
}
|
|
2365
2374
|
const auth = await authenticate(req, config);
|
|
2375
|
+
if (closing) {
|
|
2376
|
+
sendJson(res, 503, { error: "Server is shutting down." });
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2366
2379
|
const authenticatedReq = req;
|
|
2367
2380
|
authenticatedReq.auth = authInfoFromAuthResult(auth);
|
|
2368
2381
|
authenticatedReq.auth.resource = config.resourceUrl;
|
|
2369
2382
|
if (req.method === "POST") {
|
|
2370
2383
|
const body = await readJsonBody(req, config.maxBodyBytes);
|
|
2384
|
+
if (closing) {
|
|
2385
|
+
sendJson(res, 503, { error: "Server is shutting down." });
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2371
2388
|
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : undefined;
|
|
2372
2389
|
if (sessionId) {
|
|
2373
2390
|
const session = sessions.get(sessionId);
|
|
@@ -2422,6 +2439,11 @@ export async function startHttpServer(config = getHttpConfig()) {
|
|
|
2422
2439
|
});
|
|
2423
2440
|
};
|
|
2424
2441
|
await mcpServer.connect(transport);
|
|
2442
|
+
if (closing) {
|
|
2443
|
+
await mcpServer.close();
|
|
2444
|
+
sendJson(res, 503, { error: "Server is shutting down." });
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2425
2447
|
await transport.handleRequest(authenticatedReq, res, body);
|
|
2426
2448
|
return;
|
|
2427
2449
|
}
|
|
@@ -2480,12 +2502,12 @@ export async function startHttpServer(config = getHttpConfig()) {
|
|
|
2480
2502
|
console.error(`Thalovant MCP Streamable HTTP listening at ${url}`);
|
|
2481
2503
|
return {
|
|
2482
2504
|
url,
|
|
2483
|
-
close:
|
|
2505
|
+
close: () => {
|
|
2506
|
+
if (closing)
|
|
2507
|
+
return closing;
|
|
2484
2508
|
clearInterval(cleanupTimer);
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
}
|
|
2488
|
-
await new Promise((resolveClose, rejectClose) => {
|
|
2509
|
+
// Stop admission before closing sessions; then release remaining HTTP sockets.
|
|
2510
|
+
const stopped = new Promise((resolveClose, rejectClose) => {
|
|
2489
2511
|
httpServer.close((error) => {
|
|
2490
2512
|
if (error)
|
|
2491
2513
|
rejectClose(error);
|
|
@@ -2493,9 +2515,44 @@ export async function startHttpServer(config = getHttpConfig()) {
|
|
|
2493
2515
|
resolveClose();
|
|
2494
2516
|
});
|
|
2495
2517
|
});
|
|
2518
|
+
closing = Promise.all([
|
|
2519
|
+
stopped,
|
|
2520
|
+
(async () => {
|
|
2521
|
+
try {
|
|
2522
|
+
await Promise.all(Array.from(sessions.keys(), closeSession));
|
|
2523
|
+
}
|
|
2524
|
+
finally {
|
|
2525
|
+
httpServer.closeAllConnections();
|
|
2526
|
+
}
|
|
2527
|
+
})(),
|
|
2528
|
+
]).then(() => undefined);
|
|
2529
|
+
return closing;
|
|
2496
2530
|
},
|
|
2497
2531
|
};
|
|
2498
2532
|
}
|
|
2533
|
+
// CLI signal handling is explicit because Node may run as PID 1 in the OCI image.
|
|
2534
|
+
function handleShutdown(close) {
|
|
2535
|
+
let stopping = false;
|
|
2536
|
+
const shutdown = () => {
|
|
2537
|
+
if (stopping)
|
|
2538
|
+
return;
|
|
2539
|
+
stopping = true;
|
|
2540
|
+
const deadline = setTimeout(() => {
|
|
2541
|
+
console.error("MCP shutdown exceeded 10 seconds.");
|
|
2542
|
+
process.exit(1);
|
|
2543
|
+
}, 10_000);
|
|
2544
|
+
void Promise.resolve().then(close).then(() => {
|
|
2545
|
+
clearTimeout(deadline);
|
|
2546
|
+
process.exit(0);
|
|
2547
|
+
}, (error) => {
|
|
2548
|
+
clearTimeout(deadline);
|
|
2549
|
+
console.error("Error during MCP shutdown:", error);
|
|
2550
|
+
process.exit(1);
|
|
2551
|
+
});
|
|
2552
|
+
};
|
|
2553
|
+
process.on("SIGTERM", shutdown);
|
|
2554
|
+
process.on("SIGINT", shutdown);
|
|
2555
|
+
}
|
|
2499
2556
|
export async function main() {
|
|
2500
2557
|
const requestedTransport = process.argv.includes("--http")
|
|
2501
2558
|
? "http"
|
|
@@ -2503,12 +2560,14 @@ export async function main() {
|
|
|
2503
2560
|
? "stdio"
|
|
2504
2561
|
: (process.env.MCP_TRANSPORT ?? "stdio");
|
|
2505
2562
|
if (requestedTransport === "http" || requestedTransport === "streamable-http") {
|
|
2506
|
-
await startHttpServer();
|
|
2563
|
+
const http = await startHttpServer();
|
|
2564
|
+
handleShutdown(http.close);
|
|
2507
2565
|
return;
|
|
2508
2566
|
}
|
|
2509
2567
|
const transport = new StdioServerTransport();
|
|
2510
2568
|
const server = createServer();
|
|
2511
2569
|
await server.connect(transport);
|
|
2570
|
+
handleShutdown(() => server.close());
|
|
2512
2571
|
}
|
|
2513
2572
|
function isMainModule() {
|
|
2514
2573
|
if (!process.argv[1])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ThalovantReply } from "@thalovant/sdk";
|
|
2
|
+
/** Keep embedded skill bytes out of the language model's text context. */
|
|
3
|
+
export declare function runtimeReplyContent(reply: ThalovantReply, includeAudio?: boolean): {
|
|
4
|
+
summary: {
|
|
5
|
+
text: string;
|
|
6
|
+
displayText: string;
|
|
7
|
+
utterances: string[];
|
|
8
|
+
handled: boolean;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
sessionId: string | undefined;
|
|
11
|
+
requestId: string | undefined;
|
|
12
|
+
lang: string | undefined;
|
|
13
|
+
hasAudio: boolean;
|
|
14
|
+
droppedMedia: number;
|
|
15
|
+
displayItems: import("@thalovant/sdk").ThalovantDisplayItem[];
|
|
16
|
+
events: {
|
|
17
|
+
data: {
|
|
18
|
+
[x: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
}[];
|
|
21
|
+
media: {
|
|
22
|
+
eventIndex: number;
|
|
23
|
+
byteLength?: number;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}[];
|
|
27
|
+
failureEvent: {
|
|
28
|
+
data: {
|
|
29
|
+
[x: string]: unknown;
|
|
30
|
+
};
|
|
31
|
+
} | undefined;
|
|
32
|
+
};
|
|
33
|
+
clips: ({
|
|
34
|
+
type: "audio";
|
|
35
|
+
data: string;
|
|
36
|
+
mimeType: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: "resource";
|
|
39
|
+
resource: {
|
|
40
|
+
uri: string;
|
|
41
|
+
mimeType: string;
|
|
42
|
+
blob: string;
|
|
43
|
+
};
|
|
44
|
+
})[];
|
|
45
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
function eventSummary(event) {
|
|
3
|
+
const { binary_data: _encoded, ...data } = event.data;
|
|
4
|
+
return { ...event.asObject(), data };
|
|
5
|
+
}
|
|
6
|
+
/** Keep embedded skill bytes out of the language model's text context. */
|
|
7
|
+
export function runtimeReplyContent(reply, includeAudio = false) {
|
|
8
|
+
const clips = [];
|
|
9
|
+
const media = [];
|
|
10
|
+
const events = reply.events.map((event, eventIndex) => {
|
|
11
|
+
const output = eventSummary(event);
|
|
12
|
+
if (!event.isAudio)
|
|
13
|
+
return output;
|
|
14
|
+
try {
|
|
15
|
+
const bytes = Buffer.from(event.audioBytes());
|
|
16
|
+
const mimeType = bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WAVE" ? "audio/wav"
|
|
17
|
+
: bytes.subarray(0, 4).toString() === "OggS" ? "audio/ogg"
|
|
18
|
+
: bytes.subarray(0, 4).toString() === "fLaC" ? "audio/flac"
|
|
19
|
+
: bytes.subarray(0, 3).toString() === "ID3" ? "audio/mpeg" : "application/octet-stream";
|
|
20
|
+
media.push({ eventIndex, byteLength: bytes.length, mimeType });
|
|
21
|
+
if (includeAudio) {
|
|
22
|
+
const encoded = bytes.toString("base64");
|
|
23
|
+
clips.push(mimeType.startsWith("audio/") ? { type: "audio", data: encoded, mimeType } : {
|
|
24
|
+
type: "resource", resource: { uri: `thalovant-audio://sha256/${createHash("sha256").update(bytes).digest("hex")}`, mimeType, blob: encoded },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
media.push({ eventIndex, error: "Missing, invalid or oversized embedded audio." });
|
|
30
|
+
}
|
|
31
|
+
return output;
|
|
32
|
+
});
|
|
33
|
+
return { summary: {
|
|
34
|
+
text: reply.text, displayText: reply.displayText, utterances: reply.utterances,
|
|
35
|
+
handled: reply.handled, ok: reply.ok, sessionId: reply.sessionId, requestId: reply.requestId,
|
|
36
|
+
lang: reply.lang, hasAudio: reply.hasAudio ?? false, droppedMedia: reply.droppedMedia ?? 0,
|
|
37
|
+
displayItems: reply.displayItems({ maxTextChars: 1_000 }), events, media,
|
|
38
|
+
// A failure reference must not bypass text sanitization or add a clip
|
|
39
|
+
// outside the SDK's admitted event sequence and aggregate media budget.
|
|
40
|
+
failureEvent: reply.failureEvent ? eventSummary(reply.failureEvent) : undefined,
|
|
41
|
+
}, clips };
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thalovant/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
67
|
+
"@thalovant/sdk": "^0.6.0",
|
|
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.
|
|
10
|
+
"version": "0.4.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@thalovant/mcp-server",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.4.0",
|
|
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.
|
|
22
|
+
"identifier": "ghcr.io/thalovant/thalovant-mcp:0.4.0",
|
|
23
23
|
"transport": {
|
|
24
24
|
"type": "streamable-http",
|
|
25
25
|
"url": "http://127.0.0.1:3000/mcp"
|