@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +575 -0
  7. package/dist/http.d.ts +17 -0
  8. package/dist/http.js +294 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +60 -0
  11. package/dist/oauth.d.ts +33 -0
  12. package/dist/oauth.js +146 -0
  13. package/dist/server.d.ts +30 -0
  14. package/dist/server.js +203 -0
  15. package/dist/tools/artifacts.d.ts +16 -0
  16. package/dist/tools/artifacts.js +19 -0
  17. package/dist/tools/auth_status.d.ts +35 -0
  18. package/dist/tools/auth_status.js +109 -0
  19. package/dist/tools/common.d.ts +35 -0
  20. package/dist/tools/common.js +60 -0
  21. package/dist/tools/exec.d.ts +4 -0
  22. package/dist/tools/exec.js +120 -0
  23. package/dist/tools/files.d.ts +4 -0
  24. package/dist/tools/files.js +22 -0
  25. package/dist/tools/files_ops.d.ts +12 -0
  26. package/dist/tools/files_ops.js +54 -0
  27. package/dist/tools/git.d.ts +4 -0
  28. package/dist/tools/git.js +30 -0
  29. package/dist/tools/identity.d.ts +4 -0
  30. package/dist/tools/identity.js +5 -0
  31. package/dist/tools/ports.d.ts +4 -0
  32. package/dist/tools/ports.js +6 -0
  33. package/dist/tools/previews.d.ts +18 -0
  34. package/dist/tools/previews.js +80 -0
  35. package/dist/tools/run.d.ts +4 -0
  36. package/dist/tools/run.js +11 -0
  37. package/dist/tools/sandboxes.d.ts +4 -0
  38. package/dist/tools/sandboxes.js +72 -0
  39. package/dist/tools/sessions_admin.d.ts +9 -0
  40. package/dist/tools/sessions_admin.js +62 -0
  41. package/dist/tools/snapshots.d.ts +11 -0
  42. package/dist/tools/snapshots.js +79 -0
  43. package/dist/tools/ssh.d.ts +15 -0
  44. package/dist/tools/ssh.js +17 -0
  45. package/dist/tools/templates.d.ts +14 -0
  46. package/dist/tools/templates.js +142 -0
  47. package/dist/tools/volumes.d.ts +16 -0
  48. package/dist/tools/volumes.js +77 -0
  49. package/dist/tools/workspace.d.ts +4 -0
  50. package/dist/tools/workspace.js +129 -0
  51. package/package.json +59 -4
