@luxorai/mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +13 -8
  3. package/SECURITY.md +2 -2
  4. package/dist/client.d.ts +16 -1
  5. package/dist/client.js +62 -16
  6. package/dist/env.d.ts +6 -0
  7. package/dist/env.js +25 -0
  8. package/dist/http.js +13 -10
  9. package/dist/index.js +9 -8
  10. package/dist/oauth.d.ts +7 -1
  11. package/dist/oauth.js +29 -9
  12. package/dist/server.d.ts +25 -5
  13. package/dist/server.js +88 -53
  14. package/dist/tools/artifacts.d.ts +3 -3
  15. package/dist/tools/artifacts.js +6 -6
  16. package/dist/tools/auth_status.d.ts +3 -3
  17. package/dist/tools/auth_status.js +8 -9
  18. package/dist/tools/common.d.ts +35 -0
  19. package/dist/tools/common.js +78 -0
  20. package/dist/tools/egress.d.ts +38 -0
  21. package/dist/tools/egress.js +153 -0
  22. package/dist/tools/exec.js +1 -1
  23. package/dist/tools/files.js +36 -3
  24. package/dist/tools/files_ops.js +9 -1
  25. package/dist/tools/git.d.ts +0 -1
  26. package/dist/tools/git.js +85 -23
  27. package/dist/tools/ports.d.ts +8 -1
  28. package/dist/tools/ports.js +24 -3
  29. package/dist/tools/previews.js +59 -22
  30. package/dist/tools/sandboxes.d.ts +1 -1
  31. package/dist/tools/sandboxes.js +342 -26
  32. package/dist/tools/sessions_admin.js +63 -23
  33. package/dist/tools/snapshots.js +41 -22
  34. package/dist/tools/ssh.d.ts +3 -3
  35. package/dist/tools/ssh.js +35 -9
  36. package/dist/tools/templates.d.ts +2 -1
  37. package/dist/tools/templates.js +36 -16
  38. package/dist/tools/titles.d.ts +8 -0
  39. package/dist/tools/titles.js +102 -0
  40. package/dist/tools/volumes.js +36 -11
  41. package/dist/tools/workspace.d.ts +9 -1
  42. package/dist/tools/workspace.js +29 -90
  43. package/package.json +5 -4
package/dist/server.d.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * Security controls (see SECURITY.md; maps to CSA MCP Server Top-10 MCP-07
6
6
  * "excessive permissions"). Every tool is registered through a guard that:
7
- * 1. tags it with MCP annotations (readOnlyHint / destructiveHint / openWorldHint)
8
- * so clients can surface or gate dangerous tools;
7
+ * 1. gives it a human-readable title (tools/titles.ts) and MCP annotations
8
+ * (readOnlyHint / destructiveHint / openWorldHint) so clients can surface
9
+ * or gate dangerous tools;
9
10
  * 2. enforces least-privilege via env:
10
11
  * TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
11
12
  * TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
@@ -14,9 +15,28 @@
14
15
  */
15
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
17
  import type { TenkiClient } from "./client.js";
17
- export declare const VERSION = "0.3.1";
18
- type Cls = "read" | "write" | "destructive";
18
+ export declare const VERSION = "0.4.0";
19
+ /**
20
+ * Tool classes, mapped to MCP annotations by annotationsFor():
21
+ * read → readOnlyHint: true. Pure inspection: no state change, no spend.
22
+ * additive → readOnlyHint: false, destructiveHint: false. Only creates new
23
+ * things or extends existing ones (a sandbox, a snapshot, more
24
+ * lifetime, a keep-alive); never changes or removes what exists.
25
+ * destructive → readOnlyHint: false, destructiveHint: true. May overwrite,
26
+ * modify, interrupt or delete existing state.
27
+ *
28
+ * Destructive is the DEFAULT for anything that is not a read: a non-read tool
29
+ * is additive only when it is listed in ADDITIVE. MCP defines
30
+ * `destructiveHint: false` as "performs only additive updates", and clients use
31
+ * it to decide whether to ask before a call, so an unlisted new tool must fail
32
+ * safe (prompt) rather than claim to be harmless.
33
+ */
34
+ type Cls = "read" | "additive" | "destructive";
19
35
  export declare function classifyTool(name: string): Cls;
