@luxorai/mcp 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +192 -0
  6. package/dist/client.js +621 -0
  7. package/dist/env.d.ts +6 -0
  8. package/dist/env.js +25 -0
  9. package/dist/http.d.ts +17 -0
  10. package/dist/http.js +297 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +61 -0
  13. package/dist/oauth.d.ts +39 -0
  14. package/dist/oauth.js +166 -0
  15. package/dist/server.d.ts +50 -0
  16. package/dist/server.js +238 -0
  17. package/dist/tools/artifacts.d.ts +16 -0
  18. package/dist/tools/artifacts.js +19 -0
  19. package/dist/tools/auth_status.d.ts +35 -0
  20. package/dist/tools/auth_status.js +108 -0
  21. package/dist/tools/common.d.ts +70 -0
  22. package/dist/tools/common.js +138 -0
  23. package/dist/tools/egress.d.ts +38 -0
  24. package/dist/tools/egress.js +153 -0
  25. package/dist/tools/exec.d.ts +4 -0
  26. package/dist/tools/exec.js +120 -0
  27. package/dist/tools/files.d.ts +4 -0
  28. package/dist/tools/files.js +55 -0
  29. package/dist/tools/files_ops.d.ts +12 -0
  30. package/dist/tools/files_ops.js +62 -0
  31. package/dist/tools/git.d.ts +3 -0
  32. package/dist/tools/git.js +92 -0
  33. package/dist/tools/identity.d.ts +4 -0
  34. package/dist/tools/identity.js +5 -0
  35. package/dist/tools/ports.d.ts +11 -0
  36. package/dist/tools/ports.js +27 -0
  37. package/dist/tools/previews.d.ts +18 -0
  38. package/dist/tools/previews.js +117 -0
  39. package/dist/tools/run.d.ts +4 -0
  40. package/dist/tools/run.js +11 -0
  41. package/dist/tools/sandboxes.d.ts +4 -0
  42. package/dist/tools/sandboxes.js +388 -0
  43. package/dist/tools/sessions_admin.d.ts +9 -0
  44. package/dist/tools/sessions_admin.js +102 -0
  45. package/dist/tools/snapshots.d.ts +11 -0
  46. package/dist/tools/snapshots.js +98 -0
  47. package/dist/tools/ssh.d.ts +15 -0
  48. package/dist/tools/ssh.js +43 -0
  49. package/dist/tools/templates.d.ts +15 -0
  50. package/dist/tools/templates.js +162 -0
  51. package/dist/tools/titles.d.ts +8 -0
  52. package/dist/tools/titles.js +102 -0
  53. package/dist/tools/volumes.d.ts +16 -0
  54. package/dist/tools/volumes.js +102 -0
  55. package/dist/tools/workspace.d.ts +12 -0
  56. package/dist/tools/workspace.js +68 -0
  57. package/package.json +60 -4
