@thalovant/mcp-server 0.1.9 → 0.1.11

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/.env.example CHANGED
@@ -4,7 +4,11 @@ THALOVANT_API_URL=https://api.thalovant.com
4
4
 
5
5
  # Use one control-plane auth option for private/admin tools.
6
6
  # Recommended: a scoped, revocable API token (dashboard or device flow).
7
- # Minimum scopes for the full tool surface: hubs:read, clients:write, memory:read, memory:write.
7
+ # Minimum scopes for the full tool surface: hubs:read, hubs:write, clients:write,
8
+ # memory:read, memory:write. hubs:write implies hubs:read, which implies hubs:inspect.
9
+ # Discovery only (browse the skill catalog, never provision): hubs:read alone.
10
+ # Hub and runtime-group provisioning also requires a PAID plan; the marketplace
11
+ # catalog is not paid-gated, so free-tier tokens can browse but not install.
8
12
  # THALOVANT_API_TOKEN=
9
13
  # THALOVANT_ACCESS_TOKEN=
10
14
  # THALOVANT_EMAIL=
@@ -45,6 +49,27 @@ MCP_HTTP_AUTH_TOKEN=
45
49
  # THALOVANT_PRINCIPAL_CREDENTIALS_DIR=/run/secrets/thalovant-principals
46
50
  # THALOVANT_ALLOW_SHARED_CREDENTIALS=false
47
51
 
52
+ # Destructive control-plane tools (thalovant_delete_hub, thalovant_delete_runtime_group)
53
+ # are NOT registered unless this is explicitly enabled. Leave it unset in any
54
+ # deployment where a model drives the tools with a long-lived token.
55
+ # THALOVANT_ENABLE_DESTRUCTIVE_TOOLS=false
56
+
57
+ # Non-catalog skill sources (e.g. thalovant_install_runtime_group_skill with
58
+ # sourceType "git" and an arbitrary sourceRef) can pull unvetted code into a
59
+ # production runtime. They are refused unless this is explicitly enabled. Leave
60
+ # it unset whenever a model drives the tools with a long-lived token.
61
+ # THALOVANT_ENABLE_GIT_SKILL_SOURCES=false
62
+
63
+ # Directory that thalovant_create_client_identity may write identity files into.
64
+ # savePath is confined to this directory; absolute paths outside it and ".."
65
+ # traversal are rejected. Defaults to <config-dir>/thalovant/identities.
66
+ # THALOVANT_MCP_IDENTITY_DIR=/var/lib/thalovant-mcp/identities
67
+
68
+ # Read-only mode: register ONLY tools annotated readOnlyHint:true, so a model
69
+ # cannot mutate control-plane or runtime state. Safer default for observe-only
70
+ # agents than hand-writing a denylist.
71
+ # THALOVANT_MCP_READONLY=false
72
+
48
73
  # Optional policy and audit controls.
49
74
  # MCP_TOOL_ALLOWLIST=thalovant_*
50
75
  # MCP_TOOL_DENYLIST=thalovant_delete_memory_item
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Security
6
+
7
+ - `thalovant_install_runtime_group_skill` now refuses non-catalog install sources by default. A `sourceType` other than `catalog` (notably `git` with an arbitrary `sourceRef`) can pull unvetted code into a production runtime, and the control-plane validator is format-only with no host allowlist, so it is rejected before any control-plane request unless the operator sets `THALOVANT_ENABLE_GIT_SKILL_SOURCES` (accepting `1`/`true`/`yes`/`on`), mirroring the `THALOVANT_ENABLE_DESTRUCTIVE_TOOLS` gate. `sourceType` is trimmed and case-folded before it is both checked and forwarded, so a variant such as `" Catalog "` or `"CATALOG"` cannot pass the local gate yet reach the control plane — which special-cases only the exact string `"catalog"` — as an unrecognized non-catalog source. The tool is now annotated `destructiveHint: true`, and `thalovant_config_status` reports `gitSkillSourcesEnabled`.
8
+ - `thalovant_create_client_identity` now confines the optional `savePath` to a configurable identity directory (`THALOVANT_MCP_IDENTITY_DIR`, default `<config-dir>/thalovant/identities`). Absolute paths outside the directory and `..` traversal are rejected, and the destination is validated before the identity is created in the control plane, so a model can no longer drop a `0600` credential file into a git working tree or synced folder. `thalovant_config_status` reports `identityDir`.
9
+ - `thalovant_healthcheck` output is now passed through `redactSecrets`, matching every other tool output.
10
+ - `thalovant_get_analytics_overview` no longer advertises the `admin` and (admin-only) `ownerId` arguments to the model, so it no longer teaches an admin mode that only ever 403s for non-admin callers; only the plain overview call remains. Injected `admin`/`ownerId` values are stripped and never reach the control plane.
11
+ - Extended output redaction to additional secret-ish keys: `device_code`, `user_code`, `psk`, `cert`, and `jwt`.
12
+ - Added an opt-in read-only mode (`THALOVANT_MCP_READONLY`): only tools annotated `readOnlyHint: true` are registered, so an observe-only agent needs no hand-written denylist. `thalovant_config_status` reports `readOnly`.
13
+
14
+ ## 0.1.10
15
+
16
+ - Added hub-provisioning and skill-discovery tools so an agent can discover skills and provision hubs conversationally, all delegating to `@thalovant/sdk` (now `^0.2.28`).
17
+ - Discovery tools: `thalovant_list_marketplace_skills`, `thalovant_list_runtime_group_marketplace`, `thalovant_list_runtime_group_inventory`, `thalovant_list_runtime_groups`, `thalovant_get_runtime_group`, `thalovant_get_runtime_group_config`, `thalovant_get_hub_runtime_capabilities`.
18
+ - Provisioning tools: `thalovant_create_hub`, `thalovant_update_hub`, `thalovant_release_hub`, `thalovant_set_hub_rating`, `thalovant_clear_hub_rating`, `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`.
19
+ - `thalovant_delete_hub` and `thalovant_delete_runtime_group` are **disabled by default**: they are not registered at all unless the operator sets `THALOVANT_ENABLE_DESTRUCTIVE_TOOLS`, so they never appear in `tools/list`. The existing allow/deny tool policy is a call-time filter that cannot express a default-off tool, so this is a separate registration-time gate; once enabled, the delete tools remain subject to that policy. `thalovant_config_status` now reports `destructiveToolsEnabled`.
20
+ - Mirrored the control-plane API's exact constraints in the new input schemas so invalid values fail client-side instead of as an opaque 422: hub `spec.version` required, `name` max 128, `slug` 1-191 matching `^[a-z0-9]+(?:-[a-z0-9]+)*$`, `namespace` max 128, `domain` max 255, `visibility` max 32, `capacity_profile` restricted to `standard`/`autoscaling`, hub rating 1-5, runtime group `name` max 128 and `description` max 255, runtime group `spec.replicas` 1-20, skill `skill_id` max 191, `source_ref` max 255, `version_pin` max 64, and `marketplace_skill_id` a UUID. `source_type` is intentionally left a free-form 1-32 string because the API does not constrain it to an enum.
21
+ - `thalovant_update_hub` requires an `etag` and no longer accepts `name`, `namespace`, or `domain`, which are immutable after creation; the description directs agents to read the etag from the hub resource body, since the API sends no `ETag` response header. `thalovant_delete_hub` requires an `etag` for the same reason.
22
+ - Control-plane failures on the new tools now carry actionable guidance that distinguishes `403 Insufficient scopes` (checked before the plan gate, so it can mask a plan problem), the `402 API access requires a paid plan.` gate, the distinct `402 This skill requires paid marketplace access for the tenant plan.` at skill install, `412 ETag mismatch`, and `409` idempotency-key reuse.
23
+ - Documented the scope map for the new surface — `hubs:read` for the catalog, `hubs:inspect` for runtime-group and hub runtime views, `hubs:write` plus a paid plan for provisioning — and noted that catalog browsing is not paid-gated, so a free-tier token can browse the marketplace but cannot install.
24
+
3
25
  ## 0.1.9
4
26
 