36
+ /** Title for a tool: the TOOL_TITLES entry, else one derived from the name so
37
+ * a tool is never advertised without a title (the offline suite fails on a
38
+ * missing entry, so the fallback only guards production). */
39
+ export declare function titleFor(name: string): string;
20
40
  /**
21
41
  * Build a fresh MCP server instance with all tools registered against `client`.
22
42
  *
@@ -24,7 +44,7 @@ export declare function classifyTool(name: string): Cls;
24
44
  * start — which MCP clients report as an opaque "server failed to start" —
25
45
  * the server boots with ONLY tenki_auth_status registered, so an agent can
26
46
  * discover and explain the missing credential. Registering the other tools in
27
- * that state would offer 70 tools that can only fail.
47
+ * that state would offer 82 tools that can only fail.
28
48
  */
29
49
  export declare function createServer(client: TenkiClient | null): McpServer;
30
50
  export {};
package/dist/server.js CHANGED
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * Security controls (see SECURITY.md; maps to CSA MCP Server Top-10 MCP-07
6
6
  * "excessive permissions"). Every tool is registered through a guard that:
7
- * 1. tags it with MCP annotations (readOnlyHint / destructiveHint / openWorldHint)
8
- * so clients can surface or gate dangerous tools;
7
+ * 1. gives it a human-readable title (tools/titles.ts) and MCP annotations
8
+ * (readOnlyHint / destructiveHint / openWorldHint) so clients can surface
9
+ * or gate dangerous tools;
9
10
  * 2. enforces least-privilege via env:
10
11
  * TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
11
12
  * TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
@@ -29,8 +30,11 @@ import { registerTemplates } from "./tools/templates.js";
29
30
  import { registerWorkspace } from "./tools/workspace.js";
30
31
  import { registerArtifacts } from "./tools/artifacts.js";
31
32
  import { registerSsh } from "./tools/ssh.js";
33
+ import { registerEgress } from "./tools/egress.js";
32
34
  import { registerAuthStatus } from "./tools/auth_status.js";
