@luxorai/mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +13 -8
  3. package/SECURITY.md +2 -2
  4. package/dist/client.d.ts +16 -1
  5. package/dist/client.js +62 -16
  6. package/dist/env.d.ts +6 -0
  7. package/dist/env.js +25 -0
  8. package/dist/http.js +13 -10
  9. package/dist/index.js +9 -8
  10. package/dist/oauth.d.ts +7 -1
  11. package/dist/oauth.js +29 -9
  12. package/dist/server.d.ts +25 -5
  13. package/dist/server.js +88 -53
  14. package/dist/tools/artifacts.d.ts +3 -3
  15. package/dist/tools/artifacts.js +6 -6
  16. package/dist/tools/auth_status.d.ts +3 -3
  17. package/dist/tools/auth_status.js +8 -9
  18. package/dist/tools/common.d.ts +35 -0
  19. package/dist/tools/common.js +78 -0
  20. package/dist/tools/egress.d.ts +38 -0
  21. package/dist/tools/egress.js +153 -0
  22. package/dist/tools/exec.js +1 -1
  23. package/dist/tools/files.js +36 -3
  24. package/dist/tools/files_ops.js +9 -1
  25. package/dist/tools/git.d.ts +0 -1
  26. package/dist/tools/git.js +85 -23
  27. package/dist/tools/ports.d.ts +8 -1
  28. package/dist/tools/ports.js +24 -3
  29. package/dist/tools/previews.js +59 -22
  30. package/dist/tools/sandboxes.d.ts +1 -1
  31. package/dist/tools/sandboxes.js +342 -26
  32. package/dist/tools/sessions_admin.js +63 -23
  33. package/dist/tools/snapshots.js +41 -22
  34. package/dist/tools/ssh.d.ts +3 -3
  35. package/dist/tools/ssh.js +35 -9
  36. package/dist/tools/templates.d.ts +2 -1
  37. package/dist/tools/templates.js +36 -16
  38. package/dist/tools/titles.d.ts +8 -0
  39. package/dist/tools/titles.js +102 -0
  40. package/dist/tools/volumes.js +36 -11
  41. package/dist/tools/workspace.d.ts +9 -1
  42. package/dist/tools/workspace.js +29 -90
  43. package/package.json +5 -4
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ok, sessionIdSchema } from "./common.js";
2
+ import { ok, sessionIdSchema, tagsPatch, tagsSchema } from "./common.js";
3
3
  /**
4
4
  * Snapshots — capture a sandbox's disk + memory as a reusable image.
5
5
  *
@@ -20,54 +20,73 @@ export function registerSnapshots(server, client) {
20
20
  .boolean()
21
21
  .optional()
22
22
  .describe("Also store the raw disk image alongside the snapshot (needed to download it later)."),
23
- }, async ({ session_id, name, expires_at, store_raw_image }) => ok(await client.control("CreateSnapshot", {
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", {
24
28
  sessionId: session_id,
25
29
  ...(name ? { name } : {}),
26
30
  ...(expires_at ? { expiresAt: expires_at } : {}),
27
31
  ...(store_raw_image !== undefined ? { storeRawImage: store_raw_image } : {}),
32
+ ...(isAsync ? { async: true } : {}),
28
33
  })));
29
- server.tool("tenki_get_snapshot", "Fetch one snapshot's status and metadata by ID.", { snapshot_id: z.string() }, async ({ snapshot_id }) => ok(await client.control("GetSnapshot", { snapshotId: snapshot_id })));
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 })));
30
35
  server.tool("tenki_list_snapshots", "List the saved snapshots for the workspace (owner inferred from the API key).", {
31
- page_size: z.number().int().positive().optional(),
32
- page_token: z.string().optional(),
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."),
33
38
  }, async ({ page_size, page_token }) => ok(await client.control("ListSnapshots", {
34
39
  ...(page_size ? { pageSize: page_size } : {}),
35
40
  ...(page_token ? { pageToken: page_token } : {}),
36
41
  })));
37
42
  server.tool("tenki_list_session_snapshots", "List the snapshots captured from a specific sandbox session.", {
38
43
  session_id: sessionIdSchema,
39
- page_size: z.number().int().positive().optional(),
40
- page_token: z.string().optional(),
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."),
41
46
  }, async ({ session_id, page_size, page_token }) => ok(await client.control("ListSessionSnapshots", {
42
47
  sessionId: session_id,
43
48
  ...(page_size ? { pageSize: page_size } : {}),
44
49
  ...(page_token ? { pageToken: page_token } : {}),
45
50
  })));
46
51
  server.tool("tenki_list_dangling_snapshots", "List dangling snapshots — those whose source session no longer exists — for the workspace, useful for cleanup.", {
47
- page_size: z.number().int().positive().optional(),
48
- page_token: z.string().optional(),
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."),
49
54
  }, async ({ page_size, page_token }) => ok(await client.control("ListDanglingSnapshots", {
50
55
  ...(page_size ? { pageSize: page_size } : {}),
51
56
  ...(page_token ? { pageToken: page_token } : {}),
52
57
  })));
53
- server.tool("tenki_update_snapshot", "Update a snapshot's mutable metadata (name and/or expiry).", {
54
- snapshot_id: z.string(),
55
- name: z.string().optional().describe("New human-readable name."),
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)."),
56
61
  expires_at: z
57
62
  .string()
58
63
  .optional()
59
64
  .describe("New RFC-3339 / ISO-8601 auto-delete timestamp."),
60
- }, async ({ snapshot_id, name, expires_at }) => ok(await client.control("UpdateSnapshot", {
61
- snapshotId: snapshot_id,
62
- ...(name !== undefined ? { name } : {}),
63
- ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
64
- })));
65
- server.tool("tenki_delete_snapshot", "Permanently delete a snapshot by ID.", { snapshot_id: z.string() }, async ({ snapshot_id }) => ok(await client.control("DeleteSnapshot", { snapshotId: snapshot_id })));
66
- 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() }, async ({ snapshot_id }) => ok(await client.control("GetSnapshotDownloadURL", { snapshotId: snapshot_id })));
67
- server.tool("tenki_list_workspace_snapshots", "List all snapshots in a workspace (defaults to the key's first workspace). Supports pagination.", {
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.", {
68
87
  workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
69
- page_size: z.number().int().positive().optional(),
70
- page_token: z.string().optional(),
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."),
71
90
  }, async ({ workspace_id, page_size, page_token }) => {
72
91
  const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
73
92
  return ok(await client.control("ListWorkspaceSnapshots", {
@@ -6,9 +6,9 @@
6
6
  * SEPARATE ConnectRPC service (SSHGatewayClientService), reached by passing the
7
7
  * service path to client.control.
8
8
  *
9
- * Request shapes are grounded in the published Tenki API surface (CreateSession's
10
- * ssh_authorized_keys field → sshAuthorizedKeys). The cert-issuance and gateway
11
- * shapes are SDK-name-verified but not exercised end-to-end here.
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
12
  */
