@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 +30 -2
- package/SECURITY.md +6 -6
- package/dist/client.d.ts +7 -0
- package/dist/client.js +14 -3
- package/dist/http.d.ts +4 -6
- package/dist/http.js +80 -20
- package/dist/index.js +2 -1
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/auth_status.d.ts +2 -2
- package/dist/tools/auth_status.js +8 -3
- package/package.json +2 -2
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
|
-
|
|
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`.
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
-
|
|
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 |
|
|
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 |
|
|
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", ...
|
|
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 =
|
|
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
|
|
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,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
|
-
|
|
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");
|
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
|
/**
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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
|
},
|