@tenkicloud/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +499 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +234 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +59 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +205 -0
- package/dist/tools/artifacts.d.ts +16 -0
- package/dist/tools/artifacts.js +19 -0
- package/dist/tools/auth_status.d.ts +35 -0
- package/dist/tools/auth_status.js +104 -0
- package/dist/tools/common.d.ts +33 -0
- package/dist/tools/common.js +42 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +88 -0
- package/dist/tools/files.d.ts +4 -0
- package/dist/tools/files.js +22 -0
- package/dist/tools/files_ops.d.ts +12 -0
- package/dist/tools/files_ops.js +54 -0
- package/dist/tools/git.d.ts +4 -0
- package/dist/tools/git.js +30 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +5 -0
- package/dist/tools/ports.d.ts +4 -0
- package/dist/tools/ports.js +6 -0
- package/dist/tools/previews.d.ts +18 -0
- package/dist/tools/previews.js +102 -0
- package/dist/tools/registry.d.ts +18 -0
- package/dist/tools/registry.js +98 -0
- package/dist/tools/run.d.ts +4 -0
- package/dist/tools/run.js +11 -0
- package/dist/tools/sandboxes.d.ts +4 -0
- package/dist/tools/sandboxes.js +66 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +76 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +91 -0
- package/dist/tools/ssh.d.ts +15 -0
- package/dist/tools/ssh.js +17 -0
- package/dist/tools/templates.d.ts +14 -0
- package/dist/tools/templates.js +151 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +94 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- package/package.json +61 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
});
|
package/dist/server.d.ts
ADDED
|
@@ -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;
|