13
13
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import type { TenkiClient } from "../client.js";
package/dist/tools/ssh.js CHANGED
@@ -2,16 +2,42 @@ import { z } from "zod";
2
2
  import { ok, sessionIdSchema } from "./common.js";
3
3
  const SSH_GATEWAY_SERVICE = "tenki.sandbox.v1.SSHGatewayClientService";
4
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.", {
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
6
  session_id: sessionIdSchema,
7
- public_keys: z.array(z.string()).describe("SSH public keys (ssh-ed25519 …, ssh-rsa …) to authorize. Replaces the current set."),
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."),
8
11
  }, async ({ session_id, public_keys }) => ok(await client.control("UpdateSSHAuthorizedKeys", { sessionId: session_id, sshAuthorizedKeys: public_keys })));
9
- 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.", {
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.", {
10
13
  session_id: sessionIdSchema,
11
- public_key: z.string().describe("The SSH public key to sign into a certificate."),
12
- }, async ({ session_id, public_key }) => ok(await client.control("IssueSandboxSSHCert", { sessionId: session_id, publicKey: public_key }, SSH_GATEWAY_SERVICE)));
13
- server.tool("tenki_list_ssh_gateways", "List the currently active SSH gateways for the workspace.", { workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace).") }, async ({ workspace_id }) => {
14
- const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
15
- return ok(await client.control("ListActiveSSHGateways", { ...(workspaceId ? { workspaceId } : {}) }, SSH_GATEWAY_SERVICE));
16
- });
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)));
17
43
  }
