@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.
- package/CHANGELOG.md +37 -0
- package/README.md +13 -8
- package/SECURITY.md +2 -2
- package/dist/client.d.ts +16 -1
- package/dist/client.js +62 -16
- package/dist/env.d.ts +6 -0
- package/dist/env.js +25 -0
- package/dist/http.js +13 -10
- package/dist/index.js +9 -8
- package/dist/oauth.d.ts +7 -1
- package/dist/oauth.js +29 -9
- package/dist/server.d.ts +25 -5
- package/dist/server.js +88 -53
- package/dist/tools/artifacts.d.ts +3 -3
- package/dist/tools/artifacts.js +6 -6
- package/dist/tools/auth_status.d.ts +3 -3
- package/dist/tools/auth_status.js +8 -9
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +78 -0
- package/dist/tools/egress.d.ts +38 -0
- package/dist/tools/egress.js +153 -0
- package/dist/tools/exec.js +1 -1
- package/dist/tools/files.js +36 -3
- package/dist/tools/files_ops.js +9 -1
- package/dist/tools/git.d.ts +0 -1
- package/dist/tools/git.js +85 -23
- package/dist/tools/ports.d.ts +8 -1
- package/dist/tools/ports.js +24 -3
- package/dist/tools/previews.js +59 -22
- package/dist/tools/sandboxes.d.ts +1 -1
- package/dist/tools/sandboxes.js +342 -26
- package/dist/tools/sessions_admin.js +63 -23
- package/dist/tools/snapshots.js +41 -22
- package/dist/tools/ssh.d.ts +3 -3
- package/dist/tools/ssh.js +35 -9
- package/dist/tools/templates.d.ts +2 -1
- package/dist/tools/templates.js +36 -16
- package/dist/tools/titles.d.ts +8 -0
- package/dist/tools/titles.js +102 -0
- package/dist/tools/volumes.js +36 -11
- package/dist/tools/workspace.d.ts +9 -1
- package/dist/tools/workspace.js +29 -90
- 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
|
+
}
|
package/dist/tools/exec.js
CHANGED
|
@@ -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()
|
package/dist/tools/files.js
CHANGED
|
@@ -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).
|
|
6
|
-
|
|
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"),
|
package/dist/tools/files_ops.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/tools/git.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
10
|
-
|
|
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("
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|
package/dist/tools/ports.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { TenkiClient } from "../client.js";
|
|
3
|
-
/**
|
|
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;
|
package/dist/tools/ports.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
|
|
2
|
-
/**
|
|
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.
|
|
5
|
-
|
|
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
|
}
|
package/dist/tools/previews.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
-
|
|
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:
|
|
79
|
+
previewUrls: Array.isArray(resp.previewUrls) ? resp.previewUrls : [],
|
|
60
80
|
...(resp.nextPageToken ? { nextPageToken: resp.nextPageToken } : {}),
|
|
61
|
-
...(
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
+
import { type TenkiClient } from "../client.js";
|
|
3
3
|
/** Sandbox (session) lifecycle. */
|
|
4
4
|
export declare function registerSandboxes(server: McpServer, client: TenkiClient): void;
|