5
27
  - Added first-class scoped API token auth for the control plane: set `THALOVANT_API_TOKEN` and the server authenticates with the token directly and never calls the login endpoint. Precedence is `THALOVANT_API_TOKEN`, then `THALOVANT_ACCESS_TOKEN`, then the `THALOVANT_EMAIL`/`THALOVANT_PASSWORD` login fallback.
package/README.md CHANGED
@@ -56,12 +56,28 @@ 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`, 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`, and the hub lookup inside `thalovant_create_client_identity` |
60
+ | `hubs:inspect` | `thalovant_get_hub_runtime_capabilities`, `thalovant_list_runtime_group_marketplace`, `thalovant_list_runtime_group_inventory` |
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 hub rating tools, and the opt-in delete tools |
60
62
  | `clients:write` | `thalovant_create_client_identity` (`POST /v1/clients`) |
61
63
  | `memory:read` | `thalovant_list_memory_items`, `thalovant_get_memory_summary`, `thalovant_get_memory_item` |
62
64
  | `memory:write` | `thalovant_create_memory_item`, `thalovant_update_memory_item`, `thalovant_delete_memory_item` |
63
65
 
64
- Grant fewer scopes for narrower deployments: a read-only assistant needs only `hubs:read` and `memory:read`. `thalovant_get_analytics_overview` with `admin: true` additionally requires an admin account with `admin:analytics`, which API tokens for regular use should not carry. Runtime hub tools (`thalovant_ask`, `thalovant_send_action`, and friends) use Thalovant client identities, not control-plane tokens.
66
+ The hub scopes imply one another: `hubs:write` grants `hubs:read`, which grants `hubs:inspect` and `hubs:preview`. Minting a token with `hubs:read` is therefore enough for every discovery tool in the table above.
67
+
68
+ Scope is not the whole story for provisioning. Every hub and runtime-group write also requires a **paid plan**, and the API checks scope *before* the plan, so the two failure modes are ordered:
69
+
70
+ - A token missing the scope fails `403 Insufficient scopes`. Free-plan API tokens are capped at `hubs:read`, `clients:read`, and `clients:write`, so on the free tier provisioning fails with this 403 and never reaches the 402.
71
+ - A correctly scoped token on a free plan fails `402 API access requires a paid plan.`
72
+ - `thalovant_install_runtime_group_skill` can fail with a **second, distinct** 402, `This skill requires paid marketplace access for the tenant plan.`, when the plan is paid but does not include `access_tier: paid` catalog entries.
73
+
74
+ Discovery is deliberately not paid-gated: **a free-tier token can browse the marketplace catalog and set hub ratings, but cannot install skills or provision hubs.** Use `thalovant_list_runtime_group_marketplace` before installing — it reports `installable`, `purchase_required`, and `access_message` per skill, which turns an opaque 402 into a decision you can make up front.
75
+
76
+ Grant fewer scopes for narrower deployments: a read-only assistant needs only `hubs:read` and `memory:read`, and a discovery-only agent that browses skills but never provisions needs `hubs:read` alone. `thalovant_get_analytics_overview` with `admin: true` additionally requires an admin account with `admin:analytics`, which API tokens for regular use should not carry. Runtime hub tools (`thalovant_ask`, `thalovant_send_action`, and friends) use Thalovant client identities, not control-plane tokens.
77
+
78
+ ### Hub Etags
79
+
80
+ `thalovant_update_hub` and `thalovant_delete_hub` use optimistic locking and require the hub's current etag, sent as `If-Match`. The etag is only available in the **body** of the hub resource — the API sends no `ETag` response header — so an agent must call `thalovant_get_hub` first and pass the `etag` field from that response. A missing or stale value fails `412 ETag mismatch` and changes nothing; re-fetch and retry. `name`, `namespace`, and `domain` are immutable after creation, so `thalovant_update_hub` does not accept them at all; send only the fields you are changing rather than round-tripping a whole hub resource. Runtime-group writes do not use etags.
65
81
 
66
82
  ### Login Fallback
67
83
 
@@ -201,6 +217,8 @@ export MCP_TOOL_DENYLIST="thalovant_delete_memory_item"
201
217
 
202
218
  Per-principal credential files may also include `allowedTools` and `deniedTools`.
203
219
 