@@ -9,6 +9,7 @@ import type { TenkiClient } from "../client.js";
9
9
  * `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
10
10
  * plane actually speaks). Notable shapes: sizing is a nested `resources` object
11
11
  * ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
12
- * addressed by `buildId`; and ListActiveTemplateBuilds is scoped by `templateId`.
12
+ * addressed by `buildId`; and ListActiveTemplateBuilds / ListTemplateBuilds are scoped
13
+ * by `templateId`.
13
14
  */
14
15
  export declare function registerTemplates(server: McpServer, client: TenkiClient): void;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ok, envSchema } from "./common.js";
2
+ import { envSchema, listQuery, ok, protoEnum, searchSchema, tagsPatch, tagsSchema } from "./common.js";
3
3
  /**
4
4
  * Template tools — custom-image templates and their builds.
5
5
  *
@@ -9,7 +9,8 @@ import { ok, envSchema } from "./common.js";
9
9
  * `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
10
10
  * plane actually speaks). Notable shapes: sizing is a nested `resources` object
11
11
  * ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
12
- * addressed by `buildId`; and ListActiveTemplateBuilds is scoped by `templateId`.
12
+ * addressed by `buildId`; and ListActiveTemplateBuilds / ListTemplateBuilds are scoped
13
+ * by `templateId`.
13
14
  */
14
15
  export function registerTemplates(server, client) {
15
16
  /** Assemble the nested TemplateResources object from flat sizing params (omitting any unset). */
@@ -25,15 +26,15 @@ export function registerTemplates(server, client) {
25
26
  };
26
27
  // ── Create ──────────────────────────────────────────────────────────────────
27
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.", {
28
- name: z.string().describe("Human-readable template name."),
29
+ name: z.string().min(1).max(64).describe("Human-readable template name (1-64 chars)."),
29
30
  base_image_id: z.string().optional().describe("Base image ID to build on top of."),
30
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)."),
31
32
  start_cmd: z.string().optional().describe("Command run when a sandbox boots from this template."),
32
- cpu_cores: z.number().int().min(1).max(16).optional().describe("Default vCPUs for sandboxes from this template (1-16)."),
33
- memory_mb: z.number().int().min(512).max(65536).optional().describe("Default memory in MB (512-65536)."),
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)."),
34
35
  disk_size_gb: z.number().int().min(5).max(100).optional().describe("Default disk in GB (5-100)."),
35
36
  env_vars: envSchema,
36
- tags: z.array(z.string()).optional().describe("Tags for later filtering."),
37
+ tags: tagsSchema.describe("Tags for later filtering (≤20, each ≤32 chars of a-z 0-9 _ : . -)."),
37
38
  parent_template_id: z.string().optional().describe("Derive this template from an existing template."),
38
39
  parent_image: z.string().optional().describe("Derive this template from an existing built image reference."),
39
40
  builder_spec: z
@@ -63,17 +64,29 @@ export function registerTemplates(server, client) {
63
64
  // ── Get ─────────────────────────────────────────────────────────────────────
64
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 })));
65
66
  // ── List ────────────────────────────────────────────────────────────────────
