@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +152 -0
  6. package/dist/client.js +499 -0
  7. package/dist/http.d.ts +19 -0
  8. package/dist/http.js +234 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +59 -0
  11. package/dist/server.d.ts +30 -0
  12. package/dist/server.js +205 -0
  13. package/dist/tools/artifacts.d.ts +16 -0
  14. package/dist/tools/artifacts.js +19 -0
  15. package/dist/tools/auth_status.d.ts +35 -0
  16. package/dist/tools/auth_status.js +104 -0
  17. package/dist/tools/common.d.ts +33 -0
  18. package/dist/tools/common.js +42 -0
  19. package/dist/tools/exec.d.ts +4 -0
  20. package/dist/tools/exec.js +88 -0
  21. package/dist/tools/files.d.ts +4 -0
  22. package/dist/tools/files.js +22 -0
  23. package/dist/tools/files_ops.d.ts +12 -0
  24. package/dist/tools/files_ops.js +54 -0
  25. package/dist/tools/git.d.ts +4 -0
  26. package/dist/tools/git.js +30 -0
  27. package/dist/tools/identity.d.ts +4 -0
  28. package/dist/tools/identity.js +5 -0
  29. package/dist/tools/ports.d.ts +4 -0
  30. package/dist/tools/ports.js +6 -0
  31. package/dist/tools/previews.d.ts +18 -0
  32. package/dist/tools/previews.js +102 -0
  33. package/dist/tools/registry.d.ts +18 -0
  34. package/dist/tools/registry.js +98 -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 +66 -0
  39. package/dist/tools/sessions_admin.d.ts +9 -0
  40. package/dist/tools/sessions_admin.js +76 -0
  41. package/dist/tools/snapshots.d.ts +11 -0
  42. package/dist/tools/snapshots.js +91 -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 +151 -0
  47. package/dist/tools/volumes.d.ts +16 -0
  48. package/dist/tools/volumes.js +94 -0
  49. package/dist/tools/workspace.d.ts +4 -0
  50. package/dist/tools/workspace.js +129 -0
  51. package/package.json +61 -0
