@thalovant/mcp-server 0.6.6 → 0.7.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,8 +1,24 @@
1
- Last Edit: Codex (exact runtime model identifier unavailable) - 2026-10-25 - Motive: Document the scoped dependency security patch.
1
+ Last Edit: Claude Opus 5.5 - 2026-11-03 - Motive: 0.7.0 chat apps tools; date the published 0.6.6.
2
2
 
3
3
  # Changelog
4
4
 
5
- ## 0.6.6 - Unreleased
5
+ ## 0.7.0 - Unreleased
6
+
7
+ A hub can answer in a chat network through a chat app: Telegram and Discord, and Matrix, Mattermost and Twitch as beta once the API lists them. These tools let an agent read and look after the bridges a person connected in the console. Existing tool schemas are unchanged.
8
+
9
+ - **Reads:** `thalovant_list_chat_app_connectors` (status, setup steps in `en` or `fr`, the secrets the console asks for by name, the settings form, chat id format, helpers), `thalovant_get_bridge_networks` (networks offered and the plan's allowance, with a `summary`; Free plans get 0), `thalovant_list_bridges`, `thalovant_get_bridge` and `thalovant_list_bridge_chats`. All five change nothing. The first four need `hubs:read` and stay in `THALOVANT_MCP_READONLY` mode. `thalovant_list_bridge_chats` asks the chat network on the bot's behalf, so the API serves it only to `hubs:write`, the hub's owner or a manager, and a paid plan; read-only mode leaves it out.
10
+ - **Status in plain words.** Each bridge's `status` keeps the API's `reason`, `phase`, `ready` and `applied`, and adds `summary`: what `Setup`, `Starting`, `Connected`, `Paused`, `Unavailable`, `TokenMissing` or `TokenRejected` means and what to do, a `plan_limit` or `account_suspended` hold and since when, and whether the latest change has reached the bridge. A reason this server does not know yet is kept and named. The platform's own diagnostic message (`status.message`, `status.operator_reason`) is never included, even for an administrator's token.
11
+ - **Writes:** `thalovant_pause_bridge` and `thalovant_resume_bridge` (`PATCH paused`), `thalovant_update_bridge` (`allowed`, which replaces the whole list, `requireMention`, `sessionScope` and non-secret `settings`) and `thalovant_send_bridge_test_message`. Each takes an optional `etag`, sent as `If-Match` only when given.
12
+ - **Tokens and secrets stay in the console.** No tool takes a credential as an argument, so creating a bridge and replacing its token or secrets have no tool; descriptions and outputs say where to go (Connections > Add connection > Chat apps, or the hub's Chat apps tab, and **Replace token**). `thalovant_update_bridge` refuses a setting whose name looks like a token or secret, at any depth, before any request, naming it by its dotted path without its value.
13
+ - **`thalovant_delete_bridge`**, opt-in with the other destructive tools (`THALOVANT_ENABLE_DESTRUCTIVE_TOOLS`): it removes the bridge and its stored token, which only the console can put back.
14
+ - **Outputs are allowlisted.** Bridges, connectors, chats and the allowance are built from the members the API documents, not passed through; the owner's `settings` and a queued `operation` go through the usual redaction. Redacting whole bridges by key name would have hidden `secrets_set` and `secrets` (names) and `token_updated_at` (a time).
15
+ - **Hints by code** after a refused chat app call: the settings a `bridge_settings_invalid` or `bridge_url_*` refusal names (from the SDK's new `ThalovantApiError.fields`, names only), Telegram's pause-to-list rule (`bridge_running`), the plan's allowance after `402` or `403 plan_limit`, the wait after a `429`, a stale `etag` (`412`), a bridge the token cannot see (`404`), and the console for every token or secret code.
16
+ - The catalog has 59 default tools (28 in read-only mode, 63 with destructive tools enabled).
17
+ - Takes `@thalovant/sdk` ^0.10.0, which carries the chat app methods and `ThalovantApiError.fields`.
18
+ - The parity contract declares `chat-apps` as required and refreshes the evidence hashes of `src/index.ts`, `test/http.test.ts` and `test/provisioning.test.ts`.
19
+ - `package.json`, `package-lock.json`, `server.json`, the OCI label, the README and this file move to 0.7.0 together.
20
+
21
+ ## 0.6.6 - 2026-10-26
6
22
 
7
23
  - Update body-parser to 2.4.0 (CVE-2026-111181), Zod to 4.6.7 (patched bounds checks from 4.6.6), and the existing fast-uri override to 3.1.9. No audit suppression is added.
8
24
  - Migrate five records to Zod 4's explicit string-key signature. Bounded arrays reject oversized input before parsing elements. UUIDs now enforce standard version/variant bits (plus nil/max UUIDs); memory-list offsets reject unsafe integers.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- Last Edit: Codex (exact runtime model identifier unavailable) - 2026-10-25 - Motive: Record security dependency compatibility and the next patch version.
1
+ Last Edit: Claude Opus 5.5 - 2026-11-03 - Motive: Chat apps tools (connectors, bridges, pause/resume, chats, test message, allowance) for 0.7.0.
2
2
 
3
3
  # Thalovant MCP Server
4
4
 
@@ -16,6 +16,7 @@ It uses the official Thalovant Node.js SDK and the production MCP TypeScript SDK
16
16
  - Static bearer tokens for simple/private deployments.
17
17
  - JWT/JWKS and OAuth token introspection for production remote deployments.
18
18
  - Per-principal Thalovant credentials and tool policy.
19
+ - Chat app tools: connectors, bridges and their status in words, pause and resume, chats, test messages and the plan's allowance. Bot tokens stay in the console.
19
20
  - Host/origin validation, CORS, rate limiting, body limits, secure headers, and session binding.
20
21
  - Optional resumability event storage and JSONL audit logs.
21
22
  - Docker, Compose, Kubernetes, CI, npm package metadata, and MCP registry `server.json`.
@@ -73,9 +74,9 @@ Minimum scopes for the full control-plane tool surface:
73
74
 
74
75
  | Scope | Used by |
75
76
  |-------|---------|
76
- | `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` |
77
+ | `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`, the hub lookup inside `thalovant_create_client_identity`, and the chat app reads (`thalovant_list_chat_app_connectors`, `thalovant_get_bridge_networks`, `thalovant_list_bridges`, `thalovant_get_bridge`) |
77
78
  | `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` |
78
- | `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 |
79
+ | `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, the chat app writes (`thalovant_pause_bridge`, `thalovant_resume_bridge`, `thalovant_update_bridge`, `thalovant_send_bridge_test_message`), `thalovant_list_bridge_chats` (it changes nothing but asks the chat network, so the API serves it only to `hubs:write` and a paid plan), and the opt-in delete tools |
79
80
  | `clients:write` | `thalovant_create_client_identity` (`POST /v1/clients`) and the opt-in `thalovant_delete_client` |
80
81
  | `memory:read` | `thalovant_list_memory_items`, `thalovant_get_memory_summary`, `thalovant_get_memory_item` |
81
82
  | `memory:write` | `thalovant_create_memory_item`, `thalovant_update_memory_item`, `thalovant_delete_memory_item` |
@@ -139,6 +140,37 @@ Each refusal of step 2 names what to do next after the API's own text (see [Tool
139
140
 
140
141
  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.
141
142
 
143
+ ## Chat Apps
144
+
145
+ A hub can answer in a chat network through a **chat app**: Telegram and Discord are offered, and Matrix, Mattermost and Twitch appear as beta when the API lists them. Each connection of a hub to a chat network is a *bridge* (`/v1/bridges`). The person makes the bot in the chat network, then connects it to a hub in the Thalovant console.
146
+
147
+ **Tokens and secrets stay in the console.** Creating a bridge and replacing its token or secrets need the bot's credential, and no tool of this server takes a credential as an argument (see [Control-Plane Auth](#control-plane-auth)), so there is no tool for them. The descriptions and outputs say where to go instead: Connections > Add connection > Chat apps, or the hub's Chat apps tab, and **Replace token** on the bot. `thalovant_update_bridge` refuses a setting whose name looks like a token or secret, at any depth, before any request, naming it by its dotted path and never its value.
148
+
149
+ | Tool | What it does | Kind |
150
+ |------|--------------|------|
151
+ | `thalovant_list_chat_app_connectors` | The chat apps (`GET /v1/bridges/connectors`) in `en` or `fr`: status (`offered`, `beta`), whether a bridge can be created now, description and setup steps, the secrets the console asks for (by key and label, never a value), the settings form, what a chat id looks like (`targets`), the mention rule, session scopes and helpers | read-only |
152
+ | `thalovant_get_bridge_networks` | Networks offered and the plan's allowance (`GET /v1/bridges/networks`): `limit`, `used`, `remaining`, `paid_plan`, `can_create`, and a `summary` in words. Free plans get 0. With `hubId`, the hub owner's allowance | read-only |
153
+ | `thalovant_list_bridges` | Bridges of one hub (`hubId`) or every bridge the token may see (`ownerId` narrows it; `limit` 1-500) | read-only |
154
+ | `thalovant_get_bridge` | One bridge, with its status in plain words | read-only |
155
+ | `thalovant_list_bridge_chats` | The chats, channels or rooms the bot can see, to choose from; `manual_entry: true` when the ids are typed instead. Changes nothing, but needs `hubs:write` and a paid plan (it asks the network) | read-only, write scope |
156
+ | `thalovant_pause_bridge` | `PATCH paused: true`: stops answering everywhere, keeps chats, settings and token. Always allowed | write |
157
+ | `thalovant_resume_bridge` | `PATCH paused: false`. Needs a paid plan with room in its allowance | write |
158
+ | `thalovant_update_bridge` | The chats it answers in (`allowed`, which replaces the whole list), `requireMention`, `sessionScope`, and non-secret `settings` | write |
159
+ | `thalovant_send_bridge_test_message` | Posts a short "Connected to <hub>." line in one allowed chat | write |
160
+ | `thalovant_delete_bridge` | Deletes the bridge and its stored token; opt-in like the other [destructive tools](#destructive-tools) | destructive |
161
+
162
+ Each bridge comes back with the API's own members (`network`, `connector`, `state`, `allowed`, `require_mention`, `session_scope`, `paused`, `hold`, `bot`, `invite_links`, `secrets_set` by name, `etag`, timestamps, and `operation` when the change queued one) and `status`:
163
+
164
+ - `reason` is the API's customer-safe status, and `summary` says it in words: `Setup` (no chats chosen yet), `Starting`, `Connected`, `Paused`, `Unavailable`, `TokenMissing` and `TokenRejected` (both send the person to **Replace token** in the console). A reason this server does not know yet is kept and named.
165
+ - `phase`, `ready`, and `applied`: `true` once the bridge carries the latest change, `false` while it is on its way, `null` in setup.
166
+ - `hold` says why the platform paused a bridge: `plan_limit` (the plan allows fewer running chat apps, after a downgrade; it resumes on its own once there is room) or `account_suspended`. It is `null` for the owner's own pause.
167
+
168
+ The platform's own diagnostic sentence and condition (`status.message`, `status.operator_reason`, sent to administrators' tokens) are never included. Outputs are built from the members the API documents rather than passed through, so a member added later does not reach the model unreviewed; `settings` and `operation` go through the usual secret redaction.
169
+
170
+ The write tools take an optional `etag` (the bridge's `etag` field; there is no ETag header), sent as `If-Match`: a stale one fails `412` and changes nothing. Telegram lists chats only while its bridge is paused (`409 bridge_running`), so the flow is pause, send `/start@<bot>` in the new group, list, update `allowed`, resume. A refusal keeps the API's code, sentence and fields (see [Tool Errors](#tool-errors)) and adds a hint by code: the settings a `bridge_settings_invalid` or `bridge_url_*` refusal names (names only), the plan's allowance after `402` or `403 plan_limit`, the wait after `429`, and the console for anything about a token.
171
+
172
+ Reads need `hubs:read`, except `thalovant_list_bridge_chats`: it changes nothing, but it asks the chat network on the bot's behalf, so it needs `hubs:write`, the hub's owner or a workspace manager, and a paid plan, like the writes. Writes need the same, except pausing and deleting, which always work on any plan. `THALOVANT_MCP_READONLY` keeps the four reads a `hubs:read` token can call and leaves `thalovant_list_bridge_chats` out.
173
+
142
174
  ## Local Stdio
143
175
 
144
176
  The server speaks MCP over stdio and does not write logs to stdout.
@@ -412,11 +444,24 @@ Hub skills, acting on the hub’s shared runtime (see [Hub Skills](#hub-skills);
412
444
  - `thalovant_update_hub_skill`
413
445
  - `thalovant_remove_hub_skill`
414
446
 
447
+ Chat apps (see [Chat Apps](#chat-apps); the first five are read-only, and `thalovant_list_bridge_chats` needs `hubs:write`):
448
+
449
+ - `thalovant_list_chat_app_connectors`
450
+ - `thalovant_get_bridge_networks`
451
+ - `thalovant_list_bridges`
452
+ - `thalovant_get_bridge`
453
+ - `thalovant_list_bridge_chats`
454
+ - `thalovant_pause_bridge`
455
+ - `thalovant_resume_bridge`
456
+ - `thalovant_update_bridge`
457
+ - `thalovant_send_bridge_test_message`
458
+
415
459
  Destructive, **not registered unless explicitly enabled** (see [Destructive Tools](#destructive-tools)):
416
460
 
417
461
  - `thalovant_delete_hub`
418
462
  - `thalovant_delete_runtime_group`
419
463
  - `thalovant_delete_client`
464
+ - `thalovant_delete_bridge`
420
465
 
421
466
  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`.
422
467
 
@@ -467,6 +512,8 @@ fields: {"allowed_images":{"bus":[...],"core":[...]},"allowed_repositories":{"co
467
512
  echoes back from the request -- each entry's `input` in a `detail` or
468
513
  `errors` list, whatever its key or shape -- is replaced with `"[omitted]"`
469
514
  there and never reaches the first line. A plan refusal carries `resource`, `limit`, `used` and `plan` here.
515
+ A chat app refusal about settings or secrets carries the API's own `fields`
516
+ list here: their names, never their values. Its hint repeats the names.
470
517
 
471
518
  A hint may follow for statuses that need one. A `platform_image_required` or
472
519
  `plan_limit` 403 gets its own hint rather than the general scope hint.
@@ -500,7 +547,7 @@ or use an SDK that preserves large JSON integers.
500
547
 
501
548
  ## Destructive Tools
502
549
 
503
- `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.
550
+ `thalovant_delete_hub`, `thalovant_delete_runtime_group`, `thalovant_delete_client` and `thalovant_delete_bridge` 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.
504
551
 
505
552
  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:
506
553
 
@@ -520,7 +567,7 @@ export MCP_TOOL_DENYLIST="thalovant_delete_hub,thalovant_delete_runtime_group"
520
567
 
521
568
  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.
522
569
 
523
- 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.
570
+ 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. Deleting a chat app bridge removes its stored token, so connecting the bot again means pasting the token in the console; to stop a bridge for now, pause it instead.
524
571
 
525
572
  ## Non-Catalog Skill Sources
526
573
 
@@ -534,7 +581,7 @@ With the flag unset, a call with any `sourceType` other than `catalog` fails bef
534
581
 
535
582
  ## Read-Only Mode
536
583
 
537
- 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`.
584
+ 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), less those the API serves only to a write scope: `thalovant_list_bridge_chats` changes nothing but needs `hubs:write`, which an observe-only agent's token does not carry. 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`.
538
585
 
539
586
  ## HiveMind Runtime Compatibility
540
587
 
@@ -542,7 +589,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
542
589
  MCP process. Use a distinct client identity for each independently running MCP
543
590
  server so the hub can keep their sessions separate.
544
591
 
545
- Version 0.6.6 requires `@thalovant/sdk` `^0.9.9` (0.9.9 through versions below 0.10.0). Since 0.5.1 the server enforces
592
+ Version 0.7.0 requires `@thalovant/sdk` `^0.10.0` (0.10.0 through versions below 0.11.0), which carries the chat app methods and `ThalovantApiError.fields`. Since 0.5.1 the server enforces
546
593
  secure effective MQTT URLs and carries a single connection deadline through
547
594
  MQTT setup and HTTP failure cleanup. Runtime tools support
548
595
  HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
@@ -622,7 +669,7 @@ one tool for every SDK conversation or event-listener method.
622
669
 
623
670
  ### Shared-runtime skill management
624
671
 
625
- The catalog has 50 default tools, 24 in read-only mode, and 53 with destructive tools enabled.
672
+ The catalog has 59 default tools, 28 in read-only mode, and 63 with destructive tools enabled.
626
673
 
627
674
  Hub-addressed skill methods select the runtime group attached to the hub UUID.
628
675
  Every hub sharing that group sees the same skill changes and history. The API
@@ -0,0 +1,46 @@
1
+ import type { Bridge, BridgeChatList, BridgeNetworks, ChatAppConnector } from "@thalovant/sdk";
2
+ /**
3
+ * What a model reads about chat apps: connectors, bridges, the allowance and
4
+ * the hints after a refusal.
5
+ *
6
+ * A bridge's token and secrets never reach this server: the API never sends
7
+ * them, and no tool takes them. Each view below is still built from a list of
8
+ * the members the API documents, never by passing the body through, so a
9
+ * member the API adds later (or the platform-only `status.message` and
10
+ * `status.operator_reason`, which an administrator's token is sent) does not
11
+ * reach the model unreviewed. The owner's free-form values -- `settings` and a
12
+ * queued `operation` -- go through the server's secret redaction. Redacting
13
+ * the whole view by key name would hide `secrets_set` and `secrets`, which are
14
+ * names, and `token_updated_at`, which is a time, so it is not done.
15
+ */
16
+ export type ChatAppLanguage = "en" | "fr";
17
+ type Redact = (value: unknown) => unknown;
18
+ /** Where a person creates a bridge or pastes its token and secrets. */
19
+ export declare const CONSOLE_CHAT_APPS = "the Thalovant console (Connections > Add connection > Chat apps, or the hub's Chat apps tab)";
20
+ /** Where a person replaces a bridge's token or secrets. */
21
+ export declare const CONSOLE_REPLACE_TOKEN = "the Thalovant console: open the bot from Connections or the hub's Chat apps tab and use Replace token";
22
+ /** One connector as a model reads it, in one language. */
23
+ export declare function connectorView(connector: ChatAppConnector, language: ChatAppLanguage, redact: Redact): Record<string, unknown>;
24
+ export declare function connectorsOutput(connectors: readonly ChatAppConnector[], language: ChatAppLanguage, redact: Redact): Record<string, unknown>;
25
+ /**
26
+ * A bridge's status in words, from the API's customer-safe `status.reason`,
27
+ * its `hold` and `status.applied`. Never the platform's own message.
28
+ */
29
+ export declare function bridgeStatusSummary(bridge: Pick<Bridge, "status" | "hold" | "paused">): string;
30
+ /** One bridge as a model reads it: the API's members, without the platform-only status fields. */
31
+ export declare function bridgeView(bridge: Bridge, redact: Redact): Record<string, unknown>;
32
+ export declare function networksOutput(networks: BridgeNetworks, redact: Redact): Record<string, unknown>;
33
+ export declare function chatsOutput(list: BridgeChatList, redact: Redact): Record<string, unknown>;
34
+ /**
35
+ * Setting names a tool refuses to send because they look like a token or
36
+ * secret, at any depth, as dotted paths (`server.auth.token`, `hosts.0.token`).
37
+ * Only names are returned, never values.
38
+ */
39
+ export declare function secretLookingSettings(settings: Record<string, unknown> | undefined, isSecretKey: (key: string) => boolean): string[];
40
+ export declare function secretSettingsRefusal(names: readonly string[]): string;
41
+ /**
42
+ * Guidance after a refused chat app call, by the API's code first and then
43
+ * its status. Undefined when there is nothing a hint could add.
44
+ */
45
+ export declare function bridgeErrorHint(error: unknown): string | undefined;
46
+ export {};
@@ -0,0 +1,346 @@
1
+ import { ThalovantApiError, ThalovantApiUnreachableError } from "@thalovant/sdk";
2
+ /** Where a person creates a bridge or pastes its token and secrets. */
3
+ export const CONSOLE_CHAT_APPS = "the Thalovant console (Connections > Add connection > Chat apps, or the hub's Chat apps tab)";
4
+ /** Where a person replaces a bridge's token or secrets. */
5
+ export const CONSOLE_REPLACE_TOKEN = "the Thalovant console: open the bot from Connections or the hub's Chat apps tab and use Replace token";
6
+ const NO_SECRETS_HERE = "This server never takes a token or secret.";
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function text(value, language) {
11
+ if (!value)
12
+ return null;
13
+ return language === "fr" && value.fr ? value.fr : value.en;
14
+ }
15
+ function strings(value) {
16
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
17
+ }
18
+ /** The settings form a connector's JSON Schema describes, one entry per setting. */
19
+ function settingFields(schema, language) {
20
+ if (!isRecord(schema) || !isRecord(schema.properties))
21
+ return [];
22
+ const required = new Set(strings(schema.required));
23
+ return Object.entries(schema.properties).map(([key, raw]) => {
24
+ const property = isRecord(raw) ? raw : {};
25
+ const french = language === "fr" && typeof property["x-title-fr"] === "string" ? property["x-title-fr"] : undefined;
26
+ const field = {
27
+ key,
28
+ title: french ?? (typeof property.title === "string" ? property.title : key),
29
+ required: required.has(key),
30
+ };
31
+ for (const name of ["type", "format", "description", "pattern", "enum", "default", "minLength", "maxLength"]) {
32
+ if (property[name] !== undefined)
33
+ field[name] = property[name];
34
+ }
35
+ return field;
36
+ });
37
+ }
38
+ /** One connector as a model reads it, in one language. */
39
+ export function connectorView(connector, language, redact) {
40
+ return {
41
+ id: connector.id,
42
+ name: connector.name,
43
+ status: connector.status,
44
+ offered: connector.offered,
45
+ version: connector.version?.version ?? null,
46
+ description: text(connector.description, language),
47
+ setup_steps: text(connector.setup_steps, language),
48
+ // What the console asks the person for: names and labels, never a value.
49
+ secrets: (connector.secrets ?? []).map((secret) => ({
50
+ key: secret.key,
51
+ label: text(secret.label, language),
52
+ description: text(secret.description, language),
53
+ required: secret.required,
54
+ })),
55
+ settings: redact(settingFields(connector.config_schema, language)),
56
+ targets: connector.targets
57
+ ? {
58
+ label: text(connector.targets.label, language),
59
+ pattern: connector.targets.pattern,
60
+ max: connector.targets.max,
61
+ lowercase: connector.targets.lowercase,
62
+ strip_prefix: connector.targets.strip_prefix,
63
+ }
64
+ : null,
65
+ require_mention_fixed: connector.options?.require_mention_fixed ?? false,
66
+ session_scopes: [...(connector.options?.session_scopes ?? [])],
67
+ helpers: [...(connector.helpers ?? [])],
68
+ };
69
+ }
70
+ export function connectorsOutput(connectors, language, redact) {
71
+ return {
72
+ language,
73
+ data: connectors.map((connector) => connectorView(connector, language, redact)),
74
+ next: `Create a bridge, and paste or replace its token or secrets, in ${CONSOLE_CHAT_APPS}. ${NO_SECRETS_HERE} Once it exists, thalovant_list_bridges finds it and the other chat app tools manage it.`,
75
+ };
76
+ }
77
+ function holdSentence(hold) {
78
+ switch (hold.reason) {
79
+ case "plan_limit":
80
+ return `The platform paused it on ${hold.since} because the plan allows fewer running chat apps than the account has (after a downgrade). It resumes on its own, oldest first, once the plan has room; thalovant_resume_bridge resumes it sooner when thalovant_get_bridge_networks shows room.`;
81
+ case "account_suspended":
82
+ return `The platform paused it on ${hold.since} because the account was deactivated; it resumes when the account is reactivated.`;
83
+ default:
84
+ return `The platform paused it on ${hold.since} (${hold.reason}).`;
85
+ }
86
+ }
87
+ /**
88
+ * A bridge's status in words, from the API's customer-safe `status.reason`,
89
+ * its `hold` and `status.applied`. Never the platform's own message.
90
+ */
91
+ export function bridgeStatusSummary(bridge) {
92
+ const status = bridge.status;
93
+ const reason = status?.reason;
94
+ const hold = bridge.hold ?? null;
95
+ const parts = [];
96
+ switch (reason) {
97
+ case "Setup":
98
+ parts.push("Setting up: no chats are chosen yet, so it does not run. Choose where it answers with thalovant_update_bridge (allowed); thalovant_list_bridge_chats lists the chats it can see.");
99
+ break;
100
+ case "Starting":
101
+ parts.push("Starting: the bridge is on its way or has not reported yet, usually for a minute or two.");
102
+ break;
103
+ case "Connected":
104
+ parts.push("Connected: the bridge is running and answers in its allowed chats.");
105
+ break;
106
+ case "Paused":
107
+ parts.push(hold ? "Paused." : "Paused by its owner: it answers nowhere until thalovant_resume_bridge.");
108
+ break;
109
+ case "Unavailable":
110
+ parts.push("Unavailable: the bridge is deployed but not running, or the platform hit an error. Nothing on the owner's side needs to change; check again later.");
111
+ break;
112
+ case "TokenMissing":
113
+ parts.push(`Token missing: the bridge's token has not reached it. If this lasts more than a few minutes, paste the token again in ${CONSOLE_REPLACE_TOKEN}.`);
114
+ break;
115
+ case "TokenRejected":
116
+ parts.push(`Token rejected: the chat network refused the bridge's credentials. Paste new ones in ${CONSOLE_REPLACE_TOKEN}. ${NO_SECRETS_HERE}`);
117
+ break;
118
+ default:
119
+ parts.push(`${reason ?? "Unknown"}: a status this server does not know yet (phase ${status?.phase ?? "unknown"}).`);
120
+ }
121
+ if (hold)
122
+ parts.push(holdSentence(hold));
123
+ else if (bridge.paused && reason === "Setup")
124
+ parts.push("It is paused too, so choosing chats will not start it until thalovant_resume_bridge.");
125
+ else if (bridge.paused && reason !== "Paused")
126
+ parts.push("It is paused; stopping it may still be on its way.");
127
+ else if (bridge.paused === false && reason === "Paused")
128
+ parts.push("It is no longer paused; starting again may still be on its way.");
129
+ if (status?.applied === true)
130
+ parts.push("Every change is applied.");
131
+ else if (status?.applied === false)
132
+ parts.push("A change is still on its way to the bridge.");
133
+ return parts.join(" ");
134
+ }
135
+ /** One bridge as a model reads it: the API's members, without the platform-only status fields. */
136
+ export function bridgeView(bridge, redact) {
137
+ const status = bridge.status;
138
+ return {
139
+ id: bridge.id,
140
+ hub_id: bridge.hub_id,
141
+ owner_id: bridge.owner_id,
142
+ network: bridge.network,
143
+ connector: bridge.connector
144
+ ? { id: bridge.connector.id, name: bridge.connector.name, version: bridge.connector.version }
145
+ : null,
146
+ state: bridge.state,
147
+ paused: bridge.paused,
148
+ hold: bridge.hold ? { reason: bridge.hold.reason, since: bridge.hold.since } : null,
149
+ status: {
150
+ reason: status?.reason ?? null,
151
+ phase: status?.phase ?? null,
152
+ ready: status?.ready ?? false,
153
+ applied: status?.applied ?? null,
154
+ summary: bridgeStatusSummary(bridge),
155
+ },
156
+ allowed: (bridge.allowed ?? []).map((chat) => ({
157
+ id: chat.id,
158
+ title: chat.title,
159
+ kind: chat.kind,
160
+ guild_id: chat.guild_id,
161
+ guild_name: chat.guild_name,
162
+ })),
163
+ require_mention: bridge.require_mention,
164
+ session_scope: bridge.session_scope,
165
+ settings: redact(bridge.settings ?? {}),
166
+ // Which secrets are stored, by name; their values are never sent.
167
+ secrets_set: strings(bridge.secrets_set),
168
+ bot: bridge.bot
169
+ ? {
170
+ user_id: bridge.bot.user_id,
171
+ username: bridge.bot.username,
172
+ display_name: bridge.bot.display_name,
173
+ application_id: bridge.bot.application_id,
174
+ avatar_url: bridge.bot.avatar_url,
175
+ }
176
+ : null,
177
+ invite_links: bridge.invite_links
178
+ ? { add_to_group: bridge.invite_links.add_to_group, direct_chat: bridge.invite_links.direct_chat }
179
+ : null,
180
+ etag: bridge.etag,
181
+ token_updated_at: bridge.token_updated_at,
182
+ activated_at: bridge.activated_at,
183
+ created_at: bridge.created_at,
184
+ updated_at: bridge.updated_at,
185
+ operation: bridge.operation ? redact(bridge.operation) : null,
186
+ };
187
+ }
188
+ function allowanceSummary(allowance) {
189
+ if (!allowance.paid_plan) {
190
+ return `The ${allowance.plan} plan is not a paid plan, and chat apps need one: Free plans get 0, and creating one answers 402. Pausing and deleting a bridge always work.`;
191
+ }
192
+ if (allowance.limit === 0) {
193
+ return `The ${allowance.plan} plan includes no chat apps.`;
194
+ }
195
+ const room = allowance.can_create
196
+ ? `A new one would pass the plan checks (create it in ${CONSOLE_CHAT_APPS}).`
197
+ : "A new one would not pass the plan checks now.";
198
+ return `The ${allowance.plan} plan: ${allowance.used} of ${allowance.limit} chat apps used (set up or running), ${allowance.remaining} left. ${room}`;
199
+ }
200
+ export function networksOutput(networks, redact) {
201
+ return {
202
+ networks: (networks.networks ?? []).map((offer) => ({
203
+ network: offer.network,
204
+ name: offer.name,
205
+ status: offer.status,
206
+ offered: offer.offered,
207
+ })),
208
+ allowance: redact({ ...networks.allowance }),
209
+ summary: allowanceSummary(networks.allowance),
210
+ };
211
+ }
212
+ export function chatsOutput(list, redact) {
213
+ return {
214
+ ...redact({
215
+ network: list.network,
216
+ manual_entry: list.manual_entry,
217
+ note: list.note,
218
+ data: (list.data ?? []).map((chat) => ({
219
+ id: chat.id,
220
+ title: chat.title,
221
+ kind: chat.kind,
222
+ guild_id: chat.guild_id,
223
+ guild_name: chat.guild_name,
224
+ guild_icon_url: chat.guild_icon_url,
225
+ allowed: chat.allowed,
226
+ encrypted: chat.encrypted,
227
+ selectable: chat.selectable,
228
+ })),
229
+ }),
230
+ next: list.manual_entry
231
+ ? "This chat app cannot list its chats: type each id yourself, matching targets.pattern from thalovant_list_chat_app_connectors, in allowed for thalovant_update_bridge."
232
+ : "Pass the chosen rows (id, title, kind, guild_id, guild_name) as allowed to thalovant_update_bridge. allowed replaces the whole list, so keep the chats the bridge already answers in (allowed: true). A row with selectable false cannot be chosen.",
233
+ };
234
+ }
235
+ /**
236
+ * Setting names a tool refuses to send because they look like a token or
237
+ * secret, at any depth, as dotted paths (`server.auth.token`, `hosts.0.token`).
238
+ * Only names are returned, never values.
239
+ */
240
+ export function secretLookingSettings(settings, isSecretKey) {
241
+ const found = [];
242
+ const visit = (value, path) => {
243
+ if (Array.isArray(value)) {
244
+ value.forEach((item, index) => visit(item, `${path}.${index}`));
245
+ return;
246
+ }
247
+ if (!isRecord(value))
248
+ return;
249
+ for (const [key, nested] of Object.entries(value)) {
250
+ const here = path ? `${path}.${key}` : key;
251
+ if (isSecretKey(key))
252
+ found.push(here);
253
+ else
254
+ visit(nested, here);
255
+ }
256
+ };
257
+ visit(settings, "");
258
+ return found;
259
+ }
260
+ export function secretSettingsRefusal(names) {
261
+ return `Nothing was sent: settings never carry a token or secret, and these setting names look like one: ${names.join(", ")}. Paste tokens and secrets in ${CONSOLE_REPLACE_TOKEN}. ${NO_SECRETS_HERE}`;
262
+ }
263
+ function named(error, what) {
264
+ const names = error.fields ?? [];
265
+ return names.length ? ` The API named the ${what}: ${names.join(", ")} (values are never repeated).` : "";
266
+ }
267
+ /**
268
+ * Guidance after a refused chat app call, by the API's code first and then
269
+ * its status. Undefined when there is nothing a hint could add.
270
+ */
271
+ export function bridgeErrorHint(error) {
272
+ if (error instanceof ThalovantApiUnreachableError) {
273
+ return "The Thalovant API could not be reached, which says nothing about the bridge: a change may have been made all the same. Read the bridge with thalovant_get_bridge before trying again.";
274
+ }
275
+ if (!(error instanceof ThalovantApiError))
276
+ return undefined;
277
+ const wait = error.retryAfterSeconds !== undefined ? ` Wait ${error.retryAfterSeconds} seconds before trying again.` : " Wait before trying again.";
278
+ switch (error.code) {
279
+ case "bridge_running":
280
+ return "Telegram lists chats only while the bridge is paused, because the running bridge reads the bot's messages itself. Pause it with thalovant_pause_bridge, send /start@<bot username> in the new group, list the chats, choose them with thalovant_update_bridge, then resume it with thalovant_resume_bridge. A chat id you already know can be set without listing.";
281
+ case "bridge_telegram_updates_busy":
282
+ return "Telegram itself refused the listing: another program or a webhook is reading this bot's messages. Stop it, or type the chat id.";
283
+ case "bridge_helper_unavailable":
284
+ return "This chat app has no test message. Mention the bot in one of its allowed chats instead.";
285
+ case "bridge_token_missing":
286
+ case "bridge_token_malformed":
287
+ case "bridge_token_invalid":
288
+ case "bridge_token_not_a_bot":
289
+ case "bridge_token_other_bot":
290
+ case "bridge_bot_in_use":
291
+ case "bridge_secrets_invalid":
292
+ return `This is about the bridge's token or secrets, which only ${CONSOLE_CHAT_APPS} takes. ${NO_SECRETS_HERE}${named(error, "secrets")}`;
293
+ case "bridge_network_refused":
294
+ return "The chat network refused the request, for example because the bot was removed from that chat; detail gives its reason. Add the bot back, or choose another chat.";
295
+ case "bridge_allowed_required":
296
+ return "allowed cannot be empty. To stop the bridge answering, pause it with thalovant_pause_bridge.";
297
+ case "bridge_chat_id_invalid":
298
+ return "A chat id does not match this chat app's format: see targets.pattern in thalovant_list_chat_app_connectors, or pick ids from thalovant_list_bridge_chats.";
299
+ case "bridge_chat_not_allowed":
300
+ return "A test message goes only to a chat in the bridge's allowed list. Pick one from thalovant_get_bridge, or add the chat with thalovant_update_bridge first (allowed replaces the whole list).";
301
+ case "bridge_settings_invalid":
302
+ return `A setting is unknown, missing or outside the chat app's settings form (settings in thalovant_list_chat_app_connectors).${named(error, "settings")} Tokens and secrets are never settings: they go in ${CONSOLE_CHAT_APPS}.`;
303
+ case "bridge_url_invalid":
304
+ case "bridge_url_port_not_allowed":
305
+ case "bridge_url_not_public":
306
+ case "bridge_url_unresolvable":
307
+ return `An address setting must be https on port 443, with a public host name (no IP address, no local or internal name, no user name, query or fragment) that resolves to public addresses only.${named(error, "settings")}`;
308
+ case "bridge_too_many_targets":
309
+ return "More chats than this chat app allows: see targets.max in thalovant_list_chat_app_connectors.";
310
+ case "bridge_mention_required":
311
+ return "A Discord bridge answers only messages that mention it or reply to it, so requireMention cannot be false.";
312
+ case "bridge_rate_limited":
313
+ return `Too many test messages or chat listings for this bridge.${wait}`;
314
+ case "bridge_network_rate_limited":
315
+ return `The chat network is limiting this bot.${wait}`;
316
+ case "bridge_network_unavailable":
317
+ case "bridge_network_timeout":
318
+ return "The chat network could not be reached or did not answer in time. Try again later.";
319
+ case "bridge_network_not_offered":
320
+ return "This chat app is not offered on this API right now; thalovant_get_bridge_networks says which are.";
321
+ case "bridge_secret_store_failed":
322
+ case "bridge_secret_store_unavailable":
323
+ return "The platform could not reach the store that keeps bridge tokens. Try again later.";
324
+ case "plan_limit":
325
+ return "The plan's chat-app allowance is used (resource, limit and used are in the fields above). Resuming needs room in it: pause or delete another bridge, or move to a larger plan; thalovant_get_bridge_networks shows the allowance. Pausing is always allowed, and granting a scope will not help.";
326
+ default:
327
+ break;
328
+ }
329
+ switch (error.statusCode) {
330
+ case 401:
331
+ case 423:
332
+ return "The API refused the token itself: it is unknown, expired or revoked (401), or the account is locked (423). Sign in again with thalovant_begin_device_login, or configure a valid token.";
333
+ case 402:
334
+ return "The hub's owner is on a plan without chat apps (Free plans get 0). Pausing and deleting a bridge always work; anything else needs a paid plan, and granting a scope will not help.";
335
+ case 403:
336
+ return "Reading chat apps needs the hubs:read scope, and changing them hubs:write. Listing a bridge's chats (thalovant_list_bridge_chats) also needs hubs:write and a paid plan, because it asks the chat network. A 403 can also mean the token's account does not manage this bridge (only the hub's owner or a workspace manager may change it or list its chats), or that the owner's account is deactivated.";
337
+ case 404:
338
+ return "No bridge with this id is visible to this token: a bridge is visible to its hub's owner, the workspace's members above basic, and platform administrators. thalovant_list_bridges lists the visible ones. An API without chat apps answers 404 on every bridge route.";
339
+ case 412:
340
+ return "ETag mismatch: the bridge changed after its etag was read, and nothing changed now. Read it again with thalovant_get_bridge and retry with its new etag field, or leave etag out to change the bridge as it is now.";
341
+ case 429:
342
+ return `Too many requests.${wait}`;
343
+ default:
344
+ return undefined;
345
+ }
346
+ }
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 { bridgeErrorHint, bridgeView, chatsOutput, connectorsOutput, CONSOLE_CHAT_APPS, CONSOLE_REPLACE_TOKEN, networksOutput, secretLookingSettings, secretSettingsRefusal, } from "./chat-apps-output.js";
3
4
  import { DeviceLogins } from "./device-login.js";
4
5
  import { inventoryPresentation } from "./inventory-output.js";
5
6
  import { runtimeReplyContent } from "./reply-output.js";
@@ -129,7 +130,12 @@ const runtimeAuthSchema = {
129
130
  * they are absent from tools/list entirely rather than merely failing when
130
131
  * called. See destructiveToolsEnabled().
131
132
  */
132
- const DESTRUCTIVE_TOOLS = ["thalovant_delete_hub", "thalovant_delete_runtime_group", "thalovant_delete_client"];
133
+ const DESTRUCTIVE_TOOLS = [
134
+ "thalovant_delete_hub",
135
+ "thalovant_delete_runtime_group",
136
+ "thalovant_delete_client",
137
+ "thalovant_delete_bridge",
138
+ ];
133
139
  /**
134
140
  * Device sign-ins in progress, and the tokens approved ones minted, per
135
141
  * principal, for the life of this process. See device-login.ts.
@@ -184,6 +190,15 @@ function isCatalogSource(sourceType) {
184
190
  function readOnlyModeEnabled() {
185
191
  return parseBool(process.env.THALOVANT_MCP_READONLY, false);
186
192
  }
193
+ /**
194
+ * Tools that change nothing (readOnlyHint: true) but that the API serves only
195
+ * to a write scope, so read-only mode leaves them out as well: an observe-only
196
+ * agent runs on a read-only token, which the API would refuse with 403.
197
+ * thalovant_list_bridge_chats asks the chat network on the bot's behalf, and
198
+ * the API answers it only for hubs:write, the hub's owner or a manager, and a
199
+ * paid plan.
200
+ */
201
+ const WRITE_SCOPE_READ_TOOLS = new Set(["thalovant_list_bridge_chats"]);
187
202
  const capacityProfileSchema = z
188
203
  .enum(["standard", "autoscaling"])
189
204
  .describe('Hub capacity profile. Only "standard" (the API default, 1 replica) and "autoscaling" (2-32 replicas) are accepted. Omit the key entirely rather than sending null — an explicit null fails 422 INVALID_HUB_CAPACITY_PROFILE. "autoscaling" is plan-gated: 403 HUB_AUTOSCALING_NOT_INCLUDED when the plan has no autoscaling, 409 AUTOSCALING_HUB_LIMIT_REACHED when its autoscaling slots are already used.');
@@ -195,6 +210,24 @@ const hubEtagSchema = z
195
210
  .string()
196
211
  .min(1)
197
212
  .describe("Required. The hub's current etag, sent as If-Match for optimistic locking. Read it from the `etag` field in the hub resource BODY returned by thalovant_get_hub — the API does not send an ETag response header, so you must fetch the hub first. A missing or stale value fails with HTTP 412 and changes nothing: re-fetch the hub, take the new etag, and retry.");
213
+ const bridgeIdSchema = z.string().uuid().describe("Bridge UUID: the id from thalovant_list_bridges.");
214
+ const bridgeEtagSchema = z
215
+ .string()
216
+ .min(1)
217
+ .max(200)
218
+ .optional()
219
+ .describe("Optional. The bridge's etag, read from the `etag` field of thalovant_get_bridge (the API sends no ETag header), sent as If-Match: a bridge changed since fails with HTTP 412 and nothing changes. Leave it out to change the bridge as it is now.");
220
+ /** A chat, channel or room id as the API accepts one; each chat app's targets.pattern narrows it. */
221
+ const bridgeChatIdSchema = z.string().regex(/^[^\s,]{1,255}$/, "A chat id is 1-255 characters with no spaces or commas.");
222
+ // The display members are nullable because a bridge's own allowed entries,
223
+ // which a model is told to keep, carry null where the API has nothing.
224
+ const bridgeAllowedChatSchema = z.object({
225
+ id: bridgeChatIdSchema.describe("The chat, channel or room id: a row's id from thalovant_list_bridge_chats, or typed to match the chat app's targets.pattern."),
226
+ title: z.string().max(200).nullable().optional().describe("The chat's name, for display only."),
227
+ kind: z.string().max(32).nullable().optional().describe("The row's kind, for display only."),
228
+ guild_id: z.string().regex(/^[0-9]{1,20}$/).nullable().optional().describe("Discord: the server the channel belongs to."),
229
+ guild_name: z.string().max(100).nullable().optional().describe("Discord: the server's name."),
230
+ });
198
231
  const runtimeGroupResourceLimitsSchema = z
199
232
  .object({
200
233
  requests: z.record(z.string(), z.string()).optional(),
@@ -293,6 +326,14 @@ function withConnectionHint(error, connectionType) {
293
326
  const said = apiErrorText(error, redactSecrets) ?? (error instanceof Error ? error.message : String(error));
294
327
  throw new Error(`${said}\n\n${hint}`, { cause: error });
295
328
  }
329
+ /** Rethrow a refused chat app call with its guidance (see chat-apps-output.ts), when it has any. */
330
+ function withBridgeHint(error) {
331
+ const hint = bridgeErrorHint(error);
332
+ if (!hint)
333
+ throw error;
334
+ const said = apiErrorText(error, redactSecrets) ?? (error instanceof Error ? error.message : String(error));
335
+ throw new Error(`${said}\n\n${hint}`, { cause: error });
336
+ }
296
337
  /**
297
338
  * Run a control-plane call and, on failure, append actionable guidance.
298
339
  *
@@ -610,8 +651,9 @@ async function auditLog(event) {
610
651
  function registerThalovantTool(server, name, config, handler) {
611
652
  if (readOnlyModeEnabled()) {
612
653
  const annotations = (config.annotations ?? {});
613
- if (annotations.readOnlyHint !== true) {
614
- // Read-only mode: do not register write/destructive tools at all.
654
+ if (annotations.readOnlyHint !== true || WRITE_SCOPE_READ_TOOLS.has(name)) {
655
+ // Read-only mode: do not register write/destructive tools at all, nor
656
+ // the reads only a write scope may call.
615
657
  return;
616
658
  }
617
659
  }
@@ -658,6 +700,11 @@ function clampTimeout(timeoutMs) {
658
700
  return DEFAULT_TIMEOUT_MS;
659
701
  return Math.min(Math.max(timeoutMs, 1_000), MAX_TIMEOUT_MS);
660
702
  }
703
+ /** Whether a member name looks like it holds a credential (see SECRET_KEYS). */
704
+ function isSecretKeyName(key) {
705
+ const normalized = key.toLowerCase();
706
+ return SECRET_KEYS.some((secretKey) => normalized.includes(secretKey.toLowerCase()));
707
+ }
661
708
  function redactSecrets(value) {
662
709
  if (Array.isArray(value)) {
663
710
  return value.map((item) => redactSecrets(item));
@@ -667,9 +714,7 @@ function redactSecrets(value) {
667
714
  }
668
715
  const output = {};
669
716
  for (const [key, nestedValue] of Object.entries(value)) {
670
- const normalized = key.toLowerCase();
671
- const isSecret = SECRET_KEYS.some((secretKey) => normalized.includes(secretKey.toLowerCase()));
672
- output[key] = isSecret ? "[redacted]" : redactSecrets(nestedValue);
717
+ output[key] = isSecretKeyName(key) ? "[redacted]" : redactSecrets(nestedValue);
673
718
  }
674
719
  return output;
675
720
  }
@@ -2531,7 +2576,150 @@ export function createServer() {
2531
2576
  const result = await callControlPlane("write", () => api.removeHubSkill(hubId, skill, options));
2532
2577
  return jsonContent(redactSecrets({ ...result, operation: result.operation ?? null }));
2533
2578
  });
2579
+ // Chat apps (/v1/bridges): a hub answering in Telegram, Discord and the
2580
+ // other chat networks. Creating a bridge and replacing its token or secrets
2581
+ // take a credential, which no tool of this server accepts, so those stay in
2582
+ // the console; see chat-apps-output.ts for what each output shows.
2583
+ registerThalovantTool(server, "thalovant_list_chat_app_connectors", {
2584
+ title: "List Chat App Connectors",
2585
+ description: `List the chat apps a hub can answer in (GET /v1/bridges/connectors): Telegram and Discord, and Matrix, Mattermost or Twitch when they are listed. Each has status (offered, or beta), offered (whether a bridge can be created for it now), its description and setup steps in the language asked (English where there is no French), the secrets the console asks for (key, label, required: names only, never a value), the settings form, what a chat id looks like (targets: label, pattern, max, lowercase, strip_prefix), require_mention_fixed, the session scopes, and its helpers: with discover, thalovant_list_bridge_chats lists the chats (otherwise ids are typed); with test_message, thalovant_send_bridge_test_message works. Creating a bridge, and pasting or replacing its token or secrets, happen only in ${CONSOLE_CHAT_APPS}: no tool takes a token or secret. Requires the hubs:read scope. Reads only.`,
2586
+ inputSchema: {
2587
+ ...controlPlaneSchema,
2588
+ language: z.enum(["en", "fr"]).default("en").describe("Language for descriptions, setup steps and labels: en or fr. French falls back to English where a text has no French."),
2589
+ },
2590
+ annotations: { readOnlyHint: true, openWorldHint: true },
2591
+ }, async ({ language, ...auth }) => {
2592
+ const api = await createControlPlane(auth);
2593
+ ensureAuthenticated(api);
2594
+ const connectors = await api.listChatAppConnectors().catch(withBridgeHint);
2595
+ return jsonContent(connectorsOutput(connectors, language, redactSecrets));
2596
+ });
2597
+ registerThalovantTool(server, "thalovant_get_bridge_networks", {
2598
+ title: "Get Chat App Allowance",
2599
+ description: "Show which chat networks are offered and the plan's chat-app allowance (GET /v1/bridges/networks): networks (network, name, status offered or beta, offered) and allowance (plan, paid_plan, limit, used: bridges set up or running, remaining, can_create: whether a new bridge would pass the plan checks, saying nothing about a token). Free plans get 0. With hubId the allowance is the hub owner's, since bridges count against the owner; otherwise the caller's. summary says it in words. Read it before suggesting a new chat app or resuming one the plan paused. Requires the hubs:read scope. Reads only.",
2600
+ inputSchema: {
2601
+ ...controlPlaneSchema,
2602
+ hubId: z.string().uuid().optional().describe("Hub UUID: report the allowance of this hub's owner."),
2603
+ },
2604
+ annotations: { readOnlyHint: true, openWorldHint: true },
2605
+ }, async ({ hubId, ...auth }) => {
2606
+ const api = await createControlPlane(auth);
2607
+ ensureAuthenticated(api);
2608
+ const networks = await api.getBridgeNetworks({ hubId }).catch(withBridgeHint);
2609
+ return jsonContent(networksOutput(networks, redactSecrets));
2610
+ });
2611
+ registerThalovantTool(server, "thalovant_list_bridges", {
2612
+ title: "List Chat App Bridges",
2613
+ description: "List chat app bridges (GET /v1/bridges): one hub's with hubId, otherwise every bridge the token may see (its own, and those of workspaces where it is a member above basic); ownerId narrows that to one owner. Each bridge has its network and connector, state (setup or active), the chats it answers in (allowed), require_mention, session_scope, paused, hold (why the platform paused it: plan_limit or account_suspended; null for the owner's own pause), the bot, its invite links, secrets_set (which secrets are stored, by name), its etag, and status: reason (Setup, Starting, Connected, Paused, Unavailable, TokenMissing, TokenRejected), phase, ready, applied (whether the latest change has reached the bridge) and summary, the status in plain words. The platform's own diagnostic message is never included. Requires the hubs:read scope. Reads only.",
2614
+ inputSchema: {
2615
+ ...controlPlaneSchema,
2616
+ hubId: z.string().uuid().optional().describe("Hub UUID: list only this hub's bridges."),
2617
+ ownerId: z.string().uuid().optional().describe("Owner UUID: narrow the visible bridges to this owner's."),
2618
+ limit: z.number().int().min(1).max(500).optional().describe("At most this many bridges, 1-500. The API answers up to 200 when omitted."),
2619
+ },
2620
+ annotations: { readOnlyHint: true, openWorldHint: true },
2621
+ }, async ({ hubId, ownerId, limit, ...auth }) => {
2622
+ const api = await createControlPlane(auth);
2623
+ ensureAuthenticated(api);
2624
+ const bridges = await api.listBridges({ hubId, ownerId, limit }).catch(withBridgeHint);
2625
+ return jsonContent({ data: bridges.map((bridge) => bridgeView(bridge, redactSecrets)) });
2626
+ });
2627
+ registerThalovantTool(server, "thalovant_get_bridge", {
2628
+ title: "Get Chat App Bridge",
2629
+ description: "Fetch one chat app bridge (GET /v1/bridges/{id}) with the same fields as thalovant_list_bridges: status.reason, phase, ready, applied and summary (the status in plain words), hold, the chats it answers in, and the etag the write tools accept. The platform's own diagnostic message is never included. Requires the hubs:read scope. Reads only.",
2630
+ inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
2631
+ annotations: { readOnlyHint: true, openWorldHint: true },
2632
+ }, async ({ bridgeId, ...auth }) => {
2633
+ const api = await createControlPlane(auth);
2634
+ ensureAuthenticated(api);
2635
+ return jsonContent(bridgeView(await api.getBridge(bridgeId).catch(withBridgeHint), redactSecrets));
2636
+ });
2637
+ registerThalovantTool(server, "thalovant_list_bridge_chats", {
2638
+ title: "List Bridge Chats",
2639
+ description: "List the chats, channels or rooms a bridge's bot can see (GET /v1/bridges/{id}/chats), to choose from for allowed in thalovant_update_bridge. Each row has id, title, kind, the Discord server (guild_id, guild_name), allowed (it already answers there), and selectable (false for an encrypted room it cannot read). manual_entry true means this chat app cannot list its chats: type the ids instead, matching targets.pattern from thalovant_list_chat_app_connectors. Telegram lists only chats that wrote to the bot in the last 24 hours, and only while the bridge is paused (409 bridge_running otherwise): pause it with thalovant_pause_bridge, send /start@<bot username> in the new group, list, choose with thalovant_update_bridge, then resume. Listing is rate-limited per bridge. It changes nothing, but because it asks the chat network on the bot's behalf it needs the hubs:write scope, a paid plan, and the hub's owner or a workspace manager (403 for a hubs:read token, 402 on a plan without chat apps); THALOVANT_MCP_READONLY leaves it out.",
2640
+ inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
2641
+ annotations: { readOnlyHint: true, openWorldHint: true },
2642
+ }, async ({ bridgeId, ...auth }) => {
2643
+ const api = await createControlPlane(auth);
2644
+ ensureAuthenticated(api);
2645
+ return jsonContent(chatsOutput(await api.listBridgeChats(bridgeId).catch(withBridgeHint), redactSecrets));
2646
+ });
2647
+ registerThalovantTool(server, "thalovant_pause_bridge", {
2648
+ title: "Pause Chat App Bridge",
2649
+ description: "Pause a chat app bridge (PATCH /v1/bridges/{id} with paused: true): it stops answering in every chat until resumed, and keeps its chats, settings and token. Always allowed, whatever the plan. Returns the bridge; status.reason turns Paused once the bridge has stopped. Requires the hubs:write scope, and the hub's owner or a workspace manager.",
2650
+ inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema, etag: bridgeEtagSchema },
2651
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2652
+ }, async ({ bridgeId, etag, ...auth }) => {
2653
+ const api = await createControlPlane(auth);
2654
+ ensureAuthenticated(api);
2655
+ return jsonContent(bridgeView(await api.updateBridge(bridgeId, { paused: true }, { etag }).catch(withBridgeHint), redactSecrets));
2656
+ });
2657
+ registerThalovantTool(server, "thalovant_resume_bridge", {
2658
+ title: "Resume Chat App Bridge",
2659
+ description: "Resume a paused chat app bridge (PATCH /v1/bridges/{id} with paused: false). Needs a paid plan with room for one more running bridge: 402 on a plan without chat apps, 403 plan_limit when its running bridges are already at the limit (thalovant_get_bridge_networks shows the allowance). A bridge paused because the account was deactivated (hold account_suspended) resumes when the account is reactivated. A bridge with no chats yet (status Setup) starts only once thalovant_update_bridge sets allowed. Returns the bridge; status.reason turns Connected once it runs. Requires the hubs:write scope, and the hub's owner or a workspace manager.",
2660
+ inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema, etag: bridgeEtagSchema },
2661
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2662
+ }, async ({ bridgeId, etag, ...auth }) => {
2663
+ const api = await createControlPlane(auth);
2664
+ ensureAuthenticated(api);
2665
+ return jsonContent(bridgeView(await api.updateBridge(bridgeId, { paused: false }, { etag }).catch(withBridgeHint), redactSecrets));
2666
+ });
2667
+ registerThalovantTool(server, "thalovant_update_bridge", {
2668
+ title: "Update Chat App Bridge",
2669
+ description: `Change where and how a chat app bridge answers (PATCH /v1/bridges/{id}); send only what changes, at least one of allowed, requireMention, sessionScope and settings. allowed REPLACES the whole list of chats, channels or rooms the bridge answers in (1-50, never empty: pause it instead): keep the current ones from thalovant_get_bridge, add rows from thalovant_list_bridge_chats, or type ids matching the chat app's targets.pattern. Setting allowed the first time starts a bridge in setup. requireMention false makes it answer every message in its groups, which spends the daily allowance faster; a Discord bridge always needs a mention (422 bridge_mention_required). sessionScope person gives each person a conversation of their own, chat one per chat. settings are the chat app's non-secret settings (its settings form in thalovant_list_chat_app_connectors), merged into the stored ones and checked again; an address must be https on port 443 with a public host. A token or secret is never a setting: a setting name that looks like one is refused here before any request, and tokens and secrets are replaced in ${CONSOLE_REPLACE_TOKEN}. To pause or resume, use thalovant_pause_bridge or thalovant_resume_bridge. Needs a paid plan. Requires the hubs:write scope, and the hub's owner or a workspace manager.`,
2670
+ inputSchema: {
2671
+ ...controlPlaneSchema,
2672
+ bridgeId: bridgeIdSchema,
2673
+ allowed: z.array(bridgeAllowedChatSchema).min(1).max(50).optional().describe("Every chat the bridge should answer in, 1-50; replaces the stored list."),
2674
+ requireMention: z.boolean().optional().describe("Answer only messages that mention the bot or reply to it. Discord bridges are always true."),
2675
+ sessionScope: z.enum(["person", "chat"]).optional().describe("person: each person has a conversation of their own; chat: one conversation per chat."),
2676
+ settings: jsonRecordSchema
2677
+ .refine((value) => Object.keys(value).length <= 32, "At most 32 settings.")
2678
+ .optional()
2679
+ .describe("Non-secret settings to change, at most 32, by the setting keys of the chat app's settings form; merged into the stored ones."),
2680
+ etag: bridgeEtagSchema,
2681
+ },
2682
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2683
+ }, async ({ bridgeId, allowed, requireMention, sessionScope, settings, etag, ...auth }) => {
2684
+ if (allowed === undefined && requireMention === undefined && sessionScope === undefined && settings === undefined) {
2685
+ throw new Error("Nothing to change: pass at least one of allowed, requireMention, sessionScope and settings. To pause or resume, use thalovant_pause_bridge or thalovant_resume_bridge.");
2686
+ }
2687
+ const secretLooking = secretLookingSettings(settings, isSecretKeyName);
2688
+ if (secretLooking.length)
2689
+ throw new Error(secretSettingsRefusal(secretLooking));
2690
+ const api = await createControlPlane(auth);
2691
+ ensureAuthenticated(api);
2692
+ const bridge = await api
2693
+ .updateBridge(bridgeId, { allowed, requireMention, sessionScope, settings }, { etag })
2694
+ .catch(withBridgeHint);
2695
+ return jsonContent(bridgeView(bridge, redactSecrets));
2696
+ });
2697
+ registerThalovantTool(server, "thalovant_send_bridge_test_message", {
2698
+ title: "Send Bridge Test Message",
2699
+ description: "Post a short \"Connected to <hub>.\" message, in the hub's language, in one of a chat app bridge's allowed chats (POST /v1/bridges/{id}/test). Everyone in that chat sees it. chatId must be in the bridge's allowed list (422 bridge_chat_not_allowed otherwise). Only chat apps with the test_message helper have one (Telegram and Discord; 409 bridge_helper_unavailable otherwise: mention the bot in the chat instead). A few test messages an hour per bridge; past that, 429 says how long to wait. Needs a paid plan. Requires the hubs:write scope, and the hub's owner or a workspace manager.",
2700
+ inputSchema: {
2701
+ ...controlPlaneSchema,
2702
+ bridgeId: bridgeIdSchema,
2703
+ chatId: bridgeChatIdSchema.describe("An id from the bridge's allowed list."),
2704
+ },
2705
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2706
+ }, async ({ bridgeId, chatId, ...auth }) => {
2707
+ const api = await createControlPlane(auth);
2708
+ ensureAuthenticated(api);
2709
+ return jsonContent(redactSecrets(await api.sendBridgeTestMessage(bridgeId, chatId).catch(withBridgeHint)));
2710
+ });
2534
2711
  if (destructiveToolsEnabled()) {
2712
+ registerThalovantTool(server, "thalovant_delete_bridge", {
2713
+ title: "Delete Chat App Bridge",
2714
+ description: `Permanently delete a chat app bridge (DELETE /v1/bridges/{id}): it stops answering for the hub, and its stored token and secrets are removed from Thalovant. This cannot be undone: connecting the bot again means pasting its token in ${CONSOLE_CHAT_APPS}, which no tool can do. The bot itself stays in the person's Telegram account or Discord Developer Portal. To stop it answering for now, pause it with thalovant_pause_bridge instead. Always allowed, whatever the plan. Requires the hubs:write scope. This tool is disabled unless the operator sets THALOVANT_ENABLE_DESTRUCTIVE_TOOLS.`,
2715
+ inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
2716
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
2717
+ }, async ({ bridgeId, ...auth }) => {
2718
+ const api = await createControlPlane(auth);
2719
+ ensureAuthenticated(api);
2720
+ await api.deleteBridge(bridgeId).catch(withBridgeHint);
2721
+ return jsonContent({ deleted: true, bridgeId });
2722
+ });
2535
2723
  registerThalovantTool(server, "thalovant_delete_hub", {
2536
2724
  title: "Delete Hub",
2537
2725
  description: "Permanently delete a Thalovant hub along with its dependent clients and ACLs. This cannot be undone. Requires the hub's current etag, read from the `etag` field in the hub resource BODY returned by thalovant_get_hub — the API sends no ETag response header — and sent as If-Match; a missing or stale etag fails 412 and deletes nothing. Requires the hubs:write scope and a paid plan. This tool is disabled unless the operator sets THALOVANT_ENABLE_DESTRUCTIVE_TOOLS.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.6.6",
3
+ "version": "0.7.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.9.9",
67
+ "@thalovant/sdk": "^0.10.0",
68
68
  "jose": "^6.2.8",
69
69
  "zod": "^4.6.6"
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.6.6",
10
+ "version": "0.7.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.6.6",
15
+ "version": "0.7.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.6.6",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.7.0",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"