66
- server.tool("tenki_list_templates", "List templates for the workspace, optionally filtered by tags.", {
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.", {
67
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."),
68
78
  workspace_id: z.string().optional().describe("Workspace to list from (defaults to the key's first workspace)."),
69
- page_size: z.number().int().positive().optional(),
70
- page_token: z.string().optional(),
71
- }, async ({ tags, workspace_id, page_size, page_token }) => {
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 }) => {
72
82
  const owner = await client.resolveOwner();
73
83
  const workspaceId = workspace_id ?? owner.workspaceId;
74
84
  return ok(await client.control("ListTemplates", {
75
85
  ...(workspaceId ? { workspaceId } : {}),
76
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),
77
90
  ...(page_size ? { pageSize: page_size } : {}),
78
91
  ...(page_token ? { pageToken: page_token } : {}),
79
92
  }));
@@ -85,11 +98,11 @@ export function registerTemplates(server, client) {
85
98
  base_image_id: z.string().optional().describe("New base image ID."),
86
99
  setup_script: z.string().optional().describe("New build-time provisioning script."),
87
100
  start_cmd: z.string().optional().describe("New boot command."),
88
- cpu_cores: z.number().int().min(1).max(16).optional().describe("New default vCPUs (1-16)."),
89
- memory_mb: z.number().int().min(512).max(65536).optional().describe("New default memory in MB (512-65536)."),
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)."),
90
103
  disk_size_gb: z.number().int().min(5).max(100).optional().describe("New default disk in GB (5-100)."),
91
104
  env_vars: envSchema,
92
- tags: z.array(z.string()).optional().describe("Replacement set of tags."),
105
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
93
106
  clear_tags: z.boolean().optional().describe("Remove all tags from the template."),
94
107
  builder_spec: z.record(z.string(), z.unknown()).optional().describe("Advanced structured build spec (TemplateBuildSpec); passed through as-is."),
95
108
  }, async (a) => {
@@ -102,10 +115,12 @@ export function registerTemplates(server, client) {
102
115
  ...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
103
116
  ...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
104
117
  ...(Object.keys(resources).length ? { resources } : {}),
105
- ...(a.tags && a.tags.length ? { tags: a.tags } : {}),
106
- ...(a.clear_tags ? { clearTags: true } : {}),
118
+ ...tagsPatch(a.tags, a.clear_tags),
107
119
  ...(a.builder_spec ? { builderSpec: a.builder_spec } : {}),
108
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
+ }
109
124
  return ok(await client.control("UpdateTemplate", body));
110
125
  });
111
126
  // ── Delete ──────────────────────────────────────────────────────────────────
@@ -121,8 +136,9 @@ export function registerTemplates(server, client) {
121
136
  template_id: z.string().describe("The template ID to build."),
122
137
  image_name: z
123
138
  .string()
139
+ .regex(/^[a-z][a-z0-9-]{0,63}$/, "lowercase letters, digits and hyphens; must start with a letter; max 64 chars")
124
140
  .optional()
125
- .describe("Name for the resulting image. Requires a TYPED template (created with builder_spec) — the API rejects it for legacy setup-script templates."),
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."),
126
142
  publish_raw_image: z.boolean().optional().describe("Publish the raw rootfs image alongside the build snapshot."),
127
143
  build_secrets: z.record(z.string(), z.string()).optional().describe("Build-time secrets as a key→value object (not persisted into the image)."),
128
144
  build_env: z.record(z.string(), z.string()).optional().describe("Per-build environment overrides frozen into this build only."),
@@ -139,4 +155,8 @@ export function registerTemplates(server, client) {
139
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 })));
140
156
  // ── List active builds ──────────────────────────────────────────────────────────
141
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 })));
142
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
+ };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ok, pathSchema, sessionIdSchema } from "./common.js";
2
+ import { listQuery, ok, pathSchema, protoEnum, searchSchema, sessionIdSchema, tagsPatch, tagsSchema } from "./common.js";
3
3
  /** Volume size bounds the control plane accepts: 1 MiB … 100 GiB, in bytes. */
4
4
  const MIN_VOLUME_BYTES = 1_048_576; // 1 MiB
