@thalovant/mcp-server 0.6.6 → 0.7.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 +23 -2
- package/README.md +63 -9
- package/dist/chat-apps-output.d.ts +46 -0
- package/dist/chat-apps-output.js +346 -0
- package/dist/index.js +205 -8
- package/package.json +2 -2
- package/server.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
Last Edit:
|
|
1
|
+
Last Edit: Claude Fable 5.1 - 2026-11-06 - Motive: 0.7.1 hints for the store's licensing refusal and the memory limit; date the published 0.7.0.
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.7.1 - Unreleased
|
|
6
|
+
|
|
7
|
+
- **Two refusals explained by their code.** A skill the store does not install for licensing reasons (YouTube, YouTube Music, Spotify, SoundCloud and Bandcamp; the API's 409 `skill_unavailable_licensing` with `reason: licensing`, declared with the launch data-protection change) got the generic 409 hint, which talks about a version already installed. It now says the service's terms do not allow a hosted assistant to play its content, that no plan, scope, version or retry changes that, that radio and aggregator skills install as usual, and that `thalovant_list_marketplace_skills` reports it per skill as `unavailable_reason` and `installable`. A memory create or update refused with 409 `memory_limit` (the plan's remembered items are used up; a Free plan keeps one) carried no hint at all, because the memory tools never went through the hint path; `thalovant_create_memory_item` and `thalovant_update_memory_item` now do, under a profile of their own that adds a hint for that code alone (the hub-centric scope and plan hints would misdescribe the memory routes), naming the `limit` and `upgrade_plan` fields, the delete tool that frees a slot, the update tool that changes the item that exists, and that granting a scope will not help. Two tests drive both through the fake control plane.
|
|
8
|
+
- `package.json`, `package-lock.json`, `server.json`, the OCI label, the README and this file move to 0.7.1 together.
|
|
9
|
+
|
|
10
|
+
## 0.7.0 - 2026-11-03
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- **`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.
|
|
19
|
+
- **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).
|
|
20
|
+
- **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.
|
|
21
|
+
- The catalog has 59 default tools (28 in read-only mode, 63 with destructive tools enabled).
|
|
22
|
+
- Takes `@thalovant/sdk` ^0.10.0, which carries the chat app methods and `ThalovantApiError.fields`.
|
|
23
|
+
- 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`.
|
|
24
|
+
- `package.json`, `package-lock.json`, `server.json`, the OCI label, the README and this file move to 0.7.0 together.
|
|
25
|
+
|
|
26
|
+
## 0.6.6 - 2026-10-26
|
|
6
27
|
|
|
7
28
|
- 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
29
|
- 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:
|
|
1
|
+
Last Edit: Claude Fable 5.1 - 2026-11-06 - Motive: Say what the 409 skill_unavailable_licensing and memory_limit refusals get as hints (0.7.1).
|
|
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`,
|
|
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` |
|
|
@@ -102,7 +103,7 @@ Grant fewer scopes for narrower deployments: a read-only assistant needs only `h
|
|
|
102
103
|
|
|
103
104
|
`thalovant_list_hub_skills` returns the whole `GET /v1/hubs/{hub_id}/skills` envelope: `hub_id`, `runtime_group_id`, `observed_at`, `source`, the runtime's phase and message, and `data`, one row per skill (possibly empty) with `skill`, `title`, `marketplace_skill_id`, `package_name`, `source_type`, `install_source`, `version`, `version_pin`, `installed_version`, `observed_version`, `previous_version`, `latest_version`, `available_version`, `update_available`, `changelog`, `active`, `state`, the runtime's phase, message and last error, and `last_transition_at`. `state` is one of `pending`, `installed`, `failed`, `removing`, `drifted`, `quarantined`, or `unmanaged`; a change in progress shows as `pending`.
|
|
104
105
|
|
|
105
|
-
Each write answers `202` with `operation_id`, `hub_id`, `runtime_group_id`, `skill`, `version` (`null` for a removal), `previous_version`, and `state` (`installing`, `updating`, or `removing`); pass `wait: true` to poll that operation every 2 s until it converges (`installed`, or `removed` for a removal; a `failed` or `timed_out` operation raises an error carrying its `error_message`), with a 120 s default `timeoutMs`, or follow it yourself with `thalovant_get_operation`. Installing a skill that is already installed at another version performs an update; the same version fails `409` with code `skill_version_already_installed`. A hub with no runtime group fails `404` with code `hub_without_runtime_group` (a plain `404` means an unknown hub or a skill that is not installed), and an unresolvable `latest` or an invalid version fails `422`. Errors are RFC 7807 problem bodies; the tool error keeps the short `message`, which appends the root `code` in parentheses, for example `Thalovant API request failed with HTTP 409: Skill version already installed. (skill_version_already_installed)`, and then the code and the body's other fields (see [Tool Errors](#tool-errors)).
|
|
106
|
+
Each write answers `202` with `operation_id`, `hub_id`, `runtime_group_id`, `skill`, `version` (`null` for a removal), `previous_version`, and `state` (`installing`, `updating`, or `removing`); pass `wait: true` to poll that operation every 2 s until it converges (`installed`, or `removed` for a removal; a `failed` or `timed_out` operation raises an error carrying its `error_message`), with a 120 s default `timeoutMs`, or follow it yourself with `thalovant_get_operation`. Installing a skill that is already installed at another version performs an update; the same version fails `409` with code `skill_version_already_installed`. A hub with no runtime group fails `404` with code `hub_without_runtime_group` (a plain `404` means an unknown hub or a skill that is not installed), and an unresolvable `latest` or an invalid version fails `422`. Errors are RFC 7807 problem bodies; the tool error keeps the short `message`, which appends the root `code` in parentheses, for example `Thalovant API request failed with HTTP 409: Skill version already installed. (skill_version_already_installed)`, and then the code and the body's other fields (see [Tool Errors](#tool-errors)). A skill the store does not install for licensing reasons (YouTube, YouTube Music, Spotify, SoundCloud and Bandcamp: the service's terms do not allow a hosted assistant to play its content) fails `409` with code `skill_unavailable_licensing` and `reason: licensing`, which `thalovant_list_marketplace_skills` announces per skill as `unavailable_reason` and `installable`; no plan, scope or version changes that.
|
|
106
107
|
|
|
107
108
|
Listing needs `hubs:inspect` (implied by `hubs:read`); the writes need `hubs:write` and a paid plan, and because scope is checked before plan a free-plan token sees `403`, never `402`. Hub-restricted tokens (a `hub_ids` allowlist) are honoured on all four routes. The server uses the published Node SDK hub-skill methods and preserves MCP cancellation checks during polling. A failed status read retains the accepted operation ID; use `thalovant_get_operation` with that ID instead of submitting the write again. No new poll starts at or after the polling deadline. That deadline does not cancel an HTTP request already in flight.
|
|
108
109
|
|
|
@@ -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,9 +512,18 @@ 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.
|
|
520
|
+
A 409 `skill_unavailable_licensing` says the store refuses the skill for its
|
|
521
|
+
licensing and where the catalog announces it, instead of the version hint. A
|
|
522
|
+
memory create or update refused with 409 `memory_limit` (the plan's remembered
|
|
523
|
+
items are used up; a Free plan keeps one, `limit` and `upgrade_plan` are in the
|
|
524
|
+
fields) names the delete tool that frees a slot, the update tool that changes
|
|
525
|
+
the item that exists, and the plan to move to; other memory refusals carry the
|
|
526
|
+
SDK's message alone.
|
|
473
527
|
|
|
474
528
|
## Request hints, embedded audio, and configuration merging
|
|
475
529
|
|
|
@@ -500,7 +554,7 @@ or use an SDK that preserves large JSON integers.
|
|
|
500
554
|
|
|
501
555
|
## Destructive Tools
|
|
502
556
|
|
|
503
|
-
`thalovant_delete_hub`, `thalovant_delete_runtime_group` and `
|
|
557
|
+
`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
558
|
|
|
505
559
|
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
560
|
|
|
@@ -520,7 +574,7 @@ export MCP_TOOL_DENYLIST="thalovant_delete_hub,thalovant_delete_runtime_group"
|
|
|
520
574
|
|
|
521
575
|
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
576
|
|
|
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.
|
|
577
|
+
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
578
|
|
|
525
579
|
## Non-Catalog Skill Sources
|
|
526
580
|
|
|
@@ -534,7 +588,7 @@ With the flag unset, a call with any `sourceType` other than `catalog` fails bef
|
|
|
534
588
|
|
|
535
589
|
## Read-Only Mode
|
|
536
590
|
|
|
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`.
|
|
591
|
+
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
592
|
|
|
539
593
|
## HiveMind Runtime Compatibility
|
|
540
594
|
|
|
@@ -542,7 +596,7 @@ Runtime calls sharing the same hub client identity run sequentially within one
|
|
|
542
596
|
MCP process. Use a distinct client identity for each independently running MCP
|
|
543
597
|
server so the hub can keep their sessions separate.
|
|
544
598
|
|
|
545
|
-
Version 0.
|
|
599
|
+
Version 0.7.1 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
600
|
secure effective MQTT URLs and carries a single connection deadline through
|
|
547
601
|
MQTT setup and HTTP failure cleanup. Runtime tools support
|
|
548
602
|
HiveMind v3 Noise over WSS, HTTPS and MQTT over TLS. `thalovant_healthcheck`
|
|
@@ -622,7 +676,7 @@ one tool for every SDK conversation or event-listener method.
|
|
|
622
676
|
|
|
623
677
|
### Shared-runtime skill management
|
|
624
678
|
|
|
625
|
-
The catalog has
|
|
679
|
+
The catalog has 59 default tools, 28 in read-only mode, and 63 with destructive tools enabled.
|
|
626
680
|
|
|
627
681
|
Hub-addressed skill methods select the runtime group attached to the hub UUID.
|
|
628
682
|
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 = [
|
|
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(),
|
|
@@ -226,11 +259,18 @@ function releaseOptionsFrom(args) {
|
|
|
226
259
|
const SCOPE_HINT_403 = 'HTTP 403 "Insufficient scopes" — the token lacks the scope this route needs: hubs:write for provisioning, hubs:read for the marketplace catalog, hubs:inspect for runtime-group and hub runtime views (hubs:write implies hubs:read, which implies hubs:inspect and hubs:preview). The API checks scope BEFORE the paid-plan gate, so a 403 can mask a plan problem and granting the scope may surface a 402 next. Free-plan API tokens are capped at hubs:read, clients:read, and clients:write, so a free-tier API token fails provisioning with this 403 and never reaches the 402. A 403 here can also mean the caller does not own the resource ("Ownership required"), or a plan restriction such as HUB_AUTOSCALING_NOT_INCLUDED or a custom hub domain the plan does not allow.';
|
|
227
260
|
const PLATFORM_IMAGE_HINT_403 = 'HTTP 403 platform_image_required — only a platform administrator can run an image the platform does not release. This is not a scope problem: pick, for each key in refused_images, one of that key\'s allowed_images, or any tag or digest of its allowed_repositories entry (both are in the fields above), and retry.';
|
|
228
261
|
const PLAN_LIMIT_HINT_403 = 'HTTP 403 plan_limit — the plan\'s allowance for the named resource is used up (see resource, used and limit in the fields above). Granting a scope will not help: free one (delete or reuse an existing one) or move the workspace to a larger plan.';
|
|
262
|
+
const LICENSING_HINT_409 = "HTTP 409 skill_unavailable_licensing — the store does not install this skill on Thalovant: the service's terms do not allow a hosted assistant to play its content (YouTube, YouTube Music, Spotify, SoundCloud and Bandcamp; the problem's reason says licensing). No plan, scope or version changes that, and retrying will not either; radio and aggregator skills such as TuneIn, SomaFM and News install as usual. thalovant_list_marketplace_skills reports this per skill as unavailable_reason and installable (false), so check them before installing.";
|
|
263
|
+
const MEMORY_LIMIT_HINT_409 = "HTTP 409 memory_limit — the plan's remembered items are used up (see limit in the fields above; a Free plan keeps one). Free a slot with thalovant_delete_memory_item, change the item that exists with thalovant_update_memory_item, or move the workspace to the plan upgrade_plan names (Home); granting a scope will not help.";
|
|
229
264
|
const PLAN_HINT_402 = 'HTTP 402 "API access requires a paid plan." — the token is valid and correctly scoped but the tenant is on the free plan. Free-tier callers can browse the catalog (thalovant_list_marketplace_skills) and set hub ratings, but cannot create, update, release, or delete hubs and runtime groups, and cannot install skills.';
|
|
230
265
|
const MARKETPLACE_HINT_402 = 'HTTP 402 "This skill requires paid marketplace access for the tenant plan." — a DIFFERENT 402 from the plan gate: the plan is paid enough to provision, but this catalog entry has access_tier "paid" and the plan does not include paid marketplace skills. thalovant_list_runtime_group_marketplace reports this per skill as purchase_required, installable, and access_message; check it before installing.';
|
|
231
266
|
const HUB_SKILL_HINT_422 = 'HTTP 422 — the requested hub skill version is unusable: "latest" could not be resolved to a published version of that skill, or the version string is not a valid version. Check the skill name and its published versions with thalovant_list_marketplace_skills, or pass an explicit x.y.z.';
|
|
232
267
|
const ETAG_HINT_412 = 'HTTP 412 "ETag mismatch" — the If-Match etag was missing or stale and nothing changed. Re-fetch the hub with thalovant_get_hub, read the `etag` field from the response BODY (there is no ETag response header), and retry with that exact value. The comparison is exact string equality, so do not rewrite or weaken the value.';
|
|
233
268
|
function controlPlaneErrorHint(profile, status, body, code) {
|
|
269
|
+
if (profile === "memoryWrite") {
|
|
270
|
+
// The memory routes have their own scopes and refusals, which the hub-centric
|
|
271
|
+
// hints below would misdescribe; the one worth a hint is the plan's limit.
|
|
272
|
+
return status === 409 && code === "memory_limit" ? MEMORY_LIMIT_HINT_409 : undefined;
|
|
273
|
+
}
|
|
234
274
|
switch (status) {
|
|
235
275
|
case 402:
|
|
236
276
|
return (profile === "skillInstall" || profile === "hubSkillInstall") && /marketplace/i.test(body) ? MARKETPLACE_HINT_402 : PLAN_HINT_402;
|
|
@@ -247,6 +287,8 @@ function controlPlaneErrorHint(profile, status, body, code) {
|
|
|
247
287
|
? "HTTP 404 (hub_without_runtime_group) — the hub exists but has no runtime group attached yet, so it cannot hold skills; attach it to a runtime group (thalovant_update_hub) before managing its skills. A 404 without that code means the hub is unknown, or the named skill is not installed on it."
|
|
248
288
|
: undefined;
|
|
249
289
|
case 409:
|
|
290
|
+
if (code === "skill_unavailable_licensing")
|
|
291
|
+
return LICENSING_HINT_409;
|
|
250
292
|
if (profile === "hubWrite") {
|
|
251
293
|
return 'HTTP 409 — for a hub create this is either a duplicate ("Hub with name ... already exists for this owner" / "Hub slug ... already exists"), an autoscaling slot limit (AUTOSCALING_HUB_LIMIT_REACHED), or "Idempotency key re-used with different payload". Only hub create honors Idempotency-Key: retrying an identical create is safe and returns the original hub, but reusing a key with a changed body conflicts — use a new idempotencyKey for a genuinely different hub.';
|
|
252
294
|
}
|
|
@@ -293,6 +335,14 @@ function withConnectionHint(error, connectionType) {
|
|
|
293
335
|
const said = apiErrorText(error, redactSecrets) ?? (error instanceof Error ? error.message : String(error));
|
|
294
336
|
throw new Error(`${said}\n\n${hint}`, { cause: error });
|
|
295
337
|
}
|
|
338
|
+
/** Rethrow a refused chat app call with its guidance (see chat-apps-output.ts), when it has any. */
|
|
339
|
+
function withBridgeHint(error) {
|
|
340
|
+
const hint = bridgeErrorHint(error);
|
|
341
|
+
if (!hint)
|
|
342
|
+
throw error;
|
|
343
|
+
const said = apiErrorText(error, redactSecrets) ?? (error instanceof Error ? error.message : String(error));
|
|
344
|
+
throw new Error(`${said}\n\n${hint}`, { cause: error });
|
|
345
|
+
}
|
|
296
346
|
/**
|
|
297
347
|
* Run a control-plane call and, on failure, append actionable guidance.
|
|
298
348
|
*
|
|
@@ -610,8 +660,9 @@ async function auditLog(event) {
|
|
|
610
660
|
function registerThalovantTool(server, name, config, handler) {
|
|
611
661
|
if (readOnlyModeEnabled()) {
|
|
612
662
|
const annotations = (config.annotations ?? {});
|
|
613
|
-
if (annotations.readOnlyHint !== true) {
|
|
614
|
-
// Read-only mode: do not register write/destructive tools at all
|
|
663
|
+
if (annotations.readOnlyHint !== true || WRITE_SCOPE_READ_TOOLS.has(name)) {
|
|
664
|
+
// Read-only mode: do not register write/destructive tools at all, nor
|
|
665
|
+
// the reads only a write scope may call.
|
|
615
666
|
return;
|
|
616
667
|
}
|
|
617
668
|
}
|
|
@@ -658,6 +709,11 @@ function clampTimeout(timeoutMs) {
|
|
|
658
709
|
return DEFAULT_TIMEOUT_MS;
|
|
659
710
|
return Math.min(Math.max(timeoutMs, 1_000), MAX_TIMEOUT_MS);
|
|
660
711
|
}
|
|
712
|
+
/** Whether a member name looks like it holds a credential (see SECRET_KEYS). */
|
|
713
|
+
function isSecretKeyName(key) {
|
|
714
|
+
const normalized = key.toLowerCase();
|
|
715
|
+
return SECRET_KEYS.some((secretKey) => normalized.includes(secretKey.toLowerCase()));
|
|
716
|
+
}
|
|
661
717
|
function redactSecrets(value) {
|
|
662
718
|
if (Array.isArray(value)) {
|
|
663
719
|
return value.map((item) => redactSecrets(item));
|
|
@@ -667,9 +723,7 @@ function redactSecrets(value) {
|
|
|
667
723
|
}
|
|
668
724
|
const output = {};
|
|
669
725
|
for (const [key, nestedValue] of Object.entries(value)) {
|
|
670
|
-
|
|
671
|
-
const isSecret = SECRET_KEYS.some((secretKey) => normalized.includes(secretKey.toLowerCase()));
|
|
672
|
-
output[key] = isSecret ? "[redacted]" : redactSecrets(nestedValue);
|
|
726
|
+
output[key] = isSecretKeyName(key) ? "[redacted]" : redactSecrets(nestedValue);
|
|
673
727
|
}
|
|
674
728
|
return output;
|
|
675
729
|
}
|
|
@@ -1945,7 +1999,7 @@ export function createServer() {
|
|
|
1945
1999
|
const { loginScope, apiUrl, ...payload } = args;
|
|
1946
2000
|
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1947
2001
|
ensureAuthenticated(api);
|
|
1948
|
-
return jsonContent(redactSecrets(await api.createMemoryItem(payload)));
|
|
2002
|
+
return jsonContent(redactSecrets(await callControlPlane("memoryWrite", () => api.createMemoryItem(payload))));
|
|
1949
2003
|
});
|
|
1950
2004
|
registerThalovantTool(server, "thalovant_update_memory_item", {
|
|
1951
2005
|
title: "Update Memory Item",
|
|
@@ -1974,7 +2028,7 @@ export function createServer() {
|
|
|
1974
2028
|
const { loginScope, apiUrl, memoryId, ...payload } = args;
|
|
1975
2029
|
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1976
2030
|
ensureAuthenticated(api);
|
|
1977
|
-
return jsonContent(redactSecrets(await api.updateMemoryItem(memoryId, payload)));
|
|
2031
|
+
return jsonContent(redactSecrets(await callControlPlane("memoryWrite", () => api.updateMemoryItem(memoryId, payload))));
|
|
1978
2032
|
});
|
|
1979
2033
|
registerThalovantTool(server, "thalovant_delete_memory_item", {
|
|
1980
2034
|
title: "Delete Memory Item",
|
|
@@ -2531,7 +2585,150 @@ export function createServer() {
|
|
|
2531
2585
|
const result = await callControlPlane("write", () => api.removeHubSkill(hubId, skill, options));
|
|
2532
2586
|
return jsonContent(redactSecrets({ ...result, operation: result.operation ?? null }));
|
|
2533
2587
|
});
|
|
2588
|
+
// Chat apps (/v1/bridges): a hub answering in Telegram, Discord and the
|
|
2589
|
+
// other chat networks. Creating a bridge and replacing its token or secrets
|
|
2590
|
+
// take a credential, which no tool of this server accepts, so those stay in
|
|
2591
|
+
// the console; see chat-apps-output.ts for what each output shows.
|
|
2592
|
+
registerThalovantTool(server, "thalovant_list_chat_app_connectors", {
|
|
2593
|
+
title: "List Chat App Connectors",
|
|
2594
|
+
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.`,
|
|
2595
|
+
inputSchema: {
|
|
2596
|
+
...controlPlaneSchema,
|
|
2597
|
+
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."),
|
|
2598
|
+
},
|
|
2599
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2600
|
+
}, async ({ language, ...auth }) => {
|
|
2601
|
+
const api = await createControlPlane(auth);
|
|
2602
|
+
ensureAuthenticated(api);
|
|
2603
|
+
const connectors = await api.listChatAppConnectors().catch(withBridgeHint);
|
|
2604
|
+
return jsonContent(connectorsOutput(connectors, language, redactSecrets));
|
|
2605
|
+
});
|
|
2606
|
+
registerThalovantTool(server, "thalovant_get_bridge_networks", {
|
|
2607
|
+
title: "Get Chat App Allowance",
|
|
2608
|
+
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.",
|
|
2609
|
+
inputSchema: {
|
|
2610
|
+
...controlPlaneSchema,
|
|
2611
|
+
hubId: z.string().uuid().optional().describe("Hub UUID: report the allowance of this hub's owner."),
|
|
2612
|
+
},
|
|
2613
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2614
|
+
}, async ({ hubId, ...auth }) => {
|
|
2615
|
+
const api = await createControlPlane(auth);
|
|
2616
|
+
ensureAuthenticated(api);
|
|
2617
|
+
const networks = await api.getBridgeNetworks({ hubId }).catch(withBridgeHint);
|
|
2618
|
+
return jsonContent(networksOutput(networks, redactSecrets));
|
|
2619
|
+
});
|
|
2620
|
+
registerThalovantTool(server, "thalovant_list_bridges", {
|
|
2621
|
+
title: "List Chat App Bridges",
|
|
2622
|
+
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.",
|
|
2623
|
+
inputSchema: {
|
|
2624
|
+
...controlPlaneSchema,
|
|
2625
|
+
hubId: z.string().uuid().optional().describe("Hub UUID: list only this hub's bridges."),
|
|
2626
|
+
ownerId: z.string().uuid().optional().describe("Owner UUID: narrow the visible bridges to this owner's."),
|
|
2627
|
+
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."),
|
|
2628
|
+
},
|
|
2629
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2630
|
+
}, async ({ hubId, ownerId, limit, ...auth }) => {
|
|
2631
|
+
const api = await createControlPlane(auth);
|
|
2632
|
+
ensureAuthenticated(api);
|
|
2633
|
+
const bridges = await api.listBridges({ hubId, ownerId, limit }).catch(withBridgeHint);
|
|
2634
|
+
return jsonContent({ data: bridges.map((bridge) => bridgeView(bridge, redactSecrets)) });
|
|
2635
|
+
});
|
|
2636
|
+
registerThalovantTool(server, "thalovant_get_bridge", {
|
|
2637
|
+
title: "Get Chat App Bridge",
|
|
2638
|
+
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.",
|
|
2639
|
+
inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
|
|
2640
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2641
|
+
}, async ({ bridgeId, ...auth }) => {
|
|
2642
|
+
const api = await createControlPlane(auth);
|
|
2643
|
+
ensureAuthenticated(api);
|
|
2644
|
+
return jsonContent(bridgeView(await api.getBridge(bridgeId).catch(withBridgeHint), redactSecrets));
|
|
2645
|
+
});
|
|
2646
|
+
registerThalovantTool(server, "thalovant_list_bridge_chats", {
|
|
2647
|
+
title: "List Bridge Chats",
|
|
2648
|
+
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.",
|
|
2649
|
+
inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
|
|
2650
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
2651
|
+
}, async ({ bridgeId, ...auth }) => {
|
|
2652
|
+
const api = await createControlPlane(auth);
|
|
2653
|
+
ensureAuthenticated(api);
|
|
2654
|
+
return jsonContent(chatsOutput(await api.listBridgeChats(bridgeId).catch(withBridgeHint), redactSecrets));
|
|
2655
|
+
});
|
|
2656
|
+
registerThalovantTool(server, "thalovant_pause_bridge", {
|
|
2657
|
+
title: "Pause Chat App Bridge",
|
|
2658
|
+
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.",
|
|
2659
|
+
inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema, etag: bridgeEtagSchema },
|
|
2660
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2661
|
+
}, async ({ bridgeId, etag, ...auth }) => {
|
|
2662
|
+
const api = await createControlPlane(auth);
|
|
2663
|
+
ensureAuthenticated(api);
|
|
2664
|
+
return jsonContent(bridgeView(await api.updateBridge(bridgeId, { paused: true }, { etag }).catch(withBridgeHint), redactSecrets));
|
|
2665
|
+
});
|
|
2666
|
+
registerThalovantTool(server, "thalovant_resume_bridge", {
|
|
2667
|
+
title: "Resume Chat App Bridge",
|
|
2668
|
+
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.",
|
|
2669
|
+
inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema, etag: bridgeEtagSchema },
|
|
2670
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2671
|
+
}, async ({ bridgeId, etag, ...auth }) => {
|
|
2672
|
+
const api = await createControlPlane(auth);
|
|
2673
|
+
ensureAuthenticated(api);
|
|
2674
|
+
return jsonContent(bridgeView(await api.updateBridge(bridgeId, { paused: false }, { etag }).catch(withBridgeHint), redactSecrets));
|
|
2675
|
+
});
|
|
2676
|
+
registerThalovantTool(server, "thalovant_update_bridge", {
|
|
2677
|
+
title: "Update Chat App Bridge",
|
|
2678
|
+
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.`,
|
|
2679
|
+
inputSchema: {
|
|
2680
|
+
...controlPlaneSchema,
|
|
2681
|
+
bridgeId: bridgeIdSchema,
|
|
2682
|
+
allowed: z.array(bridgeAllowedChatSchema).min(1).max(50).optional().describe("Every chat the bridge should answer in, 1-50; replaces the stored list."),
|
|
2683
|
+
requireMention: z.boolean().optional().describe("Answer only messages that mention the bot or reply to it. Discord bridges are always true."),
|
|
2684
|
+
sessionScope: z.enum(["person", "chat"]).optional().describe("person: each person has a conversation of their own; chat: one conversation per chat."),
|
|
2685
|
+
settings: jsonRecordSchema
|
|
2686
|
+
.refine((value) => Object.keys(value).length <= 32, "At most 32 settings.")
|
|
2687
|
+
.optional()
|
|
2688
|
+
.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."),
|
|
2689
|
+
etag: bridgeEtagSchema,
|
|
2690
|
+
},
|
|
2691
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2692
|
+
}, async ({ bridgeId, allowed, requireMention, sessionScope, settings, etag, ...auth }) => {
|
|
2693
|
+
if (allowed === undefined && requireMention === undefined && sessionScope === undefined && settings === undefined) {
|
|
2694
|
+
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.");
|
|
2695
|
+
}
|
|
2696
|
+
const secretLooking = secretLookingSettings(settings, isSecretKeyName);
|
|
2697
|
+
if (secretLooking.length)
|
|
2698
|
+
throw new Error(secretSettingsRefusal(secretLooking));
|
|
2699
|
+
const api = await createControlPlane(auth);
|
|
2700
|
+
ensureAuthenticated(api);
|
|
2701
|
+
const bridge = await api
|
|
2702
|
+
.updateBridge(bridgeId, { allowed, requireMention, sessionScope, settings }, { etag })
|
|
2703
|
+
.catch(withBridgeHint);
|
|
2704
|
+
return jsonContent(bridgeView(bridge, redactSecrets));
|
|
2705
|
+
});
|
|
2706
|
+
registerThalovantTool(server, "thalovant_send_bridge_test_message", {
|
|
2707
|
+
title: "Send Bridge Test Message",
|
|
2708
|
+
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.",
|
|
2709
|
+
inputSchema: {
|
|
2710
|
+
...controlPlaneSchema,
|
|
2711
|
+
bridgeId: bridgeIdSchema,
|
|
2712
|
+
chatId: bridgeChatIdSchema.describe("An id from the bridge's allowed list."),
|
|
2713
|
+
},
|
|
2714
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
2715
|
+
}, async ({ bridgeId, chatId, ...auth }) => {
|
|
2716
|
+
const api = await createControlPlane(auth);
|
|
2717
|
+
ensureAuthenticated(api);
|
|
2718
|
+
return jsonContent(redactSecrets(await api.sendBridgeTestMessage(bridgeId, chatId).catch(withBridgeHint)));
|
|
2719
|
+
});
|
|
2534
2720
|
if (destructiveToolsEnabled()) {
|
|
2721
|
+
registerThalovantTool(server, "thalovant_delete_bridge", {
|
|
2722
|
+
title: "Delete Chat App Bridge",
|
|
2723
|
+
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.`,
|
|
2724
|
+
inputSchema: { ...controlPlaneSchema, bridgeId: bridgeIdSchema },
|
|
2725
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
2726
|
+
}, async ({ bridgeId, ...auth }) => {
|
|
2727
|
+
const api = await createControlPlane(auth);
|
|
2728
|
+
ensureAuthenticated(api);
|
|
2729
|
+
await api.deleteBridge(bridgeId).catch(withBridgeHint);
|
|
2730
|
+
return jsonContent({ deleted: true, bridgeId });
|
|
2731
|
+
});
|
|
2535
2732
|
registerThalovantTool(server, "thalovant_delete_hub", {
|
|
2536
2733
|
title: "Delete Hub",
|
|
2537
2734
|
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.
|
|
3
|
+
"version": "0.7.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,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
67
|
-
"@thalovant/sdk": "^0.
|
|
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.
|
|
10
|
+
"version": "0.7.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@thalovant/mcp-server",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.7.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.
|
|
22
|
+
"identifier": "ghcr.io/thalovant/thalovant-mcp:0.7.1",
|
|
23
23
|
"transport": {
|
|
24
24
|
"type": "streamable-http",
|
|
25
25
|
"url": "http://127.0.0.1:3000/mcp"
|