@tenkicloud/mcp 0.2.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/README.md CHANGED
@@ -22,10 +22,30 @@ Nothing to clone or build. The package installs one command, `tenki-mcp`.
22
22
 
23
23
  ### Use it in Claude Code
24
24
 
25
+ Connect to Tenki's hosted MCP service and sign in with your Tenki account:
26
+
27
+ ```bash
28
+ claude mcp add --transport http tenki https://mcp.tenki.cloud/mcp
29
+ claude mcp login tenki
30
+ ```
31
+
32
+ Claude Code's `mcp add` command stores the server configuration; `mcp login` performs the one-time browser authorization and workspace selection.
33
+
34
+ Alternatively, run the MCP server locally with an API key:
35
+
25
36
  ```bash
26
37
  claude mcp add tenki --env TENKI_API_KEY=tk_your_key_here -- npx -y @tenkicloud/mcp
27
38
  ```
28
39
 
40
+ ### Or install it as a Claude Code plugin
41
+
42
+ The repo doubles as a plugin marketplace. This prompts for your API key on install and stores it in your OS keychain — no env var to manage:
43
+
44
+ ```
45
+ /plugin marketplace add LuxorLabs/tenki-mcp
46
+ /plugin install tenki@tenki
47
+ ```
48
+
29
49
  ### Use it in Claude Desktop
30
50
 
31
51
  Add to `claude_desktop_config.json`:
@@ -106,6 +126,12 @@ Substitute `node /absolute/path/to/tenki-mcp/dist/index.js` for the `npx` comman
106
126
  | `PORT` | `3000` | HTTP transport port. |
107
127
  | `TENKI_MCP_HTTP_HOST` | `127.0.0.1` | HTTP bind host; non-loopback requires `TENKI_MCP_HTTP_TOKEN`. |
108
128
  | `TENKI_MCP_HTTP_TOKEN` | — | Bearer token for the HTTP endpoint; optional on loopback, required on a non-loopback host. |
129
+ | `TENKI_MCP_PUBLIC_URL` | — | Public base URL for an OAuth-protected hosted server. |
130
+ | `TENKI_MCP_OAUTH_ISSUER` | — | OAuth authorization-server issuer. Enables delegated OAuth HTTP mode. |
131
+ | `TENKI_MCP_OAUTH_RESOURCE` | `<public URL>/mcp` | RFC 8707 resource identifier accepted in access-token audiences. |
132
+ | `TENKI_MCP_OAUTH_SCOPE` | `mcp` | Required delegated scope. |
133
+ | `TENKI_MCP_IDENTITY_URL` | — | Internal Tenki Identity service endpoint used to exchange OAuth access tokens. |
134
+ | `TENKI_MCP_IDENTITY_SERVICE_TOKEN` | — | Service credential for the private Identity token-exchange RPC. |
109
135
 
110
136
  ## Tools
111
137
 
@@ -146,7 +172,7 @@ TENKI_MCP_TRANSPORT=http PORT=3000 TENKI_API_KEY=… npx -y @tenkicloud/mcp
146
172
  # → tenki-mcp running on http://127.0.0.1:3000/mcp (Streamable HTTP) [loopback only, no auth]
147
173
  ```
148
174
 
149
- **Security — this endpoint is a capability.** In HTTP mode the process holds one shared `TENKI_API_KEY` and exposes every tool, including arbitrary code execution and credit spend. So by default it:
175
+ For a single-user deployment, HTTP mode can hold one shared `TENKI_API_KEY` and therefore exposes a powerful capability. By default it:
150
176
 
151
177
  - **binds to loopback (`127.0.0.1`) only** — set `TENKI_MCP_HTTP_HOST=0.0.0.0` to expose it, but then
152
178
  - it **requires a bearer token**: set `TENKI_MCP_HTTP_TOKEN` and send `Authorization: Bearer <token>`. It **refuses to start** on a non-loopback host without one.
@@ -158,7 +184,9 @@ TENKI_MCP_TRANSPORT=http TENKI_MCP_HTTP_HOST=0.0.0.0 PORT=3000 \
158
184
  TENKI_MCP_HTTP_TOKEN=$(openssl rand -hex 32) TENKI_API_KEY=… npx -y @tenkicloud/mcp
159
185
  ```
160
186
 
