@luxorai/mcp 0.0.0-stage → 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 (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +192 -0
  6. package/dist/client.js +621 -0
  7. package/dist/env.d.ts +6 -0
  8. package/dist/env.js +25 -0
  9. package/dist/http.d.ts +17 -0
  10. package/dist/http.js +297 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +61 -0
  13. package/dist/oauth.d.ts +39 -0
  14. package/dist/oauth.js +166 -0
  15. package/dist/server.d.ts +50 -0
  16. package/dist/server.js +238 -0
  17. package/dist/tools/artifacts.d.ts +16 -0
  18. package/dist/tools/artifacts.js +19 -0
  19. package/dist/tools/auth_status.d.ts +35 -0
  20. package/dist/tools/auth_status.js +108 -0
  21. package/dist/tools/common.d.ts +70 -0
  22. package/dist/tools/common.js +138 -0
  23. package/dist/tools/egress.d.ts +38 -0
  24. package/dist/tools/egress.js +153 -0
  25. package/dist/tools/exec.d.ts +4 -0
  26. package/dist/tools/exec.js +120 -0
  27. package/dist/tools/files.d.ts +4 -0
  28. package/dist/tools/files.js +55 -0
  29. package/dist/tools/files_ops.d.ts +12 -0
  30. package/dist/tools/files_ops.js +62 -0
  31. package/dist/tools/git.d.ts +3 -0
  32. package/dist/tools/git.js +92 -0
  33. package/dist/tools/identity.d.ts +4 -0
  34. package/dist/tools/identity.js +5 -0
  35. package/dist/tools/ports.d.ts +11 -0
  36. package/dist/tools/ports.js +27 -0
  37. package/dist/tools/previews.d.ts +18 -0
  38. package/dist/tools/previews.js +117 -0
  39. package/dist/tools/run.d.ts +4 -0
  40. package/dist/tools/run.js +11 -0
  41. package/dist/tools/sandboxes.d.ts +4 -0
  42. package/dist/tools/sandboxes.js +388 -0
  43. package/dist/tools/sessions_admin.d.ts +9 -0
  44. package/dist/tools/sessions_admin.js +102 -0
  45. package/dist/tools/snapshots.d.ts +11 -0
  46. package/dist/tools/snapshots.js +98 -0
  47. package/dist/tools/ssh.d.ts +15 -0
  48. package/dist/tools/ssh.js +43 -0
  49. package/dist/tools/templates.d.ts +15 -0
  50. package/dist/tools/templates.js +162 -0
  51. package/dist/tools/titles.d.ts +8 -0
  52. package/dist/tools/titles.js +102 -0
  53. package/dist/tools/volumes.d.ts +16 -0
  54. package/dist/tools/volumes.js +102 -0
  55. package/dist/tools/workspace.d.ts +12 -0
  56. package/dist/tools/workspace.js +68 -0
  57. package/package.json +60 -4
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ import { listQuery, ok, protoEnum, searchSchema, sessionIdSchema, tagsPatch, tagsSchema } from "./common.js";
3
+ const SESSION_STATES = ["CREATING", "RUNNING", "PAUSING", "PAUSED", "RESUMING", "TERMINATING", "TERMINATED", "USER_SHUTDOWN"];
4
+ /**
5
+ * Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
6
+ * extension, mutable-field updates, bulk termination, activity heartbeats, and
7
+ * workspace-scoped listing. The lifecycle basics
8
+ * (create/get/list/terminate/pause/resume) live in sandboxes.ts.
9
+ */
10
+ export function registerSessionsAdmin(server, client) {
11
+ server.tool("tenki_extend_sandbox", "Extend a running sandbox's wall-clock lifetime by N seconds so it isn't auto-terminated at its max-duration cap.", {
12
+ session_id: sessionIdSchema.describe("The sandbox/session ID to extend."),
13
+ additional_duration_seconds: z
14
+ .number()
15
+ .int()
16
+ .positive()
17
+ .describe("Extra lifetime to add, in seconds (sent as a Duration string, e.g. 3600s)."),
18
+ }, async ({ session_id, additional_duration_seconds }) => ok(await client.control("ExtendSession", {
19
+ sessionId: session_id,
20
+ additionalDuration: `${additional_duration_seconds}s`,
21
+ })));
22
+ server.tool("tenki_update_sandbox", "Update mutable fields on an existing sandbox: name, tags, sticky flag, or max duration. The idle timeout is NOT updatable after creation (UpdateSession has no such field). max_duration_seconds requires an explicit sticky value on the wire: when you pass it without `sticky` the tool checks the sandbox and sends sticky=false only if it is not already sticky (a sticky sandbox is refused, to avoid silently revoking stickiness); sticky=true discards max_duration with a warning. The response carries any `warnings` the API returned.", {
23
+ session_id: sessionIdSchema.describe("The sandbox/session ID to update."),
24
+ name: z.string().max(64).optional().describe("New human-readable name (max 64 chars)."),
25
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
26
+ clear_tags: z.boolean().optional().describe("Remove all tags from the sandbox."),
27
+ sticky: z
28
+ .boolean()
29
+ .optional()
30
+ .describe("true = keep the sandbox alive with no hard lifetime cap (not allowed for service credentials). false = ordinary lifetime: pass max_duration_seconds with it, otherwise the API resets the cap to the workspace default duration from now."),
31
+ max_duration_seconds: z
32
+ .number()
33
+ .int()
34
+ .positive()
35
+ .optional()
36
+ .describe("New hard lifetime cap in seconds (sent as a Duration string, e.g. 3600s). Sends sticky=false for a non-sticky sandbox unless sticky is given; refused on a sticky sandbox without an explicit sticky value."),
37
+ }, async ({ session_id, name, tags, clear_tags, sticky, max_duration_seconds }) => {
38
+ const tagPatch = tagsPatch(tags, clear_tags);
39
+ if (name === undefined && sticky === undefined && max_duration_seconds === undefined && !Object.keys(tagPatch).length) {
40
+ throw new Error("tenki_update_sandbox: pass at least one field to change — nothing was sent (the API would return the unchanged sandbox, which reads like a successful update).");
41
+ }
42
+ // The API rejects max_duration without an explicit sticky value, and sticky=false
43
+ // is the only combination that applies the cap. Sending false on a STICKY
44
+ // sandbox would silently revoke stickiness (hard lifetime + idle reaper), so
45
+ // the default is only filled in after checking the sandbox is not sticky.
46
+ let stickyValue = sticky;
47
+ if (stickyValue === undefined && max_duration_seconds !== undefined) {
48
+ const current = await client.control("GetSession", { sessionId: session_id });
49
+ if ((current.session ?? current)?.sticky === true) {
50
+ throw new Error("tenki_update_sandbox: this sandbox is sticky, so max_duration_seconds would revoke that — pass sticky:false explicitly to un-sticky it with this cap, or sticky:true to keep it (the cap is then ignored).");
51
+ }
52
+ stickyValue = false;
53
+ }
54
+ return ok(await client.control("UpdateSession", {
55
+ sessionId: session_id,
56
+ ...(name !== undefined ? { name } : {}),
57
+ ...tagPatch,
58
+ ...(stickyValue !== undefined ? { sticky: stickyValue } : {}),
59
+ ...(max_duration_seconds !== undefined ? { maxDuration: `${max_duration_seconds}s` } : {}),
60
+ }));
61
+ });
62
+ server.tool("tenki_terminate_sandboxes", "Terminate MULTIPLE sandboxes in one call (bulk, max 100 per call). IRREVERSIBLE — every listed sandbox and its filesystem is destroyed. The response lists `sessions` that were terminated and `failures` with a reason per id that was not. Use tenki_terminate_sandbox for a single one.", {
63
+ session_ids: z.array(sessionIdSchema).min(1).max(100).describe("The sandbox/session IDs to terminate (1-100)."),
64
+ }, async ({ session_ids }) => ok(await client.control("TerminateSessions", { sessionIds: session_ids })));
65
+ server.tool("tenki_report_sandbox_activity", "Report client-side activity on a sandbox to reset its idle timer and keep it from being reaped as idle (a keep-alive heartbeat).", { session_id: sessionIdSchema.describe("The sandbox/session ID to mark as active.") }, async ({ session_id }) => ok(await client.control("ReportSessionActivity", { sessionId: session_id })));
66
+ server.tool("tenki_list_workspace_sandboxes", "List every sandbox belonging to a specific workspace (defaults to the API key's workspace) with server-side search (name, id, snapshot names/ids, image refs), state/tag/sticky filters, sorting and optional state-count facets — useful for finding a sandbox by name or spotting leaked, still-billing ones. NOTE: the underlying RPC (ListWorkspaceSandboxes) is marked deprecated upstream, but it is the only list with search/sort; tenki_list_sandboxes is the plain credential-scoped list.", {
67
+ workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
68
+ include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
69
+ search: searchSchema.describe("Free-text search over name, id, snapshot names/ids and published image refs (max 256 chars)."),
70
+ states: z.array(z.enum(SESSION_STATES)).max(16).optional().describe("Only sandboxes in any of these states (empty = no filter)."),
71
+ state: z.enum(SESSION_STATES).optional().describe("Only sandboxes in this one state (combines with states)."),
72
+ tags: z.array(z.string()).optional().describe("Only sandboxes carrying all of these tags."),
73
+ sticky: z.boolean().optional().describe("Only sticky (true) or only non-sticky (false) sandboxes."),
74
+ order: z
75
+ .enum(["STATE_THEN_CREATED"])
76
+ .optional()
77
+ .describe("Default order is newest first; STATE_THEN_CREATED groups running, then paused, then terminated (newest first within each). Ignored when sort_by is set."),
78
+ sort_by: z
79
+ .enum(["NAME", "STATE", "CREATED_AT", "CPU_CORES", "MEMORY_MB", "DISK_SIZE_GB", "EXPIRATION", "SNAPSHOT_STORAGE"])
80
+ .optional()
81
+ .describe("Column to sort by (overrides order)."),
82
+ sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
83
+ include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope (facets)."),
84
+ page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (max 100)."),
85
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
86
+ }, async ({ workspace_id, include_terminated, search, states, state, tags, sticky, order, sort_by, sort_desc, include_facets, page_size, page_token }) => {
87
+ const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
88
+ return ok(await client.control("ListWorkspaceSandboxes", {
89
+ ...(workspaceId ? { workspaceId } : {}),
90
+ ...(include_terminated ? { includeTerminated: true } : {}),
91
+ ...(state ? { state: protoEnum("SESSION_STATE", state) } : {}),
92
+ ...(states && states.length ? { states: states.map((s) => protoEnum("SESSION_STATE", s)) } : {}),
93
+ ...(tags && tags.length ? { tags } : {}),
94
+ ...(sticky !== undefined ? { sticky } : {}),
95
+ ...(order ? { order: protoEnum("SANDBOX_LIST_ORDER", order) } : {}),
96
+ ...(sort_by ? { sortBy: protoEnum("SANDBOX_SORT_FIELD", sort_by) } : {}),
97
+ ...listQuery(search, include_facets, sort_desc),
98
+ ...(page_size ? { pageSize: page_size } : {}),
99
+ ...(page_token ? { pageToken: page_token } : {}),
100
+ }));
101
+ });
102
+ }
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /**
4
+ * Snapshots — capture a sandbox's disk + memory as a reusable image.
5
+ *
6
+ * Restoring is deliberately NOT a tool here: booting a fresh sandbox from a
7
+ * snapshot is `tenki_create_sandbox` with `snapshot_id` (CreateSession under the
8
+ * hood). Attached volumes are NOT captured in a snapshot and must be re-attached
9
+ * to the restored session separately.
10
+ */
11
+ export declare function registerSnapshots(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import { ok, sessionIdSchema, tagsPatch, tagsSchema } from "./common.js";
3
+ /**
4
+ * Snapshots — capture a sandbox's disk + memory as a reusable image.
5
+ *
6
+ * Restoring is deliberately NOT a tool here: booting a fresh sandbox from a
7
+ * snapshot is `tenki_create_sandbox` with `snapshot_id` (CreateSession under the
8
+ * hood). Attached volumes are NOT captured in a snapshot and must be re-attached
9
+ * to the restored session separately.
10
+ */
11
+ export function registerSnapshots(server, client) {
12
+ server.tool("tenki_create_snapshot", "Capture a running sandbox's disk and memory as a reusable snapshot (attached volumes are NOT captured); boot a new sandbox from it later with tenki_create_sandbox + snapshot_id.", {
13
+ session_id: sessionIdSchema.describe("The sandbox session to snapshot."),
14
+ name: z.string().optional().describe("Human-readable name for the snapshot."),
15
+ expires_at: z
16
+ .string()
17
+ .optional()
18
+ .describe("RFC-3339 / ISO-8601 timestamp at which the snapshot is auto-deleted. Omit to keep indefinitely."),
19
+ store_raw_image: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("Also store the raw disk image alongside the snapshot (needed to download it later)."),
23
+ async: z
24
+ .boolean()
25
+ .optional()
26
+ .describe("Return as soon as the snapshot row exists and poll tenki_get_snapshot for READY, instead of blocking until the capture finishes (default false)."),
27
+ }, async ({ session_id, name, expires_at, store_raw_image, async: isAsync }) => ok(await client.control("CreateSnapshot", {
28
+ sessionId: session_id,
29
+ ...(name ? { name } : {}),
30
+ ...(expires_at ? { expiresAt: expires_at } : {}),
31
+ ...(store_raw_image !== undefined ? { storeRawImage: store_raw_image } : {}),
32
+ ...(isAsync ? { async: true } : {}),
33
+ })));
34
+ server.tool("tenki_get_snapshot", "Fetch one snapshot's status and metadata by ID.", { snapshot_id: z.string().describe("Snapshot id, from tenki_create_snapshot or tenki_list_snapshots.") }, async ({ snapshot_id }) => ok(await client.control("GetSnapshot", { snapshotId: snapshot_id })));
35
+ server.tool("tenki_list_snapshots", "List the saved snapshots for the workspace (owner inferred from the API key).", {
36
+ page_size: z.number().int().positive().optional().describe("Rows per page."),
37
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
38
+ }, async ({ page_size, page_token }) => ok(await client.control("ListSnapshots", {
39
+ ...(page_size ? { pageSize: page_size } : {}),
40
+ ...(page_token ? { pageToken: page_token } : {}),
41
+ })));
42
+ server.tool("tenki_list_session_snapshots", "List the snapshots captured from a specific sandbox session.", {
43
+ session_id: sessionIdSchema,
44
+ page_size: z.number().int().positive().optional().describe("Rows per page."),
45
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
46
+ }, async ({ session_id, page_size, page_token }) => ok(await client.control("ListSessionSnapshots", {
47
+ sessionId: session_id,
48
+ ...(page_size ? { pageSize: page_size } : {}),
49
+ ...(page_token ? { pageToken: page_token } : {}),
50
+ })));
51
+ server.tool("tenki_list_dangling_snapshots", "List dangling snapshots — those whose source session no longer exists — for the workspace, useful for cleanup.", {
52
+ page_size: z.number().int().positive().optional().describe("Rows per page."),
53
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
54
+ }, async ({ page_size, page_token }) => ok(await client.control("ListDanglingSnapshots", {
55
+ ...(page_size ? { pageSize: page_size } : {}),
56
+ ...(page_token ? { pageToken: page_token } : {}),
57
+ })));
58
+ server.tool("tenki_update_snapshot", "Update a snapshot's mutable metadata: name, expiry (set or clear), and tags. An expired snapshot can no longer be updated, and a pause snapshot's expiry is managed by its sandbox (both failed_precondition).", {
59
+ snapshot_id: z.string().describe("Snapshot id, from tenki_create_snapshot or tenki_list_snapshots."),
60
+ name: z.string().max(64).optional().describe("New human-readable name (max 64 chars)."),
61
+ expires_at: z
62
+ .string()
63
+ .optional()
64
+ .describe("New RFC-3339 / ISO-8601 auto-delete timestamp."),
65
+ clear_expires_at: z.boolean().optional().describe("Remove the expiry so the snapshot is kept indefinitely."),
66
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
67
+ clear_tags: z.boolean().optional().describe("Remove all tags from the snapshot."),
68
+ }, async ({ snapshot_id, name, expires_at, clear_expires_at, tags, clear_tags }) => {
69
+ if (expires_at !== undefined && clear_expires_at) {
70
+ throw new Error("tenki_update_snapshot: pass either expires_at or clear_expires_at, not both — clear would win and the new expiry would be silently dropped.");
71
+ }
72
+ const tagPatch = tagsPatch(tags, clear_tags);
73
+ if (name === undefined && expires_at === undefined && !clear_expires_at && !Object.keys(tagPatch).length) {
74
+ throw new Error("tenki_update_snapshot: pass at least one field to change — nothing was sent.");
75
+ }
76
+ return ok(await client.control("UpdateSnapshot", {
77
+ snapshotId: snapshot_id,
78
+ ...(name !== undefined ? { name } : {}),
79
+ ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
80
+ ...(clear_expires_at ? { clearExpiresAt: true } : {}),
81
+ ...tagPatch,
82
+ }));
83
+ });
84
+ server.tool("tenki_delete_snapshot", "Permanently delete a snapshot by ID.", { snapshot_id: z.string().describe("Snapshot id, from tenki_create_snapshot or tenki_list_snapshots.") }, async ({ snapshot_id }) => ok(await client.control("DeleteSnapshot", { snapshotId: snapshot_id })));
85
+ server.tool("tenki_get_snapshot_download_url", "Get a short-lived, pre-signed URL to download a snapshot's raw disk image (requires the snapshot to have been created with store_raw_image).", { snapshot_id: z.string().describe("Snapshot id, from tenki_create_snapshot or tenki_list_snapshots.") }, async ({ snapshot_id }) => ok(await client.control("GetSnapshotDownloadURL", { snapshotId: snapshot_id })));
86
+ server.tool("tenki_list_workspace_snapshots", "List all snapshots in a workspace (defaults to the key's first workspace). Supports pagination. NOTE: the underlying RPC (ListWorkspaceSnapshots) is deprecated upstream in favour of the credential-scoped tenki_list_snapshots; prefer that unless you need an explicit workspace_id.", {
87
+ workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
88
+ page_size: z.number().int().positive().optional().describe("Rows per page."),
89
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
90
+ }, async ({ workspace_id, page_size, page_token }) => {
91
+ const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
92
+ return ok(await client.control("ListWorkspaceSnapshots", {
93
+ ...(workspaceId ? { workspaceId } : {}),
94
+ ...(page_size ? { pageSize: page_size } : {}),
95
+ ...(page_token ? { pageToken: page_token } : {}),
96
+ }));
97
+ });
98
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * ssh.ts — SSH access tools for tenki-mcp.
3
+ *
4
+ * UpdateSSHAuthorizedKeys sets the authorized_keys on a running sandbox (on
5
+ * SandboxService). IssueSandboxSSHCert and ListActiveSSHGateways live on a
6
+ * SEPARATE ConnectRPC service (SSHGatewayClientService), reached by passing the
7
+ * service path to client.control.
8
+ *
9
+ * Request shapes follow proto/tenki/sandbox/v1/ssh_gateway_client.proto:
10
+ * IssueSandboxSSHCertRequest { session_id, public_key (32-8192 chars), requested_ttl, source_addresses[] }
11
+ * ListActiveSSHGatewaysRequest { region, session_id } — there is NO workspace_id field.
12
+ */
13
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import type { TenkiClient } from "../client.js";
15
+ export declare function registerSsh(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { ok, sessionIdSchema } from "./common.js";
3
+ const SSH_GATEWAY_SERVICE = "tenki.sandbox.v1.SSHGatewayClientService";
4
+ export function registerSsh(server, client) {
5
+ server.tool("tenki_update_ssh_keys", "Set the SSH authorized public keys on a running sandbox, enabling direct SSH access for the given keys. Replaces the current set (at least one key is required — the API has no 'clear' operation).", {
6
+ session_id: sessionIdSchema,
7
+ public_keys: z
8
+ .array(z.string().min(1))
9
+ .min(1)
10
+ .describe("SSH public keys (ssh-ed25519 …, ssh-rsa …) to authorize. Replaces the current set."),
11
+ }, async ({ session_id, public_keys }) => ok(await client.control("UpdateSSHAuthorizedKeys", { sessionId: session_id, sshAuthorizedKeys: public_keys })));
12
+ server.tool("tenki_issue_ssh_cert", "Issue a short-lived SSH certificate for a public key, authorizing SSH access to a sandbox via the SSH gateway. Returns the certificate, the CA public key (caPub) for known_hosts, expiry, a renewal hint, and pinHostKey — when true, verify the gateway host certificate against caPub (e.g. a `@cert-authority` known_hosts line) rather than trusting on first use.", {
13
+ session_id: sessionIdSchema,
14
+ public_key: z
15
+ .string()
16
+ .min(32)
17
+ .max(8192)
18
+ .describe("OpenSSH-format public key to sign into a certificate (e.g. 'ssh-ed25519 AAAA…')."),
19
+ requested_ttl_seconds: z
20
+ .number()
21
+ .int()
22
+ .positive()
23
+ .optional()
24
+ .describe("Requested certificate lifetime in seconds; the server caps it to policy."),
25
+ source_addresses: z
26
+ .array(z.string())
27
+ .max(16)
28
+ .optional()
29
+ .describe("CIDRs bound into the cert's source-address critical option; SSH refuses the cert from any other source IP."),
30
+ }, async ({ session_id, public_key, requested_ttl_seconds, source_addresses }) => ok(await client.control("IssueSandboxSSHCert", {
31
+ sessionId: session_id,
32
+ publicKey: public_key,
33
+ ...(requested_ttl_seconds ? { requestedTtl: `${requested_ttl_seconds}s` } : {}),
34
+ ...(source_addresses && source_addresses.length ? { sourceAddresses: source_addresses } : {}),
35
+ }, SSH_GATEWAY_SERVICE)));
36
+ server.tool("tenki_list_ssh_gateways", "List the currently active SSH gateways (id, region, public endpoint, health). Filter by region, or pass session_id to get only the gateways that can reach that sandbox's host. Gateways are fleet-wide, not per workspace.", {
37
+ region: z.string().optional().describe("Only gateways in this region (e.g. 'us', 'eu'). Omit for all regions."),
38
+ session_id: sessionIdSchema.optional().describe("Only gateways that can reach this sandbox's host."),
39
+ }, async ({ region, session_id }) => ok(await client.control("ListActiveSSHGateways", {
40
+ ...(region ? { region } : {}),
41
+ ...(session_id ? { sessionId: session_id } : {}),
42
+ }, SSH_GATEWAY_SERVICE)));
43
+ }
@@ -0,0 +1,15 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /**
4
+ * Template tools — custom-image templates and their builds.
5
+ *
6
+ * A "template" is a reusable sandbox-image spec (base image + setup script +
7
+ * default resources/env); building one produces a snapshot/image that sandboxes
8
+ * can boot from. Method names and request fields are matched to the generated
9
+ * `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
10
+ * plane actually speaks). Notable shapes: sizing is a nested `resources` object
11
+ * ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
12
+ * addressed by `buildId`; and ListActiveTemplateBuilds / ListTemplateBuilds are scoped
13
+ * by `templateId`.
14
+ */
15
+ export declare function registerTemplates(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,162 @@
1
+ import { z } from "zod";
2
+ import { envSchema, listQuery, ok, protoEnum, searchSchema, tagsPatch, tagsSchema } from "./common.js";
3
+ /**
4
+ * Template tools — custom-image templates and their builds.
5
+ *
6
+ * A "template" is a reusable sandbox-image spec (base image + setup script +
7
+ * default resources/env); building one produces a snapshot/image that sandboxes
8
+ * can boot from. Method names and request fields are matched to the generated
9
+ * `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
10
+ * plane actually speaks). Notable shapes: sizing is a nested `resources` object
11
+ * ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
12
+ * addressed by `buildId`; and ListActiveTemplateBuilds / ListTemplateBuilds are scoped
13
+ * by `templateId`.
14
+ */
15
+ export function registerTemplates(server, client) {
16
+ /** Assemble the nested TemplateResources object from flat sizing params (omitting any unset). */
17
+ const resourcesFrom = (cpuCores, memoryMb, diskSizeGb) => {
18
+ const r = {};
19
+ if (cpuCores !== undefined)
20
+ r.cpuCores = cpuCores;
21
+ if (memoryMb !== undefined)
22
+ r.memoryMb = memoryMb;
23
+ if (diskSizeGb !== undefined)
24
+ r.diskSizeGb = diskSizeGb;
25
+ return r;
26
+ };
27
+ // ── Create ──────────────────────────────────────────────────────────────────
28
+ server.tool("tenki_create_template", "Create a custom-image template (a reusable sandbox-image spec: base image + setup script + default resources). Build it into a bootable image later with tenki_build_template. NOTE: only a TYPED template (created with builder_spec, no legacy fields) can build a named, publishable image (image_name) that tenki_create_sandbox boots via its `image` arg.", {
29
+ name: z.string().min(1).max(64).describe("Human-readable template name (1-64 chars)."),
30
+ base_image_id: z.string().optional().describe("Base image ID to build on top of."),
31
+ setup_script: z.string().optional().describe("Shell script run at build time to provision the image. Required for a from-scratch template (the API rejects a create without it unless you derive from a parent template/image)."),
32
+ start_cmd: z.string().optional().describe("Command run when a sandbox boots from this template."),
33
+ cpu_cores: z.number().int().min(1).max(128).optional().describe("Default vCPUs for sandboxes from this template (1-128; workspace plan may cap lower)."),
34
+ memory_mb: z.number().int().min(512).max(524288).refine((n) => n % 2 === 0, "memory_mb must be even").optional().describe("Default memory in MB (512-524288, even)."),
35
+ disk_size_gb: z.number().int().min(5).max(100).optional().describe("Default disk in GB (5-100)."),
36
+ env_vars: envSchema,
37
+ tags: tagsSchema.describe("Tags for later filtering (≤20, each ≤32 chars of a-z 0-9 _ : . -)."),
38
+ parent_template_id: z.string().optional().describe("Derive this template from an existing template."),
39
+ parent_image: z.string().optional().describe("Derive this template from an existing built image reference."),
40
+ builder_spec: z
41
+ .record(z.string(), z.unknown())
42
+ .optional()
43
+ .describe("Typed template spec, passed through as-is — e.g. {specVersion:'tenki.template.v1', base:{image:'sandbox'}, workdir:'/home/tenki', steps:[{run:{command:'...'}}], resources:{cpuCores,memoryMb,diskSizeGb}}. Mutually exclusive with base_image_id/setup_script/start_cmd/env_vars/cpu_cores/memory_mb/disk_size_gb/parent_* (the API rejects mixing). Required if the template's builds should publish an image (tenki_build_template image_name)."),
44
+ workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
45
+ }, async (a) => {
46
+ const owner = await client.resolveOwner();
47
+ const workspaceId = a.workspace_id ?? owner.workspaceId;
48
+ const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
49
+ const body = {
50
+ ...(workspaceId ? { workspaceId } : {}),
51
+ name: a.name,
52
+ ...(a.base_image_id ? { baseImageId: a.base_image_id } : {}),
53
+ ...(a.setup_script !== undefined ? { setupScript: a.setup_script } : {}),
54
+ ...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
55
+ ...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
56
+ ...(Object.keys(resources).length ? { resources } : {}),
57
+ ...(a.tags && a.tags.length ? { tags: a.tags } : {}),
58
+ ...(a.parent_template_id ? { parentTemplateId: a.parent_template_id } : {}),
59
+ ...(a.parent_image ? { parentImage: a.parent_image } : {}),
60
+ ...(a.builder_spec ? { builderSpec: a.builder_spec } : {}),
61
+ };
62
+ return ok(await client.control("CreateTemplate", body));
63
+ });
64
+ // ── Get ─────────────────────────────────────────────────────────────────────
65
+ server.tool("tenki_get_template", "Retrieve one template by ID.", { template_id: z.string().describe("The template ID.") }, async ({ template_id }) => ok(await client.control("GetTemplate", { templateId: template_id })));
66
+ // ── List ────────────────────────────────────────────────────────────────────
67
+ server.tool("tenki_list_templates", "List templates for the workspace with server-side search, build-state filter, tag filter, sorting and optional state-count facets.", {
68
+ tags: z.array(z.string()).optional().describe("Only return templates that carry all of these tags."),
69
+ search: searchSchema.describe("Free-text search over template name/id (max 256 chars)."),
70
+ states: z
71
+ .array(z.enum(["READY", "BUILDING", "PENDING", "FAILED", "UNBUILT"]))
72
+ .max(16)
73
+ .optional()
74
+ .describe("Only templates whose latest build is in any of these states (UNBUILT = never built)."),
75
+ sort_by: z.enum(["NAME", "CREATED_AT", "UPDATED_AT"]).optional().describe("Column to sort by."),
76
+ sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
77
+ include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope."),
78
+ workspace_id: z.string().optional().describe("Workspace to list from (defaults to the key's first workspace)."),
79
+ page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (max 100)."),
80
+ page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
81
+ }, async ({ tags, search, states, sort_by, sort_desc, include_facets, workspace_id, page_size, page_token }) => {
82
+ const owner = await client.resolveOwner();
83
+ const workspaceId = workspace_id ?? owner.workspaceId;
84
+ return ok(await client.control("ListTemplates", {
85
+ ...(workspaceId ? { workspaceId } : {}),
86
+ ...(tags && tags.length ? { tags } : {}),
87
+ ...(states && states.length ? { states: states.map((s) => protoEnum("TEMPLATE_LIST_STATE", s)) } : {}),
88
+ ...(sort_by ? { sortBy: protoEnum("TEMPLATE_SORT_FIELD", sort_by) } : {}),
89
+ ...listQuery(search, include_facets, sort_desc),
90
+ ...(page_size ? { pageSize: page_size } : {}),
91
+ ...(page_token ? { pageToken: page_token } : {}),
92
+ }));
93
+ });
94
+ // ── Update ──────────────────────────────────────────────────────────────────
95
+ server.tool("tenki_update_template", "Update mutable fields on a template. Only the fields you provide are changed; pass clear_tags to remove all tags.", {
96
+ template_id: z.string().describe("The template ID to update."),
97
+ name: z.string().optional().describe("New human-readable name."),
98
+ base_image_id: z.string().optional().describe("New base image ID."),
99
+ setup_script: z.string().optional().describe("New build-time provisioning script."),
100
+ start_cmd: z.string().optional().describe("New boot command."),
101
+ cpu_cores: z.number().int().min(1).max(128).optional().describe("New default vCPUs (1-128)."),
102
+ memory_mb: z.number().int().min(512).max(524288).refine((n) => n % 2 === 0, "memory_mb must be even").optional().describe("New default memory in MB (512-524288, even)."),
103
+ disk_size_gb: z.number().int().min(5).max(100).optional().describe("New default disk in GB (5-100)."),
104
+ env_vars: envSchema,
105
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
106
+ clear_tags: z.boolean().optional().describe("Remove all tags from the template."),
107
+ builder_spec: z.record(z.string(), z.unknown()).optional().describe("Advanced structured build spec (TemplateBuildSpec); passed through as-is."),
108
+ }, async (a) => {
109
+ const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
110
+ const body = {
111
+ templateId: a.template_id,
112
+ ...(a.name !== undefined ? { name: a.name } : {}),
113
+ ...(a.base_image_id !== undefined ? { baseImageId: a.base_image_id } : {}),
114
+ ...(a.setup_script !== undefined ? { setupScript: a.setup_script } : {}),
115
+ ...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
116
+ ...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
117
+ ...(Object.keys(resources).length ? { resources } : {}),
118
+ ...tagsPatch(a.tags, a.clear_tags),
119
+ ...(a.builder_spec ? { builderSpec: a.builder_spec } : {}),
120
+ };
121
+ if (Object.keys(body).length === 1) {
122
+ throw new Error("tenki_update_template: pass at least one field to change — nothing was sent.");
123
+ }
124
+ return ok(await client.control("UpdateTemplate", body));
125
+ });
126
+ // ── Delete ──────────────────────────────────────────────────────────────────
127
+ server.tool("tenki_delete_template", "Delete a template by ID. Pass force to delete even when builds or dependents exist.", {
128
+ template_id: z.string().describe("The template ID to delete."),
129
+ force: z.boolean().optional().describe("Force deletion despite dependents (default false)."),
130
+ }, async ({ template_id, force }) => ok(await client.control("DeleteTemplate", {
131
+ templateId: template_id,
132
+ ...(force ? { force: true } : {}),
133
+ })));
134
+ // ── Build ───────────────────────────────────────────────────────────────────
135
+ server.tool("tenki_build_template", "Trigger a build for a template, producing a bootable image. Returns the created build — poll it with tenki_get_template_build until READY; the ready build's imageDigestRef is what tenki_create_sandbox's `image` arg takes.", {
136
+ template_id: z.string().describe("The template ID to build."),
137
+ image_name: z
138
+ .string()
139
+ .regex(/^[a-z][a-z0-9-]{0,63}$/, "lowercase letters, digits and hyphens; must start with a letter; max 64 chars")
140
+ .optional()
141
+ .describe("Name for the resulting image (^[a-z][a-z0-9-]{0,63}$). Requires a TYPED template (created with builder_spec) — the API rejects it for legacy setup-script templates."),
142
+ publish_raw_image: z.boolean().optional().describe("Publish the raw rootfs image alongside the build snapshot."),
143
+ build_secrets: z.record(z.string(), z.string()).optional().describe("Build-time secrets as a key→value object (not persisted into the image)."),
144
+ build_env: z.record(z.string(), z.string()).optional().describe("Per-build environment overrides frozen into this build only."),
145
+ }, async ({ template_id, image_name, publish_raw_image, build_secrets, build_env }) => ok(await client.control("BuildTemplate", {
146
+ templateId: template_id,
147
+ ...(image_name !== undefined ? { imageName: image_name } : {}),
148
+ ...(publish_raw_image !== undefined ? { publishRawImage: publish_raw_image } : {}),
149
+ ...(build_secrets && Object.keys(build_secrets).length ? { buildSecrets: build_secrets } : {}),
150
+ ...(build_env && Object.keys(build_env).length ? { buildEnv: build_env } : {}),
151
+ })));
152
+ // ── Cancel build ──────────────────────────────────────────────────────────────
153
+ server.tool("tenki_cancel_template_build", "Cancel an in-progress template build by its build ID.", { build_id: z.string().describe("The template build ID to cancel.") }, async ({ build_id }) => ok(await client.control("CancelTemplateBuild", { buildId: build_id })));
154
+ // ── Get build ─────────────────────────────────────────────────────────────────
155
+ server.tool("tenki_get_template_build", "Retrieve one template build by its build ID (state, progress, and result image). A READY build's imageDigestRef is the reference tenki_create_sandbox's `image` arg takes.", { build_id: z.string().describe("The template build ID.") }, async ({ build_id }) => ok(await client.control("GetTemplateBuild", { buildId: build_id })));
156
+ // ── List active builds ──────────────────────────────────────────────────────────
157
+ server.tool("tenki_list_active_template_builds", "List the currently active (in-progress) builds for a given template.", { template_id: z.string().describe("The template ID whose active builds to list.") }, async ({ template_id }) => ok(await client.control("ListActiveTemplateBuilds", { templateId: template_id })));
158
+ // ── List builds (history + storage) ─────────────────────────────────────────────
159
+ server.tool("tenki_list_template_builds", "List every build of a template (not just active ones) with what each image holds in workspace storage: version, state, imageBytes, isCurrent, isLaunchable, and deleteBlockedReason (empty when tenki_delete_template_build can free it). Also returns the template-level storage totals.", { template_id: z.string().min(1).describe("The template ID whose builds to list.") }, async ({ template_id }) => ok(await client.control("ListTemplateBuilds", { templateId: template_id })));
160
+ // ── Delete build ────────────────────────────────────────────────────────────────
161
+ server.tool("tenki_delete_template_build", "Delete one template build's image to free workspace storage and retire the build (irreversible; a published image version carrying this build is removed with it). Refused with failed_precondition when the image cannot be freed, e.g. the build is still running, its image is still being written or deleted, the template is published from it, a sandbox that is not terminated (running or paused) still uses it, or an image tag, share, snapshot variant or derived template references it — check deleteBlockedReason via tenki_list_template_builds first (empty = deletable). Deleting the template's current build repoints the template at its latest remaining launchable build, or leaves it with none. Returns freedBytes, or cleanupPending when the image is still being removed.", { build_id: z.string().min(1).describe("The template build ID to delete.") }, async ({ build_id }) => ok(await client.control("DeleteTemplateBuild", { buildId: build_id })));
162
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Human-readable tool titles, applied centrally by the registration guard in
3
+ * server.ts so every tool advertises one (MCP `Tool.title` and
4
+ * `annotations.title`). Connector directories require a title on every tool;
5
+ * test/presubmission.test.mjs fails if a registered tool has no entry here or an
6
+ * entry names a tool that is not registered.
7
+ */
8
+ export declare const TOOL_TITLES: Readonly<Record<string, string>>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Human-readable tool titles, applied centrally by the registration guard in
3
+ * server.ts so every tool advertises one (MCP `Tool.title` and
4
+ * `annotations.title`). Connector directories require a title on every tool;
5
+ * test/presubmission.test.mjs fails if a registered tool has no entry here or an
6
+ * entry names a tool that is not registered.
7
+ */
8
+ export const TOOL_TITLES = {
9
+ // Identity / auth
10
+ tenki_whoami: "Show Current Identity",
11
+ tenki_auth_status: "Check Credential Status",
12
+ // Sandboxes
13
+ tenki_run_code: "Run Code in a Throwaway Sandbox",
14
+ tenki_create_sandbox: "Create Sandbox",
15
+ tenki_get_sandbox: "Get Sandbox",
16
+ tenki_get_sandbox_metrics: "Get Sandbox Metrics",
17
+ tenki_list_sandboxes: "List Sandboxes",
18
+ tenki_list_workspace_sandboxes: "List Workspace Sandboxes",
19
+ tenki_terminate_sandbox: "Terminate Sandbox",
20
+ tenki_terminate_sandboxes: "Terminate Multiple Sandboxes",
21
+ tenki_pause_sandbox: "Pause Sandbox",
22
+ tenki_resume_sandbox: "Resume Sandbox",
23
+ tenki_fork_sandbox: "Fork Sandbox",
24
+ tenki_extend_sandbox: "Extend Sandbox Lifetime",
25
+ tenki_update_sandbox: "Update Sandbox",
26
+ tenki_report_sandbox_activity: "Report Sandbox Activity",
27
+ // Commands and files
28
+ tenki_exec: "Run Command in Sandbox",
29
+ tenki_read_file: "Read File",
30
+ tenki_write_file: "Write File",
31
+ tenki_list_files: "List Directory",
32
+ tenki_stat_path: "Get Path Info",
33
+ tenki_make_dir: "Create Directory",
34
+ tenki_remove_path: "Delete File or Directory",
35
+ tenki_move_path: "Move or Rename Path",
36
+ tenki_get_upload_url: "Get File Upload URL",
37
+ tenki_get_download_url: "Get Artifact Download URL",
38
+ // Git
39
+ tenki_git_clone: "Clone Git Repository",
40
+ tenki_git_checkout: "Check Out Git Ref",
41
+ tenki_git_diff: "Show Git Diff",
42
+ tenki_git_log: "Show Git Log",
43
+ // Ports and previews
44
+ tenki_expose_port: "Expose Port",
45
+ tenki_list_exposed_ports: "List Exposed Ports",
46
+ tenki_unexpose_port: "Unexpose Port",
47
+ tenki_create_preview_url: "Create Preview URL",
48
+ tenki_open_preview: "Open Authenticated Preview",
49
+ tenki_list_preview_urls: "List Preview URLs",
50
+ tenki_get_preview_url: "Get Preview URL",
51
+ tenki_delete_preview_url: "Delete Preview URL",
52
+ tenki_touch_preview: "Keep Preview Alive",
53
+ tenki_bind_preview_url: "Bind Preview URL",
54
+ tenki_unbind_preview_url: "Unbind Preview URL",
55
+ tenki_resolve_preview_token: "Resolve Preview Token",
56
+ // Snapshots
57
+ tenki_create_snapshot: "Create Snapshot",
58
+ tenki_get_snapshot: "Get Snapshot",
59
+ tenki_list_snapshots: "List Snapshots",
60
+ tenki_list_session_snapshots: "List Sandbox Snapshots",
61
+ tenki_list_dangling_snapshots: "List Dangling Snapshots",
62
+ tenki_list_workspace_snapshots: "List Workspace Snapshots",
63
+ tenki_update_snapshot: "Update Snapshot",
64
+ tenki_delete_snapshot: "Delete Snapshot",
65
+ tenki_get_snapshot_download_url: "Get Snapshot Download URL",
66
+ // Volumes
67
+ tenki_create_volume: "Create Volume",
68
+ tenki_get_volume: "Get Volume",
69
+ tenki_list_volumes: "List Volumes",
70
+ tenki_update_volume: "Update Volume",
71
+ tenki_delete_volume: "Delete Volume",
72
+ tenki_resize_volume: "Resize Volume",
73
+ tenki_attach_volume: "Attach Volume",
74
+ tenki_detach_volume: "Detach Volume",
75
+ // Egress policies
76
+ tenki_create_egress_policy: "Create Egress Policy",
77
+ tenki_get_egress_policy: "Get Egress Policy",
78
+ tenki_list_egress_policies: "List Egress Policies",
79
+ tenki_update_egress_policy: "Replace Egress Policy",
80
+ tenki_delete_egress_policy: "Delete Egress Policy",
81
+ // Templates
82
+ tenki_create_template: "Create Template",
83
+ tenki_get_template: "Get Template",
84
+ tenki_list_templates: "List Templates",
85
+ tenki_update_template: "Update Template",
86
+ tenki_delete_template: "Delete Template",
87
+ tenki_build_template: "Build Template",
88
+ tenki_cancel_template_build: "Cancel Template Build",
89
+ tenki_get_template_build: "Get Template Build",
90
+ tenki_list_active_template_builds: "List Active Template Builds",
91
+ tenki_list_template_builds: "List Template Builds",
92
+ tenki_delete_template_build: "Delete Template Build",
93
+ // Workspace
94
+ tenki_get_workspace_usage: "Get Workspace Usage",
95
+ tenki_list_ci_runner_sizes: "List CI Runner Sizes",
96
+ tenki_get_workspace_preview_domains: "Get Wildcard Preview Domains",
97
+ tenki_update_workspace_preview_domains: "Update Wildcard Preview Domains",
98
+ // SSH
99
+ tenki_update_ssh_keys: "Set SSH Authorized Keys",
100
+ tenki_issue_ssh_cert: "Issue SSH Certificate",
101
+ tenki_list_ssh_gateways: "List SSH Gateways",
102
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Volume tools — workspace-scoped persistent block storage for Tenki sandboxes.
3
+ *
4
+ * Volumes are durable disks that outlive any single sandbox: create one in a
5
+ * workspace, then attach it into a running session at a mount path. All calls
6
+ * here are control-plane (tenki.sandbox.v1.SandboxService). Method names and
7
+ * request field shapes are ported from the live-verified n8n community node
8
+ * (github.com/opencolin/n8n-nodes-tenki) and its endpoint research:
9
+ * CreateVolume · GetVolume · ListVolumes · UpdateVolume · DeleteVolume ·
10
+ * ResizeVolume · AttachVolume · DetachVolume
11
+ *
12
+ * CreateVolumeRequest is flat: { workspaceId, name, sizeBytes (int64) }.
13
+ */
14
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import type { TenkiClient } from "../client.js";
16
+ export declare function registerVolumes(server: McpServer, client: TenkiClient): void;