@luxorai/mcp 0.3.1 → 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +13 -8
  3. package/SECURITY.md +2 -2
  4. package/dist/client.d.ts +16 -1
  5. package/dist/client.js +62 -16
  6. package/dist/env.d.ts +6 -0
  7. package/dist/env.js +25 -0
  8. package/dist/http.js +13 -10
  9. package/dist/index.js +9 -8
  10. package/dist/oauth.d.ts +7 -1
  11. package/dist/oauth.js +29 -9
  12. package/dist/server.d.ts +25 -5
  13. package/dist/server.js +88 -53
  14. package/dist/tools/artifacts.d.ts +3 -3
  15. package/dist/tools/artifacts.js +6 -6
  16. package/dist/tools/auth_status.d.ts +3 -3
  17. package/dist/tools/auth_status.js +8 -9
  18. package/dist/tools/common.d.ts +35 -0
  19. package/dist/tools/common.js +78 -0
  20. package/dist/tools/egress.d.ts +38 -0
  21. package/dist/tools/egress.js +153 -0
  22. package/dist/tools/exec.js +1 -1
  23. package/dist/tools/files.js +36 -3
  24. package/dist/tools/files_ops.js +9 -1
  25. package/dist/tools/git.d.ts +0 -1
  26. package/dist/tools/git.js +85 -23
  27. package/dist/tools/ports.d.ts +8 -1
  28. package/dist/tools/ports.js +24 -3
  29. package/dist/tools/previews.js +59 -22
  30. package/dist/tools/sandboxes.d.ts +1 -1
  31. package/dist/tools/sandboxes.js +342 -26
  32. package/dist/tools/sessions_admin.js +63 -23
  33. package/dist/tools/snapshots.js +41 -22
  34. package/dist/tools/ssh.d.ts +3 -3
  35. package/dist/tools/ssh.js +35 -9
  36. package/dist/tools/templates.d.ts +2 -1
  37. package/dist/tools/templates.js +36 -16
  38. package/dist/tools/titles.d.ts +8 -0
  39. package/dist/tools/titles.js +102 -0
  40. package/dist/tools/volumes.js +36 -11
  41. package/dist/tools/workspace.d.ts +9 -1
  42. package/dist/tools/workspace.js +29 -90
  43. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to `@tenkicloud/mcp`. This project follows semantic versioning.
4
4
 
