@luxorai/mcp 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +216 -2
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +575 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +294 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +203 -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 +109 -0
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +60 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +120 -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 +80 -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 +72 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +62 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +79 -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 +142 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +77 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- package/package.json +59 -4
package/dist/http.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
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: loopback-only by default; DNS-rebinding protection on
|
|
12
|
+
* (Host allowlist); static bearer auth or OAuth required for non-loopback
|
|
13
|
+
* binds; and per-session/global DoS caps are applied.
|
|
14
|
+
*/
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
19
|
+
import { TenkiClient } from "./client.js";
|
|
20
|
+
import { OAuthResourceRoutes, OAuthTokenVerifier, authorizationBinding, bearerToken, loadOAuthConfig, } from "./oauth.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, publicUrl) {
|
|
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
|
+
const configured = (process.env.TENKI_MCP_ALLOWED_HOSTS || "")
|
|
118
|
+
.split(",")
|
|
119
|
+
.map((value) => value.trim())
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
if (publicUrl) {
|
|
122
|
+
const url = new URL(publicUrl);
|
|
123
|
+
configured.push(url.host, url.hostname);
|
|
124
|
+
}
|
|
125
|
+
return Array.from(new Set([...hosts.flatMap((h) => ports.map((p) => `${h}:${p}`)), ...configured]));
|
|
126
|
+
}
|
|
127
|
+
function oauthUnauthorized(res, metadataUrl, scope) {
|
|
128
|
+
res
|
|
129
|
+
.writeHead(401, {
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
"Cache-Control": "no-store",
|
|
132
|
+
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}", scope="${scope}"`,
|
|
133
|
+
})
|
|
134
|
+
.end(JSON.stringify({ error: "unauthorized" }));
|
|
135
|
+
}
|
|
136
|
+
export function startHttp(client, port) {
|
|
137
|
+
const host = process.env.TENKI_MCP_HTTP_HOST || "127.0.0.1";
|
|
138
|
+
const httpToken = process.env.TENKI_MCP_HTTP_TOKEN || "";
|
|
139
|
+
const isLoopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
140
|
+
const oauthConfig = loadOAuthConfig();
|
|
141
|
+
const oauthVerifier = oauthConfig ? new OAuthTokenVerifier(oauthConfig) : null;
|
|
142
|
+
const oauthRoutes = oauthConfig ? new OAuthResourceRoutes(oauthConfig) : null;
|
|
143
|
+
// Refuse to expose an unauthenticated capability to the network.
|
|
144
|
+
if (!isLoopback && !httpToken && !oauthConfig) {
|
|
145
|
+
console.error("tenki-mcp: refusing to bind HTTP to a non-loopback host without TENKI_MCP_HTTP_TOKEN " +
|
|
146
|
+
"or OAuth configuration (the /mcp endpoint would be unauthenticated and can spend credits / run code).");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
const sessions = new Map();
|
|
150
|
+
const sweep = setInterval(() => {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
for (const [id, s] of sessions) {
|
|
153
|
+
if (now - s.lastSeen > SESSION_IDLE_MS) {
|
|
154
|
+
try {
|
|
155
|
+
s.transport.close();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
sessions.delete(id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}, 60_000);
|
|
164
|
+
sweep.unref?.();
|
|
165
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(req.url || "/", `http://${host}`);
|
|
168
|
+
if (oauthRoutes && (await oauthRoutes.handle(req, res, url)))
|
|
169
|
+
return;
|
|
170
|
+
if (url.pathname !== "/mcp") {
|
|
171
|
+
res.writeHead(404, { "Content-Type": "text/plain" }).end("not found — MCP endpoint is /mcp");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let delegated;
|
|
175
|
+
if (oauthConfig && oauthVerifier) {
|
|
176
|
+
const token = bearerToken(req.headers["authorization"]);
|
|
177
|
+
delegated = token ? await oauthVerifier.verify(token) : null;
|
|
178
|
+
if (!delegated) {
|
|
179
|
+
oauthUnauthorized(res, oauthConfig.metadataUrl, oauthConfig.scope);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (!authOk(req.headers["authorization"], httpToken)) {
|
|
184
|
+
res.writeHead(401, { "Content-Type": "text/plain" }).end("unauthorized");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const sid = req.headers["mcp-session-id"];
|
|
188
|
+
const existingEntry = sid ? sessions.get(sid) : undefined;
|
|
189
|
+
if (existingEntry?.authorization &&
|
|
190
|
+
delegated &&
|
|
191
|
+
existingEntry.authorization.binding !== authorizationBinding(delegated)) {
|
|
192
|
+
oauthUnauthorized(res, oauthConfig.metadataUrl, oauthConfig.scope);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (existingEntry?.authorization && delegated) {
|
|
196
|
+
existingEntry.authorization.apiDelegationToken = delegated.apiDelegationToken;
|
|
197
|
+
}
|
|
198
|
+
if (req.method === "POST") {
|
|
199
|
+
let body;
|
|
200
|
+
try {
|
|
201
|
+
body = await readJson(req);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
if (e instanceof BodyTooLarge) {
|
|
205
|
+
res
|
|
206
|
+
.writeHead(413, { "Content-Type": "application/json", Connection: "close" })
|
|
207
|
+
.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: e.message }, id: null }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
res
|
|
211
|
+
.writeHead(400, { "Content-Type": "application/json" })
|
|
212
|
+
.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let entry = existingEntry;
|
|
216
|
+
if (!entry && isInitializeRequest(body)) {
|
|
217
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
218
|
+
res.writeHead(503, { "Content-Type": "text/plain" }).end("too many sessions");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const sessionAuthorization = delegated
|
|
222
|
+
? {
|
|
223
|
+
binding: authorizationBinding(delegated),
|
|
224
|
+
apiDelegationToken: delegated.apiDelegationToken,
|
|
225
|
+
}
|
|
226
|
+
: undefined;
|
|
227
|
+
const transport = new StreamableHTTPServerTransport({
|
|
228
|
+
sessionIdGenerator: () => randomUUID(),
|
|
229
|
+
// DNS-rebinding defense: only accept these Host headers, so a rebound
|
|
230
|
+
// attacker-domain request from a browser is rejected.
|
|
231
|
+
enableDnsRebindingProtection: true,
|
|
232
|
+
allowedHosts: allowedHostsFor(httpServer, host, port, oauthConfig?.publicUrl),
|
|
233
|
+
onsessioninitialized: (id) => {
|
|
234
|
+
sessions.set(id, {
|
|
235
|
+
transport,
|
|
236
|
+
lastSeen: Date.now(),
|
|
237
|
+
...(sessionAuthorization ? { authorization: sessionAuthorization } : {}),
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
transport.onclose = () => {
|
|
242
|
+
const id = transport.sessionId;
|
|
243
|
+
if (id)
|
|
244
|
+
sessions.delete(id);
|
|
245
|
+
};
|
|
246
|
+
const sessionClient = delegated
|
|
247
|
+
? new TenkiClient("", process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined, {
|
|
248
|
+
workspaceId: delegated.workspaceId,
|
|
249
|
+
bearerTokenProvider: () => sessionAuthorization.apiDelegationToken,
|
|
250
|
+
})
|
|
251
|
+
: client;
|
|
252
|
+
await createServer(sessionClient).connect(transport);
|
|
253
|
+
entry = {
|
|
254
|
+
transport,
|
|
255
|
+
lastSeen: Date.now(),
|
|
256
|
+
...(sessionAuthorization ? { authorization: sessionAuthorization } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!entry) {
|
|
260
|
+
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 }));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
entry.lastSeen = Date.now();
|
|
264
|
+
await entry.transport.handleRequest(req, res, body);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// GET opens the SSE stream; DELETE ends a session.
|
|
268
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
269
|
+
const entry = sid ? sessions.get(sid) : undefined;
|
|
270
|
+
if (!entry) {
|
|
271
|
+
res.writeHead(400, { "Content-Type": "text/plain" }).end("No session for the given mcp-session-id.");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
entry.lastSeen = Date.now();
|
|
275
|
+
await entry.transport.handleRequest(req, res);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
res.writeHead(405, { "Content-Type": "text/plain" }).end("method not allowed");
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
// Never echo internals to the client; log server-side only.
|
|
282
|
+
if (!res.headersSent)
|
|
283
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
284
|
+
res.end("internal error");
|
|
285
|
+
console.error("tenki-mcp http error:", e.message);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
httpServer.on("close", () => clearInterval(sweep));
|
|
289
|
+
httpServer.listen(port, host, () => {
|
|
290
|
+
console.error(`tenki-mcp running on http://${host}:${port}/mcp (Streamable HTTP)` +
|
|
291
|
+
(oauthConfig ? " [OAuth required]" : httpToken ? " [bearer auth required]" : " [loopback only, no auth]"));
|
|
292
|
+
});
|
|
293
|
+
return httpServer;
|
|
294
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
const oauthHttp = Boolean(process.env.TENKI_MCP_OAUTH_ISSUER);
|
|
27
|
+
if (!token && !oauthHttp) {
|
|
28
|
+
console.error("tenki-mcp: no credential — starting in unauthenticated mode (only tenki_auth_status is available). " +
|
|
29
|
+
"Set TENKI_API_KEY (tk_…) or TENKI_AUTH_TOKEN (ory_st_…) in the server's env and restart, " +
|
|
30
|
+
"e.g. claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y @tenkicloud/mcp");
|
|
31
|
+
}
|
|
32
|
+
const baseUrl = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined;
|
|
33
|
+
/** Positive integer from env, or undefined so the client keeps its own default. */
|
|
34
|
+
const envMs = (name) => {
|
|
35
|
+
const n = Number.parseInt(process.env[name] ?? "", 10);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
37
|
+
};
|
|
38
|
+
const client = token
|
|
39
|
+
? new TenkiClient(token, baseUrl, {
|
|
40
|
+
timeoutMs: envMs("TENKI_MCP_TIMEOUT_MS"),
|
|
41
|
+
slowTimeoutMs: envMs("TENKI_MCP_SLOW_TIMEOUT_MS"),
|
|
42
|
+
})
|
|
43
|
+
: null;
|
|
44
|
+
async function main() {
|
|
45
|
+
if ((process.env.TENKI_MCP_TRANSPORT || "stdio").toLowerCase() === "http") {
|
|
46
|
+
const httpServer = startHttp(client, Number(process.env.PORT) || 3000);
|
|
47
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
48
|
+
process.on(sig, () => httpServer.close(() => process.exit(0)));
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const server = createServer(client);
|
|
53
|
+
const transport = new StdioServerTransport();
|
|
54
|
+
await server.connect(transport);
|
|
55
|
+
console.error("tenki-mcp running on stdio");
|
|
56
|
+
}
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
console.error("tenki-mcp fatal:", err);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
export interface OAuthConfig {
|
|
3
|
+
issuer: string;
|
|
4
|
+
resource: string;
|
|
5
|
+
publicUrl: string;
|
|
6
|
+
metadataUrl: string;
|
|
7
|
+
identityUrl: string;
|
|
8
|
+
identityServiceToken: string;
|
|
9
|
+
scope: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DelegatedAuthorization {
|
|
12
|
+
tokenDigest: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
clientId: string;
|
|
16
|
+
scope: string[];
|
|
17
|
+
expiresAt?: number;
|
|
18
|
+
apiDelegationToken: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function loadOAuthConfig(): OAuthConfig | null;
|
|
21
|
+
export declare class OAuthTokenVerifier {
|
|
22
|
+
private readonly config;
|
|
23
|
+
private readonly cache;
|
|
24
|
+
constructor(config: OAuthConfig);
|
|
25
|
+
verify(token: string): Promise<DelegatedAuthorization | null>;
|
|
26
|
+
}
|
|
27
|
+
export declare class OAuthResourceRoutes {
|
|
28
|
+
private readonly config;
|
|
29
|
+
constructor(config: OAuthConfig);
|
|
30
|
+
handle(_req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
export declare function bearerToken(header: string | undefined): string | null;
|
|
33
|
+
export declare function authorizationBinding(authorization: DelegatedAuthorization): string;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const DEFAULT_SCOPE = "mcp";
|
|
3
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
4
|
+
const TOKEN_CACHE_MS = 15_000;
|
|
5
|
+
const EXCHANGE_PROCEDURE = "/tenki.cloud.identity.private.v1beta1.IdentityPrivateService/ExchangeMcpOAuthToken";
|
|
6
|
+
function trimUrl(value) {
|
|
7
|
+
return value.trim().replace(/\/+$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function loadOAuthConfig() {
|
|
10
|
+
const issuer = trimUrl(process.env.TENKI_MCP_OAUTH_ISSUER || "");
|
|
11
|
+
const identityUrl = trimUrl(process.env.TENKI_MCP_IDENTITY_URL || "");
|
|
12
|
+
const identityServiceToken = (process.env.TENKI_MCP_IDENTITY_SERVICE_TOKEN || "").trim();
|
|
13
|
+
const publicUrl = trimUrl(process.env.TENKI_MCP_PUBLIC_URL || "");
|
|
14
|
+
if (!issuer && !identityUrl && !identityServiceToken && !publicUrl)
|
|
15
|
+
return null;
|
|
16
|
+
if (!issuer || !identityUrl || !identityServiceToken || !publicUrl) {
|
|
17
|
+
throw new Error("TENKI_MCP_OAUTH_ISSUER, TENKI_MCP_IDENTITY_URL, TENKI_MCP_IDENTITY_SERVICE_TOKEN, and TENKI_MCP_PUBLIC_URL must be set together.");
|
|
18
|
+
}
|
|
19
|
+
const resource = trimUrl(process.env.TENKI_MCP_OAUTH_RESOURCE || `${publicUrl}/mcp`);
|
|
20
|
+
return {
|
|
21
|
+
issuer,
|
|
22
|
+
resource,
|
|
23
|
+
publicUrl,
|
|
24
|
+
metadataUrl: `${publicUrl}/.well-known/oauth-protected-resource/mcp`,
|
|
25
|
+
identityUrl,
|
|
26
|
+
identityServiceToken,
|
|
27
|
+
scope: (process.env.TENKI_MCP_OAUTH_SCOPE || DEFAULT_SCOPE).trim(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function tokenDigest(token) {
|
|
31
|
+
return createHash("sha256").update(token).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function stringArray(value) {
|
|
34
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
35
|
+
}
|
|
36
|
+
async function fetchJson(url, init = {}) {
|
|
37
|
+
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
let body = {};
|
|
40
|
+
try {
|
|
41
|
+
body = text ? JSON.parse(text) : {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`upstream returned non-JSON HTTP ${response.status}`);
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error(`upstream returned HTTP ${response.status}`);
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
export class OAuthTokenVerifier {
|
|
51
|
+
config;
|
|
52
|
+
cache = new Map();
|
|
53
|
+
constructor(config) {
|
|
54
|
+
this.config = config;
|
|
55
|
+
}
|
|
56
|
+
async verify(token) {
|
|
57
|
+
const digest = tokenDigest(token);
|
|
58
|
+
const cached = this.cache.get(digest);
|
|
59
|
+
if (cached && cached.cachedUntil > Date.now())
|
|
60
|
+
return cached.authorization;
|
|
61
|
+
let body;
|
|
62
|
+
try {
|
|
63
|
+
body = await fetchJson(`${this.config.identityUrl}${EXCHANGE_PROCEDURE}`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"X-Service-Token": this.config.identityServiceToken,
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"Connect-Protocol-Version": "1",
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({ accessToken: token }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const subject = typeof body.subject === "string" ? body.subject.trim() : "";
|
|
77
|
+
const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
|
|
78
|
+
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : "";
|
|
79
|
+
const apiDelegationToken = typeof body.apiDelegationToken === "string" ? body.apiDelegationToken.trim() : "";
|
|
80
|
+
const scope = stringArray(body.scopes);
|
|
81
|
+
const expiresAtUnix = Number(body.expiresAtUnix);
|
|
82
|
+
const expiresAt = Number.isFinite(expiresAtUnix) ? expiresAtUnix * 1000 : undefined;
|
|
83
|
+
if (!subject ||
|
|
84
|
+
!workspaceId ||
|
|
85
|
+
!clientId ||
|
|
86
|
+
!apiDelegationToken ||
|
|
87
|
+
!scope.includes(this.config.scope) ||
|
|
88
|
+
(expiresAt !== undefined && expiresAt <= Date.now())) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const authorization = {
|
|
92
|
+
tokenDigest: digest,
|
|
93
|
+
subject,
|
|
94
|
+
workspaceId,
|
|
95
|
+
clientId,
|
|
96
|
+
scope,
|
|
97
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
98
|
+
apiDelegationToken,
|
|
99
|
+
};
|
|
100
|
+
this.cache.set(digest, {
|
|
101
|
+
authorization,
|
|
102
|
+
cachedUntil: Math.min(Date.now() + TOKEN_CACHE_MS, expiresAt ?? Number.POSITIVE_INFINITY),
|
|
103
|
+
});
|
|
104
|
+
return authorization;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function json(res, status, body) {
|
|
108
|
+
res.writeHead(status, {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
"Cache-Control": "no-store",
|
|
111
|
+
Pragma: "no-cache",
|
|
112
|
+
});
|
|
113
|
+
res.end(JSON.stringify(body));
|
|
114
|
+
}
|
|
115
|
+
export class OAuthResourceRoutes {
|
|
116
|
+
config;
|
|
117
|
+
constructor(config) {
|
|
118
|
+
this.config = config;
|
|
119
|
+
}
|
|
120
|
+
async handle(_req, res, url) {
|
|
121
|
+
if (url.pathname === "/.well-known/oauth-protected-resource/mcp" || url.pathname === "/.well-known/oauth-protected-resource") {
|
|
122
|
+
json(res, 200, {
|
|
123
|
+
resource: this.config.resource,
|
|
124
|
+
authorization_servers: [this.config.issuer],
|
|
125
|
+
scopes_supported: [this.config.scope, "offline_access"],
|
|
126
|
+
bearer_methods_supported: ["header"],
|
|
127
|
+
resource_name: "Tenki MCP",
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (url.pathname === "/healthz") {
|
|
132
|
+
json(res, 200, { status: "ok" });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function bearerToken(header) {
|
|
139
|
+
const match = /^Bearer (.+)$/.exec(header ?? "");
|
|
140
|
+
return match?.[1]?.trim() || null;
|
|
141
|
+
}
|
|
142
|
+
export function authorizationBinding(authorization) {
|
|
143
|
+
return createHash("sha256")
|
|
144
|
+
.update(JSON.stringify([authorization.subject, authorization.workspaceId, authorization.clientId]))
|
|
145
|
+
.digest("hex");
|
|
146
|
+
}
|
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.3.1";
|
|
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 70 tools that can only fail.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createServer(client: TenkiClient | null): McpServer;
|
|
30
|
+
export {};
|