@thalovant/mcp-server 0.5.3 → 0.6.1

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 — 2026-09-28
4
+
5
+ - **Sign in as a registered app.** `thalovant_begin_device_login` takes an optional `clientId`, passed to the API as `client_id` only when given: `thalovant-home-assistant` makes the approval page show the platform's name for Home Assistant as verified, and approving it again replaces the token it already holds. An id the API does not know is refused with 400 `unknown_client`, and the tool error carries the code and sentence like any other refusal.
6
+ - **An echoed validation input never reaches a tool error.** The `fields:` line of a tool error printed a validation error's `input` whenever it was a bare string or sat under an ordinary key: the secret redaction matches key names only. Each entry of a `detail` or Problem+JSON `errors` list now has its `input` replaced with `"[omitted]"` before redaction, whatever its key or shape.
7
+ - **Where the Noise key lives.** `@thalovant/sdk` 0.9.1 keeps an identity file's Noise key and hub pins in that file's folder, so every program reading the file presents one key, and copies them there once from the shared folder when that key already met the hub. Runtime tools given an `identityFile` follow it.
8
+ - Takes `@thalovant/sdk` ^0.9.1, which carries `clientId`, `describeDeviceLogin()`, the refused-client-key error and the key folder. The lock resolves the published 0.9.1.
9
+ - `fast-uri`, which `ajv` brings in, is overridden to 3.1.8: 3.1.6 now carries two high advisories (GHSA-qw65-cvwx-89v3, GHSA-58mr-gqgx-xq4g), and 3.1.8 still satisfies `ajv`'s `^3.0.1`.
10
+ - The parity contract acknowledges the reference's `3b0acf7c8b2f` and declares its new `link-carriers` capability not applicable: this server's carriers are the Node SDK's, which runs those vectors. The re-vendored `api-error-vectors.json` and `device-login-vectors.json` run through the SDK that resolves here and through the tools over stdio; the `describe` cases through the SDK only, since a model has no approval screen to read. All 78 recorded cases, and each file's digest, equal the reference's.
11
+
12
+ ## 0.6.0 — 2026-09-28
13
+
14
+ 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.
15
+
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **`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.
20
+ - The catalog has 50 default tools (24 in read-only mode, 53 with destructive tools enabled). `thalovant_config_status` reports `deviceLoginSignedIn`.
21
+ - Takes `@thalovant/sdk` ^0.9.0, which carries the device sign-in, the connection kinds and admission; the lock resolves 0.9.0.
22
+ - 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.
23
+ - The Dockerfile's version label had stayed at 0.4.0; it now says 0.6.0 like the rest.
24
+
3
25
  ## 0.5.3 — 2026-09-26
4
26
 
5
27
  - 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.
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), `clientName` and `clientId` answers `loginId`, `userCode`, `verificationUri`, `verificationUriComplete`, `interval` and `expiresIn`. Show the person the URL and the code. `clientId` signs in as a registered app, `thalovant-home-assistant` for Home Assistant: the approval page shows the platform's name for it as verified, and approving it again replaces the token it already holds. An id the API does not know is refused (400 `unknown_client`).
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
 
@@ -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
 
@@ -422,8 +453,9 @@ fields: {"allowed_images":{"bus":[...],"core":[...]},"allowed_repositories":{"co
422
453
  - `fields` is every other member of the error body as compact JSON (the
423
454
  Problem+JSON `type`, `title`, `status` and `instance` are left out), with the
424
455
  same secret redaction as every other tool output. A value a validation error
425
- echoes back from the request is redacted there and never reaches the first
426
- line. A plan refusal carries `resource`, `limit`, `used` and `plan` here.
456
+ echoes back from the request -- each entry's `input` in a `detail` or
457
+ `errors` list, whatever its key or shape -- is replaced with `"[omitted]"`
458
+ there and never reaches the first line. A plan refusal carries `resource`, `limit`, `used` and `plan` here.
427
459
 
428
460
  A hint may follow for statuses that need one. A `platform_image_required` or
429
461
  `plan_limit` 403 gets its own hint rather than the general scope hint.
@@ -457,9 +489,9 @@ or use an SDK that preserves large JSON integers.
457
489
 
458
490
  ## Destructive Tools
459
491
 
460
- `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.
492
+ `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.
461
493
 
462
- 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:
494
+ 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:
463
495
 
464
496
  ```bash
