@thalovant/mcp-server 0.5.1 → 0.5.3

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3 — 2026-09-26
4
+
5
+ - A tool error says what the API said, not only the SDK's one-line message. That line is cut at 160 characters, and a `platform_image_required` refusal names every image each refused key may be instead -- longer than that -- so the list a model needed to retry was the part cut off, and `refused_images`, `allowed_images` and `allowed_repositories` never reached it at all. The error text now goes on with `code:`, `detail:` (the whole sentence, when the first line had to shorten it) and `fields:`, every other member of the body as compact, redacted JSON. A value a validation error echoes back from the request is redacted there and never reaches the first line.
6
+ - A `platform_image_required` or `plan_limit` 403 gets a hint that fits it instead of the general "Insufficient scopes" hint: granting a scope changes neither.
7
+ - The status behind a hint is read from the SDK error's `statusCode`, not parsed out of its message.
8
+ - Take `@thalovant/sdk` ^0.8.7, whose `ThalovantApiError` carries `code`, `detail` and `problem`. The shared `api-error-vectors.json` run here twice: through the SDK that resolves, recorded before it is asserted, and through a real tool call against the built server.
9
+ - New `api-errors` capability in the parity contract, declared `required`.
10
+
11
+ ## 0.5.2 — 2026-09-19
12
+
13
+ - Take `@thalovant/sdk` ^0.8.2, which ends an ask the hub refuses at once as a typed refusal rather than waiting out its deadline and reporting that the hub did not answer in time. This server implements no part of an ask, so the behaviour arrives with the dependency -- and the shared refusal vectors now run here, against whatever version actually resolves, beside the conversation carry, the hive kinds and the binary frames.
14
+ - The parity contract's `refusal` capability moves from `planned` to `required`. It was the last capability holding the reference's release gate.
15
+
3
16
  ## 0.5.1 — 2026-09-13
4
17
 
5
18
  - Expose advisory reply claim status and first-seen pipeline/skill identifiers, with shared conformance for fallback, mixed stages, legacy hubs and malformed stamps. Existing reply construction remains compatible.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Thalovant MCP Server
2
2
 
