@luxorai/mcp 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +13 -8
- package/SECURITY.md +2 -2
- package/dist/client.d.ts +16 -1
- package/dist/client.js +62 -16
- package/dist/env.d.ts +6 -0
- package/dist/env.js +25 -0
- package/dist/http.js +13 -10
- package/dist/index.js +9 -8
- package/dist/oauth.d.ts +7 -1
- package/dist/oauth.js +29 -9
- package/dist/server.d.ts +25 -5
- package/dist/server.js +88 -53
- package/dist/tools/artifacts.d.ts +3 -3
- package/dist/tools/artifacts.js +6 -6
- package/dist/tools/auth_status.d.ts +3 -3
- package/dist/tools/auth_status.js +8 -9
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +78 -0
- package/dist/tools/egress.d.ts +38 -0
- package/dist/tools/egress.js +153 -0
- package/dist/tools/exec.js +1 -1
- package/dist/tools/files.js +36 -3
- package/dist/tools/files_ops.js +9 -1
- package/dist/tools/git.d.ts +0 -1
- package/dist/tools/git.js +85 -23
- package/dist/tools/ports.d.ts +8 -1
- package/dist/tools/ports.js +24 -3
- package/dist/tools/previews.js +59 -22
- package/dist/tools/sandboxes.d.ts +1 -1
- package/dist/tools/sandboxes.js +342 -26
- package/dist/tools/sessions_admin.js +63 -23
- package/dist/tools/snapshots.js +41 -22
- package/dist/tools/ssh.d.ts +3 -3
- package/dist/tools/ssh.js +35 -9
- package/dist/tools/templates.d.ts +2 -1
- package/dist/tools/templates.js +36 -16
- package/dist/tools/titles.d.ts +8 -0
- package/dist/tools/titles.js +102 -0
- package/dist/tools/volumes.js +36 -11
- package/dist/tools/workspace.d.ts +9 -1
- package/dist/tools/workspace.js +29 -90
- package/package.json +5 -4
package/dist/server.d.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Security controls (see SECURITY.md; maps to CSA MCP Server Top-10 MCP-07
|
|
6
6
|
* "excessive permissions"). Every tool is registered through a guard that:
|
|
7
|
-
* 1.
|
|
8
|
-
* so clients can surface
|
|
7
|
+
* 1. gives it a human-readable title (tools/titles.ts) and MCP annotations
|
|
8
|
+
* (readOnlyHint / destructiveHint / openWorldHint) so clients can surface
|
|
9
|
+
* or gate dangerous tools;
|
|
9
10
|
* 2. enforces least-privilege via env:
|
|
10
11
|
* TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
|
|
11
12
|
* TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
|
|
@@ -14,9 +15,28 @@
|
|
|
14
15
|
*/
|
|
15
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
17
|
import type { TenkiClient } from "./client.js";
|
|
17
|
-
export declare const VERSION = "0.
|
|
18
|
-
|
|
18
|
+
export declare const VERSION = "0.4.0";
|
|
19
|
+
/**
|
|
20
|
+
* Tool classes, mapped to MCP annotations by annotationsFor():
|
|
21
|
+
* read → readOnlyHint: true. Pure inspection: no state change, no spend.
|
|
22
|
+
* additive → readOnlyHint: false, destructiveHint: false. Only creates new
|
|
23
|
+
* things or extends existing ones (a sandbox, a snapshot, more
|
|
24
|
+
* lifetime, a keep-alive); never changes or removes what exists.
|
|
25
|
+
* destructive → readOnlyHint: false, destructiveHint: true. May overwrite,
|
|
26
|
+
* modify, interrupt or delete existing state.
|
|
27
|
+
*
|
|
28
|
+
* Destructive is the DEFAULT for anything that is not a read: a non-read tool
|
|
29
|
+
* is additive only when it is listed in ADDITIVE. MCP defines
|
|
30
|
+
* `destructiveHint: false` as "performs only additive updates", and clients use
|
|
31
|
+
* it to decide whether to ask before a call, so an unlisted new tool must fail
|
|
32
|
+
* safe (prompt) rather than claim to be harmless.
|
|
33
|
+
*/
|
|
34
|
+
type Cls = "read" | "additive" | "destructive";
|
|
19
35
|
export declare function classifyTool(name: string): Cls;
|
|
36
|
+
/** Title for a tool: the TOOL_TITLES entry, else one derived from the name so
|
|
37
|
+
* a tool is never advertised without a title (the offline suite fails on a
|
|
38
|
+
* missing entry, so the fallback only guards production). */
|
|
39
|
+
export declare function titleFor(name: string): string;
|
|
20
40
|
/**
|
|
21
41
|
* Build a fresh MCP server instance with all tools registered against `client`.
|
|
22
42
|
*
|
|
@@ -24,7 +44,7 @@ export declare function classifyTool(name: string): Cls;
|
|
|
24
44
|
* start — which MCP clients report as an opaque "server failed to start" —
|
|
25
45
|
* the server boots with ONLY tenki_auth_status registered, so an agent can
|
|
26
46
|
* discover and explain the missing credential. Registering the other tools in
|
|
27
|
-
* that state would offer
|
|
47
|
+
* that state would offer 82 tools that can only fail.
|
|
28
48
|
*/
|
|
29
49
|
export declare function createServer(client: TenkiClient | null): McpServer;
|
|
30
50
|
export {};
|
package/dist/server.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Security controls (see SECURITY.md; maps to CSA MCP Server Top-10 MCP-07
|
|
6
6
|
* "excessive permissions"). Every tool is registered through a guard that:
|
|
7
|
-
* 1.
|
|
8
|
-
* so clients can surface
|
|
7
|
+
* 1. gives it a human-readable title (tools/titles.ts) and MCP annotations
|
|
8
|
+
* (readOnlyHint / destructiveHint / openWorldHint) so clients can surface
|
|
9
|
+
* or gate dangerous tools;
|
|
9
10
|
* 2. enforces least-privilege via env:
|
|
10
11
|
* TENKI_MCP_READONLY=1 → register ONLY read tools (no create/run/delete/spend)
|
|
11
12
|
* TENKI_MCP_DISABLED_TOOLS=a,b → skip these named tools
|
|
@@ -29,8 +30,11 @@ import { registerTemplates } from "./tools/templates.js";
|
|
|
29
30
|
import { registerWorkspace } from "./tools/workspace.js";
|
|
30
31
|
import { registerArtifacts } from "./tools/artifacts.js";
|
|
31
32
|
import { registerSsh } from "./tools/ssh.js";
|
|
33
|
+
import { registerEgress } from "./tools/egress.js";
|
|
32
34
|
import { registerAuthStatus } from "./tools/auth_status.js";
|
|
33
|
-
|
|
35
|
+
import { readEnv } from "./env.js";
|
|
36
|
+
import { TOOL_TITLES } from "./tools/titles.js";
|
|
37
|
+
export const VERSION = "0.4.0";
|
|
34
38
|
const modules = [
|
|
35
39
|
registerIdentity,
|
|
36
40
|
registerRun,
|
|
@@ -44,40 +48,75 @@ const modules = [
|
|
|
44
48
|
registerPreviews,
|
|
45
49
|
registerSnapshots,
|
|
46
50
|
registerVolumes,
|
|
51
|
+
registerEgress,
|
|
47
52
|
registerTemplates,
|
|
48
53
|
registerWorkspace,
|
|
49
54
|
registerArtifacts,
|
|
50
55
|
registerSsh,
|
|
51
56
|
];
|
|
52
|
-
// Destroys/removes/revokes a resource
|
|
57
|
+
// Destroys/removes/revokes a resource. Checked first, so a name with one of
|
|
58
|
+
// these prefixes is destructive even if it is mistakenly listed in ADDITIVE.
|
|
53
59
|
const DESTRUCTIVE = /^tenki_(terminate|delete|remove|unshare|revoke|detach|unexpose|unbind)/;
|
|
54
|
-
// Named exceptions that match the READ prefix below but actually grant a
|
|
55
|
-
// write/spend capability, so they must never be treated as read-only.
|
|
56
|
-
// tenki_get_upload_url returns a signed URL for an arbitrary PUT into the sandbox.
|
|
57
|
-
const WRITE_OVERRIDE = new Set(["tenki_get_upload_url"]);
|
|
58
|
-
// Named exceptions that are pure reads but don't match the READ prefix below.
|
|
59
|
-
// tenki_auth_status only inspects the ambient credential + probes WhoAmI, so it
|
|
60
|
-
// must stay available under TENKI_MCP_READONLY (it is how an operator diagnoses
|
|
61
|
-
// a credential problem in that posture).
|
|
62
|
-
const READ_OVERRIDE = new Set(["tenki_auth_status"]);
|
|
63
60
|
// Pure inspection, no state change / no spend → readOnlyHint.
|
|
64
61
|
const READ = /^tenki_(get|list|whoami|resolve|stat|read)/;
|
|
62
|
+
// Names that match the READ prefix but are not reads. tenki_get_upload_url
|
|
63
|
+
// returns a signed URL for an HTTP PUT that can overwrite a file in the sandbox.
|
|
64
|
+
const NOT_READ = new Set(["tenki_get_upload_url"]);
|
|
65
|
+
// Pure reads that don't match the READ prefix. tenki_auth_status only inspects
|
|
66
|
+
// the ambient credential + probes WhoAmI, so it must stay available under
|
|
67
|
+
// TENKI_MCP_READONLY (it is how an operator diagnoses a credential problem in
|
|
68
|
+
// that posture). git diff/log only read the repository.
|
|
69
|
+
const READ_OVERRIDE = new Set(["tenki_auth_status", "tenki_git_diff", "tenki_git_log"]);
|
|
70
|
+
// Non-read tools that only add. Everything else that is not a read is
|
|
71
|
+
// destructive — see the class comment above for why that is the default.
|
|
72
|
+
const ADDITIVE = new Set([
|
|
73
|
+
"tenki_run_code", // boots, uses and tears down its own throwaway sandbox
|
|
74
|
+
"tenki_create_sandbox",
|
|
75
|
+
"tenki_fork_sandbox", // the source keeps running; children are new sandboxes
|
|
76
|
+
"tenki_resume_sandbox",
|
|
77
|
+
"tenki_extend_sandbox",
|
|
78
|
+
"tenki_report_sandbox_activity", // keep-alive
|
|
79
|
+
"tenki_make_dir",
|
|
80
|
+
"tenki_git_clone", // git refuses a non-empty target directory
|
|
81
|
+
"tenki_expose_port",
|
|
82
|
+
"tenki_create_preview_url",
|
|
83
|
+
"tenki_touch_preview", // keep-alive
|
|
84
|
+
"tenki_create_snapshot",
|
|
85
|
+
"tenki_create_volume",
|
|
86
|
+
"tenki_attach_volume",
|
|
87
|
+
"tenki_create_egress_policy",
|
|
88
|
+
"tenki_create_template",
|
|
89
|
+
// Not tenki_build_template: a build with image_name publishes a new latest
|
|
90
|
+
// version, which changes what an existing `workspace/name` reference boots.
|
|
91
|
+
"tenki_issue_ssh_cert",
|
|
92
|
+
]);
|
|
65
93
|
export function classifyTool(name) {
|
|
66
94
|
if (DESTRUCTIVE.test(name))
|
|
67
95
|
return "destructive";
|
|
68
|
-
if (
|
|
69
|
-
return "write";
|
|
70
|
-
if (READ_OVERRIDE.has(name) || READ.test(name))
|
|
96
|
+
if (!NOT_READ.has(name) && (READ_OVERRIDE.has(name) || READ.test(name)))
|
|
71
97
|
return "read";
|
|
72
|
-
|
|
98
|
+
if (ADDITIVE.has(name))
|
|
99
|
+
return "additive";
|
|
100
|
+
return "destructive";
|
|
101
|
+
}
|
|
102
|
+
/** Title for a tool: the TOOL_TITLES entry, else one derived from the name so
|
|
103
|
+
* a tool is never advertised without a title (the offline suite fails on a
|
|
104
|
+
* missing entry, so the fallback only guards production). */
|
|
105
|
+
export function titleFor(name) {
|
|
106
|
+
return (TOOL_TITLES[name] ??
|
|
107
|
+
name
|
|
108
|
+
.replace(/^tenki_/, "")
|
|
109
|
+
.split("_")
|
|
110
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
111
|
+
.join(" "));
|
|
73
112
|
}
|
|
74
113
|
function readGuardOpts() {
|
|
75
114
|
const truthy = (v) => v === "1" || (v ?? "").toLowerCase() === "true";
|
|
76
|
-
const disabled = new Set((
|
|
115
|
+
const disabled = new Set((readEnv("MCP_DISABLED_TOOLS") || "")
|
|
77
116
|
.split(",")
|
|
78
117
|
.map((s) => s.trim())
|
|
79
118
|
.filter(Boolean));
|
|
80
|
-
return { readonly: truthy(
|
|
119
|
+
return { readonly: truthy(readEnv("MCP_READONLY")), disabled, audit: truthy(readEnv("MCP_AUDIT")) };
|
|
81
120
|
}
|
|
82
121
|
/** Log a tool call's name + argument KEYS (never values/content/token) to stderr. */
|
|
83
122
|
function auditKeys(args) {
|
|
@@ -87,8 +126,9 @@ function auditKeys(args) {
|
|
|
87
126
|
return keys.length ? ` args=[${keys.join(",")}]` : "";
|
|
88
127
|
}
|
|
89
128
|
/** MCP annotations derived from a tool's classification. */
|
|
90
|
-
function annotationsFor(cls) {
|
|
129
|
+
function annotationsFor(name, cls) {
|
|
91
130
|
return {
|
|
131
|
+
title: titleFor(name),
|
|
92
132
|
readOnlyHint: cls === "read",
|
|
93
133
|
destructiveHint: cls === "destructive",
|
|
94
134
|
idempotentHint: cls === "read",
|
|
@@ -128,45 +168,40 @@ function noopToolHandle() {
|
|
|
128
168
|
};
|
|
129
169
|
}
|
|
130
170
|
/**
|
|
131
|
-
* Wrap a server so every tool registration from a module is annotated
|
|
132
|
-
* to the least-privilege env controls above. Both registration APIs
|
|
133
|
-
* the legacy `.tool(name, description, schema, handler)` form and
|
|
134
|
-
* `.registerTool(name, config, handler)` form (the only one that
|
|
135
|
-
* outputSchema) — so neither path can bypass
|
|
136
|
-
* the denylist.
|
|
171
|
+
* Wrap a server so every tool registration from a module is titled, annotated
|
|
172
|
+
* and subject to the least-privilege env controls above. Both registration APIs
|
|
173
|
+
* are guarded: the legacy `.tool(name, description, schema, handler)` form and
|
|
174
|
+
* the modern `.registerTool(name, config, handler)` form (the only one that
|
|
175
|
+
* accepts an outputSchema) — so neither path can bypass titles, annotations,
|
|
176
|
+
* read-only mode, or the denylist. Legacy calls are re-issued as registerTool,
|
|
177
|
+
* because the SDK's tool() overloads have no title parameter (MCP `Tool.title`
|
|
178
|
+
* would be undefined). Other access passes through to the real server unchanged.
|
|
137
179
|
*/
|
|
138
180
|
function guard(server, opts) {
|
|
181
|
+
const register = (target, name, config, handler) => {
|
|
182
|
+
const cls = classifyTool(name);
|
|
183
|
+
if (opts.disabled.has(name))
|
|
184
|
+
return noopToolHandle(); // explicit denylist
|
|
185
|
+
if (opts.readonly && cls !== "read")
|
|
186
|
+
return noopToolHandle(); // read-only posture: skip anything that mutates/spends
|
|
187
|
+
if (opts.registered)
|
|
188
|
+
opts.registered.count++;
|
|
189
|
+
// Name-derived classification and title stay authoritative so a module
|
|
190
|
+
// cannot soften the hints; other annotation fields pass through.
|
|
191
|
+
const annotations = {
|
|
192
|
+
...config.annotations,
|
|
193
|
+
...annotationsFor(name, cls),
|
|
194
|
+
};
|
|
195
|
+
return target.registerTool(name, { ...config, title: titleFor(name), annotations }, withAudit(name, handler, opts.audit));
|
|
196
|
+
};
|
|
139
197
|
return new Proxy(server, {
|
|
140
198
|
get(target, prop, receiver) {
|
|
141
199
|
if (prop === "tool") {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (opts.disabled.has(name))
|
|
145
|
-
return noopToolHandle(); // explicit denylist
|
|
146
|
-
if (opts.readonly && cls !== "read")
|
|
147
|
-
return noopToolHandle(); // read-only posture: skip anything that mutates/spends
|
|
148
|
-
if (opts.registered)
|
|
149
|
-
opts.registered.count++;
|
|
150
|
-
return target.tool(name, description, schema, annotationsFor(cls), withAudit(name, handler, opts.audit));
|
|
151
|
-
};
|
|
200
|
+
// Every module uses the four-argument form; a schema-less tool passes {}.
|
|
201
|
+
return (name, description, schema, handler) => register(target, name, { description, inputSchema: schema }, handler);
|
|
152
202
|
}
|
|
153
203
|
if (prop === "registerTool") {
|
|
154
|
-
return (name, config, handler) =>
|
|
155
|
-
const cls = classifyTool(name);
|
|
156
|
-
if (opts.disabled.has(name))
|
|
157
|
-
return noopToolHandle(); // explicit denylist
|
|
158
|
-
if (opts.readonly && cls !== "read")
|
|
159
|
-
return noopToolHandle(); // read-only posture: skip anything that mutates/spends
|
|
160
|
-
// Name-derived classification stays authoritative for the four hints so
|
|
161
|
-
// a module cannot soften them; other annotation fields pass through.
|
|
162
|
-
if (opts.registered)
|
|
163
|
-
opts.registered.count++;
|
|
164
|
-
const annotations = {
|
|
165
|
-
...config.annotations,
|
|
166
|
-
...annotationsFor(cls),
|
|
167
|
-
};
|
|
168
|
-
return target.registerTool(name, { ...config, annotations }, withAudit(name, handler, opts.audit));
|
|
169
|
-
};
|
|
204
|
+
return (name, config, handler) => register(target, name, config, handler);
|
|
170
205
|
}
|
|
171
206
|
return Reflect.get(target, prop, receiver);
|
|
172
207
|
},
|
|
@@ -179,7 +214,7 @@ function guard(server, opts) {
|
|
|
179
214
|
* start — which MCP clients report as an opaque "server failed to start" —
|
|
180
215
|
* the server boots with ONLY tenki_auth_status registered, so an agent can
|
|
181
216
|
* discover and explain the missing credential. Registering the other tools in
|
|
182
|
-
* that state would offer
|
|
217
|
+
* that state would offer 82 tools that can only fail.
|
|
183
218
|
*/
|
|
184
219
|
export function createServer(client) {
|
|
185
220
|
const server = new McpServer({ name: "tenki", version: VERSION });
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* GetArtifactDownloadUrl to GET one out. These tools return the signed URL; the
|
|
8
8
|
* caller performs the actual HTTP PUT/GET.
|
|
9
9
|
*
|
|
10
|
-
* GetArtifactUploadUrlRequest {
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* GetArtifactUploadUrlRequest is { session_id, path, content_type }.
|
|
11
|
+
* GetArtifactDownloadUrlRequest is { artifact_id } ONLY — there is no session_id
|
|
12
|
+
* field (the API discards unknown fields silently), so the tool no longer requires one.
|
|
13
13
|
*/
|
|
14
14
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
15
|
import type { TenkiClient } from "../client.js";
|
package/dist/tools/artifacts.js
CHANGED
|
@@ -10,10 +10,10 @@ export function registerArtifacts(server, client) {
|
|
|
10
10
|
path,
|
|
11
11
|
...(content_type ? { contentType: content_type } : {}),
|
|
12
12
|
})));
|
|
13
|
-
server.tool("tenki_get_download_url", "Get a short-lived signed URL to download (HTTP GET)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
async ({
|
|
13
|
+
server.tool("tenki_get_download_url", "Get a short-lived signed URL to download (HTTP GET) an artifact by its artifact id (e.g. the artifactId returned by tenki_get_upload_url, or a template build's buildLogArtifactId). The API supports download-by-artifact-id only, not download-by-path.", {
|
|
14
|
+
artifact_id: z.string().describe("Artifact id (UUID) to download."),
|
|
15
|
+
session_id: sessionIdSchema
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Ignored — kept for backwards compatibility; the API addresses artifacts by id alone."),
|
|
18
|
+
}, async ({ artifact_id }) => ok(await client.control("GetArtifactDownloadUrl", { artifactId: artifact_id })));
|
|
19
19
|
}
|
|
@@ -18,13 +18,13 @@ export type CredentialKind = "none" | "api_key" | "oauth_session_token" | "sessi
|
|
|
18
18
|
export interface CredentialInfo {
|
|
19
19
|
kind: CredentialKind;
|
|
20
20
|
/** Env var the token came from, or undefined when there is none. */
|
|
21
|
-
source?: "TENKI_AUTH_TOKEN" | "TENKI_API_KEY";
|
|
21
|
+
source?: "LUXORAI_AUTH_TOKEN" | "LUXORAI_API_KEY" | "TENKI_AUTH_TOKEN" | "TENKI_API_KEY";
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
24
|
* Classify the ambient credential WITHOUT returning any of its material.
|
|
25
|
-
* Mirrors the header selection in client.ts (`tk_` → Bearer API key,
|
|
25
|
+
* Mirrors the header selection in client.ts (`lx_`/`tk_` → Bearer API key,
|
|
26
26
|
* `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
|
|
27
|
-
* index.ts's precedence (
|
|
27
|
+
* index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, TENKI_API_KEY).
|
|
28
28
|
*/
|
|
29
29
|
export declare function describeCredential(env?: NodeJS.ProcessEnv): CredentialInfo;
|
|
30
30
|
/**
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { isApiKey, readEnv, resolvedEnvName } from "../env.js";
|
|
2
3
|
/**
|
|
3
4
|
* Classify the ambient credential WITHOUT returning any of its material.
|
|
4
|
-
* Mirrors the header selection in client.ts (`tk_` → Bearer API key,
|
|
5
|
+
* Mirrors the header selection in client.ts (`lx_`/`tk_` → Bearer API key,
|
|
5
6
|
* `ory_st_` → OAuth/Ory session token, anything else → session cookie) and
|
|
6
|
-
* index.ts's precedence (
|
|
7
|
+
* index.ts's precedence (LUXORAI_AUTH_TOKEN, LUXORAI_API_KEY, TENKI_AUTH_TOKEN, TENKI_API_KEY).
|
|
7
8
|
*/
|
|
8
9
|
export function describeCredential(env = process.env) {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
if (!raw && env.TENKI_MCP_OAUTH_ISSUER?.trim())
|
|
10
|
+
const source = resolvedEnvName(env, "AUTH_TOKEN", "API_KEY");
|
|
11
|
+
const raw = source ? env[source].trim() : "";
|
|
12
|
+
if (!raw && resolvedEnvName(env, "MCP_OAUTH_ISSUER"))
|
|
13
13
|
return { kind: "hosted_oauth" };
|
|
14
14
|
if (!raw)
|
|
15
15
|
return { kind: "none" };
|
|
16
|
-
|
|
17
|
-
if (raw.startsWith("tk_"))
|
|
16
|
+
if (isApiKey(raw))
|
|
18
17
|
return { kind: "api_key", source };
|
|
19
18
|
if (raw.startsWith("ory_st_"))
|
|
20
19
|
return { kind: "oauth_session_token", source };
|
|
@@ -64,7 +63,7 @@ export function registerAuthStatus(server, client, toolsRegistered) {
|
|
|
64
63
|
outputSchema: authOutputSchema,
|
|
65
64
|
}, async () => {
|
|
66
65
|
const cred = describeCredential();
|
|
67
|
-
const endpoint =
|
|
66
|
+
const endpoint = readEnv("API_ENDPOINT", "API_URL") || "https://api.tenki.cloud";
|
|
68
67
|
const base = {
|
|
69
68
|
credential: cred.kind,
|
|
70
69
|
...(cred.source ? { source: cred.source } : {}),
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -8,6 +8,19 @@ export declare const ok: (value: unknown) => {
|
|
|
8
8
|
text: string;
|
|
9
9
|
}[];
|
|
10
10
|
};
|
|
11
|
+
/** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
|
|
12
|
+
export declare function utf8CharStart(buf: Buffer, i: number): number;
|
|
13
|
+
/** Largest end ≤ limit that does not split a character; always > start when limit > start. */
|
|
14
|
+
export declare function utf8CharEnd(buf: Buffer, start: number, limit: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
|
|
17
|
+
* text unchanged when it fits; otherwise the head plus the original size.
|
|
18
|
+
*/
|
|
19
|
+
export declare function capText(text: string, maxBytes: number): {
|
|
20
|
+
text: string;
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
totalBytes: number;
|
|
23
|
+
};
|
|
11
24
|
/** Shared env-map schema used by tools that accept environment variables. */
|
|
12
25
|
export declare const envSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
13
26
|
/** Shared session-id schema — every per-sandbox tool takes one of these.
|
|
@@ -33,3 +46,25 @@ export declare const pathSchema: z.ZodString;
|
|
|
33
46
|
* those stay server-side.
|
|
34
47
|
*/
|
|
35
48
|
export declare const slugSchema: z.ZodString;
|
|
49
|
+
/**
|
|
50
|
+
* Shared tag-list schema, matching the API's repeated-string constraint on
|
|
51
|
+
* sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
|
|
52
|
+
* [a-z0-9_:.-] starting with a letter or digit.
|
|
53
|
+
*/
|
|
54
|
+
export declare const tagsSchema: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
+
/**
|
|
56
|
+
* Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
|
|
57
|
+
* volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
|
|
58
|
+
* a non-empty list together with clear_tags is rejected because the server
|
|
59
|
+
* applies clear_tags first and would silently discard the list.
|
|
60
|
+
*/
|
|
61
|
+
export declare function tagsPatch(tags?: string[], clearTags?: boolean): {
|
|
62
|
+
tags?: string[];
|
|
63
|
+
clearTags?: true;
|
|
64
|
+
};
|
|
65
|
+
/** Free-text search across a list (server-side; API max 256 chars). */
|
|
66
|
+
export declare const searchSchema: z.ZodOptional<z.ZodString>;
|
|
67
|
+
/** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
|
|
68
|
+
export declare const protoEnum: (prefix: string, value: string) => string;
|
|
69
|
+
/** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
|
|
70
|
+
export declare const listQuery: (search?: string, includeFacets?: boolean, sortDesc?: boolean) => Record<string, unknown>;
|
package/dist/tools/common.js
CHANGED
|
@@ -8,6 +8,8 @@ const PUBLIC_KEYS = {
|
|
|
8
8
|
source_registry_workspace_id: "source_image_workspace_id",
|
|
9
9
|
sourceRegistryRef: "sourceImage",
|
|
10
10
|
source_registry_ref: "source_image",
|
|
11
|
+
registryImageName: "publishedImageName",
|
|
12
|
+
registry_image_name: "published_image_name",
|
|
11
13
|
};
|
|
12
14
|
/** Keep registry-backed implementation fields out of public MCP responses. */
|
|
13
15
|
export function publicValue(value) {
|
|
@@ -21,6 +23,48 @@ export function publicValue(value) {
|
|
|
21
23
|
export const ok = (value) => ({
|
|
22
24
|
content: [{ type: "text", text: JSON.stringify(publicValue(value), null, 2) }],
|
|
23
25
|
});
|
|
26
|
+
// UTF-8 boundary handling for byte-capped text. A position is "inside a
|
|
27
|
+
// character" only when a lead byte at most 3 bytes before it declares a
|
|
28
|
+
// sequence long enough to cover it. Stray continuation bytes (binary data,
|
|
29
|
+
// invalid UTF-8) are not inside anything, so a cut lands exactly there. Start
|
|
30
|
+
// and end use the same rule, so consecutive pages tile the file with no gap or
|
|
31
|
+
// overlap, on valid UTF-8 and on binary alike.
|
|
32
|
+
const isContinuation = (b) => (b & 0xc0) === 0x80;
|
|
33
|
+
/** Sequence length a lead byte declares (1 for ASCII and invalid leads). */
|
|
34
|
+
const utf8SeqLen = (b) => (b >= 0xf0 && b <= 0xf7 ? 4 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xc0 && b <= 0xdf ? 2 : 1);
|
|
35
|
+
/** Start of the character covering i, or -1 when i is a character start or a stray byte. */
|
|
36
|
+
function coveringLead(buf, i, floor) {
|
|
37
|
+
if (i >= buf.length || !isContinuation(buf[i]))
|
|
38
|
+
return -1;
|
|
39
|
+
for (let back = 1; back <= 3 && i - back >= floor; back++) {
|
|
40
|
+
const b = buf[i - back];
|
|
41
|
+
if (!isContinuation(b))
|
|
42
|
+
return utf8SeqLen(b) > back ? i - back : -1;
|
|
43
|
+
}
|
|
44
|
+
return -1;
|
|
45
|
+
}
|
|
46
|
+
/** First index ≥ i that starts a character: i itself unless i is inside one, then that character's end. */
|
|
47
|
+
export function utf8CharStart(buf, i) {
|
|
48
|
+
const lead = coveringLead(buf, i, 0);
|
|
49
|
+
return lead < 0 ? i : Math.min(lead + utf8SeqLen(buf[lead]), buf.length);
|
|
50
|
+
}
|
|
51
|
+
/** Largest end ≤ limit that does not split a character; always > start when limit > start. */
|
|
52
|
+
export function utf8CharEnd(buf, start, limit) {
|
|
53
|
+
if (limit >= buf.length)
|
|
54
|
+
return buf.length;
|
|
55
|
+
const lead = coveringLead(buf, limit, start + 1);
|
|
56
|
+
return lead < 0 ? limit : lead;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cap a text field at maxBytes without splitting a UTF-8 character. Returns the
|
|
60
|
+
* text unchanged when it fits; otherwise the head plus the original size.
|
|
61
|
+
*/
|
|
62
|
+
export function capText(text, maxBytes) {
|
|
63
|
+
const buf = Buffer.from(text, "utf8");
|
|
64
|
+
if (buf.length <= maxBytes)
|
|
65
|
+
return { text, truncated: false, totalBytes: buf.length };
|
|
66
|
+
return { text: buf.subarray(0, utf8CharEnd(buf, 0, maxBytes)).toString("utf8"), truncated: true, totalBytes: buf.length };
|
|
67
|
+
}
|
|
24
68
|
/** Shared env-map schema used by tools that accept environment variables. */
|
|
25
69
|
export const envSchema = z.record(z.string(), z.string()).optional().describe("Environment variables as a key→value object.");
|
|
26
70
|
/** Shared session-id schema — every per-sandbox tool takes one of these.
|
|
@@ -58,3 +102,37 @@ export const slugSchema = z
|
|
|
58
102
|
.max(63)
|
|
59
103
|
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits, and hyphens; cannot start or end with a hyphen")
|
|
60
104
|
.describe("Subdomain slug for the preview URL (3-63 chars, lowercase letters/digits/hyphens, no leading/trailing hyphen).");
|
|
105
|
+
/**
|
|
106
|
+
* Shared tag-list schema, matching the API's repeated-string constraint on
|
|
107
|
+
* sessions, snapshots, volumes and templates: ≤20 tags, each ≤32 chars of
|
|
108
|
+
* [a-z0-9_:.-] starting with a letter or digit.
|
|
109
|
+
*/
|
|
110
|
+
export const tagsSchema = z
|
|
111
|
+
.array(z.string().max(32).regex(/^[a-z0-9][a-z0-9_:.-]*$/, "lowercase letters, digits, and _:.- ; must start with a letter or digit"))
|
|
112
|
+
.max(20)
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Tags (≤20, each ≤32 chars of a-z 0-9 _ : . -).");
|
|
115
|
+
/**
|
|
116
|
+
* Wire mapping for the tags/clear_tags pair shared by sessions, snapshots and
|
|
117
|
+
* volumes. proto3 drops an empty repeated field, so `[]` becomes clear_tags;
|
|
118
|
+
* a non-empty list together with clear_tags is rejected because the server
|
|
119
|
+
* applies clear_tags first and would silently discard the list.
|
|
120
|
+
*/
|
|
121
|
+
export function tagsPatch(tags, clearTags) {
|
|
122
|
+
if (clearTags && tags && tags.length) {
|
|
123
|
+
throw new Error("pass either tags (a replacement list) or clear_tags, not both — clear_tags would win and the tags would be silently dropped.");
|
|
124
|
+
}
|
|
125
|
+
if (clearTags || (tags !== undefined && tags.length === 0))
|
|
126
|
+
return { clearTags: true };
|
|
127
|
+
return tags && tags.length ? { tags } : {};
|
|
128
|
+
}
|
|
129
|
+
/** Free-text search across a list (server-side; API max 256 chars). */
|
|
130
|
+
export const searchSchema = z.string().max(256).optional();
|
|
131
|
+
/** Prefix a bare enum name the way protobuf JSON expects it (e.g. RUNNING → SESSION_STATE_RUNNING). */
|
|
132
|
+
export const protoEnum = (prefix, value) => `${prefix}_${value}`;
|
|
133
|
+
/** Common server-side list options shared by the sandbox, volume, template and preview-URL lists. */
|
|
134
|
+
export const listQuery = (search, includeFacets, sortDesc) => ({
|
|
135
|
+
...(search && search.trim() ? { search: search.trim() } : {}),
|
|
136
|
+
...(includeFacets ? { includeFacets: true } : {}),
|
|
137
|
+
...(sortDesc ? { sortDesc: true } : {}),
|
|
138
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { TenkiClient } from "../client.js";
|
|
4
|
+
export declare const policyNameSchema: z.ZodString;
|
|
5
|
+
export declare const httpRuleSchema: z.ZodObject<{
|
|
6
|
+
domains: z.ZodArray<z.ZodString>;
|
|
7
|
+
methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8
|
+
path_exact: z.ZodOptional<z.ZodString>;
|
|
9
|
+
path_prefix: z.ZodOptional<z.ZodString>;
|
|
10
|
+
path_regex: z.ZodOptional<z.ZodString>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
export type HttpRule = z.infer<typeof httpRuleSchema>;
|
|
13
|
+
export declare const httpRuleToWire: (r: HttpRule) => Record<string, unknown>;
|
|
14
|
+
export declare const allowDomainsSchema: z.ZodArray<z.ZodString>;
|
|
15
|
+
export declare const allowCidrsSchema: z.ZodArray<z.ZodString>;
|
|
16
|
+
export declare const interceptHttpsSchema: z.ZodBoolean;
|
|
17
|
+
export declare const httpRulesSchema: z.ZodArray<z.ZodObject<{
|
|
18
|
+
domains: z.ZodArray<z.ZodString>;
|
|
19
|
+
methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
20
|
+
path_exact: z.ZodOptional<z.ZodString>;
|
|
21
|
+
path_prefix: z.ZodOptional<z.ZodString>;
|
|
22
|
+
path_regex: z.ZodOptional<z.ZodString>;
|
|
23
|
+
}, z.core.$strict>>;
|
|
24
|
+
export declare const allowedPortsSchema: z.ZodArray<z.ZodNumber>;
|
|
25
|
+
export interface EgressBody {
|
|
26
|
+
allow_domains?: string[];
|
|
27
|
+
allow_cidrs?: string[];
|
|
28
|
+
intercept_https?: boolean;
|
|
29
|
+
http_rules?: HttpRule[];
|
|
30
|
+
allowed_ports?: number[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Map an egress body to SessionEgressPolicy JSON. `requireComplete` applies the
|
|
34
|
+
* checks the API runs on a policy that stands alone; on create they are skipped
|
|
35
|
+
* when named policies are pinned, because the API merges them first.
|
|
36
|
+
*/
|
|
37
|
+
export declare function egressBodyToWire(tool: string, b: EgressBody, requireComplete: boolean): Record<string, unknown> | undefined;
|
|
38
|
+
export declare function registerEgress(server: McpServer, client: TenkiClient): void;
|