@luxorai/mcp 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +192 -0
  6. package/dist/client.js +621 -0
  7. package/dist/env.d.ts +6 -0
  8. package/dist/env.js +25 -0
  9. package/dist/http.d.ts +17 -0
  10. package/dist/http.js +297 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +61 -0
  13. package/dist/oauth.d.ts +39 -0
  14. package/dist/oauth.js +166 -0
  15. package/dist/server.d.ts +50 -0
  16. package/dist/server.js +238 -0
  17. package/dist/tools/artifacts.d.ts +16 -0
  18. package/dist/tools/artifacts.js +19 -0
  19. package/dist/tools/auth_status.d.ts +35 -0
  20. package/dist/tools/auth_status.js +108 -0
  21. package/dist/tools/common.d.ts +70 -0
  22. package/dist/tools/common.js +138 -0
  23. package/dist/tools/egress.d.ts +38 -0
  24. package/dist/tools/egress.js +153 -0
  25. package/dist/tools/exec.d.ts +4 -0
  26. package/dist/tools/exec.js +120 -0
  27. package/dist/tools/files.d.ts +4 -0
  28. package/dist/tools/files.js +55 -0
  29. package/dist/tools/files_ops.d.ts +12 -0
  30. package/dist/tools/files_ops.js +62 -0
  31. package/dist/tools/git.d.ts +3 -0
  32. package/dist/tools/git.js +92 -0
  33. package/dist/tools/identity.d.ts +4 -0
  34. package/dist/tools/identity.js +5 -0
  35. package/dist/tools/ports.d.ts +11 -0
  36. package/dist/tools/ports.js +27 -0
  37. package/dist/tools/previews.d.ts +18 -0
  38. package/dist/tools/previews.js +117 -0
  39. package/dist/tools/run.d.ts +4 -0
  40. package/dist/tools/run.js +11 -0
  41. package/dist/tools/sandboxes.d.ts +4 -0
  42. package/dist/tools/sandboxes.js +388 -0
  43. package/dist/tools/sessions_admin.d.ts +9 -0
  44. package/dist/tools/sessions_admin.js +102 -0
  45. package/dist/tools/snapshots.d.ts +11 -0
  46. package/dist/tools/snapshots.js +98 -0
  47. package/dist/tools/ssh.d.ts +15 -0
  48. package/dist/tools/ssh.js +43 -0
  49. package/dist/tools/templates.d.ts +15 -0
  50. package/dist/tools/templates.js +162 -0
  51. package/dist/tools/titles.d.ts +8 -0
  52. package/dist/tools/titles.js +102 -0
  53. package/dist/tools/volumes.d.ts +16 -0
  54. package/dist/tools/volumes.js +102 -0
  55. package/dist/tools/workspace.d.ts +12 -0
  56. package/dist/tools/workspace.js +68 -0
  57. package/package.json +60 -4