161
- Point an HTTP-capable MCP client at `/mcp`. v2.0-beta uses one shared `TENKI_API_KEY` for all sessions; per-request auth (multi-tenant hosting) is not yet implemented. Verified end-to-end (`test/http-transport.test.mjs`: auth gate, DNS-rebinding rejection, connect → tools/list → tool call over HTTP).
187
+ Point an HTTP-capable MCP client at `/mcp`. Static-key mode uses one shared `TENKI_API_KEY` for all sessions. Verified end-to-end (`test/http-transport.test.mjs`: auth gate, DNS-rebinding rejection, connect → tools/list → tool call over HTTP).
188
+
189
+ Hosted multi-tenant deployments instead use Tenki Identity's OAuth facade. The MCP server publishes RFC 9728 protected-resource metadata and exchanges each caller's access token through the private Identity service. Identity owns Hydra, validates the requested audience and scope, and issues a short-lived API delegation bound to the user, client, and workspace selected on the Tenki consent page. `tenki-mcp` has no Hydra Admin access and does not hold the delegation-signing secret.
162
190
 
163
191
  ## How it works
164
192
 
package/SECURITY.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # Security
2
2
 
3
- `tenki-mcp` gives an AI agent a capability: a disposable microVM it can create, run code in, and spend Tenki credits with. Treat the server and the API key it holds — accordingly. This document is the threat model and the controls, mapped to the [CSA MCP Server Top 10](https://modelcontextprotocol-security.io/top10/server/).
3
+ `tenki-mcp` gives an AI agent a capability: a disposable microVM it can create, run code in, and spend Tenki credits with. Treat the server and its credentials accordingly. This document is the threat model and the controls, mapped to the [CSA MCP Server Top 10](https://modelcontextprotocol-security.io/top10/server/).
4
4
 
5
5
  ## Reporting a vulnerability
6
6
  Please **do not** open a public issue for security reports. Open a private [GitHub security advisory](https://github.com/LuxorLabs/tenki-mcp/security/advisories/new), or email **hello@luxor.tech**. We'll acknowledge within a few business days.
7
7
 
8
8
  ## Trust boundaries (read this first)
9
- - **The API key is a capability.** In every mode the process authenticates to Tenki with one `TENKI_API_KEY` and can create sandboxes, run arbitrary code, and spend credits. Anyone who can invoke the server can do those things. Scope the key if your Tenki plan allows it; never commit it (`.env` is gitignored).
9
+ - **Credentials are capabilities.** Local and single-user modes authenticate with a `TENKI_API_KEY` or session token. Hosted OAuth mode exchanges each caller's token through Tenki Identity and receives only a short-lived, workspace-bound internal delegation to the Tenki API. Never commit credentials (`.env` is gitignored).
10
10
  - **Sandbox output is untrusted.** `tenki_run_code` / `tenki_exec` / `tenki_read_file` return output produced by *untrusted, AI-generated code running in the sandbox*. That output flows back to the calling model as a tool result — a classic **indirect / output prompt-injection** vector. The microVM is the isolation boundary; the model should treat tool results as **data, not instructions**. (MCP clients are responsible for not executing instructions found in tool output.)
11
- - **The HTTP endpoint is a capability.** In HTTP mode the `/mcp` endpoint is equivalent to handing out the key protect it (below).
11
+ - **The HTTP endpoint is a capability.** In static-key HTTP mode, access to `/mcp` is equivalent to access to the configured key. Hosted mode requires a valid OAuth bearer on every request and binds the MCP session to its user, client, and workspace.
12
12
 
13
13
  ## Controls this server provides
14
14
 
@@ -28,7 +28,7 @@ Grant the smallest set that the use case needs.
28
28
  - **HTTP** (`TENKI_MCP_TRANSPORT=http`) binds **loopback-only** by default, **requires a bearer token** to bind to a non-loopback host, has **DNS-rebinding protection**, and caps sessions + body size. For network exposure put it behind a **TLS-terminating proxy**. (Hardening details in the transport module, `src/http.ts`.)
29
29
 
30
30
  ### Secrets & audit (MCP-04, Observability)
31
- - The key is read from env and sent only as an auth header never logged, never in error responses.
31
+ - Static credentials are read from env and sent only as auth headers. Hosted OAuth tokens are sent only to the private Tenki Identity exchange endpoint and are never forwarded to the Tenki API; the API receives a short-lived signed delegation instead.
32
32
  - `TENKI_MCP_AUDIT=1` logs each tool call's **name + argument keys** to stderr (never values, content, or the token) for an operator audit trail.
33
33
 
34
34
  ## CSA MCP Server Top-10 mapping
@@ -36,9 +36,9 @@ Grant the smallest set that the use case needs.
36
36
  | # | Risk | tenki-mcp posture |
37
37
  |---|---|---|
38
38
  | MCP-01 | Prompt Injection | zod-validates every tool arg pre-network; **sandbox output is untrusted** (treat tool results as data) |
39
- | MCP-02 | Confused Deputy | single shared key; HTTP endpoint requires its own bearer token; `READONLY`/denylist bound the blast radius; per-request auth is not yet implemented |
39
+ | MCP-02 | Confused Deputy | static mode requires its own HTTP bearer; hosted mode authenticates every request and binds the session and API delegation to the authorized user, client, and workspace; `READONLY`/denylist further bound the blast radius |
40
40
  | MCP-03 | Tool Poisoning | tool descriptions are static and authored (no dynamic/remote descriptions); verify the package via its npm provenance attestation (published from GitHub Actions, linking each release to its source commit) + MCP-registry namespace ownership |
41
- | MCP-04 | Credential/Token Exposure | key via env only; never logged/committed/echoed; audit logs keys not values |
41
+ | MCP-04 | Credential/Token Exposure | credentials are never logged/committed/echoed; hosted OAuth tokens go only to Tenki Identity and only short-lived internal delegations reach the API; audit logs keys not values |
42
42
  | MCP-05 | Insecure Configuration | HTTP transport is loopback + token + DNS-rebinding-protected + DoS-capped by default |
43
43
  | MCP-06 | Supply Chain | 2 direct runtime deps (`@modelcontextprotocol/sdk`, `zod`) — ~91 transitive, nearly all via the MCP SDK; lockfile committed; released only from CI with npm provenance, Actions pinned to commit SHAs |
44
44
  | MCP-07 | Excessive Permissions | tool annotations + `TENKI_MCP_READONLY` + `TENKI_MCP_DISABLED_TOOLS` |
package/dist/client.d.ts CHANGED
@@ -37,6 +37,10 @@ export interface TenkiClientOptions {
37
37
  slowTimeoutMs?: number;
38
38
  /** Assumed session-credential lifetime when the API returns no parseable expiry (default 5min). */
39
39
  credTtlMs?: number;
40
+ /** Workspace selected by an upstream delegated authorization grant. */
41
+ workspaceId?: string;
42
+ /** Supplies a short-lived Bearer credential for hosted delegated calls. */
43
+ bearerTokenProvider?: () => string;
40
44
  }
41
45
  export declare class TenkiClient {
42
46
  private readonly token;
@@ -47,7 +51,10 @@ export declare class TenkiClient {
47
51
  private readonly execTimeoutMs;
48
52
  private readonly slowTimeoutMs;
49
53
  private readonly credTtlMs;
54
+ private readonly workspaceId?;
55
+ private readonly bearerTokenProvider?;
50
56
  constructor(token: string, baseUrl?: string, opts?: TenkiClientOptions);
57
+ private controlAuthHeaders;
51
58
  /**
52
59
  * ExecuteCommand blocks until the command finishes, so its timeout follows
53
60
  * the command's own timeout (plus margin) instead of the unary default.
package/dist/client.js CHANGED
@@ -128,6 +128,8 @@ export class TenkiClient {
128
128
  execTimeoutMs;
129
129
  slowTimeoutMs;
130
130
  credTtlMs;
131
+ workspaceId;
132
+ bearerTokenProvider;
131
133
  constructor(token, baseUrl = DEFAULT_BASE_URL, opts = {}) {
132
134
  this.token = token;
133
135
  this.baseUrl = baseUrl.replace(/\/+$/, "");
@@ -135,6 +137,13 @@ export class TenkiClient {
135
137
  this.execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
136
138
  this.slowTimeoutMs = opts.slowTimeoutMs ?? DEFAULT_SLOW_TIMEOUT_MS;
137
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);
138
147
  }
139
148
  /**
140
149
  * ExecuteCommand blocks until the command finishes, so its timeout follows
@@ -207,7 +216,7 @@ export class TenkiClient {
207
216
  try {
208
217
  res = await this.fetchTextWithTimeout(url, {
209
218
  method: "POST",
210
- headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...authHeaders(this.token) },
219
+ headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...this.controlAuthHeaders() },
211
220
  body: JSON.stringify(body ?? {}),
212
221
  }, deadline - Date.now(), method);
213
222
  }
@@ -360,7 +369,9 @@ export class TenkiClient {
360
369
  async resolveOwner() {
361
370
  const resp = await this.control("WhoAmI", {});
362
371
  const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces : [];
363
- const ws = workspaces[0];
372
+ const ws = this.workspaceId
373
+ ? workspaces.find((candidate) => (candidate?.workspaceId ?? candidate?.id) === this.workspaceId)
374
+ : workspaces[0];
364
375
  let ownerType = resp.ownerType;
365
376
  let ownerId = resp.ownerId;
366
377
  // Substitute the placeholder only when WhoAmI returned a type CreateSession
@@ -373,7 +384,7 @@ export class TenkiClient {
373
384
  return {
374
385
  ownerType,
375
386
  ownerId,
376
- workspaceId: ws?.workspaceId ?? ws?.id,
387
+ workspaceId: this.workspaceId ?? ws?.workspaceId ?? ws?.id,
377
388
  };
378
389
  }
379
390
  /** Poll GetSession until it reaches (or passes into) the target state. */
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,7 +23,8 @@ 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
30
  "e.g. claude mcp add tenki --env TENKI_API_KEY=tk_… -- npx -y @tenkicloud/mcp");
@@ -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.2.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
  /**
package/dist/server.js CHANGED
@@ -30,7 +30,7 @@ import { registerWorkspace } from "./tools/workspace.js";
30
30
  import { registerArtifacts } from "./tools/artifacts.js";
31
31
  import { registerSsh } from "./tools/ssh.js";
32
32
  import { registerAuthStatus } from "./tools/auth_status.js";
33
- export const VERSION = "0.2.0";
33
+ export const VERSION = "0.3.0";
34
34
  const modules = [
35
35
  registerIdentity,
36
36
  registerRun,
@@ -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. */
@@ -9,6 +9,8 @@ export function describeCredential(env = process.env) {
9
9
  const fromToken = env.TENKI_AUTH_TOKEN?.trim();
10
10
  const fromKey = env.TENKI_API_KEY?.trim();
11
11
  const raw = fromToken || fromKey;
12
+ if (!raw && env.TENKI_MCP_OAUTH_ISSUER?.trim())
13
+ return { kind: "hosted_oauth" };
12
14
  if (!raw)
13
15
  return { kind: "none" };
14
16
  const source = fromToken ? "TENKI_AUTH_TOKEN" : "TENKI_API_KEY";
@@ -23,12 +25,13 @@ const CREDENTIAL_HELP = {
23
25
  api_key: "Authenticated with a tk_… API key (Authorization: Bearer).",
24
26
  oauth_session_token: "Authenticated with an ory_st_… session token (X-Session-Token). Session tokens expire; an API key is the stabler choice for a long-running server.",
25
27
  session_cookie: "Authenticated with a session cookie (the token matched neither the tk_ nor ory_st_ prefix, so it is sent as a tenki_session cookie). If that is not what you intended, check the value.",
28
+ hosted_oauth: "Authenticated through the hosted OAuth session for this MCP connection.",
26
29
  };
27
30
  const authOutputSchema = {
28
31
  authenticated: z.boolean().describe("True only when a credential is present AND a live identity probe succeeded."),
29
32
  credential: z
30
- .enum(["none", "api_key", "oauth_session_token", "session_cookie"])
31
- .describe("Kind of credential the server is running with, derived from its prefix. Never includes the token itself."),
33
+ .enum(["none", "api_key", "oauth_session_token", "session_cookie", "hosted_oauth"])
34
+ .describe("Kind of credential the server is running with. Never includes the token itself."),
32
35
  source: z
33
36
  .string()
34
37
  .optional()
@@ -96,7 +99,9 @@ export function registerAuthStatus(server, client, toolsRegistered) {
96
99
  ...base,
97
100
  authenticated: false,
98
101
  error: e.message,
99
- detail: `A ${cred.kind === "api_key" ? "tk_… API key" : "credential"} is set (from ${cred.source}) but the identity probe failed — it may be expired, revoked, or the endpoint may be wrong. ${CREDENTIAL_HELP.none}`,
102
+ detail: cred.kind === "hosted_oauth"
103
+ ? "The hosted OAuth session is present, but the identity probe failed. Run MCP login again."
104
+ : `A ${cred.kind === "api_key" ? "tk_… API key" : "credential"} is set (from ${cred.source}) but the identity probe failed — it may be expired, revoked, or the endpoint may be wrong. ${CREDENTIAL_HELP.none}`,
100
105
  };
101
106
  return { structuredContent: result, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
102
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenkicloud/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.LuxorLabs/tenki-mcp",
5
5
  "description": "Model Context Protocol server for Tenki Cloud — disposable microVM sandboxes for AI agents. Create sandboxes, run code, read/write files, run git, expose preview URLs — from any MCP client.",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "prepack": "npm run build",
22
22
  "prepublishOnly": "npm run build",
23
23
  "pretest": "npm run build",
24
- "test": "node test/offline.test.mjs && node test/public-shapes.test.mjs && node test/exec-output.test.mjs && node test/http-input.test.mjs && node test/security.test.mjs && node test/client-net.test.mjs",
24
+ "test": "node test/offline.test.mjs && node test/public-shapes.test.mjs && node test/exec-output.test.mjs && node test/http-input.test.mjs && node test/oauth-http.test.mjs && node test/security.test.mjs && node test/client-net.test.mjs",
25
25
  "test:all": "npm run build && node test/run.mjs",
26
26
  "test:offline": "npm run build && node test/offline.test.mjs"
27
27
  },