package/dist/http.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * HTTP/SSE transport for tenki-mcp (v2.0) — makes the server hostable, not just
3
+ * local-stdio. Uses the MCP SDK's StreamableHTTPServerTransport with a stateful
4
+ * per-session model: one server + transport per MCP session.
5
+ *
6
+ * Enable with TENKI_MCP_TRANSPORT=http. Config:
7
+ * PORT — listen port (default 3000)
8
+ * TENKI_MCP_HTTP_HOST — bind host (default 127.0.0.1, loopback-only)
9
+ * TENKI_MCP_HTTP_TOKEN — required Bearer token for the /mcp endpoint
10
+ *
11
+ * Security posture (the process holds one shared TENKI_API_KEY and exposes all
12
+ * tools, incl. arbitrary code execution + credit spend, so the endpoint is a
13
+ * capability): loopback-only by default; DNS-rebinding protection on (Host
14
+ * allowlist); optional bearer auth; and it REFUSES to bind to a non-loopback
15
+ * host without a token set. Per-session/global DoS caps are applied.
16
+ */
17
+ import http from "node:http";
18
+ import { randomUUID, timingSafeEqual } from "node:crypto";
19
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
21
+ import { createServer } from "./server.js";
22
+ const MAX_BODY_BYTES = 1 << 20; // 1 MiB — reject larger POST bodies (memory-DoS guard)
23
+ const MAX_SESSIONS = 256; // cap concurrent sessions (init-flood DoS guard)
24
+ const SESSION_IDLE_MS = 30 * 60 * 1000; // reap sessions idle longer than this
25
+ class BodyTooLarge extends Error {
26
+ constructor() {
27
+ super(`Request body exceeds the ${MAX_BODY_BYTES}-byte limit.`);
28
+ this.name = "BodyTooLarge";
29
+ }
30
+ }
31
+ class BadJson extends Error {
32
+ }
33
+ /**
34
+ * Read a JSON request body with a hard size cap (never throws un-typed).
35
+ * Rejects early on an oversized Content-Length, and independently enforces the
36
+ * cap while streaming, so a lying or absent Content-Length can't slip past the
37
+ * byte counter. Listeners are always removed on the first settle.
38
+ */
39
+ function readJson(req) {
40
+ return new Promise((resolve, reject) => {
41
+ const declared = Number(req.headers["content-length"]);
42
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
43
+ req.resume(); // drain so the socket can close cleanly
44
+ reject(new BodyTooLarge());
45
+ return;
46
+ }
47
+ let size = 0;
48
+ const chunks = [];
49
+ let settled = false;
50
+ const cleanup = () => {
51
+ req.off("data", onData);
52
+ req.off("end", onEnd);
53
+ req.off("error", onError);
54
+ };
55
+ const onData = (c) => {
56
+ if (settled)
57
+ return;
58
+ size += c.length;
59
+ if (size > MAX_BODY_BYTES) {
60
+ settled = true;
61
+ cleanup();
62
+ req.resume();
63
+ reject(new BodyTooLarge());
64
+ return;
65
+ }
66
+ chunks.push(c);
67
+ };
68
+ const onEnd = () => {
69
+ if (settled)
70
+ return;
71
+ settled = true;
72
+ cleanup();
73
+ const s = Buffer.concat(chunks).toString("utf8");
74
+ if (!s)
75
+ return resolve(undefined);
76
+ try {
77
+ resolve(JSON.parse(s));
78
+ }
79
+ catch {
80
+ reject(new BadJson());
81
+ }
82
+ };
83
+ const onError = (error) => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ cleanup();
88
+ reject(error);
89
+ };
90
+ req.on("data", onData);
91
+ req.on("end", onEnd);
92
+ req.on("error", onError);
93
+ });
94
+ }
95
+ /** Constant-time bearer-token check. No token configured → gate is open (loopback-only enforced at bind). */
96
+ function authOk(header, expected) {
97
+ if (!expected)
98
+ return true;
99
+ const m = /^Bearer (.+)$/.exec(header ?? "");
100
+ if (!m)
101
+ return false;
102
+ const a = Buffer.from(m[1]);
103
+ const b = Buffer.from(expected);
104
+ return a.length === b.length && timingSafeEqual(a, b);
105
+ }
106
+ /**
107
+ * Host:port values the DNS-rebinding guard accepts: the configured host plus the
108
+ * loopback aliases, at BOTH the configured port and the port actually bound (so
109
+ * an ephemeral `port: 0` bind still accepts its own address). Anything else —
110
+ * e.g. a rebound attacker domain — is rejected by the transport.
111
+ */
112
+ function allowedHostsFor(server, host, port) {
113
+ const addr = server.address();
114
+ const bound = addr && typeof addr === "object" ? addr.port : port;
115
+ const ports = Array.from(new Set([port, bound]));
116
+ const hosts = Array.from(new Set([host, "127.0.0.1", "localhost", "[::1]", "::1"]));
117
+ return hosts.flatMap((h) => ports.map((p) => `${h}:${p}`));
118
+ }
119
+ export function startHttp(client, port) {
120
+ const host = process.env.TENKI_MCP_HTTP_HOST || "127.0.0.1";
121
+ const httpToken = process.env.TENKI_MCP_HTTP_TOKEN || "";
122
+ const isLoopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
123
+ // Refuse to expose an unauthenticated capability to the network.
124
+ if (!isLoopback && !httpToken) {
125
+ console.error("tenki-mcp: refusing to bind HTTP to a non-loopback host without TENKI_MCP_HTTP_TOKEN " +
126
+ "(the /mcp endpoint would be unauthenticated and can spend credits / run code). " +
127
+ "Set TENKI_MCP_HTTP_TOKEN, or bind to 127.0.0.1.");
128
+ process.exit(1);
129
+ }
130
+ const sessions = new Map();
131
+ const sweep = setInterval(() => {
132
+ const now = Date.now();
133
+ for (const [id, s] of sessions) {
134
+ if (now - s.lastSeen > SESSION_IDLE_MS) {
135
+ try {
136
+ s.transport.close();
137
+ }
138
+ catch {
139
+ /* ignore */
140
+ }
141
+ sessions.delete(id);
142
+ }
143
+ }
144
+ }, 60_000);
145
+ sweep.unref?.();
146
+ const httpServer = http.createServer(async (req, res) => {
147
+ try {
148
+ if (!authOk(req.headers["authorization"], httpToken)) {
149
+ res.writeHead(401, { "Content-Type": "text/plain" }).end("unauthorized");
150
+ return;
151
+ }
152
+ const url = new URL(req.url || "/", `http://${host}`);
153
+ if (url.pathname !== "/mcp") {
154
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("not found — MCP endpoint is /mcp");
155
+ return;
156
+ }
157
+ const sid = req.headers["mcp-session-id"];
158
+ if (req.method === "POST") {
159
+ let body;
160
+ try {
161
+ body = await readJson(req);
162
+ }
163
+ catch (e) {
164
+ if (e instanceof BodyTooLarge) {
165
+ res
166
+ .writeHead(413, { "Content-Type": "application/json", Connection: "close" })
167
+ .end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: e.message }, id: null }));
168
+ return;
169
+ }
170
+ res
171
+ .writeHead(400, { "Content-Type": "application/json" })
172
+ .end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
173
+ return;
174
+ }
175
+ let entry = sid ? sessions.get(sid) : undefined;
176
+ if (!entry && isInitializeRequest(body)) {
177
+ if (sessions.size >= MAX_SESSIONS) {
178
+ res.writeHead(503, { "Content-Type": "text/plain" }).end("too many sessions");
179
+ return;
180
+ }
181
+ const transport = new StreamableHTTPServerTransport({
182
+ sessionIdGenerator: () => randomUUID(),
183
+ // DNS-rebinding defense: only accept these Host headers, so a rebound
184
+ // attacker-domain request from a browser is rejected.
185
+ enableDnsRebindingProtection: true,
186
+ allowedHosts: allowedHostsFor(httpServer, host, port),
187
+ onsessioninitialized: (id) => {
188
+ sessions.set(id, { transport, lastSeen: Date.now() });
189
+ },
190
+ });
191
+ transport.onclose = () => {
192
+ const id = transport.sessionId;
193
+ if (id)
194
+ sessions.delete(id);
195
+ };
196
+ await createServer(client).connect(transport);
197
+ entry = { transport, lastSeen: Date.now() };
198
+ }
199
+ if (!entry) {
200
+ res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "No valid session; send an initialize request first." }, id: null }));
201
+ return;
202
+ }
203
+ entry.lastSeen = Date.now();
204
+ await entry.transport.handleRequest(req, res, body);
205
+ return;
206
+ }
207
+ // GET opens the SSE stream; DELETE ends a session.
208
+ if (req.method === "GET" || req.method === "DELETE") {
209
+ const entry = sid ? sessions.get(sid) : undefined;
210
+ if (!entry) {
211
+ res.writeHead(400, { "Content-Type": "text/plain" }).end("No session for the given mcp-session-id.");
212
+ return;
213
+ }
214
+ entry.lastSeen = Date.now();
215
+ await entry.transport.handleRequest(req, res);
216
+ return;
217
+ }
218
+ res.writeHead(405, { "Content-Type": "text/plain" }).end("method not allowed");
219
+ }
220
+ catch (e) {
221
+ // Never echo internals to the client; log server-side only.
222
+ if (!res.headersSent)
223
+ res.writeHead(500, { "Content-Type": "text/plain" });
224
+ res.end("internal error");
225
+ console.error("tenki-mcp http error:", e.message);
226
+ }
227
+ });
228
+ httpServer.on("close", () => clearInterval(sweep));
229
+ httpServer.listen(port, host, () => {
230
+ console.error(`tenki-mcp running on http://${host}:${port}/mcp (Streamable HTTP)` +
231
+ (httpToken ? " [bearer auth required]" : " [loopback only, no auth]"));
232
+ });
233
+ return httpServer;
234
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tenki-mcp — a Model Context Protocol server for Tenki Cloud.
4
+ *
5
+ * Exposes Tenki's sandbox platform (disposable microVMs for AI agents) as MCP
6
+ * tools, so any agent — Claude, Codex, Cursor — can create sandboxes, run code,
7
+ * manage files/snapshots/volumes/templates/images, run git, and expose preview URLs.
8
+ *
9
+ * Transports:
10
+ * - stdio (default) — for local MCP clients (Claude Desktop, Cursor, Claude Code).
11
+ * - HTTP/SSE — set TENKI_MCP_TRANSPORT=http (+ PORT, default 3000) to host it.
12
+ *
13
+ * Tools live in self-registering modules under ./tools; the server factory is in
14
+ * ./server.ts. Auth: set TENKI_API_KEY (or TENKI_AUTH_TOKEN) in the environment.
15
+ */
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { TenkiClient } from "./client.js";
18
+ import { createServer } from "./server.js";
19
+ import { startHttp } from "./http.js";
20
+ // A missing credential is NOT fatal: exiting here is reported by MCP clients as
21
+ // an opaque "server failed to start" with stderr usually swallowed, leaving the
22
+ // user with no idea a token is needed. Instead the server boots with only
23
+ // tenki_auth_status registered (see createServer), so an agent can ask what is
24
+ // wrong and relay the fix.
25
+ const token = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY;
26
+ if (!token) {
27
+ console.error("tenki-mcp: no credential — starting in unauthenticated mode (only tenki_auth_status is available). " +
28
+ "Set TENKI_API_KEY (tk_…) or TENKI_AUTH_TOKEN (ory_st_…) in the server's env and restart, " +
29
+ "e.g. claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y tenki-mcp");
30
+ }
31
+ const baseUrl = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined;
32
+ /** Positive integer from env, or undefined so the client keeps its own default. */
33
+ const envMs = (name) => {
34
+ const n = Number.parseInt(process.env[name] ?? "", 10);
35
+ return Number.isFinite(n) && n > 0 ? n : undefined;
36
+ };
37
+ const client = token
38
+ ? new TenkiClient(token, baseUrl, {
39
+ timeoutMs: envMs("TENKI_MCP_TIMEOUT_MS"),
40
+ slowTimeoutMs: envMs("TENKI_MCP_SLOW_TIMEOUT_MS"),
41
+ })
42
+ : null;
43
+ async function main() {
44
+ if ((process.env.TENKI_MCP_TRANSPORT || "stdio").toLowerCase() === "http") {
45
+ const httpServer = startHttp(client, Number(process.env.PORT) || 3000);
46
+ for (const sig of ["SIGINT", "SIGTERM"]) {
47
+ process.on(sig, () => httpServer.close(() => process.exit(0)));
48
+ }
49
+ return;
50
+ }
51
+ const server = createServer(client);
52
+ const transport = new StdioServerTransport();
53
+ await server.connect(transport);
54
+ console.error("tenki-mcp running on stdio");
55
+ }
56
+ main().catch((err) => {
57
+ console.error("tenki-mcp fatal:", err);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,30 @@
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 type { TenkiClient } from "./client.js";
17
+ export declare const VERSION = "0.1.0";
18
+ type Cls = "read" | "write" | "destructive";
19
+ export declare function classifyTool(name: string): Cls;
20
+ /**
21
+ * Build a fresh MCP server instance with all tools registered against `client`.
22
+ *
23
+ * `client` is null when no credential was supplied. Rather than refusing to
24
+ * start — which MCP clients report as an opaque "server failed to start" —
25
+ * the server boots with ONLY tenki_auth_status registered, so an agent can
26
+ * discover and explain the missing credential. Registering the other tools in
27
+ * that state would offer 84 tools that can only fail.
28
+ */
29
+ export declare function createServer(client: TenkiClient | null): McpServer;
30
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,205 @@
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 { registerRegistry } from "./tools/registry.js";
30
+ import { registerWorkspace } from "./tools/workspace.js";
31
+ import { registerArtifacts } from "./tools/artifacts.js";
32
+ import { registerSsh } from "./tools/ssh.js";
33
+ import { registerAuthStatus } from "./tools/auth_status.js";
34
+ export const VERSION = "0.1.0";
35
+ const modules = [
36
+ registerIdentity,
37
+ registerRun,
38
+ registerSandboxes,
39
+ registerSessionsAdmin,
40
+ registerExec,
41
+ registerFiles,
42
+ registerFilesOps,
43
+ registerGit,
44
+ registerPorts,
45
+ registerPreviews,
46
+ registerSnapshots,
47
+ registerVolumes,
48
+ registerTemplates,
49
+ registerRegistry,
50
+ registerWorkspace,
51
+ registerArtifacts,
52
+ registerSsh,
53
+ ];
54
+ // Destroys/removes/revokes a resource → destructiveHint.
55
+ const DESTRUCTIVE = /^tenki_(terminate|delete|remove|unshare|revoke|detach|unexpose|unbind)/;
56
+ // Named exceptions that match the READ prefix below but actually grant a
57
+ // write/spend capability, so they must never be treated as read-only.
58
+ // tenki_get_upload_url returns a signed URL for an arbitrary PUT into the sandbox.
59
+ const WRITE_OVERRIDE = new Set(["tenki_get_upload_url"]);
60
+ // Named exceptions that are pure reads but don't match the READ prefix below.
61
+ // tenki_auth_status only inspects the ambient credential + probes WhoAmI, so it
62
+ // must stay available under TENKI_MCP_READONLY (it is how an operator diagnoses
63
+ // a credential problem in that posture).
64
+ const READ_OVERRIDE = new Set(["tenki_auth_status"]);
65
+ // Pure inspection, no state change / no spend → readOnlyHint.
66
+ const READ = /^tenki_(get|list|whoami|resolve|stat|read)/;
67
+ export function classifyTool(name) {
68
+ if (DESTRUCTIVE.test(name))
69
+ return "destructive";
70
+ if (WRITE_OVERRIDE.has(name))
71
+ return "write";
72
+ if (READ_OVERRIDE.has(name) || READ.test(name))
73
+ return "read";
74
+ return "write";
75
+ }
76
+ function readGuardOpts() {
77
+ const truthy = (v) => v === "1" || (v ?? "").toLowerCase() === "true";
78
+ const disabled = new Set((process.env.TENKI_MCP_DISABLED_TOOLS || "")
79
+ .split(",")
80
+ .map((s) => s.trim())
81
+ .filter(Boolean));
82
+ return { readonly: truthy(process.env.TENKI_MCP_READONLY), disabled, audit: truthy(process.env.TENKI_MCP_AUDIT) };
83
+ }
84
+ /** Log a tool call's name + argument KEYS (never values/content/token) to stderr. */
85
+ function auditKeys(args) {
86
+ if (!args || typeof args !== "object")
87
+ return "";
88
+ const keys = Object.keys(args);
89
+ return keys.length ? ` args=[${keys.join(",")}]` : "";
90
+ }
91
+ /** MCP annotations derived from a tool's classification. */
92
+ function annotationsFor(cls) {
93
+ return {
94
+ readOnlyHint: cls === "read",
95
+ destructiveHint: cls === "destructive",
96
+ idempotentHint: cls === "read",
97
+ openWorldHint: true, // every tool reaches the external Tenki API
98
+ };
99
+ }
100
+ /** Wrap a handler so TENKI_MCP_AUDIT=1 logs the call name + argument keys. */
101
+ function withAudit(name, handler, audit) {
102
+ if (!audit)
103
+ return handler;
104
+ return async (args, extra) => {
105
+ try {
106
+ // A tool registered WITHOUT an input schema is invoked as (extra) —
107
+ // one argument — so the first param would be the request context, not
108
+ // tool args. Log arg keys only for the two-argument (args, extra) shape.
109
+ console.error(`[tenki-mcp audit] ${name}${extra === undefined ? "" : auditKeys(args)}`);
110
+ }
111
+ catch {
112
+ /* never let logging break a call */
113
+ }
114
+ return handler(args, extra);
115
+ };
116
+ }
117
+ /**
118
+ * Handle returned for a tool the guard skipped (denylist / read-only posture).
119
+ * The real registration APIs return a RegisteredTool handle; a module that
120
+ * calls .enable()/.remove() on its registration must not crash only in
121
+ * read-only or denylist mode, so skipped registrations get an inert stand-in.
122
+ */
123
+ function noopToolHandle() {
124
+ return {
125
+ enabled: false,
126
+ enable() { },
127
+ disable() { },
128
+ update() { },
129
+ remove() { },
130
+ };
131
+ }
132
+ /**
133
+ * Wrap a server so every tool registration from a module is annotated + subject
134
+ * to the least-privilege env controls above. Both registration APIs are guarded:
135
+ * the legacy `.tool(name, description, schema, handler)` form and the modern
136
+ * `.registerTool(name, config, handler)` form (the only one that accepts an
137
+ * outputSchema) — so neither path can bypass annotations, read-only mode, or
138
+ * the denylist. Other access passes through to the real server unchanged.
139
+ */
140
+ function guard(server, opts) {
141
+ return new Proxy(server, {
142
+ get(target, prop, receiver) {
143
+ if (prop === "tool") {
144
+ return (name, description, schema, handler) => {
145
+ const cls = classifyTool(name);
146
+ if (opts.disabled.has(name))
147
+ return noopToolHandle(); // explicit denylist
148
+ if (opts.readonly && cls !== "read")
149
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
150
+ if (opts.registered)
151
+ opts.registered.count++;
152
+ return target.tool(name, description, schema, annotationsFor(cls), withAudit(name, handler, opts.audit));
153
+ };
154
+ }
155
+ if (prop === "registerTool") {
156
+ return (name, config, handler) => {
157
+ const cls = classifyTool(name);
158
+ if (opts.disabled.has(name))
159
+ return noopToolHandle(); // explicit denylist
160
+ if (opts.readonly && cls !== "read")
161
+ return noopToolHandle(); // read-only posture: skip anything that mutates/spends
162
+ // Name-derived classification stays authoritative for the four hints so
163
+ // a module cannot soften them; other annotation fields pass through.
164
+ if (opts.registered)
165
+ opts.registered.count++;
166
+ const annotations = {
167
+ ...config.annotations,
168
+ ...annotationsFor(cls),
169
+ };
170
+ return target.registerTool(name, { ...config, annotations }, withAudit(name, handler, opts.audit));
171
+ };
172
+ }
173
+ return Reflect.get(target, prop, receiver);
174
+ },
175
+ });
176
+ }
177
+ /**
178
+ * Build a fresh MCP server instance with all tools registered against `client`.
179
+ *
180
+ * `client` is null when no credential was supplied. Rather than refusing to
181
+ * start — which MCP clients report as an opaque "server failed to start" —
182
+ * the server boots with ONLY tenki_auth_status registered, so an agent can
183
+ * discover and explain the missing credential. Registering the other tools in
184
+ * that state would offer 84 tools that can only fail.
185
+ */
186
+ export function createServer(client) {
187
+ const server = new McpServer({ name: "tenki", version: VERSION });
188
+ const opts = { ...readGuardOpts(), registered: { count: 0 } };
189
+ const guarded = guard(server, opts);
190
+ if (client)
191
+ for (const register of modules)
192
+ register(guarded, client);
193
+ // Registered last, through the same guard as everything else, so its
194
+ // toolsRegistered figure counts the tools above it (+1 for itself).
195
+ // READ_OVERRIDE keeps it available under TENKI_MCP_READONLY;
196
+ // TENKI_MCP_DISABLED_TOOLS can still drop it.
197
+ registerAuthStatus(guarded, client, opts.registered.count + 1);
198
+ if (!client)
199
+ console.error("tenki-mcp: no credential — only tenki_auth_status registered. Set TENKI_API_KEY or TENKI_AUTH_TOKEN and restart.");
200
+ if (opts.readonly)
201
+ console.error("tenki-mcp: TENKI_MCP_READONLY — only read-only tools registered.");
202
+ else if (opts.disabled.size)
203
+ console.error(`tenki-mcp: disabled tools — ${[...opts.disabled].join(", ")}`);
204
+ return server;
205
+ }
@@ -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 a credential was supplied, derived from the token's prefix. */
17
+ export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "session_cookie";
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;