465
497
  export THALOVANT_ENABLE_DESTRUCTIVE_TOOLS="true"
@@ -477,7 +509,7 @@ export MCP_TOOL_DENYLIST="thalovant_delete_hub,thalovant_delete_runtime_group"
477
509
 
478
510
  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.
479
511
 
480
- 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.
512
+ 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.
481
513
 
482
514
  ## Non-Catalog Skill Sources
483
515
 
@@ -491,7 +523,7 @@ With the flag unset, a call with any `sourceType` other than `catalog` fails bef
491
523
 
492
524
  ## Read-Only Mode
493
525
 
494
- 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`.
526
+ 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`.
495
527
 
496
528
  ## HiveMind Runtime Compatibility
497
529
 
@@ -499,7 +531,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
499
531
  MCP process. Use a distinct client identity for each independently running MCP
500
532
  server so the hub can keep their sessions separate.
501
533
 
502
- Version 0.5.1 requires `@thalovant/sdk` `^0.7.1` (0.7.1 through versions below 0.8.0). This release enforces
534
+ Version 0.6.1 requires `@thalovant/sdk` `^0.9.1` (0.9.1 through versions below 0.10.0). Since 0.5.1 the server enforces
503
535
  secure effective MQTT URLs and carries a single connection deadline through
504
536
  MQTT setup and HTTP failure cleanup. Runtime tools support
505
537
  HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
@@ -577,7 +609,7 @@ one tool for every SDK conversation or event-listener method.
577
609
 
578
610
  ### Shared-runtime skill management
579
611
 
580
- The catalog has 46 default tools, 23 in read-only mode, and 48 with destructive tools enabled.
612
+ The catalog has 50 default tools, 24 in read-only mode, and 53 with destructive tools enabled.
581
613
 
582
614
  Hub-addressed skill methods select the runtime group attached to the hub UUID.
583
615
  Every hub sharing that group sees the same skill changes and history. The API
@@ -7,8 +7,10 @@
7
7
  * image each refused key may be instead, and that list was the part cut off.
8
8
  * So the code, the whole sentence (when the message line had to shorten it)
9
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.
10
+ * `redact` is the server's own secret redaction, applied to the fields. A
11
+ * validation error echoes back what it was sent as each entry's `input`, which
12
+ * is replaced with "[omitted]" first: redaction by key name cannot see a bare
13
+ * string, or a secret under an ordinary key.
12
14
  *
13
15
  * Returns undefined for anything that is not a `ThalovantApiError`.
14
16
  */
@@ -5,6 +5,22 @@ import { ThalovantApiError } from "@thalovant/sdk";
5
5
  * are the handler's own bookkeeping.
6
6
  */
7
7
  const ENVELOPE = new Set(["type", "title", "status", "instance"]);
8
+ /**
9
+ * The lists a validation error arrives in: FastAPI's `detail` and a
10
+ * Problem+JSON `errors`. Each entry's `input` is what the request sent,
11
+ * echoed back -- a generated secret, a name, a whole spec -- under whatever key
12
+ * and in whatever shape the field had, so key-name redaction cannot catch it.
13
+ */
14
+ const VALIDATION_LISTS = new Set(["detail", "errors"]);
15
+ const OMITTED = "[omitted]";
16
+ /** The entries of a validation list, each with its echoed `input` replaced. */
17
+ function withoutEchoedInput(value) {
18
+ if (!Array.isArray(value))
19
+ return value;
20
+ return value.map((entry) => (entry && typeof entry === "object" && !Array.isArray(entry) && "input" in entry
21
+ ? { ...entry, input: OMITTED }
22
+ : entry));
23
+ }
8
24
  /** A detail or a field list longer than this is cut, and says so. */