package/dist/server.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Shared MCP server factory — builds a server with every tool module registered.
3
+ * Used by both transports: stdio (index.ts) and HTTP (http.ts).
4
+ *
5
+ * Security controls (see SECURITY.md; maps to CSA MCP Server Top-10 MCP-07
6
+ * "excessive permissions"). Every tool is registered through a guard that:
7
+ * 1. tags it with MCP annotations (readOnlyHint / destructiveHint / openWorldHint)
8
+ * so clients can surface or gate dangerous tools;
9
+ * 2. enforces least-privilege via env:
10
+ * TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
11
+ * TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
12
+ * 3. optionally audit-logs each call name to stderr: TENKI_MCP_AUDIT=1
13
+ * (tool name + argument KEYS only — never values, content, or the token).
14
+ */
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { registerIdentity } from "./tools/identity.js";
17
+ import { registerRun } from "./tools/run.js";
18
+ import { registerSandboxes } from "./tools/sandboxes.js";
19
+ import { registerSessionsAdmin } from "./tools/sessions_admin.js";
20
+ import { registerExec } from "./tools/exec.js";
21
+ import { registerFiles } from "./tools/files.js";
22
+ import { registerFilesOps } from "./tools/files_ops.js";
23
+ import { registerGit } from "./tools/git.js";
24
+ import { registerPorts } from "./tools/ports.js";
25
+ import { registerPreviews } from "./tools/previews.js";
26
+ import { registerSnapshots } from "./tools/snapshots.js";
27
+ import { registerVolumes } from "./tools/volumes.js";
28
+ import { registerTemplates } from "./tools/templates.js";
29
+ import { registerWorkspace } from "./tools/workspace.js";
30
+ import { registerArtifacts } from "./tools/artifacts.js";
31
+ import { registerSsh } from "./tools/ssh.js";
32
+ import { registerAuthStatus } from "./tools/auth_status.js";
33
+ export const VERSION = "0.3.1";
34
+ const modules = [
35
+ registerIdentity,
36
+ registerRun,
37
+ registerSandboxes,
38
+ registerSessionsAdmin,
39
+ registerExec,
40
+ registerFiles,
41
+ registerFilesOps,
42
+ registerGit,
43
+ registerPorts,
44
+ registerPreviews,
45
+ registerSnapshots,
46
+ registerVolumes,
47
+ registerTemplates,
48
+ registerWorkspace,
49
+ registerArtifacts,
50
+ registerSsh,
51
+ ];
52
+ // Destroys/removes/revokes a resource → destructiveHint.
53
+ const DESTRUCTIVE = /^tenki_(terminate|delete|remove|unshare|revoke|detach|unexpose|unbind)/;
54
+ // Named exceptions that match the READ prefix below but actually grant a
55
+ // write/spend capability, so they must never be treated as read-only.
56
+ // tenki_get_upload_url returns a signed URL for an arbitrary PUT into the sandbox.
57
+ const WRITE_OVERRIDE = new Set(["tenki_get_upload_url"]);
58
+ // Named exceptions that are pure reads but don't match the READ prefix below.
59
+ // tenki_auth_status only inspects the ambient credential + probes WhoAmI, so it
60
+ // must stay available under TENKI_MCP_READONLY (it is how an operator diagnoses
61
+ // a credential problem in that posture).
62
+ const READ_OVERRIDE = new Set(["tenki_auth_status"]);
63
+ // Pure inspection, no state change / no spend → readOnlyHint.
64
+ const READ = /^tenki_(get|list|whoami|resolve|stat|read)/;
65
+ export function classifyTool(name) {
66
+ if (DESTRUCTIVE.test(name))
67
+ return "destructive";
68
+ if (WRITE_OVERRIDE.has(name))
69
+ return "write";
70
+ if (READ_OVERRIDE.has(name) || READ.test(name))
71
+ return "read";
72
+ return "write";
73
+ }
74
+ function readGuardOpts() {
75
+ const truthy = (v) => v === "1" || (v ?? "").toLowerCase() === "true";
76
+ const disabled = new Set((process.env.TENKI_MCP_DISABLED_TOOLS || "")
77
+ .split(",")
78
+ .map((s) => s.trim())
79
+ .filter(Boolean));
80
+ return { readonly: truthy(process.env.TENKI_MCP_READONLY), disabled, audit: truthy(process.env.TENKI_MCP_AUDIT) };
81
+ }
82
+ /** Log a tool call's name + argument KEYS (never values/content/token) to stderr. */
83
+ function auditKeys(args) {
84
+ if (!args || typeof args !== "object")
85
+ return "";
86
+ const keys = Object.keys(args);
87
+ return keys.length ? ` args=[${keys.join(",")}]` : "";
88
+ }
89
+ /** MCP annotations derived from a tool's classification. */
90
+ function annotationsFor(cls) {
91
+ return {
92
+ readOnlyHint: cls === "read",
93
+ destructiveHint: cls === "destructive",
94
+ idempotentHint: cls === "read",
95
+ openWorldHint: true, // every tool reaches the external Tenki API
96
+ };
97
+ }
98
+ /** Wrap a handler so TENKI_MCP_AUDIT=1 logs the call name + argument keys. */
99
+ function withAudit(name, handler, audit) {
100
+ if (!audit)
101
+ return handler;
102
+ return async (args, extra) => {
103
+ try {
104
+ // A tool registered WITHOUT an input schema is invoked as (extra) —
105
+ // one argument — so the first param would be the request context, not
106
+ // tool args. Log arg keys only for the two-argument (args, extra) shape.
107
+ console.error(`[tenki-mcp audit] ${name}${extra === undefined ? "" : auditKeys(args)}`);
108
+ }
109
+ catch {
110
+ /* never let logging break a call */
111
+ }
112
+ return handler(args, extra);
113
+ };
114
+ }
115
+ /**
116
+ * Handle returned for a tool the guard skipped (denylist / read-only posture).
117
+ * The real registration APIs return a RegisteredTool handle; a module that
118
+ * calls .enable()/.remove() on its registration must not crash only in
119
+ * read-only or denylist mode, so skipped registrations get an inert stand-in.
120
+ */
121
+ function noopToolHandle() {
122
+ return {
123
+ enabled: false,
124
+ enable() { },
125
+ disable() { },
126
+ update() { },
127
+ remove() { },
128
+ };
129
+ }
130
+ /**
131
+ * Wrap a server so every tool registration from a module is annotated + subject
132
+ * to the least-privilege env controls above. Both registration APIs are guarded:
133
+ * the legacy `.tool(name, description, schema, handler)` form and the modern
134
+ * `.registerTool(name, config, handler)` form (the only one that accepts an
135
+ * outputSchema) — so neither path can bypass annotations, read-only mode, or
136
+ * the denylist. Other access passes through to the real server unchanged.
137
+ */
138
+ function guard(server, opts) {
139
+ return new Proxy(server, {
140
+ get(target, prop, receiver) {
141
+ if (prop === "tool") {
142
+ return (name, description, schema, handler) => {
143
+ const cls = classifyTool(name);
144
+ if (opts.disabled.has(name))
145
+ return noopToolHandle(); // explicit denylist
146
+ if (opts.readonly && cls !== "read")
147
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
148
+ if (opts.registered)
149
+ opts.registered.count++;
150
+ return target.tool(name, description, schema, annotationsFor(cls), withAudit(name, handler, opts.audit));
151
+ };
152
+ }
153
+ if (prop === "registerTool") {
154
+ return (name, config, handler) => {
155
+ const cls = classifyTool(name);
156
+ if (opts.disabled.has(name))
157
+ return noopToolHandle(); // explicit denylist
158
+ if (opts.readonly && cls !== "read")
159
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
160
+ // Name-derived classification stays authoritative for the four hints so
161
+ // a module cannot soften them; other annotation fields pass through.
162
+ if (opts.registered)
163
+ opts.registered.count++;
164
+ const annotations = {
165
+ ...config.annotations,
166
+ ...annotationsFor(cls),
167
+ };
168
+ return target.registerTool(name, { ...config, annotations }, withAudit(name, handler, opts.audit));
169
+ };
170
+ }
171
+ return Reflect.get(target, prop, receiver);
172
+ },
173
+ });
174
+ }
175
+ /**
176
+ * Build a fresh MCP server instance with all tools registered against `client`.
177
+ *
178
+ * `client` is null when no credential was supplied. Rather than refusing to
179
+ * start — which MCP clients report as an opaque "server failed to start" —
180
+ * the server boots with ONLY tenki_auth_status registered, so an agent can
181
+ * discover and explain the missing credential. Registering the other tools in
182
+ * that state would offer 70 tools that can only fail.
183
+ */
184
+ export function createServer(client) {
185
+ const server = new McpServer({ name: "tenki", version: VERSION });
186
+ const opts = { ...readGuardOpts(), registered: { count: 0 } };
187
+ const guarded = guard(server, opts);
188
+ if (client)
189
+ for (const register of modules)
190
+ register(guarded, client);
191
+ // Registered last, through the same guard as everything else, so its
192
+ // toolsRegistered figure counts the tools above it (+1 for itself).
193
+ // READ_OVERRIDE keeps it available under TENKI_MCP_READONLY;
194
+ // TENKI_MCP_DISABLED_TOOLS can still drop it.
195
+ registerAuthStatus(guarded, client, opts.registered.count + 1);
196
+ if (!client)
197
+ console.error("tenki-mcp: no credential — only tenki_auth_status registered. Set TENKI_API_KEY or TENKI_AUTH_TOKEN and restart.");
198
+ if (opts.readonly)
199
+ console.error("tenki-mcp: TENKI_MCP_READONLY — only read-only tools registered.");
200
+ else if (opts.disabled.size)
201
+ console.error(`tenki-mcp: disabled tools — ${[...opts.disabled].join(", ")}`);
202
+ return server;
203
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * artifacts.ts — signed-URL binary transfer for tenki-mcp.
3
+ *
4
+ * The text file tools (read_file/write_file) round-trip UTF-8 over the data plane.
5
+ * For binary payloads (datasets, wheels, images, build outputs) Tenki issues short-
6
+ * lived signed URLs: GetArtifactUploadUrl to PUT a file into the sandbox, and
7
+ * GetArtifactDownloadUrl to GET one out. These tools return the signed URL; the
8
+ * caller performs the actual HTTP PUT/GET.
9
+ *
10
+ * GetArtifactUploadUrlRequest { sessionId, path, contentType } is verified from the
11
+ * published Tenki API surface. GetArtifactDownloadUrl takes an artifactId only — the API
12
+ * rejects a path (command stdout/stderr are surfaced as artifact ids).
13
+ */
14
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import type { TenkiClient } from "../client.js";
16
+ export declare function registerArtifacts(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import { ok, pathSchema, sessionIdSchema } from "./common.js";
3
+ export function registerArtifacts(server, client) {
4
+ server.tool("tenki_get_upload_url", "Get a short-lived signed URL to upload (HTTP PUT) a binary file to a path inside a sandbox. Use for non-text payloads too large or binary for tenki_write_file.", {
5
+ session_id: sessionIdSchema,
6
+ path: pathSchema.describe("Destination path in the sandbox, e.g. /home/tenki/data.bin"),
7
+ content_type: z.string().optional().describe("MIME type of the upload, e.g. application/octet-stream."),
8
+ }, async ({ session_id, path, content_type }) => ok(await client.control("GetArtifactUploadUrl", {
9
+ sessionId: session_id,
10
+ path,
11
+ ...(content_type ? { contentType: content_type } : {}),
12
+ })));
13
+ server.tool("tenki_get_download_url", "Get a short-lived signed URL to download (HTTP GET) a command artifact from a sandbox by its artifact id (e.g. a command's stdout/stderr artifact). Note: the API supports download-by-artifact-id only, not download-by-path.", {
14
+ session_id: sessionIdSchema,
15
+ artifact_id: z.string().describe("Artifact id to download (e.g. a command's stdout/stderr artifact)."),
16
+ },
17
+ // GetArtifactDownloadUrl only accepts an artifact UUID; a `path` is rejected (live-verified).
18
+ async ({ session_id, artifact_id }) => ok(await client.control("GetArtifactDownloadUrl", { sessionId: session_id, artifactId: artifact_id })));
19
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * auth_status.ts — the one tool that works without a credential.
3
+ *
4
+ * Without this, a user who installs the server before setting a token gets a
5
+ * process that exits 1; MCP clients surface that as "server failed to start"
6
+ * and usually swallow stderr, so the actual cause (no token) is invisible. The
7
+ * server therefore boots credential-less with ONLY this tool registered, so an
8
+ * agent can ask what's wrong and get an actionable answer.
9
+ *
10
+ * It reports status; it does NOT log in. Obtaining a token is out of scope for
11
+ * the server (see `tenki login`), and nothing here opens a browser or writes
12
+ * credentials anywhere.
13
+ */
14
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import type { TenkiClient } from "../client.js";
16
+ /** How the server authenticates API calls. */
17
+ export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "session_cookie" | "hosted_oauth";
18
+ export interface CredentialInfo {
19
+ kind: CredentialKind;
20
+ /** Env var the token came from, or undefined when there is none. */
21
+ source?: "TENKI_AUTH_TOKEN" | "TENKI_API_KEY";
22
+ }
23
+ /**
24
+ * Classify the ambient credential WITHOUT returning any of its material.
25
+ * Mirrors the header selection in client.ts (`tk_` → Bearer API key,
26
+ * `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
27
+ * index.ts's precedence (TENKI_AUTH_TOKEN wins over TENKI_API_KEY).
28
+ */
29
+ export declare function describeCredential(env?: NodeJS.ProcessEnv): CredentialInfo;
30
+ /**
31
+ * Report authentication status. `client` is null when the server booted without
32
+ * a credential; `toolsRegistered` lets the caller see it is in that degraded
33
+ * single-tool mode rather than guessing from a short tools/list.
34
+ */
35
+ export declare function registerAuthStatus(server: McpServer, client: TenkiClient | null, toolsRegistered: number): void;
@@ -0,0 +1,109 @@
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 && env.TENKI_MCP_OAUTH_ISSUER?.trim())
13
+ return { kind: "hosted_oauth" };
14
+ if (!raw)
15
+ return { kind: "none" };
16
+ const source = fromToken ? "TENKI_AUTH_TOKEN" : "TENKI_API_KEY";
17
+ if (raw.startsWith("tk_"))
18
+ return { kind: "api_key", source };
19
+ if (raw.startsWith("ory_st_"))
20
+ return { kind: "oauth_session_token", source };
21
+ return { kind: "session_cookie", source };
22
+ }
23
+ const CREDENTIAL_HELP = {
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.",
25
+ api_key: "Authenticated with a tk_… API key (Authorization: Bearer).",
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.",
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.",
29
+ };
30
+ const authOutputSchema = {
31
+ authenticated: z.boolean().describe("True only when a credential is present AND a live identity probe succeeded."),
32
+ credential: z
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."),
35
+ source: z
36
+ .string()
37
+ .optional()
38
+ .describe("Environment variable the credential came from (TENKI_AUTH_TOKEN takes precedence over TENKI_API_KEY)."),
39
+ endpoint: z.string().describe("Control-plane base URL the server is pointed at."),
40
+ toolsRegistered: z
41
+ .number()
42
+ .int()
43
+ .describe("How many tools this server registered. Without a credential only this one is registered."),
44
+ identity: z
45
+ .object({
46
+ ownerType: z.string().optional(),
47
+ ownerId: z.string().optional(),
48
+ workspaces: z.number().int().optional(),
49
+ })
50
+ .optional()
51
+ .describe("Identity returned by the live probe, when it succeeded."),
52
+ error: z.string().optional().describe("Why the probe failed, when a credential is present but unusable (expired, revoked, wrong endpoint)."),
53
+ detail: z.string().describe("Human-readable status plus, when unauthenticated, how to supply a credential."),
54
+ };
55
+ /**
56
+ * Report authentication status. `client` is null when the server booted without
57
+ * a credential; `toolsRegistered` lets the caller see it is in that degraded
58
+ * single-tool mode rather than guessing from a short tools/list.
59
+ */
60
+ export function registerAuthStatus(server, client, toolsRegistered) {
61
+ server.registerTool("tenki_auth_status", {
62
+ 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.",
63
+ inputSchema: {},
64
+ outputSchema: authOutputSchema,
65
+ }, async () => {
66
+ const cred = describeCredential();
67
+ const endpoint = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || "https://api.tenki.cloud";
68
+ const base = {
69
+ credential: cred.kind,
70
+ ...(cred.source ? { source: cred.source } : {}),
71
+ endpoint,
72
+ toolsRegistered,
73
+ };
74
+ if (!client || cred.kind === "none") {
75
+ const result = {
76
+ ...base,
77
+ authenticated: false,
78
+ detail: CREDENTIAL_HELP.none,
79
+ };
80
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
81
+ }
82
+ try {
83
+ const resp = await client.control("WhoAmI", {});
84
+ const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces.length : undefined;
85
+ const result = {
86
+ ...base,
87
+ authenticated: true,
88
+ identity: {
89
+ ...(typeof resp.ownerType === "string" ? { ownerType: resp.ownerType } : {}),
90
+ ...(typeof resp.ownerId === "string" ? { ownerId: resp.ownerId } : {}),
91
+ ...(workspaces !== undefined ? { workspaces } : {}),
92
+ },
93
+ detail: CREDENTIAL_HELP[cred.kind],
94
+ };
95
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
96
+ }
97
+ catch (e) {
98
+ const result = {
99
+ ...base,
100
+ authenticated: false,
101
+ error: e.message,
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}`,
105
+ };
106
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ /** Keep registry-backed implementation fields out of public MCP responses. */
3
+ export declare function publicValue(value: unknown): unknown;
4
+ /** Serialize any tool return value as MCP text content. */
5
+ export declare const ok: (value: unknown) => {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ };
11
+ /** Shared env-map schema used by tools that accept environment variables. */
12
+ export declare const envSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
+ /** Shared session-id schema — every per-sandbox tool takes one of these.
14
+ * Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
15
+ * whitespace is always an accident (and the API's uuid validation would
16
+ * reject it), which makes trimming a safe correction rather than the silent
17
+ * retargeting it would be for a filename. */
18
+ export declare const sessionIdSchema: z.ZodString;
19
+ /** Shared TCP-port schema. */
20
+ export declare const portSchema: z.ZodNumber;
21
+ /** Shared sandbox-path schema — rejects an empty or whitespace-only path
22
+ * client-side instead of by a server error, WITHOUT transforming the value:
23
+ * leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
24
+ * (a transform, not a check) would silently retarget the operation to a
25
+ * different file. Call sites override the description with their own examples. */
26
+ export declare const pathSchema: z.ZodString;
27
+ /**
28
+ * Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
29
+ * lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
30
+ * slug through the SAME preview validation (exposePersistentPreviewURL), so
31
+ * this applies to tenki_expose_port too. The server additionally rejects
32
+ * consecutive hyphens, reserved names, and workspace-length overflows —
33
+ * those stay server-side.
34
+ */
35
+ export declare const slugSchema: z.ZodString;
@@ -0,0 +1,60 @@
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
+ }
20
+ /** Serialize any tool return value as MCP text content. */
21
+ export const ok = (value) => ({
22
+ content: [{ type: "text", text: JSON.stringify(publicValue(value), null, 2) }],
23
+ });
24
+ /** Shared env-map schema used by tools that accept environment variables. */
25
+ export const envSchema = z.record(z.string(), z.string()).optional().describe("Environment variables as a key→value object.");
26
+ /** Shared session-id schema — every per-sandbox tool takes one of these.
27
+ * Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
28
+ * whitespace is always an accident (and the API's uuid validation would
29
+ * reject it), which makes trimming a safe correction rather than the silent
30
+ * retargeting it would be for a filename. */
31
+ export const sessionIdSchema = z
32
+ .string()
33
+ .trim()
34
+ .min(1)
35
+ .describe("Sandbox session id (UUID), from tenki_create_sandbox or tenki_list_sandboxes.");
36
+ /** Shared TCP-port schema. */
37
+ export const portSchema = z.number().int().min(1).max(65535).describe("TCP port inside the sandbox (1-65535).");
38
+ /** Shared sandbox-path schema — rejects an empty or whitespace-only path
39
+ * client-side instead of by a server error, WITHOUT transforming the value:
40
+ * leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
41
+ * (a transform, not a check) would silently retarget the operation to a
42
+ * different file. Call sites override the description with their own examples. */
43
+ export const pathSchema = z
44
+ .string()
45
+ .refine((s) => s.trim().length > 0, "path must not be empty or whitespace-only")
46
+ .describe("Absolute path inside the sandbox, under /home/tenki.");
47
+ /**
48
+ * Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
49
+ * lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
50
+ * slug through the SAME preview validation (exposePersistentPreviewURL), so
51
+ * this applies to tenki_expose_port too. The server additionally rejects
52
+ * consecutive hyphens, reserved names, and workspace-length overflows —
53
+ * those stay server-side.
54
+ */
55
+ export const slugSchema = z
56
+ .string()
57
+ .min(3)
58
+ .max(63)
59
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits, and hyphens; cannot start or end with a hyphen")
60
+ .describe("Subdomain slug for the preview URL (3-63 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen).");
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /** Command execution inside an existing sandbox. */
4
+ export declare function registerExec(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ import { envSchema, sessionIdSchema } from "./common.js";
3
+ /**
4
+ * Structured result contract for tenki_exec. Mirrors ExecResult in client.ts —
5
+ * the SDK validates every successful result against this, so the two must stay
6
+ * in lockstep field-for-field.
7
+ */
8
+ const execOutputSchema = {
9
+ command: z.string().describe("The executable that was run."),
10
+ args: z.array(z.string()).describe("Arguments the executable was invoked with."),
11
+ stdout: z.string().describe("Captured standard output (empty when capture failed — see captureError)."),
12
+ stderr: z.string().describe("Captured standard error (empty when capture failed — see captureError)."),
13
+ exitCode: z.number().int().describe("Process exit code; 0 means success. (The API omits zero values; the server normalizes an absent code to 0.)"),
14
+ ok: z.boolean().describe("True only when exitCode is 0 AND stdout/stderr capture succeeded."),
15
+ captureError: z
16
+ .string()
17
+ .optional()
18
+ .describe("Present when the command ran but its output could not be read back; stdout/stderr are unknown, not empty."),
19
+ stdoutTruncated: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("Present (true) when stdout exceeded the output cap and carries only a head+tail preview."),
23
+ stderrTruncated: z
24
+ .boolean()
25
+ .optional()
26
+ .describe("Present (true) when stderr exceeded the output cap and carries only a head+tail preview."),
27
+ stdoutPath: z
28
+ .string()
29
+ .optional()
30
+ .describe("Sandbox path holding the FULL stdout, present only when truncated — page through it with tenki_exec (e.g. sed -n / tail -c)."),
31
+ stderrPath: z
32
+ .string()
33
+ .optional()
34
+ .describe("Sandbox path holding the FULL stderr, present only when truncated — page through it with tenki_exec (e.g. sed -n / tail -c)."),
35
+ };
36
+ /**
37
+ * Field drift between execOutputSchema and ExecResult must fail the build, not
38
+ * the tool call — at runtime the SDK rejects a mismatched result outright and
39
+ * the command's output is lost.
40
+ */
41
+ const execOutput = z.object(execOutputSchema);
42
+ const _execSchemaLockstep = true;
43
+ void _execSchemaLockstep;
44
+ /**
45
+ * Sandbox output is untrusted (SECURITY.md). Neutralize characters that can
46
+ * misrepresent output in a terminal or transcript — C0 controls except \n and
47
+ * \t (ANSI sequences, CR line-overwrite spoofing), DEL, C1 controls (0x80–0x9F
48
+ * are single-character equivalents: U+009B is CSI), bidi controls
49
+ * (U+202A–U+202E, U+2066–U+2069 reorder displayed text), and zero-width/
50
+ * invisible marks (U+200B–U+200F, U+FEFF) — into visible \xNN / \uNNNN
51
+ * escapes. The previous JSON.stringify rendering escaped controls implicitly;
52
+ * a raw text rendering must do it explicitly. structuredContent keeps the raw
53
+ * strings — JSON encoding neutralizes them on the wire, and typed consumers
54
+ * need unmodified data.
55
+ */
56
+ function sanitizeForTerminal(s) {
57
+ // eslint-disable-next-line no-control-regex
58
+ return s.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, (c) => {
59
+ const code = c.charCodeAt(0);
60
+ return code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u${code.toString(16).padStart(4, "0")}`;
61
+ });
62
+ }
63
+ /**
64
+ * Render an ExecResult as plain text for clients that don't consume
65
+ * structuredContent. Carries the streams as returned (control chars escaped);
66
+ * any capping happened upstream in execCaptured, which marks it explicitly —
67
+ * a truncated stream carries an inline marker and its full-output path.
68
+ */
69
+ function execText(r) {
70
+ const head = `exit ${r.exitCode}${r.ok ? "" : " (failed)"}`;
71
+ const capture = r.captureError
72
+ ? `\ncapture error: ${sanitizeForTerminal(r.captureError)} — stdout/stderr may be incomplete`
73
+ : "";
74
+ const stdout = sanitizeForTerminal(r.stdout);
75
+ const stderr = sanitizeForTerminal(r.stderr);
76
+ const outTag = r.stdoutTruncated ? ", TRUNCATED — full output at " + r.stdoutPath : "";
77
+ const errTag = r.stderrTruncated ? ", TRUNCATED — full output at " + r.stderrPath : "";
78
+ return `${head}${capture}\n--- stdout (${r.stdout.length} chars, control chars escaped${outTag}) ---\n${stdout}\n--- stderr (${r.stderr.length} chars, control chars escaped${errTag}) ---\n${stderr}`;
79
+ }
80
+ /** Command execution inside an existing sandbox. */
81
+ export function registerExec(server, client) {
82
+ server.registerTool("tenki_exec", {
83
+ description: "Run a command in an existing sandbox and return stdout, stderr, and exit code inline. Streams over max_output_bytes (default 64KB) come back as a head+tail preview with the full output retained at stdoutPath/stderrPath in the sandbox.",
84
+ inputSchema: {
85
+ session_id: sessionIdSchema,
86
+ command: z.string().describe("Executable, e.g. 'npm' or 'python3'."),
87
+ args: z.array(z.string()).optional().describe("Arguments."),
88
+ cwd: z.string().optional().describe("Working directory (honored in-script)."),
89
+ env: envSchema,
90
+ timeout_seconds: z.number().int().positive().optional(),
91
+ max_output_bytes: z
92
+ .number()
93
+ .int()
94
+ .min(1024)
95
+ .max(10_000_000)
96
+ .optional()
97
+ .describe("Per-stream inline output cap in bytes (default 65536). Larger output is truncated head+tail and kept in the sandbox at stdoutPath/stderrPath."),
98
+ },
99
+ outputSchema: execOutputSchema,
100
+ }, async ({ session_id, command, args, cwd, env, timeout_seconds, max_output_bytes }) => {
101
+ const result = await client.execCaptured(session_id, command, {
102
+ args,
103
+ cwd,
104
+ env,
105
+ timeoutSeconds: timeout_seconds,
106
+ maxOutputBytes: max_output_bytes,
107
+ });
108
+ return {
109
+ structuredContent: { ...result },
110
+ // Two text blocks: serialized JSON first (the MCP spec's
111
+ // backwards-compatibility contract for structured content — existing
112
+ // text-only consumers, our own test harness included, parse the first
113
+ // text block as JSON), then the human-readable rendering.
114
+ content: [
115
+ { type: "text", text: JSON.stringify(result, null, 2) },
116
+ { type: "text", text: execText(result) },
117
+ ],
118
+ };
119
+ });
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /** Filesystem I/O against a sandbox's data plane. */
4
+ export declare function registerFiles(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,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;