@luxorai/mcp 0.0.0-stage → 0.3.1
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 +46 -0
- package/LICENSE +21 -0
- package/README.md +216 -2
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +575 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +294 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.js +146 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +203 -0
- package/dist/tools/artifacts.d.ts +16 -0
- package/dist/tools/artifacts.js +19 -0
- package/dist/tools/auth_status.d.ts +35 -0
- package/dist/tools/auth_status.js +109 -0
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +60 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +120 -0
- package/dist/tools/files.d.ts +4 -0
- package/dist/tools/files.js +22 -0
- package/dist/tools/files_ops.d.ts +12 -0
- package/dist/tools/files_ops.js +54 -0
- package/dist/tools/git.d.ts +4 -0
- package/dist/tools/git.js +30 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +5 -0
- package/dist/tools/ports.d.ts +4 -0
- package/dist/tools/ports.js +6 -0
- package/dist/tools/previews.d.ts +18 -0
- package/dist/tools/previews.js +80 -0
- package/dist/tools/run.d.ts +4 -0
- package/dist/tools/run.js +11 -0
- package/dist/tools/sandboxes.d.ts +4 -0
- package/dist/tools/sandboxes.js +72 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +62 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +79 -0
- package/dist/tools/ssh.d.ts +15 -0
- package/dist/tools/ssh.js +17 -0
- package/dist/tools/templates.d.ts +14 -0
- package/dist/tools/templates.js +142 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +77 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- package/package.json +59 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, envSchema } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Template tools — custom-image templates and their builds.
|
|
5
|
+
*
|
|
6
|
+
* A "template" is a reusable sandbox-image spec (base image + setup script +
|
|
7
|
+
* default resources/env); building one produces a snapshot/image that sandboxes
|
|
8
|
+
* can boot from. Method names and request fields are matched to the generated
|
|
9
|
+
* `tenki.sandbox.v1.SandboxService` protobuf (the wire contract the control
|
|
10
|
+
* plane actually speaks). Notable shapes: sizing is a nested `resources` object
|
|
11
|
+
* ({ cpuCores, memoryMb, diskSizeGb }); the env map is `envVars`; a build is
|
|
12
|
+
* addressed by `buildId`; and ListActiveTemplateBuilds is scoped by `templateId`.
|
|
13
|
+
*/
|
|
14
|
+
export function registerTemplates(server, client) {
|
|
15
|
+
/** Assemble the nested TemplateResources object from flat sizing params (omitting any unset). */
|
|
16
|
+
const resourcesFrom = (cpuCores, memoryMb, diskSizeGb) => {
|
|
17
|
+
const r = {};
|
|
18
|
+
if (cpuCores !== undefined)
|
|
19
|
+
r.cpuCores = cpuCores;
|
|
20
|
+
if (memoryMb !== undefined)
|
|
21
|
+
r.memoryMb = memoryMb;
|
|
22
|
+
if (diskSizeGb !== undefined)
|
|
23
|
+
r.diskSizeGb = diskSizeGb;
|
|
24
|
+
return r;
|
|
25
|
+
};
|
|
26
|
+
// ── Create ──────────────────────────────────────────────────────────────────
|
|
27
|
+
server.tool("tenki_create_template", "Create a custom-image template (a reusable sandbox-image spec: base image + setup script + default resources). Build it into a bootable image later with tenki_build_template. NOTE: only a TYPED template (created with builder_spec, no legacy fields) can build a named, publishable image (image_name) that tenki_create_sandbox boots via its `image` arg.", {
|
|
28
|
+
name: z.string().describe("Human-readable template name."),
|
|
29
|
+
base_image_id: z.string().optional().describe("Base image ID to build on top of."),
|
|
30
|
+
setup_script: z.string().optional().describe("Shell script run at build time to provision the image. Required for a from-scratch template (the API rejects a create without it unless you derive from a parent template/image)."),
|
|
31
|
+
start_cmd: z.string().optional().describe("Command run when a sandbox boots from this template."),
|
|
32
|
+
cpu_cores: z.number().int().min(1).max(16).optional().describe("Default vCPUs for sandboxes from this template (1-16)."),
|
|
33
|
+
memory_mb: z.number().int().min(512).max(65536).optional().describe("Default memory in MB (512-65536)."),
|
|
34
|
+
disk_size_gb: z.number().int().min(5).max(100).optional().describe("Default disk in GB (5-100)."),
|
|
35
|
+
env_vars: envSchema,
|
|
36
|
+
tags: z.array(z.string()).optional().describe("Tags for later filtering."),
|
|
37
|
+
parent_template_id: z.string().optional().describe("Derive this template from an existing template."),
|
|
38
|
+
parent_image: z.string().optional().describe("Derive this template from an existing built image reference."),
|
|
39
|
+
builder_spec: z
|
|
40
|
+
.record(z.string(), z.unknown())
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Typed template spec, passed through as-is — e.g. {specVersion:'tenki.template.v1', base:{image:'sandbox'}, workdir:'/home/tenki', steps:[{run:{command:'...'}}], resources:{cpuCores,memoryMb,diskSizeGb}}. Mutually exclusive with base_image_id/setup_script/start_cmd/env_vars/cpu_cores/memory_mb/disk_size_gb/parent_* (the API rejects mixing). Required if the template's builds should publish an image (tenki_build_template image_name)."),
|
|
43
|
+
workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
|
|
44
|
+
}, async (a) => {
|
|
45
|
+
const owner = await client.resolveOwner();
|
|
46
|
+
const workspaceId = a.workspace_id ?? owner.workspaceId;
|
|
47
|
+
const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
|
|
48
|
+
const body = {
|
|
49
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
50
|
+
name: a.name,
|
|
51
|
+
...(a.base_image_id ? { baseImageId: a.base_image_id } : {}),
|
|
52
|
+
...(a.setup_script !== undefined ? { setupScript: a.setup_script } : {}),
|
|
53
|
+
...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
|
|
54
|
+
...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
|
|
55
|
+
...(Object.keys(resources).length ? { resources } : {}),
|
|
56
|
+
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
57
|
+
...(a.parent_template_id ? { parentTemplateId: a.parent_template_id } : {}),
|
|
58
|
+
...(a.parent_image ? { parentImage: a.parent_image } : {}),
|
|
59
|
+
...(a.builder_spec ? { builderSpec: a.builder_spec } : {}),
|
|
60
|
+
};
|
|
61
|
+
return ok(await client.control("CreateTemplate", body));
|
|
62
|
+
});
|
|
63
|
+
// ── Get ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
server.tool("tenki_get_template", "Retrieve one template by ID.", { template_id: z.string().describe("The template ID.") }, async ({ template_id }) => ok(await client.control("GetTemplate", { templateId: template_id })));
|
|
65
|
+
// ── List ────────────────────────────────────────────────────────────────────
|
|
66
|
+
server.tool("tenki_list_templates", "List templates for the workspace, optionally filtered by tags.", {
|
|
67
|
+
tags: z.array(z.string()).optional().describe("Only return templates that carry all of these tags."),
|
|
68
|
+
workspace_id: z.string().optional().describe("Workspace to list from (defaults to the key's first workspace)."),
|
|
69
|
+
page_size: z.number().int().positive().optional(),
|
|
70
|
+
page_token: z.string().optional(),
|
|
71
|
+
}, async ({ tags, workspace_id, page_size, page_token }) => {
|
|
72
|
+
const owner = await client.resolveOwner();
|
|
73
|
+
const workspaceId = workspace_id ?? owner.workspaceId;
|
|
74
|
+
return ok(await client.control("ListTemplates", {
|
|
75
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
76
|
+
...(tags && tags.length ? { tags } : {}),
|
|
77
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
78
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
79
|
+
}));
|
|
80
|
+
});
|
|
81
|
+
// ── Update ──────────────────────────────────────────────────────────────────
|
|
82
|
+
server.tool("tenki_update_template", "Update mutable fields on a template. Only the fields you provide are changed; pass clear_tags to remove all tags.", {
|
|
83
|
+
template_id: z.string().describe("The template ID to update."),
|
|
84
|
+
name: z.string().optional().describe("New human-readable name."),
|
|
85
|
+
base_image_id: z.string().optional().describe("New base image ID."),
|
|
86
|
+
setup_script: z.string().optional().describe("New build-time provisioning script."),
|
|
87
|
+
start_cmd: z.string().optional().describe("New boot command."),
|
|
88
|
+
cpu_cores: z.number().int().min(1).max(16).optional().describe("New default vCPUs (1-16)."),
|
|
89
|
+
memory_mb: z.number().int().min(512).max(65536).optional().describe("New default memory in MB (512-65536)."),
|
|
90
|
+
disk_size_gb: z.number().int().min(5).max(100).optional().describe("New default disk in GB (5-100)."),
|
|
91
|
+
env_vars: envSchema,
|
|
92
|
+
tags: z.array(z.string()).optional().describe("Replacement set of tags."),
|
|
93
|
+
clear_tags: z.boolean().optional().describe("Remove all tags from the template."),
|
|
94
|
+
builder_spec: z.record(z.string(), z.unknown()).optional().describe("Advanced structured build spec (TemplateBuildSpec); passed through as-is."),
|
|
95
|
+
}, async (a) => {
|
|
96
|
+
const resources = resourcesFrom(a.cpu_cores, a.memory_mb, a.disk_size_gb);
|
|
97
|
+
const body = {
|
|
98
|
+
templateId: a.template_id,
|
|
99
|
+
...(a.name !== undefined ? { name: a.name } : {}),
|
|
100
|
+
...(a.base_image_id !== undefined ? { baseImageId: a.base_image_id } : {}),
|
|
101
|
+
...(a.setup_script !== undefined ? { setupScript: a.setup_script } : {}),
|
|
102
|
+
...(a.start_cmd !== undefined ? { startCmd: a.start_cmd } : {}),
|
|
103
|
+
...(a.env_vars && Object.keys(a.env_vars).length ? { envVars: a.env_vars } : {}),
|
|
104
|
+
...(Object.keys(resources).length ? { resources } : {}),
|
|
105
|
+
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
106
|
+
...(a.clear_tags ? { clearTags: true } : {}),
|
|
107
|
+
...(a.builder_spec ? { builderSpec: a.builder_spec } : {}),
|
|
108
|
+
};
|
|
109
|
+
return ok(await client.control("UpdateTemplate", body));
|
|
110
|
+
});
|
|
111
|
+
// ── Delete ──────────────────────────────────────────────────────────────────
|
|
112
|
+
server.tool("tenki_delete_template", "Delete a template by ID. Pass force to delete even when builds or dependents exist.", {
|
|
113
|
+
template_id: z.string().describe("The template ID to delete."),
|
|
114
|
+
force: z.boolean().optional().describe("Force deletion despite dependents (default false)."),
|
|
115
|
+
}, async ({ template_id, force }) => ok(await client.control("DeleteTemplate", {
|
|
116
|
+
templateId: template_id,
|
|
117
|
+
...(force ? { force: true } : {}),
|
|
118
|
+
})));
|
|
119
|
+
// ── Build ───────────────────────────────────────────────────────────────────
|
|
120
|
+
server.tool("tenki_build_template", "Trigger a build for a template, producing a bootable image. Returns the created build — poll it with tenki_get_template_build until READY; the ready build's imageDigestRef is what tenki_create_sandbox's `image` arg takes.", {
|
|
121
|
+
template_id: z.string().describe("The template ID to build."),
|
|
122
|
+
image_name: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Name for the resulting image. Requires a TYPED template (created with builder_spec) — the API rejects it for legacy setup-script templates."),
|
|
126
|
+
publish_raw_image: z.boolean().optional().describe("Publish the raw rootfs image alongside the build snapshot."),
|
|
127
|
+
build_secrets: z.record(z.string(), z.string()).optional().describe("Build-time secrets as a key→value object (not persisted into the image)."),
|
|
128
|
+
build_env: z.record(z.string(), z.string()).optional().describe("Per-build environment overrides frozen into this build only."),
|
|
129
|
+
}, async ({ template_id, image_name, publish_raw_image, build_secrets, build_env }) => ok(await client.control("BuildTemplate", {
|
|
130
|
+
templateId: template_id,
|
|
131
|
+
...(image_name !== undefined ? { imageName: image_name } : {}),
|
|
132
|
+
...(publish_raw_image !== undefined ? { publishRawImage: publish_raw_image } : {}),
|
|
133
|
+
...(build_secrets && Object.keys(build_secrets).length ? { buildSecrets: build_secrets } : {}),
|
|
134
|
+
...(build_env && Object.keys(build_env).length ? { buildEnv: build_env } : {}),
|
|
135
|
+
})));
|
|
136
|
+
// ── Cancel build ──────────────────────────────────────────────────────────────
|
|
137
|
+
server.tool("tenki_cancel_template_build", "Cancel an in-progress template build by its build ID.", { build_id: z.string().describe("The template build ID to cancel.") }, async ({ build_id }) => ok(await client.control("CancelTemplateBuild", { buildId: build_id })));
|
|
138
|
+
// ── Get build ─────────────────────────────────────────────────────────────────
|
|
139
|
+
server.tool("tenki_get_template_build", "Retrieve one template build by its build ID (state, progress, and result image). A READY build's imageDigestRef is the reference tenki_create_sandbox's `image` arg takes.", { build_id: z.string().describe("The template build ID.") }, async ({ build_id }) => ok(await client.control("GetTemplateBuild", { buildId: build_id })));
|
|
140
|
+
// ── List active builds ──────────────────────────────────────────────────────────
|
|
141
|
+
server.tool("tenki_list_active_template_builds", "List the currently active (in-progress) builds for a given template.", { template_id: z.string().describe("The template ID whose active builds to list.") }, async ({ template_id }) => ok(await client.control("ListActiveTemplateBuilds", { templateId: template_id })));
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volume tools — workspace-scoped persistent block storage for Tenki sandboxes.
|
|
3
|
+
*
|
|
4
|
+
* Volumes are durable disks that outlive any single sandbox: create one in a
|
|
5
|
+
* workspace, then attach it into a running session at a mount path. All calls
|
|
6
|
+
* here are control-plane (tenki.sandbox.v1.SandboxService). Method names and
|
|
7
|
+
* request field shapes are ported from the live-verified n8n community node
|
|
8
|
+
* (github.com/opencolin/n8n-nodes-tenki) and its endpoint research:
|
|
9
|
+
* CreateVolume · GetVolume · ListVolumes · UpdateVolume · DeleteVolume ·
|
|
10
|
+
* ResizeVolume · AttachVolume · DetachVolume
|
|
11
|
+
*
|
|
12
|
+
* CreateVolumeRequest is flat: { workspaceId, name, sizeBytes (int64) }.
|
|
13
|
+
*/
|
|
14
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import type { TenkiClient } from "../client.js";
|
|
16
|
+
export declare function registerVolumes(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, pathSchema, sessionIdSchema } from "./common.js";
|
|
3
|
+
/** Volume size bounds the control plane accepts: 1 MiB … 100 GiB, in bytes. */
|
|
4
|
+
const MIN_VOLUME_BYTES = 1_048_576; // 1 MiB
|
|
5
|
+
const MAX_VOLUME_BYTES = 107_374_182_400; // 100 GiB
|
|
6
|
+
/** Shared size-in-bytes schema for create + resize, range-checked before the call. */
|
|
7
|
+
const sizeBytesSchema = z
|
|
8
|
+
.number()
|
|
9
|
+
.int()
|
|
10
|
+
.min(MIN_VOLUME_BYTES)
|
|
11
|
+
.max(MAX_VOLUME_BYTES)
|
|
12
|
+
.describe("Volume size in bytes. Must be between 1 MiB (1048576) and 100 GiB (107374182400).");
|
|
13
|
+
export function registerVolumes(server, client) {
|
|
14
|
+
// ── Create ────────────────────────────────────────────────────────────────
|
|
15
|
+
server.tool("tenki_create_volume", "Create a workspace-scoped persistent volume — durable block storage that survives sandbox teardown. Defaults the workspace to the API key's first; override with workspace_id.", {
|
|
16
|
+
name: z.string().describe("Human-readable name for the volume."),
|
|
17
|
+
size_bytes: sizeBytesSchema,
|
|
18
|
+
workspace_id: z.string().optional().describe("Workspace to create the volume in (defaults to the key's first workspace)."),
|
|
19
|
+
}, async ({ name, size_bytes, workspace_id }) => {
|
|
20
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
21
|
+
return ok(await client.control("CreateVolume", {
|
|
22
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
23
|
+
name,
|
|
24
|
+
sizeBytes: size_bytes,
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
// ── Get ───────────────────────────────────────────────────────────────────
|
|
28
|
+
server.tool("tenki_get_volume", "Fetch a single volume's metadata and current state by its id.", { volume_id: z.string().describe("The volume id, e.g. vol_….") }, async ({ volume_id }) => ok(await client.control("GetVolume", { volumeId: volume_id })));
|
|
29
|
+
// ── List ──────────────────────────────────────────────────────────────────
|
|
30
|
+
server.tool("tenki_list_volumes", "List persistent volumes in a workspace (defaults to the key's first workspace). Supports pagination.", {
|
|
31
|
+
workspace_id: z.string().optional().describe("Workspace to list volumes from (defaults to the key's first workspace)."),
|
|
32
|
+
page_size: z.number().int().positive().optional().describe("Max volumes to return per page."),
|
|
33
|
+
page_token: z.string().optional().describe("Page token from a previous response's nextPageToken."),
|
|
34
|
+
}, async ({ workspace_id, page_size, page_token }) => {
|
|
35
|
+
const owner = await client.resolveOwner();
|
|
36
|
+
const workspaceId = workspace_id ?? owner.workspaceId;
|
|
37
|
+
return ok(await client.control("ListVolumes", {
|
|
38
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
39
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
40
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
// ── Update ────────────────────────────────────────────────────────────────
|
|
44
|
+
server.tool("tenki_update_volume", "Rename a volume (update its human-readable name). To change a volume's size use tenki_resize_volume instead.", {
|
|
45
|
+
volume_id: z.string().describe("The volume id to update."),
|
|
46
|
+
name: z.string().describe("New human-readable name for the volume."),
|
|
47
|
+
}, async ({ volume_id, name }) => ok(await client.control("UpdateVolume", { volumeId: volume_id, name })));
|
|
48
|
+
// ── Delete ────────────────────────────────────────────────────────────────
|
|
49
|
+
server.tool("tenki_delete_volume", "Permanently delete a volume and destroy its data. Fails with VolumeInUse if the volume is still attached to a session — detach it first.", { volume_id: z.string().describe("The volume id to delete.") }, async ({ volume_id }) => ok(await client.control("DeleteVolume", { volumeId: volume_id })));
|
|
50
|
+
// ── Resize ────────────────────────────────────────────────────────────────
|
|
51
|
+
server.tool("tenki_resize_volume", "Grow a volume to a new size in bytes (1 MiB … 100 GiB). Volumes can grow but not shrink.", {
|
|
52
|
+
volume_id: z.string().describe("The volume id to resize."),
|
|
53
|
+
size_bytes: sizeBytesSchema,
|
|
54
|
+
}, async ({ volume_id, size_bytes }) => ok(await client.control("ResizeVolume", { volumeId: volume_id, newSizeBytes: size_bytes })));
|
|
55
|
+
// ── Attach ────────────────────────────────────────────────────────────────
|
|
56
|
+
server.tool("tenki_attach_volume", "Mount a volume into a running sandbox at an absolute path. Set read_only to mount without write access.", {
|
|
57
|
+
session_id: sessionIdSchema.describe("The sandbox session to attach the volume to."),
|
|
58
|
+
volume_id: z.string().describe("The volume id to attach."),
|
|
59
|
+
mount_path: pathSchema.describe("Absolute path inside the sandbox to mount at, e.g. /mnt/data."),
|
|
60
|
+
read_only: z.boolean().optional().describe("Mount the volume read-only (default false = read-write)."),
|
|
61
|
+
}, async ({ session_id, volume_id, mount_path, read_only }) => ok(
|
|
62
|
+
// AttachVolumeRequest nests the target under a `volume` sub-message
|
|
63
|
+
// (live-verified: a flat volumeId is rejected "volume: value is required").
|
|
64
|
+
await client.control("AttachVolume", {
|
|
65
|
+
sessionId: session_id,
|
|
66
|
+
volume: {
|
|
67
|
+
volumeId: volume_id,
|
|
68
|
+
mountPath: mount_path,
|
|
69
|
+
...(read_only !== undefined ? { readOnly: read_only } : {}),
|
|
70
|
+
},
|
|
71
|
+
})));
|
|
72
|
+
// ── Detach ────────────────────────────────────────────────────────────────
|
|
73
|
+
server.tool("tenki_detach_volume", "Unmount a volume from a sandbox session.", {
|
|
74
|
+
session_id: sessionIdSchema.describe("The sandbox session to detach the volume from."),
|
|
75
|
+
volume_id: z.string().describe("The volume id to detach."),
|
|
76
|
+
}, async ({ session_id, volume_id }) => ok(await client.control("DetachVolume", { sessionId: session_id, volumeId: volume_id })));
|
|
77
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/** Workspace-level sandbox usage + default settings (incl. snapshot retention). */
|
|
4
|
+
export declare function registerWorkspace(server: McpServer, client: TenkiClient): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve which workspace an op targets: honour an explicit id, else fall back
|
|
5
|
+
* to the API key's first workspace (via WhoAmI, inside resolveOwner). Mirrors
|
|
6
|
+
* the live-verified n8n node's `resolveWorkspaceId` helper.
|
|
7
|
+
*/
|
|
8
|
+
async function resolveWorkspaceId(client, provided) {
|
|
9
|
+
if (provided && provided.trim())
|
|
10
|
+
return provided.trim();
|
|
11
|
+
const owner = await client.resolveOwner();
|
|
12
|
+
return owner.workspaceId;
|
|
13
|
+
}
|
|
14
|
+
/** Workspace-level sandbox usage + default settings (incl. snapshot retention). */
|
|
15
|
+
export function registerWorkspace(server, client) {
|
|
16
|
+
server.tool("tenki_get_workspace_usage", "Get per-second sandbox billing and usage figures for a workspace — use this for cost visibility across all of the workspace's sandboxes.", {
|
|
17
|
+
workspace_id: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Workspace to report on. Omit to use the API key's first workspace."),
|
|
21
|
+
}, async ({ workspace_id }) => {
|
|
22
|
+
const workspaceId = await resolveWorkspaceId(client, workspace_id);
|
|
23
|
+
return ok(await client.control("GetWorkspaceSandboxUsage", {
|
|
24
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
server.tool("tenki_get_workspace_settings", "Read a workspace's sandbox quotas and retention policy: max snapshots/templates/volumes/total bytes, max concurrent and sticky sessions, max preview URLs, and the pause/snapshot retention periods. These are workspace limits — there are no per-session defaults (idle timeout and max duration are set per sandbox at creation).", {
|
|
28
|
+
workspace_id: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Workspace to read. Omit to use the API key's first workspace."),
|
|
32
|
+
}, async ({ workspace_id }) => {
|
|
33
|
+
const workspaceId = await resolveWorkspaceId(client, workspace_id);
|
|
34
|
+
return ok(await client.control("GetWorkspaceSandboxSettings", {
|
|
35
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
server.tool("tenki_update_workspace_settings", "Update a workspace's sandbox quotas and retention periods. Only the fields you pass change. Each quota has a matching clear_* flag to remove the override and fall back to the platform default; pass the value OR its clear flag, not both. There are no per-session defaults here — idle timeout and max duration are set per sandbox at creation.", {
|
|
39
|
+
workspace_id: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Workspace to update. Omit to use the API key's first workspace."),
|
|
43
|
+
pause_retention_days: z
|
|
44
|
+
.number()
|
|
45
|
+
.int()
|
|
46
|
+
.positive()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("How long a paused sandbox's snapshot is kept before it becomes unresumable."),
|
|
49
|
+
clear_pause_retention: z.boolean().optional().describe("Remove the pause-retention override (use the platform default)."),
|
|
50
|
+
snapshot_retention_days: z
|
|
51
|
+
.number()
|
|
52
|
+
.int()
|
|
53
|
+
.positive()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("How long snapshots are kept before automatic cleanup."),
|
|
56
|
+
clear_snapshot_retention: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Remove the snapshot-retention override, i.e. keep snapshots indefinitely."),
|
|
60
|
+
max_snapshots: z.number().int().positive().optional().describe("Maximum snapshots in the workspace."),
|
|
61
|
+
max_templates: z.number().int().positive().optional().describe("Maximum templates in the workspace."),
|
|
62
|
+
max_volumes: z.number().int().positive().optional().describe("Maximum volumes in the workspace."),
|
|
63
|
+
max_total_bytes: z.number().int().positive().optional().describe("Maximum combined snapshot + volume storage in bytes."),
|
|
64
|
+
max_concurrent_sessions: z.number().int().positive().optional().describe("Maximum simultaneously active sandboxes."),
|
|
65
|
+
max_sticky_sessions: z.number().int().positive().optional().describe("Maximum sticky sandboxes."),
|
|
66
|
+
max_preview_urls: z.number().int().positive().optional().describe("Maximum preview URLs in the workspace."),
|
|
67
|
+
}, async (a) => {
|
|
68
|
+
const workspaceId = await resolveWorkspaceId(client, a.workspace_id);
|
|
69
|
+
const body = { ...(workspaceId ? { workspaceId } : {}) };
|
|
70
|
+
// Durations are protobuf Duration — JSON wants a seconds string ("2592000s").
|
|
71
|
+
if (a.pause_retention_days !== undefined)
|
|
72
|
+
body.pauseRetention = `${a.pause_retention_days * 86400}s`;
|
|
73
|
+
if (a.clear_pause_retention)
|
|
74
|
+
body.clearPauseRetention = true;
|
|
75
|
+
if (a.snapshot_retention_days !== undefined)
|
|
76
|
+
body.snapshotRetention = `${a.snapshot_retention_days * 86400}s`;
|
|
77
|
+
if (a.clear_snapshot_retention)
|
|
78
|
+
body.clearSnapshotRetention = true;
|
|
79
|
+
if (a.max_snapshots !== undefined)
|
|
80
|
+
body.maxSnapshots = a.max_snapshots;
|
|
81
|
+
if (a.max_templates !== undefined)
|
|
82
|
+
body.maxTemplates = a.max_templates;
|
|
83
|
+
if (a.max_volumes !== undefined)
|
|
84
|
+
body.maxVolumes = a.max_volumes;
|
|
85
|
+
// int64 on the wire — send as a string so large values survive JSON.
|
|
86
|
+
if (a.max_total_bytes !== undefined)
|
|
87
|
+
body.maxTotalBytes = String(a.max_total_bytes);
|
|
88
|
+
if (a.max_concurrent_sessions !== undefined)
|
|
89
|
+
body.maxConcurrentSessions = a.max_concurrent_sessions;
|
|
90
|
+
if (a.max_sticky_sessions !== undefined)
|
|
91
|
+
body.maxStickySessions = a.max_sticky_sessions;
|
|
92
|
+
// The per-project field is deprecated; the workspace alias is backed by the same quota.
|
|
93
|
+
if (a.max_preview_urls !== undefined)
|
|
94
|
+
body.maxPreviewUrlsPerWorkspace = a.max_preview_urls;
|
|
95
|
+
if (Object.keys(body).length <= 1) {
|
|
96
|
+
throw new Error("tenki_update_workspace_settings: pass at least one setting to change. Nothing was sent — the API would have returned the unchanged settings, which reads like a successful update.");
|
|
97
|
+
}
|
|
98
|
+
return ok(await client.control("UpdateWorkspaceSandboxSettings", body));
|
|
99
|
+
});
|
|
100
|
+
server.tool("tenki_get_snapshot_retention_settings", "Get the workspace's pause- and snapshot-retention periods. DEPRECATED upstream: this RPC is marked deprecated in the API — tenki_get_workspace_settings returns the same retention fields alongside the quotas. An empty response means no retention override, i.e. kept indefinitely.", { workspace_id: z.string().optional().describe("Workspace (defaults to the key's first workspace).") }, async ({ workspace_id }) => {
|
|
101
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
102
|
+
return ok(await client.control("GetWorkspaceSnapshotRetentionSettings", { ...(workspaceId ? { workspaceId } : {}) }));
|
|
103
|
+
});
|
|
104
|
+
server.tool("tenki_update_snapshot_retention_settings", "Update the workspace's snapshot-retention policy: how long snapshots are kept before automatic cleanup. Pass retention_days to set it, or clear_retention to keep snapshots indefinitely (the unset state) — exactly one of the two. DEPRECATED upstream: prefer tenki_update_workspace_settings, which sets the same retention (and pause retention) alongside the quotas.", {
|
|
105
|
+
retention_days: z
|
|
106
|
+
.number()
|
|
107
|
+
.int()
|
|
108
|
+
.positive()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("Days to retain snapshots before automatic cleanup. Omit and pass clear_retention to keep them indefinitely."),
|
|
111
|
+
clear_retention: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Remove the retention period so snapshots are kept indefinitely. Mutually exclusive with retention_days."),
|
|
115
|
+
workspace_id: z.string().optional().describe("Workspace (defaults to the key's first workspace)."),
|
|
116
|
+
}, async ({ retention_days, clear_retention, workspace_id }) => {
|
|
117
|
+
if ((retention_days === undefined) === (clear_retention !== true)) {
|
|
118
|
+
throw new Error("tenki_update_snapshot_retention_settings: pass exactly one of retention_days (to set a period) or clear_retention: true (to keep snapshots indefinitely).");
|
|
119
|
+
}
|
|
120
|
+
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
121
|
+
return ok(await client.control("UpdateWorkspaceSnapshotRetentionSettings", {
|
|
122
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
123
|
+
// protobuf Duration on the wire, with a companion clear flag —
|
|
124
|
+
// there is no `retentionDays` field (sending one is silently dropped).
|
|
125
|
+
...(retention_days !== undefined ? { snapshotRetention: `${retention_days * 86400}s` } : {}),
|
|
126
|
+
...(clear_retention ? { clearSnapshotRetention: true } : {}),
|
|
127
|
+
}));
|
|
128
|
+
});
|
|
129
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luxorai/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"mcpName": "io.github.LuxorLabs/tenki-mcp",
|
|
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
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tenki-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"SECURITY.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"prepack": "npm run build",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
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/oauth-http.test.mjs && node test/security.test.mjs && node test/client-net.test.mjs",
|
|
25
|
+
"test:all": "npm run build && node test/run.mjs",
|
|
26
|
+
"test:offline": "npm run build && node test/offline.test.mjs"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"tenki",
|
|
30
|
+
"mcp",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"sandbox",
|
|
33
|
+
"microvm",
|
|
34
|
+
"ai-agents",
|
|
35
|
+
"code-execution"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"author": "Tenki Cloud (https://tenki.cloud)",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.2.0",
|
|
51
|
+
"typescript": "^7.0.2"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/LuxorLabs/tenki-mcp.git"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/LuxorLabs/tenki-mcp#readme",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/LuxorLabs/tenki-mcp/issues"
|
|
60
|
+
}
|
|
61
|
+
}
|