@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/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", ...authHeaders(this.token) },
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/project for CreateSession
339
- * (which requires a projectId). Picks the first workspace that has a project so
340
- * the (workspace, project) pair stays consistent.
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 = workspaces.find((w) => Array.isArray(w?.projects) && w.projects.length > 0) ?? workspaces[0];
352
- const proj = Array.isArray(ws?.projects) ? ws.projects[0] : undefined;
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
- stdout = await this.readTextFile(sessionId, outPath);
436
- stderr = await this.readTextFile(sessionId, errPath);
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", outPath, errPath] });
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 (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.
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 type { TenkiClient } from "./client.js";
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 (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.
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
- return hosts.flatMap((h) => ports.map((p) => `${h}:${p}`));
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 = sid ? sessions.get(sid) : undefined;
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, { transport, lastSeen: Date.now() });
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
- await createServer(client).connect(transport);
197
- entry = { transport, lastSeen: Date.now() };
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
- if (!token) {
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 tenki-mcp");
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. */
@@ -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.1.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 84 tools that can only fail.
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.1.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 84 tools that can only fail.
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 a credential was supplied, derived from the token's prefix. */
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. */