@tenkicloud/mcp 0.1.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 +161 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +499 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +234 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +59 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +205 -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 +104 -0
- package/dist/tools/common.d.ts +33 -0
- package/dist/tools/common.js +42 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +88 -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 +102 -0
- package/dist/tools/registry.d.ts +18 -0
- package/dist/tools/registry.js +98 -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 +66 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +76 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +91 -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 +151 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +94 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- package/package.json +61 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Classify the ambient credential WITHOUT returning any of its material.
|
|
4
|
+
* Mirrors the header selection in client.ts (`tk_` → Bearer API key,
|
|
5
|
+
* `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
|
|
6
|
+
* index.ts's precedence (TENKI_AUTH_TOKEN wins over TENKI_API_KEY).
|
|
7
|
+
*/
|
|
8
|
+
export function describeCredential(env = process.env) {
|
|
9
|
+
const fromToken = env.TENKI_AUTH_TOKEN?.trim();
|
|
10
|
+
const fromKey = env.TENKI_API_KEY?.trim();
|
|
11
|
+
const raw = fromToken || fromKey;
|
|
12
|
+
if (!raw)
|
|
13
|
+
return { kind: "none" };
|
|
14
|
+
const source = fromToken ? "TENKI_AUTH_TOKEN" : "TENKI_API_KEY";
|
|
15
|
+
if (raw.startsWith("tk_"))
|
|
16
|
+
return { kind: "api_key", source };
|
|
17
|
+
if (raw.startsWith("ory_st_"))
|
|
18
|
+
return { kind: "oauth_session_token", source };
|
|
19
|
+
return { kind: "session_cookie", source };
|
|
20
|
+
}
|
|
21
|
+
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 tenki-mcp`, or the \"env\" block in claude_desktop_config.json / .cursor/mcp.json.",
|
|
23
|
+
api_key: "Authenticated with a tk_… API key (Authorization: Bearer).",
|
|
24
|
+
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
|
+
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.",
|
|
26
|
+
};
|
|
27
|
+
const authOutputSchema = {
|
|
28
|
+
authenticated: z.boolean().describe("True only when a credential is present AND a live identity probe succeeded."),
|
|
29
|
+
credential: z
|
|
30
|
+
.enum(["none", "api_key", "oauth_session_token", "session_cookie"])
|
|
31
|
+
.describe("Kind of credential the server is running with, derived from its prefix. Never includes the token itself."),
|
|
32
|
+
source: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Environment variable the credential came from (TENKI_AUTH_TOKEN takes precedence over TENKI_API_KEY)."),
|
|
36
|
+
endpoint: z.string().describe("Control-plane base URL the server is pointed at."),
|
|
37
|
+
toolsRegistered: z
|
|
38
|
+
.number()
|
|
39
|
+
.int()
|
|
40
|
+
.describe("How many tools this server registered. Without a credential only this one is registered."),
|
|
41
|
+
identity: z
|
|
42
|
+
.object({
|
|
43
|
+
ownerType: z.string().optional(),
|
|
44
|
+
ownerId: z.string().optional(),
|
|
45
|
+
workspaces: z.number().int().optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Identity returned by the live probe, when it succeeded."),
|
|
49
|
+
error: z.string().optional().describe("Why the probe failed, when a credential is present but unusable (expired, revoked, wrong endpoint)."),
|
|
50
|
+
detail: z.string().describe("Human-readable status plus, when unauthenticated, how to supply a credential."),
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Report authentication status. `client` is null when the server booted without
|
|
54
|
+
* a credential; `toolsRegistered` lets the caller see it is in that degraded
|
|
55
|
+
* single-tool mode rather than guessing from a short tools/list.
|
|
56
|
+
*/
|
|
57
|
+
export function registerAuthStatus(server, client, toolsRegistered) {
|
|
58
|
+
server.registerTool("tenki_auth_status", {
|
|
59
|
+
description: "Report whether the server has a usable Tenki credential, which kind (API key vs OAuth session token), and the endpoint it targets — verified with a live identity probe. Call this first when other tools fail with auth errors, or when this is the only tool available (which means no credential is configured). Reports status only; it does not log in and never returns the token.",
|
|
60
|
+
inputSchema: {},
|
|
61
|
+
outputSchema: authOutputSchema,
|
|
62
|
+
}, async () => {
|
|
63
|
+
const cred = describeCredential();
|
|
64
|
+
const endpoint = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || "https://api.tenki.cloud";
|
|
65
|
+
const base = {
|
|
66
|
+
credential: cred.kind,
|
|
67
|
+
...(cred.source ? { source: cred.source } : {}),
|
|
68
|
+
endpoint,
|
|
69
|
+
toolsRegistered,
|
|
70
|
+
};
|
|
71
|
+
if (!client || cred.kind === "none") {
|
|
72
|
+
const result = {
|
|
73
|
+
...base,
|
|
74
|
+
authenticated: false,
|
|
75
|
+
detail: CREDENTIAL_HELP.none,
|
|
76
|
+
};
|
|
77
|
+
return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const resp = await client.control("WhoAmI", {});
|
|
81
|
+
const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces.length : undefined;
|
|
82
|
+
const result = {
|
|
83
|
+
...base,
|
|
84
|
+
authenticated: true,
|
|
85
|
+
identity: {
|
|
86
|
+
...(typeof resp.ownerType === "string" ? { ownerType: resp.ownerType } : {}),
|
|
87
|
+
...(typeof resp.ownerId === "string" ? { ownerId: resp.ownerId } : {}),
|
|
88
|
+
...(workspaces !== undefined ? { workspaces } : {}),
|
|
89
|
+
},
|
|
90
|
+
detail: CREDENTIAL_HELP[cred.kind],
|
|
91
|
+
};
|
|
92
|
+
return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
const result = {
|
|
96
|
+
...base,
|
|
97
|
+
authenticated: false,
|
|
98
|
+
error: e.message,
|
|
99
|
+
detail: `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
|
+
};
|
|
101
|
+
return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Serialize any tool return value as MCP text content. */
|
|
3
|
+
export declare const ok: (value: unknown) => {
|
|
4
|
+
content: {
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
9
|
+
/** Shared env-map schema used by tools that accept environment variables. */
|
|
10
|
+
export declare const envSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
+
/** Shared session-id schema — every per-sandbox tool takes one of these.
|
|
12
|
+
* Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
|
|
13
|
+
* whitespace is always an accident (and the API's uuid validation would
|
|
14
|
+
* reject it), which makes trimming a safe correction rather than the silent
|
|
15
|
+
* retargeting it would be for a filename. */
|
|
16
|
+
export declare const sessionIdSchema: z.ZodString;
|
|
17
|
+
/** Shared TCP-port schema. */
|
|
18
|
+
export declare const portSchema: z.ZodNumber;
|
|
19
|
+
/** Shared sandbox-path schema — rejects an empty or whitespace-only path
|
|
20
|
+
* client-side instead of by a server error, WITHOUT transforming the value:
|
|
21
|
+
* leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
|
|
22
|
+
* (a transform, not a check) would silently retarget the operation to a
|
|
23
|
+
* different file. Call sites override the description with their own examples. */
|
|
24
|
+
export declare const pathSchema: z.ZodEffects<z.ZodString, string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
|
|
27
|
+
* lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
|
|
28
|
+
* slug through the SAME preview validation (exposePersistentPreviewURL), so
|
|
29
|
+
* this applies to tenki_expose_port too. The server additionally rejects
|
|
30
|
+
* consecutive hyphens, reserved names, and workspace-length overflows —
|
|
31
|
+
* those stay server-side.
|
|
32
|
+
*/
|
|
33
|
+
export declare const slugSchema: z.ZodString;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Serialize any tool return value as MCP text content. */
|
|
3
|
+
export const ok = (value) => ({
|
|
4
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
5
|
+
});
|
|
6
|
+
/** 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.");
|
|
8
|
+
/** Shared session-id schema — every per-sandbox tool takes one of these.
|
|
9
|
+
* Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
|
|
10
|
+
* whitespace is always an accident (and the API's uuid validation would
|
|
11
|
+
* reject it), which makes trimming a safe correction rather than the silent
|
|
12
|
+
* retargeting it would be for a filename. */
|
|
13
|
+
export const sessionIdSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.trim()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe("Sandbox session id (UUID), from tenki_create_sandbox or tenki_list_sandboxes.");
|
|
18
|
+
/** Shared TCP-port schema. */
|
|
19
|
+
export const portSchema = z.number().int().min(1).max(65535).describe("TCP port inside the sandbox (1-65535).");
|
|
20
|
+
/** Shared sandbox-path schema — rejects an empty or whitespace-only path
|
|
21
|
+
* client-side instead of by a server error, WITHOUT transforming the value:
|
|
22
|
+
* leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
|
|
23
|
+
* (a transform, not a check) would silently retarget the operation to a
|
|
24
|
+
* different file. Call sites override the description with their own examples. */
|
|
25
|
+
export const pathSchema = z
|
|
26
|
+
.string()
|
|
27
|
+
.refine((s) => s.trim().length > 0, "path must not be empty or whitespace-only")
|
|
28
|
+
.describe("Absolute path inside the sandbox, under /home/tenki.");
|
|
29
|
+
/**
|
|
30
|
+
* Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
|
|
31
|
+
* lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
|
|
32
|
+
* slug through the SAME preview validation (exposePersistentPreviewURL), so
|
|
33
|
+
* this applies to tenki_expose_port too. The server additionally rejects
|
|
34
|
+
* consecutive hyphens, reserved names, and workspace-length overflows —
|
|
35
|
+
* those stay server-side.
|
|
36
|
+
*/
|
|
37
|
+
export const slugSchema = z
|
|
38
|
+
.string()
|
|
39
|
+
.min(3)
|
|
40
|
+
.max(63)
|
|
41
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits, and hyphens; cannot start or end with a hyphen")
|
|
42
|
+
.describe("Subdomain slug for the preview URL (3-63 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen).");
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { envSchema, sessionIdSchema } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Structured result contract for tenki_exec. Mirrors ExecResult in client.ts —
|
|
5
|
+
* the SDK validates every successful result against this, so the two must stay
|
|
6
|
+
* in lockstep field-for-field.
|
|
7
|
+
*/
|
|
8
|
+
const execOutputSchema = {
|
|
9
|
+
command: z.string().describe("The executable that was run."),
|
|
10
|
+
args: z.array(z.string()).describe("Arguments the executable was invoked with."),
|
|
11
|
+
stdout: z.string().describe("Captured standard output (empty when capture failed — see captureError)."),
|
|
12
|
+
stderr: z.string().describe("Captured standard error (empty when capture failed — see captureError)."),
|
|
13
|
+
exitCode: z.number().int().describe("Process exit code; 0 means success. (The API omits zero values; the server normalizes an absent code to 0.)"),
|
|
14
|
+
ok: z.boolean().describe("True only when exitCode is 0 AND stdout/stderr capture succeeded."),
|
|
15
|
+
captureError: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Present when the command ran but its output could not be read back; stdout/stderr are unknown, not empty."),
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Field drift between execOutputSchema and ExecResult must fail the build, not
|
|
22
|
+
* the tool call — at runtime the SDK rejects a mismatched result outright and
|
|
23
|
+
* the command's output is lost.
|
|
24
|
+
*/
|
|
25
|
+
const execOutput = z.object(execOutputSchema);
|
|
26
|
+
const _execSchemaLockstep = true;
|
|
27
|
+
void _execSchemaLockstep;
|
|
28
|
+
/**
|
|
29
|
+
* Sandbox output is untrusted (SECURITY.md). Neutralize characters that can
|
|
30
|
+
* misrepresent output in a terminal or transcript — C0 controls except \n and
|
|
31
|
+
* \t (ANSI sequences, CR line-overwrite spoofing), DEL, C1 controls (0x80–0x9F
|
|
32
|
+
* are single-character equivalents: U+009B is CSI), bidi controls
|
|
33
|
+
* (U+202A–U+202E, U+2066–U+2069 reorder displayed text), and zero-width/
|
|
34
|
+
* invisible marks (U+200B–U+200F, U+FEFF) — into visible \xNN / \uNNNN
|
|
35
|
+
* escapes. The previous JSON.stringify rendering escaped controls implicitly;
|
|
36
|
+
* a raw text rendering must do it explicitly. structuredContent keeps the raw
|
|
37
|
+
* strings — JSON encoding neutralizes them on the wire, and typed consumers
|
|
38
|
+
* need unmodified data.
|
|
39
|
+
*/
|
|
40
|
+
function sanitizeForTerminal(s) {
|
|
41
|
+
// eslint-disable-next-line no-control-regex
|
|
42
|
+
return s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, (c) => {
|
|
43
|
+
const code = c.charCodeAt(0);
|
|
44
|
+
return code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u${code.toString(16).padStart(4, "0")}`;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Render an ExecResult as plain text for clients that don't consume
|
|
49
|
+
* structuredContent. Carries the FULL stdout/stderr (control chars escaped) —
|
|
50
|
+
* output capping is a separate concern and must not silently drop data here.
|
|
51
|
+
*/
|
|
52
|
+
function execText(r) {
|
|
53
|
+
const head = `exit ${r.exitCode}${r.ok ? "" : " (failed)"}`;
|
|
54
|
+
const capture = r.captureError
|
|
55
|
+
? `\ncapture error: ${sanitizeForTerminal(r.captureError)} — stdout/stderr may be incomplete`
|
|
56
|
+
: "";
|
|
57
|
+
const stdout = sanitizeForTerminal(r.stdout);
|
|
58
|
+
const stderr = sanitizeForTerminal(r.stderr);
|
|
59
|
+
return `${head}${capture}\n--- stdout (${r.stdout.length} chars, control chars escaped) ---\n${stdout}\n--- stderr (${r.stderr.length} chars, control chars escaped) ---\n${stderr}`;
|
|
60
|
+
}
|
|
61
|
+
/** Command execution inside an existing sandbox. */
|
|
62
|
+
export function registerExec(server, client) {
|
|
63
|
+
server.registerTool("tenki_exec", {
|
|
64
|
+
description: "Run a command in an existing sandbox and return stdout, stderr, and exit code inline.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
session_id: sessionIdSchema,
|
|
67
|
+
command: z.string().describe("Executable, e.g. 'npm' or 'python3'."),
|
|
68
|
+
args: z.array(z.string()).optional().describe("Arguments."),
|
|
69
|
+
cwd: z.string().optional().describe("Working directory (honored in-script)."),
|
|
70
|
+
env: envSchema,
|
|
71
|
+
timeout_seconds: z.number().int().positive().optional(),
|
|
72
|
+
},
|
|
73
|
+
outputSchema: execOutputSchema,
|
|
74
|
+
}, async ({ session_id, command, args, cwd, env, timeout_seconds }) => {
|
|
75
|
+
const result = await client.execCaptured(session_id, command, { args, cwd, env, timeoutSeconds: timeout_seconds });
|
|
76
|
+
return {
|
|
77
|
+
structuredContent: { ...result },
|
|
78
|
+
// Two text blocks: serialized JSON first (the MCP spec's
|
|
79
|
+
// backwards-compatibility contract for structured content — existing
|
|
80
|
+
// text-only consumers, our own test harness included, parse the first
|
|
81
|
+
// text block as JSON), then the human-readable rendering.
|
|
82
|
+
content: [
|
|
83
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
84
|
+
{ type: "text", text: execText(result) },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, pathSchema, sessionIdSchema } from "./common.js";
|
|
3
|
+
/** Filesystem I/O against a sandbox's data plane. */
|
|
4
|
+
export function registerFiles(server, client) {
|
|
5
|
+
server.tool("tenki_read_file", "Read a UTF-8 text file from a sandbox (paths under /home/tenki).", { session_id: sessionIdSchema, path: pathSchema }, async ({ session_id, path }) => ok({ path, content: await client.readTextFile(session_id, path) }));
|
|
6
|
+
server.tool("tenki_write_file", "Write a UTF-8 text file to a sandbox (paths under /home/tenki).", { session_id: sessionIdSchema, path: pathSchema, content: z.string() }, async ({ session_id, path, content }) => ok(await client.writeTextFile(session_id, path, content)));
|
|
7
|
+
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
|
+
session_id: sessionIdSchema,
|
|
9
|
+
path: pathSchema.describe("Directory path, e.g. /home/tenki"),
|
|
10
|
+
include_hidden: z
|
|
11
|
+
.boolean()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Include dot-prefixed entries (default true). The data plane omits them unless asked, which hides .git/.env from a listing."),
|
|
14
|
+
}, async ({ session_id, path, include_hidden }) => {
|
|
15
|
+
// Default TRUE, inverting the wire default: a listing that silently omits
|
|
16
|
+
// .git/.env/.gitignore leads an agent to conclude they do not exist.
|
|
17
|
+
const resp = await client.data(session_id, "List", { path, includeHidden: include_hidden !== false });
|
|
18
|
+
// proto3 omits an empty repeated field, so an empty directory comes back
|
|
19
|
+
// as {} — indistinguishable from a malformed call. Normalize it.
|
|
20
|
+
return ok({ path, entries: Array.isArray(resp.entries) ? resp.entries : [] });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Filesystem metadata + mutation ops against a sandbox's data plane.
|
|
5
|
+
*
|
|
6
|
+
* Extends the basic read/write/list coverage in `files.ts` with `stat`, `mkdir`,
|
|
7
|
+
* `remove`, and `move`. Stat/Mkdir/Remove are genuine unary data-plane RPCs
|
|
8
|
+
* (`SandboxSessionDataPlaneService`). The data plane exposes no `Move` method, so
|
|
9
|
+
* `tenki_move_path` is exec-backed (`mv`) — matching the live-verified n8n node.
|
|
10
|
+
* Paths are rooted at /home/tenki (the server enforces the sandbox root).
|
|
11
|
+
*/
|
|
12
|
+
export declare function registerFilesOps(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,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.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-07-20): the preview-URL methods are PROJECT-scoped —
|
|
12
|
+
* the server rejects them with `project_id: value is empty` unless a projectId is
|
|
13
|
+
* sent, and CreatePreviewUrl additionally requires a `slug` (>=3 chars, [a-z0-9-]).
|
|
14
|
+
* projectId defaults to the API key's first project; override with project_id.
|
|
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,102 @@
|
|
|
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). Project-scoped; defaults to the key's first project.", {
|
|
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
|
+
project_id: z.string().optional().describe("Project the preview URL belongs to (defaults to the key's first project)."),
|
|
15
|
+
expires_at: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.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, project_id, expires_at }) => {
|
|
20
|
+
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
21
|
+
return ok(await client.control("CreatePreviewUrl", {
|
|
22
|
+
sessionId: session_id,
|
|
23
|
+
port,
|
|
24
|
+
slug,
|
|
25
|
+
...(projectId ? { projectId } : {}),
|
|
26
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
27
|
+
}));
|
|
28
|
+
});
|
|
29
|
+
// ── Open (get) a live preview for a port ──────────────────────────────────────
|
|
30
|
+
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
|
+
session_id: sessionIdSchema.describe("The sandbox session serving the port."),
|
|
32
|
+
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
|
+
expires_at: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Optional RFC-3339 timestamp at which the preview auto-expires. Omit to keep it until the sandbox ends."),
|
|
38
|
+
}, async ({ session_id, port, project_id, expires_at }) => {
|
|
39
|
+
const projectId = project_id ?? (await client.resolveOwner()).projectId;
|
|
40
|
+
return ok(await client.control("OpenPreview", {
|
|
41
|
+
sessionId: session_id,
|
|
42
|
+
port,
|
|
43
|
+
...(projectId ? { projectId } : {}),
|
|
44
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
// ── List the preview URLs bound to a sandbox / project ────────────────────────
|
|
48
|
+
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
|
+
session_id: sessionIdSchema
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Keep only preview URLs bound to this sandbox. Applied client-side to the fetched page."),
|
|
52
|
+
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
53
|
+
page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (server default 20, max 100)."),
|
|
54
|
+
page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
|
|
55
|
+
}, async ({ session_id, workspace_id, page_size, page_token }) => {
|
|
56
|
+
// The RPC has no sessionId field (ListPreviewUrlsRequest: workspace_id,
|
|
57
|
+
// page_size, page_token, and a deprecated project_id), so a sessionId sent
|
|
58
|
+
// on the wire is silently discarded and every session's rows come back.
|
|
59
|
+
// Filter here instead of advertising a filter that does nothing.
|
|
60
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
61
|
+
const resp = await client.control("ListPreviewUrls", {
|
|
62
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
63
|
+
...(page_size !== undefined ? { pageSize: page_size } : {}),
|
|
64
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
65
|
+
});
|
|
66
|
+
const rows = Array.isArray(resp.previewUrls) ? resp.previewUrls : [];
|
|
67
|
+
const filtered = session_id ? rows.filter((r) => r?.sessionId === session_id) : rows;
|
|
68
|
+
return ok({
|
|
69
|
+
previewUrls: filtered,
|
|
70
|
+
...(resp.nextPageToken ? { nextPageToken: resp.nextPageToken } : {}),
|
|
71
|
+
...(session_id ? { filteredClientSide: true, fetchedOnThisPage: rows.length } : {}),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
// ── Get / delete a specific preview URL ───────────────────────────────────────
|
|
75
|
+
server.tool("tenki_get_preview_url", "Fetch a specific preview URL's details by id (project-scoped).", {
|
|
76
|
+
preview_url_id: z.string().describe("The preview URL id."),
|
|
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
|
+
});
|
|
89
|
+
// ── Touch (keep-alive) a preview ──────────────────────────────────────────────
|
|
90
|
+
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
|
+
// TouchPreview takes a previewToken, not session/port (live-verified).
|
|
92
|
+
async ({ preview_token }) => ok(await client.control("TouchPreview", { previewToken: preview_token })));
|
|
93
|
+
// ── Bind / unbind a named preview URL to a session+port ───────────────────────
|
|
94
|
+
// Advanced routing primitives (shapes SDK-name-verified; not exercised end-to-end here).
|
|
95
|
+
server.tool("tenki_bind_preview_url", "Bind a named preview URL to a sandbox session and port (advanced routing).", {
|
|
96
|
+
preview_url_id: z.string().describe("The preview URL id to bind."),
|
|
97
|
+
session_id: sessionIdSchema,
|
|
98
|
+
port: portSchema.describe("The port to route the preview URL to."),
|
|
99
|
+
}, async ({ preview_url_id, session_id, port }) => ok(await client.control("BindPreviewUrl", { previewUrlId: preview_url_id, sessionId: session_id, port })));
|
|
100
|
+
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 })));
|
|
101
|
+
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 })));
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tenki-mcp — Registry tools (custom sandbox images).
|
|
3
|
+
*
|
|
4
|
+
* The registry lets a workspace publish a sandbox snapshot/template as a reusable
|
|
5
|
+
* custom image (`<workspace>/<artifact>[:tag]`), resolve refs, control visibility,
|
|
6
|
+
* and share images across workspaces.
|
|
7
|
+
*
|
|
8
|
+
* Request shapes are LIVE-VERIFIED against api.tenki.cloud (2026-07-21). The API
|
|
9
|
+
* is inconsistent about field names, so note the specifics:
|
|
10
|
+
* - most methods take `ref` (a bare `<ws>/<artifact>`; grants/unshare need it TAGLESS);
|
|
11
|
+
* - ShareImage uses `imageRef` + `targetWorkspaceId` (NOT `ref`);
|
|
12
|
+
* - version delete + grant revoke take UUIDs (`imageId`/`snapshotId`/`grantId`);
|
|
13
|
+
* - visibility + publish-kind are string ENUMS (`REGISTRY_VISIBILITY_*`, `REGISTRY_IMAGE_KIND_*`).
|
|
14
|
+
* (The n8n reference this was first ported from marked all of these UNVERIFIED.)
|
|
15
|
+
*/
|
|
16
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import type { TenkiClient } from "../client.js";
|
|
18
|
+
export declare function registerRegistry(server: McpServer, client: TenkiClient): void;
|