3
+ [![M8ven Live Monitored](https://m8ven.ai/badge/mcp/thalovant-thalovant-mcp-1cyhb4)](https://m8ven.ai/mcp/thalovant-thalovant-mcp-1cyhb4)
4
+
3
5
  Public-ready MCP server for Thalovant control-plane and hub runtime APIs.
4
6
 
5
7
  It uses the official Thalovant Node.js SDK and the production MCP TypeScript SDK over stdio and Streamable HTTP, so it works with local MCP hosts such as Claude Desktop, Codex, Cursor, and remote MCP clients.
@@ -85,7 +87,7 @@ Grant fewer scopes for narrower deployments: a read-only assistant needs only `h
85
87
 
86
88
  `thalovant_list_hub_skills` returns the whole `GET /v1/hubs/{hub_id}/skills` envelope: `hub_id`, `runtime_group_id`, `observed_at`, `source`, the runtime's phase and message, and `data`, one row per skill (possibly empty) with `skill`, `title`, `marketplace_skill_id`, `package_name`, `source_type`, `install_source`, `version`, `version_pin`, `installed_version`, `observed_version`, `previous_version`, `latest_version`, `available_version`, `update_available`, `changelog`, `active`, `state`, the runtime's phase, message and last error, and `last_transition_at`. `state` is one of `pending`, `installed`, `failed`, `removing`, `drifted`, `quarantined`, or `unmanaged`; a change in progress shows as `pending`.
87
89
 
88
- Each write answers `202` with `operation_id`, `hub_id`, `runtime_group_id`, `skill`, `version` (`null` for a removal), `previous_version`, and `state` (`installing`, `updating`, or `removing`); pass `wait: true` to poll that operation every 2 s until it converges (`installed`, or `removed` for a removal; a `failed` or `timed_out` operation raises an error carrying its `error_message`), with a 120 s default `timeoutMs`, or follow it yourself with `thalovant_get_operation`. Installing a skill that is already installed at another version performs an update; the same version fails `409` with code `skill_version_already_installed`. A hub with no runtime group fails `404` with code `hub_without_runtime_group` (a plain `404` means an unknown hub or a skill that is not installed), and an unresolvable `latest` or an invalid version fails `422`. Errors are RFC 7807 problem bodies; the tool error keeps the short `message` and appends the root `code` in parentheses, for example `Thalovant API request failed with HTTP 409: Skill version already installed. (skill_version_already_installed)`.
90
+ Each write answers `202` with `operation_id`, `hub_id`, `runtime_group_id`, `skill`, `version` (`null` for a removal), `previous_version`, and `state` (`installing`, `updating`, or `removing`); pass `wait: true` to poll that operation every 2 s until it converges (`installed`, or `removed` for a removal; a `failed` or `timed_out` operation raises an error carrying its `error_message`), with a 120 s default `timeoutMs`, or follow it yourself with `thalovant_get_operation`. Installing a skill that is already installed at another version performs an update; the same version fails `409` with code `skill_version_already_installed`. A hub with no runtime group fails `404` with code `hub_without_runtime_group` (a plain `404` means an unknown hub or a skill that is not installed), and an unresolvable `latest` or an invalid version fails `422`. Errors are RFC 7807 problem bodies; the tool error keeps the short `message`, which appends the root `code` in parentheses, for example `Thalovant API request failed with HTTP 409: Skill version already installed. (skill_version_already_installed)`, and then the code and the body's other fields (see [Tool Errors](#tool-errors)).
89
91
 
90
92
  Listing needs `hubs:inspect` (implied by `hubs:read`); the writes need `hubs:write` and a paid plan, and because scope is checked before plan a free-plan token sees `403`, never `402`. Hub-restricted tokens (a `hub_ids` allowlist) are honoured on all four routes. The server uses the published Node SDK hub-skill methods and preserves MCP cancellation checks during polling. A failed status read retains the accepted operation ID; use `thalovant_get_operation` with that ID instead of submitting the write again. No new poll starts at or after the polling deadline. That deadline does not cancel an HTTP request already in flight.
91
93
 
@@ -401,6 +403,31 @@ unknown (empty lists/strings), with explanatory notes. Intent language order is
401
403
  explicit and survives JSON key sorting. Each tool call retains its own identity
402
404
  lease; the server does not persist inventory or share a session across callers.
403
405
 
406
+ ## Tool Errors
407
+
408
+ When the Thalovant API refuses a control-plane call, the tool result is an
409
+ error whose text starts with the SDK's one-line message (which names the HTTP
410
+ status and can be shortened) and then says what the API said, in full:
411
+
412
+ ```text
413
+ Thalovant API request failed with HTTP 403: Only an administrator can run an image the platform does not release: bus may be one of ghcr.io/thalovant/ovos-messagebus:2026.09.2, ... (platform_image_required)
414
+ code: platform_image_required
415
+ detail: Only an administrator can run an image the platform does not release: bus may be one of ghcr.io/thalovant/ovos-messagebus:2026.09.2, ghcr.io/thalovant/ovos-messagebus:2026.09.3-alpha.1, ghcr.io/thalovant/ovos-messagebus:2026.08.7; core may be any tag or digest of ghcr.io/thalovant/ovos-core.
416
+ fields: {"allowed_images":{"bus":[...],"core":[...]},"allowed_repositories":{"core":"ghcr.io/thalovant/ovos-core"},"component":"runtime_group","refused_images":{...}}
417
+ ```
418
+
419
+ - `code` is the API's machine-readable code, when it sent one.
420
+ - `detail` is the whole sentence, and appears only when the first line had to
421
+ shorten it.
422
+ - `fields` is every other member of the error body as compact JSON (the
423
+ Problem+JSON `type`, `title`, `status` and `instance` are left out), with the
424
+ same secret redaction as every other tool output. A value a validation error
425
+ echoes back from the request is redacted there and never reaches the first
426
+ line. A plan refusal carries `resource`, `limit`, `used` and `plan` here.
427
+
428
+ A hint may follow for statuses that need one. A `platform_image_required` or
429
+ `plan_limit` 403 gets its own hint rather than the general scope hint.
430
+
404
431
  ## Request hints, embedded audio, and configuration merging
405
432
 
406
433
  `thalovant_ask` accepts `sttLang`, an ordered `pipeline`, and `location`
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What a model reads when a control-plane call fails: the SDK's one-line
3
+ * message, then what the API actually said beside it.
4
+ *
5
+ * The message is bounded at 160 characters, and a structured refusal is
6
+ * usually longer than that -- a `platform_image_required` 403 names every
7
+ * image each refused key may be instead, and that list was the part cut off.
8
+ * So the code, the whole sentence (when the message line had to shorten it)
9
+ * and every other field of the body follow, as one compact JSON object.
10
+ * `redact` is the server's own secret redaction, applied to the fields: a
11
+ * validation error echoes back what it was sent.
12
+ *
13
+ * Returns undefined for anything that is not a `ThalovantApiError`.
14
+ */
15
+ export declare function apiErrorText(error: unknown, redact: (value: unknown) => unknown): string | undefined;
@@ -0,0 +1,54 @@
1
+ import { ThalovantApiError } from "@thalovant/sdk";
2
+ /**
3
+ * Problem+JSON members every refusal carries that say nothing the lines above
4
+ * them do not: the status is on the first line, and `type`/`title`/`instance`
5
+ * are the handler's own bookkeeping.
6
+ */
7
+ const ENVELOPE = new Set(["type", "title", "status", "instance"]);
8
+ /** A detail or a field list longer than this is cut, and says so. */
9
+ const MAX_SECTION_CHARS = 4_000;
10
+ function bounded(text) {
11
+ if (text.length <= MAX_SECTION_CHARS)
12
+ return text;
13
+ return `${text.slice(0, MAX_SECTION_CHARS)}… (${text.length - MAX_SECTION_CHARS} more characters)`;
14
+ }
15
+ /**
16
+ * What a model reads when a control-plane call fails: the SDK's one-line
17
+ * message, then what the API actually said beside it.
18
+ *
19
+ * The message is bounded at 160 characters, and a structured refusal is
20
+ * usually longer than that -- a `platform_image_required` 403 names every
21
+ * image each refused key may be instead, and that list was the part cut off.
22
+ * So the code, the whole sentence (when the message line had to shorten it)
23
+ * and every other field of the body follow, as one compact JSON object.
24
+ * `redact` is the server's own secret redaction, applied to the fields: a
25
+ * validation error echoes back what it was sent.
26
+ *
27
+ * Returns undefined for anything that is not a `ThalovantApiError`.
28
+ */
29
+ export function apiErrorText(error, redact) {
30
+ if (!(error instanceof ThalovantApiError))
31
+ return undefined;
32
+ const lines = [error.message];
33
+ if (error.code)
34
+ lines.push(`code: ${error.code}`);
35
+ const shown = error.message.replace(/\s+/g, " ");
36
+ if (error.detail && !shown.includes(error.detail.replace(/\s+/g, " ").trim())) {
37
+ lines.push(`detail: ${bounded(error.detail)}`);
38
+ }
39
+ const problem = error.problem;
40
+ if (problem) {
41
+ const fields = {};
42
+ for (const [key, value] of Object.entries(problem)) {
43
+ if (ENVELOPE.has(key))
44
+ continue;
45
+ // Already on a line above when they were read as strings.
46
+ if ((key === "code" || key === "detail") && typeof value === "string")
47
+ continue;
48
+ fields[key] = value;
49
+ }
50
+ if (Object.keys(fields).length)
51
+ lines.push(`fields: ${bounded(JSON.stringify(redact(fields)))}`);
52
+ }
53
+ return lines.join("\n");
54
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { apiErrorText } from "./api-error-output.js";
2
3
  import { inventoryPresentation } from "./inventory-output.js";
3
4
  import { runtimeReplyContent } from "./reply-output.js";
4
5
  import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
@@ -6,7 +7,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
8
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
9
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
- import { DEFAULT_CONTROL_API_URL, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, buildLocation, } from "@thalovant/sdk";
10
+ import { DEFAULT_CONTROL_API_URL, ThalovantApiError, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, buildLocation, } from "@thalovant/sdk";
10
11
  import { AsyncLocalStorage } from "node:async_hooks";
11
12
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
12
13
  import { createServer as createHttpServer } from "node:http";
@@ -204,22 +205,30 @@ const releaseOptionsSchema = {
204
205
  channel: z.string().min(1).optional().describe("Release channel. Falls back to the workspace release policy when omitted."),
205
206
  mode: z.string().min(1).optional().describe('Release mode. Passing images without mode switches to "custom".'),
206
207
  version: z.string().min(1).optional().describe("Pinned release version."),
207
- images: z.record(z.string().min(1)).optional().describe("Explicit image overrides. Switches to custom mode unless mode is also set."),
208
+ images: z.record(z.string().min(1)).optional().describe("Explicit image overrides. Switches to custom mode unless mode is also set. Non-admins may pass only images the platform releases for each key: a catalog pin of the stable or alpha channel, the resource's current, recommended or release-policy image, or the platform's default image; runtime core also accepts any tag or digest of ghcr.io/thalovant/ovos-core and hub listener any tag or digest of ghcr.io/thalovant/hivemind-listener, while bus and preview_bridge take only the listed images. Anything else fails with 403 platform_image_required, whose error lists allowed_images and allowed_repositories per refused key."),
208
209
  reason: z.string().min(1).optional().describe("Audit reason recorded with the release."),
209
210
  };
210
211
  function releaseOptionsFrom(args) {
211
212
  return { channel: args.channel, mode: args.mode, version: args.version, images: args.images, reason: args.reason };
212
213
  }
213
214
  const SCOPE_HINT_403 = 'HTTP 403 "Insufficient scopes" — the token lacks the scope this route needs: hubs:write for provisioning, hubs:read for the marketplace catalog, hubs:inspect for runtime-group and hub runtime views (hubs:write implies hubs:read, which implies hubs:inspect and hubs:preview). The API checks scope BEFORE the paid-plan gate, so a 403 can mask a plan problem and granting the scope may surface a 402 next. Free-plan API tokens are capped at hubs:read, clients:read, and clients:write, so a free-tier API token fails provisioning with this 403 and never reaches the 402. A 403 here can also mean the caller does not own the resource ("Ownership required"), or a plan restriction such as HUB_AUTOSCALING_NOT_INCLUDED or a custom hub domain the plan does not allow.';
215
+ const PLATFORM_IMAGE_HINT_403 = 'HTTP 403 platform_image_required — only a platform administrator can run an image the platform does not release. This is not a scope problem: pick, for each key in refused_images, one of that key\'s allowed_images, or any tag or digest of its allowed_repositories entry (both are in the fields above), and retry.';
216
+ const PLAN_LIMIT_HINT_403 = 'HTTP 403 plan_limit — the plan\'s allowance for the named resource is used up (see resource, used and limit in the fields above). Granting a scope will not help: free one (delete or reuse an existing one) or move the workspace to a larger plan.';
214
217
  const PLAN_HINT_402 = 'HTTP 402 "API access requires a paid plan." — the token is valid and correctly scoped but the tenant is on the free plan. Free-tier callers can browse the catalog (thalovant_list_marketplace_skills) and set hub ratings, but cannot create, update, release, or delete hubs and runtime groups, and cannot install skills.';
215
218
  const MARKETPLACE_HINT_402 = 'HTTP 402 "This skill requires paid marketplace access for the tenant plan." — a DIFFERENT 402 from the plan gate: the plan is paid enough to provision, but this catalog entry has access_tier "paid" and the plan does not include paid marketplace skills. thalovant_list_runtime_group_marketplace reports this per skill as purchase_required, installable, and access_message; check it before installing.';
216
219
  const HUB_SKILL_HINT_422 = 'HTTP 422 — the requested hub skill version is unusable: "latest" could not be resolved to a published version of that skill, or the version string is not a valid version. Check the skill name and its published versions with thalovant_list_marketplace_skills, or pass an explicit x.y.z.';
217
220
  const ETAG_HINT_412 = 'HTTP 412 "ETag mismatch" — the If-Match etag was missing or stale and nothing changed. Re-fetch the hub with thalovant_get_hub, read the `etag` field from the response BODY (there is no ETag response header), and retry with that exact value. The comparison is exact string equality, so do not rewrite or weaken the value.';
218
- function controlPlaneErrorHint(profile, status, body) {
221
+ function controlPlaneErrorHint(profile, status, body, code) {
219
222
  switch (status) {
220
223
  case 402:
221
224
  return (profile === "skillInstall" || profile === "hubSkillInstall") && /marketplace/i.test(body) ? MARKETPLACE_HINT_402 : PLAN_HINT_402;
222
225
  case 403:
226
+ // A 403 that names its reason is not the scope problem the general hint
227
+ // describes, and sending a model off to grant scopes wastes the turn.
228
+ if (code === "platform_image_required")
229
+ return PLATFORM_IMAGE_HINT_403;
230
+ if (code === "plan_limit")
231
+ return PLAN_LIMIT_HINT_403;
223
232
  return SCOPE_HINT_403;
224
233
  case 404:
225
234
  return /hub_without_runtime_group/.test(body)
@@ -241,9 +250,10 @@ function controlPlaneErrorHint(profile, status, body) {
241
250
  /**
242
251
  * Run a control-plane call and, on failure, append actionable guidance.
243
252
  *
244
- * The SDK raises ThalovantApiError with the status embedded in the message
245
- * ("Thalovant API request failed with HTTP 412: ..."), so the status is
246
- * recovered by parsing rather than from a structured field.
253
+ * The status and code come from the SDK's ThalovantApiError. What the API
254
+ * said -- the code, the whole sentence, every other field of the body -- goes
255
+ * ahead of the hint (see apiErrorText); an error that is not an API error
256
+ * keeps its own message, and its status is read from it when it names one.
247
257
  */
248
258
  async function callControlPlane(profile, run) {
249
259
  try {
@@ -251,11 +261,13 @@ async function callControlPlane(profile, run) {
251
261
  }
252
262
  catch (error) {
253
263
  const message = error instanceof Error ? error.message : String(error);
254
- const status = Number(/HTTP (\d{3})/.exec(message)?.[1]);
255
- const hint = Number.isFinite(status) ? controlPlaneErrorHint(profile, status, message) : undefined;
264
+ const apiError = error instanceof ThalovantApiError ? error : undefined;
265
+ const status = apiError?.statusCode ?? Number(/HTTP (\d{3})/.exec(message)?.[1]);
266
+ const said = [message, apiError?.code, apiError?.detail].filter(Boolean).join(" ");
267
+ const hint = Number.isFinite(status) ? controlPlaneErrorHint(profile, status, said, apiError?.code) : undefined;
256
268
  if (!hint)
257
269
  throw error;
258
- throw new Error(`${message}\n\n${hint}`);
270
+ throw new Error(`${apiErrorText(error, redactSecrets) ?? message}\n\n${hint}`, { cause: error });
259
271
  }
260
272
  }
261
273
  const HUB_SKILL_DEFAULT_TIMEOUT_MS = 120_000;
@@ -586,7 +598,12 @@ function registerThalovantTool(server, name, config, handler) {
586
598
  durationMs: Date.now() - start,
587
599
  error: error instanceof Error ? error.message : String(error),
588
600
  });
589
- throw error;
601
+ // An API error that no hint was added to still says what the API said:
602
+ // the SDK's message is one bounded line, and the structured fields a
603
+ // refusal carries (the images a caller may pin, a plan's numbers) are
604
+ // only on the error itself.
605
+ const text = apiErrorText(error, redactSecrets);
606
+ throw text === undefined ? error : new Error(text, { cause: error });
590
607
  }
591
608
  });
592
609
  }
@@ -1879,7 +1896,7 @@ export function createServer() {
1879
1896
  });
1880
1897
  registerThalovantTool(server, "thalovant_release_hub", {
1881
1898
  title: "Release Hub",
1882
- description: "Apply a release policy to a hub and return the updated hub. Every option is optional; omitted fields fall back to the workspace release policy. Passing images switches the hub to custom mode unless mode is also set. Requires the hubs:write scope and a paid plan. No etag is needed.",
1899
+ description: "Apply a release policy to a hub and return the updated hub. Every option is optional; omitted fields fall back to the workspace release policy. Passing images switches the hub to custom mode unless mode is also set; non-admins may pass only images the platform releases (see images). Requires the hubs:write scope and a paid plan. No etag is needed.",
1883
1900
  inputSchema: {
1884
1901
  ...controlPlaneSchema,
1885
1902
  hubId: z.string().min(1).describe("Hub UUID."),
@@ -2072,7 +2089,7 @@ export function createServer() {
2072
2089
  });
2073
2090
  registerThalovantTool(server, "thalovant_release_runtime_group", {
2074
2091
  title: "Release Runtime Group",
2075
- description: "Apply a runtime image policy to a Thalovant runtime group and return the updated group. Options behave like thalovant_release_hub: everything is optional, omitted fields fall back to the workspace release policy, and passing images switches to custom mode unless mode is also set. Requires the hubs:write scope and a paid plan.",
2092
+ description: "Apply a runtime image policy to a Thalovant runtime group and return the updated group. Options behave like thalovant_release_hub: everything is optional, omitted fields fall back to the workspace release policy, and passing images switches to custom mode unless mode is also set (non-admins: only images the platform releases, see images). Requires the hubs:write scope and a paid plan.",
2076
2093
  inputSchema: {
2077
2094
  ...controlPlaneSchema,
2078
2095
  runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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.7.1",
67
+ "@thalovant/sdk": "^0.8.7",
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.5.1",
10
+ "version": "0.5.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.5.1",
15
+ "version": "0.5.3",
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.5.1",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.5.3",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"