9
25
  const MAX_SECTION_CHARS = 4_000;
10
26
  function bounded(text) {
@@ -21,8 +37,10 @@ function bounded(text) {
21
37
  * image each refused key may be instead, and that list was the part cut off.
22
38
  * So the code, the whole sentence (when the message line had to shorten it)
23
39
  * 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.
40
+ * `redact` is the server's own secret redaction, applied to the fields. A
41
+ * validation error echoes back what it was sent as each entry's `input`, which
42
+ * is replaced with "[omitted]" first: redaction by key name cannot see a bare
43
+ * string, or a secret under an ordinary key.
26
44
  *
27
45
  * Returns undefined for anything that is not a `ThalovantApiError`.
28
46
  */
@@ -45,7 +63,7 @@ export function apiErrorText(error, redact) {
45
63
  // Already on a line above when they were read as strings.
46
64
  if ((key === "code" || key === "detail") && typeof value === "string")
47
65
  continue;
48
- fields[key] = value;
66
+ fields[key] = VALIDATION_LISTS.has(key) ? withoutEchoedInput(value) : value;
49
67
  }
50
68
  if (Object.keys(fields).length)
51
69
  lines.push(`fields: ${bounded(JSON.stringify(redact(fields)))}`);
@@ -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,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { apiErrorText } from "./api-error-output.js";
3
+ import { DeviceLogins } from "./device-login.js";
3
4
  import { inventoryPresentation } from "./inventory-output.js";
4
5
  import { runtimeReplyContent } from "./reply-output.js";
5
6
  import { throwIfRuntimeCancelled, withRuntimeLease } from "./runtime-lease.js";
@@ -7,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
10
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
10
- import { DEFAULT_CONTROL_API_URL, ThalovantApiError, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, buildLocation, } from "@thalovant/sdk";
11
+ import { CONNECTION_TYPE_HOME_ASSISTANT, DEFAULT_CONTROL_API_URL, HOME_ASSISTANT_CLIENT_ID, HOME_ASSISTANT_SCOPES, ThalovantAdmissionFailedError, ThalovantAdmissionTimeoutError, ThalovantAlreadyLinkedError, ThalovantApiError, ThalovantApiUnreachableError, ThalovantAuthError, ThalovantClient, ThalovantControlPlane, ThalovantDeviceLoginDeniedError, ThalovantDeviceLoginExpiredError, ThalovantDeviceLoginPendingError, ThalovantIdentity, ThalovantPlanError, ThalovantUnsupportedConnectionTypeError, buildClientContext, buildLocation, } from "@thalovant/sdk";
11
12
  import { AsyncLocalStorage } from "node:async_hooks";
12
13
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
13
14
  import { createServer as createHttpServer } from "node:http";
@@ -61,6 +62,10 @@ const SECRET_KEYS = [
61
62
  "secret",
62
63
  ];
63
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.`);
64
69
  const limitSchema = z.number().int().min(1).max(MAX_LIMIT).default(25).describe("Page size, 1-100.");
65
70
  const publicHubsLimitSchema = z
66
71
  .number()
@@ -124,7 +129,14 @@ const runtimeAuthSchema = {
124
129
  * they are absent from tools/list entirely rather than merely failing when
125
130
  * called. See destructiveToolsEnabled().
126
131
  */
127
- 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;
128
140
  /**
129
141
  * Destructive control-plane tools are opt-in.
130
142
  *
@@ -247,6 +259,40 @@ function controlPlaneErrorHint(profile, status, body, code) {
247
259
  return undefined;
248
260
  }
249
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
+ }
250
296
  /**
251
297
  * Run a control-plane call and, on failure, append actionable guidance.
252
298
  *
@@ -627,17 +673,38 @@ function redactSecrets(value) {
627
673
  }
628
674
  return output;
629
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
+ }
630
690
  /** Keep server-managed credentials on their configured control-plane origin. */
631
691
  async function createControlPlane(options) {
632
692
  const principal = currentPrincipal();
633
693
  const credential = await credentialForPrincipal(principal);
634
694
  const canUseShared = allowSharedThalovantCredentials(principal);
635
- const configuredApiUrl = credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
695
+ const configuredApiUrl = await configuredControlApiUrl(principal);
636
696
  const apiUrl = options.apiUrl ?? configuredApiUrl;
637
- const accessToken = credential?.control?.accessToken ??
638
- (canUseShared ? process.env.THALOVANT_API_TOKEN ?? process.env.THALOVANT_ACCESS_TOKEN : undefined);
639
697
  const email = canUseShared ? process.env.THALOVANT_EMAIL : undefined;
640
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;
641
708
  if (accessToken || (email && password)) {
642
709
  let endpoint;
643
710
  let configuredEndpoint;
@@ -672,6 +739,24 @@ function ensureAuthenticated(api) {
672
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.");
673
740
  }
674
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
+ }
675
760
  function controlPlaneAuthModeFromEnv() {
676
761
  if (process.env.THALOVANT_API_TOKEN)
677
762
  return "api-token";
@@ -1172,6 +1257,7 @@ export function createServer() {
1172
1257
  }, async () => jsonContent({
1173
1258
  apiUrl: process.env.THALOVANT_API_URL ?? DEFAULT_CONTROL_API_URL,
1174
1259
  controlPlaneAuthMode: controlPlaneAuthModeFromEnv(),
1260
+ deviceLoginSignedIn: Boolean(deviceLogins.signedInAs(currentPrincipal().id)),
1175
1261
  hasApiToken: Boolean(process.env.THALOVANT_API_TOKEN),
1176
1262
  hasAccessToken: Boolean(process.env.THALOVANT_ACCESS_TOKEN),
1177
1263
  hasEmailPassword: Boolean(process.env.THALOVANT_EMAIL && process.env.THALOVANT_PASSWORD),
@@ -1265,7 +1351,7 @@ export function createServer() {
1265
1351
  });
1266
1352
  registerThalovantTool(server, "thalovant_create_client_identity", {
1267
1353
  title: "Create Client Identity",
1268
- 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.",
1269
1355
  inputSchema: {
1270
1356
  ...controlPlaneSchema,
1271
1357
  hubId: z
@@ -1279,6 +1365,7 @@ export function createServer() {
1279
1365
  preferredProtocols: z.array(protocolSchema).min(1).default(["wss", "https"]),
1280
1366
  idempotencyKey: z.string().min(1).optional(),
1281
1367
  spec: jsonRecordSchema.optional(),
1368
+ connectionType: connectionTypeSchema.optional(),
1282
1369
  savePath: z
1283
1370
  .string()
1284
1371
  .min(1)
@@ -1291,7 +1378,7 @@ export function createServer() {
1291
1378
  idempotentHint: false,
1292
1379
  openWorldHint: true,
1293
1380
  },
1294
- }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, savePath, ...auth }) => {
1381
+ }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, connectionType, savePath, ...auth }) => {
1295
1382
  // Validate the destination BEFORE creating a cloud identity, so a path
1296
1383
  // that escapes the identity directory is refused without leaving an
1297
1384
  // orphaned credential behind in the control plane.
@@ -1306,19 +1393,186 @@ export function createServer() {
1306
1393
  preferredProtocols,
1307
1394
  idempotencyKey,
1308
1395
  spec,
1309
- });
1396
+ connectionType,
1397
+ }).catch((error) => withConnectionHint(error, connectionType));
1310
1398
  const selectedEndpoint = api.requireRuntimeProtocol(result, preferredProtocols[0]);
1311
1399
  const savedIdentityPath = targetIdentityPath ? await writeIdentityFile(targetIdentityPath, result.identity) : undefined;
1312
1400
  return jsonContent({
1313
1401
  result: redactSecrets(result.asObject({ includeSecrets: false })),
1402
+ clientId: result.clientId ?? null,
1403
+ connectionType: result.connectionType ?? null,
1404
+ operationId: result.operation?.id ?? null,
1314
1405
  selectedEndpoint,
1315
1406
  enabledProtocols: result.identity.enabledProtocols(),
1316
1407
  savedIdentityPath,
1317
1408
  secretNotice: savedIdentityPath
1318
1409
  ? "Full identity was written to the requested local path with mode 0600."
1319
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.",
1320
1414
  });
1321
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, and signs in as the registered app thalovant-home-assistant (clientId).",
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. With clientId, the device's own label beside the app's verified name."),
1476
+ clientId: z
1477
+ .string()
1478
+ .min(1)
1479
+ .max(128)
1480
+ .optional()
1481
+ .describe(`Sign in as a registered app: ${HOME_ASSISTANT_CLIENT_ID} for Home Assistant. The approval page then shows the platform's name for the app as verified, and approving it again replaces the token it already holds. The app may ask only for its own scopes, and an id the API does not know is refused (400 unknown_client). Left out when omitted.`),
1482
+ },
1483
+ annotations: {
1484
+ readOnlyHint: false,
1485
+ destructiveHint: false,
1486
+ idempotentHint: false,
1487
+ openWorldHint: true,
1488
+ },
1489
+ }, async ({ apiUrl, scopes, clientName, clientId }) => {
1490
+ const api = await deviceLoginControlPlane(apiUrl);
1491
+ const grant = await api.beginDeviceLogin({ scopes, clientName, clientId });
1492
+ const loginId = deviceLogins.start(currentPrincipal().id, api, grant);
1493
+ return jsonContent({
1494
+ loginId,
1495
+ userCode: grant.userCode,
1496
+ verificationUri: grant.verificationUri,
1497
+ verificationUriComplete: grant.verificationUriComplete,
1498
+ interval: grant.interval,
1499
+ expiresIn: grant.expiresIn,
1500
+ 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.`,
1501
+ });
1502
+ });
1503
+ registerThalovantTool(server, "thalovant_poll_device_login", {
1504
+ title: "Poll Device Sign-In",
1505
+ 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.",
1506
+ inputSchema: {
1507
+ loginId: z.string().min(1).max(64).describe("loginId from thalovant_begin_device_login."),
1508
+ },
1509
+ annotations: {
1510
+ readOnlyHint: false,
1511
+ destructiveHint: false,
1512
+ idempotentHint: false,
1513
+ openWorldHint: true,
1514
+ },
1515
+ }, async ({ loginId }) => {
1516
+ const principal = currentPrincipal().id;
1517
+ const login = deviceLogins.find(loginId, principal);
1518
+ if (!login) {
1519
+ 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.");
1520
+ }
1521
+ try {
1522
+ const token = await login.api.pollDeviceLogin(login.authorization);
1523
+ deviceLogins.finish(loginId);
1524
+ deviceLogins.approve(principal, login.api);
1525
+ return jsonContent({
1526
+ outcome: "approved",
1527
+ tokenType: token.tokenType,
1528
+ scopes: [...token.scopes],
1529
+ expiresAt: token.expiresAt,
1530
+ tokenId: token.tokenId,
1531
+ next: "Signed in. Control-plane tools for this principal now use this token until thalovant_revoke_device_login or a server restart.",
1532
+ });
1533
+ }
1534
+ catch (error) {
1535
+ if (error instanceof ThalovantDeviceLoginPendingError) {
1536
+ return jsonContent({ outcome: "pending", interval: error.interval });
1537
+ }
1538
+ if (error instanceof ThalovantDeviceLoginExpiredError) {
1539
+ deviceLogins.finish(loginId);
1540
+ return jsonContent({ outcome: "expired", status: error.statusCode ?? null, next: "The code expired before anybody approved it. Begin a new sign-in." });
1541
+ }
1542
+ if (error instanceof ThalovantDeviceLoginDeniedError) {
1543
+ deviceLogins.finish(loginId);
1544
+ return jsonContent({ outcome: "denied", status: error.statusCode ?? null, next: "The person declined the sign-in in the browser." });
1545
+ }
1546
+ throw error;
1547
+ }
1548
+ });
1549
+ registerThalovantTool(server, "thalovant_revoke_device_login", {
1550
+ title: "Revoke Device Sign-In",
1551
+ 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.",
1552
+ inputSchema: {},
1553
+ annotations: {
1554
+ readOnlyHint: false,
1555
+ destructiveHint: true,
1556
+ idempotentHint: true,
1557
+ openWorldHint: true,
1558
+ },
1559
+ }, async () => {
1560
+ const principal = currentPrincipal().id;
1561
+ const signedIn = deviceLogins.signedInAs(principal);
1562
+ if (!signedIn) {
1563
+ // Signing out twice is signing out once: the token is already gone.
1564
+ const earlier = deviceLogins.revokedOf(principal);
1565
+ if (earlier !== undefined)
1566
+ return jsonContent({ revoked: true, tokenId: earlier, alreadyRevoked: true });
1567
+ throw new Error("This principal has not signed in with a device sign-in, so there is no token of its own to revoke.");
1568
+ }
1569
+ const tokenId = signedIn.api.tokenId ?? null;
1570
+ // A token the API no longer accepts (401 on its own revoke) is already
1571
+ // revoked; the SDK counts that as done.
1572
+ await signedIn.api.revokeApiToken();
1573
+ deviceLogins.signOut(principal, tokenId);
1574
+ return jsonContent({ revoked: true, tokenId });
1575
+ });
1322
1576
  registerThalovantTool(server, "thalovant_identity_status", {
1323
1577
  title: "Identity Status",
1324
1578
  description: "Load a Thalovant identity and return redacted protocol and endpoint status.",
@@ -2294,6 +2548,26 @@ export function createServer() {
2294
2548
  await callControlPlane("hubWrite", () => api.deleteHub(hubId, { etag }));
2295
2549
  return textContent("Hub deleted.");
2296
2550
  });
2551
+ registerThalovantTool(server, "thalovant_delete_client", {
2552
+ title: "Delete Client",
2553
+ 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.",
2554
+ inputSchema: {
2555
+ ...controlPlaneSchema,
2556
+ 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."),
2557
+ etag: z.string().min(1).optional().describe("The client's current etag. Read first when omitted."),
2558
+ },
2559
+ annotations: {
2560
+ readOnlyHint: false,
2561
+ destructiveHint: true,
2562
+ idempotentHint: true,
2563
+ openWorldHint: true,
2564
+ },
2565
+ }, async ({ clientId, etag, ...auth }) => {
2566
+ const api = await createControlPlane(auth);
2567
+ ensureAuthenticated(api);
2568
+ await api.deleteClient(clientId, { etag }).catch((error) => withConnectionHint(error));
2569
+ return jsonContent({ deleted: true, clientId });
2570
+ });
2297
2571
  registerThalovantTool(server, "thalovant_delete_runtime_group", {
2298
2572
  title: "Delete Runtime Group",
2299
2573
  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.3",
3
+ "version": "0.6.1",
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,12 +64,12 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@modelcontextprotocol/sdk": "^1.30.0",
67
- "@thalovant/sdk": "^0.8.7",
67
+ "@thalovant/sdk": "^0.9.1",
68
68
  "jose": "^6.2.8",
69
69
  "zod": "^3.25.76"
70
70
  },
71
71
  "overrides": {
72
- "fast-uri": "3.1.6"
72
+ "fast-uri": "3.1.8"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@types/node": "^22.18.12",
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.3",
10
+ "version": "0.6.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.5.3",
15
+ "version": "0.6.1",
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.3",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.6.1",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"