@luxorai/mcp 0.0.0-stage → 0.3.1
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 +46 -0
- package/LICENSE +21 -0
- package/README.md +216 -2
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +575 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +294 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +203 -0
- package/dist/tools/artifacts.d.ts +16 -0
- package/dist/tools/artifacts.js +19 -0
- package/dist/tools/auth_status.d.ts +35 -0
- package/dist/tools/auth_status.js +109 -0
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +60 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +120 -0
- package/dist/tools/files.d.ts +4 -0
- package/dist/tools/files.js +22 -0
- package/dist/tools/files_ops.d.ts +12 -0
- package/dist/tools/files_ops.js +54 -0
- package/dist/tools/git.d.ts +4 -0
- package/dist/tools/git.js +30 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +5 -0
- package/dist/tools/ports.d.ts +4 -0
- package/dist/tools/ports.js +6 -0
- package/dist/tools/previews.d.ts +18 -0
- package/dist/tools/previews.js +80 -0
- package/dist/tools/run.d.ts +4 -0
- package/dist/tools/run.js +11 -0
- package/dist/tools/sandboxes.d.ts +4 -0
- package/dist/tools/sandboxes.js +72 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +62 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +79 -0
- package/dist/tools/ssh.d.ts +15 -0
- package/dist/tools/ssh.js +17 -0
- package/dist/tools/templates.d.ts +14 -0
- package/dist/tools/templates.js +142 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +77 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- package/package.json +59 -4
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}, async ({ session_id, path, recursive }) => ok(await client.data(session_id, "Mkdir", {
|
|
22
|
+
path,
|
|
23
|
+
...(recursive ? { recursive: true } : {}),
|
|
24
|
+
})));
|
|
25
|
+
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
|
+
session_id: sessionIdSchema,
|
|
27
|
+
path: pathSchema.describe("Path to delete under /home/tenki."),
|
|
28
|
+
recursive: z.boolean().optional().describe("Remove a directory and its contents (default false)."),
|
|
29
|
+
}, async ({ session_id, path, recursive }) => ok(await client.data(session_id, "Remove", {
|
|
30
|
+
path,
|
|
31
|
+
...(recursive ? { recursive: true } : {}),
|
|
32
|
+
})));
|
|
33
|
+
server.tool("tenki_move_path", "Move or rename a file or directory within a sandbox. Both paths are under /home/tenki.", {
|
|
34
|
+
session_id: sessionIdSchema,
|
|
35
|
+
from: pathSchema.describe("Source path under /home/tenki, e.g. /home/tenki/old.txt"),
|
|
36
|
+
to: pathSchema.describe("Destination path under /home/tenki, e.g. /home/tenki/new.txt"),
|
|
37
|
+
}, async ({ session_id, from, to }) => {
|
|
38
|
+
// The data plane has no Move RPC (ReadFile/WriteFile/Stat/Mkdir/Remove/List only),
|
|
39
|
+
// so relocate with an exec-backed `mv`, mirroring the live-verified n8n node.
|
|
40
|
+
// `ok` keys off the exit code, NOT result.ok: the capture files are an
|
|
41
|
+
// internal detail here — whether mv's (empty) output could be read back
|
|
42
|
+
// says nothing about whether the file moved.
|
|
43
|
+
// `--` so a path beginning with a hyphen is an operand, not an mv option.
|
|
44
|
+
const result = await client.execCaptured(session_id, "mv", { args: ["--", from, to] });
|
|
45
|
+
return ok({
|
|
46
|
+
from,
|
|
47
|
+
to,
|
|
48
|
+
ok: result.exitCode === 0,
|
|
49
|
+
exitCode: result.exitCode,
|
|
50
|
+
...(result.stderr ? { stderr: result.stderr } : {}),
|
|
51
|
+
...(result.captureError ? { captureError: result.captureError } : {}),
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/** Git operations inside a sandbox (one RPC dispatched by operation string). */
|
|
4
|
+
export declare function registerGit(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, sessionIdSchema } from "./common.js";
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
const GIT_OPERATIONS = ["clone", "checkout", "diff", "log"];
|
|
10
|
+
/** Git operations inside a sandbox (one RPC dispatched by operation string). */
|
|
11
|
+
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?.", {
|
|
16
|
+
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 } : {}) }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -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,6 @@
|
|
|
1
|
+
import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
|
|
2
|
+
/** Port exposure / preview URLs. */
|
|
3
|
+
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 })));
|
|
6
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, portSchema, sessionIdSchema, slugSchema } from "./common.js";
|
|
3
|
+
export function registerPreviews(server, client) {
|
|
4
|
+
// ── 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.", {
|
|
6
|
+
session_id: sessionIdSchema.describe("The sandbox session whose port to unexpose."),
|
|
7
|
+
port: portSchema.describe("The TCP port inside the sandbox to unexpose (1-65535)."),
|
|
8
|
+
}, async ({ session_id, port }) => ok(await client.control("UnexposePort", { sessionId: session_id, port })));
|
|
9
|
+
// ── Create a shareable preview URL for a port ─────────────────────────────────
|
|
10
|
+
server.tool("tenki_create_preview_url", "Create a shareable public preview URL for a port in a sandbox. The sandbox must have inbound networking enabled (create it with allow_inbound).", {
|
|
11
|
+
session_id: sessionIdSchema.describe("The sandbox session serving the port."),
|
|
12
|
+
port: portSchema.describe("The TCP port inside the sandbox to create a preview URL for (1-65535)."),
|
|
13
|
+
slug: slugSchema,
|
|
14
|
+
expires_at: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Optional RFC-3339 timestamp at which the preview URL auto-expires. Omit to keep it until the sandbox ends."),
|
|
18
|
+
}, async ({ session_id, port, slug, expires_at }) => ok(await client.control("CreatePreviewUrl", {
|
|
19
|
+
sessionId: session_id,
|
|
20
|
+
port,
|
|
21
|
+
slug,
|
|
22
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
23
|
+
})));
|
|
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)."),
|
|
28
|
+
expires_at: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Optional RFC-3339 timestamp at which the preview auto-expires. Omit to keep it until the sandbox ends."),
|
|
32
|
+
}, async ({ session_id, port, expires_at }) => ok(await client.control("OpenPreview", {
|
|
33
|
+
sessionId: session_id,
|
|
34
|
+
port,
|
|
35
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
36
|
+
})));
|
|
37
|
+
// ── 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
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Keep only preview URLs bound to this sandbox. Applied client-side to the fetched page."),
|
|
42
|
+
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
43
|
+
page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (server default 20, max 100)."),
|
|
44
|
+
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;
|
|
51
|
+
const resp = await client.control("ListPreviewUrls", {
|
|
52
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
53
|
+
...(page_size !== undefined ? { pageSize: page_size } : {}),
|
|
54
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
55
|
+
});
|
|
56
|
+
const rows = Array.isArray(resp.previewUrls) ? resp.previewUrls : [];
|
|
57
|
+
const filtered = session_id ? rows.filter((r) => r?.sessionId === session_id) : rows;
|
|
58
|
+
return ok({
|
|
59
|
+
previewUrls: filtered,
|
|
60
|
+
...(resp.nextPageToken ? { nextPageToken: resp.nextPageToken } : {}),
|
|
61
|
+
...(session_id ? { filteredClientSide: true, fetchedOnThisPage: rows.length } : {}),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
// ── 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 })));
|
|
66
|
+
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
|
+
// ── Touch (keep-alive) a preview ──────────────────────────────────────────────
|
|
68
|
+
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).") },
|
|
69
|
+
// TouchPreview takes a previewToken, not session/port (live-verified).
|
|
70
|
+
async ({ preview_token }) => ok(await client.control("TouchPreview", { previewToken: preview_token })));
|
|
71
|
+
// ── Bind / unbind a named preview URL to a session+port ───────────────────────
|
|
72
|
+
// 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).", {
|
|
74
|
+
preview_url_id: z.string().describe("The preview URL id to bind."),
|
|
75
|
+
session_id: sessionIdSchema,
|
|
76
|
+
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 })));
|
|
78
|
+
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
|
+
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, envSchema } from "./common.js";
|
|
3
|
+
/** The headline one-shot execution tool. */
|
|
4
|
+
export function registerRun(server, client) {
|
|
5
|
+
server.tool("tenki_run_code", "Boot a throwaway microVM, run a snippet (shell/python/javascript), return its stdout/stderr/exit code, and tear the sandbox down. Cost-guarded and self-terminating. Use this for one-shot execution when you don't need a persistent sandbox. Output over ~64KB per stream is truncated head+tail — and the sandbox is gone, so for large output use tenki_create_sandbox + tenki_exec and page through the retained file.", {
|
|
6
|
+
language: z.enum(["shell", "python", "javascript"]).describe("Interpreter for the snippet."),
|
|
7
|
+
code: z.string().describe("The code to run."),
|
|
8
|
+
env: envSchema,
|
|
9
|
+
timeout_seconds: z.number().int().positive().optional().describe("Max seconds for the run (default 30)."),
|
|
10
|
+
}, async ({ language, code, env, timeout_seconds }) => ok(await client.runCode(language, code, { env, timeoutSeconds: timeout_seconds })));
|
|
11
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, envSchema, sessionIdSchema } from "./common.js";
|
|
3
|
+
/** Sandbox (session) lifecycle. */
|
|
4
|
+
export function registerSandboxes(server, client) {
|
|
5
|
+
server.registerTool("tenki_create_sandbox", {
|
|
6
|
+
description: "Create a persistent sandbox microVM, optionally from a snapshot or template image. Returns the session (id, state) and its data-plane endpoint. Boots in ~2s. Use tenki_exec / tenki_read_file / tenki_write_file against the returned session_id.",
|
|
7
|
+
inputSchema: z
|
|
8
|
+
.object({
|
|
9
|
+
name: z.string().optional().describe("Human-readable name."),
|
|
10
|
+
cpu_cores: z.number().int().min(1).max(16).optional().describe("vCPUs (default 2)."),
|
|
11
|
+
memory_mb: z.number().int().min(128).max(65536).optional().describe("Memory in MB (default 4096)."),
|
|
12
|
+
disk_size_gb: z.number().int().positive().optional().describe("Disk in GB (default 5)."),
|
|
13
|
+
max_duration_seconds: z.number().int().positive().optional().describe("Hard lifetime cap in seconds."),
|
|
14
|
+
idle_timeout_minutes: z.number().int().positive().optional().describe("Reap after N idle minutes."),
|
|
15
|
+
clone_repo_url: z.string().optional().describe("Git URL to clone into the sandbox on boot."),
|
|
16
|
+
allow_outbound: z.boolean().optional().describe("Allow outbound networking (off by default)."),
|
|
17
|
+
allow_inbound: z.boolean().optional().describe("Allow inbound networking (off by default)."),
|
|
18
|
+
snapshot_id: z.string().optional().describe("Boot from a snapshot."),
|
|
19
|
+
image: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Boot from a template image, passed as a reference STRING: the imageDigestRef of a READY build from tenki_build_template / tenki_get_template_build (e.g. 'ws/name@sha256:...'), or 'workspace/name' for its latest version."),
|
|
23
|
+
tags: z.array(z.string()).optional().describe("Tags for later filtering."),
|
|
24
|
+
workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
|
|
25
|
+
env: envSchema,
|
|
26
|
+
wait_ready: z.boolean().optional().describe("Poll until the sandbox is RUNNING before returning (default true)."),
|
|
27
|
+
})
|
|
28
|
+
.strict(),
|
|
29
|
+
}, async (a) => {
|
|
30
|
+
const owner = await client.resolveOwner();
|
|
31
|
+
const workspaceId = a.workspace_id ?? owner.workspaceId;
|
|
32
|
+
const body = {
|
|
33
|
+
...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
|
|
34
|
+
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
35
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
36
|
+
...(a.name ? { name: a.name } : {}),
|
|
37
|
+
...(a.cpu_cores ? { cpuCores: a.cpu_cores } : {}),
|
|
38
|
+
...(a.memory_mb ? { memoryMb: a.memory_mb } : {}),
|
|
39
|
+
...(a.disk_size_gb ? { diskSizeGb: a.disk_size_gb } : {}),
|
|
40
|
+
...(a.max_duration_seconds ? { maxDuration: `${a.max_duration_seconds}s` } : {}),
|
|
41
|
+
...(a.idle_timeout_minutes ? { idleTimeoutMinutes: a.idle_timeout_minutes } : {}),
|
|
42
|
+
...(a.clone_repo_url ? { cloneRepoUrl: a.clone_repo_url } : {}),
|
|
43
|
+
...(a.allow_outbound ? { allowOutbound: true } : {}),
|
|
44
|
+
...(a.allow_inbound ? { allowInbound: true } : {}),
|
|
45
|
+
...(a.snapshot_id ? { snapshotId: a.snapshot_id } : {}),
|
|
46
|
+
// The API still uses registryRef internally for template-image launches.
|
|
47
|
+
...(a.image ? { registryRef: a.image } : {}),
|
|
48
|
+
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
49
|
+
...(a.env && Object.keys(a.env).length ? { env: a.env } : {}),
|
|
50
|
+
};
|
|
51
|
+
const resp = await client.control("CreateSession", body);
|
|
52
|
+
const session = resp.session ?? resp;
|
|
53
|
+
const sessionId = session.id ?? resp.sessionId;
|
|
54
|
+
const dataPlaneEndpoint = resp.dataPlaneEndpoint ?? resp.data_plane_endpoint;
|
|
55
|
+
const wait = a.wait_ready !== false;
|
|
56
|
+
const finalSession = wait && sessionId ? await client.waitForState(sessionId, "RUNNING") : session;
|
|
57
|
+
return ok({ session: finalSession, dataPlaneEndpoint });
|
|
58
|
+
});
|
|
59
|
+
server.tool("tenki_get_sandbox", "Fetch a sandbox's current state and metadata.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("GetSession", { sessionId: session_id })));
|
|
60
|
+
server.tool("tenki_list_sandboxes", "List sandboxes for the workspace.", {
|
|
61
|
+
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
62
|
+
page_size: z.number().int().positive().optional(),
|
|
63
|
+
page_token: z.string().optional(),
|
|
64
|
+
}, async ({ include_terminated, page_size, page_token }) => ok(await client.control("ListSessions", {
|
|
65
|
+
...(include_terminated ? { includeTerminated: true } : {}),
|
|
66
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
67
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
68
|
+
})));
|
|
69
|
+
server.tool("tenki_terminate_sandbox", "Terminate (destroy) a sandbox. The microVM and its filesystem are gone after this.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("TerminateSession", { sessionId: session_id })));
|
|
70
|
+
server.tool("tenki_pause_sandbox", "Pause a sandbox (snapshot + suspend) so it can be resumed later.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("PauseSession", { sessionId: session_id })));
|
|
71
|
+
server.tool("tenki_resume_sandbox", "Resume a previously paused sandbox.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("ResumeSession", { sessionId: session_id })));
|
|
72
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
|
+
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
6
|
+
* workspace-scoped listing. The lifecycle basics
|
|
7
|
+
* (create/get/list/terminate/pause/resume) live in sandboxes.ts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerSessionsAdmin(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, sessionIdSchema } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
|
+
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
6
|
+
* workspace-scoped listing. The lifecycle basics
|
|
7
|
+
* (create/get/list/terminate/pause/resume) live in sandboxes.ts.
|
|
8
|
+
*/
|
|
9
|
+
export function registerSessionsAdmin(server, client) {
|
|
10
|
+
server.tool("tenki_extend_sandbox", "Extend a running sandbox's wall-clock lifetime by N seconds so it isn't auto-terminated at its max-duration cap.", {
|
|
11
|
+
session_id: sessionIdSchema.describe("The sandbox/session ID to extend."),
|
|
12
|
+
additional_duration_seconds: z
|
|
13
|
+
.number()
|
|
14
|
+
.int()
|
|
15
|
+
.positive()
|
|
16
|
+
.describe("Extra lifetime to add, in seconds (sent as a Duration string, e.g. 3600s)."),
|
|
17
|
+
}, async ({ session_id, additional_duration_seconds }) => ok(await client.control("ExtendSession", {
|
|
18
|
+
sessionId: session_id,
|
|
19
|
+
additionalDuration: `${additional_duration_seconds}s`,
|
|
20
|
+
})));
|
|
21
|
+
server.tool("tenki_update_sandbox", "Update mutable fields on an existing sandbox — its name, tags, idle timeout, or max duration.", {
|
|
22
|
+
session_id: sessionIdSchema.describe("The sandbox/session ID to update."),
|
|
23
|
+
name: z.string().optional().describe("New human-readable name."),
|
|
24
|
+
tags: z.array(z.string()).optional().describe("Replacement tag list (send [] to clear all tags)."),
|
|
25
|
+
idle_timeout_minutes: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.positive()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Auto-pause after this many idle minutes (cost-safety cap)."),
|
|
31
|
+
max_duration_seconds: z
|
|
32
|
+
.number()
|
|
33
|
+
.int()
|
|
34
|
+
.positive()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("New hard lifetime cap in seconds (sent as a Duration string, e.g. 3600s)."),
|
|
37
|
+
}, async ({ session_id, name, tags, idle_timeout_minutes, max_duration_seconds }) => ok(await client.control("UpdateSession", {
|
|
38
|
+
sessionId: session_id,
|
|
39
|
+
...(name !== undefined ? { name } : {}),
|
|
40
|
+
...(tags !== undefined ? { tags } : {}),
|
|
41
|
+
...(idle_timeout_minutes !== undefined ? { idleTimeoutMinutes: idle_timeout_minutes } : {}),
|
|
42
|
+
...(max_duration_seconds !== undefined ? { maxDuration: `${max_duration_seconds}s` } : {}),
|
|
43
|
+
})));
|
|
44
|
+
server.tool("tenki_terminate_sandboxes", "Terminate MULTIPLE sandboxes in one call (bulk). IRREVERSIBLE — every listed sandbox and its filesystem is destroyed. Use tenki_terminate_sandbox for a single one.", {
|
|
45
|
+
session_ids: z.array(sessionIdSchema).min(1).describe("The sandbox/session IDs to terminate."),
|
|
46
|
+
}, async ({ session_ids }) => ok(await client.control("TerminateSessions", { sessionIds: session_ids })));
|
|
47
|
+
server.tool("tenki_report_sandbox_activity", "Report client-side activity on a sandbox to reset its idle timer and keep it from being reaped as idle (a keep-alive heartbeat).", { session_id: sessionIdSchema.describe("The sandbox/session ID to mark as active.") }, async ({ session_id }) => ok(await client.control("ReportSessionActivity", { sessionId: session_id })));
|
|
48
|
+
server.tool("tenki_list_workspace_sandboxes", "List every sandbox belonging to a specific workspace (defaults to the API key's workspace) — useful for spotting leaked, still-billing sandboxes across the workspace.", {
|
|
49
|
+
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
50
|
+
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
51
|
+
page_size: z.number().int().positive().optional(),
|
|
52
|
+
page_token: z.string().optional(),
|
|
53
|
+
}, async ({ workspace_id, include_terminated, page_size, page_token }) => {
|
|
54
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
55
|
+
return ok(await client.control("ListWorkspaceSandboxes", {
|
|
56
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
57
|
+
...(include_terminated ? { includeTerminated: true } : {}),
|
|
58
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
59
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Snapshots — capture a sandbox's disk + memory as a reusable image.
|
|
5
|
+
*
|
|
6
|
+
* Restoring is deliberately NOT a tool here: booting a fresh sandbox from a
|
|
7
|
+
* snapshot is `tenki_create_sandbox` with `snapshot_id` (CreateSession under the
|
|
8
|
+
* hood). Attached volumes are NOT captured in a snapshot and must be re-attached
|
|
9
|
+
* to the restored session separately.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerSnapshots(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, sessionIdSchema } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Snapshots — capture a sandbox's disk + memory as a reusable image.
|
|
5
|
+
*
|
|
6
|
+
* Restoring is deliberately NOT a tool here: booting a fresh sandbox from a
|
|
7
|
+
* snapshot is `tenki_create_sandbox` with `snapshot_id` (CreateSession under the
|
|
8
|
+
* hood). Attached volumes are NOT captured in a snapshot and must be re-attached
|
|
9
|
+
* to the restored session separately.
|
|
10
|
+
*/
|
|
11
|
+
export function registerSnapshots(server, client) {
|
|
12
|
+
server.tool("tenki_create_snapshot", "Capture a running sandbox's disk and memory as a reusable snapshot (attached volumes are NOT captured); boot a new sandbox from it later with tenki_create_sandbox + snapshot_id.", {
|
|
13
|
+
session_id: sessionIdSchema.describe("The sandbox session to snapshot."),
|
|
14
|
+
name: z.string().optional().describe("Human-readable name for the snapshot."),
|
|
15
|
+
expires_at: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("RFC-3339 / ISO-8601 timestamp at which the snapshot is auto-deleted. Omit to keep indefinitely."),
|
|
19
|
+
store_raw_image: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Also store the raw disk image alongside the snapshot (needed to download it later)."),
|
|
23
|
+
}, async ({ session_id, name, expires_at, store_raw_image }) => ok(await client.control("CreateSnapshot", {
|
|
24
|
+
sessionId: session_id,
|
|
25
|
+
...(name ? { name } : {}),
|
|
26
|
+
...(expires_at ? { expiresAt: expires_at } : {}),
|
|
27
|
+
...(store_raw_image !== undefined ? { storeRawImage: store_raw_image } : {}),
|
|
28
|
+
})));
|
|
29
|
+
server.tool("tenki_get_snapshot", "Fetch one snapshot's status and metadata by ID.", { snapshot_id: z.string() }, async ({ snapshot_id }) => ok(await client.control("GetSnapshot", { snapshotId: snapshot_id })));
|
|
30
|
+
server.tool("tenki_list_snapshots", "List the saved snapshots for the workspace (owner inferred from the API key).", {
|
|
31
|
+
page_size: z.number().int().positive().optional(),
|
|
32
|
+
page_token: z.string().optional(),
|
|
33
|
+
}, async ({ page_size, page_token }) => ok(await client.control("ListSnapshots", {
|
|
34
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
35
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
36
|
+
})));
|
|
37
|
+
server.tool("tenki_list_session_snapshots", "List the snapshots captured from a specific sandbox session.", {
|
|
38
|
+
session_id: sessionIdSchema,
|
|
39
|
+
page_size: z.number().int().positive().optional(),
|
|
40
|
+
page_token: z.string().optional(),
|
|
41
|
+
}, async ({ session_id, page_size, page_token }) => ok(await client.control("ListSessionSnapshots", {
|
|
42
|
+
sessionId: session_id,
|
|
43
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
44
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
45
|
+
})));
|
|
46
|
+
server.tool("tenki_list_dangling_snapshots", "List dangling snapshots — those whose source session no longer exists — for the workspace, useful for cleanup.", {
|
|
47
|
+
page_size: z.number().int().positive().optional(),
|
|
48
|
+
page_token: z.string().optional(),
|
|
49
|
+
}, async ({ page_size, page_token }) => ok(await client.control("ListDanglingSnapshots", {
|
|
50
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
51
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
52
|
+
})));
|
|
53
|
+
server.tool("tenki_update_snapshot", "Update a snapshot's mutable metadata (name and/or expiry).", {
|
|
54
|
+
snapshot_id: z.string(),
|
|
55
|
+
name: z.string().optional().describe("New human-readable name."),
|
|
56
|
+
expires_at: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("New RFC-3339 / ISO-8601 auto-delete timestamp."),
|
|
60
|
+
}, async ({ snapshot_id, name, expires_at }) => ok(await client.control("UpdateSnapshot", {
|
|
61
|
+
snapshotId: snapshot_id,
|
|
62
|
+
...(name !== undefined ? { name } : {}),
|
|
63
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
64
|
+
})));
|
|
65
|
+
server.tool("tenki_delete_snapshot", "Permanently delete a snapshot by ID.", { snapshot_id: z.string() }, async ({ snapshot_id }) => ok(await client.control("DeleteSnapshot", { snapshotId: snapshot_id })));
|
|
66
|
+
server.tool("tenki_get_snapshot_download_url", "Get a short-lived, pre-signed URL to download a snapshot's raw disk image (requires the snapshot to have been created with store_raw_image).", { snapshot_id: z.string() }, async ({ snapshot_id }) => ok(await client.control("GetSnapshotDownloadURL", { snapshotId: snapshot_id })));
|
|
67
|
+
server.tool("tenki_list_workspace_snapshots", "List all snapshots in a workspace (defaults to the key's first workspace). Supports pagination.", {
|
|
68
|
+
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
69
|
+
page_size: z.number().int().positive().optional(),
|
|
70
|
+
page_token: z.string().optional(),
|
|
71
|
+
}, async ({ workspace_id, page_size, page_token }) => {
|
|
72
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
73
|
+
return ok(await client.control("ListWorkspaceSnapshots", {
|
|
74
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
75
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
76
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
77
|
+
}));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh.ts — SSH access tools for tenki-mcp.
|
|
3
|
+
*
|
|
4
|
+
* UpdateSSHAuthorizedKeys sets the authorized_keys on a running sandbox (on
|
|
5
|
+
* SandboxService). IssueSandboxSSHCert and ListActiveSSHGateways live on a
|
|
6
|
+
* SEPARATE ConnectRPC service (SSHGatewayClientService), reached by passing the
|
|
7
|
+
* service path to client.control.
|
|
8
|
+
*
|
|
9
|
+
* Request shapes are grounded in the published Tenki API surface (CreateSession's
|
|
10
|
+
* ssh_authorized_keys field → sshAuthorizedKeys). The cert-issuance and gateway
|
|
11
|
+
* shapes are SDK-name-verified but not exercised end-to-end here.
|
|
12
|
+
*/
|
|
13
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import type { TenkiClient } from "../client.js";
|
|
15
|
+
export declare function registerSsh(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, sessionIdSchema } from "./common.js";
|
|
3
|
+
const SSH_GATEWAY_SERVICE = "tenki.sandbox.v1.SSHGatewayClientService";
|
|
4
|
+
export function registerSsh(server, client) {
|
|
5
|
+
server.tool("tenki_update_ssh_keys", "Set the SSH authorized public keys on a running sandbox, enabling direct SSH access for the given keys.", {
|
|
6
|
+
session_id: sessionIdSchema,
|
|
7
|
+
public_keys: z.array(z.string()).describe("SSH public keys (ssh-ed25519 …, ssh-rsa …) to authorize. Replaces the current set."),
|
|
8
|
+
}, async ({ session_id, public_keys }) => ok(await client.control("UpdateSSHAuthorizedKeys", { sessionId: session_id, sshAuthorizedKeys: public_keys })));
|
|
9
|
+
server.tool("tenki_issue_ssh_cert", "Issue a short-lived SSH certificate for a public key, authorizing SSH access to a sandbox via the SSH gateway.", {
|
|
10
|
+
session_id: sessionIdSchema,
|
|
11
|
+
public_key: z.string().describe("The SSH public key to sign into a certificate."),
|
|
12
|
+
}, async ({ session_id, public_key }) => ok(await client.control("IssueSandboxSSHCert", { sessionId: session_id, publicKey: public_key }, SSH_GATEWAY_SERVICE)));
|
|
13
|
+
server.tool("tenki_list_ssh_gateways", "List the currently active SSH gateways for the workspace.", { workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace).") }, async ({ workspace_id }) => {
|
|
14
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
15
|
+
return ok(await client.control("ListActiveSSHGateways", { ...(workspaceId ? { workspaceId } : {}) }, SSH_GATEWAY_SERVICE));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Template tools — custom-image templates and their builds.
|
|
5
|
+
*
|
|
6
|
+
* A "template" is a reusable sandbox-image spec (base image + setup script +
|
|
7
|
+
* default resources/env); building one produces a snapshot/image that sandboxes
|
|
8
|
+
* can boot from. Method names and request fields are matched to the generated
|
|
9
|
+
* `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
|
|
10
|
+
* plane actually speaks). Notable shapes: sizing is a nested `resources` object
|
|
11
|
+
* ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
|
|
12
|
+
* addressed by `buildId`; and ListActiveTemplateBuilds is scoped by `templateId`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerTemplates(server: McpServer, client: TenkiClient): void;
|