@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.
- package/CHANGELOG.md +37 -0
- package/README.md +13 -8
- package/SECURITY.md +2 -2
- package/dist/client.d.ts +16 -1
- package/dist/client.js +62 -16
- package/dist/env.d.ts +6 -0
- package/dist/env.js +25 -0
- package/dist/http.js +13 -10
- package/dist/index.js +9 -8
- package/dist/oauth.d.ts +7 -1
- package/dist/oauth.js +29 -9
- package/dist/server.d.ts +25 -5
- package/dist/server.js +88 -53
- package/dist/tools/artifacts.d.ts +3 -3
- package/dist/tools/artifacts.js +6 -6
- package/dist/tools/auth_status.d.ts +3 -3
- package/dist/tools/auth_status.js +8 -9
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +78 -0
- package/dist/tools/egress.d.ts +38 -0
- package/dist/tools/egress.js +153 -0
- package/dist/tools/exec.js +1 -1
- package/dist/tools/files.js +36 -3
- package/dist/tools/files_ops.js +9 -1
- package/dist/tools/git.d.ts +0 -1
- package/dist/tools/git.js +85 -23
- package/dist/tools/ports.d.ts +8 -1
- package/dist/tools/ports.js +24 -3
- package/dist/tools/previews.js +59 -22
- package/dist/tools/sandboxes.d.ts +1 -1
- package/dist/tools/sandboxes.js +342 -26
- package/dist/tools/sessions_admin.js +63 -23
- package/dist/tools/snapshots.js +41 -22
- package/dist/tools/ssh.d.ts +3 -3
- package/dist/tools/ssh.js +35 -9
- package/dist/tools/templates.d.ts +2 -1
- package/dist/tools/templates.js +36 -16
- package/dist/tools/titles.d.ts +8 -0
- package/dist/tools/titles.js +102 -0
- package/dist/tools/volumes.js +36 -11
- package/dist/tools/workspace.d.ts +9 -1
- package/dist/tools/workspace.js +29 -90
- 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
|
-
**
|
|
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** | `
|
|
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
|
-
| **
|
|
156
|
-
| **
|
|
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
|
|
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
|
|
17
|
-
`readOnlyHint` on inspection tools
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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").
|
|
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,
|
|
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 = (
|
|
118
|
+
const configured = (readEnv("MCP_ALLOWED_HOSTS") || "")
|
|
118
119
|
.split(",")
|
|
119
120
|
.map((value) => value.trim())
|
|
120
121
|
.filter(Boolean);
|
|
121
|
-
|
|
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 =
|
|
138
|
-
const httpToken =
|
|
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.
|
|
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.
|
|
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
|
|
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("",
|
|
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 =
|
|
26
|
-
const oauthHttp = Boolean(
|
|
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 =
|
|
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 = (
|
|
35
|
-
const n = Number.parseInt(
|
|
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("
|
|
41
|
-
slowTimeoutMs: envMs("
|
|
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 ((
|
|
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(
|
|
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(
|
|
11
|
-
const identityUrl = trimUrl(
|
|
12
|
-
const identityServiceToken = (
|
|
13
|
-
const publicUrl = trimUrl(
|
|
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
|
|
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: (
|
|
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(
|
|
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.
|
|
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;
|