package/dist/server.js ADDED
@@ -0,0 +1,238 @@
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. gives it a human-readable title (tools/titles.ts) and MCP annotations
8
+ * (readOnlyHint / destructiveHint / openWorldHint) so clients can surface
9
+ * or gate dangerous tools;
10
+ * 2. enforces least-privilege via env:
11
+ * TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
12
+ * TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
13
+ * 3. optionally audit-logs each call name to stderr: TENKI_MCP_AUDIT=1
14
+ * (tool name + argument KEYS only — never values, content, or the token).
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { registerIdentity } from "./tools/identity.js";
18
+ import { registerRun } from "./tools/run.js";
19
+ import { registerSandboxes } from "./tools/sandboxes.js";
20
+ import { registerSessionsAdmin } from "./tools/sessions_admin.js";
21
+ import { registerExec } from "./tools/exec.js";
22
+ import { registerFiles } from "./tools/files.js";
23
+ import { registerFilesOps } from "./tools/files_ops.js";
24
+ import { registerGit } from "./tools/git.js";
25
+ import { registerPorts } from "./tools/ports.js";
26
+ import { registerPreviews } from "./tools/previews.js";
27
+ import { registerSnapshots } from "./tools/snapshots.js";
28
+ import { registerVolumes } from "./tools/volumes.js";
29
+ import { registerTemplates } from "./tools/templates.js";
30
+ import { registerWorkspace } from "./tools/workspace.js";
31
+ import { registerArtifacts } from "./tools/artifacts.js";
32
+ import { registerSsh } from "./tools/ssh.js";
33
+ import { registerEgress } from "./tools/egress.js";
34
+ import { registerAuthStatus } from "./tools/auth_status.js";
35
+ import { readEnv } from "./env.js";
36
+ import { TOOL_TITLES } from "./tools/titles.js";
37
+ export const VERSION = "0.4.0";
38
+ const modules = [
39
+ registerIdentity,
40
+ registerRun,
41
+ registerSandboxes,
42
+ registerSessionsAdmin,
43
+ registerExec,
44
+ registerFiles,
45
+ registerFilesOps,
46
+ registerGit,
47
+ registerPorts,
48
+ registerPreviews,
49
+ registerSnapshots,
50
+ registerVolumes,
51
+ registerEgress,
52
+ registerTemplates,
53
+ registerWorkspace,
54
+ registerArtifacts,
55
+ registerSsh,
56
+ ];
57
+ // Destroys/removes/revokes a resource. Checked first, so a name with one of
58
+ // these prefixes is destructive even if it is mistakenly listed in ADDITIVE.
59
+ const DESTRUCTIVE = /^tenki_(terminate|delete|remove|unshare|revoke|detach|unexpose|unbind)/;
60
+ // Pure inspection, no state change / no spend → readOnlyHint.
61
+ const READ = /^tenki_(get|list|whoami|resolve|stat|read)/;
62
+ // Names that match the READ prefix but are not reads. tenki_get_upload_url
63
+ // returns a signed URL for an HTTP PUT that can overwrite a file in the sandbox.
64
+ const NOT_READ = new Set(["tenki_get_upload_url"]);
65
+ // Pure reads that don't match the READ prefix. tenki_auth_status only inspects
66
+ // the ambient credential + probes WhoAmI, so it must stay available under
67
+ // TENKI_MCP_READONLY (it is how an operator diagnoses a credential problem in
68
+ // that posture). git diff/log only read the repository.
69
+ const READ_OVERRIDE = new Set(["tenki_auth_status", "tenki_git_diff", "tenki_git_log"]);
70
+ // Non-read tools that only add. Everything else that is not a read is
71
+ // destructive — see the class comment above for why that is the default.
72
+ const ADDITIVE = new Set([
73
+ "tenki_run_code", // boots, uses and tears down its own throwaway sandbox
74
+ "tenki_create_sandbox",
75
+ "tenki_fork_sandbox", // the source keeps running; children are new sandboxes
76
+ "tenki_resume_sandbox",
77
+ "tenki_extend_sandbox",
78
+ "tenki_report_sandbox_activity", // keep-alive
79
+ "tenki_make_dir",
80
+ "tenki_git_clone", // git refuses a non-empty target directory
81
+ "tenki_expose_port",
82
+ "tenki_create_preview_url",
83
+ "tenki_touch_preview", // keep-alive
84
+ "tenki_create_snapshot",
85
+ "tenki_create_volume",
86
+ "tenki_attach_volume",
87
+ "tenki_create_egress_policy",
88
+ "tenki_create_template",
89
+ // Not tenki_build_template: a build with image_name publishes a new latest
90
+ // version, which changes what an existing `workspace/name` reference boots.
91
+ "tenki_issue_ssh_cert",
92
+ ]);
93
+ export function classifyTool(name) {
94
+ if (DESTRUCTIVE.test(name))
95
+ return "destructive";
96
+ if (!NOT_READ.has(name) && (READ_OVERRIDE.has(name) || READ.test(name)))
97
+ return "read";
98
+ if (ADDITIVE.has(name))
99
+ return "additive";
100
+ return "destructive";
101
+ }
102
+ /** Title for a tool: the TOOL_TITLES entry, else one derived from the name so
103
+ * a tool is never advertised without a title (the offline suite fails on a
104
+ * missing entry, so the fallback only guards production). */
105
+ export function titleFor(name) {
106
+ return (TOOL_TITLES[name] ??
107
+ name
108
+ .replace(/^tenki_/, "")
109
+ .split("_")
110
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
111
+ .join(" "));
112
+ }
113
+ function readGuardOpts() {
114
+ const truthy = (v) => v === "1" || (v ?? "").toLowerCase() === "true";
115
+ const disabled = new Set((readEnv("MCP_DISABLED_TOOLS") || "")
116
+ .split(",")
117
+ .map((s) => s.trim())
118
+ .filter(Boolean));
119
+ return { readonly: truthy(readEnv("MCP_READONLY")), disabled, audit: truthy(readEnv("MCP_AUDIT")) };
120
+ }
121
+ /** Log a tool call's name + argument KEYS (never values/content/token) to stderr. */
122
+ function auditKeys(args) {
123
+ if (!args || typeof args !== "object")
124
+ return "";
125
+ const keys = Object.keys(args);
126
+ return keys.length ? ` args=[${keys.join(",")}]` : "";
127
+ }
128
+ /** MCP annotations derived from a tool's classification. */
129
+ function annotationsFor(name, cls) {
130
+ return {
131
+ title: titleFor(name),
132
+ readOnlyHint: cls === "read",
133
+ destructiveHint: cls === "destructive",
134
+ idempotentHint: cls === "read",
135
+ openWorldHint: true, // every tool reaches the external Tenki API
136
+ };
137
+ }
138
+ /** Wrap a handler so TENKI_MCP_AUDIT=1 logs the call name + argument keys. */
139
+ function withAudit(name, handler, audit) {
140
+ if (!audit)
141
+ return handler;
142
+ return async (args, extra) => {
143
+ try {
144
+ // A tool registered WITHOUT an input schema is invoked as (extra) —
145
+ // one argument — so the first param would be the request context, not
146
+ // tool args. Log arg keys only for the two-argument (args, extra) shape.
147
+ console.error(`[tenki-mcp audit] ${name}${extra === undefined ? "" : auditKeys(args)}`);
148
+ }
149
+ catch {
150
+ /* never let logging break a call */
151
+ }
152
+ return handler(args, extra);
153
+ };
154
+ }
155
+ /**
156
+ * Handle returned for a tool the guard skipped (denylist / read-only posture).
157
+ * The real registration APIs return a RegisteredTool handle; a module that
158
+ * calls .enable()/.remove() on its registration must not crash only in
159
+ * read-only or denylist mode, so skipped registrations get an inert stand-in.
160
+ */
161
+ function noopToolHandle() {
162
+ return {
163
+ enabled: false,
164
+ enable() { },
165
+ disable() { },
166
+ update() { },
167
+ remove() { },
168
+ };
169
+ }
170
+ /**
171
+ * Wrap a server so every tool registration from a module is titled, annotated
172
+ * and subject to the least-privilege env controls above. Both registration APIs
173
+ * are guarded: the legacy `.tool(name, description, schema, handler)` form and
174
+ * the modern `.registerTool(name, config, handler)` form (the only one that
175
+ * accepts an outputSchema) — so neither path can bypass titles, annotations,
176
+ * read-only mode, or the denylist. Legacy calls are re-issued as registerTool,
177
+ * because the SDK's tool() overloads have no title parameter (MCP `Tool.title`
178
+ * would be undefined). Other access passes through to the real server unchanged.
179
+ */
180
+ function guard(server, opts) {
181
+ const register = (target, name, config, handler) => {
182
+ const cls = classifyTool(name);
183
+ if (opts.disabled.has(name))
184
+ return noopToolHandle(); // explicit denylist
185
+ if (opts.readonly && cls !== "read")
186
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
187
+ if (opts.registered)
188
+ opts.registered.count++;
189
+ // Name-derived classification and title stay authoritative so a module
190
+ // cannot soften the hints; other annotation fields pass through.
191
+ const annotations = {
192
+ ...config.annotations,
193
+ ...annotationsFor(name, cls),
194
+ };
195
+ return target.registerTool(name, { ...config, title: titleFor(name), annotations }, withAudit(name, handler, opts.audit));
196
+ };
197
+ return new Proxy(server, {
198
+ get(target, prop, receiver) {
199
+ if (prop === "tool") {
200
+ // Every module uses the four-argument form; a schema-less tool passes {}.
201
+ return (name, description, schema, handler) => register(target, name, { description, inputSchema: schema }, handler);
202
+ }
203
+ if (prop === "registerTool") {
204
+ return (name, config, handler) => register(target, name, config, handler);
205
+ }
206
+ return Reflect.get(target, prop, receiver);
207
+ },
208
+ });
209
+ }
210
+ /**
211
+ * Build a fresh MCP server instance with all tools registered against `client`.
212
+ *
213
+ * `client` is null when no credential was supplied. Rather than refusing to
214
+ * start — which MCP clients report as an opaque "server failed to start" —
215
+ * the server boots with ONLY tenki_auth_status registered, so an agent can
216
+ * discover and explain the missing credential. Registering the other tools in
217
+ * that state would offer 82 tools that can only fail.
218
+ */
219
+ export function createServer(client) {
220
+ const server = new McpServer({ name: "tenki", version: VERSION });
221
+ const opts = { ...readGuardOpts(), registered: { count: 0 } };
222
+ const guarded = guard(server, opts);
223
+ if (client)
224
+ for (const register of modules)
225
+ register(guarded, client);
226
+ // Registered last, through the same guard as everything else, so its
227
+ // toolsRegistered figure counts the tools above it (+1 for itself).
228
+ // READ_OVERRIDE keeps it available under TENKI_MCP_READONLY;
229
+ // TENKI_MCP_DISABLED_TOOLS can still drop it.
230
+ registerAuthStatus(guarded, client, opts.registered.count + 1);
231
+ if (!client)
232
+ console.error("tenki-mcp: no credential — only tenki_auth_status registered. Set TENKI_API_KEY or TENKI_AUTH_TOKEN and restart.");
233
+ if (opts.readonly)
234
+ console.error("tenki-mcp: TENKI_MCP_READONLY — only read-only tools registered.");
235
+ else if (opts.disabled.size)
236
+ console.error(`tenki-mcp: disabled tools — ${[...opts.disabled].join(", ")}`);
237
+ return server;
238
+ }
@@ -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 is { session_id, path, content_type }.
11
+ * GetArtifactDownloadUrlRequest is { artifact_id } ONLY — there is no session_id
12
+ * field (the API discards unknown fields silently), so the tool no longer requires one.
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) an artifact by its artifact id (e.g. the artifactId returned by tenki_get_upload_url, or a template build's buildLogArtifactId). The API supports download-by-artifact-id only, not download-by-path.", {
14
+ artifact_id: z.string().describe("Artifact id (UUID) to download."),
15
+ session_id: sessionIdSchema
16
+ .optional()
17
+ .describe("Ignored — kept for backwards compatibility; the API addresses artifacts by id alone."),
18
+ }, async ({ artifact_id }) => ok(await client.control("GetArtifactDownloadUrl", { 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?: "LUXORAI_AUTH_TOKEN" | "LUXORAI_API_KEY" | "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 (`lx_`/`tk_` → Bearer API key,
26
+ * `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
27
+ * index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, 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,108 @@
1
+ import { z } from "zod";
2
+ import { isApiKey, readEnv, resolvedEnvName } from "../env.js";
3
+ /**
4
+ * Classify the ambient credential WITHOUT returning any of its material.
5
+ * Mirrors the header selection in client.ts (`lx_`/`tk_` → Bearer API key,
6
+ * `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
7
+ * index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, TENKI_API_KEY).
8
+ */
9
+ export function describeCredential(env = process.env) {
10
+ const source = resolvedEnvName(env, "AUTH_TOKEN", "API_KEY");
11
+ const raw = source ? env[source].trim() : "";
12
+ if (!raw && resolvedEnvName(env, "MCP_OAUTH_ISSUER"))
13
+ return { kind: "hosted_oauth" };
14
+ if (!raw)
15
+ return { kind: "none" };
16
+ if (isApiKey(raw))
17
+ return { kind: "api_key", source };
18
+ if (raw.startsWith("ory_st_"))
19
+ return { kind: "oauth_session_token", source };
20
+ return { kind: "session_cookie", source };
21
+ }
22
+ const CREDENTIAL_HELP = {
23
+ 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.",
24
+ api_key: "Authenticated with a tk_… API key (Authorization: Bearer).",
25
+ 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.",
26
+ 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.",
27
+ hosted_oauth: "Authenticated through the hosted OAuth session for this MCP connection.",
28
+ };
29
+ const authOutputSchema = {
30
+ authenticated: z.boolean().describe("True only when a credential is present AND a live identity probe succeeded."),
31
+ credential: z
32
+ .enum(["none", "api_key", "oauth_session_token", "session_cookie", "hosted_oauth"])
33
+ .describe("Kind of credential the server is running with. Never includes the token itself."),
34
+ source: z
35
+ .string()
36
+ .optional()
37
+ .describe("Environment variable the credential came from (TENKI_AUTH_TOKEN takes precedence over TENKI_API_KEY)."),
38
+ endpoint: z.string().describe("Control-plane base URL the server is pointed at."),
39
+ toolsRegistered: z
40
+ .number()
41
+ .int()
42
+ .describe("How many tools this server registered. Without a credential only this one is registered."),
43
+ identity: z
44
+ .object({
45
+ ownerType: z.string().optional(),
46
+ ownerId: z.string().optional(),
47
+ workspaces: z.number().int().optional(),
48
+ })
49
+ .optional()
50
+ .describe("Identity returned by the live probe, when it succeeded."),
51
+ error: z.string().optional().describe("Why the probe failed, when a credential is present but unusable (expired, revoked, wrong endpoint)."),
52
+ detail: z.string().describe("Human-readable status plus, when unauthenticated, how to supply a credential."),
53
+ };
54
+ /**
55
+ * Report authentication status. `client` is null when the server booted without
56
+ * a credential; `toolsRegistered` lets the caller see it is in that degraded
57
+ * single-tool mode rather than guessing from a short tools/list.
58
+ */
59
+ export function registerAuthStatus(server, client, toolsRegistered) {
60
+ server.registerTool("tenki_auth_status", {
61
+ 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.",
62
+ inputSchema: {},
63
+ outputSchema: authOutputSchema,
64
+ }, async () => {
65
+ const cred = describeCredential();
66
+ const endpoint = readEnv("API_ENDPOINT", "API_URL") || "https://api.tenki.cloud";
67
+ const base = {
68
+ credential: cred.kind,
69
+ ...(cred.source ? { source: cred.source } : {}),
70
+ endpoint,
71
+ toolsRegistered,
72
+ };
73
+ if (!client || cred.kind === "none") {
74
+ const result = {
75
+ ...base,
76
+ authenticated: false,
77
+ detail: CREDENTIAL_HELP.none,
78
+ };
79
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
80
+ }
81
+ try {
82
+ const resp = await client.control("WhoAmI", {});
83
+ const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces.length : undefined;
84
+ const result = {
85
+ ...base,
86
+ authenticated: true,
87
+ identity: {
88
+ ...(typeof resp.ownerType === "string" ? { ownerType: resp.ownerType } : {}),
89
+ ...(typeof resp.ownerId === "string" ? { ownerId: resp.ownerId } : {}),
90
+ ...(workspaces !== undefined ? { workspaces } : {}),
91
+ },
92
+ detail: CREDENTIAL_HELP[cred.kind],
93
+ };
94
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
95
+ }
96
+ catch (e) {
97
+ const result = {
98
+ ...base,
99
+ authenticated: false,
100
+ error: e.message,
101
+ detail: cred.kind === "hosted_oauth"
102
+ ? "The hosted OAuth session is present, but the identity probe failed. Run MCP login again."
103
+ : `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}`,
104
+ };
105
+ return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,70 @@
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
+ /** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
12
+ export declare function utf8CharStart(buf: Buffer, i: number): number;
13
+ /** Largest end ≤ limit that does not split a character; always > start when limit > start. */
14
+ export declare function utf8CharEnd(buf: Buffer, start: number, limit: number): number;
15
+ /**
16
+ * Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
17
+ * text unchanged when it fits; otherwise the head plus the original size.
18
+ */
19
+ export declare function capText(text: string, maxBytes: number): {
20
+ text: string;
21
+ truncated: boolean;
22
+ totalBytes: number;
23
+ };
24
+ /** Shared env-map schema used by tools that accept environment variables. */
25
+ export declare const envSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
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 declare const sessionIdSchema: z.ZodString;
32
+ /** Shared TCP-port schema. */
33
+ export declare const portSchema: z.ZodNumber;
34
+ /** Shared sandbox-path schema — rejects an empty or whitespace-only path
35
+ * client-side instead of by a server error, WITHOUT transforming the value:
36
+ * leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
37
+ * (a transform, not a check) would silently retarget the operation to a
38
+ * different file. Call sites override the description with their own examples. */
39
+ export declare const pathSchema: z.ZodString;
40
+ /**
41
+ * Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
42
+ * lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
43
+ * slug through the SAME preview validation (exposePersistentPreviewURL), so
44
+ * this applies to tenki_expose_port too. The server additionally rejects
45
+ * consecutive hyphens, reserved names, and workspace-length overflows —
46
+ * those stay server-side.
47
+ */
48
+ export declare const slugSchema: z.ZodString;
49
+ /**
50
+ * Shared tag-list schema, matching the API's repeated-string constraint on
51
+ * sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
52
+ * [a-z0-9_:.-] starting with a letter or digit.
53
+ */
54
+ export declare const tagsSchema: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ /**
56
+ * Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
57
+ * volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
58
+ * a non-empty list together with clear_tags is rejected because the server
59
+ * applies clear_tags first and would silently discard the list.
60
+ */
61
+ export declare function tagsPatch(tags?: string[], clearTags?: boolean): {
62
+ tags?: string[];
63
+ clearTags?: true;
64
+ };
65
+ /** Free-text search across a list (server-side; API max 256 chars). */
66
+ export declare const searchSchema: z.ZodOptional<z.ZodString>;
67
+ /** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
68
+ export declare const protoEnum: (prefix: string, value: string) => string;
69
+ /** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
70
+ export declare const listQuery: (search?: string, includeFacets?: boolean, sortDesc?: boolean) => Record<string, unknown>;
@@ -0,0 +1,138 @@
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
+ registryImageName: "publishedImageName",
12
+ registry_image_name: "published_image_name",
13
+ };
14
+ /** Keep registry-backed implementation fields out of public MCP responses. */
15
+ export function publicValue(value) {
16
+ if (Array.isArray(value))
17
+ return value.map(publicValue);
18
+ if (!value || typeof value !== "object")
19
+ return value;
20
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [PUBLIC_KEYS[key] ?? key, publicValue(child)]));
21
+ }
22
+ /** Serialize any tool return value as MCP text content. */
23
+ export const ok = (value) => ({
24
+ content: [{ type: "text", text: JSON.stringify(publicValue(value), null, 2) }],
25
+ });
26
+ // UTF-8 boundary handling for byte-capped text. A position is "inside a
27
+ // character" only when a lead byte at most 3 bytes before it declares a
28
+ // sequence long enough to cover it. Stray continuation bytes (binary data,
29
+ // invalid UTF-8) are not inside anything, so a cut lands exactly there. Start
30
+ // and end use the same rule, so consecutive pages tile the file with no gap or
31
+ // overlap, on valid UTF-8 and on binary alike.
32
+ const isContinuation = (b) => (b & 0xc0) === 0x80;
33
+ /** Sequence length a lead byte declares (1 for ASCII and invalid leads). */
34
+ const utf8SeqLen = (b) => (b >= 0xf0 && b <= 0xf7 ? 4 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xc0 && b <= 0xdf ? 2 : 1);
35
+ /** Start of the character covering i, or -1 when i is a character start or a stray byte. */
36
+ function coveringLead(buf, i, floor) {
37
+ if (i >= buf.length || !isContinuation(buf[i]))
38
+ return -1;
39
+ for (let back = 1; back <= 3 && i - back >= floor; back++) {
40
+ const b = buf[i - back];
41
+ if (!isContinuation(b))
42
+ return utf8SeqLen(b) > back ? i - back : -1;
43
+ }
44
+ return -1;
45
+ }
46
+ /** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
47
+ export function utf8CharStart(buf, i) {
48
+ const lead = coveringLead(buf, i, 0);
49
+ return lead < 0 ? i : Math.min(lead + utf8SeqLen(buf[lead]), buf.length);
50
+ }
51
+ /** Largest end ≤ limit that does not split a character; always > start when limit > start. */
52
+ export function utf8CharEnd(buf, start, limit) {
53
+ if (limit >= buf.length)
54
+ return buf.length;
55
+ const lead = coveringLead(buf, limit, start + 1);
56
+ return lead < 0 ? limit : lead;
57
+ }
58
+ /**
59
+ * Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
60
+ * text unchanged when it fits; otherwise the head plus the original size.
61
+ */
62
+ export function capText(text, maxBytes) {
63
+ const buf = Buffer.from(text, "utf8");
64
+ if (buf.length <= maxBytes)
65
+ return { text, truncated: false, totalBytes: buf.length };
66
+ return { text: buf.subarray(0, utf8CharEnd(buf, 0, maxBytes)).toString("utf8"), truncated: true, totalBytes: buf.length };
67
+ }
68
+ /** Shared env-map schema used by tools that accept environment variables. */
69
+ export const envSchema = z.record(z.string(), z.string()).optional().describe("Environment variables as a key→value object.");
70
+ /** Shared session-id schema — every per-sandbox tool takes one of these.
71
+ * Trimmed, unlike pathSchema below: a session id is a UUID, so surrounding
72
+ * whitespace is always an accident (and the API's uuid validation would
73
+ * reject it), which makes trimming a safe correction rather than the silent
74
+ * retargeting it would be for a filename. */
75
+ export const sessionIdSchema = z
76
+ .string()
77
+ .trim()
78
+ .min(1)
79
+ .describe("Sandbox session id (UUID), from tenki_create_sandbox or tenki_list_sandboxes.");
80
+ /** Shared TCP-port schema. */
81
+ export const portSchema = z.number().int().min(1).max(65535).describe("TCP port inside the sandbox (1-65535).");
82
+ /** Shared sandbox-path schema — rejects an empty or whitespace-only path
83
+ * client-side instead of by a server error, WITHOUT transforming the value:
84
+ * leading/trailing whitespace is legal in POSIX filenames, and zod's .trim()
85
+ * (a transform, not a check) would silently retarget the operation to a
86
+ * different file. Call sites override the description with their own examples. */
87
+ export const pathSchema = z
88
+ .string()
89
+ .refine((s) => s.trim().length > 0, "path must not be empty or whitespace-only")
90
+ .describe("Absolute path inside the sandbox, under /home/tenki.");
91
+ /**
92
+ * Preview-slug schema, matching the server's validatePreviewSlug: 3-63 chars,
93
+ * lowercase/digits/hyphens, no leading/trailing hyphen. ExposePort routes a
94
+ * slug through the SAME preview validation (exposePersistentPreviewURL), so
95
+ * this applies to tenki_expose_port too. The server additionally rejects
96
+ * consecutive hyphens, reserved names, and workspace-length overflows —
97
+ * those stay server-side.
98
+ */
99
+ export const slugSchema = z
100
+ .string()
101
+ .min(3)
102
+ .max(63)
103
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits, and hyphens; cannot start or end with a hyphen")
104
+ .describe("Subdomain slug for the preview URL (3-63 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen).");
105
+ /**
106
+ * Shared tag-list schema, matching the API's repeated-string constraint on
107
+ * sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
108
+ * [a-z0-9_:.-] starting with a letter or digit.
109
+ */
110
+ export const tagsSchema = z
111
+ .array(z.string().max(32).regex(/^[a-z0-9][a-z0-9_:.-]*$/, "lowercase letters, digits, and _:.- ; must start with a letter or digit"))
112
+ .max(20)
113
+ .optional()
114
+ .describe("Tags (≤20, each ≤32 chars of a-z 0-9 _ : . -).");
115
+ /**
116
+ * Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
117
+ * volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
118
+ * a non-empty list together with clear_tags is rejected because the server
119
+ * applies clear_tags first and would silently discard the list.
120
+ */
121
+ export function tagsPatch(tags, clearTags) {
122
+ if (clearTags && tags && tags.length) {
123
+ throw new Error("pass either tags (a replacement list) or clear_tags, not both — clear_tags would win and the tags would be silently dropped.");
124
+ }
125
+ if (clearTags || (tags !== undefined && tags.length === 0))
126
+ return { clearTags: true };
127
+ return tags && tags.length ? { tags } : {};
128
+ }
129
+ /** Free-text search across a list (server-side; API max 256 chars). */
130
+ export const searchSchema = z.string().max(256).optional();
131
+ /** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
132
+ export const protoEnum = (prefix, value) => `${prefix}_${value}`;
133
+ /** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
134
+ export const listQuery = (search, includeFacets, sortDesc) => ({
135
+ ...(search && search.trim() ? { search: search.trim() } : {}),
136
+ ...(includeFacets ? { includeFacets: true } : {}),
137
+ ...(sortDesc ? { sortDesc: true } : {}),
138
+ });