5
5
  const MAX_VOLUME_BYTES = 107_374_182_400; // 100 GiB
@@ -27,28 +27,48 @@ export function registerVolumes(server, client) {
27
27
  // ── Get ───────────────────────────────────────────────────────────────────
28
28
  server.tool("tenki_get_volume", "Fetch a single volume's metadata and current state by its id.", { volume_id: z.string().describe("The volume id, e.g. vol_….") }, async ({ volume_id }) => ok(await client.control("GetVolume", { volumeId: volume_id })));
29
29
  // ── List ──────────────────────────────────────────────────────────────────
30
- server.tool("tenki_list_volumes", "List persistent volumes in a workspace (defaults to the key's first workspace). Supports pagination.", {
30
+ server.tool("tenki_list_volumes", "List persistent volumes in a workspace (defaults to the key's first workspace) with server-side search, state filter, sorting and optional state-count facets. Supports pagination.", {
31
31
  workspace_id: z.string().optional().describe("Workspace to list volumes from (defaults to the key's first workspace)."),
32
- page_size: z.number().int().positive().optional().describe("Max volumes to return per page."),
32
+ search: searchSchema.describe("Free-text search over volume name/id (max 256 chars)."),
33
+ states: z.array(z.enum(["AVAILABLE", "IN_USE", "DELETING", "DELETED"])).max(16).optional().describe("Only volumes in any of these states."),
34
+ sort_by: z.enum(["NAME", "STATE", "SIZE_BYTES", "CREATED_AT"]).optional().describe("Column to sort by."),
35
+ sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
36
+ include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope."),
37
+ page_size: z.number().int().min(1).max(100).optional().describe("Max volumes to return per page (max 100)."),
33
38
  page_token: z.string().optional().describe("Page token from a previous response's nextPageToken."),
34
- }, async ({ workspace_id, page_size, page_token }) => {
39
+ }, async ({ workspace_id, search, states, sort_by, sort_desc, include_facets, page_size, page_token }) => {
35
40
  const owner = await client.resolveOwner();
36
41
  const workspaceId = workspace_id ?? owner.workspaceId;
37
42
  return ok(await client.control("ListVolumes", {
38
43
  ...(workspaceId ? { workspaceId } : {}),
44
+ ...(states && states.length ? { states: states.map((s) => protoEnum("VOLUME_STATE", s)) } : {}),
45
+ ...(sort_by ? { sortBy: protoEnum("VOLUME_SORT_FIELD", sort_by) } : {}),
46
+ ...listQuery(search, include_facets, sort_desc),
39
47
  ...(page_size ? { pageSize: page_size } : {}),
40
48
  ...(page_token ? { pageToken: page_token } : {}),
41
49
  }));
42
50
  });
43
51
  // ── Update ────────────────────────────────────────────────────────────────
44
- server.tool("tenki_update_volume", "Rename a volume (update its human-readable name). To change a volume's size use tenki_resize_volume instead.", {
52
+ server.tool("tenki_update_volume", "Update a volume's name and/or tags (pass at least one). To change a volume's size use tenki_resize_volume instead.", {
45
53
  volume_id: z.string().describe("The volume id to update."),
46
- name: z.string().describe("New human-readable name for the volume."),
47
- }, async ({ volume_id, name }) => ok(await client.control("UpdateVolume", { volumeId: volume_id, name })));
54
+ name: z.string().min(1).max(64).optional().describe("New human-readable name for the volume."),
55
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
56
+ clear_tags: z.boolean().optional().describe("Remove all tags from the volume."),
57
+ }, async ({ volume_id, name, tags, clear_tags }) => {
58
+ const tagPatch = tagsPatch(tags, clear_tags);
59
+ if (name === undefined && !Object.keys(tagPatch).length) {
60
+ throw new Error("tenki_update_volume: pass name, tags, or clear_tags — nothing was sent.");
61
+ }
62
+ return ok(await client.control("UpdateVolume", {
63
+ volumeId: volume_id,
64
+ ...(name !== undefined ? { name } : {}),
65
+ ...tagPatch,
66
+ }));
67
+ });
48
68
  // ── Delete ────────────────────────────────────────────────────────────────
49
69
  server.tool("tenki_delete_volume", "Permanently delete a volume and destroy its data. Fails with VolumeInUse if the volume is still attached to a session — detach it first.", { volume_id: z.string().describe("The volume id to delete.") }, async ({ volume_id }) => ok(await client.control("DeleteVolume", { volumeId: volume_id })));
50
70
  // ── Resize ────────────────────────────────────────────────────────────────
51
- server.tool("tenki_resize_volume", "Grow a volume to a new size in bytes (1 MiB … 100 GiB). Volumes can grow but not shrink.", {
71
+ server.tool("tenki_resize_volume", "Change a volume's size in bytes (1 MiB … 100 GiB). Only a volume that has never been attached can be resized; once attached its size is fixed (failed_precondition), so create a new volume for a different size.", {
52
72
  volume_id: z.string().describe("The volume id to resize."),
53
73
  size_bytes: sizeBytesSchema,
54
74
  }, async ({ volume_id, size_bytes }) => ok(await client.control("ResizeVolume", { volumeId: volume_id, newSizeBytes: size_bytes })));
@@ -66,12 +86,17 @@ export function registerVolumes(server, client) {
66
86
  volume: {
67
87
  volumeId: volume_id,
68
88
  mountPath: mount_path,
69
- ...(read_only !== undefined ? { readOnly: read_only } : {}),
89
+ // proto field is `readonly` (one word); `readOnly` is an unknown field the API discards.
90
+ ...(read_only !== undefined ? { readonly: read_only } : {}),
70
91
  },
71
92
  })));
72
93
  // ── Detach ────────────────────────────────────────────────────────────────
73
- server.tool("tenki_detach_volume", "Unmount a volume from a sandbox session.", {
94
+ server.tool("tenki_detach_volume", "Unmount a volume from a sandbox session and sync it back to storage. A volume whose sync back is still pending (SYNC_PENDING) cannot be detached, even with force (failed_precondition): wait for the upload to finish or ask Tenki support to recover it. Set force to unplug an attached volume the guest cannot unmount — writes the guest has not flushed are lost.", {
74
95
  session_id: sessionIdSchema.describe("The sandbox session to detach the volume from."),
75
96
  volume_id: z.string().describe("The volume id to detach."),
76
- }, async ({ session_id, volume_id }) => ok(await client.control("DetachVolume", { sessionId: session_id, volumeId: volume_id })));
97
+ force: z
98
+ .boolean()
99
+ .optional()
100
+ .describe("Force-unplug an ATTACHED volume (default false). Unflushed guest writes are lost. Never releases a SYNC_PENDING attachment; on a host that cannot force detach yet the API returns failed_precondition."),
101
+ }, async ({ session_id, volume_id, force }) => ok(await client.control("DetachVolume", { sessionId: session_id, volumeId: volume_id, ...(force ? { forceDetach: true } : {}) })));
77
102
  }
@@ -1,4 +1,12 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { TenkiClient } from "../client.js";
3
- /** Workspace-level sandbox usage + default settings (incl. snapshot retention). */
3
+ /**
4
+ * Workspace-level sandbox usage, CI runner sizes and wildcard preview domains.
5
+ *
6
+ * The workspace quota/retention settings RPCs (Get/UpdateWorkspaceSandboxSettings,
7
+ * Get/UpdateWorkspaceSnapshotRetentionSettings) were REMOVED from the API
8
+ * (tenki-app #5007, 2026-08-07; they 404 on staging and production), so their
9
+ * tools are gone. Quotas now come from the workspace plan and are read-only
10
+ * through tenki_get_workspace_usage.
11
+ */
4
12
  export declare function registerWorkspace(server: McpServer, client: TenkiClient): void;