@thalovant/mcp-server 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-09-28
4
+
5
+ Link Home Assistant, or any home controller, to a hub from an agent: sign in on the device, create a connection of the `home_assistant` kind, and wait for the hub to admit it. Existing tool schemas are unchanged; every addition is optional.
6
+
7
+ - **Device sign-in as two tools.** `thalovant_begin_device_login` answers the code and URL to show a person with an opaque `loginId` (an empty `scopes` list is accepted and left out, so the API's default applies); `thalovant_poll_device_login` asks once and answers `pending` (with the interval, lengthened for good after a `slow_down`), `approved`, `expired` or `denied`. The device code and the minted token stay on the server, bound to the principal that began the sign-in, and never reach the model. With no credential configured, every control-plane tool for that principal then uses the token. `thalovant_revoke_device_login` signs out; a token already revoked counts as revoked, and signing out again answers `alreadyRevoked` without asking the API. A sign-in reaches only the configured API origin, because its verification URL is shown to a person.
8
+ - **Connections of a kind.** `thalovant_create_client_identity` takes `connectionType`; the API must answer with the same kind, and a connection it made without it is deleted again before the call fails. The result carries `clientId`, `connectionType` and `operationId`. A refusal adds guidance by kind after the API's own text: a kind the API does not know yet, a plan that does not allow it, a hub already linked (naming the connection that holds the link), or a token to sign in again.
9
+ - **Admission.** `thalovant_wait_for_admission` (read-only) follows the create's operation for the ninety seconds a hub takes and answers `admitted`; `failed`, with the operation's `errorCode` for a failure on the platform or the API's `status`, `code` and `detail` when it refused the wait itself; or `timeout` as an outcome to wait on again. A 429 is ridden out for the time the API names (its body, else `Retry-After`, else `RateLimit-Reset`), and one asking for longer than is left is a timeout at once. A token the API refuses and an API out of reach are tool errors of their own, each with a hint. It honours MCP cancellation and never follows a link to another origin than the API's.
10
+ - **`thalovant_delete_client`**, opt-in with the other destructive tools: `If-Match`, reading the etag first when none is given, one retry on `412`, and a connection already gone counts as deleted.
11
+ - The catalog has 50 default tools (24 in read-only mode, 53 with destructive tools enabled). `thalovant_config_status` reports `deviceLoginSignedIn`.
12
+ - Takes `@thalovant/sdk` ^0.9.0, which carries the device sign-in, the connection kinds and admission; the lock resolves 0.9.0.
13
+ - The parity contract declares `device-login`, `connection-kinds` and `connection-admission` as required and `home-link` as not applicable: a tool call holds a hub connection only for its own length, and nothing could answer a hub's request. The three vector files run through the SDK that resolves here, recorded before asserting, and through the tools over stdio, recorded under the same names; all 46 cases digest as the reference's (reference `2460eb90bd56`). Each worker of the recorder now writes only a shard of its own, and a Vitest global setup (`vitest.config.ts`) merges the shards once after every worker has finished: merging inside the workers let two of them stage the same file and rename each other's away, and a merge that scanned early could land after a complete one.
14
+ - The Dockerfile's version label had stayed at 0.4.0; it now says 0.6.0 like the rest.
15
+
16
+ ## 0.5.3 — 2026-09-26
17
+
18
+ - 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.
19
+ - 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.
20
+ - The status behind a hint is read from the SDK error's `statusCode`, not parsed out of its message.
21
+ - 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.
22
+ - New `api-errors` capability in the parity contract, declared `required`.
23
+
3
24
  ## 0.5.2 — 2026-09-19
4
25
 
5
26
  - 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.
package/README.md CHANGED
@@ -43,6 +43,17 @@ The server selects control-plane auth in this order:
43
43
 
44
44
  When a token is set, the server never calls the login endpoint. `thalovant_config_status` reports the active mode as `controlPlaneAuthMode` without revealing token values.
45
45
 
46
+ With none of these configured, a person can sign the server in through the browser instead: see [Device Sign-In](#device-sign-in). The token that sign-in mints is used only when nothing above is configured.
47
+
48
+ ### Device Sign-In
49
+
50
+ A tool call cannot wait for a person, so the device flow (RFC 8628) is two tools:
51
+
52
+ 1. `thalovant_begin_device_login` with optional `scopes` (an empty list, like none, asks for the API's default) and `clientName` answers `loginId`, `userCode`, `verificationUri`, `verificationUriComplete`, `interval` and `expiresIn`. Show the person the URL and the code.
53
+ 2. `thalovant_poll_device_login` with that `loginId` asks once. `outcome` is `pending` (poll again after `interval` seconds, already five seconds longer for good if the API asked to slow down), `approved` (with `tokenId`, `scopes` and `expiresAt`), `expired` or `denied`.
54
+
55
+ The device code and the token never reach the model: the server keeps both, bound to the principal that began the sign-in, for the life of the process. Once approved, every control-plane tool for that principal uses the token when no credential is configured. `thalovant_revoke_device_login` signs out: it revokes that token (a token may always revoke itself, and one already revoked counts as revoked) and forgets it; signing out again answers `alreadyRevoked` without a request, and configured tokens are never touched. A sign-in reaches only the configured API origin (`THALOVANT_API_URL`, or the default), since the verification URL it answers with is shown to a person. A Home Assistant link asks for `hubs:read`, `clients:read` and `clients:write`, which is also all a Free plan can approve.
56
+
46
57
  ### API Tokens (Recommended For AI Agents And CI)
47
58
 
48
59
  Scoped API tokens are the right credential for AI and automation use: they are minted from the Thalovant dashboard (or through the device flow), carry only the scopes you grant, can be revoked individually, and never involve your account password or MFA. Tokens start with `tvpat_`.
@@ -61,7 +72,7 @@ Minimum scopes for the full control-plane tool surface:
61
72
  | `hubs:read` | `thalovant_list_hubs`, `thalovant_get_hub`, `thalovant_get_analytics_overview`, `thalovant_list_marketplace_skills`, `thalovant_list_runtime_groups`, `thalovant_get_runtime_group`, `thalovant_get_runtime_group_config`, the guarded merge read in `thalovant_update_runtime_group_config`, and the hub lookup inside `thalovant_create_client_identity` |
62
73
  | `hubs:inspect` | `thalovant_get_hub_runtime_capabilities`, `thalovant_list_runtime_group_marketplace`, `thalovant_list_runtime_group_inventory`, `thalovant_list_hub_skills`, `thalovant_list_hub_skill_history` |
63
74
  | `hubs:write` | All hub and runtime-group provisioning: `thalovant_create_hub`, `thalovant_update_hub`, `thalovant_release_hub`, `thalovant_create_runtime_group`, `thalovant_update_runtime_group`, `thalovant_update_runtime_group_config`, `thalovant_release_runtime_group`, `thalovant_install_runtime_group_skill`, `thalovant_uninstall_runtime_group_skill`, the per-hub `thalovant_install_hub_skill`, `thalovant_update_hub_skill`, `thalovant_remove_hub_skill`, the hub rating tools, and the opt-in delete tools |
64
- | `clients:write` | `thalovant_create_client_identity` (`POST /v1/clients`) |
75
+ | `clients:write` | `thalovant_create_client_identity` (`POST /v1/clients`) and the opt-in `thalovant_delete_client` |
65
76
  | `memory:read` | `thalovant_list_memory_items`, `thalovant_get_memory_summary`, `thalovant_get_memory_item` |
66
77
  | `memory:write` | `thalovant_create_memory_item`, `thalovant_update_memory_item`, `thalovant_delete_memory_item` |
67
78
 
@@ -87,7 +98,7 @@ Grant fewer scopes for narrower deployments: a read-only assistant needs only `h
87
98
 
88
99
  `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`.
89
100
 
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` and appends the root `code` in parentheses, for example `Thalovant API request failed with HTTP 409: Skill version already installed. (skill_version_already_installed)`.
101
+ 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)).
91
102
 
92
103
  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.
93
104
 
@@ -112,6 +123,18 @@ URL spellings and paths on the same origin are allowed; custom origins must be
112
123
  configured alongside their credentials. Anonymous public discovery may still
113
124
  select a custom API URL.
114
125
 
126
+ ## Link Home Assistant
127
+
128
+ A home controller such as Home Assistant links to a hub with a connection of its own kind:
129
+
130
+ 1. Sign in, when no token is configured: [Device Sign-In](#device-sign-in).
131
+ 2. `thalovant_create_client_identity` with `connectionType: "home_assistant"`. The API must answer with the same kind; one that made an ordinary connection instead has it deleted again and the call fails. The result carries `clientId`, `connectionType` and `operationId`.
132
+ 3. `thalovant_wait_for_admission` with that `operationId`. A hub admits a new connection in about ninety seconds and refuses it until then. `outcome` is `admitted`, `failed` or `timeout`. A failure on the platform carries the operation's `errorCode` (and `status` null); the API refusing the wait itself carries its `status`, `code` and `detail`. A `timeout` means the connection may still be admitted: call the tool again rather than creating another connection. A 429 is waited out for the time the API names (in its body, else `Retry-After`, else `RateLimit-Reset`); one asking for longer than is left is a `timeout` at once. A token the API refuses (401, 403) and an API out of reach are tool errors of their own, each with a hint, never a failed admission. It reads only, honours cancellation, and never follows a `links.self` to another origin than the API's.
133
+
134
+ Each refusal of step 2 names what to do next after the API's own text (see [Tool Errors](#tool-errors)): a kind the API does not know yet, a plan that does not allow the connection, a hub that already holds its one Home Assistant link (with the `client_id` holding it), or a token the API refused (sign in again). `thalovant_delete_client` removes a connection (`If-Match`, reading the etag first when none is given, retrying once on `412`, and counting a connection already gone as deleted); it is a destructive tool the operator enables.
135
+
136
+ Answering the hub's requests afterwards is the integration's own long-lived job, on the data plane; an MCP tool call holds a hub connection only for its own length, so that part of the link is not a tool.
137
+
115
138
  ## Local Stdio
116
139
 
117
140
  The server speaks MCP over stdio and does not write logs to stdout.
@@ -325,6 +348,7 @@ Read-only:
325
348
  - `thalovant_list_hubs`
326
349
  - `thalovant_get_hub`
327
350
  - `thalovant_get_operation`
351
+ - `thalovant_wait_for_admission`
328
352
  - `thalovant_identity_status`
329
353
  - `thalovant_healthcheck`
330
354
  - `thalovant_intent_inventory`
@@ -344,6 +368,12 @@ Skill and runtime-group discovery (read-only):
344
368
  - `thalovant_get_runtime_group_config`
345
369
  - `thalovant_get_hub_runtime_capabilities`
346
370
 
371
+ Sign-in (see [Device Sign-In](#device-sign-in)):
372
+
373
+ - `thalovant_begin_device_login`
374
+ - `thalovant_poll_device_login`
375
+ - `thalovant_revoke_device_login`
376
+
347
377
  Writes or hub events:
348
378
 
349
379
  - `thalovant_create_client_identity`
@@ -382,6 +412,7 @@ Destructive, **not registered unless explicitly enabled** (see [Destructive Tool
382
412
 
383
413
  - `thalovant_delete_hub`
384
414
  - `thalovant_delete_runtime_group`
415
+ - `thalovant_delete_client`
385
416
 
386
417
  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`.
387
418
 
@@ -403,6 +434,31 @@ unknown (empty lists/strings), with explanatory notes. Intent language order is
403
434
  explicit and survives JSON key sorting. Each tool call retains its own identity
404
435
  lease; the server does not persist inventory or share a session across callers.
405
436
 
437
+ ## Tool Errors
438
+
439
+ When the Thalovant API refuses a control-plane call, the tool result is an
440
+ error whose text starts with the SDK's one-line message (which names the HTTP
441
+ status and can be shortened) and then says what the API said, in full:
442
+
443
+ ```text
444
+ 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)
445
+ code: platform_image_required
446
+ 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.
447
+ fields: {"allowed_images":{"bus":[...],"core":[...]},"allowed_repositories":{"core":"ghcr.io/thalovant/ovos-core"},"component":"runtime_group","refused_images":{...}}
448
+ ```
449
+
450
+ - `code` is the API's machine-readable code, when it sent one.
451
+ - `detail` is the whole sentence, and appears only when the first line had to
452
+ shorten it.
453
+ - `fields` is every other member of the error body as compact JSON (the
454
+ Problem+JSON `type`, `title`, `status` and `instance` are left out), with the
455
+ same secret redaction as every other tool output. A value a validation error
456
+ echoes back from the request is redacted there and never reaches the first
457
+ line. A plan refusal carries `resource`, `limit`, `used` and `plan` here.
458
+
459
+ A hint may follow for statuses that need one. A `platform_image_required` or
460
+ `plan_limit` 403 gets its own hint rather than the general scope hint.
461
+
406
462
  ## Request hints, embedded audio, and configuration merging
407
463
 
408
464
  `thalovant_ask` accepts `sttLang`, an ordered `pipeline`, and `location`
@@ -432,9 +488,9 @@ or use an SDK that preserves large JSON integers.
432
488
 
433
489
  ## Destructive Tools
434
490
 
435
- `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.
491
+ `thalovant_delete_hub`, `thalovant_delete_runtime_group` and `thalovant_delete_client` 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.
436
492
 
437
- 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:
493
+ 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 are opt-in:
438
494
 
439
495
  ```bash
440
496
  export THALOVANT_ENABLE_DESTRUCTIVE_TOOLS="true"
@@ -452,7 +508,7 @@ export MCP_TOOL_DENYLIST="thalovant_delete_hub,thalovant_delete_runtime_group"
452
508
 
453
509
  The global deny applies to every principal and cannot be overridden by a principal's `allowedTools`. To restrict only selected principals, leave these tools out of the global denylist and use those principals' `deniedTools` instead.
454
510
 
455
- 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.
511
+ 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. Deleting a client cuts off the device or link using it; its etag is read first when none is given.
456
512
 
457
513
  ## Non-Catalog Skill Sources
458
514
 
@@ -466,7 +522,7 @@ With the flag unset, a call with any `sourceType` other than `catalog` fails bef
466
522
 
467
523
  ## Read-Only Mode
468
524
 
469
- 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`.
525
+ Set `THALOVANT_MCP_READONLY=1` to register only tools annotated `readOnlyHint: true` (the sign-in tools are not among them; `thalovant_wait_for_admission` is). 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`.
470
526
 
471
527
  ## HiveMind Runtime Compatibility
472
528
 
@@ -474,7 +530,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
474
530
  MCP process. Use a distinct client identity for each independently running MCP
475
531
  server so the hub can keep their sessions separate.
476
532
 
477
- Version 0.5.1 requires `@thalovant/sdk` `^0.7.1` (0.7.1 through versions below 0.8.0). This release enforces
533
+ Version 0.6.0 requires `@thalovant/sdk` `^0.9.0` (0.9.0 through versions below 0.10.0). Since 0.5.1 the server enforces
478
534
  secure effective MQTT URLs and carries a single connection deadline through
479
535
  MQTT setup and HTTP failure cleanup. Runtime tools support
480
536
  HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
@@ -552,7 +608,7 @@ one tool for every SDK conversation or event-listener method.
552
608
 
553
609
  ### Shared-runtime skill management
554
610
 
555
- The catalog has 46 default tools, 23 in read-only mode, and 48 with destructive tools enabled.
611
+ The catalog has 50 default tools, 24 in read-only mode, and 53 with destructive tools enabled.
556
612
 
557
613
  Hub-addressed skill methods select the runtime group attached to the hub UUID.
558
614
  Every hub sharing that group sees the same skill changes and history. The API
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import type { DeviceAuthorization, ThalovantControlPlane } from "@thalovant/sdk";
2
+ /**
3
+ * Device sign-ins, held by the server between the tool calls that run them.
4
+ *
5
+ * A tool call cannot wait for a person to approve a sign-in in a browser, so
6
+ * the device flow is two tools: one starts it and one polls it. What sits
7
+ * between them stays here, in this process, and never reaches the model: the
8
+ * device code (the secret half of the grant) behind an opaque login id, and
9
+ * the token an approved sign-in minted. Both belong to the principal that
10
+ * started the sign-in; another principal can neither poll it nor use its token.
11
+ */
12
+ /** A sign-in started and not finished. */
13
+ interface PendingLogin {
14
+ readonly principal: string;
15
+ readonly api: ThalovantControlPlane;
16
+ readonly authorization: DeviceAuthorization;
17
+ readonly expiresAt: number;
18
+ }
19
+ /** The token an approved sign-in minted, on the control plane that holds it. */
20
+ export interface SignedIn {
21
+ readonly api: ThalovantControlPlane;
22
+ readonly origin: string;
23
+ }
24
+ export declare class DeviceLogins {
25
+ private readonly now;
26
+ private readonly pending;
27
+ private readonly signedIn;
28
+ /** The id of the token each principal last revoked, until it signs in again. */
29
+ private readonly revoked;
30
+ constructor(now?: () => number);
31
+ /**
32
+ * Keep a started sign-in for `principal`; returns the id its polls name it by.
33
+ *
34
+ * A principal holds at most a few at once, and starting another drops only
35
+ * its own oldest. Throws when the server as a whole holds as many as it will.
36
+ */
37
+ start(principal: string, api: ThalovantControlPlane, authorization: DeviceAuthorization): string;
38
+ /** The pending sign-in `loginId` names, if `principal` started it and it has not expired. */
39
+ find(loginId: string, principal: string): PendingLogin | undefined;
40
+ /** Forget a sign-in that ended: approved, expired or denied. */
41
+ finish(loginId: string): void;
42
+ /** Keep the token an approved sign-in minted, as `principal`'s. */
43
+ approve(principal: string, api: ThalovantControlPlane): void;
44
+ /** The token `principal` signed in with on `origin`, if there is one. */
45
+ signedInOn(principal: string, origin: string): SignedIn | undefined;
46
+ /** The token `principal` signed in with, wherever. */
47
+ signedInAs(principal: string): SignedIn | undefined;
48
+ /** Forget `principal`'s token, remembering its id as revoked. */
49
+ signOut(principal: string, tokenId?: string | null): void;
50
+ /**
51
+ * The id of the token `principal` signed out of, when it has signed out and
52
+ * not signed in since; undefined when it never signed out.
53
+ */
54
+ revokedOf(principal: string): string | null | undefined;
55
+ private prune;
56
+ }
57
+ export {};
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** Sign-ins one principal may have pending at once; its own oldest is dropped first. */
3
+ const MAX_PENDING_PER_PRINCIPAL = 4;
4
+ /**
5
+ * Sign-ins pending at once across every principal. Past it a new sign-in is
6
+ * refused rather than evicting somebody else's: dropping another principal's
7
+ * sign-in would let one caller keep everyone else from ever signing in.
8
+ */
9
+ const MAX_PENDING_LOGINS = 256;
10
+ export class DeviceLogins {
11
+ now;
12
+ pending = new Map();
13
+ signedIn = new Map();
14
+ /** The id of the token each principal last revoked, until it signs in again. */
15
+ revoked = new Map();
16
+ constructor(now = () => Date.now()) {
17
+ this.now = now;
18
+ }
19
+ /**
20
+ * Keep a started sign-in for `principal`; returns the id its polls name it by.
21
+ *
22
+ * A principal holds at most a few at once, and starting another drops only
23
+ * its own oldest. Throws when the server as a whole holds as many as it will.
24
+ */
25
+ start(principal, api, authorization) {
26
+ this.prune();
27
+ const own = [...this.pending].filter(([, login]) => login.principal === principal);
28
+ for (const [loginId] of own.slice(0, Math.max(0, own.length - MAX_PENDING_PER_PRINCIPAL + 1))) {
29
+ this.pending.delete(loginId);
30
+ }
31
+ if (this.pending.size >= MAX_PENDING_LOGINS) {
32
+ throw new Error("Too many device sign-ins are in progress on this server. Try again once some have finished or expired.");
33
+ }
34
+ const loginId = randomUUID();
35
+ this.pending.set(loginId, {
36
+ principal,
37
+ api,
38
+ authorization,
39
+ expiresAt: this.now() + authorization.expiresIn * 1000,
40
+ });
41
+ return loginId;
42
+ }
43
+ /** The pending sign-in `loginId` names, if `principal` started it and it has not expired. */
44
+ find(loginId, principal) {
45
+ this.prune();
46
+ const login = this.pending.get(loginId);
47
+ return login && login.principal === principal ? login : undefined;
48
+ }
49
+ /** Forget a sign-in that ended: approved, expired or denied. */
50
+ finish(loginId) {
51
+ this.pending.delete(loginId);
52
+ }
53
+ /** Keep the token an approved sign-in minted, as `principal`'s. */
54
+ approve(principal, api) {
55
+ this.signedIn.set(principal, { api, origin: new URL(api.apiUrl).origin });
56
+ this.revoked.delete(principal);
57
+ }
58
+ /** The token `principal` signed in with on `origin`, if there is one. */
59
+ signedInOn(principal, origin) {
60
+ const signedIn = this.signedIn.get(principal);
61
+ return signedIn && signedIn.origin === origin && signedIn.api.accessToken ? signedIn : undefined;
62
+ }
63
+ /** The token `principal` signed in with, wherever. */
64
+ signedInAs(principal) {
65
+ const signedIn = this.signedIn.get(principal);
66
+ return signedIn?.api.accessToken ? signedIn : undefined;
67
+ }
68
+ /** Forget `principal`'s token, remembering its id as revoked. */
69
+ signOut(principal, tokenId = null) {
70
+ this.signedIn.delete(principal);
71
+ this.revoked.set(principal, tokenId);
72
+ }
73
+ /**
74
+ * The id of the token `principal` signed out of, when it has signed out and
75
+ * not signed in since; undefined when it never signed out.
76
+ */
77
+ revokedOf(principal) {
78
+ return this.revoked.has(principal) ? this.revoked.get(principal) : undefined;
79
+ }
80
+ prune() {
81
+ const now = this.now();
82
+ for (const [loginId, login] of this.pending) {
83
+ if (login.expiresAt <= now)
84
+ this.pending.delete(loginId);
85
+ }
86
+ }
87
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { apiErrorText } from "./api-error-output.js";
3
+ import { DeviceLogins } from "./device-login.js";
2
4
  import { inventoryPresentation } from "./inventory-output.js";
3
5
  import { runtimeReplyContent } from "./reply-output.js";
4
6
  import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
@@ -6,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
10
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
- import { DEFAULT_CONTROL_API_URL, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, buildLocation, } from "@thalovant/sdk";
11
+ import { CONNECTION_TYPE_HOME_ASSISTANT, DEFAULT_CONTROL_API_URL, HOME_ASSISTANT_SCOPES, ThalovantAdmissionFailedError, ThalovantAdmissionTimeoutError, ThalovantAlreadyLinkedError, ThalovantApiError, ThalovantApiUnreachableError, ThalovantAuthError, ThalovantClient, ThalovantControlPlane, ThalovantDeviceLoginDeniedError, ThalovantDeviceLoginExpiredError, ThalovantDeviceLoginPendingError, ThalovantIdentity, ThalovantPlanError, ThalovantUnsupportedConnectionTypeError, buildClientContext, buildLocation, } from "@thalovant/sdk";
10
12
  import { AsyncLocalStorage } from "node:async_hooks";
11
13
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
12
14
  import { createServer as createHttpServer } from "node:http";
@@ -60,6 +62,10 @@ const SECRET_KEYS = [
60
62
  "secret",
61
63
  ];
62
64
  const protocolSchema = z.enum(["wss", "https", "mqtt"]);
65
+ const connectionTypeSchema = z
66
+ .string()
67
+ .regex(/^[a-z][a-z0-9_]{0,63}$/)
68
+ .describe(`The kind of connection, sent as spec.connection_type: voice_satellite, web_chat, developer, embedded, or ${CONNECTION_TYPE_HOME_ASSISTANT} for a Home Assistant link (one per hub). The kind decides what the connection may send and receive.`);
63
69
  const limitSchema = z.number().int().min(1).max(MAX_LIMIT).default(25).describe("Page size, 1-100.");
64
70
  const publicHubsLimitSchema = z
65
71
  .number()
@@ -123,7 +129,14 @@ const runtimeAuthSchema = {
123
129
  * they are absent from tools/list entirely rather than merely failing when
124
130
  * called. See destructiveToolsEnabled().
125
131
  */
126
- const DESTRUCTIVE_TOOLS = ["thalovant_delete_hub", "thalovant_delete_runtime_group"];
132
+ const DESTRUCTIVE_TOOLS = ["thalovant_delete_hub", "thalovant_delete_runtime_group", "thalovant_delete_client"];
133
+ /**
134
+ * Device sign-ins in progress, and the tokens approved ones minted, per
135
+ * principal, for the life of this process. See device-login.ts.
136
+ */
137
+ const deviceLogins = new DeviceLogins();
138
+ /** How long thalovant_wait_for_admission waits by default: a hub admits a new connection in about ninety seconds. */
139
+ const ADMISSION_DEFAULT_TIMEOUT_MS = 120_000;
127
140
  /**
128
141
  * Destructive control-plane tools are opt-in.
129
142
  *
@@ -204,22 +217,30 @@ const releaseOptionsSchema = {
204
217
  channel: z.string().min(1).optional().describe("Release channel. Falls back to the workspace release policy when omitted."),
205
218
  mode: z.string().min(1).optional().describe('Release mode. Passing images without mode switches to "custom".'),
206
219
  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."),
220
+ 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
221
  reason: z.string().min(1).optional().describe("Audit reason recorded with the release."),
209
222
  };
210
223
  function releaseOptionsFrom(args) {
211
224
  return { channel: args.channel, mode: args.mode, version: args.version, images: args.images, reason: args.reason };
212
225
  }
213
226
  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.';
227
+ 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.';
228
+ 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
229
  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
230
  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
231
  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
232
  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) {
233
+ function controlPlaneErrorHint(profile, status, body, code) {
219
234
  switch (status) {
220
235
  case 402:
221
236
  return (profile === "skillInstall" || profile === "hubSkillInstall") && /marketplace/i.test(body) ? MARKETPLACE_HINT_402 : PLAN_HINT_402;
222
237
  case 403:
238
+ // A 403 that names its reason is not the scope problem the general hint
239
+ // describes, and sending a model off to grant scopes wastes the turn.
240
+ if (code === "platform_image_required")
241
+ return PLATFORM_IMAGE_HINT_403;
242
+ if (code === "plan_limit")
243
+ return PLAN_LIMIT_HINT_403;
223
244
  return SCOPE_HINT_403;
224
245
  case 404:
225
246
  return /hub_without_runtime_group/.test(body)
@@ -238,12 +259,47 @@ function controlPlaneErrorHint(profile, status, body) {
238
259
  return undefined;
239
260
  }
240
261
  }
262
+ /**
263
+ * Guidance for a refused connection create or delete, by the SDK's class for
264
+ * the refusal. Undefined when the class says nothing a hint could add to.
265
+ */
266
+ function connectionErrorHint(error, connectionType) {
267
+ if (error instanceof ThalovantUnsupportedConnectionTypeError) {
268
+ return error.statusCode === undefined
269
+ ? `The API made an ordinary connection instead of a ${JSON.stringify(connectionType)} one, so the server deleted it again (the message says if that failed). This API does not support the kind yet; retrying will not change that.`
270
+ : `The API does not know the connection type ${JSON.stringify(connectionType)} yet (HTTP 422). Retrying will not change that; omit connectionType for an ordinary connection.`;
271
+ }
272
+ if (error instanceof ThalovantAlreadyLinkedError) {
273
+ return `A hub takes one Home Assistant connection, and this one already has it${error.clientId ? `: client ${error.clientId}` : ""}. Use that connection, or delete it with thalovant_delete_client (a destructive tool the operator enables) and create the link again.`;
274
+ }
275
+ if (error instanceof ThalovantPlanError) {
276
+ return error.code === "plan_limit"
277
+ ? "The plan's connections are used up (see resource, used and limit in the fields above). Delete one with thalovant_delete_client, or move the workspace to a larger plan; granting a scope will not help."
278
+ : "The plan does not allow this connection (HTTP 402). Move the workspace to a paid plan; granting a scope will not help.";
279
+ }
280
+ if (error instanceof ThalovantAuthError) {
281
+ return "The API refused the token itself: it is unknown, expired or revoked (401), the account is locked (423), or it lacks clients:write (403 Insufficient scopes). Sign in again with thalovant_begin_device_login, or configure a token that carries clients:read and clients:write.";
282
+ }
283
+ if (error instanceof ThalovantApiUnreachableError) {
284
+ return "The Thalovant API could not be reached, which says nothing about the connection: it may be admitted, or created, all the same. Try again once the API answers; do not create another connection before checking.";
285
+ }
286
+ return undefined;
287
+ }
288
+ /** Rethrow a refused connection call with its guidance, when it has any. */
289
+ function withConnectionHint(error, connectionType) {
290
+ const hint = connectionErrorHint(error, connectionType);
291
+ if (!hint)
292
+ throw error;
293
+ const said = apiErrorText(error, redactSecrets) ?? (error instanceof Error ? error.message : String(error));
294
+ throw new Error(`${said}\n\n${hint}`, { cause: error });
295
+ }
241
296
  /**
242
297
  * Run a control-plane call and, on failure, append actionable guidance.
243
298
  *
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.
299
+ * The status and code come from the SDK's ThalovantApiError. What the API
300
+ * said -- the code, the whole sentence, every other field of the body -- goes
301
+ * ahead of the hint (see apiErrorText); an error that is not an API error
302
+ * keeps its own message, and its status is read from it when it names one.
247
303
  */
248
304
  async function callControlPlane(profile, run) {
249
305
  try {
@@ -251,11 +307,13 @@ async function callControlPlane(profile, run) {
251
307
  }
252
308
  catch (error) {
253
309
  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;
310
+ const apiError = error instanceof ThalovantApiError ? error : undefined;
311
+ const status = apiError?.statusCode ?? Number(/HTTP (\d{3})/.exec(message)?.[1]);
312
+ const said = [message, apiError?.code, apiError?.detail].filter(Boolean).join(" ");
313
+ const hint = Number.isFinite(status) ? controlPlaneErrorHint(profile, status, said, apiError?.code) : undefined;
256
314
  if (!hint)
257
315
  throw error;
258
- throw new Error(`${message}\n\n${hint}`);
316
+ throw new Error(`${apiErrorText(error, redactSecrets) ?? message}\n\n${hint}`, { cause: error });
259
317
  }
260
318
  }
261
319
  const HUB_SKILL_DEFAULT_TIMEOUT_MS = 120_000;
@@ -586,7 +644,12 @@ function registerThalovantTool(server, name, config, handler) {
586
644
  durationMs: Date.now() - start,
587
645
  error: error instanceof Error ? error.message : String(error),
588
646
  });
589
- throw error;
647
+ // An API error that no hint was added to still says what the API said:
648
+ // the SDK's message is one bounded line, and the structured fields a
649
+ // refusal carries (the images a caller may pin, a plan's numbers) are
650
+ // only on the error itself.
651
+ const text = apiErrorText(error, redactSecrets);
652
+ throw text === undefined ? error : new Error(text, { cause: error });
590
653
  }
591
654
  });
592
655
  }
@@ -610,17 +673,38 @@ function redactSecrets(value) {
610
673
  }
611
674
  return output;
612
675
  }
676
+ /** The control-plane URL this principal's credentials belong to. */
677
+ async function configuredControlApiUrl(principal = currentPrincipal()) {
678
+ const credential = await credentialForPrincipal(principal);
679
+ const canUseShared = allowSharedThalovantCredentials(principal);
680
+ return credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
681
+ }
682
+ function originOf(url) {
683
+ try {
684
+ return new URL(url).origin;
685
+ }
686
+ catch {
687
+ return undefined;
688
+ }
689
+ }
613
690
  /** Keep server-managed credentials on their configured control-plane origin. */
614
691
  async function createControlPlane(options) {
615
692
  const principal = currentPrincipal();
616
693
  const credential = await credentialForPrincipal(principal);
617
694
  const canUseShared = allowSharedThalovantCredentials(principal);
618
- const configuredApiUrl = credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
695
+ const configuredApiUrl = await configuredControlApiUrl(principal);
619
696
  const apiUrl = options.apiUrl ?? configuredApiUrl;
620
- const accessToken = credential?.control?.accessToken ??
621
- (canUseShared ? process.env.THALOVANT_API_TOKEN ?? process.env.THALOVANT_ACCESS_TOKEN : undefined);
622
697
  const email = canUseShared ? process.env.THALOVANT_EMAIL : undefined;
623
698
  const password = canUseShared ? process.env.THALOVANT_PASSWORD : undefined;
699
+ const configuredToken = credential?.control?.accessToken ??
700
+ (canUseShared ? process.env.THALOVANT_API_TOKEN ?? process.env.THALOVANT_ACCESS_TOKEN : undefined);
701
+ // With nothing configured, the token this principal signed in with through
702
+ // thalovant_begin_device_login / thalovant_poll_device_login, which exists
703
+ // only for the configured origin.
704
+ const signedIn = configuredToken || (email && password)
705
+ ? undefined
706
+ : deviceLogins.signedInOn(principal.id, originOf(configuredApiUrl) ?? "");
707
+ const accessToken = configuredToken ?? signedIn?.api.accessToken;
624
708
  if (accessToken || (email && password)) {
625
709
  let endpoint;
626
710
  let configuredEndpoint;
@@ -655,6 +739,24 @@ function ensureAuthenticated(api) {
655
739
  throw new Error("This tool requires Thalovant API auth. Configure THALOVANT_API_TOKEN (recommended: a scoped, revocable API token), THALOVANT_ACCESS_TOKEN, THALOVANT_EMAIL/THALOVANT_PASSWORD, or per-principal Thalovant credentials for this MCP principal.");
656
740
  }
657
741
  }
742
+ /**
743
+ * A control plane for a device sign-in, on the configured API origin only.
744
+ *
745
+ * It carries no credential: the sign-in is what mints one. The origin is fixed
746
+ * because the verification URL the API answers with is shown to a person, and
747
+ * the token it mints is kept here for later calls; a tool argument must not
748
+ * send either somewhere else.
749
+ */
750
+ async function deviceLoginControlPlane(apiUrl) {
751
+ const configuredApiUrl = await configuredControlApiUrl();
752
+ const configured = originOf(configuredApiUrl);
753
+ if (!configured)
754
+ throw new Error("The configured control-plane API URL is invalid.");
755
+ if (apiUrl !== undefined && originOf(apiUrl) !== configured) {
756
+ throw new Error("apiUrl must use the configured Thalovant API origin for a device sign-in.");
757
+ }
758
+ return new ThalovantControlPlane(apiUrl ?? configuredApiUrl, { userAgent: `thalovant-mcp/${VERSION}` });
759
+ }
658
760
  function controlPlaneAuthModeFromEnv() {
659
761
  if (process.env.THALOVANT_API_TOKEN)
660
762
  return "api-token";
@@ -1155,6 +1257,7 @@ export function createServer() {
1155
1257
  }, async () => jsonContent({
1156
1258
  apiUrl: process.env.THALOVANT_API_URL ?? DEFAULT_CONTROL_API_URL,
1157
1259
  controlPlaneAuthMode: controlPlaneAuthModeFromEnv(),
1260
+ deviceLoginSignedIn: Boolean(deviceLogins.signedInAs(currentPrincipal().id)),
1158
1261
  hasApiToken: Boolean(process.env.THALOVANT_API_TOKEN),
1159
1262
  hasAccessToken: Boolean(process.env.THALOVANT_ACCESS_TOKEN),
1160
1263
  hasEmailPassword: Boolean(process.env.THALOVANT_EMAIL && process.env.THALOVANT_PASSWORD),
@@ -1248,7 +1351,7 @@ export function createServer() {
1248
1351
  });
1249
1352
  registerThalovantTool(server, "thalovant_create_client_identity", {
1250
1353
  title: "Create Client Identity",
1251
- description: "Create a Thalovant client identity for a hub. The identity is secret; output is redacted unless savePath is used.",
1354
+ description: "Create a Thalovant client identity (a hub connection) for a hub. The identity is secret; output is redacted unless savePath is used. Pass connectionType (for example home_assistant) for a connection of that kind: the API must answer with the same kind, or the connection it made instead is deleted and the call fails. A new connection is admitted by its hub in about ninety seconds; follow operationId with thalovant_wait_for_admission before connecting.",
1252
1355
  inputSchema: {
1253
1356
  ...controlPlaneSchema,
1254
1357
  hubId: z
@@ -1262,6 +1365,7 @@ export function createServer() {
1262
1365
  preferredProtocols: z.array(protocolSchema).min(1).default(["wss", "https"]),
1263
1366
  idempotencyKey: z.string().min(1).optional(),
1264
1367
  spec: jsonRecordSchema.optional(),
1368
+ connectionType: connectionTypeSchema.optional(),
1265
1369
  savePath: z
1266
1370
  .string()
1267
1371
  .min(1)
@@ -1274,7 +1378,7 @@ export function createServer() {
1274
1378
  idempotentHint: false,
1275
1379
  openWorldHint: true,
1276
1380
  },
1277
- }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, savePath, ...auth }) => {
1381
+ }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, connectionType, savePath, ...auth }) => {
1278
1382
  // Validate the destination BEFORE creating a cloud identity, so a path
1279
1383
  // that escapes the identity directory is refused without leaving an
1280
1384
  // orphaned credential behind in the control plane.
@@ -1289,19 +1393,180 @@ export function createServer() {
1289
1393
  preferredProtocols,
1290
1394
  idempotencyKey,
1291
1395
  spec,
1292
- });
1396
+ connectionType,
1397
+ }).catch((error) => withConnectionHint(error, connectionType));
1293
1398
  const selectedEndpoint = api.requireRuntimeProtocol(result, preferredProtocols[0]);
1294
1399
  const savedIdentityPath = targetIdentityPath ? await writeIdentityFile(targetIdentityPath, result.identity) : undefined;
1295
1400
  return jsonContent({
1296
1401
  result: redactSecrets(result.asObject({ includeSecrets: false })),
1402
+ clientId: result.clientId ?? null,
1403
+ connectionType: result.connectionType ?? null,
1404
+ operationId: result.operation?.id ?? null,
1297
1405
  selectedEndpoint,
1298
1406
  enabledProtocols: result.identity.enabledProtocols(),
1299
1407
  savedIdentityPath,
1300
1408
  secretNotice: savedIdentityPath
1301
1409
  ? "Full identity was written to the requested local path with mode 0600."
1302
1410
  : "Secret identity fields were not returned. Pass savePath to write a protected local identity file.",
1411
+ next: result.operation
1412
+ ? "The hub admits a new connection in about ninety seconds and refuses it until then. Call thalovant_wait_for_admission with this operationId before connecting."
1413
+ : "The API returned no admission operation, so there is nothing to wait for.",
1303
1414
  });
1304
1415
  });
1416
+ registerThalovantTool(server, "thalovant_wait_for_admission", {
1417
+ title: "Wait For Connection Admission",
1418
+ description: "Wait until a hub admits a connection made with thalovant_create_client_identity: about ninety seconds after the create. Follows the operation the create returned (operationId), reading it every pollIntervalMs; a 429 is waited out for the time the API names. outcome is admitted (ready, or an operation the API no longer tracks), failed -- the platform could not admit it (errorCode says why, status is null) or the API refused the wait itself (status, code and detail say what it answered) -- or timeout: timeoutMs passed first and the connection may still be admitted, so call this tool again rather than creating another connection. A token the API refuses (401, 403) or an API out of reach is a tool error of its own, not a failed admission. Omit operationId when the create returned none. Reads only.",
1419
+ inputSchema: {
1420
+ ...controlPlaneSchema,
1421
+ operationId: z
1422
+ .string()
1423
+ .min(1)
1424
+ .max(500)
1425
+ .optional()
1426
+ .describe("operationId from thalovant_create_client_identity: the operation's id or its links.self path. An absolute URL must be on the API's own origin."),
1427
+ timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(ADMISSION_DEFAULT_TIMEOUT_MS),
1428
+ pollIntervalMs: z.number().int().min(500).max(10_000).default(2_000).describe("Milliseconds between two reads of the operation."),
1429
+ },
1430
+ annotations: {
1431
+ readOnlyHint: true,
1432
+ openWorldHint: true,
1433
+ },
1434
+ }, async ({ operationId, timeoutMs, pollIntervalMs, ...auth }) => {
1435
+ const api = await createControlPlane(auth);
1436
+ ensureAuthenticated(api);
1437
+ const signal = requestContext.getStore()?.signal;
1438
+ throwIfRuntimeCancelled(signal);
1439
+ try {
1440
+ await api.waitForAdmission(operationId ?? null, { timeoutMs: clampTimeout(timeoutMs), pollIntervalMs, signal });
1441
+ return jsonContent({ outcome: "admitted" });
1442
+ }
1443
+ catch (error) {
1444
+ if (error instanceof ThalovantAdmissionTimeoutError) {
1445
+ return jsonContent({
1446
+ outcome: "timeout",
1447
+ next: "The hub has not admitted the connection yet and may still. Call thalovant_wait_for_admission again with the same operationId; do not create another connection.",
1448
+ });
1449
+ }
1450
+ if (error instanceof ThalovantAdmissionFailedError) {
1451
+ // The API's own words when it refused the wait; null for a failure on the platform.
1452
+ return jsonContent(redactSecrets({
1453
+ outcome: "failed",
1454
+ errorCode: error.errorCode ?? null,
1455
+ status: error.statusCode ?? null,
1456
+ code: error.code ?? null,
1457
+ detail: error.detail ?? null,
1458
+ message: error.message,
1459
+ }));
1460
+ }
1461
+ throwIfRuntimeCancelled(signal);
1462
+ withConnectionHint(error);
1463
+ }
1464
+ });
1465
+ registerThalovantTool(server, "thalovant_begin_device_login", {
1466
+ title: "Begin Device Sign-In",
1467
+ description: "Start a device sign-in (RFC 8628) when no API token is configured: a person approves it in a browser. Show the person verificationUri and userCode (or verificationUriComplete, which carries the code), then call thalovant_poll_device_login with loginId every `interval` seconds. The device code stays on this server. Once approved, control-plane tools for this principal use the token it minted. A Home Assistant link asks for hubs:read, clients:read and clients:write, which is also all a Free plan can approve.",
1468
+ inputSchema: {
1469
+ apiUrl: controlPlaneSchema.apiUrl,
1470
+ scopes: z
1471
+ .array(z.string().min(1).max(64))
1472
+ .max(32)
1473
+ .optional()
1474
+ .describe(`Scopes the token will carry. The API applies its default when omitted or empty (an empty list is left out of the request). A Home Assistant link asks for ${HOME_ASSISTANT_SCOPES.join(", ")}.`),
1475
+ clientName: z.string().min(1).max(128).optional().describe("The name the approval page and the dashboard's token list show."),
1476
+ },
1477
+ annotations: {
1478
+ readOnlyHint: false,
1479
+ destructiveHint: false,
1480
+ idempotentHint: false,
1481
+ openWorldHint: true,
1482
+ },
1483
+ }, async ({ apiUrl, scopes, clientName }) => {
1484
+ const api = await deviceLoginControlPlane(apiUrl);
1485
+ const grant = await api.beginDeviceLogin({ scopes, clientName });
1486
+ const loginId = deviceLogins.start(currentPrincipal().id, api, grant);
1487
+ return jsonContent({
1488
+ loginId,
1489
+ userCode: grant.userCode,
1490
+ verificationUri: grant.verificationUri,
1491
+ verificationUriComplete: grant.verificationUriComplete,
1492
+ interval: grant.interval,
1493
+ expiresIn: grant.expiresIn,
1494
+ next: `Ask the person to open ${grant.verificationUri} and enter ${grant.userCode}, then call thalovant_poll_device_login with this loginId every ${grant.interval} seconds until the outcome is no longer pending. The code expires in ${grant.expiresIn} seconds.`,
1495
+ });
1496
+ });
1497
+ registerThalovantTool(server, "thalovant_poll_device_login", {
1498
+ title: "Poll Device Sign-In",
1499
+ description: "Ask once whether the person approved a device sign-in started with thalovant_begin_device_login. outcome is pending (poll again after `interval` seconds, already lengthened if the API asked to slow down), approved (control-plane tools for this principal now use the token; tokenId, scopes and expiresAt describe it), expired or denied (begin a new sign-in). The token itself is never returned.",
1500
+ inputSchema: {
1501
+ loginId: z.string().min(1).max(64).describe("loginId from thalovant_begin_device_login."),
1502
+ },
1503
+ annotations: {
1504
+ readOnlyHint: false,
1505
+ destructiveHint: false,
1506
+ idempotentHint: false,
1507
+ openWorldHint: true,
1508
+ },
1509
+ }, async ({ loginId }) => {
1510
+ const principal = currentPrincipal().id;
1511
+ const login = deviceLogins.find(loginId, principal);
1512
+ if (!login) {
1513
+ throw new Error("No device sign-in in progress has this loginId: it expired, already ended, or was started by another principal. Begin a new one with thalovant_begin_device_login.");
1514
+ }
1515
+ try {
1516
+ const token = await login.api.pollDeviceLogin(login.authorization);
1517
+ deviceLogins.finish(loginId);
1518
+ deviceLogins.approve(principal, login.api);
1519
+ return jsonContent({
1520
+ outcome: "approved",
1521
+ tokenType: token.tokenType,
1522
+ scopes: [...token.scopes],
1523
+ expiresAt: token.expiresAt,
1524
+ tokenId: token.tokenId,
1525
+ next: "Signed in. Control-plane tools for this principal now use this token until thalovant_revoke_device_login or a server restart.",
1526
+ });
1527
+ }
1528
+ catch (error) {
1529
+ if (error instanceof ThalovantDeviceLoginPendingError) {
1530
+ return jsonContent({ outcome: "pending", interval: error.interval });
1531
+ }
1532
+ if (error instanceof ThalovantDeviceLoginExpiredError) {
1533
+ deviceLogins.finish(loginId);
1534
+ return jsonContent({ outcome: "expired", status: error.statusCode ?? null, next: "The code expired before anybody approved it. Begin a new sign-in." });
1535
+ }
1536
+ if (error instanceof ThalovantDeviceLoginDeniedError) {
1537
+ deviceLogins.finish(loginId);
1538
+ return jsonContent({ outcome: "denied", status: error.statusCode ?? null, next: "The person declined the sign-in in the browser." });
1539
+ }
1540
+ throw error;
1541
+ }
1542
+ });
1543
+ registerThalovantTool(server, "thalovant_revoke_device_login", {
1544
+ title: "Revoke Device Sign-In",
1545
+ description: "Sign out: revoke the API token this principal signed in with through thalovant_poll_device_login, and stop using it. A token may always revoke itself, and one already revoked counts as revoked; signing out again answers alreadyRevoked without a request. Tokens configured on the server are never touched.",
1546
+ inputSchema: {},
1547
+ annotations: {
1548
+ readOnlyHint: false,
1549
+ destructiveHint: true,
1550
+ idempotentHint: true,
1551
+ openWorldHint: true,
1552
+ },
1553
+ }, async () => {
1554
+ const principal = currentPrincipal().id;
1555
+ const signedIn = deviceLogins.signedInAs(principal);
1556
+ if (!signedIn) {
1557
+ // Signing out twice is signing out once: the token is already gone.
1558
+ const earlier = deviceLogins.revokedOf(principal);
1559
+ if (earlier !== undefined)
1560
+ return jsonContent({ revoked: true, tokenId: earlier, alreadyRevoked: true });
1561
+ throw new Error("This principal has not signed in with a device sign-in, so there is no token of its own to revoke.");
1562
+ }
1563
+ const tokenId = signedIn.api.tokenId ?? null;
1564
+ // A token the API no longer accepts (401 on its own revoke) is already
1565
+ // revoked; the SDK counts that as done.
1566
+ await signedIn.api.revokeApiToken();
1567
+ deviceLogins.signOut(principal, tokenId);
1568
+ return jsonContent({ revoked: true, tokenId });
1569
+ });
1305
1570
  registerThalovantTool(server, "thalovant_identity_status", {
1306
1571
  title: "Identity Status",
1307
1572
  description: "Load a Thalovant identity and return redacted protocol and endpoint status.",
@@ -1879,7 +2144,7 @@ export function createServer() {
1879
2144
  });
1880
2145
  registerThalovantTool(server, "thalovant_release_hub", {
1881
2146
  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.",
2147
+ 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
2148
  inputSchema: {
1884
2149
  ...controlPlaneSchema,
1885
2150
  hubId: z.string().min(1).describe("Hub UUID."),
@@ -2072,7 +2337,7 @@ export function createServer() {
2072
2337
  });
2073
2338
  registerThalovantTool(server, "thalovant_release_runtime_group", {
2074
2339
  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.",
2340
+ 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
2341
  inputSchema: {
2077
2342
  ...controlPlaneSchema,
2078
2343
  runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
@@ -2277,6 +2542,26 @@ export function createServer() {
2277
2542
  await callControlPlane("hubWrite", () => api.deleteHub(hubId, { etag }));
2278
2543
  return textContent("Hub deleted.");
2279
2544
  });
2545
+ registerThalovantTool(server, "thalovant_delete_client", {
2546
+ title: "Delete Client",
2547
+ description: "Permanently delete a client (a hub connection): the device or link using it loses access to the hub. This cannot be undone. etag is the client's current etag, sent as If-Match; when omitted the server reads it first. A client changed underneath (412) is read again and the delete retried once, and a client already gone counts as deleted. Requires the clients:write scope. This tool is disabled unless the operator sets THALOVANT_ENABLE_DESTRUCTIVE_TOOLS.",
2548
+ inputSchema: {
2549
+ ...controlPlaneSchema,
2550
+ clientId: z.string().min(1).max(128).describe("Client UUID, for example the clientId a create returned or the client_id of an already-linked refusal."),
2551
+ etag: z.string().min(1).optional().describe("The client's current etag. Read first when omitted."),
2552
+ },
2553
+ annotations: {
2554
+ readOnlyHint: false,
2555
+ destructiveHint: true,
2556
+ idempotentHint: true,
2557
+ openWorldHint: true,
2558
+ },
2559
+ }, async ({ clientId, etag, ...auth }) => {
2560
+ const api = await createControlPlane(auth);
2561
+ ensureAuthenticated(api);
2562
+ await api.deleteClient(clientId, { etag }).catch((error) => withConnectionHint(error));
2563
+ return jsonContent({ deleted: true, clientId });
2564
+ });
2280
2565
  registerThalovantTool(server, "thalovant_delete_runtime_group", {
2281
2566
  title: "Delete Runtime Group",
2282
2567
  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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Stdio and Streamable HTTP MCP server for Thalovant control-plane and hub runtime APIs.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@modelcontextprotocol/sdk": "^1.30.0",
67
- "@thalovant/sdk": "^0.8.2",
67
+ "@thalovant/sdk": "^0.9.0",
68
68
  "jose": "^6.2.8",
69
69
  "zod": "^3.25.76"
70
70
  },
package/server.json CHANGED
@@ -7,19 +7,19 @@
7
7
  "url": "https://github.com/thalovant/thalovant-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.5.2",
10
+ "version": "0.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.5.2",
15
+ "version": "0.6.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
19
19
  },
20
20
  {
21
21
  "registryType": "oci",
22
- "identifier": "ghcr.io/thalovant/thalovant-mcp:0.5.2",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.6.0",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"