@@ -0,0 +1,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;
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /** Command execution inside an existing sandbox. */
4
+ export declare function registerExec(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ import { envSchema, sessionIdSchema } from "./common.js";
3
+ /**
4
+ * Structured result contract for tenki_exec. Mirrors ExecResult in client.ts —
5
+ * the SDK validates every successful result against this, so the two must stay
6
+ * in lockstep field-for-field.
7
+ */
8
+ const execOutputSchema = {
9
+ command: z.string().describe("The executable that was run."),
10
+ args: z.array(z.string()).describe("Arguments the executable was invoked with."),
11
+ stdout: z.string().describe("Captured standard output (empty when capture failed — see captureError)."),
12
+ stderr: z.string().describe("Captured standard error (empty when capture failed — see captureError)."),
13
+ exitCode: z.number().int().describe("Process exit code; 0 means success. (The API omits zero values; the server normalizes an absent code to 0.)"),
14
+ ok: z.boolean().describe("True only when exitCode is 0 AND stdout/stderr capture succeeded."),
15
+ captureError: z
16
+ .string()
17
+ .optional()
18
+ .describe("Present when the command ran but its output could not be read back; stdout/stderr are unknown, not empty."),
19
+ stdoutTruncated: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("Present (true) when stdout exceeded the output cap and carries only a head+tail preview."),
23
+ stderrTruncated: z
24
+ .boolean()
25
+ .optional()
26
+ .describe("Present (true) when stderr exceeded the output cap and carries only a head+tail preview."),
27
+ stdoutPath: z
28
+ .string()
29
+ .optional()
30
+ .describe("Sandbox path holding the FULL stdout, present only when truncated — page through it with tenki_exec (e.g. sed -n / tail -c)."),
31
+ stderrPath: z
32
+ .string()
33
+ .optional()
34
+ .describe("Sandbox path holding the FULL stderr, present only when truncated — page through it with tenki_exec (e.g. sed -n / tail -c)."),
35
+ };
36
+ /**
37
+ * Field drift between execOutputSchema and ExecResult must fail the build, not
38
+ * the tool call — at runtime the SDK rejects a mismatched result outright and
39
+ * the command's output is lost.
40
+ */
41
+ const execOutput = z.object(execOutputSchema);
42
+ const _execSchemaLockstep = true;
43
+ void _execSchemaLockstep;
44
+ /**
45
+ * Sandbox output is untrusted (SECURITY.md). Neutralize characters that can
46
+ * misrepresent output in a terminal or transcript — C0 controls except \n and
47
+ * \t (ANSI sequences, CR line-overwrite spoofing), DEL, C1 controls (0x80–0x9F
48
+ * are single-character equivalents: U+009B is CSI), bidi controls
49
+ * (U+202A–U+202E, U+2066–U+2069 reorder displayed text), and zero-width/
50
+ * invisible marks (U+200B–U+200F, U+FEFF) — into visible \xNN / \uNNNN
51
+ * escapes. The previous JSON.stringify rendering escaped controls implicitly;
52
+ * a raw text rendering must do it explicitly. structuredContent keeps the raw
53
+ * strings — JSON encoding neutralizes them on the wire, and typed consumers
54
+ * need unmodified data.
55
+ */
56
+ function sanitizeForTerminal(s) {
57
+ // eslint-disable-next-line no-control-regex
58
+ return s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, (c) => {
59
+ const code = c.charCodeAt(0);
60
+ return code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u${code.toString(16).padStart(4, "0")}`;
61
+ });
62
+ }
63
+ /**
64
+ * Render an ExecResult as plain text for clients that don't consume
65
+ * structuredContent. Carries the streams as returned (control chars escaped);
66
+ * any capping happened upstream in execCaptured, which marks it explicitly —
67
+ * a truncated stream carries an inline marker and its full-output path.
68
+ */
69
+ function execText(r) {
70
+ const head = `exit ${r.exitCode}${r.ok ? "" : " (failed)"}`;
71
+ const capture = r.captureError
72
+ ? `\ncapture error: ${sanitizeForTerminal(r.captureError)} — stdout/stderr may be incomplete`
73
+ : "";
74
+ const stdout = sanitizeForTerminal(r.stdout);
75
+ const stderr = sanitizeForTerminal(r.stderr);
76
+ const outTag = r.stdoutTruncated ? ", TRUNCATED — full output at " + r.stdoutPath : "";
77
+ const errTag = r.stderrTruncated ? ", TRUNCATED — full output at " + r.stderrPath : "";
78
+ return `${head}${capture}\n--- stdout (${r.stdout.length} chars, control chars escaped${outTag}) ---\n${stdout}\n--- stderr (${r.stderr.length} chars, control chars escaped${errTag}) ---\n${stderr}`;
79
+ }
80
+ /** Command execution inside an existing sandbox. */
81
+ export function registerExec(server, client) {
82
+ server.registerTool("tenki_exec", {
83
+ description: "Run a command in an existing sandbox and return stdout, stderr, and exit code inline. Streams over max_output_bytes (default 64KB) come back as a head+tail preview with the full output retained at stdoutPath/stderrPath in the sandbox.",
84
+ inputSchema: {
85
+ session_id: sessionIdSchema,
86
+ command: z.string().describe("Executable, e.g. 'npm' or 'python3'."),
87
+ args: z.array(z.string()).optional().describe("Arguments."),
88
+ cwd: z.string().optional().describe("Working directory (honored in-script)."),
89
+ env: envSchema,
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
+ max_output_bytes: z
92
+ .number()
93
+ .int()
94
+ .min(1024)
95
+ .max(10_000_000)
96
+ .optional()
97
+ .describe("Per-stream inline output cap in bytes (default 65536). Larger output is truncated head+tail and kept in the sandbox at stdoutPath/stderrPath."),
98
+ },
99
+ outputSchema: execOutputSchema,
100
+ }, async ({ session_id, command, args, cwd, env, timeout_seconds, max_output_bytes }) => {
101
+ const result = await client.execCaptured(session_id, command, {
102
+ args,
103
+ cwd,
104
+ env,
105
+ timeoutSeconds: timeout_seconds,
106
+ maxOutputBytes: max_output_bytes,
107
+ });
108
+ return {
109
+ structuredContent: { ...result },
110
+ // Two text blocks: serialized JSON first (the MCP spec's
111
+ // backwards-compatibility contract for structured content — existing
112
+ // text-only consumers, our own test harness included, parse the first
113
+ // text block as JSON), then the human-readable rendering.
114
+ content: [
115
+ { type: "text", text: JSON.stringify(result, null, 2) },
116
+ { type: "text", text: execText(result) },
117
+ ],
118
+ };
119
+ });
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /** Filesystem I/O against a sandbox's data plane. */
4
+ export declare function registerFiles(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
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;
8
+ /** Filesystem I/O against a sandbox's data plane. */
9
+ export function registerFiles(server, client) {
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)));
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.", {
41
+ session_id: sessionIdSchema,
42
+ path: pathSchema.describe("Directory path, e.g. /home/tenki"),
43
+ include_hidden: z
44
+ .boolean()
45
+ .optional()
46
+ .describe("Include dot-prefixed entries (default true). The data plane omits them unless asked, which hides .git/.env from a listing."),
47
+ }, async ({ session_id, path, include_hidden }) => {
48
+ // Default TRUE, inverting the wire default: a listing that silently omits
49
+ // .git/.env/.gitignore leads an agent to conclude they do not exist.
50
+ const resp = await client.data(session_id, "List", { path, includeHidden: include_hidden !== false });
51
+ // proto3 omits an empty repeated field, so an empty directory comes back
52
+ // as {} — indistinguishable from a malformed call. Normalize it.
53
+ return ok({ path, entries: Array.isArray(resp.entries) ? resp.entries : [] });
54
+ });
55
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /**
4
+ * Filesystem metadata + mutation ops against a sandbox's data plane.
5
+ *
6
+ * Extends the basic read/write/list coverage in `files.ts` with `stat`, `mkdir`,
7
+ * `remove`, and `move`. Stat/Mkdir/Remove are genuine unary data-plane RPCs
8
+ * (`SandboxSessionDataPlaneService`). The data plane exposes no `Move` method, so
9
+ * `tenki_move_path` is exec-backed (`mv`) — matching the live-verified n8n node.
10
+ * Paths are rooted at /home/tenki (the server enforces the sandbox root).
11
+ */
12
+ export declare function registerFilesOps(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import { ok, pathSchema, sessionIdSchema } from "./common.js";
3
+ /**
4
+ * Filesystem metadata + mutation ops against a sandbox's data plane.
5
+ *
6
+ * Extends the basic read/write/list coverage in `files.ts` with `stat`, `mkdir`,
7
+ * `remove`, and `move`. Stat/Mkdir/Remove are genuine unary data-plane RPCs
8
+ * (`SandboxSessionDataPlaneService`). The data plane exposes no `Move` method, so
9
+ * `tenki_move_path` is exec-backed (`mv`) — matching the live-verified n8n node.
10
+ * Paths are rooted at /home/tenki (the server enforces the sandbox root).
11
+ */
12
+ export function registerFilesOps(server, client) {
13
+ server.tool("tenki_stat_path", "Get metadata (size, mode, type, timestamps) for a file or directory in a sandbox. Use to check whether a path exists or inspect it before reading/removing.", {
14
+ session_id: sessionIdSchema,
15
+ path: pathSchema.describe("Absolute path under /home/tenki, e.g. /home/tenki/output.txt"),
16
+ }, async ({ session_id, path }) => ok(await client.data(session_id, "Stat", { path })));
17
+ server.tool("tenki_make_dir", "Create a directory in a sandbox. Set recursive to also create any missing parent directories (mkdir -p).", {
18
+ session_id: sessionIdSchema,
19
+ path: pathSchema.describe("Directory path under /home/tenki, e.g. /home/tenki/project/out"),
20
+ recursive: z.boolean().optional().describe("Create parent directories as needed (default false)."),
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", {
29
+ path,
30
+ ...(recursive ? { recursive: true } : {}),
31
+ ...(mode !== undefined ? { mode } : {}),
32
+ })));
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).", {
34
+ session_id: sessionIdSchema,
35
+ path: pathSchema.describe("Path to delete under /home/tenki."),
36
+ recursive: z.boolean().optional().describe("Remove a directory and its contents (default false)."),
37
+ }, async ({ session_id, path, recursive }) => ok(await client.data(session_id, "Remove", {
38
+ path,
39
+ ...(recursive ? { recursive: true } : {}),
40
+ })));
41
+ server.tool("tenki_move_path", "Move or rename a file or directory within a sandbox. Both paths are under /home/tenki.", {
42
+ session_id: sessionIdSchema,
43
+ from: pathSchema.describe("Source path under /home/tenki, e.g. /home/tenki/old.txt"),
44
+ to: pathSchema.describe("Destination path under /home/tenki, e.g. /home/tenki/new.txt"),
45
+ }, async ({ session_id, from, to }) => {
46
+ // The data plane has no Move RPC (ReadFile/WriteFile/Stat/Mkdir/Remove/List only),
47
+ // so relocate with an exec-backed `mv`, mirroring the live-verified n8n node.
48
+ // `ok` keys off the exit code, NOT result.ok: the capture files are an
49
+ // internal detail here — whether mv's (empty) output could be read back
50
+ // says nothing about whether the file moved.
51
+ // `--` so a path beginning with a hyphen is an operand, not an mv option.
52
+ const result = await client.execCaptured(session_id, "mv", { args: ["--", from, to] });
53
+ return ok({
54
+ from,
55
+ to,
56
+ ok: result.exitCode === 0,
57
+ exitCode: result.exitCode,
58
+ ...(result.stderr ? { stderr: result.stderr } : {}),
59
+ ...(result.captureError ? { captureError: result.captureError } : {}),
60
+ });
61
+ });
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ export declare function registerGit(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ import { capText, ok, sessionIdSchema } from "./common.js";
3
+ /**
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 `--`.
23
+ */
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
+ }
58
+ export function registerGit(server, client) {
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.", {
67
+ session_id: sessionIdSchema,
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 });
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 }));
92
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /** Identity / credential tools. */
4
+ export declare function registerIdentity(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,5 @@
1
+ import { ok } from "./common.js";
2
+ /** Identity / credential tools. */
3
+ export function registerIdentity(server, client) {
4
+ server.tool("tenki_whoami", "Return the identity and workspaces for the current API key. Cheap credential test.", {}, async () => ok(await client.control("WhoAmI", {})));
5
+ }
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
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
+ */
11
+ export declare function registerPorts(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
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
+ */
11
+ export function registerPorts(server, client) {
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 })));
27
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * previews.ts — port preview-URL tools for tenki-mcp.
3
+ *
4
+ * Covers the preview / exposure-teardown half of Tenki's Port resource:
5
+ * removing an inbound exposure (UnexposePort) and the preview-URL lifecycle
6
+ * (CreatePreviewUrl, OpenPreview, ListPreviewUrls). Port *exposure* itself
7
+ * (ExposePort / ListExposedPorts) lives in ports.ts and is not re-implemented here.
8
+ *
9
+ * All control-plane ConnectRPC calls on tenki.sandbox.v1.SandboxService.
10
+ *
11
+ * LIVE-VERIFIED shapes (2026-08-21): projects were REMOVED from the API (every
12
+ * project_id field is proto-reserved and unknown fields are silently discarded),
13
+ * so the preview-URL methods are workspace/id-scoped. CreatePreviewUrl requires
14
+ * a `slug` (>=3 chars, [a-z0-9-]).
15
+ */
16
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import type { TenkiClient } from "../client.js";
18
+ export declare function registerPreviews(server: McpServer, client: TenkiClient): void;