220
+ Both are call-time filters, and an empty allowlist means "allow everything". They cannot make a tool default-off or hide it from `tools/list`, which is why the two destructive control-plane tools are gated separately by `THALOVANT_ENABLE_DESTRUCTIVE_TOOLS`. See [Destructive Tools](#destructive-tools).
221
+
204
222
  Audit logs:
205
223
 
206
224
  ```bash
@@ -295,6 +313,16 @@ Read-only:
295
313
  - `thalovant_get_memory_summary`
296
314
  - `thalovant_get_memory_item`
297
315
 
316
+ Skill and runtime-group discovery (read-only):
317
+
318
+ - `thalovant_list_marketplace_skills`
319
+ - `thalovant_list_runtime_group_marketplace`
320
+ - `thalovant_list_runtime_group_inventory`
321
+ - `thalovant_list_runtime_groups`
322
+ - `thalovant_get_runtime_group`
323
+ - `thalovant_get_runtime_group_config`
324
+ - `thalovant_get_hub_runtime_capabilities`
325
+
298
326
  Writes or hub events:
299
327
 
300
328
  - `thalovant_create_client_identity`
@@ -306,7 +334,64 @@ Writes or hub events:
306
334
  - `thalovant_update_memory_item`
307
335
  - `thalovant_delete_memory_item`
308
336
 
309
- 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`.
337
+ Hub and runtime-group provisioning:
338
+
339
+ - `thalovant_create_hub`
340
+ - `thalovant_update_hub`
341
+ - `thalovant_release_hub`
342
+ - `thalovant_set_hub_rating`
343
+ - `thalovant_clear_hub_rating`
344
+ - `thalovant_create_runtime_group`
345
+ - `thalovant_update_runtime_group`
346
+ - `thalovant_update_runtime_group_config`
347
+ - `thalovant_release_runtime_group`
348
+ - `thalovant_install_runtime_group_skill`
349
+ - `thalovant_uninstall_runtime_group_skill`
350
+
351
+ Destructive, **not registered unless explicitly enabled** (see [Destructive Tools](#destructive-tools)):
352
+
353
+ - `thalovant_delete_hub`
354
+ - `thalovant_delete_runtime_group`
355
+
356
+ 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
+
358
+ ## Destructive Tools
359
+
360
+ `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.
361
+
362
+ A long-lived control-plane token combined with an always-available delete tool is a categorically different risk from a read or update tool: deleting a hub also deletes its dependent clients and ACLs, and none of it is reversible. So these two are opt-in:
363
+
364
+ ```bash
365
+ export THALOVANT_ENABLE_DESTRUCTIVE_TOOLS="true"
366
+ ```
367
+
368
+ Accepted true values are `1`, `true`, `yes`, and `on`; anything else, including unset, leaves the tools off. The flag is read when a server instance is created, so restart the server (or, in Streamable HTTP mode, start a new session) after changing it. `thalovant_config_status` reports the current state as `destructiveToolsEnabled` and lists the tools the flag controls.
369
+
370
+ This is a separate mechanism from the existing tool policy, deliberately. `MCP_TOOL_ALLOWLIST` / `MCP_TOOL_DENYLIST` and the per-principal `allowedTools` / `deniedTools` are call-time filters where an empty allowlist means "allow everything"; they cannot express a tool that is off until an operator turns it on, and they cannot hide a tool from `tools/list`. Once `THALOVANT_ENABLE_DESTRUCTIVE_TOOLS` is set the delete tools are ordinary tools again and remain subject to that policy, so the two layers compose:
371
+
372
+ ```bash
373
+ # Enable deletes server-wide, but deny them to everyone except trusted principals.
374
+ export THALOVANT_ENABLE_DESTRUCTIVE_TOOLS="true"
375
+ export MCP_TOOL_DENYLIST="thalovant_delete_hub,thalovant_delete_runtime_group"
376
+ ```
377
+
378
+ with the trusted principal's credential file granting them back via `allowedTools`.
379
+
380
+ Deleting a hub still requires a current etag (`412` otherwise), and deleting a runtime group fails with `409` while it is the workspace default or still has hubs attached.
381
+
382
+ ## Non-Catalog Skill Sources
383
+
384
+ `thalovant_install_runtime_group_skill` installs from the vetted marketplace catalog by default. Any other source — notably `sourceType: "git"` with an arbitrary `sourceRef` repository URL — pulls code the marketplace never reviewed straight into a production runtime, and the control-plane validator is format-only with no host allowlist. Because the tools are driven by a model holding a long-lived token, non-catalog sources are refused unless an operator opts in:
385
+
386
+ ```bash
387
+ export THALOVANT_ENABLE_GIT_SKILL_SOURCES="true"
388
+ ```
389
+
390
+ With the flag unset, a call with any `sourceType` other than `catalog` fails before any control-plane request is made. Accepted true values are `1`, `true`, `yes`, and `on`. `thalovant_config_status` reports the state as `gitSkillSourcesEnabled`. The tool is annotated `destructiveHint: true`.
391
+
392
+ ## Read-Only Mode
393
+
394
+ Set `THALOVANT_MCP_READONLY=1` to register only tools annotated `readOnlyHint: true`. Write and destructive tools are then never registered and never appear in `tools/list`, so an operator can run an observe-only agent without hand-writing a denylist. Like the other registration-time gates it is read when a server instance is created; `thalovant_config_status` reports the state as `readOnly`.
310
395
 
311
396
  ## Development
312
397
 
package/dist/index.js CHANGED
@@ -9,12 +9,13 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
9
9
  import { createServer as createHttpServer } from "node:http";
10
10
  import { realpathSync } from "node:fs";
11
11
  import { appendFile, readFile, writeFile } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { homedir } from "node:os";
13
14
  import { mkdir } from "node:fs/promises";
14
15
  import { fileURLToPath, pathToFileURL } from "node:url";
15
16
  import { createRemoteJWKSet, jwtVerify } from "jose";
16
17
  import { z } from "zod";
17
- const VERSION = "0.1.9";
18
+ const VERSION = "0.1.10";
18
19
  const DEFAULT_TIMEOUT_MS = 30_000;
19
20
  const MAX_TIMEOUT_MS = 300_000;
20
21
  const MAX_LIMIT = 100;
@@ -42,9 +43,16 @@ const SECRET_KEYS = [
42
43
  "privateKey",
43
44
  "refresh_token",
44
45
  "refreshToken",
46
+ "device_code",
47
+ "deviceCode",
48
+ "user_code",
49
+ "userCode",
45
50
  "authToken",
46
51
  "authorization",
47
52
  "bearer",
53
+ "jwt",
54
+ "psk",
55
+ "cert",
48
56
  "token",
49
57
  "secret",
50
58
  ];
@@ -106,6 +114,140 @@ const runtimeAuthSchema = {
106
114
  .describe("Load the identity from Thalovant SDK environment variables."),
107
115
  protocol: protocolSchema.default("wss").describe("Runtime transport protocol."),
108
116
  };
117
+ /**
118
+ * Tools that destroy control-plane resources. They are never registered unless
119
+ * an operator opts in with THALOVANT_ENABLE_DESTRUCTIVE_TOOLS, so by default
120
+ * they are absent from tools/list entirely rather than merely failing when
121
+ * called. See destructiveToolsEnabled().
122
+ */
123
+ const DESTRUCTIVE_TOOLS = ["thalovant_delete_hub", "thalovant_delete_runtime_group"];
124
+ /**
125
+ * Destructive control-plane tools are opt-in.
126
+ *
127
+ * The per-principal tool policy in authorizeTool() is an allow/deny filter
128
+ * evaluated at call time: an empty allowlist means "allow everything", so it
129
+ * cannot express a tool that is off until an operator turns it on, and it
130
+ * cannot hide a tool from tools/list. A long-lived token plus an always-listed
131
+ * delete tool is a categorically different risk from a read or update tool, so
132
+ * these two are gated at registration time by this env flag instead. When the
133
+ * flag is on they are registered like any other tool and remain subject to the
134
+ * global and per-principal policy, which can still deny them.
135
+ */
136
+ function destructiveToolsEnabled() {
137
+ return parseBool(process.env.THALOVANT_ENABLE_DESTRUCTIVE_TOOLS, false);
138
+ }
139
+ /**
140
+ * Non-catalog skill sources are opt-in.
141
+ *
142
+ * thalovant_install_runtime_group_skill can install a skill from a source other
143
+ * than the vetted marketplace catalog — notably `sourceType: "git"` with an
144
+ * arbitrary `sourceRef` repository URL. The control-plane validator is
145
+ * format-only with no host allowlist, so any non-catalog source is effectively
146
+ * an arbitrary-code-into-the-runtime primitive. Because this server hands its
147
+ * tools to an LLM holding a long-lived token, that primitive is refused by
148
+ * default and only enabled when an operator opts in with this flag, mirroring
149
+ * the THALOVANT_ENABLE_DESTRUCTIVE_TOOLS gate. Installing from the catalog (the
150
+ * default source) is always allowed.
151
+ */
152
+ function gitSkillSourcesEnabled() {
153
+ return parseBool(process.env.THALOVANT_ENABLE_GIT_SKILL_SOURCES, false);
154
+ }
155
+ /** The default install source is the vetted marketplace catalog. */
156
+ function isCatalogSource(sourceType) {
157
+ return !sourceType || sourceType.trim().toLowerCase() === "catalog";
158
+ }
159
+ /**
160
+ * Read-only mode.
161
+ *
162
+ * When THALOVANT_MCP_READONLY is set, only tools annotated readOnlyHint: true
163
+ * are registered, so an operator can run a safe agent without hand-writing a
164
+ * tool denylist. Non-read-only tools are never registered and so never appear
165
+ * in tools/list. This complements, and is independent of, the per-principal
166
+ * allow/deny policy in authorizeTool().
167
+ */
168
+ function readOnlyModeEnabled() {
169
+ return parseBool(process.env.THALOVANT_MCP_READONLY, false);
170
+ }
171
+ const capacityProfileSchema = z
172
+ .enum(["standard", "autoscaling"])
173
+ .describe('Hub capacity profile. Only "standard" (the API default, 1 replica) and "autoscaling" (2-32 replicas) are accepted. Omit the key entirely rather than sending null — an explicit null fails 422 INVALID_HUB_CAPACITY_PROFILE. "autoscaling" is plan-gated: 403 HUB_AUTOSCALING_NOT_INCLUDED when the plan has no autoscaling, 409 AUTOSCALING_HUB_LIMIT_REACHED when its autoscaling slots are already used.');
174
+ const hubSpecSchema = z
175
+ .object({ version: z.string().min(1).describe("Required. Hub spec version, e.g. \"1\".") })
176
+ .passthrough()
177
+ .describe('Hub spec object. `version` is REQUIRED and must be a non-empty string — {"version": "1"} is the minimum valid spec; omitting it fails 422 "Schema validation failed". Other keys pass through, and the API injects defaults for replicas, resources, catalog, and protocols.');
178
+ const hubEtagSchema = z
179
+ .string()
180
+ .min(1)
181
+ .describe("Required. The hub's current etag, sent as If-Match for optimistic locking. Read it from the `etag` field in the hub resource BODY returned by thalovant_get_hub — the API does not send an ETag response header, so you must fetch the hub first. A missing or stale value fails with HTTP 412 and changes nothing: re-fetch the hub, take the new etag, and retry.");
182
+ const runtimeGroupResourceLimitsSchema = z
183
+ .object({
184
+ requests: z.record(z.string()).optional(),
185
+ limits: z.record(z.string()).optional(),
186
+ })
187
+ .optional();
188
+ const runtimeGroupSpecSchema = z
189
+ .object({
190
+ replicas: z.number().int().min(1).max(20).optional().describe("Replica count, 1-20."),
191
+ resources: z
192
+ .object({
193
+ core: runtimeGroupResourceLimitsSchema,
194
+ messagebus: runtimeGroupResourceLimitsSchema,
195
+ })
196
+ .optional()
197
+ .describe("Container resource requests/limits, as string-valued maps (for example {\"cpu\": \"500m\"})."),
198
+ })
199
+ .describe("Patches replicas and container resources only. Note the replica ceiling here is 20, separate from a hub's autoscaling ceiling of 32.");
200
+ const releaseOptionsSchema = {
201
+ channel: z.string().min(1).optional().describe("Release channel. Falls back to the workspace release policy when omitted."),
202
+ mode: z.string().min(1).optional().describe('Release mode. Passing images without mode switches to "custom".'),
203
+ version: z.string().min(1).optional().describe("Pinned release version."),
204
+ images: z.record(z.string().min(1)).optional().describe("Explicit image overrides. Switches to custom mode unless mode is also set."),
205
+ reason: z.string().min(1).optional().describe("Audit reason recorded with the release."),
206
+ };
207
+ function releaseOptionsFrom(args) {
208
+ return { channel: args.channel, mode: args.mode, version: args.version, images: args.images, reason: args.reason };
209
+ }
210
+ 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.';
211
+ 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.';
212
+ 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.';
213
+ 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.';
214
+ function controlPlaneErrorHint(profile, status, body) {
215
+ switch (status) {
216
+ case 402:
217
+ return profile === "skillInstall" && /marketplace/i.test(body) ? MARKETPLACE_HINT_402 : PLAN_HINT_402;
218
+ case 403:
219
+ return SCOPE_HINT_403;
220
+ case 409:
221
+ if (profile === "hubWrite") {
222
+ return 'HTTP 409 — for a hub create this is either a duplicate ("Hub with name ... already exists for this owner" / "Hub slug ... already exists"), an autoscaling slot limit (AUTOSCALING_HUB_LIMIT_REACHED), or "Idempotency key re-used with different payload". Only hub create honors Idempotency-Key: retrying an identical create is safe and returns the original hub, but reusing a key with a changed body conflicts — use a new idempotencyKey for a genuinely different hub.';
223
+ }
224
+ return "HTTP 409 — the resource is not in a state that allows this call: a hub with no connected client cannot report runtime capabilities, a runtime group cannot be deleted while it is the workspace default or still has hubs attached, and a deactivated marketplace skill cannot be installed.";
225
+ case 412:
226
+ return ETAG_HINT_412;
227
+ default:
228
+ return undefined;
229
+ }
230
+ }
231
+ /**
232
+ * Run a control-plane call and, on failure, append actionable guidance.
233
+ *
234
+ * The SDK raises ThalovantApiError with the status embedded in the message
235
+ * ("Thalovant API request failed with HTTP 412: ..."), so the status is
236
+ * recovered by parsing rather than from a structured field.
237
+ */
238
+ async function callControlPlane(profile, run) {
239
+ try {
240
+ return await run();
241
+ }
242
+ catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ const status = Number(/HTTP (\d{3})/.exec(message)?.[1]);
245
+ const hint = Number.isFinite(status) ? controlPlaneErrorHint(profile, status, message) : undefined;
246
+ if (!hint)
247
+ throw error;
248
+ throw new Error(`${message}\n\n${hint}`);
249
+ }
250
+ }
109
251
  class HttpError extends Error {
110
252
  status;
111
253
  constructor(status, message) {
@@ -365,6 +507,13 @@ async function auditLog(event) {
365
507
  }
366
508
  }
367
509
  function registerThalovantTool(server, name, config, handler) {
510
+ if (readOnlyModeEnabled()) {
511
+ const annotations = (config.annotations ?? {});
512
+ if (annotations.readOnlyHint !== true) {
513
+ // Read-only mode: do not register write/destructive tools at all.
514
+ return;
515
+ }
516
+ }
368
517
  server.registerTool(name, config, async (args, extra) => {
369
518
  const principal = principalFromExtra(extra);
370
519
  const start = Date.now();
@@ -517,8 +666,53 @@ function summarizeReply(reply) {
517
666
  failureEvent: reply.failureEvent ? redactSecrets(reply.failureEvent.asObject()) : undefined,
518
667
  };
519
668
  }
520
- async function saveIdentity(path, identity) {
521
- const resolvedPath = resolve(path);
669
+ /**
670
+ * Base directory that saved client-identity files are confined to.
671
+ *
672
+ * thalovant_create_client_identity takes an LLM-chosen savePath. The file is
673
+ * written 0600, but without a base directory the model controls WHERE the
674
+ * secret lands and could drop a credential file into a git working tree or a
675
+ * synced folder. Files are therefore confined to THALOVANT_MCP_IDENTITY_DIR,
676
+ * defaulting to an `identities` folder under the Thalovant SDK config dir
677
+ * ($XDG_CONFIG_HOME/thalovant, %APPDATA%/Thalovant on Windows, else
678
+ * ~/.config/thalovant), matching the SDK's own convention.
679
+ */
680
+ function identityBaseDir() {
681
+ const override = process.env.THALOVANT_MCP_IDENTITY_DIR?.trim();
682
+ if (override)
683
+ return resolve(override);
684
+ if (process.env.XDG_CONFIG_HOME)
685
+ return join(process.env.XDG_CONFIG_HOME, "thalovant", "identities");
686
+ if (process.platform === "win32" && process.env.APPDATA)
687
+ return join(process.env.APPDATA, "Thalovant", "identities");
688
+ return join(homedir(), ".config", "thalovant", "identities");
689
+ }
690
+ /**
691
+ * Resolve an LLM-supplied savePath against the identity base directory and
692
+ * refuse anything that escapes it. Relative paths resolve inside the base dir;
693
+ * absolute paths and `..` traversal that land outside it are rejected. The base
694
+ * dir is realpath'd so a symlinked base still validates correctly.
695
+ */
696
+ function resolveIdentityPath(savePath) {
697
+ const baseDir = identityBaseDir();
698
+ let canonicalBase = resolve(baseDir);
699
+ try {
700
+ canonicalBase = realpathSync(canonicalBase);
701
+ }
702
+ catch {
703
+ // Base dir does not exist yet; writeIdentityFile creates it. Use the resolved
704
+ // (non-canonical) path for containment — nothing is symlinked yet.
705
+ }
706
+ const candidate = resolve(canonicalBase, savePath);
707
+ const rel = relative(canonicalBase, candidate);
708
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
709
+ throw new Error(`savePath must resolve to a location inside the identity directory (${canonicalBase}). ` +
710
+ `Absolute paths outside it and ".." traversal are rejected; pass a plain filename such as "my-hub.json". ` +
711
+ `Set THALOVANT_MCP_IDENTITY_DIR to change the identity directory.`);
712
+ }
713
+ return candidate;
714
+ }
715
+ async function writeIdentityFile(resolvedPath, identity) {
522
716
  await mkdir(dirname(resolvedPath), { recursive: true, mode: 0o700 });
523
717
  await writeFile(resolvedPath, `${stableStringify(identity.asObject(true))}\n`, { mode: 0o600 });
524
718
  return resolvedPath;
@@ -913,7 +1107,12 @@ export function createServer() {
913
1107
  profile: process.env.THALOVANT_PROFILE,
914
1108
  hasRuntimeEnvIdentity: Boolean(process.env.THALOVANT_ACCESS_KEY || process.env.THALOVANT_IDENTITY),
915
1109
  defaultProtocol: "wss",
916
- secretHandling: "Secrets are redacted in tool output. Identity files are only written when savePath is provided.",
1110
+ destructiveToolsEnabled: destructiveToolsEnabled(),
1111
+ destructiveTools: destructiveToolsEnabled() ? [...DESTRUCTIVE_TOOLS] : [],
1112
+ gitSkillSourcesEnabled: gitSkillSourcesEnabled(),
1113
+ readOnly: readOnlyModeEnabled(),
1114
+ identityDir: identityBaseDir(),
1115
+ secretHandling: "Secrets are redacted in tool output. Identity files are only written when savePath is provided, and only inside identityDir.",
917
1116
  }));
918
1117
  registerThalovantTool(server, "thalovant_list_public_hubs", {
919
1118
  title: "List Public Hubs",
@@ -1003,7 +1202,7 @@ export function createServer() {
1003
1202
  .string()
1004
1203
  .min(1)
1005
1204
  .optional()
1006
- .describe("Optional local file path for the full secret identity JSON. File mode is set to 0600."),
1205
+ .describe("Optional filename for the full secret identity JSON, written 0600 inside the server's identity directory (see identityDir in thalovant_config_status). Pass a plain filename like \"my-hub.json\"; absolute paths outside the identity directory and \"..\" traversal are rejected."),
1007
1206
  },
1008
1207
  annotations: {
1009
1208
  readOnlyHint: false,
@@ -1012,6 +1211,10 @@ export function createServer() {
1012
1211
  openWorldHint: true,
1013
1212
  },
1014
1213
  }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, savePath, ...auth }) => {
1214
+ // Validate the destination BEFORE creating a cloud identity, so a path
1215
+ // that escapes the identity directory is refused without leaving an
1216
+ // orphaned credential behind in the control plane.
1217
+ const targetIdentityPath = savePath ? resolveIdentityPath(savePath) : undefined;
1015
1218
  const api = await createControlPlane(auth);
1016
1219
  ensureAuthenticated(api);
1017
1220
  const result = await api.createClientIdentity(hubId, {
@@ -1024,7 +1227,7 @@ export function createServer() {
1024
1227
  spec,
1025
1228
  });
1026
1229
  const selectedEndpoint = api.requireRuntimeProtocol(result, preferredProtocols[0]);
1027
- const savedIdentityPath = savePath ? await saveIdentity(savePath, result.identity) : undefined;
1230
+ const savedIdentityPath = targetIdentityPath ? await writeIdentityFile(targetIdentityPath, result.identity) : undefined;
1028
1231
  return jsonContent({
1029
1232
  result: redactSecrets(result.asObject({ includeSecrets: false })),
1030
1233
  selectedEndpoint,
@@ -1076,7 +1279,7 @@ export function createServer() {
1076
1279
  const client = await createRuntimeClient(runtime);
1077
1280
  try {
1078
1281
  await client.connect(clampTimeout(timeoutMs));
1079
- return jsonContent(client.healthcheck());
1282
+ return jsonContent(redactSecrets(client.healthcheck()));
1080
1283
  }
1081
1284
  finally {
1082
1285
  await client.close();
@@ -1251,10 +1454,8 @@ export function createServer() {
1251
1454
  description: "Read authenticated Thalovant analytics overview data.",
1252
1455
  inputSchema: {
1253
1456
  ...controlPlaneSchema,
1254
- admin: z.boolean().optional(),
1255
1457
  range: z.string().min(1).optional(),
1256
1458
  bucket: z.string().min(1).optional(),
1257
- ownerId: z.string().min(1).optional(),
1258
1459
  hubId: z.string().min(1).optional(),
1259
1460
  clientId: z.string().min(1).optional(),
1260
1461
  country: z.string().min(1).optional(),
@@ -1412,6 +1613,484 @@ export function createServer() {
1412
1613
  await api.deleteMemoryItem(memoryId);
1413
1614
  return textContent("Memory item deleted.");
1414
1615
  });
1616
+ registerThalovantTool(server, "thalovant_list_marketplace_skills", {
1617
+ title: "List Marketplace Skills",
1618
+ description: "Browse the Thalovant marketplace skill catalog. Start here when discovering what a hub could run: each entry carries the fields an install needs (skill_id, source_type, source_ref, package_name, version compatibility, config_schema, secret_schema) plus category, tags, verified, access_tier, and billing_sku. Requires the hubs:read scope and is NOT paid-gated, so a free-tier token can browse the catalog even though it cannot install from it. The response is not paginated — the whole catalog comes back at once.",
1619
+ inputSchema: {
1620
+ ...controlPlaneSchema,
1621
+ ownerId: z
1622
+ .string()
1623
+ .min(1)
1624
+ .optional()
1625
+ .describe("Admin tokens only. A non-admin caller is silently scoped to their own tenant instead of being rejected, so this is a no-op for ordinary tokens."),
1626
+ includeInactive: z
1627
+ .boolean()
1628
+ .optional()
1629
+ .describe("Admin tokens only. A non-admin caller silently gets active entries only, with no error."),
1630
+ forceRefresh: z
1631
+ .boolean()
1632
+ .optional()
1633
+ .describe("Re-sync the global catalog from its source before answering. Open to all callers; noticeably slower."),
1634
+ },
1635
+ annotations: {
1636
+ readOnlyHint: true,
1637
+ openWorldHint: true,
1638
+ },
1639
+ }, async ({ ownerId, includeInactive, forceRefresh, ...auth }) => {
1640
+ const api = await createControlPlane(auth);
1641
+ ensureAuthenticated(api);
1642
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.listMarketplaceSkills({ ownerId, includeInactive, forceRefresh }))));
1643
+ });
1644
+ registerThalovantTool(server, "thalovant_list_runtime_group_marketplace", {
1645
+ title: "List Runtime Group Marketplace",
1646
+ description: "List the marketplace catalog resolved against one runtime group. This is the view to read immediately before installing: every catalog entry comes back with the group's own state folded in — whether the skill is desired (active, version_pin, source_type), whether it was observed running (observed_source, observed_at, intent counts), operator status, and the plan verdict (purchase_required, installable, access_message). Check installable and purchase_required here to avoid a 402 at install time. Requires the hubs:inspect scope; browsing needs no paid plan. Answers 404 for an unknown group and 403 when the caller does not own it.",
1647
+ inputSchema: {
1648
+ ...controlPlaneSchema,
1649
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1650
+ refreshInventory: z
1651
+ .boolean()
1652
+ .optional()
1653
+ .describe("Force a live read from the runtime operator. Without it the envelope source is runtime-group-cache (or runtime-group-cache-empty), never a live read."),
1654
+ },
1655
+ annotations: {
1656
+ readOnlyHint: true,
1657
+ openWorldHint: true,
1658
+ },
1659
+ }, async ({ runtimeGroupId, refreshInventory, ...auth }) => {
1660
+ const api = await createControlPlane(auth);
1661
+ ensureAuthenticated(api);
1662
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.listRuntimeGroupMarketplace(runtimeGroupId, { refreshInventory }))));
1663
+ });
1664
+ registerThalovantTool(server, "thalovant_list_runtime_group_inventory", {
1665
+ title: "List Runtime Group Inventory",
1666
+ description: "List the skills a runtime group is actually observed running right now. Where thalovant_list_runtime_group_marketplace answers 'what could be installed here', this answers 'what is loaded'. Each entry carries skill_id, version, source, active, adapt_intents, padatious_intents, total_intents, and observed_at; the envelope reports source (ovos-runtime-operator, runtime-group-cache, or ovos-runtime-operator-pending), operator_phase, and operator_message. Unlike thalovant_get_hub_runtime_capabilities this never fails with 409 when nothing is reporting — it returns an empty list with a pending source. Requires the hubs:inspect scope; no paid plan needed.",
1667
+ inputSchema: {
1668
+ ...controlPlaneSchema,
1669
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1670
+ refresh: z
1671
+ .boolean()
1672
+ .optional()
1673
+ .describe("Force a live operator read. The API also refreshes on its own when it holds no cached snapshot."),
1674
+ },
1675
+ annotations: {
1676
+ readOnlyHint: true,
1677
+ openWorldHint: true,
1678
+ },
1679
+ }, async ({ runtimeGroupId, refresh, ...auth }) => {
1680
+ const api = await createControlPlane(auth);
1681
+ ensureAuthenticated(api);
1682
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.listRuntimeGroupInventory(runtimeGroupId, { refresh }))));
1683
+ });
1684
+ registerThalovantTool(server, "thalovant_create_hub", {
1685
+ title: "Create Hub",
1686
+ description: "Create a Thalovant hub. Requires the hubs:write scope AND a paid plan: a token without the scope fails 403, and because the scope is checked before the plan, granting the scope can then surface a 402 'API access requires a paid plan'. The create is idempotent — the SDK sends an Idempotency-Key, so a retried create returns the first hub rather than making a second one; reusing a key with a DIFFERENT body fails 409.",
1687
+ inputSchema: {
1688
+ ...controlPlaneSchema,
1689
+ name: z.string().min(1).max(128).describe("Hub name, 1-128 characters. Required. Immutable once created."),
1690
+ spec: hubSpecSchema,
1691
+ slug: z
1692
+ .string()
1693
+ .min(1)
1694
+ .max(191)
1695
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug must be lowercase alphanumeric segments separated by single hyphens.")
1696
+ .optional()
1697
+ .describe("1-191 chars, lowercase alphanumeric segments separated by single hyphens. Defaults to a slug derived from name. Unlike name, slug stays mutable."),
1698
+ namespace: z.string().min(1).max(128).optional().describe("1-128 characters. Resolved server-side when omitted. Immutable once created."),
1699
+ domain: z
1700
+ .string()
1701
+ .min(1)
1702
+ .max(255)
1703
+ .optional()
1704
+ .describe("Max 255 characters. Immutable once created. Plans without custom domains are rejected with 403; when omitted, a managed subdomain is generated if the plan provides one."),
1705
+ active: z.boolean().optional().describe("Defaults to true."),
1706
+ visibility: z
1707
+ .string()
1708
+ .min(1)
1709
+ .max(32)
1710
+ .optional()
1711
+ .describe('1-32 characters, defaults to "private". Only "public" is special-cased by the API, and public listing is plan-gated.'),
1712
+ ownerId: z.string().min(1).optional().describe("Defaults to the caller. Setting another owner requires admin."),
1713
+ runtimeGroupId: z.string().min(1).optional().describe("Runtime group UUID to attach the hub to."),
1714
+ capacityProfile: capacityProfileSchema.optional(),
1715
+ isLocked: z.boolean().optional(),
1716
+ idempotencyKey: z
1717
+ .string()
1718
+ .min(1)
1719
+ .optional()
1720
+ .describe("Reuse the same key to safely retry one create. Reusing it with a different body fails 409."),
1721
+ },
1722
+ annotations: {
1723
+ readOnlyHint: false,
1724
+ destructiveHint: false,
1725
+ idempotentHint: true,
1726
+ openWorldHint: true,
1727
+ },
1728
+ }, async (args) => {
1729
+ const { loginScope, apiUrl, idempotencyKey, ...payload } = args;
1730
+ const api = await createControlPlane({ loginScope, apiUrl });
1731
+ ensureAuthenticated(api);
1732
+ return jsonContent(redactSecrets(await callControlPlane("hubWrite", () => api.createHub(payload, { idempotencyKey }))));
1733
+ });
1734
+ registerThalovantTool(server, "thalovant_update_hub", {
1735
+ title: "Update Hub",
1736
+ description: "Partially update a Thalovant hub. Send ONLY the fields you are changing — do not round-trip a whole hub resource. name, namespace, and domain are immutable and are deliberately not accepted here; the API rejects a changed value with HTTP 400. This route uses optimistic locking and requires the hub's current etag, which lives in the hub resource BODY (there is no ETag response header), so call thalovant_get_hub first and pass the etag field from its response. A missing or stale etag fails 412 and changes nothing. Requires the hubs:write scope and a paid plan.",
1737
+ inputSchema: {
1738
+ ...controlPlaneSchema,
1739
+ hubId: z.string().min(1).describe("Hub UUID. This authenticated route rejects slugs."),
1740
+ etag: hubEtagSchema,
1741
+ active: z.boolean().optional(),
1742
+ visibility: z
1743
+ .string()
1744
+ .min(1)
1745
+ .max(32)
1746
+ .optional()
1747
+ .describe('1-32 characters. Switching to "public" is plan-gated and fails 403 on plans that keep hubs private.'),
1748
+ slug: z
1749
+ .string()
1750
+ .min(1)
1751
+ .max(191)
1752
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug must be lowercase alphanumeric segments separated by single hyphens.")
1753
+ .optional()
1754
+ .describe("Mutable, unlike name/namespace/domain. Fails 409 if the slug is already taken."),
1755
+ spec: hubSpecSchema.optional(),
1756
+ ownerId: z.string().min(1).optional(),
1757
+ runtimeGroupId: z.string().min(1).optional(),
1758
+ capacityProfile: capacityProfileSchema.optional(),
1759
+ isLocked: z.boolean().optional().describe("Admin only: a non-admin gets 403, and a non-admin cannot update a locked hub at all."),
1760
+ },
1761
+ annotations: {
1762
+ readOnlyHint: false,
1763
+ destructiveHint: false,
1764
+ idempotentHint: false,
1765
+ openWorldHint: true,
1766
+ },
1767
+ }, async (args) => {
1768
+ const { loginScope, apiUrl, hubId, etag, ...payload } = args;
1769
+ const api = await createControlPlane({ loginScope, apiUrl });
1770
+ ensureAuthenticated(api);
1771
+ return jsonContent(redactSecrets(await callControlPlane("hubWrite", () => api.updateHub(hubId, payload, { etag }))));
1772
+ });
1773
+ registerThalovantTool(server, "thalovant_release_hub", {
1774
+ title: "Release Hub",
1775
+ 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.",
1776
+ inputSchema: {
1777
+ ...controlPlaneSchema,
1778
+ hubId: z.string().min(1).describe("Hub UUID."),
1779
+ ...releaseOptionsSchema,
1780
+ },
1781
+ annotations: {
1782
+ readOnlyHint: false,
1783
+ destructiveHint: false,
1784
+ idempotentHint: false,
1785
+ openWorldHint: true,
1786
+ },
1787
+ }, async ({ hubId, channel, mode, version, images, reason, ...auth }) => {
1788
+ const api = await createControlPlane(auth);
1789
+ ensureAuthenticated(api);
1790
+ return jsonContent(redactSecrets(await callControlPlane("hubWrite", () => api.releaseHub(hubId, releaseOptionsFrom({ channel, mode, version, images, reason })))));
1791
+ });
1792
+ registerThalovantTool(server, "thalovant_set_hub_rating", {
1793
+ title: "Set Hub Rating",
1794
+ description: 'Rate a public Thalovant hub from 1 to 5 and return the updated hub. Requires the hubs:write scope; no paid plan is needed, so free-tier tokens can rate. Rating a non-public hub fails 400 "Only public hubs can be rated." and rating your own hub fails 400 "Hub owners cannot rate their own public hubs."',
1795
+ inputSchema: {
1796
+ ...controlPlaneSchema,
1797
+ hubId: z.string().min(1).describe("Hub UUID."),
1798
+ rating: z.number().int().min(1).max(5).describe("Rating, an integer from 1 to 5."),
1799
+ },
1800
+ annotations: {
1801
+ readOnlyHint: false,
1802
+ destructiveHint: false,
1803
+ idempotentHint: true,
1804
+ openWorldHint: true,
1805
+ },
1806
+ }, async ({ hubId, rating, ...auth }) => {
1807
+ const api = await createControlPlane(auth);
1808
+ ensureAuthenticated(api);
1809
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.setHubRating(hubId, rating))));
1810
+ });
1811
+ registerThalovantTool(server, "thalovant_clear_hub_rating", {
1812
+ title: "Clear Hub Rating",
1813
+ description: "Remove the caller's own rating from a public Thalovant hub and return the hub. This clears only the caller's rating, not the hub. Requires the hubs:write scope; no paid plan is needed.",
1814
+ inputSchema: {
1815
+ ...controlPlaneSchema,
1816
+ hubId: z.string().min(1).describe("Hub UUID."),
1817
+ },
1818
+ annotations: {
1819
+ readOnlyHint: false,
1820
+ destructiveHint: false,
1821
+ idempotentHint: true,
1822
+ openWorldHint: true,
1823
+ },
1824
+ }, async ({ hubId, ...auth }) => {
1825
+ const api = await createControlPlane(auth);
1826
+ ensureAuthenticated(api);
1827
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.clearHubRating(hubId))));
1828
+ });
1829
+ registerThalovantTool(server, "thalovant_get_hub_runtime_capabilities", {
1830
+ title: "Get Hub Runtime Capabilities",
1831
+ description: "Read the live skill and intent inventory a hub runtime exposes. Requires the hubs:inspect scope. The API answers HTTP 409 when the hub has no connected client that can report inventory — for a group-level view that returns an empty list instead of failing, use thalovant_list_runtime_group_inventory.",
1832
+ inputSchema: {
1833
+ ...controlPlaneSchema,
1834
+ hubId: z.string().min(1).describe("Hub UUID."),
1835
+ },
1836
+ annotations: {
1837
+ readOnlyHint: true,
1838
+ openWorldHint: true,
1839
+ },
1840
+ }, async ({ hubId, ...auth }) => {
1841
+ const api = await createControlPlane(auth);
1842
+ ensureAuthenticated(api);
1843
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.getHubRuntimeCapabilities(hubId))));
1844
+ });
1845
+ registerThalovantTool(server, "thalovant_list_runtime_groups", {
1846
+ title: "List Runtime Groups",
1847
+ description: "List the Thalovant runtime groups visible to the authenticated account. Requires the hubs:read scope. The response is not paginated — every visible group is returned at once.",
1848
+ inputSchema: {
1849
+ ...controlPlaneSchema,
1850
+ ownerId: z
1851
+ .string()
1852
+ .min(1)
1853
+ .optional()
1854
+ .describe("Admin tokens only. Unlike the marketplace catalog, which silently ignores this for non-admins, passing another account's id here fails 403 \"Ownership required\". Omit it to list your own groups."),
1855
+ },
1856
+ annotations: {
1857
+ readOnlyHint: true,
1858
+ openWorldHint: true,
1859
+ },
1860
+ }, async ({ ownerId, ...auth }) => {
1861
+ const api = await createControlPlane(auth);
1862
+ ensureAuthenticated(api);
1863
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.listRuntimeGroups({ ownerId }))));
1864
+ });
1865
+ registerThalovantTool(server, "thalovant_get_runtime_group", {
1866
+ title: "Get Runtime Group",
1867
+ description: "Fetch one Thalovant runtime group. Requires the hubs:read scope.",
1868
+ inputSchema: {
1869
+ ...controlPlaneSchema,
1870
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1871
+ },
1872
+ annotations: {
1873
+ readOnlyHint: true,
1874
+ openWorldHint: true,
1875
+ },
1876
+ }, async ({ runtimeGroupId, ...auth }) => {
1877
+ const api = await createControlPlane(auth);
1878
+ ensureAuthenticated(api);
1879
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.getRuntimeGroup(runtimeGroupId))));
1880
+ });
1881
+ registerThalovantTool(server, "thalovant_create_runtime_group", {
1882
+ title: "Create Runtime Group",
1883
+ description: "Create a Thalovant runtime group — the unit that hosts hubs and holds installed skills. Requires the hubs:write scope and a paid plan. Unlike the hub write routes this one takes no etag.",
1884
+ inputSchema: {
1885
+ ...controlPlaneSchema,
1886
+ name: z.string().min(1).max(128).describe("Runtime group name, 1-128 characters. Required."),
1887
+ description: z.string().max(255).optional().describe("Max 255 characters."),
1888
+ environment: z.string().min(1).max(32).optional().describe("1-32 characters. Lowercased server-side; defaults from server settings."),
1889
+ ownerId: z.string().min(1).optional().describe("Defaults to the caller. Setting another owner requires admin."),
1890
+ cloneFromDefault: z
1891
+ .boolean()
1892
+ .optional()
1893
+ .describe("Seed the new group from the workspace default group instead of starting empty."),
1894
+ },
1895
+ annotations: {
1896
+ readOnlyHint: false,
1897
+ destructiveHint: false,
1898
+ idempotentHint: false,
1899
+ openWorldHint: true,
1900
+ },
1901
+ }, async (args) => {
1902
+ const { loginScope, apiUrl, ...payload } = args;
1903
+ const api = await createControlPlane({ loginScope, apiUrl });
1904
+ ensureAuthenticated(api);
1905
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.createRuntimeGroup(payload))));
1906
+ });
1907
+ registerThalovantTool(server, "thalovant_update_runtime_group", {
1908
+ title: "Update Runtime Group",
1909
+ description: "Update a Thalovant runtime group's name, description, or spec. spec patches replicas and container resources. This route does NOT use If-Match, so no etag is required. Requires the hubs:write scope and a paid plan.",
1910
+ inputSchema: {
1911
+ ...controlPlaneSchema,
1912
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1913
+ name: z.string().min(1).max(128).optional().describe("1-128 characters."),
1914
+ description: z.string().max(255).optional().describe("Max 255 characters."),
1915
+ spec: runtimeGroupSpecSchema.optional(),
1916
+ },
1917
+ annotations: {
1918
+ readOnlyHint: false,
1919
+ destructiveHint: false,
1920
+ idempotentHint: false,
1921
+ openWorldHint: true,
1922
+ },
1923
+ }, async (args) => {
1924
+ const { loginScope, apiUrl, runtimeGroupId, ...payload } = args;
1925
+ const api = await createControlPlane({ loginScope, apiUrl });
1926
+ ensureAuthenticated(api);
1927
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.updateRuntimeGroup(runtimeGroupId, payload))));
1928
+ });
1929
+ registerThalovantTool(server, "thalovant_get_runtime_group_config", {
1930
+ title: "Get Runtime Group Config",
1931
+ description: "Read a Thalovant runtime group's runtime configuration and personas. Requires the hubs:read scope. Read this before thalovant_update_runtime_group_config so you know what the merge will land on.",
1932
+ inputSchema: {
1933
+ ...controlPlaneSchema,
1934
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1935
+ },
1936
+ annotations: {
1937
+ readOnlyHint: true,
1938
+ openWorldHint: true,
1939
+ },
1940
+ }, async ({ runtimeGroupId, ...auth }) => {
1941
+ const api = await createControlPlane(auth);
1942
+ ensureAuthenticated(api);
1943
+ return jsonContent(redactSecrets(await callControlPlane("read", () => api.getRuntimeGroupConfig(runtimeGroupId))));
1944
+ });
1945
+ registerThalovantTool(server, "thalovant_update_runtime_group_config", {
1946
+ title: "Update Runtime Group Config",
1947
+ description: "Merge runtime configuration into a Thalovant runtime group. The API MERGES config into the stored configuration rather than replacing it, and marks the group pending so the runtime operator reconciles the change. personas, when provided, is REPLACED wholesale rather than merged; omit it to leave stored personas untouched. Requires the hubs:write scope and a paid plan.",
1948
+ inputSchema: {
1949
+ ...controlPlaneSchema,
1950
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1951
+ config: jsonRecordSchema.describe("Configuration object merged into the stored configuration. Required."),
1952
+ personas: jsonRecordSchema.optional().describe("Replaces the stored personas outright. Left untouched when omitted."),
1953
+ },
1954
+ annotations: {
1955
+ readOnlyHint: false,
1956
+ destructiveHint: false,
1957
+ idempotentHint: false,
1958
+ openWorldHint: true,
1959
+ },
1960
+ }, async ({ runtimeGroupId, config, personas, ...auth }) => {
1961
+ const api = await createControlPlane(auth);
1962
+ ensureAuthenticated(api);
1963
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.updateRuntimeGroupConfig(runtimeGroupId, config, { personas }))));
1964
+ });
1965
+ registerThalovantTool(server, "thalovant_release_runtime_group", {
1966
+ title: "Release Runtime Group",
1967
+ 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.",
1968
+ inputSchema: {
1969
+ ...controlPlaneSchema,
1970
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1971
+ ...releaseOptionsSchema,
1972
+ },
1973
+ annotations: {
1974
+ readOnlyHint: false,
1975
+ destructiveHint: false,
1976
+ idempotentHint: false,
1977
+ openWorldHint: true,
1978
+ },
1979
+ }, async ({ runtimeGroupId, channel, mode, version, images, reason, ...auth }) => {
1980
+ const api = await createControlPlane(auth);
1981
+ ensureAuthenticated(api);
1982
+ return jsonContent(redactSecrets(await callControlPlane("write", () => api.releaseRuntimeGroup(runtimeGroupId, releaseOptionsFrom({ channel, mode, version, images, reason })))));
1983
+ });
1984
+ registerThalovantTool(server, "thalovant_install_runtime_group_skill", {
1985
+ title: "Install Runtime Group Skill",
1986
+ description: "Install (or re-install) a skill in a Thalovant runtime group from the marketplace catalog. Installing a skill that is already present updates the existing entry rather than failing. Discover skillId with thalovant_list_marketplace_skills, then confirm installable/purchase_required with thalovant_list_runtime_group_marketplace before calling this. Requires the hubs:write scope and a paid plan (402 'API access requires a paid plan'); a paid marketplace skill ALSO needs marketplace access on the tenant plan, which fails with a second, distinct 402 about paid marketplace access. Only catalog sources are allowed by default: installing from a non-catalog source such as sourceType \"git\" runs code the marketplace never vetted and is refused unless the operator sets THALOVANT_ENABLE_GIT_SKILL_SOURCES.",
1987
+ inputSchema: {
1988
+ ...controlPlaneSchema,
1989
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
1990
+ skillId: z
1991
+ .string()
1992
+ .min(1)
1993
+ .max(191)
1994
+ .describe("Skill id from the catalog, 1-191 characters. Sent as skill_id in the request BODY on install (it is a path segment only on uninstall). For a catalog install the API persists the resolved catalog id, which may differ from what you send."),
1995
+ marketplaceSkillId: z.string().uuid().optional().describe("Catalog entry UUID, to disambiguate when the skill id alone is ambiguous."),
1996
+ sourceType: z
1997
+ .string()
1998
+ .min(1)
1999
+ .max(32)
2000
+ .optional()
2001
+ .describe('Install source, 1-32 characters, defaulting to "catalog". The API accepts any string here rather than a fixed enum, but only "catalog" (requires the skill to exist in the marketplace catalog) and "git" (requires sourceRef) get special handling. The value is trimmed and lower-cased before it is both checked and forwarded, so "Catalog" and " catalog " are treated exactly as "catalog". Any value that is not "catalog" is refused unless the operator sets THALOVANT_ENABLE_GIT_SKILL_SOURCES, because a non-catalog source can pull unvetted code into the runtime.'),
2002
+ sourceRef: z.string().min(1).max(255).optional().describe("Max 255 characters. Required for git installs — a git install without a valid repository URL fails 422. Only usable when THALOVANT_ENABLE_GIT_SKILL_SOURCES is set."),
2003
+ versionPin: z.string().min(1).max(64).optional().describe("Pin the skill to an exact version. Max 64 characters."),
2004
+ active: z
2005
+ .boolean()
2006
+ .optional()
2007
+ .describe("Whether the skill is enabled after install. Defaults to true. Installing with false also adds the skill to the group's blacklist."),
2008
+ },
2009
+ annotations: {
2010
+ readOnlyHint: false,
2011
+ destructiveHint: true,
2012
+ idempotentHint: true,
2013
+ openWorldHint: true,
2014
+ },
2015
+ }, async ({ runtimeGroupId, skillId, marketplaceSkillId, sourceType: rawSourceType, sourceRef, versionPin, active, ...auth }) => {
2016
+ // Normalize once (trim + case-fold) and forward the SAME value the gate
2017
+ // validates. Otherwise a whitespace/case variant such as " Catalog " or
2018
+ // "CATALOG" passes the local catalog check while the control plane, which
2019
+ // special-cases only the exact string "catalog", treats it as a
2020
+ // non-catalog source — bypassing the gate for the case it exists to block.
2021
+ // A blank/omitted value defaults to the catalog source server-side.
2022
+ const trimmedSource = typeof rawSourceType === "string" ? rawSourceType.trim() : "";
2023
+ const sourceType = trimmedSource ? trimmedSource.toLowerCase() : undefined;
2024
+ if (!isCatalogSource(sourceType) && !gitSkillSourcesEnabled()) {
2025
+ throw new Error(`Refusing to install a skill from non-catalog source "${rawSourceType}". A non-catalog source (for example sourceType "git" with an arbitrary sourceRef) can pull unvetted code into the runtime and is disabled by default. ` +
2026
+ "Set THALOVANT_ENABLE_GIT_SKILL_SOURCES=1 to allow non-catalog skill sources, or install from the marketplace catalog by omitting sourceType (or setting it to \"catalog\").");
2027
+ }
2028
+ const api = await createControlPlane(auth);
2029
+ ensureAuthenticated(api);
2030
+ const options = { marketplaceSkillId, sourceType, sourceRef, versionPin, active };
2031
+ return jsonContent(redactSecrets(await callControlPlane("skillInstall", () => api.installRuntimeGroupSkill(runtimeGroupId, skillId, options))));
2032
+ });
2033
+ registerThalovantTool(server, "thalovant_uninstall_runtime_group_skill", {
2034
+ title: "Uninstall Runtime Group Skill",
2035
+ description: "Remove one skill from a Thalovant runtime group. This removes only the named skill; the runtime group and its other skills are untouched. Requires the hubs:write scope and a paid plan.",
2036
+ inputSchema: {
2037
+ ...controlPlaneSchema,
2038
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
2039
+ skillId: z.string().min(1).describe("Skill id to remove."),
2040
+ },
2041
+ annotations: {
2042
+ readOnlyHint: false,
2043
+ destructiveHint: true,
2044
+ idempotentHint: true,
2045
+ openWorldHint: true,
2046
+ },
2047
+ }, async ({ runtimeGroupId, skillId, ...auth }) => {
2048
+ const api = await createControlPlane(auth);
2049
+ ensureAuthenticated(api);
2050
+ await callControlPlane("write", () => api.uninstallRuntimeGroupSkill(runtimeGroupId, skillId));
2051
+ return textContent("Runtime group skill uninstalled.");
2052
+ });
2053
+ if (destructiveToolsEnabled()) {
2054
+ registerThalovantTool(server, "thalovant_delete_hub", {
2055
+ title: "Delete Hub",
2056
+ description: "Permanently delete a Thalovant hub along with its dependent clients and ACLs. This cannot be undone. Requires the hub's current etag, read from the `etag` field in the hub resource BODY returned by thalovant_get_hub — the API sends no ETag response header — and sent as If-Match; a missing or stale etag fails 412 and deletes nothing. Requires the hubs:write scope and a paid plan. This tool is disabled unless the operator sets THALOVANT_ENABLE_DESTRUCTIVE_TOOLS.",
2057
+ inputSchema: {
2058
+ ...controlPlaneSchema,
2059
+ hubId: z.string().min(1).describe("Hub UUID. This authenticated route rejects slugs."),
2060
+ etag: hubEtagSchema,
2061
+ },
2062
+ annotations: {
2063
+ readOnlyHint: false,
2064
+ destructiveHint: true,
2065
+ idempotentHint: false,
2066
+ openWorldHint: true,
2067
+ },
2068
+ }, async ({ hubId, etag, ...auth }) => {
2069
+ const api = await createControlPlane(auth);
2070
+ ensureAuthenticated(api);
2071
+ await callControlPlane("hubWrite", () => api.deleteHub(hubId, { etag }));
2072
+ return textContent("Hub deleted.");
2073
+ });
2074
+ registerThalovantTool(server, "thalovant_delete_runtime_group", {
2075
+ title: "Delete Runtime Group",
2076
+ description: "Permanently delete a Thalovant runtime group. This cannot be undone. The API answers HTTP 409 for the workspace default group and for a group that still has hubs attached — move or delete those hubs first. This route takes no etag. Requires the hubs:write scope and a paid plan. This tool is disabled unless the operator sets THALOVANT_ENABLE_DESTRUCTIVE_TOOLS.",
2077
+ inputSchema: {
2078
+ ...controlPlaneSchema,
2079
+ runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
2080
+ },
2081
+ annotations: {
2082
+ readOnlyHint: false,
2083
+ destructiveHint: true,
2084
+ idempotentHint: false,
2085
+ openWorldHint: true,
2086
+ },
2087
+ }, async ({ runtimeGroupId, ...auth }) => {
2088
+ const api = await createControlPlane(auth);
2089
+ ensureAuthenticated(api);
2090
+ await callControlPlane("write", () => api.deleteRuntimeGroup(runtimeGroupId));
2091
+ return textContent("Runtime group deleted.");
2092
+ });
2093
+ }
1415
2094
  return server;
1416
2095
  }
1417
2096
  export async function startHttpServer(config = getHttpConfig()) {
@@ -18,7 +18,7 @@
18
18
  }
19
19
  },
20
20
  "allowedTools": ["thalovant_*"],
21
- "deniedTools": ["thalovant_delete_memory_item"]
21
+ "deniedTools": ["thalovant_delete_memory_item", "thalovant_delete_hub", "thalovant_delete_runtime_group"]
22
22
  }
23
23
  }
24
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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.2.21",
67
+ "@thalovant/sdk": "^0.2.28",
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.9",
10
+ "version": "0.1.10",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.1.9",
15
+ "version": "0.1.10",
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.9",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.1.10",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"