5
+ ## [0.4.0] - 2026-11-10
6
+
7
+ - Read `LUXORAI_*` environment variables before their `TENKI_*` equivalents (credentials, endpoint, and every `TENKI_MCP_*` option). `TENKI_*` keeps working.
8
+ - Accept `lx_` API keys alongside `tk_`.
9
+ - `TENKI_MCP_OAUTH_RESOURCE` accepts a comma-separated list; protected-resource metadata returns the entry matching the request host.
10
+ - Add a `luxor-mcp` bin alias and a `luxor` Claude Code plugin. Prepare a gated dual publish as `@luxorai/mcp` (`io.github.LuxorLabs/luxor-mcp`, `ghcr.io/luxorlabs/luxor-mcp`).
11
+ - Remove `tenki_get_workspace_settings`, `tenki_update_workspace_settings`, `tenki_get_snapshot_retention_settings` and `tenki_update_snapshot_retention_settings`: their API methods were removed upstream and every call returned 404. Plan limits are visible through `tenki_get_workspace_usage`.
12
+ - Add `tenki_get_sandbox_metrics` (CPU/memory averages over a window) and `tenki_get_workspace_preview_domains` / `tenki_update_workspace_preview_domains` (wildcard preview domains).
13
+ - Fix `tenki_attach_volume` `read_only`, which the API silently ignored (wrong wire field name); the mount was always read-write.
14
+ - Fix `tenki_update_sandbox`: `max_duration_seconds` always failed (the API requires an explicit sticky value — sticky=false is now sent for you after checking the sandbox is not sticky; a sticky sandbox is refused without an explicit `sticky`); `tags: []` now clears tags; new `sticky` and `clear_tags` arguments; `idle_timeout_minutes` removed because the API cannot change it after creation.
15
+ - Fix `tenki_list_ssh_gateways`: `workspace_id` did nothing; the tool now takes `region` and/or `session_id`.
16
+ - `tenki_get_download_url` no longer requires `session_id` (the API ignores it).
17
+ - `tenki_list_preview_urls` filters by `session_id` on the server instead of on the fetched page.
18
+ - `tenki_create_sandbox`: explicit `allow_inbound: false` / `allow_outbound: false` are sent; new `sticky`, `volumes`, `ssh_authorized_keys`, `metadata`, `egress_allow_domains` / `egress_allow_cidrs`, `template_spec_id`, `setup_env`, `setup_secrets`, `secret_overrides`; the server-side ready wait is used; API `warnings` are returned; `memory_mb` must be even and `disk_size_gb` 5-100.
19
+ - New optional arguments: `async` on `tenki_pause_sandbox` and `tenki_create_snapshot`; `tags` / `clear_tags` on `tenki_update_snapshot` and `tenki_update_volume`, plus `clear_expires_at` on snapshots; `force` on `tenki_detach_volume`; `mode` on `tenki_make_dir`; `expires_at` on `tenki_expose_port` and `tenki_bind_preview_url`; `slug` lookup on `tenki_get_preview_url`; `requested_ttl_seconds` / `source_addresses` on `tenki_issue_ssh_cert`; `tags` / `sticky` filters on the sandbox lists.
20
+ - Tool descriptions now flag the API-deprecated methods behind `tenki_expose_port`, `tenki_unexpose_port`, `tenki_list_exposed_ports`, `tenki_list_workspace_sandboxes` and `tenki_list_workspace_snapshots`, and point to the replacements.
21
+ - `tenki_create_sandbox`: `cpu_cores` up to 128 and `memory_mb` up to 524288 (the API's new ceilings; your plan may cap lower); new `secret_files` (write a workspace secret or inline text to a guest path at boot), `secret_policies` (attach saved secret access policies that inject workspace secrets into matching outbound HTTPS requests) and `tailnet` (join the sandbox to a Tailscale tailnet with an auth key, where the workspace allows it). Template `cpu_cores` / `memory_mb` bounds raised to match.
22
+ - Server-side `search`, `states`, `sort_by` / `sort_desc` and `include_facets` on `tenki_list_workspace_sandboxes` (plus `order`), `tenki_list_volumes`, `tenki_list_templates` and `tenki_list_preview_urls`; preview-URL rows now include the bound sandbox's state and name.
23
+ - `tenki_issue_ssh_cert` documents the new `pinHostKey` flag (verify the gateway host certificate against `caPub`).
24
+ - Contradictory or empty updates are rejected before any call: `tags`+`clear_tags`, `expires_at`+`clear_expires_at`, an egress allowlist with `allow_outbound: false`, and update calls with nothing to change (sandbox, snapshot, volume, template).
25
+ - Add `tenki_list_template_builds` (every build of a template with per-image storage and whether it can be deleted) and `tenki_delete_template_build` (free a build's image and any published image version carrying it; refused while the image is still referenced, e.g. published, in use by a non-terminated sandbox, building or tagged).
26
+ - Add `tenki_create_egress_policy`, `tenki_get_egress_policy`, `tenki_list_egress_policies`, `tenki_update_egress_policy` and `tenki_delete_egress_policy` for named workspace egress policies.
27
+ - `tenki_create_sandbox`: new `egress_policies` (pin saved egress policies by name), `egress_intercept_https` (check each HTTPS request's host) and `egress_http_rules` (allow only matching HTTP methods and paths per host).
28
+ - Add `tenki_fork_sandbox` (start copies of a running sandbox from a fresh snapshot of its memory and disk) and `tenki_list_ci_runner_sizes` (the runs-on sizes Tenki CI can run for the workspace).
29
+ - `tenki_create_sandbox`: new `egress_allowed_ports` (extra TCP ports alongside HTTPS interception, also `allowed_ports` on the egress policy tools), `disable_tailnet` (skip the workspace's default Tailscale connection) and `tailnet.connection_id` / `connection_revision` (join with a Tailscale connection saved in the dashboard instead of an auth key).
30
+ - Descriptions now match the API: HTTPS interception limits public egress to ports 80/443; a volume still syncing back cannot be detached, even with `force`; expired snapshots cannot be booted from or updated, and pause snapshots cannot be booted from or have their expiry changed; another workspace's image with runtime secrets needs each one selected in `secret_overrides`; `tenki_pause_sandbox` returns once the checkpoint is saved on the host, and a tailnet-attached sandbox cannot be paused yet.
31
+ - A fork that fails partway names the snapshot and sandboxes it already created, and is never retried automatically. Create, fork and other non-repeatable calls refused by a workspace limit now fail at once instead of retrying.
32
+ - `tenki_resize_volume` and `tenki_detach_volume` descriptions now match the API: only a never-attached volume can be resized, and a forced detach loses unflushed writes.
33
+ - Update `@modelcontextprotocol/sdk` to 1.32.1 (fixes GHSA-6qxp-vccf-f47h: the OAuth client could send credentials to an authorization server chosen by the MCP server) and refresh transitive pins flagged by `npm audit`.
34
+ - `tenki_resume_sandbox` matches the API's new resume behavior (sandbox-engine v2.35.0): it waits until the sandbox is RUNNING with the 600s slow-operation budget instead of 30s, takes `async` to return once the resume is accepted, and a wait that times out says the sandbox may still be resuming. Calling it again joins a resume in progress.
35
+ - **Breaking:** `tenki_git` is replaced by `tenki_git_clone`, `tenki_git_checkout`, `tenki_git_diff` and `tenki_git_log`, so reads and writes are separate tools. Each takes typed arguments instead of an `operation` + `args` map, and checkout/diff/log take `directory` to target a repository cloned into a subdirectory (previously they always ran in `/home/tenki` and failed with "not a git repository"). Values git would parse as options (a leading `-`, e.g. `--output=<file>`) are refused, and output over 64 KiB is truncated with `totalBytes`.
36
+ - Every tool now has a human-readable `title` (MCP `Tool.title` and `annotations.title`).
37
+ - `destructiveHint` now marks every tool that can overwrite, modify, interrupt or delete existing state, not only deletes: `tenki_exec`, `tenki_write_file`, `tenki_move_path`, `tenki_git_checkout`, every `update` tool, `tenki_pause_sandbox`, `tenki_open_preview`, `tenki_bind_preview_url`, `tenki_resize_volume`, `tenki_cancel_template_build`, `tenki_build_template` (a build with `image_name` changes what `workspace/name` boots) and `tenki_get_upload_url`. Clients that prompt before destructive tools will now prompt for these. Only tools that only add, never change or remove what exists: create (sandboxes, forks, clones, snapshots, volumes, templates, egress policies, preview URLs, port exposures, directories, SSH certificates), resume, extend, attach a volume, keep-alive are non-destructive.
38
+ - `tenki_read_file` returns at most `max_bytes` (default 65536, up to 1 MiB) from `offset`, with `totalBytes`, and `truncated` + `nextOffset` when more remains. Cuts never split a UTF-8 character. Previously it returned the whole file.
39
+ - `tenki_open_preview`'s description now says what it returns (a viewer-token-gated, non-public URL; 7681 is the web terminal) and that reopening a port replaces its earlier URL, instead of steering callers away from it.
40
+ - Every tool parameter now has a description.
41
+
5
42
  ## [0.2.0] — 2026-08-21
6
43
 
7
44
  - Remove standalone image management tools. Template images remain available through template builds and sandbox creation.
package/README.md CHANGED
@@ -128,35 +128,40 @@ Substitute `node /absolute/path/to/tenki-mcp/dist/index.js` for the `npx` comman
128
128
  | `TENKI_MCP_HTTP_TOKEN` | — | Bearer token for the HTTP endpoint; optional on loopback, required on a non-loopback host. |
129
129
  | `TENKI_MCP_PUBLIC_URL` | — | Public base URL for an OAuth-protected hosted server. |
130
130
  | `TENKI_MCP_OAUTH_ISSUER` | — | OAuth authorization-server issuer. Enables delegated OAuth HTTP mode. |
131
- | `TENKI_MCP_OAUTH_RESOURCE` | `<public URL>/mcp` | RFC 8707 resource identifier accepted in access-token audiences. |
131
+ | `TENKI_MCP_OAUTH_RESOURCE` | `<public URL>/mcp` | RFC 8707 resource identifier accepted in access-token audiences. A comma-separated list serves one resource per domain; metadata returns the one matching the request host, else the first. |
132
132
  | `TENKI_MCP_OAUTH_SCOPE` | `mcp` | Required delegated scope. |
133
133
  | `TENKI_MCP_IDENTITY_URL` | — | Internal Tenki Identity service endpoint used to exchange OAuth access tokens. |
134
134
  | `TENKI_MCP_IDENTITY_SERVICE_TOKEN` | — | Service credential for the private Identity token-exchange RPC. |
135
135
 
136
+ Every `TENKI_*` variable above also has a `LUXORAI_*` equivalent (for example `LUXORAI_API_KEY`, `LUXORAI_MCP_TRANSPORT`). All `LUXORAI_*` credentials are read before any `TENKI_*` one, and `lx_…` API keys are accepted alongside `tk_…`.
137
+
136
138
  ## Tools
137
139
 
138
- **71 tools** — all 68 public unary API methods (enforced by a CI [parity audit](scripts/parity-audit.mjs)), two workflow helpers, and `tenki_auth_status`. Implementation-only control-plane methods are intentionally excluded. Grouped by domain:
140
+ **82 tools** — all 76 tool-worthy public unary API methods (enforced by a CI [parity audit](scripts/parity-audit.mjs), which also fails if a tool calls a method the API no longer has; `GitOperation` is exposed as one tool per operation), two workflow helpers, and `tenki_auth_status`. A few admin surfaces (published images, saved Tailscale connections) and implementation-only control-plane methods are intentionally excluded. Grouped by domain:
139
141
 
140
142
  | Domain | Tools |
141
143
  |---|---|
142
144
  | **Auth** | `tenki_auth_status` (which credential is configured, and does it work — the only tool available when none is) |
143
145
  | **Identity** | `tenki_whoami` |
144
146
  | **Run** | `tenki_run_code` (one-shot: boot → run shell/python/js → tear down) |
145
- | **Sandboxes** | `tenki_create_sandbox` · `tenki_get_sandbox` · `tenki_list_sandboxes` · `tenki_terminate_sandbox` · `tenki_pause_sandbox` · `tenki_resume_sandbox` |
147
+ | **Sandboxes** | `tenki_create_sandbox` · `tenki_get_sandbox` · `tenki_get_sandbox_metrics` · `tenki_list_sandboxes` · `tenki_terminate_sandbox` · `tenki_pause_sandbox` · `tenki_resume_sandbox` · `tenki_fork_sandbox` (copies of a running sandbox) |
146
148
  | **Session admin** | `tenki_extend_sandbox` · `tenki_update_sandbox` · `tenki_terminate_sandboxes` (bulk) · `tenki_report_sandbox_activity` · `tenki_list_workspace_sandboxes` |
147
149
  | **Exec** | `tenki_exec` (stdout/stderr/exit inline) |
148
150
  | **Files** | `tenki_read_file` · `tenki_write_file` · `tenki_list_files` · `tenki_stat_path` · `tenki_make_dir` · `tenki_remove_path` · `tenki_move_path` |
149
- | **Git** | `tenki_git` (clone/checkout/diff/log — the API supports exactly these four; run other git commands via `tenki_exec`) |
150
- | **Ports & previews** | expose · list-exposed · unexpose · create-preview-url · open-preview · list/get/delete-preview-url · touch-preview · bind/unbind-preview-url · resolve-preview-token |
151
+ | **Git** | `tenki_git_clone` · `tenki_git_checkout` · `tenki_git_diff` · `tenki_git_log` (the four operations the API supports; pass `directory` to target a cloned repo; run other git commands via `tenki_exec`) |
152
+ | **Ports & previews** | expose · list-exposed · unexpose · create-preview-url · open-preview (viewer-token-gated, not public; port 7681 is the web terminal) · list/get/delete-preview-url · touch-preview · bind/unbind-preview-url · resolve-preview-token |
151
153
  | **Artifacts** (binary transfer) | `tenki_get_upload_url` · `tenki_get_download_url` (signed URLs for binary PUT/GET) |
152
154
  | **SSH** | `tenki_update_ssh_keys` · `tenki_issue_ssh_cert` · `tenki_list_ssh_gateways` |
153
155
  | **Snapshots** | create · get · list · list-session · list-workspace · list-dangling · update · delete · get-download-url |
154
156
  | **Volumes** | create · get · list · update · delete · resize · attach · detach |
155
- | **Templates** | create · get · list · update · delete · build · cancel-build · get-build · list-active-builds |
156
- | **Workspace** | `tenki_get_workspace_usage` · `tenki_get_workspace_settings` · `tenki_update_workspace_settings` · `tenki_get_snapshot_retention_settings` · `tenki_update_snapshot_retention_settings` |
157
+ | **Egress policies** | create · get · list · update · delete (named workspace allowlists that `tenki_create_sandbox` pins via `egress_policies`) |
158
+ | **Templates** | create · get · list · update · delete · build · cancel-build · get-build · list-active-builds · list-builds · delete-build |
159
+ | **Workspace** | `tenki_get_workspace_usage` · `tenki_list_ci_runner_sizes` · `tenki_get_workspace_preview_domains` · `tenki_update_workspace_preview_domains` |
157
160
 
158
161
  Full per-release breakdown in [CHANGELOG.md](CHANGELOG.md).
159
162
 
163
+ **Deprecated upstream, still served:** `tenki_expose_port` / `tenki_unexpose_port` / `tenki_list_exposed_ports` (the API prefers the preview-URL tools), `tenki_list_workspace_sandboxes` / `tenki_list_workspace_snapshots` (prefer the credential-scoped lists), and the `ExecuteCommand` RPC behind `tenki_exec` / `tenki_run_code` / `tenki_move_path` (successor is the data-plane `Run` stream, which needs an HTTP/2 transport this server does not have yet). The parity audit prints these on every run.
164
+
160
165
  ## Auth
161
166
 
162
167
  Set one of `TENKI_API_KEY` or `TENKI_AUTH_TOKEN` — when both are set, `TENKI_AUTH_TOKEN` wins. The header is chosen by token prefix: `tk_…` → `Authorization: Bearer`, `ory_st_…` → `X-Session-Token`, otherwise a session cookie. Override the endpoint with `TENKI_API_ENDPOINT` (default `https://api.tenki.cloud`).
@@ -200,7 +205,7 @@ The wire details are ported from the live-verified [n8n community node](https://
200
205
 
201
206
  This server holds a Tenki API key and can run code + spend credits, so treat it as a capability. Full model + [CSA MCP Server Top-10](https://modelcontextprotocol-security.io/top10/server/) mapping in **[SECURITY.md](SECURITY.md)**. Quick controls:
202
207
 
203
- - **Least privilege:** every tool carries MCP annotations (`readOnlyHint` / `destructiveHint`). Run `TENKI_MCP_READONLY=1` for an inspection-only server (read tools only), or `TENKI_MCP_DISABLED_TOOLS=tenki_run_code,…` to drop specific tools.
208
+ - **Least privilege:** every tool carries a title and MCP annotations: `readOnlyHint` on reads, and `destructiveHint` on every tool that can overwrite, modify, interrupt or delete existing state (only tools that create, resume, extend, attach or keep alive without changing existing state are non-destructive). Run `TENKI_MCP_READONLY=1` for an inspection-only server (read tools only), or `TENKI_MCP_DISABLED_TOOLS=tenki_run_code,…` to drop specific tools.
204
209
  - **HTTP transport** is loopback-only by default and requires a bearer token to expose to a network (see [Host it over HTTP](#host-it-over-http-v20-beta)).
205
210
  - **Audit:** `TENKI_MCP_AUDIT=1` logs each tool call's name to stderr.
206
211
  - **Untrusted output:** `tenki_run_code`/`tenki_exec`/`tenki_read_file` return output from untrusted code — clients should treat tool results as data, not instructions.
package/SECURITY.md CHANGED
@@ -13,8 +13,8 @@ Please **do not** open a public issue for security reports. Open a private [GitH
13
13
  ## Controls this server provides
14
14
 
15
15
  ### Least privilege (MCP-07 — the main lever)
16
- Every tool is tagged with MCP **annotations** so clients can surface/gate danger:
17
- `readOnlyHint` on inspection tools, `destructiveHint` on the 13 that delete/terminate/revoke, `openWorldHint` on all (they call an external API). Plus two env controls:
16
+ Every tool has a human-readable **title** and MCP **annotations** so clients can surface/gate danger:
17
+ `readOnlyHint` on the 35 inspection tools; `destructiveHint` on the 30 that can overwrite, modify, interrupt or delete existing state (delete/terminate/detach, but also `tenki_exec`, `tenki_write_file`, `tenki_move_path`, `tenki_git_checkout`, every `update` tool, `tenki_pause_sandbox`, `tenki_build_template` (a named build repoints `workspace/name`), and `tenki_get_upload_url`, whose signed PUT can overwrite a file); neither on the 17 additive tools that only add, never change or remove what exists: create (sandboxes, forks, clones, snapshots, volumes, templates, egress policies, preview URLs, port exposures, directories, SSH certificates), resume, extend, attach a volume, keep-alive; `openWorldHint` on all (they call an external API). Destructive is the default: a new tool is non-destructive only once it is listed as additive in `src/server.ts`. Plus two env controls:
18
18
 
19
19
  | Env | Effect |
20
20
  |---|---|
package/dist/client.d.ts CHANGED
@@ -12,6 +12,12 @@
12
12
  * ported from the live-verified n8n community node
13
13
  * (github.com/opencolin/n8n-nodes-tenki).
14
14
  */
15
+ /**
16
+ * True when the call ended on THIS side — the client's deadline or an MCP-side
17
+ * cancel — so the server never answered and the operation may still complete.
18
+ * A server error never carries the marker, even when its message says "timed out".
19
+ */
20
+ export declare function isClientAbort(e: unknown): boolean;
15
21
  export type Language = "shell" | "python" | "javascript";
16
22
  export interface ExecResult {
17
23
  command: string;
@@ -71,6 +77,10 @@ export declare class TenkiClient {
71
77
  * Retry policy: rate limits always; `unavailable` and gateway-shaped
72
78
  * 502/503/504 (load balancers and CDNs return these with an HTML or empty
73
79
  * body, no ConnectRPC code) for every method that cannot double-apply.
80
+ * For a method that cannot double-apply, a Connect `resource_exhausted` (the
81
+ * handler's own quota or limit check; edge rate limits arrive as an uncoded
82
+ * 429) or any rejection carrying error details (a fork's partial snapshot and
83
+ * children) means the handler ran, so it is never retried.
74
84
  */
75
85
  private shouldRetry;
76
86
  /** Exponential backoff with half-jitter; a Retry-After header (seconds) wins, capped. */
@@ -85,8 +95,11 @@ export declare class TenkiClient {
85
95
  * connection reset, DNS) retry for every method that cannot double-apply.
86
96
  * `service` defaults to SandboxService; pass another fully-qualified ConnectRPC
87
97
  * service (e.g. the SSH gateway service) for methods hosted elsewhere.
98
+ * `cancel` (the MCP request's abort signal) drops the HTTP request when the
99
+ * MCP client gives up, so the API sees the disconnect; a cancelled call is
100
+ * never retried.
88
101
  */
89
- control(method: string, body?: Record<string, unknown>, service?: string): Promise<Record<string, any>>;
102
+ control(method: string, body?: Record<string, unknown>, service?: string, cancel?: AbortSignal): Promise<Record<string, any>>;
90
103
  /**
91
104
  * Mint (and cache per session) the data-plane endpoint + session certificate.
92
105
  * A credential with no parseable expiry is cached for credTtlMs — never
@@ -132,6 +145,8 @@ export declare class TenkiClient {
132
145
  timeoutMs?: number;
133
146
  intervalMs?: number;
134
147
  }): Promise<Record<string, any>>;
148
+ /** A file's raw bytes. The data plane's ReadFile has no range support, so this is always the whole file. */
149
+ readFileBytes(sessionId: string, path: string): Promise<Buffer>;
135
150
  readTextFile(sessionId: string, path: string): Promise<string>;
136
151
  writeTextFile(sessionId: string, path: string, text: string): Promise<Record<string, any>>;
137
152
  /**
package/dist/client.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * ported from the live-verified n8n community node
13
13
  * (github.com/opencolin/n8n-nodes-tenki).
14
14
  */
15
+ import { isApiKey } from "./env.js";
15
16
  const CONTROL_SERVICE = "tenki.sandbox.v1.SandboxService";
16
17
  const DATA_SERVICE = "tenki.sandbox.v1.SandboxSessionDataPlaneService";
17
18
  const DEFAULT_BASE_URL = "https://api.tenki.cloud";
@@ -32,7 +33,7 @@ const RATE_LIMIT_CODES = new Set(["rate_limited", "ratelimited", "resource_exhau
32
33
  // Skipping a teardown retry is the expensive mistake: runCode's finally
33
34
  // swallows terminate errors, so a transient blip would silently leave a
34
35
  // billing sandbox running.
35
- const NON_IDEMPOTENT_METHOD = /^(Create|Build|Publish|Resume|Extend|ExecuteCommand)/;
36
+ const NON_IDEMPOTENT_METHOD = /^(Create|Build|Publish|Resume|Extend|Fork|ExecuteCommand)/;
36
37
  // Data-plane methods safe to retry: read-shaped only. A retried Remove that
37
38
  // had actually applied would surface a spurious "not found" for an op that
38
39
  // succeeded.
@@ -43,6 +44,7 @@ const RETRY_AFTER_CAP_MS = 30_000;
43
44
  const DEFAULT_TIMEOUT_MS = 30_000;
44
45
  const DEFAULT_EXEC_TIMEOUT_MS = 630_000; // runCode's 600s hard cap + margin
45
46
  const EXEC_TIMEOUT_MARGIN_MS = 30_000; // headroom over a command's own timeout
47
+ const CREATE_WAIT_READY_TIMEOUT_MS = 90_000; // server holds wait_ready up to 60s + margin
46
48
  /**
47
49
  * Methods whose RPC does not return until a long storage/VM operation finishes,
48
50
  * so the 30s default is far too short. A timeout on these is worse than slow:
@@ -58,8 +60,26 @@ const EXEC_TIMEOUT_MARGIN_MS = 30_000; // headroom over a command's own timeout
58
60
  * ~278ms, so giving them a 10-minute budget would only delay a genuinely hung
59
61
  * call. Their orphan-on-timeout hazard is real but needs idempotency, not a
60
62
  * bigger timeout.
63
+ *
64
+ * ForkSession holds until its snapshot is READY and every child exists (and is
65
+ * RUNNING with wait); if the caller disconnects first the server terminates the
66
+ * children it could not deliver, so a short budget would destroy the fork.
67
+ *
68
+ * ResumeSession (since tenki-app #6486, sandbox-engine v2.35.0) holds until the
69
+ * session leaves RESUMING. The server's wait has no deadline of its own, and
70
+ * the restore it waits on runs detached for up to 6 minutes
71
+ * (resumeDispatchDetachedTimeout), so a 30s budget reports a slow but
72
+ * successful resume as failed.
61
73
  */
62
- const SLOW_METHOD = /^(CreateSnapshot|PauseSession)$/;
74
+ const SLOW_METHOD = /^(CreateSnapshot|PauseSession|ForkSession|ResumeSession)$/;
75
+ /**
76
+ * True when the call ended on THIS side — the client's deadline or an MCP-side
77
+ * cancel — so the server never answered and the operation may still complete.
78
+ * A server error never carries the marker, even when its message says "timed out".
79
+ */
80
+ export function isClientAbort(e) {
81
+ return typeof e?.clientAbort === "string";
82
+ }
63
83
  const DEFAULT_SLOW_TIMEOUT_MS = 600_000;
64
84
  // Session-credential cache lifetime handling: a credential whose expiry the API
65
85
  // omits (or that we cannot parse) is assumed valid only briefly — never forever —
@@ -98,11 +118,11 @@ function shellQuote(value) {
98
118
  }
99
119
  /**
100
120
  * Tenki's auth header is chosen by token prefix (verbatim from the SDK's auth.ts):
101
- * `tk_` → Bearer, `ory_st_` → X-Session-Token, otherwise a `tenki_session` cookie.
121
+ * `lx_`/`tk_` → Bearer, `ory_st_` → X-Session-Token, otherwise a `tenki_session` cookie.
102
122
  */
103
123
  function authHeaders(token) {
104
124
  const t = token.trim();
105
- if (t.startsWith("tk_"))
125
+ if (isApiKey(t))
106
126
  return { Authorization: `Bearer ${t}` };
107
127
  if (t.startsWith("ory_st_"))
108
128
  return { "X-Session-Token": t };
@@ -150,8 +170,14 @@ export class TenkiClient {
150
170
  * the command's own timeout (plus margin) instead of the unary default.
151
171
  */
152
172
  timeoutFor(method, body) {
173
+ // With `async: true` these RPCs return once the work is accepted, so the
174
+ // long storage budget would only delay a genuinely hung call.
153
175
  if (SLOW_METHOD.test(method))
154
- return this.slowTimeoutMs;
176
+ return body.async === true ? this.timeoutMs : this.slowTimeoutMs;
177
+ // CreateSession{waitReady} is held server-side for up to 60s (createWaitReadyMaxWait);
178
+ // a 30s client budget would abort a slow boot and orphan the (billing) sandbox.
179
+ if (method === "CreateSession" && body.waitReady === true)
180
+ return Math.max(this.timeoutMs, CREATE_WAIT_READY_TIMEOUT_MS);
155
181
  if (method !== "ExecuteCommand")
156
182
  return this.timeoutMs;
157
183
  const t = typeof body.timeout === "string" ? Number.parseInt(body.timeout, 10) : Number.NaN; // "30s"
@@ -163,17 +189,21 @@ export class TenkiClient {
163
189
  * response whose headers arrive but whose body stalls must fail with the
164
190
  * same friendly, retryable timeout as a connection that never responds.
165
191
  */
166
- async fetchTextWithTimeout(url, init, timeoutMs, what) {
192
+ async fetchTextWithTimeout(url, init, timeoutMs, what, cancel) {
167
193
  const budget = Math.max(1, Math.floor(timeoutMs));
194
+ const timeout = AbortSignal.timeout(budget);
168
195
  try {
169
- const res = await fetch(url, { ...init, signal: AbortSignal.timeout(budget) });
196
+ const res = await fetch(url, { ...init, signal: cancel ? AbortSignal.any([timeout, cancel]) : timeout });
170
197
  const text = await res.text();
171
198
  return { ok: res.ok, status: res.status, headers: res.headers, text };
172
199
  }
173
200
  catch (e) {
174
201
  const name = e?.name;
202
+ if (cancel?.aborted && !timeout.aborted) {
203
+ throw Object.assign(new Error(`Tenki ${what} was cancelled by the MCP client before it returned.`), { clientAbort: "cancelled" });
204
+ }
175
205
  if (name === "TimeoutError" || name === "AbortError") {
176
- throw new Error(`Tenki ${what} timed out after ${budget}ms with no response.`);
206
+ throw Object.assign(new Error(`Tenki ${what} timed out after ${budget}ms with no response.`), { clientAbort: "timeout" });
177
207
  }
178
208
  throw e;
179
209
  }
@@ -182,8 +212,15 @@ export class TenkiClient {
182
212
  * Retry policy: rate limits always; `unavailable` and gateway-shaped
183
213
  * 502/503/504 (load balancers and CDNs return these with an HTML or empty
184
214
  * body, no ConnectRPC code) for every method that cannot double-apply.
215
+ * For a method that cannot double-apply, a Connect `resource_exhausted` (the
216
+ * handler's own quota or limit check; edge rate limits arrive as an uncoded
217
+ * 429) or any rejection carrying error details (a fork's partial snapshot and
218
+ * children) means the handler ran, so it is never retried.
185
219
  */
186
- shouldRetry(method, status, code) {
220
+ shouldRetry(method, status, code, hasDetails) {
221
+ const handlerRan = hasDetails || code === "resource_exhausted" || code === "resourceexhausted";
222
+ if (handlerRan && NON_IDEMPOTENT_METHOD.test(method))
223
+ return false;
187
224
  if (status === 429 || RATE_LIMIT_CODES.has(code))
188
225
  return true;
189
226
  const transient = code === "unavailable" || status === 502 || status === 503 || status === 504;
@@ -207,8 +244,11 @@ export class TenkiClient {
207
244
  * connection reset, DNS) retry for every method that cannot double-apply.
208
245
  * `service` defaults to SandboxService; pass another fully-qualified ConnectRPC
209
246
  * service (e.g. the SSH gateway service) for methods hosted elsewhere.
247
+ * `cancel` (the MCP request's abort signal) drops the HTTP request when the
248
+ * MCP client gives up, so the API sees the disconnect; a cancelled call is
249
+ * never retried.
210
250
  */
211
- async control(method, body = {}, service = CONTROL_SERVICE) {
251
+ async control(method, body = {}, service = CONTROL_SERVICE, cancel) {
212
252
  const url = `${this.baseUrl}/${service}/${method}`;
213
253
  const deadline = Date.now() + this.timeoutFor(method, body ?? {});
214
254
  for (let attempt = 0;; attempt++) {
@@ -218,12 +258,12 @@ export class TenkiClient {
218
258
  method: "POST",
219
259
  headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...this.controlAuthHeaders() },
220
260
  body: JSON.stringify(body ?? {}),
221
- }, deadline - Date.now(), method);
261
+ }, deadline - Date.now(), method, cancel);
222
262
  }
223
263
  catch (e) {
224
264
  // Transport failure: the request may or may not have reached the
225
265
  // server, so only methods that cannot double-apply are retried.
226
- if (attempt < MAX_RETRIES && !NON_IDEMPOTENT_METHOD.test(method)) {
266
+ if (attempt < MAX_RETRIES && !cancel?.aborted && !NON_IDEMPOTENT_METHOD.test(method)) {
227
267
  const wait = this.backoffMs(attempt, null);
228
268
  if (Date.now() + wait < deadline) {
229
269
  await sleep(wait);
@@ -236,7 +276,8 @@ export class TenkiClient {
236
276
  return JSON.parse(res.text);
237
277
  const parsed = safeJson(res.text);
238
278
  const code = typeof parsed?.code === "string" ? parsed.code.toLowerCase() : "";
239
- if (attempt < MAX_RETRIES && this.shouldRetry(method, res.status, code)) {
279
+ const details = Array.isArray(parsed?.details) ? parsed.details : [];
280
+ if (attempt < MAX_RETRIES && this.shouldRetry(method, res.status, code, details.length > 0)) {
240
281
  const wait = this.backoffMs(attempt, res.headers.get("retry-after"));
241
282
  if (Date.now() + wait < deadline) {
242
283
  await sleep(wait);
@@ -244,7 +285,8 @@ export class TenkiClient {
244
285
  }
245
286
  }
246
287
  const msg = parsed?.message || res.text || `HTTP ${res.status}`;
247
- throw new Error(`Tenki ${method} failed (${res.status}${code ? ` ${code}` : ""}): ${msg}`);
288
+ // Connect error details (type, value, optional protojson `debug`) carry partial results, e.g. a failed fork's snapshot.
289
+ throw Object.assign(new Error(`Tenki ${method} failed (${res.status}${code ? ` ${code}` : ""}): ${msg}`), { code, details });
248
290
  }
249
291
  }
250
292
  /**
@@ -405,10 +447,14 @@ export class TenkiClient {
405
447
  await sleep(intervalMs);
406
448
  }
407
449
  }
408
- async readTextFile(sessionId, path) {
450
+ /** A file's raw bytes. The data plane's ReadFile has no range support, so this is always the whole file. */
451
+ async readFileBytes(sessionId, path) {
409
452
  const resp = await this.data(sessionId, "ReadFile", { path });
410
453
  const content = (resp.content ?? resp.data ?? resp.file?.content ?? "");
411
- return content ? Buffer.from(content, "base64").toString("utf8") : "";
454
+ return content ? Buffer.from(content, "base64") : Buffer.alloc(0);
455
+ }
456
+ async readTextFile(sessionId, path) {
457
+ return (await this.readFileBytes(sessionId, path)).toString("utf8");
412
458
  }
413
459
  async writeTextFile(sessionId, path, text) {
414
460
  const content = Buffer.from(text, "utf8").toString("base64");
package/dist/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Every `LUXORAI_<suffix>` is tried (in order) before any legacy `TENKI_<suffix>`. Empty or blank values fall through. */
2
+ export declare function readEnv(...suffixes: string[]): string | undefined;
3
+ /** The env var name {@link readEnv} resolved, for diagnostics. */
4
+ export declare function resolvedEnvName(env: NodeJS.ProcessEnv, ...suffixes: string[]): string | undefined;
5
+ /** API keys carry `lx_` (Luxor AI) or the legacy `tk_` prefix. */
6
+ export declare function isApiKey(token: string): boolean;
package/dist/env.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Every `LUXORAI_<suffix>` is tried (in order) before any legacy `TENKI_<suffix>`. Empty or blank values fall through. */
2
+ export function readEnv(...suffixes) {
3
+ for (const prefix of ["LUXORAI_", "TENKI_"]) {
4
+ for (const suffix of suffixes) {
5
+ const value = process.env[`${prefix}${suffix}`];
6
+ if (value?.trim())
7
+ return value;
8
+ }
9
+ }
10
+ return undefined;
11
+ }
12
+ /** The env var name {@link readEnv} resolved, for diagnostics. */
13
+ export function resolvedEnvName(env, ...suffixes) {
14
+ for (const prefix of ["LUXORAI_", "TENKI_"]) {
15
+ for (const suffix of suffixes) {
16
+ if (env[`${prefix}${suffix}`]?.trim())
17
+ return `${prefix}${suffix}`;
18
+ }
19
+ }
20
+ return undefined;
21
+ }
22
+ /** API keys carry `lx_` (Luxor AI) or the legacy `tk_` prefix. */
23
+ export function isApiKey(token) {
24
+ return token.startsWith("lx_") || token.startsWith("tk_");
25
+ }
package/dist/http.js CHANGED
@@ -17,8 +17,9 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
17
17
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
18
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
19
  import { TenkiClient } from "./client.js";
20
- import { OAuthResourceRoutes, OAuthTokenVerifier, authorizationBinding, bearerToken, loadOAuthConfig, } from "./oauth.js";
20
+ import { OAuthResourceRoutes, OAuthTokenVerifier, authorizationBinding, bearerToken, loadOAuthConfig, metadataUrlForHost, } from "./oauth.js";
21
21
  import { createServer } from "./server.js";
22
+ import { readEnv } from "./env.js";
22
23
  const MAX_BODY_BYTES = 1 << 20; // 1 MiB — reject larger POST bodies (memory-DoS guard)
23
24
  const MAX_SESSIONS = 256; // cap concurrent sessions (init-flood DoS guard)
24
25
  const SESSION_IDLE_MS = 30 * 60 * 1000; // reap sessions idle longer than this
@@ -109,16 +110,18 @@ function authOk(header, expected) {
109
110
  * an ephemeral `port: 0` bind still accepts its own address). Anything else —
110
111
  * e.g. a rebound attacker domain — is rejected by the transport.
111
112
  */
112
- function allowedHostsFor(server, host, port, publicUrl) {
113
+ function allowedHostsFor(server, host, port, publicUrls = []) {
113
114
  const addr = server.address();
114
115
  const bound = addr && typeof addr === "object" ? addr.port : port;
115
116
  const ports = Array.from(new Set([port, bound]));
116
117
  const hosts = Array.from(new Set([host, "127.0.0.1", "localhost", "[::1]", "::1"]));
117
- const configured = (process.env.TENKI_MCP_ALLOWED_HOSTS || "")
118
+ const configured = (readEnv("MCP_ALLOWED_HOSTS") || "")
118
119
  .split(",")
119
120
  .map((value) => value.trim())
120
121
  .filter(Boolean);
121
- if (publicUrl) {
122
+ for (const publicUrl of publicUrls) {
123
+ if (!URL.canParse(publicUrl))
124
+ continue;
122
125
  const url = new URL(publicUrl);
123
126
  configured.push(url.host, url.hostname);
124
127
  }
@@ -134,8 +137,8 @@ function oauthUnauthorized(res, metadataUrl, scope) {
134
137
  .end(JSON.stringify({ error: "unauthorized" }));
135
138
  }
136
139
  export function startHttp(client, port) {
137
- const host = process.env.TENKI_MCP_HTTP_HOST || "127.0.0.1";
138
- const httpToken = process.env.TENKI_MCP_HTTP_TOKEN || "";
140
+ const host = readEnv("MCP_HTTP_HOST") || "127.0.0.1";
141
+ const httpToken = readEnv("MCP_HTTP_TOKEN") || "";
139
142
  const isLoopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
140
143
  const oauthConfig = loadOAuthConfig();
141
144
  const oauthVerifier = oauthConfig ? new OAuthTokenVerifier(oauthConfig) : null;
@@ -176,7 +179,7 @@ export function startHttp(client, port) {
176
179
  const token = bearerToken(req.headers["authorization"]);
177
180
  delegated = token ? await oauthVerifier.verify(token) : null;
178
181
  if (!delegated) {
179
- oauthUnauthorized(res, oauthConfig.metadataUrl, oauthConfig.scope);
182
+ oauthUnauthorized(res, metadataUrlForHost(oauthConfig, req.headers.host), oauthConfig.scope);
180
183
  return;
181
184
  }
182
185
  }
@@ -189,7 +192,7 @@ export function startHttp(client, port) {
189
192
  if (existingEntry?.authorization &&
190
193
  delegated &&
191
194
  existingEntry.authorization.binding !== authorizationBinding(delegated)) {
192
- oauthUnauthorized(res, oauthConfig.metadataUrl, oauthConfig.scope);
195
+ oauthUnauthorized(res, metadataUrlForHost(oauthConfig, req.headers.host), oauthConfig.scope);
193
196
  return;
194
197
  }
195
198
  if (existingEntry?.authorization && delegated) {
@@ -229,7 +232,7 @@ export function startHttp(client, port) {
229
232
  // DNS-rebinding defense: only accept these Host headers, so a rebound
230
233
  // attacker-domain request from a browser is rejected.
231
234
  enableDnsRebindingProtection: true,
232
- allowedHosts: allowedHostsFor(httpServer, host, port, oauthConfig?.publicUrl),
235
+ allowedHosts: allowedHostsFor(httpServer, host, port, oauthConfig ? [oauthConfig.publicUrl, ...oauthConfig.resources] : []),
233
236
  onsessioninitialized: (id) => {
234
237
  sessions.set(id, {
235
238
  transport,
@@ -244,7 +247,7 @@ export function startHttp(client, port) {
244
247
  sessions.delete(id);
245
248
  };
246
249
  const sessionClient = delegated
247
- ? new TenkiClient("", process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined, {
250
+ ? new TenkiClient("", readEnv("API_ENDPOINT", "API_URL") || undefined, {
248
251
  workspaceId: delegated.workspaceId,
249
252
  bearerTokenProvider: () => sessionAuthorization.apiDelegationToken,
250
253
  })
package/dist/index.js CHANGED
@@ -17,32 +17,33 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { TenkiClient } from "./client.js";
18
18
  import { createServer } from "./server.js";
19
19
  import { startHttp } from "./http.js";
20
+ import { readEnv } from "./env.js";
20
21
  // A missing credential is NOT fatal: exiting here is reported by MCP clients as
21
22
  // an opaque "server failed to start" with stderr usually swallowed, leaving the
22
23
  // user with no idea a token is needed. Instead the server boots with only
23
24
  // tenki_auth_status registered (see createServer), so an agent can ask what is
24
25
  // wrong and relay the fix.
25
- const token = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY;
26
- const oauthHttp = Boolean(process.env.TENKI_MCP_OAUTH_ISSUER);
26
+ const token = readEnv("AUTH_TOKEN", "API_KEY");
27
+ const oauthHttp = Boolean(readEnv("MCP_OAUTH_ISSUER"));
27
28
  if (!token && !oauthHttp) {
28
29
  console.error("tenki-mcp: no credential — starting in unauthenticated mode (only tenki_auth_status is available). " +
29
30
  "Set TENKI_API_KEY (tk_…) or TENKI_AUTH_TOKEN (ory_st_…) in the server's env and restart, " +
30
31
  "e.g. claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y @tenkicloud/mcp");
31
32
  }
32
- const baseUrl = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined;
33
+ const baseUrl = readEnv("API_ENDPOINT", "API_URL") || undefined;
33
34
  /** Positive integer from env, or undefined so the client keeps its own default. */
34
- const envMs = (name) => {
35
- const n = Number.parseInt(process.env[name] ?? "", 10);
35
+ const envMs = (suffix) => {
36
+ const n = Number.parseInt(readEnv(suffix) ?? "", 10);
36
37
  return Number.isFinite(n) && n > 0 ? n : undefined;
37
38
  };
38
39
  const client = token
39
40
  ? new TenkiClient(token, baseUrl, {
40
- timeoutMs: envMs("TENKI_MCP_TIMEOUT_MS"),
41
- slowTimeoutMs: envMs("TENKI_MCP_SLOW_TIMEOUT_MS"),
41
+ timeoutMs: envMs("MCP_TIMEOUT_MS"),
42
+ slowTimeoutMs: envMs("MCP_SLOW_TIMEOUT_MS"),
42
43
  })
43
44
  : null;
44
45
  async function main() {
45
- if ((process.env.TENKI_MCP_TRANSPORT || "stdio").toLowerCase() === "http") {
46
+ if ((readEnv("MCP_TRANSPORT") || "stdio").toLowerCase() === "http") {
46
47
  const httpServer = startHttp(client, Number(process.env.PORT) || 3000);
47
48
  for (const sig of ["SIGINT", "SIGTERM"]) {
48
49
  process.on(sig, () => httpServer.close(() => process.exit(0)));
package/dist/oauth.d.ts CHANGED
@@ -2,6 +2,8 @@ import type http from "node:http";
2
2
  export interface OAuthConfig {
3
3
  issuer: string;
4
4
  resource: string;
5
+ /** Every accepted resource URI (first is `resource`), e.g. one per served domain. */
6
+ resources: string[];
5
7
  publicUrl: string;
6
8
  metadataUrl: string;
7
9
  identityUrl: string;
@@ -27,7 +29,11 @@ export declare class OAuthTokenVerifier {
27
29
  export declare class OAuthResourceRoutes {
28
30
  private readonly config;
29
31
  constructor(config: OAuthConfig);
30
- handle(_req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<boolean>;
32
+ handle(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<boolean>;
31
33
  }
34
+ /** The configured resource whose host matches the request (RFC 9728 requires an exact match), else the first. */
35
+ export declare function resourceForHost(config: Pick<OAuthConfig, "resource" | "resources">, host: string | undefined): string;
36
+ /** Protected-resource metadata URL to advertise in a challenge, on the same host as the matched resource. */
37
+ export declare function metadataUrlForHost(config: Pick<OAuthConfig, "resources" | "publicUrl" | "metadataUrl">, host: string | undefined): string;
32
38
  export declare function bearerToken(header: string | undefined): string | null;
33
39
  export declare function authorizationBinding(authorization: DelegatedAuthorization): string;
package/dist/oauth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { readEnv } from "./env.js";
2
3
  const DEFAULT_SCOPE = "mcp";
3
4
  const FETCH_TIMEOUT_MS = 8_000;
4
5
  const TOKEN_CACHE_MS = 15_000;
@@ -7,24 +8,26 @@ function trimUrl(value) {
7
8
  return value.trim().replace(/\/+$/, "");
8
9
  }
9
10
  export function loadOAuthConfig() {
10
- const issuer = trimUrl(process.env.TENKI_MCP_OAUTH_ISSUER || "");
11
- const identityUrl = trimUrl(process.env.TENKI_MCP_IDENTITY_URL || "");
12
- const identityServiceToken = (process.env.TENKI_MCP_IDENTITY_SERVICE_TOKEN || "").trim();
13
- const publicUrl = trimUrl(process.env.TENKI_MCP_PUBLIC_URL || "");
11
+ const issuer = trimUrl(readEnv("MCP_OAUTH_ISSUER") || "");
12
+ const identityUrl = trimUrl(readEnv("MCP_IDENTITY_URL") || "");
13
+ const identityServiceToken = (readEnv("MCP_IDENTITY_SERVICE_TOKEN") || "").trim();
14
+ const publicUrl = trimUrl(readEnv("MCP_PUBLIC_URL") || "");
14
15
  if (!issuer && !identityUrl && !identityServiceToken && !publicUrl)
15
16
  return null;
16
17
  if (!issuer || !identityUrl || !identityServiceToken || !publicUrl) {
17
18
  throw new Error("TENKI_MCP_OAUTH_ISSUER, TENKI_MCP_IDENTITY_URL, TENKI_MCP_IDENTITY_SERVICE_TOKEN, and TENKI_MCP_PUBLIC_URL must be set together.");
18
19
  }
19
- const resource = trimUrl(process.env.TENKI_MCP_OAUTH_RESOURCE || `${publicUrl}/mcp`);
20
+ const configured = (readEnv("MCP_OAUTH_RESOURCE") || "").split(",").map(trimUrl).filter(Boolean);
21
+ const resources = configured.length ? Array.from(new Set(configured)) : [`${publicUrl}/mcp`];
20
22
  return {
21
23
  issuer,
22
- resource,
24
+ resource: resources[0],
25
+ resources,
23
26
  publicUrl,
24
27
  metadataUrl: `${publicUrl}/.well-known/oauth-protected-resource/mcp`,
25
28
  identityUrl,
26
29
  identityServiceToken,
27
- scope: (process.env.TENKI_MCP_OAUTH_SCOPE || DEFAULT_SCOPE).trim(),
30
+ scope: (readEnv("MCP_OAUTH_SCOPE") || DEFAULT_SCOPE).trim(),
28
31
  };
29
32
  }
30
33
  function tokenDigest(token) {
@@ -117,10 +120,10 @@ export class OAuthResourceRoutes {
117
120
  constructor(config) {
118
121
  this.config = config;
119
122
  }
120
- async handle(_req, res, url) {
123
+ async handle(req, res, url) {
121
124
  if (url.pathname === "/.well-known/oauth-protected-resource/mcp" || url.pathname === "/.well-known/oauth-protected-resource") {
122
125
  json(res, 200, {
123
- resource: this.config.resource,
126
+ resource: resourceForHost(this.config, req.headers.host),
124
127
  authorization_servers: [this.config.issuer],
125
128
  scopes_supported: [this.config.scope, "offline_access"],
126
129
  bearer_methods_supported: ["header"],
@@ -135,6 +138,23 @@ export class OAuthResourceRoutes {
135
138
  return false;
136
139
  }
137
140
  }
141
+ /** The configured resource whose host matches the request (RFC 9728 requires an exact match), else the first. */
142
+ export function resourceForHost(config, host) {
143
+ const wanted = (host ?? "").toLowerCase();
144
+ return config.resources.find((resource) => urlHost(resource) === wanted) ?? config.resource;
145
+ }
146
+ /** Protected-resource metadata URL to advertise in a challenge, on the same host as the matched resource. */
147
+ export function metadataUrlForHost(config, host) {
148
+ const wanted = (host ?? "").toLowerCase();
149
+ const matched = config.resources.find((resource) => urlHost(resource) === wanted);
150
+ if (!matched || wanted === urlHost(config.publicUrl))
151
+ return config.metadataUrl;
152
+ return `${new URL(matched).origin}/.well-known/oauth-protected-resource/mcp`;
153
+ }
154
+ /** Lowercased host of a URL, or undefined when it does not parse (a resource may be any absolute URI). */
155
+ function urlHost(value) {
156
+ return URL.canParse(value) ? new URL(value).host.toLowerCase() : undefined;
157
+ }
138
158
  export function bearerToken(header) {
139
159
  const match = /^Bearer (.+)$/.exec(header ?? "");
140
160
  return match?.[1]?.trim() || null;