33
- export const VERSION = "0.3.1";
35
+ import { readEnv } from "./env.js";
36
+ import { TOOL_TITLES } from "./tools/titles.js";
37
+ export const VERSION = "0.4.0";
34
38
  const modules = [
35
39
  registerIdentity,
36
40
  registerRun,
@@ -44,40 +48,75 @@ const modules = [
44
48
  registerPreviews,
45
49
  registerSnapshots,
46
50
  registerVolumes,
51
+ registerEgress,
47
52
  registerTemplates,
48
53
  registerWorkspace,
49
54
  registerArtifacts,
50
55
  registerSsh,
51
56
  ];
52
- // Destroys/removes/revokes a resource → destructiveHint.
57
+ // Destroys/removes/revokes a resource. Checked first, so a name with one of
58
+ // these prefixes is destructive even if it is mistakenly listed in ADDITIVE.
53
59
  const DESTRUCTIVE = /^tenki_(terminate|delete|remove|unshare|revoke|detach|unexpose|unbind)/;
54
- // Named exceptions that match the READ prefix below but actually grant a
55
- // write/spend capability, so they must never be treated as read-only.
56
- // tenki_get_upload_url returns a signed URL for an arbitrary PUT into the sandbox.
57
- const WRITE_OVERRIDE = new Set(["tenki_get_upload_url"]);
58
- // Named exceptions that are pure reads but don't match the READ prefix below.
59
- // tenki_auth_status only inspects the ambient credential + probes WhoAmI, so it
60
- // must stay available under TENKI_MCP_READONLY (it is how an operator diagnoses
61
- // a credential problem in that posture).
62
- const READ_OVERRIDE = new Set(["tenki_auth_status"]);
63
60
  // Pure inspection, no state change / no spend → readOnlyHint.
64
61
  const READ = /^tenki_(get|list|whoami|resolve|stat|read)/;
62
+ // Names that match the READ prefix but are not reads. tenki_get_upload_url
63
+ // returns a signed URL for an HTTP PUT that can overwrite a file in the sandbox.
64
+ const NOT_READ = new Set(["tenki_get_upload_url"]);
65
+ // Pure reads that don't match the READ prefix. tenki_auth_status only inspects
66
+ // the ambient credential + probes WhoAmI, so it must stay available under
67
+ // TENKI_MCP_READONLY (it is how an operator diagnoses a credential problem in
68
+ // that posture). git diff/log only read the repository.
69
+ const READ_OVERRIDE = new Set(["tenki_auth_status", "tenki_git_diff", "tenki_git_log"]);
70
+ // Non-read tools that only add. Everything else that is not a read is
71
+ // destructive — see the class comment above for why that is the default.
72
+ const ADDITIVE = new Set([
73
+ "tenki_run_code", // boots, uses and tears down its own throwaway sandbox
74
+ "tenki_create_sandbox",
75
+ "tenki_fork_sandbox", // the source keeps running; children are new sandboxes
76
+ "tenki_resume_sandbox",
77
+ "tenki_extend_sandbox",
78
+ "tenki_report_sandbox_activity", // keep-alive
79
+ "tenki_make_dir",
80
+ "tenki_git_clone", // git refuses a non-empty target directory
81
+ "tenki_expose_port",
82
+ "tenki_create_preview_url",
83
+ "tenki_touch_preview", // keep-alive
84
+ "tenki_create_snapshot",
85
+ "tenki_create_volume",
86
+ "tenki_attach_volume",
87
+ "tenki_create_egress_policy",
88
+ "tenki_create_template",
89
+ // Not tenki_build_template: a build with image_name publishes a new latest
90
+ // version, which changes what an existing `workspace/name` reference boots.
91
+ "tenki_issue_ssh_cert",
92
+ ]);
65
93
  export function classifyTool(name) {
66
94
  if (DESTRUCTIVE.test(name))
67
95
  return "destructive";
68
- if (WRITE_OVERRIDE.has(name))
69
- return "write";
70
- if (READ_OVERRIDE.has(name) || READ.test(name))
96
+ if (!NOT_READ.has(name) && (READ_OVERRIDE.has(name) || READ.test(name)))
71
97
  return "read";
72
- return "write";
98
+ if (ADDITIVE.has(name))
99
+ return "additive";
100
+ return "destructive";
101
+ }
102
+ /** Title for a tool: the TOOL_TITLES entry, else one derived from the name so
103
+ * a tool is never advertised without a title (the offline suite fails on a
104
+ * missing entry, so the fallback only guards production). */
105
+ export function titleFor(name) {
106
+ return (TOOL_TITLES[name] ??
107
+ name
108
+ .replace(/^tenki_/, "")
109
+ .split("_")
110
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
111
+ .join(" "));
73
112
  }
74
113
  function readGuardOpts() {
75
114
  const truthy = (v) => v === "1" || (v ?? "").toLowerCase() === "true";
76
- const disabled = new Set((process.env.TENKI_MCP_DISABLED_TOOLS || "")
115
+ const disabled = new Set((readEnv("MCP_DISABLED_TOOLS") || "")
77
116
  .split(",")
78
117
  .map((s) => s.trim())
79
118
  .filter(Boolean));
80
- return { readonly: truthy(process.env.TENKI_MCP_READONLY), disabled, audit: truthy(process.env.TENKI_MCP_AUDIT) };
119
+ return { readonly: truthy(readEnv("MCP_READONLY")), disabled, audit: truthy(readEnv("MCP_AUDIT")) };
81
120
  }
82
121
  /** Log a tool call's name + argument KEYS (never values/content/token) to stderr. */
83
122
  function auditKeys(args) {
@@ -87,8 +126,9 @@ function auditKeys(args) {
87
126
  return keys.length ? ` args=[${keys.join(",")}]` : "";
88
127
  }
89
128
  /** MCP annotations derived from a tool's classification. */
90
- function annotationsFor(cls) {
129
+ function annotationsFor(name, cls) {
91
130
  return {
131
+ title: titleFor(name),
92
132
  readOnlyHint: cls === "read",
93
133
  destructiveHint: cls === "destructive",
94
134
  idempotentHint: cls === "read",
@@ -128,45 +168,40 @@ function noopToolHandle() {
128
168
  };
129
169
  }
130
170
  /**
131
- * Wrap a server so every tool registration from a module is annotated + subject
132
- * to the least-privilege env controls above. Both registration APIs are guarded:
133
- * the legacy `.tool(name, description, schema, handler)` form and the modern
134
- * `.registerTool(name, config, handler)` form (the only one that accepts an
135
- * outputSchema) — so neither path can bypass annotations, read-only mode, or
136
- * the denylist. Other access passes through to the real server unchanged.
171
+ * Wrap a server so every tool registration from a module is titled, annotated
172
+ * and subject to the least-privilege env controls above. Both registration APIs
173
+ * are guarded: the legacy `.tool(name, description, schema, handler)` form and
174
+ * the modern `.registerTool(name, config, handler)` form (the only one that
175
+ * accepts an outputSchema) — so neither path can bypass titles, annotations,
176
+ * read-only mode, or the denylist. Legacy calls are re-issued as registerTool,
177
+ * because the SDK's tool() overloads have no title parameter (MCP `Tool.title`
178
+ * would be undefined). Other access passes through to the real server unchanged.
137
179
  */
138
180
  function guard(server, opts) {
181
+ const register = (target, name, config, handler) => {
182
+ const cls = classifyTool(name);
183
+ if (opts.disabled.has(name))
184
+ return noopToolHandle(); // explicit denylist
185
+ if (opts.readonly && cls !== "read")
186
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
187
+ if (opts.registered)
188
+ opts.registered.count++;
189
+ // Name-derived classification and title stay authoritative so a module
190
+ // cannot soften the hints; other annotation fields pass through.
191
+ const annotations = {
192
+ ...config.annotations,
193
+ ...annotationsFor(name, cls),
194
+ };
195
+ return target.registerTool(name, { ...config, title: titleFor(name), annotations }, withAudit(name, handler, opts.audit));
196
+ };
139
197
  return new Proxy(server, {
140
198
  get(target, prop, receiver) {
141
199
  if (prop === "tool") {
142
- return (name, description, schema, handler) => {
143
- const cls = classifyTool(name);
144
- if (opts.disabled.has(name))
145
- return noopToolHandle(); // explicit denylist
146
- if (opts.readonly && cls !== "read")
147
- return noopToolHandle(); // read-only posture: skip anything that mutates/spends
148
- if (opts.registered)
149
- opts.registered.count++;
150
- return target.tool(name, description, schema, annotationsFor(cls), withAudit(name, handler, opts.audit));
151
- };
200
+ // Every module uses the four-argument form; a schema-less tool passes {}.
201
+ return (name, description, schema, handler) => register(target, name, { description, inputSchema: schema }, handler);
152
202
  }
153
203
  if (prop === "registerTool") {
154
- return (name, config, handler) => {
155
- const cls = classifyTool(name);
156
- if (opts.disabled.has(name))
157
- return noopToolHandle(); // explicit denylist
158
- if (opts.readonly && cls !== "read")
159
- return noopToolHandle(); // read-only posture: skip anything that mutates/spends
160
- // Name-derived classification stays authoritative for the four hints so
161
- // a module cannot soften them; other annotation fields pass through.
162
- if (opts.registered)
163
- opts.registered.count++;
164
- const annotations = {
165
- ...config.annotations,
166
- ...annotationsFor(cls),
167
- };
168
- return target.registerTool(name, { ...config, annotations }, withAudit(name, handler, opts.audit));
169
- };
204
+ return (name, config, handler) => register(target, name, config, handler);
170
205
  }
171
206
  return Reflect.get(target, prop, receiver);
172
207
  },
@@ -179,7 +214,7 @@ function guard(server, opts) {
179
214
  * start — which MCP clients report as an opaque "server failed to start" —
180
215
  * the server boots with ONLY tenki_auth_status registered, so an agent can
181
216
  * discover and explain the missing credential. Registering the other tools in
182
- * that state would offer 70 tools that can only fail.
217
+ * that state would offer 82 tools that can only fail.
183
218
  */
184
219
  export function createServer(client) {
185
220
  const server = new McpServer({ name: "tenki", version: VERSION });
@@ -7,9 +7,9 @@
7
7
  * GetArtifactDownloadUrl to GET one out. These tools return the signed URL; the
8
8
  * caller performs the actual HTTP PUT/GET.
9
9
  *
10
- * GetArtifactUploadUrlRequest { sessionId, path, contentType } is verified from the
11
- * published Tenki API surface. GetArtifactDownloadUrl takes an artifactId only — the API
12
- * rejects a path (command stdout/stderr are surfaced as artifact ids).
10
+ * GetArtifactUploadUrlRequest is { session_id, path, content_type }.
11
+ * GetArtifactDownloadUrlRequest is { artifact_id } ONLY — there is no session_id
12
+ * field (the API discards unknown fields silently), so the tool no longer requires one.
13
13
  */
14
14
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
15
  import type { TenkiClient } from "../client.js";
@@ -10,10 +10,10 @@ export function registerArtifacts(server, client) {
10
10
  path,
11
11
  ...(content_type ? { contentType: content_type } : {}),
12
12
  })));
13
- server.tool("tenki_get_download_url", "Get a short-lived signed URL to download (HTTP GET) a command artifact from a sandbox by its artifact id (e.g. a command's stdout/stderr artifact). Note: the API supports download-by-artifact-id only, not download-by-path.", {
14
- session_id: sessionIdSchema,
15
- artifact_id: z.string().describe("Artifact id to download (e.g. a command's stdout/stderr artifact)."),
16
- },
17
- // GetArtifactDownloadUrl only accepts an artifact UUID; a `path` is rejected (live-verified).
18
- async ({ session_id, artifact_id }) => ok(await client.control("GetArtifactDownloadUrl", { sessionId: session_id, artifactId: artifact_id })));
13
+ server.tool("tenki_get_download_url", "Get a short-lived signed URL to download (HTTP GET) an artifact by its artifact id (e.g. the artifactId returned by tenki_get_upload_url, or a template build's buildLogArtifactId). The API supports download-by-artifact-id only, not download-by-path.", {
14
+ artifact_id: z.string().describe("Artifact id (UUID) to download."),
15
+ session_id: sessionIdSchema
16
+ .optional()
17
+ .describe("Ignored — kept for backwards compatibility; the API addresses artifacts by id alone."),
18
+ }, async ({ artifact_id }) => ok(await client.control("GetArtifactDownloadUrl", { artifactId: artifact_id })));
19
19
  }
@@ -18,13 +18,13 @@ export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "sessi
18
18
  export interface CredentialInfo {
19
19
  kind: CredentialKind;
20
20
  /** Env var the token came from, or undefined when there is none. */
21
- source?: "TENKI_AUTH_TOKEN" | "TENKI_API_KEY";
21
+ source?: "LUXORAI_AUTH_TOKEN" | "LUXORAI_API_KEY" | "TENKI_AUTH_TOKEN" | "TENKI_API_KEY";
22
22
  }
23
23
  /**
24
24
  * Classify the ambient credential WITHOUT returning any of its material.
25
- * Mirrors the header selection in client.ts (`tk_` → Bearer API key,
25
+ * Mirrors the header selection in client.ts (`lx_`/`tk_` → Bearer API key,
26
26
  * `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
27
- * index.ts's precedence (TENKI_AUTH_TOKEN wins over TENKI_API_KEY).
27
+ * index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, TENKI_API_KEY).
28
28
  */
29
29
  export declare function describeCredential(env?: NodeJS.ProcessEnv): CredentialInfo;
30
30
  /**
@@ -1,20 +1,19 @@
1
1
  import { z } from "zod";
2
+ import { isApiKey, readEnv, resolvedEnvName } from "../env.js";
2
3
  /**
3
4
  * Classify the ambient credential WITHOUT returning any of its material.
4
- * Mirrors the header selection in client.ts (`tk_` → Bearer API key,
5
+ * Mirrors the header selection in client.ts (`lx_`/`tk_` → Bearer API key,
5
6
  * `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
6
- * index.ts's precedence (TENKI_AUTH_TOKEN wins over TENKI_API_KEY).
7
+ * index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, TENKI_API_KEY).
7
8
  */
8
9
  export function describeCredential(env = process.env) {
9
- const fromToken = env.TENKI_AUTH_TOKEN?.trim();
10
- const fromKey = env.TENKI_API_KEY?.trim();
11
- const raw = fromToken || fromKey;
12
- if (!raw && env.TENKI_MCP_OAUTH_ISSUER?.trim())
10
+ const source = resolvedEnvName(env, "AUTH_TOKEN", "API_KEY");
11
+ const raw = source ? env[source].trim() : "";
12
+ if (!raw && resolvedEnvName(env, "MCP_OAUTH_ISSUER"))
13
13
  return { kind: "hosted_oauth" };
14
14
  if (!raw)
15
15
  return { kind: "none" };
16
- const source = fromToken ? "TENKI_AUTH_TOKEN" : "TENKI_API_KEY";
17
- if (raw.startsWith("tk_"))
16
+ if (isApiKey(raw))
18
17
  return { kind: "api_key", source };
19
18
  if (raw.startsWith("ory_st_"))
20
19
  return { kind: "oauth_session_token", source };
@@ -64,7 +63,7 @@ export function registerAuthStatus(server, client, toolsRegistered) {
64
63
  outputSchema: authOutputSchema,
65
64
  }, async () => {
66
65
  const cred = describeCredential();
67
- const endpoint = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || "https://api.tenki.cloud";
66
+ const endpoint = readEnv("API_ENDPOINT", "API_URL") || "https://api.tenki.cloud";
68
67
  const base = {
69
68
  credential: cred.kind,
70
69
  ...(cred.source ? { source: cred.source } : {}),
@@ -8,6 +8,19 @@ export declare const ok: (value: unknown) => {
8
8
  text: string;
9
9
  }[];
10
10
  };
11
+ /** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
12
+ export declare function utf8CharStart(buf: Buffer, i: number): number;
13
+ /** Largest end ≤ limit that does not split a character; always > start when limit > start. */
14
+ export declare function utf8CharEnd(buf: Buffer, start: number, limit: number): number;
15
+ /**
16
+ * Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
17
+ * text unchanged when it fits; otherwise the head plus the original size.
18
+ */
19
+ export declare function capText(text: string, maxBytes: number): {
20
+ text: string;
21
+ truncated: boolean;
22
+ totalBytes: number;
23
+ };
11
24
  /** Shared env-map schema used by tools that accept environment variables. */
12
25
  export declare const envSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
26
  /** Shared session-id schema — every per-sandbox tool takes one of these.
@@ -33,3 +46,25 @@ export declare const pathSchema: z.ZodString;
33
46
  * those stay server-side.
34
47
  */
35
48
  export declare const slugSchema: z.ZodString;
49
+ /**
50
+ * Shared tag-list schema, matching the API's repeated-string constraint on
51
+ * sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
52
+ * [a-z0-9_:.-] starting with a letter or digit.
53
+ */
54
+ export declare const tagsSchema: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ /**
56
+ * Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
57
+ * volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
58
+ * a non-empty list together with clear_tags is rejected because the server
59
+ * applies clear_tags first and would silently discard the list.
60
+ */
61
+ export declare function tagsPatch(tags?: string[], clearTags?: boolean): {
62
+ tags?: string[];
63
+ clearTags?: true;
64
+ };
65
+ /** Free-text search across a list (server-side; API max 256 chars). */
66
+ export declare const searchSchema: z.ZodOptional<z.ZodString>;
67
+ /** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
68
+ export declare const protoEnum: (prefix: string, value: string) => string;
69
+ /** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
70
+ export declare const listQuery: (search?: string, includeFacets?: boolean, sortDesc?: boolean) => Record<string, unknown>;
@@ -8,6 +8,8 @@ const PUBLIC_KEYS = {
8
8
  source_registry_workspace_id: "source_image_workspace_id",
9
9
  sourceRegistryRef: "sourceImage",
10
10
  source_registry_ref: "source_image",
11
+ registryImageName: "publishedImageName",
12
+ registry_image_name: "published_image_name",
11
13
  };
12
14
  /** Keep registry-backed implementation fields out of public MCP responses. */
13
15
  export function publicValue(value) {
@@ -21,6 +23,48 @@ export function publicValue(value) {
21
23
  export const ok = (value) => ({
22
24
  content: [{ type: "text", text: JSON.stringify(publicValue(value), null, 2) }],
23
25
  });
26
+ // UTF-8 boundary handling for byte-capped text. A position is "inside a
27
+ // character" only when a lead byte at most 3 bytes before it declares a
28
+ // sequence long enough to cover it. Stray continuation bytes (binary data,
29
+ // invalid UTF-8) are not inside anything, so a cut lands exactly there. Start
30
+ // and end use the same rule, so consecutive pages tile the file with no gap or
31
+ // overlap, on valid UTF-8 and on binary alike.
32
+ const isContinuation = (b) => (b & 0xc0) === 0x80;
33
+ /** Sequence length a lead byte declares (1 for ASCII and invalid leads). */
34
+ const utf8SeqLen = (b) => (b >= 0xf0 && b <= 0xf7 ? 4 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xc0 && b <= 0xdf ? 2 : 1);
35
+ /** Start of the character covering i, or -1 when i is a character start or a stray byte. */
36
+ function coveringLead(buf, i, floor) {
37
+ if (i >= buf.length || !isContinuation(buf[i]))
38
+ return -1;
39
+ for (let back = 1; back <= 3 && i - back >= floor; back++) {
40
+ const b = buf[i - back];
41
+ if (!isContinuation(b))
42
+ return utf8SeqLen(b) > back ? i - back : -1;
43
+ }
44
+ return -1;
45
+ }
46
+ /** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
47
+ export function utf8CharStart(buf, i) {
48
+ const lead = coveringLead(buf, i, 0);
49
+ return lead < 0 ? i : Math.min(lead + utf8SeqLen(buf[lead]), buf.length);
50
+ }
51
+ /** Largest end ≤ limit that does not split a character; always > start when limit > start. */
52
+ export function utf8CharEnd(buf, start, limit) {
53
+ if (limit >= buf.length)
54
+ return buf.length;
55
+ const lead = coveringLead(buf, limit, start + 1);
56
+ return lead < 0 ? limit : lead;
57
+ }
58
+ /**
59
+ * Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
60
+ * text unchanged when it fits; otherwise the head plus the original size.
61
+ */
62
+ export function capText(text, maxBytes) {
63
+ const buf = Buffer.from(text, "utf8");
64
+ if (buf.length <= maxBytes)
65
+ return { text, truncated: false, totalBytes: buf.length };
66
+ return { text: buf.subarray(0, utf8CharEnd(buf, 0, maxBytes)).toString("utf8"), truncated: true, totalBytes: buf.length };
67
+ }
24
68
  /** Shared env-map schema used by tools that accept environment variables. */
25
69
  export const envSchema = z.record(z.string(), z.string()).optional().describe("Environment variables as a key→value object.");
26
70
  /** Shared session-id schema — every per-sandbox tool takes one of these.
@@ -58,3 +102,37 @@ export const slugSchema = z
58
102
  .max(63)
59
103
  .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits, and hyphens; cannot start or end with a hyphen")
60
104
  .describe("Subdomain slug for the preview URL (3-63 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen).");
105
+ /**
106
+ * Shared tag-list schema, matching the API's repeated-string constraint on
107
+ * sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
108
+ * [a-z0-9_:.-] starting with a letter or digit.
109
+ */
110
+ export const tagsSchema = z
111
+ .array(z.string().max(32).regex(/^[a-z0-9][a-z0-9_:.-]*$/, "lowercase letters, digits, and _:.- ; must start with a letter or digit"))
112
+ .max(20)
113
+ .optional()
114
+ .describe("Tags (≤20, each ≤32 chars of a-z 0-9 _ : . -).");
115
+ /**
116
+ * Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
117
+ * volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
118
+ * a non-empty list together with clear_tags is rejected because the server
119
+ * applies clear_tags first and would silently discard the list.
120
+ */
121
+ export function tagsPatch(tags, clearTags) {
122
+ if (clearTags && tags && tags.length) {
123
+ throw new Error("pass either tags (a replacement list) or clear_tags, not both — clear_tags would win and the tags would be silently dropped.");
124
+ }
125
+ if (clearTags || (tags !== undefined && tags.length === 0))
126
+ return { clearTags: true };
127
+ return tags && tags.length ? { tags } : {};
128
+ }
129
+ /** Free-text search across a list (server-side; API max 256 chars). */
130
+ export const searchSchema = z.string().max(256).optional();
131
+ /** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
132
+ export const protoEnum = (prefix, value) => `${prefix}_${value}`;
133
+ /** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
134
+ export const listQuery = (search, includeFacets, sortDesc) => ({
135
+ ...(search && search.trim() ? { search: search.trim() } : {}),
136
+ ...(includeFacets ? { includeFacets: true } : {}),
137
+ ...(sortDesc ? { sortDesc: true } : {}),
138
+ });
@@ -0,0 +1,38 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import type { TenkiClient } from "../client.js";
4
+ export declare const policyNameSchema: z.ZodString;
5
+ export declare const httpRuleSchema: z.ZodObject<{
6
+ domains: z.ZodArray<z.ZodString>;
7
+ methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
+ path_exact: z.ZodOptional<z.ZodString>;
9
+ path_prefix: z.ZodOptional<z.ZodString>;
10
+ path_regex: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>;
12
+ export type HttpRule = z.infer<typeof httpRuleSchema>;
13
+ export declare const httpRuleToWire: (r: HttpRule) => Record<string, unknown>;
14
+ export declare const allowDomainsSchema: z.ZodArray<z.ZodString>;
15
+ export declare const allowCidrsSchema: z.ZodArray<z.ZodString>;
16
+ export declare const interceptHttpsSchema: z.ZodBoolean;
17
+ export declare const httpRulesSchema: z.ZodArray<z.ZodObject<{
18
+ domains: z.ZodArray<z.ZodString>;
19
+ methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
20
+ path_exact: z.ZodOptional<z.ZodString>;
21
+ path_prefix: z.ZodOptional<z.ZodString>;
22
+ path_regex: z.ZodOptional<z.ZodString>;
23
+ }, z.core.$strict>>;
24
+ export declare const allowedPortsSchema: z.ZodArray<z.ZodNumber>;
25
+ export interface EgressBody {
26
+ allow_domains?: string[];
27
+ allow_cidrs?: string[];
28
+ intercept_https?: boolean;
29
+ http_rules?: HttpRule[];
30
+ allowed_ports?: number[];
31
+ }
32
+ /**
33
+ * Map an egress body to SessionEgressPolicy JSON. `requireComplete` applies the
34
+ * checks the API runs on a policy that stands alone; on create they are skipped
35
+ * when named policies are pinned, because the API merges them first.
36
+ */
37
+ export declare function egressBodyToWire(tool: string, b: EgressBody, requireComplete: boolean): Record<string, unknown> | undefined;
38
+ export declare function registerEgress(server: McpServer, client: TenkiClient): void;