@tenkicloud/mcp 0.1.0 → 0.3.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 +30 -145
- package/README.md +35 -10
- package/SECURITY.md +6 -6
- package/dist/client.d.ts +30 -5
- package/dist/client.js +89 -13
- package/dist/http.d.ts +4 -6
- package/dist/http.js +80 -20
- package/dist/index.js +3 -2
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -4
- package/dist/tools/auth_status.d.ts +2 -2
- package/dist/tools/auth_status.js +9 -4
- package/dist/tools/common.d.ts +3 -1
- package/dist/tools/common.js +20 -2
- package/dist/tools/exec.js +38 -6
- package/dist/tools/git.js +1 -1
- package/dist/tools/previews.d.ts +4 -4
- package/dist/tools/previews.js +15 -37
- package/dist/tools/run.js +1 -1
- package/dist/tools/sandboxes.js +26 -20
- package/dist/tools/sessions_admin.d.ts +1 -1
- package/dist/tools/sessions_admin.js +1 -15
- package/dist/tools/snapshots.js +0 -12
- package/dist/tools/templates.js +14 -23
- package/dist/tools/volumes.d.ts +1 -1
- package/dist/tools/volumes.js +3 -20
- package/package.json +5 -5
- package/dist/tools/registry.d.ts +0 -18
- package/dist/tools/registry.js +0 -98
|
@@ -9,6 +9,8 @@ export function describeCredential(env = process.env) {
|
|
|
9
9
|
const fromToken = env.TENKI_AUTH_TOKEN?.trim();
|
|
10
10
|
const fromKey = env.TENKI_API_KEY?.trim();
|
|
11
11
|
const raw = fromToken || fromKey;
|
|
12
|
+
if (!raw && env.TENKI_MCP_OAUTH_ISSUER?.trim())
|
|
13
|
+
return { kind: "hosted_oauth" };
|
|
12
14
|
if (!raw)
|
|
13
15
|
return { kind: "none" };
|
|
14
16
|
const source = fromToken ? "TENKI_AUTH_TOKEN" : "TENKI_API_KEY";
|
|
@@ -19,16 +21,17 @@ export function describeCredential(env = process.env) {
|
|
|
19
21
|
return { kind: "session_cookie", source };
|
|
20
22
|
}
|
|
21
23
|
const CREDENTIAL_HELP = {
|
|
22
|
-
none: "No credential found. Set TENKI_API_KEY (a tk_… API key) or TENKI_AUTH_TOKEN (an ory_st_… session token) in the server's environment, then restart it — MCP clients pass env through their server config, e.g. `claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y
|
|
24
|
+
none: "No credential found. Set TENKI_API_KEY (a tk_… API key) or TENKI_AUTH_TOKEN (an ory_st_… session token) in the server's environment, then restart it — MCP clients pass env through their server config, e.g. `claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y @tenkicloud/mcp`, or the \"env\" block in claude_desktop_config.json / .cursor/mcp.json.",
|
|
23
25
|
api_key: "Authenticated with a tk_… API key (Authorization: Bearer).",
|
|
24
26
|
oauth_session_token: "Authenticated with an ory_st_… session token (X-Session-Token). Session tokens expire; an API key is the stabler choice for a long-running server.",
|
|
25
27
|
session_cookie: "Authenticated with a session cookie (the token matched neither the tk_ nor ory_st_ prefix, so it is sent as a tenki_session cookie). If that is not what you intended, check the value.",
|
|
28
|
+
hosted_oauth: "Authenticated through the hosted OAuth session for this MCP connection.",
|
|
26
29
|
};
|
|
27
30
|
const authOutputSchema = {
|
|
28
31
|
authenticated: z.boolean().describe("True only when a credential is present AND a live identity probe succeeded."),
|
|
29
32
|
credential: z
|
|
30
|
-
.enum(["none", "api_key", "oauth_session_token", "session_cookie"])
|
|
31
|
-
.describe("Kind of credential the server is running with
|
|
33
|
+
.enum(["none", "api_key", "oauth_session_token", "session_cookie", "hosted_oauth"])
|
|
34
|
+
.describe("Kind of credential the server is running with. Never includes the token itself."),
|
|
32
35
|
source: z
|
|
33
36
|
.string()
|
|
34
37
|
.optional()
|
|
@@ -96,7 +99,9 @@ export function registerAuthStatus(server, client, toolsRegistered) {
|
|
|
96
99
|
...base,
|
|
97
100
|
authenticated: false,
|
|
98
101
|
error: e.message,
|
|
99
|
-
detail:
|
|
102
|
+
detail: cred.kind === "hosted_oauth"
|
|
103
|
+
? "The hosted OAuth session is present, but the identity probe failed. Run MCP login again."
|
|
104
|
+
: `A ${cred.kind === "api_key" ? "tk_… API key" : "credential"} is set (from ${cred.source}) but the identity probe failed — it may be expired, revoked, or the endpoint may be wrong. ${CREDENTIAL_HELP.none}`,
|
|
100
105
|
};
|
|
101
106
|
return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
102
107
|
}
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Keep registry-backed implementation fields out of public MCP responses. */
|
|
3
|
+
export declare function publicValue(value: unknown): unknown;
|
|
2
4
|
/** Serialize any tool return value as MCP text content. */
|
|
3
5
|
export declare const ok: (value: unknown) => {
|
|
4
6
|
content: {
|
|
@@ -21,7 +23,7 @@ export declare const portSchema: z.ZodNumber;
|
|
|
21
23
|
* leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
|
|
22
24
|
* (a transform, not a check) would silently retarget the operation to a
|
|
23
25
|
* different file. Call sites override the description with their own examples. */
|
|
24
|
-
export declare const pathSchema: z.
|
|
26
|
+
export declare const pathSchema: z.ZodString;
|
|
25
27
|
/**
|
|
26
28
|
* Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
|
|
27
29
|
* lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
|
package/dist/tools/common.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
const PUBLIC_KEYS = {
|
|
3
|
+
registryRef: "image",
|
|
4
|
+
registry_ref: "image",
|
|
5
|
+
sourceRegistryImageId: "sourceImageId",
|
|
6
|
+
source_registry_image_id: "source_image_id",
|
|
7
|
+
sourceRegistryWorkspaceId: "sourceImageWorkspaceId",
|
|
8
|
+
source_registry_workspace_id: "source_image_workspace_id",
|
|
9
|
+
sourceRegistryRef: "sourceImage",
|
|
10
|
+
source_registry_ref: "source_image",
|
|
11
|
+
};
|
|
12
|
+
/** Keep registry-backed implementation fields out of public MCP responses. */
|
|
13
|
+
export function publicValue(value) {
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.map(publicValue);
|
|
16
|
+
if (!value || typeof value !== "object")
|
|
17
|
+
return value;
|
|
18
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [PUBLIC_KEYS[key] ?? key, publicValue(child)]));
|
|
19
|
+
}
|
|
2
20
|
/** Serialize any tool return value as MCP text content. */
|
|
3
21
|
export const ok = (value) => ({
|
|
4
|
-
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
22
|
+
content: [{ type: "text", text: JSON.stringify(publicValue(value), null, 2) }],
|
|
5
23
|
});
|
|
6
24
|
/** Shared env-map schema used by tools that accept environment variables. */
|
|
7
|
-
export const envSchema = z.record(z.string()).optional().describe("Environment variables as a key→value object.");
|
|
25
|
+
export const envSchema = z.record(z.string(), z.string()).optional().describe("Environment variables as a key→value object.");
|
|
8
26
|
/** Shared session-id schema — every per-sandbox tool takes one of these.
|
|
9
27
|
* Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
|
|
10
28
|
* whitespace is always an accident (and the API's uuid validation would
|
package/dist/tools/exec.js
CHANGED
|
@@ -16,6 +16,22 @@ const execOutputSchema = {
|
|
|
16
16
|
.string()
|
|
17
17
|
.optional()
|
|
18
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)."),
|
|
19
35
|
};
|
|
20
36
|
/**
|
|
21
37
|
* Field drift between execOutputSchema and ExecResult must fail the build, not
|
|
@@ -46,8 +62,9 @@ function sanitizeForTerminal(s) {
|
|
|
46
62
|
}
|
|
47
63
|
/**
|
|
48
64
|
* Render an ExecResult as plain text for clients that don't consume
|
|
49
|
-
* structuredContent. Carries the
|
|
50
|
-
*
|
|
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.
|
|
51
68
|
*/
|
|
52
69
|
function execText(r) {
|
|
53
70
|
const head = `exit ${r.exitCode}${r.ok ? "" : " (failed)"}`;
|
|
@@ -56,12 +73,14 @@ function execText(r) {
|
|
|
56
73
|
: "";
|
|
57
74
|
const stdout = sanitizeForTerminal(r.stdout);
|
|
58
75
|
const stderr = sanitizeForTerminal(r.stderr);
|
|
59
|
-
|
|
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}`;
|
|
60
79
|
}
|
|
61
80
|
/** Command execution inside an existing sandbox. */
|
|
62
81
|
export function registerExec(server, client) {
|
|
63
82
|
server.registerTool("tenki_exec", {
|
|
64
|
-
description: "Run a command in an existing sandbox and return stdout, stderr, and exit code inline.",
|
|
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.",
|
|
65
84
|
inputSchema: {
|
|
66
85
|
session_id: sessionIdSchema,
|
|
67
86
|
command: z.string().describe("Executable, e.g. 'npm' or 'python3'."),
|
|
@@ -69,10 +88,23 @@ export function registerExec(server, client) {
|
|
|
69
88
|
cwd: z.string().optional().describe("Working directory (honored in-script)."),
|
|
70
89
|
env: envSchema,
|
|
71
90
|
timeout_seconds: z.number().int().positive().optional(),
|
|
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."),
|
|
72
98
|
},
|
|
73
99
|
outputSchema: execOutputSchema,
|
|
74
|
-
}, async ({ session_id, command, args, cwd, env, timeout_seconds }) => {
|
|
75
|
-
const result = await client.execCaptured(session_id, command, {
|
|
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
|
+
});
|
|
76
108
|
return {
|
|
77
109
|
structuredContent: { ...result },
|
|
78
110
|
// Two text blocks: serialized JSON first (the MCP spec's
|
package/dist/tools/git.js
CHANGED
|
@@ -20,7 +20,7 @@ export function registerGit(server, client) {
|
|
|
20
20
|
// accepted here and coerced — a client-side rejection of `create: true`
|
|
21
21
|
// for a call that would serialize identically helps nobody.
|
|
22
22
|
args: z
|
|
23
|
-
.record(z.union([z.string(), z.number(), z.boolean()]))
|
|
23
|
+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
24
24
|
.optional()
|
|
25
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
26
|
}, async ({ session_id, operation, args }) => {
|
package/dist/tools/previews.d.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* All control-plane ConnectRPC calls on tenki.sandbox.v1.SandboxService.
|
|
10
10
|
*
|
|
11
|
-
* LIVE-VERIFIED shapes (2026-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
15
|
*/
|
|
16
16
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
17
|
import type { TenkiClient } from "../client.js";
|
package/dist/tools/previews.js
CHANGED
|
@@ -7,43 +7,33 @@ export function registerPreviews(server, client) {
|
|
|
7
7
|
port: portSchema.describe("The TCP port inside the sandbox to unexpose (1-65535)."),
|
|
8
8
|
}, async ({ session_id, port }) => ok(await client.control("UnexposePort", { sessionId: session_id, port })));
|
|
9
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).
|
|
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
11
|
session_id: sessionIdSchema.describe("The sandbox session serving the port."),
|
|
12
12
|
port: portSchema.describe("The TCP port inside the sandbox to create a preview URL for (1-65535)."),
|
|
13
13
|
slug: slugSchema,
|
|
14
|
-
project_id: z.string().optional().describe("Project the preview URL belongs to (defaults to the key's first project)."),
|
|
15
14
|
expires_at: z
|
|
16
15
|
.string()
|
|
17
16
|
.optional()
|
|
18
17
|
.describe("Optional RFC-3339 timestamp at which the preview URL auto-expires. Omit to keep it until the sandbox ends."),
|
|
19
|
-
}, async ({ session_id, port, slug,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
...(projectId ? { projectId } : {}),
|
|
26
|
-
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
27
|
-
}));
|
|
28
|
-
});
|
|
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
|
+
})));
|
|
29
24
|
// ── Open (get) a live preview for a port ──────────────────────────────────────
|
|
30
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.", {
|
|
31
26
|
session_id: sessionIdSchema.describe("The sandbox session serving the port."),
|
|
32
27
|
port: portSchema.describe("The TCP port inside the sandbox to open a preview for (1-65535)."),
|
|
33
|
-
project_id: z.string().optional().describe("Project scope (defaults to the key's first project)."),
|
|
34
28
|
expires_at: z
|
|
35
29
|
.string()
|
|
36
30
|
.optional()
|
|
37
31
|
.describe("Optional RFC-3339 timestamp at which the preview auto-expires. Omit to keep it until the sandbox ends."),
|
|
38
|
-
}, async ({ session_id, port,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
...(projectId ? { projectId } : {}),
|
|
44
|
-
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
45
|
-
}));
|
|
46
|
-
});
|
|
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
|
+
})));
|
|
47
37
|
// ── List the preview URLs bound to a sandbox / project ────────────────────────
|
|
48
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.", {
|
|
49
39
|
session_id: sessionIdSchema
|
|
@@ -54,7 +44,7 @@ export function registerPreviews(server, client) {
|
|
|
54
44
|
page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
|
|
55
45
|
}, async ({ session_id, workspace_id, page_size, page_token }) => {
|
|
56
46
|
// The RPC has no sessionId field (ListPreviewUrlsRequest: workspace_id,
|
|
57
|
-
// page_size, page_token
|
|
47
|
+
// page_size, page_token), so a sessionId sent
|
|
58
48
|
// on the wire is silently discarded and every session's rows come back.
|
|
59
49
|
// Filter here instead of advertising a filter that does nothing.
|
|
60
50
|
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
@@ -72,20 +62,8 @@ export function registerPreviews(server, client) {
|
|
|
72
62
|
});
|
|
73
63
|
});
|
|
74
64
|
// ── Get / delete a specific preview URL ───────────────────────────────────────
|
|
75
|
-
server.tool("tenki_get_preview_url", "Fetch a specific preview URL's details by id (
|
|
76
|
-
|
|
77
|
-
project_id: z.string().optional().describe("Project scope (defaults to the key's first project)."),
|
|
78
|
-
}, async ({ preview_url_id, project_id }) => {
|
|
79
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
80
|
-
return ok(await client.control("GetPreviewUrl", { previewUrlId: preview_url_id, ...(projectId ? { projectId } : {}) }));
|
|
81
|
-
});
|
|
82
|
-
server.tool("tenki_delete_preview_url", "Delete a preview URL by id, taking it permanently offline (project-scoped).", {
|
|
83
|
-
preview_url_id: z.string().describe("The preview URL id to delete."),
|
|
84
|
-
project_id: z.string().optional().describe("Project scope (defaults to the key's first project)."),
|
|
85
|
-
}, async ({ preview_url_id, project_id }) => {
|
|
86
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
87
|
-
return ok(await client.control("DeletePreviewUrl", { previewUrlId: preview_url_id, ...(projectId ? { projectId } : {}) }));
|
|
88
|
-
});
|
|
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 })));
|
|
89
67
|
// ── Touch (keep-alive) a preview ──────────────────────────────────────────────
|
|
90
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).") },
|
|
91
69
|
// TouchPreview takes a previewToken, not session/port (live-verified).
|
package/dist/tools/run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { ok, envSchema } from "./common.js";
|
|
3
3
|
/** The headline one-shot execution tool. */
|
|
4
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.", {
|
|
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
6
|
language: z.enum(["shell", "python", "javascript"]).describe("Interpreter for the snippet."),
|
|
7
7
|
code: z.string().describe("The code to run."),
|
|
8
8
|
env: envSchema,
|
package/dist/tools/sandboxes.js
CHANGED
|
@@ -2,32 +2,37 @@ import { z } from "zod";
|
|
|
2
2
|
import { ok, envSchema, sessionIdSchema } from "./common.js";
|
|
3
3
|
/** Sandbox (session) lifecycle. */
|
|
4
4
|
export function registerSandboxes(server, client) {
|
|
5
|
-
server.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(),
|
|
22
29
|
}, async (a) => {
|
|
23
30
|
const owner = await client.resolveOwner();
|
|
24
|
-
const projectId = a.project_id ?? owner.projectId;
|
|
25
31
|
const workspaceId = a.workspace_id ?? owner.workspaceId;
|
|
26
32
|
const body = {
|
|
27
33
|
...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
|
|
28
34
|
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
29
35
|
...(workspaceId ? { workspaceId } : {}),
|
|
30
|
-
...(projectId ? { projectId } : {}),
|
|
31
36
|
...(a.name ? { name: a.name } : {}),
|
|
32
37
|
...(a.cpu_cores ? { cpuCores: a.cpu_cores } : {}),
|
|
33
38
|
...(a.memory_mb ? { memoryMb: a.memory_mb } : {}),
|
|
@@ -38,7 +43,8 @@ export function registerSandboxes(server, client) {
|
|
|
38
43
|
...(a.allow_outbound ? { allowOutbound: true } : {}),
|
|
39
44
|
...(a.allow_inbound ? { allowInbound: true } : {}),
|
|
40
45
|
...(a.snapshot_id ? { snapshotId: a.snapshot_id } : {}),
|
|
41
|
-
|
|
46
|
+
// The API still uses registryRef internally for template-image launches.
|
|
47
|
+
...(a.image ? { registryRef: a.image } : {}),
|
|
42
48
|
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
43
49
|
...(a.env && Object.keys(a.env).length ? { env: a.env } : {}),
|
|
44
50
|
};
|
|
@@ -3,7 +3,7 @@ import type { TenkiClient } from "../client.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
5
|
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
6
|
-
* workspace
|
|
6
|
+
* workspace-scoped listing. The lifecycle basics
|
|
7
7
|
* (create/get/list/terminate/pause/resume) live in sandboxes.ts.
|
|
8
8
|
*/
|
|
9
9
|
export declare function registerSessionsAdmin(server: McpServer, client: TenkiClient): void;
|
|
@@ -3,7 +3,7 @@ import { ok, sessionIdSchema } from "./common.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
5
|
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
6
|
-
* workspace
|
|
6
|
+
* workspace-scoped listing. The lifecycle basics
|
|
7
7
|
* (create/get/list/terminate/pause/resume) live in sandboxes.ts.
|
|
8
8
|
*/
|
|
9
9
|
export function registerSessionsAdmin(server, client) {
|
|
@@ -59,18 +59,4 @@ export function registerSessionsAdmin(server, client) {
|
|
|
59
59
|
...(page_token ? { pageToken: page_token } : {}),
|
|
60
60
|
}));
|
|
61
61
|
});
|
|
62
|
-
server.tool("tenki_list_project_sandboxes", "List every sandbox belonging to a specific project (defaults to the API key's default project).", {
|
|
63
|
-
project_id: z.string().optional().describe("Project to list (defaults to the key's first project)."),
|
|
64
|
-
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
65
|
-
page_size: z.number().int().positive().optional(),
|
|
66
|
-
page_token: z.string().optional(),
|
|
67
|
-
}, async ({ project_id, include_terminated, page_size, page_token }) => {
|
|
68
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
69
|
-
return ok(await client.control("ListProjectSandboxes", {
|
|
70
|
-
...(projectId ? { projectId } : {}),
|
|
71
|
-
...(include_terminated ? { includeTerminated: true } : {}),
|
|
72
|
-
...(page_size ? { pageSize: page_size } : {}),
|
|
73
|
-
...(page_token ? { pageToken: page_token } : {}),
|
|
74
|
-
}));
|
|
75
|
-
});
|
|
76
62
|
}
|
package/dist/tools/snapshots.js
CHANGED
|
@@ -76,16 +76,4 @@ export function registerSnapshots(server, client) {
|
|
|
76
76
|
...(page_token ? { pageToken: page_token } : {}),
|
|
77
77
|
}));
|
|
78
78
|
});
|
|
79
|
-
server.tool("tenki_list_project_snapshots", "List all snapshots in a project (defaults to the key's first project). Supports pagination.", {
|
|
80
|
-
project_id: z.string().optional().describe("Project to list (defaults to the key's first project)."),
|
|
81
|
-
page_size: z.number().int().positive().optional(),
|
|
82
|
-
page_token: z.string().optional(),
|
|
83
|
-
}, async ({ project_id, page_size, page_token }) => {
|
|
84
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
85
|
-
return ok(await client.control("ListProjectSnapshots", {
|
|
86
|
-
...(projectId ? { projectId } : {}),
|
|
87
|
-
...(page_size ? { pageSize: page_size } : {}),
|
|
88
|
-
...(page_token ? { pageToken: page_token } : {}),
|
|
89
|
-
}));
|
|
90
|
-
});
|
|
91
79
|
}
|
package/dist/tools/templates.js
CHANGED
|
@@ -24,7 +24,7 @@ export function registerTemplates(server, client) {
|
|
|
24
24
|
return r;
|
|
25
25
|
};
|
|
26
26
|
// ── Create ──────────────────────────────────────────────────────────────────
|
|
27
|
-
server.tool("tenki_create_template", "Create a custom-image template (a reusable sandbox-image spec: base image + setup script + default resources). Build it into a bootable image later with tenki_build_template.", {
|
|
27
|
+
server.tool("tenki_create_template", "Create a custom-image template (a reusable sandbox-image spec: base image + setup script + default resources). Build it into a bootable image later with tenki_build_template. NOTE: only a TYPED template (created with builder_spec, no legacy fields) can build a named, publishable image (image_name) that tenki_create_sandbox boots via its `image` arg.", {
|
|
28
28
|
name: z.string().describe("Human-readable template name."),
|
|
29
29
|
base_image_id: z.string().optional().describe("Base image ID to build on top of."),
|
|
30
30
|
setup_script: z.string().optional().describe("Shell script run at build time to provision the image. Required for a from-scratch template (the API rejects a create without it unless you derive from a parent template/image)."),
|
|
@@ -36,13 +36,14 @@ export function registerTemplates(server, client) {
|
|
|
36
36
|
tags: z.array(z.string()).optional().describe("Tags for later filtering."),
|
|
37
37
|
parent_template_id: z.string().optional().describe("Derive this template from an existing template."),
|
|
38
38
|
parent_image: z.string().optional().describe("Derive this template from an existing built image reference."),
|
|
39
|
-
builder_spec: z
|
|
39
|
+
builder_spec: z
|
|
40
|
+
.record(z.string(), z.unknown())
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Typed template spec, passed through as-is — e.g. {specVersion:'tenki.template.v1', base:{image:'sandbox'}, workdir:'/home/tenki', steps:[{run:{command:'...'}}], resources:{cpuCores,memoryMb,diskSizeGb}}. Mutually exclusive with base_image_id/setup_script/start_cmd/env_vars/cpu_cores/memory_mb/disk_size_gb/parent_* (the API rejects mixing). Required if the template's builds should publish an image (tenki_build_template image_name)."),
|
|
40
43
|
workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
|
|
41
|
-
project_id: z.string().optional().describe("Project to create in (defaults to the key's first project)."),
|
|
42
44
|
}, async (a) => {
|
|
43
45
|
const owner = await client.resolveOwner();
|
|
44
46
|
const workspaceId = a.workspace_id ?? owner.workspaceId;
|
|
45
|
-
const projectId = a.project_id ?? owner.projectId;
|
|
46
47
|
const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
|
|
47
48
|
const body = {
|
|
48
49
|
...(workspaceId ? { workspaceId } : {}),
|
|
@@ -52,7 +53,6 @@ export function registerTemplates(server, client) {
|
|
|
52
53
|
...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
|
|
53
54
|
...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
|
|
54
55
|
...(Object.keys(resources).length ? { resources } : {}),
|
|
55
|
-
...(projectId ? { projectId } : {}),
|
|
56
56
|
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
57
57
|
...(a.parent_template_id ? { parentTemplateId: a.parent_template_id } : {}),
|
|
58
58
|
...(a.parent_image ? { parentImage: a.parent_image } : {}),
|
|
@@ -91,7 +91,7 @@ export function registerTemplates(server, client) {
|
|
|
91
91
|
env_vars: envSchema,
|
|
92
92
|
tags: z.array(z.string()).optional().describe("Replacement set of tags."),
|
|
93
93
|
clear_tags: z.boolean().optional().describe("Remove all tags from the template."),
|
|
94
|
-
builder_spec: z.record(z.unknown()).optional().describe("Advanced structured build spec (TemplateBuildSpec); passed through as-is."),
|
|
94
|
+
builder_spec: z.record(z.string(), z.unknown()).optional().describe("Advanced structured build spec (TemplateBuildSpec); passed through as-is."),
|
|
95
95
|
}, async (a) => {
|
|
96
96
|
const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
|
|
97
97
|
const body = {
|
|
@@ -117,12 +117,15 @@ export function registerTemplates(server, client) {
|
|
|
117
117
|
...(force ? { force: true } : {}),
|
|
118
118
|
})));
|
|
119
119
|
// ── Build ───────────────────────────────────────────────────────────────────
|
|
120
|
-
server.tool("tenki_build_template", "Trigger a build for a template, producing a bootable image. Returns the created build
|
|
120
|
+
server.tool("tenki_build_template", "Trigger a build for a template, producing a bootable image. Returns the created build — poll it with tenki_get_template_build until READY; the ready build's imageDigestRef is what tenki_create_sandbox's `image` arg takes.", {
|
|
121
121
|
template_id: z.string().describe("The template ID to build."),
|
|
122
|
-
image_name: z
|
|
122
|
+
image_name: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Name for the resulting image. Requires a TYPED template (created with builder_spec) — the API rejects it for legacy setup-script templates."),
|
|
123
126
|
publish_raw_image: z.boolean().optional().describe("Publish the raw rootfs image alongside the build snapshot."),
|
|
124
|
-
build_secrets: z.record(z.string()).optional().describe("Build-time secrets as a key→value object (not persisted into the image)."),
|
|
125
|
-
build_env: z.record(z.string()).optional().describe("Per-build environment overrides frozen into this build only."),
|
|
127
|
+
build_secrets: z.record(z.string(), z.string()).optional().describe("Build-time secrets as a key→value object (not persisted into the image)."),
|
|
128
|
+
build_env: z.record(z.string(), z.string()).optional().describe("Per-build environment overrides frozen into this build only."),
|
|
126
129
|
}, async ({ template_id, image_name, publish_raw_image, build_secrets, build_env }) => ok(await client.control("BuildTemplate", {
|
|
127
130
|
templateId: template_id,
|
|
128
131
|
...(image_name !== undefined ? { imageName: image_name } : {}),
|
|
@@ -133,19 +136,7 @@ export function registerTemplates(server, client) {
|
|
|
133
136
|
// ── Cancel build ──────────────────────────────────────────────────────────────
|
|
134
137
|
server.tool("tenki_cancel_template_build", "Cancel an in-progress template build by its build ID.", { build_id: z.string().describe("The template build ID to cancel.") }, async ({ build_id }) => ok(await client.control("CancelTemplateBuild", { buildId: build_id })));
|
|
135
138
|
// ── Get build ─────────────────────────────────────────────────────────────────
|
|
136
|
-
server.tool("tenki_get_template_build", "Retrieve one template build by its build ID (state, progress, and result image).", { build_id: z.string().describe("The template build ID.") }, async ({ build_id }) => ok(await client.control("GetTemplateBuild", { buildId: build_id })));
|
|
139
|
+
server.tool("tenki_get_template_build", "Retrieve one template build by its build ID (state, progress, and result image). A READY build's imageDigestRef is the reference tenki_create_sandbox's `image` arg takes.", { build_id: z.string().describe("The template build ID.") }, async ({ build_id }) => ok(await client.control("GetTemplateBuild", { buildId: build_id })));
|
|
137
140
|
// ── List active builds ──────────────────────────────────────────────────────────
|
|
138
141
|
server.tool("tenki_list_active_template_builds", "List the currently active (in-progress) builds for a given template.", { template_id: z.string().describe("The template ID whose active builds to list.") }, async ({ template_id }) => ok(await client.control("ListActiveTemplateBuilds", { templateId: template_id })));
|
|
139
|
-
server.tool("tenki_list_project_templates", "List templates in a project (defaults to the key's first project). Supports pagination.", {
|
|
140
|
-
project_id: z.string().optional().describe("Project to list (defaults to the key's first project)."),
|
|
141
|
-
page_size: z.number().int().positive().optional(),
|
|
142
|
-
page_token: z.string().optional(),
|
|
143
|
-
}, async ({ project_id, page_size, page_token }) => {
|
|
144
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
145
|
-
return ok(await client.control("ListProjectTemplates", {
|
|
146
|
-
...(projectId ? { projectId } : {}),
|
|
147
|
-
...(page_size ? { pageSize: page_size } : {}),
|
|
148
|
-
...(page_token ? { pageToken: page_token } : {}),
|
|
149
|
-
}));
|
|
150
|
-
});
|
|
151
142
|
}
|
package/dist/tools/volumes.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* CreateVolume · GetVolume · ListVolumes · UpdateVolume · DeleteVolume ·
|
|
10
10
|
* ResizeVolume · AttachVolume · DetachVolume
|
|
11
11
|
*
|
|
12
|
-
* CreateVolumeRequest is flat: { workspaceId, name, sizeBytes (int64)
|
|
12
|
+
* CreateVolumeRequest is flat: { workspaceId, name, sizeBytes (int64) }.
|
|
13
13
|
*/
|
|
14
14
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
15
|
import type { TenkiClient } from "../client.js";
|
package/dist/tools/volumes.js
CHANGED
|
@@ -12,20 +12,16 @@ const sizeBytesSchema = z
|
|
|
12
12
|
.describe("Volume size in bytes. Must be between 1 MiB (1048576) and 100 GiB (107374182400).");
|
|
13
13
|
export function registerVolumes(server, client) {
|
|
14
14
|
// ── Create ────────────────────────────────────────────────────────────────
|
|
15
|
-
server.tool("tenki_create_volume", "Create a workspace-scoped persistent volume — durable block storage that survives sandbox teardown. Defaults the workspace
|
|
15
|
+
server.tool("tenki_create_volume", "Create a workspace-scoped persistent volume — durable block storage that survives sandbox teardown. Defaults the workspace to the API key's first; override with workspace_id.", {
|
|
16
16
|
name: z.string().describe("Human-readable name for the volume."),
|
|
17
17
|
size_bytes: sizeBytesSchema,
|
|
18
18
|
workspace_id: z.string().optional().describe("Workspace to create the volume in (defaults to the key's first workspace)."),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const owner = await client.resolveOwner();
|
|
22
|
-
const workspaceId = workspace_id ?? owner.workspaceId;
|
|
23
|
-
const projectId = project_id ?? owner.projectId;
|
|
19
|
+
}, async ({ name, size_bytes, workspace_id }) => {
|
|
20
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
24
21
|
return ok(await client.control("CreateVolume", {
|
|
25
22
|
...(workspaceId ? { workspaceId } : {}),
|
|
26
23
|
name,
|
|
27
24
|
sizeBytes: size_bytes,
|
|
28
|
-
...(projectId ? { projectId } : {}),
|
|
29
25
|
}));
|
|
30
26
|
});
|
|
31
27
|
// ── Get ───────────────────────────────────────────────────────────────────
|
|
@@ -78,17 +74,4 @@ export function registerVolumes(server, client) {
|
|
|
78
74
|
session_id: sessionIdSchema.describe("The sandbox session to detach the volume from."),
|
|
79
75
|
volume_id: z.string().describe("The volume id to detach."),
|
|
80
76
|
}, async ({ session_id, volume_id }) => ok(await client.control("DetachVolume", { sessionId: session_id, volumeId: volume_id })));
|
|
81
|
-
// ── List (project-scoped) ─────────────────────────────────────────────────────
|
|
82
|
-
server.tool("tenki_list_project_volumes", "List persistent volumes in a project (defaults to the key's first project). Supports pagination.", {
|
|
83
|
-
project_id: z.string().optional().describe("Project to list volumes from (defaults to the key's first project)."),
|
|
84
|
-
page_size: z.number().int().positive().optional(),
|
|
85
|
-
page_token: z.string().optional(),
|
|
86
|
-
}, async ({ project_id, page_size, page_token }) => {
|
|
87
|
-
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
88
|
-
return ok(await client.control("ListProjectVolumes", {
|
|
89
|
-
...(projectId ? { projectId } : {}),
|
|
90
|
-
...(page_size ? { pageSize: page_size } : {}),
|
|
91
|
-
...(page_token ? { pageToken: page_token } : {}),
|
|
92
|
-
}));
|
|
93
|
-
});
|
|
94
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenkicloud/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "io.github.LuxorLabs/tenki-mcp",
|
|
5
5
|
"description": "Model Context Protocol server for Tenki Cloud — disposable microVM sandboxes for AI agents. Create sandboxes, run code, read/write files, run git, expose preview URLs — from any MCP client.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"prepack": "npm run build",
|
|
22
22
|
"prepublishOnly": "npm run build",
|
|
23
23
|
"pretest": "npm run build",
|
|
24
|
-
"test": "node test/offline.test.mjs && node test/exec-output.test.mjs && node test/http-input.test.mjs && node test/security.test.mjs && node test/client-net.test.mjs",
|
|
24
|
+
"test": "node test/offline.test.mjs && node test/public-shapes.test.mjs && node test/exec-output.test.mjs && node test/http-input.test.mjs && node test/oauth-http.test.mjs && node test/security.test.mjs && node test/client-net.test.mjs",
|
|
25
25
|
"test:all": "npm run build && node test/run.mjs",
|
|
26
26
|
"test:offline": "npm run build && node test/offline.test.mjs"
|
|
27
27
|
},
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
47
|
-
"zod": "^
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@types/node": "^
|
|
51
|
-
"typescript": "^
|
|
50
|
+
"@types/node": "^26.2.0",
|
|
51
|
+
"typescript": "^7.0.2"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|