@tenkicloud/mcp 0.1.0 → 0.3.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 +30 -145
- package/README.md +35 -10
- package/SECURITY.md +6 -6
- package/dist/client.d.ts +30 -5
- package/dist/client.js +89 -13
- package/dist/http.d.ts +4 -6
- package/dist/http.js +80 -20
- package/dist/index.js +3 -2
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -4
- package/dist/tools/auth_status.d.ts +2 -2
- package/dist/tools/auth_status.js +9 -4
- package/dist/tools/common.d.ts +3 -1
- package/dist/tools/common.js +20 -2
- package/dist/tools/exec.js +38 -6
- package/dist/tools/git.js +1 -1
- package/dist/tools/previews.d.ts +4 -4
- package/dist/tools/previews.js +15 -37
- package/dist/tools/run.js +1 -1
- package/dist/tools/sandboxes.js +26 -20
- package/dist/tools/sessions_admin.d.ts +1 -1
- package/dist/tools/sessions_admin.js +1 -15
- package/dist/tools/snapshots.js +0 -12
- package/dist/tools/templates.js +14 -23
- package/dist/tools/volumes.d.ts +1 -1
- package/dist/tools/volumes.js +3 -20
- package/package.json +5 -5
- package/dist/tools/registry.d.ts +0 -18
- package/dist/tools/registry.js +0 -98
package/dist/client.js
CHANGED
|
@@ -71,6 +71,17 @@ const CRED_EXPIRY_SKEW_MS = 30_000;
|
|
|
71
71
|
const MIN_CRED_TTL_MS = 5_000; // floor for the skewed expiry of a short-lived credential
|
|
72
72
|
/** Home directory of the sandbox's `tenki` user; run-code scripts and capture files live here. */
|
|
73
73
|
const SANDBOX_HOME = "/home/tenki";
|
|
74
|
+
// Inline-output budget per stream (stdout / stderr), in bytes. Outputs at or
|
|
75
|
+
// under the cap return whole; larger ones return a head+tail preview and the
|
|
76
|
+
// full capture file is KEPT in the sandbox so the caller can page through it.
|
|
77
|
+
// The cap exists for the consumer (an LLM context) and for this process: the
|
|
78
|
+
// data plane's ReadFile has no range support, so the only alternative to a
|
|
79
|
+
// server-side preview would be pulling the entire body over the wire.
|
|
80
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 65_536;
|
|
81
|
+
const MIN_OUTPUT_BYTES = 1_024;
|
|
82
|
+
// Head-heavy split: the start of a log names the command/failure context; the
|
|
83
|
+
// tail carries the final error. 3:1 mirrors how people read build output.
|
|
84
|
+
const TRUNCATE_HEAD_FRACTION = 0.75;
|
|
74
85
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
75
86
|
function safeJson(text) {
|
|
76
87
|
try {
|
|
@@ -117,6 +128,8 @@ export class TenkiClient {
|
|
|
117
128
|
execTimeoutMs;
|
|
118
129
|
slowTimeoutMs;
|
|
119
130
|
credTtlMs;
|
|
131
|
+
workspaceId;
|
|
132
|
+
bearerTokenProvider;
|
|
120
133
|
constructor(token, baseUrl = DEFAULT_BASE_URL, opts = {}) {
|
|
121
134
|
this.token = token;
|
|
122
135
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
@@ -124,6 +137,13 @@ export class TenkiClient {
|
|
|
124
137
|
this.execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
125
138
|
this.slowTimeoutMs = opts.slowTimeoutMs ?? DEFAULT_SLOW_TIMEOUT_MS;
|
|
126
139
|
this.credTtlMs = opts.credTtlMs ?? DEFAULT_CRED_TTL_MS;
|
|
140
|
+
this.workspaceId = opts.workspaceId?.trim() || undefined;
|
|
141
|
+
this.bearerTokenProvider = opts.bearerTokenProvider;
|
|
142
|
+
}
|
|
143
|
+
controlAuthHeaders() {
|
|
144
|
+
if (this.bearerTokenProvider)
|
|
145
|
+
return { Authorization: `Bearer ${this.bearerTokenProvider()}` };
|
|
146
|
+
return authHeaders(this.token);
|
|
127
147
|
}
|
|
128
148
|
/**
|
|
129
149
|
* ExecuteCommand blocks until the command finishes, so its timeout follows
|
|
@@ -196,7 +216,7 @@ export class TenkiClient {
|
|
|
196
216
|
try {
|
|
197
217
|
res = await this.fetchTextWithTimeout(url, {
|
|
198
218
|
method: "POST",
|
|
199
|
-
headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...
|
|
219
|
+
headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...this.controlAuthHeaders() },
|
|
200
220
|
body: JSON.stringify(body ?? {}),
|
|
201
221
|
}, deadline - Date.now(), method);
|
|
202
222
|
}
|
|
@@ -335,9 +355,10 @@ export class TenkiClient {
|
|
|
335
355
|
}
|
|
336
356
|
}
|
|
337
357
|
/**
|
|
338
|
-
* Resolve the calling identity + a default workspace
|
|
339
|
-
*
|
|
340
|
-
*
|
|
358
|
+
* Resolve the calling identity + a default workspace for CreateSession and
|
|
359
|
+
* other workspace-scoped calls. (Projects were removed from the API — the
|
|
360
|
+
* proto reserves every project_id field and WhoAmI no longer lists projects —
|
|
361
|
+
* so there is nothing narrower than a workspace to resolve.)
|
|
341
362
|
*
|
|
342
363
|
* CreateSession validates owner_type ∈ {SERVICE, USER} and requires a
|
|
343
364
|
* non-empty owner_id, but derives the real owner from the authenticated
|
|
@@ -348,8 +369,9 @@ export class TenkiClient {
|
|
|
348
369
|
async resolveOwner() {
|
|
349
370
|
const resp = await this.control("WhoAmI", {});
|
|
350
371
|
const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces : [];
|
|
351
|
-
const ws =
|
|
352
|
-
|
|
372
|
+
const ws = this.workspaceId
|
|
373
|
+
? workspaces.find((candidate) => (candidate?.workspaceId ?? candidate?.id) === this.workspaceId)
|
|
374
|
+
: workspaces[0];
|
|
353
375
|
let ownerType = resp.ownerType;
|
|
354
376
|
let ownerId = resp.ownerId;
|
|
355
377
|
// Substitute the placeholder only when WhoAmI returned a type CreateSession
|
|
@@ -362,8 +384,7 @@ export class TenkiClient {
|
|
|
362
384
|
return {
|
|
363
385
|
ownerType,
|
|
364
386
|
ownerId,
|
|
365
|
-
workspaceId: ws?.workspaceId ?? ws?.id,
|
|
366
|
-
projectId: proj?.projectId ?? proj?.id,
|
|
387
|
+
workspaceId: this.workspaceId ?? ws?.workspaceId ?? ws?.id,
|
|
367
388
|
};
|
|
368
389
|
}
|
|
369
390
|
/** Poll GetSession until it reaches (or passes into) the target state. */
|
|
@@ -393,6 +414,37 @@ export class TenkiClient {
|
|
|
393
414
|
const content = Buffer.from(text, "utf8").toString("base64");
|
|
394
415
|
return this.data(sessionId, "WriteFile", { path, content });
|
|
395
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Read a capture file back, capping what crosses the wire. Stat first: a
|
|
419
|
+
* file at or under the cap is read whole; a larger one gets a server-side
|
|
420
|
+
* head+tail preview (assembled by `sh` in the sandbox, so the full body
|
|
421
|
+
* never leaves it — ReadFile has no range support). The preview file is the
|
|
422
|
+
* caller's to clean up (returned as previewPath); the ORIGINAL is theirs to
|
|
423
|
+
* keep, so the user can page through the full output afterwards.
|
|
424
|
+
*/
|
|
425
|
+
async readCaptureFile(sessionId, path, capBytes) {
|
|
426
|
+
// Stat is an optimization (skip pulling a body we'd mostly discard), not a
|
|
427
|
+
// gate: if it fails, fall back to the plain whole read — whose own failure
|
|
428
|
+
// still surfaces as captureError in the caller.
|
|
429
|
+
let size;
|
|
430
|
+
try {
|
|
431
|
+
const stat = await this.data(sessionId, "Stat", { path });
|
|
432
|
+
size = Number(stat.size ?? 0); // int64 arrives as a JSON string
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
size = undefined;
|
|
436
|
+
}
|
|
437
|
+
if (size === undefined || !Number.isFinite(size) || size <= capBytes) {
|
|
438
|
+
return { text: await this.readTextFile(sessionId, path), truncated: false };
|
|
439
|
+
}
|
|
440
|
+
const head = Math.floor(capBytes * TRUNCATE_HEAD_FRACTION);
|
|
441
|
+
const tail = capBytes - head;
|
|
442
|
+
const previewPath = `${path}.preview`;
|
|
443
|
+
const marker = `\n[... output truncated: ${size} bytes total, showing the first ${head} and last ${tail}. Full output kept in the sandbox at ${path} ...]\n`;
|
|
444
|
+
const script = `{ head -c ${head} ${shellQuote(path)}; printf %s ${shellQuote(marker)}; tail -c ${tail} ${shellQuote(path)}; } > ${shellQuote(previewPath)}`;
|
|
445
|
+
await this.control("ExecuteCommand", { sessionId, command: "sh", args: ["-c", script] });
|
|
446
|
+
return { text: await this.readTextFile(sessionId, previewPath), truncated: true, previewPath };
|
|
447
|
+
}
|
|
396
448
|
/**
|
|
397
449
|
* Run a command in a session and return stdout/stderr inline.
|
|
398
450
|
*
|
|
@@ -401,7 +453,9 @@ export class TenkiClient {
|
|
|
401
453
|
* client. Capture-read failures degrade gracefully into `captureError` rather
|
|
402
454
|
* than losing the run — but the result is marked NOT ok, because empty
|
|
403
455
|
* stdout/stderr next to a zero exit code would otherwise read as a clean,
|
|
404
|
-
* silent success.
|
|
456
|
+
* silent success. Streams over maxOutputBytes come back truncated
|
|
457
|
+
* (head+tail) with the full capture file retained in the sandbox — see
|
|
458
|
+
* readCaptureFile; truncation does not affect `ok`.
|
|
405
459
|
*/
|
|
406
460
|
async execCaptured(sessionId, command, opts = {}) {
|
|
407
461
|
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -428,18 +482,39 @@ export class TenkiClient {
|
|
|
428
482
|
// unparseable code to -1 so the user still gets their stdout/stderr.
|
|
429
483
|
const rawExit = typeof execution.exitCode === "number" ? execution.exitCode : Number(execution.exitCode ?? 0);
|
|
430
484
|
const exitCode = Number.isFinite(rawExit) ? Math.trunc(rawExit) : -1;
|
|
485
|
+
const cap = Math.max(MIN_OUTPUT_BYTES, Math.floor(opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES));
|
|
431
486
|
let stdout = "";
|
|
432
487
|
let stderr = "";
|
|
433
488
|
let captureError;
|
|
489
|
+
let stdoutTruncated = false;
|
|
490
|
+
let stderrTruncated = false;
|
|
491
|
+
// Truncated originals are KEPT (their path is returned) so the caller can
|
|
492
|
+
// page through them; everything else — untruncated captures, previews — is
|
|
493
|
+
// removed.
|
|
494
|
+
const toRemove = [];
|
|
434
495
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
496
|
+
const [out, err] = await Promise.all([
|
|
497
|
+
this.readCaptureFile(sessionId, outPath, cap),
|
|
498
|
+
this.readCaptureFile(sessionId, errPath, cap),
|
|
499
|
+
]);
|
|
500
|
+
stdout = out.text;
|
|
501
|
+
stderr = err.text;
|
|
502
|
+
stdoutTruncated = out.truncated;
|
|
503
|
+
stderrTruncated = err.truncated;
|
|
504
|
+
if (out.previewPath)
|
|
505
|
+
toRemove.push(out.previewPath);
|
|
506
|
+
if (err.previewPath)
|
|
507
|
+
toRemove.push(err.previewPath);
|
|
437
508
|
}
|
|
438
509
|
catch (e) {
|
|
439
510
|
captureError = e.message;
|
|
440
511
|
}
|
|
512
|
+
if (!stdoutTruncated)
|
|
513
|
+
toRemove.push(outPath);
|
|
514
|
+
if (!stderrTruncated)
|
|
515
|
+
toRemove.push(errPath);
|
|
441
516
|
try {
|
|
442
|
-
await this.control("ExecuteCommand", { sessionId, command: "rm", args: ["-f",
|
|
517
|
+
await this.control("ExecuteCommand", { sessionId, command: "rm", args: ["-f", ...toRemove] });
|
|
443
518
|
}
|
|
444
519
|
catch {
|
|
445
520
|
// Session may have gone away; capture files die with it.
|
|
@@ -452,6 +527,8 @@ export class TenkiClient {
|
|
|
452
527
|
exitCode,
|
|
453
528
|
ok: exitCode === 0 && !captureError,
|
|
454
529
|
...(captureError ? { captureError } : {}),
|
|
530
|
+
...(stdoutTruncated ? { stdoutTruncated: true, stdoutPath: outPath } : {}),
|
|
531
|
+
...(stderrTruncated ? { stderrTruncated: true, stderrPath: errPath } : {}),
|
|
455
532
|
};
|
|
456
533
|
}
|
|
457
534
|
/**
|
|
@@ -469,7 +546,6 @@ export class TenkiClient {
|
|
|
469
546
|
...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
|
|
470
547
|
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
471
548
|
...(owner.workspaceId ? { workspaceId: owner.workspaceId } : {}),
|
|
472
|
-
...(owner.projectId ? { projectId: owner.projectId } : {}),
|
|
473
549
|
...(opts.env && Object.keys(opts.env).length ? { env: opts.env } : {}),
|
|
474
550
|
});
|
|
475
551
|
const session = create.session ?? create;
|
package/dist/http.d.ts
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
* TENKI_MCP_HTTP_HOST — bind host (default 127.0.0.1, loopback-only)
|
|
9
9
|
* TENKI_MCP_HTTP_TOKEN — required Bearer token for the /mcp endpoint
|
|
10
10
|
*
|
|
11
|
-
* Security posture
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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.
|
|
16
14
|
*/
|
|
17
15
|
import http from "node:http";
|
|
18
|
-
import
|
|
16
|
+
import { TenkiClient } from "./client.js";
|
|
19
17
|
export declare function startHttp(client: TenkiClient | null, port: number): http.Server;
|
package/dist/http.js
CHANGED
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
* TENKI_MCP_HTTP_HOST — bind host (default 127.0.0.1, loopback-only)
|
|
9
9
|
* TENKI_MCP_HTTP_TOKEN — required Bearer token for the /mcp endpoint
|
|
10
10
|
*
|
|
11
|
-
* Security posture
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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.
|
|
16
14
|
*/
|
|
17
15
|
import http from "node:http";
|
|
18
16
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
19
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
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
21
|
import { createServer } from "./server.js";
|
|
22
22
|
const MAX_BODY_BYTES = 1 << 20; // 1 MiB — reject larger POST bodies (memory-DoS guard)
|
|
23
23
|
const MAX_SESSIONS = 256; // cap concurrent sessions (init-flood DoS guard)
|
|
@@ -109,22 +109,41 @@ function authOk(header, expected) {
|
|
|
109
109
|
* an ephemeral `port: 0` bind still accepts its own address). Anything else —
|
|
110
110
|
* e.g. a rebound attacker domain — is rejected by the transport.
|
|
111
111
|
*/
|
|
112
|
-
function allowedHostsFor(server, host, port) {
|
|
112
|
+
function allowedHostsFor(server, host, port, publicUrl) {
|
|
113
113
|
const addr = server.address();
|
|
114
114
|
const bound = addr && typeof addr === "object" ? addr.port : port;
|
|
115
115
|
const ports = Array.from(new Set([port, bound]));
|
|
116
116
|
const hosts = Array.from(new Set([host, "127.0.0.1", "localhost", "[::1]", "::1"]));
|
|
117
|
-
|
|
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" }));
|
|
118
135
|
}
|
|
119
136
|
export function startHttp(client, port) {
|
|
120
137
|
const host = process.env.TENKI_MCP_HTTP_HOST || "127.0.0.1";
|
|
121
138
|
const httpToken = process.env.TENKI_MCP_HTTP_TOKEN || "";
|
|
122
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;
|
|
123
143
|
// Refuse to expose an unauthenticated capability to the network.
|
|
124
|
-
if (!isLoopback && !httpToken) {
|
|
144
|
+
if (!isLoopback && !httpToken && !oauthConfig) {
|
|
125
145
|
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.");
|
|
146
|
+
"or OAuth configuration (the /mcp endpoint would be unauthenticated and can spend credits / run code).");
|
|
128
147
|
process.exit(1);
|
|
129
148
|
}
|
|
130
149
|
const sessions = new Map();
|
|
@@ -145,16 +164,37 @@ export function startHttp(client, port) {
|
|
|
145
164
|
sweep.unref?.();
|
|
146
165
|
const httpServer = http.createServer(async (req, res) => {
|
|
147
166
|
try {
|
|
148
|
-
if (!authOk(req.headers["authorization"], httpToken)) {
|
|
149
|
-
res.writeHead(401, { "Content-Type": "text/plain" }).end("unauthorized");
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
167
|
const url = new URL(req.url || "/", `http://${host}`);
|
|
168
|
+
if (oauthRoutes && (await oauthRoutes.handle(req, res, url)))
|
|
169
|
+
return;
|
|
153
170
|
if (url.pathname !== "/mcp") {
|
|
154
171
|
res.writeHead(404, { "Content-Type": "text/plain" }).end("not found — MCP endpoint is /mcp");
|
|
155
172
|
return;
|
|
156
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
|
+
}
|
|
157
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
|
+
}
|
|
158
198
|
if (req.method === "POST") {
|
|
159
199
|
let body;
|
|
160
200
|
try {
|
|
@@ -172,20 +212,30 @@ export function startHttp(client, port) {
|
|
|
172
212
|
.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
|
|
173
213
|
return;
|
|
174
214
|
}
|
|
175
|
-
let entry =
|
|
215
|
+
let entry = existingEntry;
|
|
176
216
|
if (!entry && isInitializeRequest(body)) {
|
|
177
217
|
if (sessions.size >= MAX_SESSIONS) {
|
|
178
218
|
res.writeHead(503, { "Content-Type": "text/plain" }).end("too many sessions");
|
|
179
219
|
return;
|
|
180
220
|
}
|
|
221
|
+
const sessionAuthorization = delegated
|
|
222
|
+
? {
|
|
223
|
+
binding: authorizationBinding(delegated),
|
|
224
|
+
apiDelegationToken: delegated.apiDelegationToken,
|
|
225
|
+
}
|
|
226
|
+
: undefined;
|
|
181
227
|
const transport = new StreamableHTTPServerTransport({
|
|
182
228
|
sessionIdGenerator: () => randomUUID(),
|
|
183
229
|
// DNS-rebinding defense: only accept these Host headers, so a rebound
|
|
184
230
|
// attacker-domain request from a browser is rejected.
|
|
185
231
|
enableDnsRebindingProtection: true,
|
|
186
|
-
allowedHosts: allowedHostsFor(httpServer, host, port),
|
|
232
|
+
allowedHosts: allowedHostsFor(httpServer, host, port, oauthConfig?.publicUrl),
|
|
187
233
|
onsessioninitialized: (id) => {
|
|
188
|
-
sessions.set(id, {
|
|
234
|
+
sessions.set(id, {
|
|
235
|
+
transport,
|
|
236
|
+
lastSeen: Date.now(),
|
|
237
|
+
...(sessionAuthorization ? { authorization: sessionAuthorization } : {}),
|
|
238
|
+
});
|
|
189
239
|
},
|
|
190
240
|
});
|
|
191
241
|
transport.onclose = () => {
|
|
@@ -193,8 +243,18 @@ export function startHttp(client, port) {
|
|
|
193
243
|
if (id)
|
|
194
244
|
sessions.delete(id);
|
|
195
245
|
};
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
};
|
|
198
258
|
}
|
|
199
259
|
if (!entry) {
|
|
200
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 }));
|
|
@@ -228,7 +288,7 @@ export function startHttp(client, port) {
|
|
|
228
288
|
httpServer.on("close", () => clearInterval(sweep));
|
|
229
289
|
httpServer.listen(port, host, () => {
|
|
230
290
|
console.error(`tenki-mcp running on http://${host}:${port}/mcp (Streamable HTTP)` +
|
|
231
|
-
(httpToken ? " [bearer auth required]" : " [loopback only, no auth]"));
|
|
291
|
+
(oauthConfig ? " [OAuth required]" : httpToken ? " [bearer auth required]" : " [loopback only, no auth]"));
|
|
232
292
|
});
|
|
233
293
|
return httpServer;
|
|
234
294
|
}
|
package/dist/index.js
CHANGED
|
@@ -23,10 +23,11 @@ import { startHttp } from "./http.js";
|
|
|
23
23
|
// tenki_auth_status registered (see createServer), so an agent can ask what is
|
|
24
24
|
// wrong and relay the fix.
|
|
25
25
|
const token = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY;
|
|
26
|
-
|
|
26
|
+
const oauthHttp = Boolean(process.env.TENKI_MCP_OAUTH_ISSUER);
|
|
27
|
+
if (!token && !oauthHttp) {
|
|
27
28
|
console.error("tenki-mcp: no credential — starting in unauthenticated mode (only tenki_auth_status is available). " +
|
|
28
29
|
"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
|
|
30
|
+
"e.g. claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y @tenkicloud/mcp");
|
|
30
31
|
}
|
|
31
32
|
const baseUrl = process.env.TENKI_API_ENDPOINT || process.env.TENKI_API_URL || undefined;
|
|
32
33
|
/** Positive integer from env, or undefined so the client keeps its own default. */
|
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
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
16
|
import type { TenkiClient } from "./client.js";
|
|
17
|
-
export declare const VERSION = "0.
|
|
17
|
+
export declare const VERSION = "0.3.0";
|
|
18
18
|
type Cls = "read" | "write" | "destructive";
|
|
19
19
|
export declare function classifyTool(name: string): Cls;
|
|
20
20
|
/**
|
|
@@ -24,7 +24,7 @@ export declare function classifyTool(name: string): Cls;
|
|
|
24
24
|
* start — which MCP clients report as an opaque "server failed to start" —
|
|
25
25
|
* the server boots with ONLY tenki_auth_status registered, so an agent can
|
|
26
26
|
* discover and explain the missing credential. Registering the other tools in
|
|
27
|
-
* that state would offer
|
|
27
|
+
* that state would offer 70 tools that can only fail.
|
|
28
28
|
*/
|
|
29
29
|
export declare function createServer(client: TenkiClient | null): McpServer;
|
|
30
30
|
export {};
|
package/dist/server.js
CHANGED
|
@@ -26,12 +26,11 @@ import { registerPreviews } from "./tools/previews.js";
|
|
|
26
26
|
import { registerSnapshots } from "./tools/snapshots.js";
|
|
27
27
|
import { registerVolumes } from "./tools/volumes.js";
|
|
28
28
|
import { registerTemplates } from "./tools/templates.js";
|
|
29
|
-
import { registerRegistry } from "./tools/registry.js";
|
|
30
29
|
import { registerWorkspace } from "./tools/workspace.js";
|
|
31
30
|
import { registerArtifacts } from "./tools/artifacts.js";
|
|
32
31
|
import { registerSsh } from "./tools/ssh.js";
|
|
33
32
|
import { registerAuthStatus } from "./tools/auth_status.js";
|
|
34
|
-
export const VERSION = "0.
|
|
33
|
+
export const VERSION = "0.3.0";
|
|
35
34
|
const modules = [
|
|
36
35
|
registerIdentity,
|
|
37
36
|
registerRun,
|
|
@@ -46,7 +45,6 @@ const modules = [
|
|
|
46
45
|
registerSnapshots,
|
|
47
46
|
registerVolumes,
|
|
48
47
|
registerTemplates,
|
|
49
|
-
registerRegistry,
|
|
50
48
|
registerWorkspace,
|
|
51
49
|
registerArtifacts,
|
|
52
50
|
registerSsh,
|
|
@@ -181,7 +179,7 @@ function guard(server, opts) {
|
|
|
181
179
|
* start — which MCP clients report as an opaque "server failed to start" —
|
|
182
180
|
* the server boots with ONLY tenki_auth_status registered, so an agent can
|
|
183
181
|
* discover and explain the missing credential. Registering the other tools in
|
|
184
|
-
* that state would offer
|
|
182
|
+
* that state would offer 70 tools that can only fail.
|
|
185
183
|
*/
|
|
186
184
|
export function createServer(client) {
|
|
187
185
|
const server = new McpServer({ name: "tenki", version: VERSION });
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
15
|
import type { TenkiClient } from "../client.js";
|
|
16
|
-
/** How
|
|
17
|
-
export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "session_cookie";
|
|
16
|
+
/** How the server authenticates API calls. */
|
|
17
|
+
export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "session_cookie" | "hosted_oauth";
|
|
18
18
|
export interface CredentialInfo {
|
|
19
19
|
kind: CredentialKind;
|
|
20
20
|
/** Env var the token came from, or undefined when there is none. */
|