@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
@@ -0,0 +1,153 @@
1
+ import { z } from "zod";
2
+ import { ok } from "./common.js";
3
+ /**
4
+ * Named workspace egress policies (tenki-app #6069): reusable egress allowlists
5
+ * that tenki_create_sandbox pins by name via `egress_policies`. A session keeps
6
+ * the expansion it was created with; later edits or deletes do not change it.
7
+ * Wire shape: SessionEgressPolicy { allowDomains, allowCidrs, interceptHttps,
8
+ * httpRules[{ domains, methods, exact | prefix | regex }], allowedPorts } — the
9
+ * path oneof members are plain fields on the rule.
10
+ */
11
+ const POLICY_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,127}$/;
12
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ export const policyNameSchema = z
14
+ .string()
15
+ .regex(POLICY_NAME, "policy names: letters, digits, _ and -, starting with a letter or _, max 128");
16
+ const domainSchema = z.string().min(1).max(253);
17
+ const pathSchema = z.string().min(1).max(2048).regex(/^\//, "paths start with /");
18
+ export const httpRuleSchema = z
19
+ .object({
20
+ domains: z
21
+ .array(domainSchema)
22
+ .min(1)
23
+ .max(256)
24
+ .describe("Hosts the rule applies to: exact names, single-label '*.example.com' wildcards, or '*' for every allowed domain."),
25
+ methods: z
26
+ .array(z.string().min(1).max(32))
27
+ .max(16)
28
+ .optional()
29
+ .describe("HTTP methods (e.g. GET, POST; uppercased by the API). Omit to match every method."),
30
+ path_exact: pathSchema.optional().describe("Match only this escaped path (no query)."),
31
+ path_prefix: pathSchema.optional().describe("Match paths starting with this prefix."),
32
+ path_regex: z.string().min(1).max(1024).optional().describe("RE2 regex that must match the whole escaped path (no query)."),
33
+ })
34
+ .strict()
35
+ .refine((r) => [r.path_exact, r.path_prefix, r.path_regex].filter((p) => p !== undefined).length <= 1, {
36
+ message: "set at most one of path_exact, path_prefix, path_regex",
37
+ });
38
+ export const httpRuleToWire = (r) => ({
39
+ domains: r.domains,
40
+ ...(r.methods?.length ? { methods: r.methods } : {}),
41
+ ...(r.path_exact !== undefined ? { exact: r.path_exact } : {}),
42
+ ...(r.path_prefix !== undefined ? { prefix: r.path_prefix } : {}),
43
+ ...(r.path_regex !== undefined ? { regex: r.path_regex } : {}),
44
+ });
45
+ export const allowDomainsSchema = z
46
+ .array(domainSchema)
47
+ .max(256)
48
+ .describe("Allowed hosts: exact names or single-label '*.example.com' wildcards (≤256).");
49
+ export const allowCidrsSchema = z
50
+ .array(z.string().min(1).max(18))
51
+ .max(64)
52
+ .describe("Allowed IPv4 prefixes reachable without a name lookup (≤64; not 0.0.0.0/0).");
53
+ export const interceptHttpsSchema = z
54
+ .boolean()
55
+ .describe("Terminate port-443 TLS with the sandbox's managed CA so each request's Host is checked; requires at least one allowed domain. With interception on, public egress is limited to TCP 80/443 through the proxy; other ports are blocked unless listed in allowed_ports.");
56
+ export const httpRulesSchema = z
57
+ .array(httpRuleSchema)
58
+ .max(64)
59
+ .describe("Per-host HTTP method/path rules (≤64; requires intercept_https). On ports 80 and 443 a request matching no rule is refused with 403.");
60
+ export const allowedPortsSchema = z
61
+ .array(z.number().int().min(1).max(65535))
62
+ .max(64)
63
+ .describe("Extra public TCP ports to open besides 80/443 (≤64; requires intercept_https). They skip HTTP inspection but still reach only allowed destinations, so 22 permits git over SSH, including pushes.");
64
+ /**
65
+ * Map an egress body to SessionEgressPolicy JSON. `requireComplete` applies the
66
+ * checks the API runs on a policy that stands alone; on create they are skipped
67
+ * when named policies are pinned, because the API merges them first.
68
+ */
69
+ export function egressBodyToWire(tool, b, requireComplete) {
70
+ const domains = b.allow_domains?.length ? b.allow_domains : undefined;
71
+ const cidrs = b.allow_cidrs?.length ? b.allow_cidrs : undefined;
72
+ const rules = b.http_rules?.length ? b.http_rules : undefined;
73
+ const ports = b.allowed_ports?.length ? b.allowed_ports : undefined;
74
+ if (requireComplete) {
75
+ if (!domains && !cidrs && (b.intercept_https || rules || ports))
76
+ throw new Error(`${tool}: an egress policy must list at least one allowed domain or CIDR.`);
77
+ if (b.intercept_https && !domains)
78
+ throw new Error(`${tool}: intercept_https requires at least one allowed domain.`);
79
+ if (rules && !b.intercept_https)
80
+ throw new Error(`${tool}: http_rules require intercept_https: true (without interception HTTPS requests would bypass them).`);
81
+ if (ports && !b.intercept_https)
82
+ throw new Error(`${tool}: allowed_ports require intercept_https: true (without interception other ports already follow the domain and CIDR allowlist).`);
83
+ }
84
+ if (!domains && !cidrs && !b.intercept_https && !rules && !ports)
85
+ return undefined;
86
+ return {
87
+ ...(domains ? { allowDomains: domains } : {}),
88
+ ...(cidrs ? { allowCidrs: cidrs } : {}),
89
+ ...(b.intercept_https ? { interceptHttps: true } : {}),
90
+ ...(rules ? { httpRules: rules.map(httpRuleToWire) } : {}),
91
+ ...(ports ? { allowedPorts: ports } : {}),
92
+ };
93
+ }
94
+ const workspaceIdSchema = z.string().optional().describe("Workspace (defaults to the key's workspace).");
95
+ const policyRefSchema = z.string().min(1).describe("Policy ID or name.");
96
+ const revisionSchema = z
97
+ .number()
98
+ .int()
99
+ .min(1)
100
+ .max(4294967295)
101
+ .describe("The policy's current revision (from tenki_get_egress_policy); a stale revision is refused with ABORTED.");
102
+ const ENABLE_NOTE = "Sandboxes cannot pin policies (failed_precondition) if session egress policies are disabled for the workspace (on by default; Tenki staff can turn it off).";
103
+ export function registerEgress(server, client) {
104
+ const workspace = async (workspace_id) => {
105
+ const id = workspace_id ?? (await client.resolveOwner()).workspaceId;
106
+ return id ? { workspaceId: id } : {};
107
+ };
108
+ const body = (tool, b) => {
109
+ const egress = egressBodyToWire(tool, b, true);
110
+ if (!egress)
111
+ throw new Error(`${tool}: an egress policy must list at least one allowed domain or CIDR.`);
112
+ return egress;
113
+ };
114
+ server.tool("tenki_create_egress_policy", `Save a named egress policy (allowed domains/CIDRs, optional HTTPS interception, per-host HTTP rules and extra ports) in the workspace, for tenki_create_sandbox's egress_policies to pin by name. ${ENABLE_NOTE}`, {
115
+ name: policyNameSchema.refine((n) => !UUID.test(n), "a policy name must not be a UUID").describe("Policy name (letters, digits, _ and -; not a UUID). Exact, never trimmed."),
116
+ allow_domains: allowDomainsSchema.optional(),
117
+ allow_cidrs: allowCidrsSchema.optional(),
118
+ intercept_https: interceptHttpsSchema.optional(),
119
+ http_rules: httpRulesSchema.optional(),
120
+ allowed_ports: allowedPortsSchema.optional(),
121
+ workspace_id: workspaceIdSchema,
122
+ }, async ({ name, workspace_id, ...b }) => ok(await client.control("CreateEgressPolicy", { ...(await workspace(workspace_id)), name, egress: body("tenki_create_egress_policy", b) })));
123
+ server.tool("tenki_update_egress_policy", "Replace a named egress policy's rules (the whole body; omitted fields are cleared). Requires the current revision. Sandboxes created earlier keep the rules they were created with.", {
124
+ policy_id: policyRefSchema,
125
+ expected_revision: revisionSchema,
126
+ allow_domains: allowDomainsSchema.optional(),
127
+ allow_cidrs: allowCidrsSchema.optional(),
128
+ intercept_https: interceptHttpsSchema.optional(),
129
+ http_rules: httpRulesSchema.optional(),
130
+ allowed_ports: allowedPortsSchema.optional(),
131
+ workspace_id: workspaceIdSchema,
132
+ }, async ({ policy_id, expected_revision, workspace_id, ...b }) => ok(await client.control("UpdateEgressPolicy", {
133
+ ...(await workspace(workspace_id)),
134
+ policyId: policy_id,
135
+ expectedRevision: expected_revision,
136
+ egress: body("tenki_update_egress_policy", b),
137
+ })));
138
+ server.tool("tenki_get_egress_policy", "Get one named egress policy by ID or name: its rules, current revision, and how many non-terminated sandboxes pinned it.", { policy_id: policyRefSchema, workspace_id: workspaceIdSchema }, async ({ policy_id, workspace_id }) => ok(await client.control("GetEgressPolicy", { ...(await workspace(workspace_id)), policyId: policy_id })));
139
+ server.tool("tenki_list_egress_policies", "List the workspace's named egress policies, ordered by name. Pass next_cursor back as cursor for the next page.", {
140
+ page_size: z.number().int().min(1).max(100).optional().describe("Max policies per page (≤100, default 50)."),
141
+ cursor: z.string().optional().describe("next_cursor from the previous page."),
142
+ workspace_id: workspaceIdSchema,
143
+ }, async ({ page_size, cursor, workspace_id }) => ok(await client.control("ListEgressPolicies", {
144
+ ...(await workspace(workspace_id)),
145
+ ...(page_size ? { pageSize: page_size } : {}),
146
+ ...(cursor ? { cursor } : {}),
147
+ })));
148
+ server.tool("tenki_delete_egress_policy", "Delete a named egress policy (requires its current revision). New sandboxes can no longer pin it; sandboxes that already pinned it keep its rules.", { policy_id: policyRefSchema, expected_revision: revisionSchema, workspace_id: workspaceIdSchema }, async ({ policy_id, expected_revision, workspace_id }) => ok(await client.control("DeleteEgressPolicy", {
149
+ ...(await workspace(workspace_id)),
150
+ policyId: policy_id,
151
+ expectedRevision: expected_revision,
152
+ })));
153
+ }
@@ -87,7 +87,7 @@ export function registerExec(server, client) {
87
87
  args: z.array(z.string()).optional().describe("Arguments."),
88
88
  cwd: z.string().optional().describe("Working directory (honored in-script)."),
89
89
  env: envSchema,
90
- timeout_seconds: z.number().int().positive().optional(),
90
+ timeout_seconds: z.number().int().positive().optional().describe("Maximum seconds the command may run before it is stopped; omit to use the API's default."),
91
91
  max_output_bytes: z
92
92
  .number()
93
93
  .int()
@@ -1,9 +1,42 @@
1
1
  import { z } from "zod";
2
- import { ok, pathSchema, sessionIdSchema } from "./common.js";
2
+ import { ok, pathSchema, sessionIdSchema, utf8CharEnd, utf8CharStart } from "./common.js";
3
+ // Inline read budget, matching tenki_exec's per-stream default. The data
4
+ // plane's ReadFile has no range support, so the whole file still crosses the
5
+ // wire to this process; the cap bounds what is returned to the model.
6
+ const DEFAULT_READ_BYTES = 65_536;
7
+ const MAX_READ_BYTES = 1_048_576;
3
8
  /** Filesystem I/O against a sandbox's data plane. */
4
9
  export function registerFiles(server, client) {
5
- server.tool("tenki_read_file", "Read a UTF-8 text file from a sandbox (paths under /home/tenki).", { session_id: sessionIdSchema, path: pathSchema }, async ({ session_id, path }) => ok({ path, content: await client.readTextFile(session_id, path) }));
6
- server.tool("tenki_write_file", "Write a UTF-8 text file to a sandbox (paths under /home/tenki).", { session_id: sessionIdSchema, path: pathSchema, content: z.string() }, async ({ session_id, path, content }) => ok(await client.writeTextFile(session_id, path, content)));
10
+ server.tool("tenki_read_file", "Read a UTF-8 text file from a sandbox (paths under /home/tenki). Returns at most max_bytes (default 65536) starting at offset, plus the file's totalBytes; when more remains the response has truncated: true and nextOffset — pass it as offset to read the next part.", {
11
+ session_id: sessionIdSchema,
12
+ path: pathSchema,
13
+ offset: z.number().int().min(0).optional().describe("Byte offset to start reading at (default 0). Use nextOffset from a truncated response."),
14
+ max_bytes: z
15
+ .number()
16
+ .int()
17
+ .min(1024)
18
+ .max(MAX_READ_BYTES)
19
+ .optional()
20
+ .describe(`Maximum bytes of content to return (default ${DEFAULT_READ_BYTES}, max ${MAX_READ_BYTES}). Cuts never split a UTF-8 character.`),
21
+ }, async ({ session_id, path, offset, max_bytes }) => {
22
+ const buf = await client.readFileBytes(session_id, path);
23
+ const totalBytes = buf.length;
24
+ const requested = offset ?? 0;
25
+ if (requested > totalBytes) {
26
+ throw new Error(`offset ${requested} is past the end of ${path} (${totalBytes} bytes).`);
27
+ }
28
+ const start = utf8CharStart(buf, requested);
29
+ const end = utf8CharEnd(buf, start, start + (max_bytes ?? DEFAULT_READ_BYTES));
30
+ const truncated = end < totalBytes;
31
+ return ok({
32
+ path,
33
+ content: buf.subarray(start, end).toString("utf8"),
34
+ totalBytes,
35
+ ...(start > 0 || truncated ? { offset: start, bytesReturned: end - start } : {}),
36
+ ...(truncated ? { truncated: true, nextOffset: end } : {}),
37
+ });
38
+ });
39
+ server.tool("tenki_write_file", "Write a UTF-8 text file to a sandbox (paths under /home/tenki), creating it or replacing its entire contents.", { session_id: sessionIdSchema, path: pathSchema, content: z.string().describe("Full text to write; an existing file is overwritten.") }, async ({ session_id, path, content }) => ok(await client.writeTextFile(session_id, path, content)));
7
40
  server.tool("tenki_list_files", "List a directory in a sandbox, including dotfiles (.git, .env, .gitignore) by default — set include_hidden false to omit them.", {
8
41
  session_id: sessionIdSchema,
9
42
  path: pathSchema.describe("Directory path, e.g. /home/tenki"),
@@ -18,9 +18,17 @@ export function registerFilesOps(server, client) {
18
18
  session_id: sessionIdSchema,
19
19
  path: pathSchema.describe("Directory path under /home/tenki, e.g. /home/tenki/project/out"),
20
20
  recursive: z.boolean().optional().describe("Create parent directories as needed (default false)."),
21
- }, async ({ session_id, path, recursive }) => ok(await client.data(session_id, "Mkdir", {
21
+ mode: z
22
+ .number()
23
+ .int()
24
+ .min(0)
25
+ .max(0o7777)
26
+ .optional()
27
+ .describe("Permission bits as a number (e.g. 448 for 0700). Default: the sandbox user's umask."),
28
+ }, async ({ session_id, path, recursive, mode }) => ok(await client.data(session_id, "Mkdir", {
22
29
  path,
23
30
  ...(recursive ? { recursive: true } : {}),
31
+ ...(mode !== undefined ? { mode } : {}),
24
32
  })));
25
33
  server.tool("tenki_remove_path", "Delete a file or directory in a sandbox. Set recursive to remove a non-empty directory and its contents (rm -r).", {
26
34
  session_id: sessionIdSchema,
@@ -1,4 +1,3 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { TenkiClient } from "../client.js";
3
- /** Git operations inside a sandbox (one RPC dispatched by operation string). */
4
3
  export declare function registerGit(server: McpServer, client: TenkiClient): void;
package/dist/tools/git.js CHANGED
@@ -1,30 +1,92 @@
1
1
  import { z } from "zod";
2
- import { ok, sessionIdSchema } from "./common.js";
2
+ import { capText, ok, sessionIdSchema } from "./common.js";
3
3
  /**
4
- * The API validates `operation` against exactly this list (GitOperationRequest's
5
- * buf.validate `in` constraint) and the engine implements exactly these four —
6
- * anything else (status/add/commit/push/...) is rejected server-side. For other
7
- * git commands, run `git ...` via tenki_exec.
4
+ * Git inside a sandbox, one tool per operation. The API exposes a single
5
+ * GitOperation RPC whose `operation` must be clone, checkout, diff or log
6
+ * (GitOperationRequest's buf.validate `in` list) with a string→string args map;
7
+ * these tools give each operation typed arguments and an accurate read/write
8
+ * classification (diff/log are reads, clone/checkout change the filesystem).
9
+ * For any other git command (status, add, commit, push, ...) use tenki_exec.
10
+ *
11
+ * Arg keys are the ones the engine reads (tenki-app
12
+ * backend/internal/domain/sandbox/service/git_operation.go, buildGitOperationArgs):
13
+ * checkout/diff/log run `git -C <directory>` when `directory` is set — without
14
+ * it they run in /home/tenki, which is not a repository unless one was cloned
15
+ * there. Values are argv to the engine's command, never a shell string.
16
+ *
17
+ * The engine places repo, branch, directory, ref, range, base and head in
18
+ * git's argv without `--end-of-options`, so a value starting with `-` would be
19
+ * parsed as an option: `git diff --output=<file>` truncates a file and
20
+ * `git clone --upload-pack=<cmd>` runs a command. Those values are refused
21
+ * here — which is also what keeps tenki_git_diff / tenki_git_log read-only.
22
+ * `path` is safe: the engine always puts it after `--`.
8
23
  */
9
- const GIT_OPERATIONS = ["clone", "checkout", "diff", "log"];
10
- /** Git operations inside a sandbox (one RPC dispatched by operation string). */
24
+ // Inline output budget, matching tenki_exec and tenki_read_file.
25
+ const MAX_OUTPUT_BYTES = 65_536;
26
+ // The engine re-trims each value with Go's strings.TrimSpace, which also strips
27
+ // U+0085 (NEL) — JavaScript's trim() and \s do not — so "\u0085--output=x"
28
+ // would reach git as "--output=x". Refuse a '-' behind any such whitespace.
29
+ const noOption = (s) => !/^[\s\u0085]*-/.test(s);
30
+ const NO_OPTION_MSG = "must not start with '-' (git would read it as an option)";
31
+ /** A value the engine passes to git as a bare argument. */
32
+ const gitArg = (description) => z.string().trim().min(1).refine(noOption, NO_OPTION_MSG).describe(description);
33
+ const directorySchema = gitArg("Repository directory in the sandbox, e.g. /home/tenki/myrepo (relative paths resolve from /home/tenki). Omit only if the repository is /home/tenki itself.").optional();
34
+ /** How to get less output, per operation (only diff/log take narrowing args). */
35
+ const NARROW = {
36
+ diff: "Narrow it with path or range/base/head, or run git through tenki_exec and page its output.",
37
+ log: "Narrow it with max_count, path or range, or run git through tenki_exec and page its output.",
38
+ };
39
+ const NARROW_DEFAULT = "Run git through tenki_exec to page the full output.";
40
+ async function gitOperation(client, sessionId, operation, args) {
41
+ const stringArgs = Object.fromEntries(Object.entries(args)
42
+ .filter(([, v]) => v !== undefined)
43
+ .map(([k, v]) => [k, String(v)]));
44
+ const resp = await client.control("GitOperation", { sessionId, operation, args: stringArgs });
45
+ if (typeof resp?.output !== "string")
46
+ return ok(resp);
47
+ const capped = capText(resp.output, MAX_OUTPUT_BYTES);
48
+ if (!capped.truncated)
49
+ return ok(resp);
50
+ return ok({
51
+ ...resp,
52
+ output: capped.text,
53
+ truncated: true,
54
+ totalBytes: capped.totalBytes,
55
+ note: `Output truncated to ${MAX_OUTPUT_BYTES} bytes. ${NARROW[operation] ?? NARROW_DEFAULT}`,
56
+ });
57
+ }
11
58
  export function registerGit(server, client) {
12
- server.tool("tenki_git", "Run a git operation in a sandbox. Only clone, checkout, diff, and log are supported by the API — for any other git command (status, add, commit, push, ...) use tenki_exec with `git ...`. " +
13
- "clone arg keys: repo (required, the URL), branch?, depth?, directory?. " +
14
- "CAVEAT (live-verified): checkout/diff/log run in the session's working directory (/home/tenki), which is not a repository and has no directory arg — so on a repo cloned into a subdirectory they fail with 'not a git repository'. Use tenki_exec with `git -C <directory> ...` instead (e.g. `git -C /home/tenki/hw log -n 2`). " +
15
- "For reference, their arg keys are — checkout: ref (required), create? ('true' = -b); diff: range? or base?+head?, path?; log: max_count?, range?, path?.", {
59
+ server.tool("tenki_git_clone", "Clone a git repository into a sandbox and return git's output. The target directory must not already exist or must be empty. For other git commands (status, add, commit, push, ...) use tenki_exec with `git ...`.", {
60
+ session_id: sessionIdSchema,
61
+ repo: gitArg("Repository URL to clone, e.g. https://github.com/octocat/Hello-World."),
62
+ branch: gitArg("Branch or tag to check out after cloning (git clone --branch).").optional(),
63
+ depth: z.number().int().positive().optional().describe("Create a shallow clone with this many commits (git clone --depth)."),
64
+ directory: gitArg("Directory to clone into, e.g. /home/tenki/myrepo. Defaults to the repository name under /home/tenki.").optional(),
65
+ }, async ({ session_id, repo, branch, depth, directory }) => gitOperation(client, session_id, "clone", { repo, branch, depth, directory }));
66
+ server.tool("tenki_git_checkout", "Check out a branch, tag or commit in a repository inside a sandbox (git checkout), optionally creating a new branch. Changes the files in the working tree.", {
16
67
  session_id: sessionIdSchema,
17
- operation: z.enum(GIT_OPERATIONS).describe("One of: clone, checkout, diff, log (the API rejects anything else)."),
18
- // The wire type is map<string,string>, but the values models most want
19
- // to send as numbers or booleans (depth, max_count, create) are
20
- // accepted here and coerced — a client-side rejection of `create: true`
21
- // for a call that would serialize identically helps nobody.
22
- args: z
23
- .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
24
- .optional()
25
- .describe("Operation args as a key→value object (values are sent as strings; numbers/booleans are coerced). clone: {repo, branch?, depth?, directory?}; checkout: {ref, create?: 'true'}; diff: {range?} or {base?, head?}, {path?}; log: {max_count?, range?, path?}."),
26
- }, async ({ session_id, operation, args }) => {
27
- const stringArgs = args ? Object.fromEntries(Object.entries(args).map(([k, v]) => [k, String(v)])) : undefined;
28
- return ok(await client.control("GitOperation", { sessionId: session_id, operation, ...(stringArgs ? { args: stringArgs } : {}) }));
68
+ ref: gitArg("Branch, tag or commit to check out, or the name of the branch to create when create is true."),
69
+ create: z.boolean().optional().describe("Create ref as a new branch from the current HEAD (git checkout -b). Default false."),
70
+ directory: directorySchema,
71
+ }, async ({ session_id, ref, create, directory }) => gitOperation(client, session_id, "checkout", { ref, create: create ? "true" : undefined, directory }));
72
+ server.tool("tenki_git_diff", `Show a diff for a repository inside a sandbox (git diff): unstaged working-tree changes by default, or between refs. Read-only. Output over ${MAX_OUTPUT_BYTES} bytes is truncated.`, {
73
+ session_id: sessionIdSchema,
74
+ range: gitArg("Revision range, e.g. main..feature or HEAD~3. Cannot be combined with base/head.").optional(),
75
+ base: gitArg("Base ref to diff from (with head: git diff base..head; alone: git diff base).").optional(),
76
+ head: gitArg("Head ref to diff to (with base: git diff base..head; alone: git diff head).").optional(),
77
+ path: z.string().trim().min(1).optional().describe("Limit the diff to this file or directory, relative to the repository root."),
78
+ directory: directorySchema,
79
+ }, async ({ session_id, range, base, head, path, directory }) => {
80
+ if (range !== undefined && (base !== undefined || head !== undefined)) {
81
+ throw new Error("tenki_git_diff: pass either range or base/head, not both.");
82
+ }
83
+ return gitOperation(client, session_id, "diff", { range, base, head, path, directory });
29
84
  });
85
+ server.tool("tenki_git_log", `Show commit history for a repository inside a sandbox (git log). Read-only. Output over ${MAX_OUTPUT_BYTES} bytes is truncated.`, {
86
+ session_id: sessionIdSchema,
87
+ max_count: z.number().int().positive().max(1000).optional().describe("Maximum number of commits to show (git log --max-count). Set this to keep output small."),
88
+ range: gitArg("Revision range, e.g. main..feature.").optional(),
89
+ path: z.string().trim().min(1).optional().describe("Only commits touching this file or directory, relative to the repository root."),
90
+ directory: directorySchema,
91
+ }, async ({ session_id, max_count, range, path, directory }) => gitOperation(client, session_id, "log", { max_count, range, path, directory }));
30
92
  }
@@ -1,4 +1,11 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { TenkiClient } from "../client.js";
3
- /** Port exposure / preview URLs. */
3
+ /**
4
+ * Port exposure. ExposePort / ListExposedPorts (and UnexposePort in previews.ts)
5
+ * are marked deprecated upstream (tenki-app #5663, 2026-09-15) in favour of the
6
+ * preview-URL RPCs; they still work — ExposePort now creates a preview URL and
7
+ * auto-mints a slug when none is given, which CreatePreviewUrl cannot do (slug
8
+ * is required there). Kept as the convenience path; descriptions steer callers
9
+ * who already have a slug to tenki_create_preview_url.
10
+ */
4
11
  export declare function registerPorts(server: McpServer, client: TenkiClient): void;
@@ -1,6 +1,27 @@
1
+ import { z } from "zod";
1
2
  import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
2
- /** Port exposure / preview URLs. */
3
+ /**
4
+ * Port exposure. ExposePort / ListExposedPorts (and UnexposePort in previews.ts)
5
+ * are marked deprecated upstream (tenki-app #5663, 2026-09-15) in favour of the
6
+ * preview-URL RPCs; they still work — ExposePort now creates a preview URL and
7
+ * auto-mints a slug when none is given, which CreatePreviewUrl cannot do (slug
8
+ * is required there). Kept as the convenience path; descriptions steer callers
9
+ * who already have a slug to tenki_create_preview_url.
10
+ */
3
11
  export function registerPorts(server, client) {
4
- server.tool("tenki_expose_port", "Expose a port from a sandbox and get a public preview URL. Useful when an agent starts a web server it wants to show.", { session_id: sessionIdSchema, port: portSchema, slug: slugSchema.optional() }, async ({ session_id, port, slug }) => ok(await client.control("ExposePort", { sessionId: session_id, port, ...(slug ? { slug } : {}) })));
5
- server.tool("tenki_list_exposed_ports", "List the ports currently exposed from a sandbox.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("ListExposedPorts", { sessionId: session_id })));
12
+ server.tool("tenki_expose_port", "Expose a port from a sandbox and get a public preview URL (a slug is auto-generated unless you pass one). Useful when an agent starts a web server it wants to show. Requires allow_inbound. The API marks ExposePort deprecated in favour of CreatePreviewUrl — if you already have a slug, prefer tenki_create_preview_url; to take the URL down use tenki_unbind_preview_url / tenki_delete_preview_url with the returned previewUrlId.", {
13
+ session_id: sessionIdSchema,
14
+ port: portSchema,
15
+ slug: slugSchema.optional(),
16
+ expires_at: z
17
+ .string()
18
+ .optional()
19
+ .describe("Optional RFC-3339 timestamp at which the preview URL auto-expires. Omit to keep it until the sandbox ends."),
20
+ }, async ({ session_id, port, slug, expires_at }) => ok(await client.control("ExposePort", {
21
+ sessionId: session_id,
22
+ port,
23
+ ...(slug ? { slug } : {}),
24
+ ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
25
+ })));
26
+ server.tool("tenki_list_exposed_ports", "List the ports currently exposed from a sandbox. DEPRECATED upstream (ListExposedPorts): prefer tenki_list_preview_urls with session_id, which lists the same bindings with their ids, slugs and expiry.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("ListExposedPorts", { sessionId: session_id })));
6
27
  }
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
- import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
2
+ import { listQuery, ok, portSchema, protoEnum, searchSchema, sessionIdSchema, slugSchema } from "./common.js";
3
+ /** The sandbox web terminal (ttyd) port — tenki-app previewports.WebTerminalPort. */
4
+ const WEB_TERMINAL_PORT = 7681;
3
5
  export function registerPreviews(server, client) {
4
6
  // ── Unexpose a port (tear down its exposure + preview) ────────────────────────
5
- server.tool("tenki_unexpose_port", "Remove an inbound port exposure from a sandbox, taking its public URL/preview offline. Use this to un-publish a port previously exposed with tenki_expose_port.", {
7
+ server.tool("tenki_unexpose_port", "Remove an inbound port exposure from a sandbox, unbinding EVERY preview URL on that port and taking them offline. DEPRECATED upstream (UnexposePort): to take one specific preview URL offline use tenki_unbind_preview_url or tenki_delete_preview_url with its id.", {
6
8
  session_id: sessionIdSchema.describe("The sandbox session whose port to unexpose."),
7
9
  port: portSchema.describe("The TCP port inside the sandbox to unexpose (1-65535)."),
8
10
  }, async ({ session_id, port }) => ok(await client.control("UnexposePort", { sessionId: session_id, port })));
@@ -21,10 +23,20 @@ export function registerPreviews(server, client) {
21
23
  slug,
22
24
  ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
23
25
  })));
24
- // ── Open (get) a live preview for a port ──────────────────────────────────────
25
- server.tool("tenki_open_preview", "Open a viewer-token-gated (AUTHENTICATED-mode) preview. USE tenki_expose_port OR tenki_create_preview_url INSTEAD for an ordinary web server: for any port other than the web terminal (7681) the API deliberately returns a non-regional fallback host that currently has no edge route, so the URL 404s (live-verified). The returned viewerToken does resolve via tenki_resolve_preview_token; only the URL is unreachable. Requires allow_inbound.", {
26
- session_id: sessionIdSchema.describe("The sandbox session serving the port."),
27
- port: portSchema.describe("The TCP port inside the sandbox to open a preview for (1-65535)."),
26
+ // ── Open a viewer-token-gated (authenticated) preview of a port ──────────────
27
+ // OpenPreview creates an AUTHENTICATED-mode preview. The API resolves a
28
+ // regional edge URL for the web terminal port (7681); any other port, and any
29
+ // sandbox with runtime secrets, gets the authenticated fallback domain served
30
+ // by the sandbox gateway, for which it registers a gateway route
31
+ // (resolveAuthenticatedPreviewURLForPort / registerAuthenticatedGatewayPreview
32
+ // in tenki-app backend/internal/domain/sandbox/service/port.go). An earlier
33
+ // description claimed those fallback URLs 404; that was not reproducible
34
+ // from the current code or production config and was dropped — confirm with
35
+ // a live OpenPreview on a non-terminal port. It replaces any earlier
36
+ // authenticated preview on the same port (deleteAuthenticatedPreviewExposures).
37
+ server.tool("tenki_open_preview", `Open a viewer-token-gated preview of a port in a sandbox and return its URL and viewer token; unlike tenki_expose_port and tenki_create_preview_url, the URL is not public. Port ${WEB_TERMINAL_PORT} is the sandbox's browser web terminal (ttyd, in the standard images). Requires a sandbox created with allow_inbound; port 22 is reserved. Opening the same port again replaces its earlier preview URL.`, {
38
+ session_id: sessionIdSchema.describe("The sandbox serving the port."),
39
+ port: portSchema.describe(`The TCP port inside the sandbox to open a preview for (1-65535, not 22). ${WEB_TERMINAL_PORT} opens the web terminal.`),
28
40
  expires_at: z
29
41
  .string()
30
42
  .optional()
@@ -35,34 +47,50 @@ export function registerPreviews(server, client) {
35
47
  ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
36
48
  })));
37
49
  // ── List the preview URLs bound to a sandbox / project ────────────────────────
38
- server.tool("tenki_list_preview_urls", "List the workspace's preview URLs, newest page first. Pass session_id to keep only the ones bound to that sandbox (filtered here, not server-side — so it applies to the page you fetched; raise page_size or follow next_page_token to widen it). Results are paginated: a nextPageToken in the response means more pages exist.", {
39
- session_id: sessionIdSchema
50
+ server.tool("tenki_list_preview_urls", "List the workspace's preview URLs with server-side search, binding-state filter, sorting and optional state-count facets; each row carries the bound sandbox's sessionState and sessionName. Pass session_id to list only the ones bound to that sandbox. Paginated: a nextPageToken in the response means more pages exist.", {
51
+ session_id: sessionIdSchema.optional().describe("Only preview URLs bound to this sandbox."),
52
+ search: searchSchema.describe("Free-text search over slug/id (max 256 chars)."),
53
+ states: z
54
+ .array(z.enum(["UNBOUND", "BOUND", "OFFLINE", "ORPHANED"]))
55
+ .max(16)
40
56
  .optional()
41
- .describe("Keep only preview URLs bound to this sandbox. Applied client-side to the fetched page."),
57
+ .describe("Only preview URLs in any of these binding states (OFFLINE = bound to a non-running sandbox, ORPHANED = bound to a sandbox that no longer exists)."),
58
+ sort_by: z.enum(["SLUG", "STATE", "CREATED_AT"]).optional().describe("Column to sort by."),
59
+ sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
60
+ include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope."),
42
61
  workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
43
62
  page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (server default 20, max 100)."),
44
63
  page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
45
- }, async ({ session_id, workspace_id, page_size, page_token }) => {
46
- // The RPC has no sessionId field (ListPreviewUrlsRequest: workspace_id,
47
- // page_size, page_token), so a sessionId sent
48
- // on the wire is silently discarded and every session's rows come back.
49
- // Filter here instead of advertising a filter that does nothing.
50
- const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
64
+ }, async ({ session_id, search, states, sort_by, sort_desc, include_facets, workspace_id, page_size, page_token }) => {
65
+ // ListPreviewUrlsRequest.session_id (field 5) filters server-side and already
66
+ // scopes the query, so the WhoAmI lookup for a default workspace is skipped then.
67
+ const workspaceId = workspace_id ?? (session_id ? undefined : (await client.resolveOwner()).workspaceId);
51
68
  const resp = await client.control("ListPreviewUrls", {
52
69
  ...(workspaceId ? { workspaceId } : {}),
70
+ ...(session_id ? { sessionId: session_id } : {}),
71
+ ...(states && states.length ? { states: states.map((s) => protoEnum("PREVIEW_URL_BINDING_STATE", s)) } : {}),
72
+ ...(sort_by ? { sortBy: protoEnum("PREVIEW_URL_SORT_FIELD", sort_by) } : {}),
73
+ ...listQuery(search, include_facets, sort_desc),
53
74
  ...(page_size !== undefined ? { pageSize: page_size } : {}),
54
75
  ...(page_token ? { pageToken: page_token } : {}),
55
76
  });
56
- const rows = Array.isArray(resp.previewUrls) ? resp.previewUrls : [];
57
- const filtered = session_id ? rows.filter((r) => r?.sessionId === session_id) : rows;
77
+ // proto3 omits an empty repeated field; normalize so an empty page is an array.
58
78
  return ok({
59
- previewUrls: filtered,
79
+ previewUrls: Array.isArray(resp.previewUrls) ? resp.previewUrls : [],
60
80
  ...(resp.nextPageToken ? { nextPageToken: resp.nextPageToken } : {}),
61
- ...(session_id ? { filteredClientSide: true, fetchedOnThisPage: rows.length } : {}),
81
+ ...(resp.facets ? { facets: resp.facets } : {}),
62
82
  });
63
83
  });
64
84
  // ── Get / delete a specific preview URL ───────────────────────────────────────
65
- server.tool("tenki_get_preview_url", "Fetch a specific preview URL's details by id.", { preview_url_id: z.string().describe("The preview URL id.") }, async ({ preview_url_id }) => ok(await client.control("GetPreviewUrl", { previewUrlId: preview_url_id })));
85
+ server.tool("tenki_get_preview_url", "Fetch a specific preview URL's details by id OR by slug (pass exactly one).", {
86
+ preview_url_id: z.string().min(1).optional().describe("The preview URL id."),
87
+ slug: slugSchema.optional().describe("The preview URL's slug (alternative to preview_url_id)."),
88
+ }, async ({ preview_url_id, slug }) => {
89
+ if ((preview_url_id === undefined) === (slug === undefined)) {
90
+ throw new Error("tenki_get_preview_url: pass exactly one of preview_url_id or slug.");
91
+ }
92
+ return ok(await client.control("GetPreviewUrl", preview_url_id ? { previewUrlId: preview_url_id } : { slug }));
93
+ });
66
94
  server.tool("tenki_delete_preview_url", "Delete a preview URL by id, taking it permanently offline.", { preview_url_id: z.string().describe("The preview URL id to delete.") }, async ({ preview_url_id }) => ok(await client.control("DeletePreviewUrl", { previewUrlId: preview_url_id })));
67
95
  // ── Touch (keep-alive) a preview ──────────────────────────────────────────────
68
96
  server.tool("tenki_touch_preview", "Refresh (keep-alive) a live preview by its preview token so it isn't torn down as idle.", { preview_token: z.string().describe("The preview token (from create_preview_url / open_preview).") },
@@ -70,11 +98,20 @@ export function registerPreviews(server, client) {
70
98
  async ({ preview_token }) => ok(await client.control("TouchPreview", { previewToken: preview_token })));
71
99
  // ── Bind / unbind a named preview URL to a session+port ───────────────────────
72
100
  // Advanced routing primitives (shapes SDK-name-verified; not exercised end-to-end here).
73
- server.tool("tenki_bind_preview_url", "Bind a named preview URL to a sandbox session and port (advanced routing).", {
101
+ server.tool("tenki_bind_preview_url", "Bind a named preview URL to a sandbox session and port (advanced routing). A URL whose expiry has lapsed can only be re-bound with a new expires_at.", {
74
102
  preview_url_id: z.string().describe("The preview URL id to bind."),
75
103
  session_id: sessionIdSchema,
76
104
  port: portSchema.describe("The port to route the preview URL to."),
77
- }, async ({ preview_url_id, session_id, port }) => ok(await client.control("BindPreviewUrl", { previewUrlId: preview_url_id, sessionId: session_id, port })));
105
+ expires_at: z
106
+ .string()
107
+ .optional()
108
+ .describe("Optional RFC-3339 timestamp replacing the URL's expiry deadline (required when re-binding a lapsed URL)."),
109
+ }, async ({ preview_url_id, session_id, port, expires_at }) => ok(await client.control("BindPreviewUrl", {
110
+ previewUrlId: preview_url_id,
111
+ sessionId: session_id,
112
+ port,
113
+ ...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
114
+ })));
78
115
  server.tool("tenki_unbind_preview_url", "Unbind a named preview URL from its current session/port (advanced routing).", { preview_url_id: z.string().describe("The preview URL id to unbind.") }, async ({ preview_url_id }) => ok(await client.control("UnbindPreviewUrl", { previewUrlId: preview_url_id })));
79
116
  server.tool("tenki_resolve_preview_token", "Resolve a preview token to the sandbox/port it points at (advanced).", { token: z.string().describe("The preview token to resolve.") }, async ({ token }) => ok(await client.control("ResolvePreviewToken", { token })));
80
117
  }
@@ -1,4 +1,4 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import type { TenkiClient } from "../client.js";
2
+ import { type TenkiClient } from "../client.js";
3
3
  /** Sandbox (session) lifecycle. */
4
4
  export declare function registerSandboxes(server: McpServer, client: TenkiClient): void;