@luxorai/mcp 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +192 -0
  6. package/dist/client.js +621 -0
  7. package/dist/env.d.ts +6 -0
  8. package/dist/env.js +25 -0
  9. package/dist/http.d.ts +17 -0
  10. package/dist/http.js +297 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +61 -0
  13. package/dist/oauth.d.ts +39 -0
  14. package/dist/oauth.js +166 -0
  15. package/dist/server.d.ts +50 -0
  16. package/dist/server.js +238 -0
  17. package/dist/tools/artifacts.d.ts +16 -0
  18. package/dist/tools/artifacts.js +19 -0
  19. package/dist/tools/auth_status.d.ts +35 -0
  20. package/dist/tools/auth_status.js +108 -0
  21. package/dist/tools/common.d.ts +70 -0
  22. package/dist/tools/common.js +138 -0
  23. package/dist/tools/egress.d.ts +38 -0
  24. package/dist/tools/egress.js +153 -0
  25. package/dist/tools/exec.d.ts +4 -0
  26. package/dist/tools/exec.js +120 -0
  27. package/dist/tools/files.d.ts +4 -0
  28. package/dist/tools/files.js +55 -0
  29. package/dist/tools/files_ops.d.ts +12 -0
  30. package/dist/tools/files_ops.js +62 -0
  31. package/dist/tools/git.d.ts +3 -0
  32. package/dist/tools/git.js +92 -0
  33. package/dist/tools/identity.d.ts +4 -0
  34. package/dist/tools/identity.js +5 -0
  35. package/dist/tools/ports.d.ts +11 -0
  36. package/dist/tools/ports.js +27 -0
  37. package/dist/tools/previews.d.ts +18 -0
  38. package/dist/tools/previews.js +117 -0
  39. package/dist/tools/run.d.ts +4 -0
  40. package/dist/tools/run.js +11 -0
  41. package/dist/tools/sandboxes.d.ts +4 -0
  42. package/dist/tools/sandboxes.js +388 -0
  43. package/dist/tools/sessions_admin.d.ts +9 -0
  44. package/dist/tools/sessions_admin.js +102 -0
  45. package/dist/tools/snapshots.d.ts +11 -0
  46. package/dist/tools/snapshots.js +98 -0
  47. package/dist/tools/ssh.d.ts +15 -0
  48. package/dist/tools/ssh.js +43 -0
  49. package/dist/tools/templates.d.ts +15 -0
  50. package/dist/tools/templates.js +162 -0
  51. package/dist/tools/titles.d.ts +8 -0
  52. package/dist/tools/titles.js +102 -0
  53. package/dist/tools/volumes.d.ts +16 -0
  54. package/dist/tools/volumes.js +102 -0
  55. package/dist/tools/workspace.d.ts +12 -0
  56. package/dist/tools/workspace.js +68 -0
  57. package/package.json +60 -4
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ import { listQuery, ok, pathSchema, protoEnum, searchSchema, sessionIdSchema, tagsPatch, tagsSchema } 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) with server-side search, state filter, sorting and optional state-count facets. Supports pagination.", {
31
+ workspace_id: z.string().optional().describe("Workspace to list volumes from (defaults to the key's first workspace)."),
32
+ search: searchSchema.describe("Free-text search over volume name/id (max 256 chars)."),
33
+ states: z.array(z.enum(["AVAILABLE", "IN_USE", "DELETING", "DELETED"])).max(16).optional().describe("Only volumes in any of these states."),
34
+ sort_by: z.enum(["NAME", "STATE", "SIZE_BYTES", "CREATED_AT"]).optional().describe("Column to sort by."),
35
+ sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
36
+ include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope."),
37
+ page_size: z.number().int().min(1).max(100).optional().describe("Max volumes to return per page (max 100)."),
38
+ page_token: z.string().optional().describe("Page token from a previous response's nextPageToken."),
39
+ }, async ({ workspace_id, search, states, sort_by, sort_desc, include_facets, page_size, page_token }) => {
40
+ const owner = await client.resolveOwner();
41
+ const workspaceId = workspace_id ?? owner.workspaceId;
42
+ return ok(await client.control("ListVolumes", {
43
+ ...(workspaceId ? { workspaceId } : {}),
44
+ ...(states && states.length ? { states: states.map((s) => protoEnum("VOLUME_STATE", s)) } : {}),
45
+ ...(sort_by ? { sortBy: protoEnum("VOLUME_SORT_FIELD", sort_by) } : {}),
46
+ ...listQuery(search, include_facets, sort_desc),
47
+ ...(page_size ? { pageSize: page_size } : {}),
48
+ ...(page_token ? { pageToken: page_token } : {}),
49
+ }));
50
+ });
51
+ // ── Update ────────────────────────────────────────────────────────────────
52
+ server.tool("tenki_update_volume", "Update a volume's name and/or tags (pass at least one). To change a volume's size use tenki_resize_volume instead.", {
53
+ volume_id: z.string().describe("The volume id to update."),
54
+ name: z.string().min(1).max(64).optional().describe("New human-readable name for the volume."),
55
+ tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
56
+ clear_tags: z.boolean().optional().describe("Remove all tags from the volume."),
57
+ }, async ({ volume_id, name, tags, clear_tags }) => {
58
+ const tagPatch = tagsPatch(tags, clear_tags);
59
+ if (name === undefined && !Object.keys(tagPatch).length) {
60
+ throw new Error("tenki_update_volume: pass name, tags, or clear_tags — nothing was sent.");
61
+ }
62
+ return ok(await client.control("UpdateVolume", {
63
+ volumeId: volume_id,
64
+ ...(name !== undefined ? { name } : {}),
65
+ ...tagPatch,
66
+ }));
67
+ });
68
+ // ── Delete ────────────────────────────────────────────────────────────────
69
+ 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 })));
70
+ // ── Resize ────────────────────────────────────────────────────────────────
71
+ server.tool("tenki_resize_volume", "Change a volume's size in bytes (1 MiB … 100 GiB). Only a volume that has never been attached can be resized; once attached its size is fixed (failed_precondition), so create a new volume for a different size.", {
72
+ volume_id: z.string().describe("The volume id to resize."),
73
+ size_bytes: sizeBytesSchema,
74
+ }, async ({ volume_id, size_bytes }) => ok(await client.control("ResizeVolume", { volumeId: volume_id, newSizeBytes: size_bytes })));
75
+ // ── Attach ────────────────────────────────────────────────────────────────
76
+ server.tool("tenki_attach_volume", "Mount a volume into a running sandbox at an absolute path. Set read_only to mount without write access.", {
77
+ session_id: sessionIdSchema.describe("The sandbox session to attach the volume to."),
78
+ volume_id: z.string().describe("The volume id to attach."),
79
+ mount_path: pathSchema.describe("Absolute path inside the sandbox to mount at, e.g. /mnt/data."),
80
+ read_only: z.boolean().optional().describe("Mount the volume read-only (default false = read-write)."),
81
+ }, async ({ session_id, volume_id, mount_path, read_only }) => ok(
82
+ // AttachVolumeRequest nests the target under a `volume` sub-message
83
+ // (live-verified: a flat volumeId is rejected "volume: value is required").
84
+ await client.control("AttachVolume", {
85
+ sessionId: session_id,
86
+ volume: {
87
+ volumeId: volume_id,
88
+ mountPath: mount_path,
89
+ // proto field is `readonly` (one word); `readOnly` is an unknown field the API discards.
90
+ ...(read_only !== undefined ? { readonly: read_only } : {}),
91
+ },
92
+ })));
93
+ // ── Detach ────────────────────────────────────────────────────────────────
94
+ server.tool("tenki_detach_volume", "Unmount a volume from a sandbox session and sync it back to storage. A volume whose sync back is still pending (SYNC_PENDING) cannot be detached, even with force (failed_precondition): wait for the upload to finish or ask Tenki support to recover it. Set force to unplug an attached volume the guest cannot unmount — writes the guest has not flushed are lost.", {
95
+ session_id: sessionIdSchema.describe("The sandbox session to detach the volume from."),
96
+ volume_id: z.string().describe("The volume id to detach."),
97
+ force: z
98
+ .boolean()
99
+ .optional()
100
+ .describe("Force-unplug an ATTACHED volume (default false). Unflushed guest writes are lost. Never releases a SYNC_PENDING attachment; on a host that cannot force detach yet the API returns failed_precondition."),
101
+ }, async ({ session_id, volume_id, force }) => ok(await client.control("DetachVolume", { sessionId: session_id, volumeId: volume_id, ...(force ? { forceDetach: true } : {}) })));
102
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TenkiClient } from "../client.js";
3
+ /**
4
+ * Workspace-level sandbox usage, CI runner sizes and wildcard preview domains.
5
+ *
6
+ * The workspace quota/retention settings RPCs (Get/UpdateWorkspaceSandboxSettings,
7
+ * Get/UpdateWorkspaceSnapshotRetentionSettings) were REMOVED from the API
8
+ * (tenki-app #5007, 2026-08-07; they 404 on staging and production), so their
9
+ * tools are gone. Quotas now come from the workspace plan and are read-only
10
+ * through tenki_get_workspace_usage.
11
+ */
12
+ export declare function registerWorkspace(server: McpServer, client: TenkiClient): void;
@@ -0,0 +1,68 @@
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
+ /**
15
+ * Workspace-level sandbox usage, CI runner sizes and wildcard preview domains.
16
+ *
17
+ * The workspace quota/retention settings RPCs (Get/UpdateWorkspaceSandboxSettings,
18
+ * Get/UpdateWorkspaceSnapshotRetentionSettings) were REMOVED from the API
19
+ * (tenki-app #5007, 2026-08-07; they 404 on staging and production), so their
20
+ * tools are gone. Quotas now come from the workspace plan and are read-only
21
+ * through tenki_get_workspace_usage.
22
+ */
23
+ export function registerWorkspace(server, client) {
24
+ server.tool("tenki_get_workspace_usage", "Get a workspace's sandbox usage against its plan limits (concurrent sandboxes, snapshots, templates, volumes, storage bytes, preview URLs) — each row has current + max. Use this for quota and cost visibility; the limits themselves come from the plan and are not editable through the API.", {
25
+ workspace_id: z
26
+ .string()
27
+ .optional()
28
+ .describe("Workspace to report on. Omit to use the API key's first workspace."),
29
+ }, async ({ workspace_id }) => {
30
+ const workspaceId = await resolveWorkspaceId(client, workspace_id);
31
+ return ok(await client.control("GetWorkspaceSandboxUsage", {
32
+ ...(workspaceId ? { workspaceId } : {}),
33
+ }));
34
+ });
35
+ server.tool("tenki_list_ci_runner_sizes", "List the Linux x64 runner sizes Tenki CI can run for a workspace — each runs-on label with its vCPUs and memory in GiB, smallest first, limited to the workspace's job-size limits — and defaultRunsOnLabel, the size a job gets when its runs-on names none. Use it to pick a runs-on label for a GitHub Actions workflow.", {
36
+ workspace_id: z
37
+ .string()
38
+ .optional()
39
+ .describe("Workspace to list sizes for. Omit to use the API key's first workspace."),
40
+ }, async ({ workspace_id }) => {
41
+ const workspaceId = await resolveWorkspaceId(client, workspace_id);
42
+ const resp = await client.control("ListCIRunnerSizes", { ...(workspaceId ? { workspaceId } : {}) });
43
+ // protojson omits empty fields; an empty workspace would otherwise come back as {}.
44
+ return ok({ ...resp, sizes: resp.sizes ?? [], defaultRunsOnLabel: resp.defaultRunsOnLabel ?? "" });
45
+ });
46
+ server.tool("tenki_get_workspace_preview_domains", "Read whether wildcard preview domains are enabled for a workspace, the per-region wildcard hostnames and their certificate readiness, and how many existing preview URLs use the wildcard scheme. Explains the `wildcard`/`wildcardStatus` fields on expose/preview responses.", {
47
+ workspace_id: z
48
+ .string()
49
+ .optional()
50
+ .describe("Workspace to read. Omit to use the API key's first workspace."),
51
+ }, async ({ workspace_id }) => {
52
+ const workspaceId = await resolveWorkspaceId(client, workspace_id);
53
+ return ok(await client.control("GetWorkspacePreviewDomains", { ...(workspaceId ? { workspaceId } : {}) }));
54
+ });
55
+ server.tool("tenki_update_workspace_preview_domains", "Enable or disable wildcard preview domains for a workspace. Enabling provisions one wildcard certificate per region and is limited to Pro-tier workspaces. DISABLING releases the certificates and existing wildcard-scheme preview URLs stop serving with a valid certificate — check wildcardPreviewUrlCount via tenki_get_workspace_preview_domains first.", {
56
+ enabled: z.boolean().describe("true to enable wildcard preview domains, false to disable them."),
57
+ workspace_id: z
58
+ .string()
59
+ .optional()
60
+ .describe("Workspace to update. Omit to use the API key's first workspace."),
61
+ }, async ({ enabled, workspace_id }) => {
62
+ const workspaceId = await resolveWorkspaceId(client, workspace_id);
63
+ return ok(await client.control("UpdateWorkspacePreviewDomains", {
64
+ ...(workspaceId ? { workspaceId } : {}),
65
+ enabled,
66
+ }));
67
+ });
68
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@luxorai/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "mcpName": "io.github.LuxorLabs/luxor-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
+ "luxor-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE",
15
+ "SECURITY.md",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "start": "node dist/index.js",
21
+ "dev": "tsc --watch",
22
+ "prepack": "npm run build",
23
+ "prepublishOnly": "npm run build",
24
+ "pretest": "npm run build",
25
+ "test": "node test/offline.test.mjs && node test/presubmission.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 && node test/wire-shapes.test.mjs",
26
+ "test:all": "npm run build && node test/run.mjs",
27
+ "test:offline": "npm run build && node test/offline.test.mjs"
28
+ },
29
+ "keywords": [
30
+ "tenki",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "sandbox",
34
+ "microvm",
35
+ "ai-agents",
36
+ "code-execution"
37
+ ],
38
+ "license": "MIT",
39
+ "author": "Tenki Cloud (https://tenki.cloud)",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.30.0",
48
+ "zod": "^4.4.3"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^26.2.0",
52
+ "typescript": "^7.0.2"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/LuxorLabs/tenki-mcp.git"
57
+ },
58
+ "homepage": "https://github.com/LuxorLabs/tenki-mcp#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/LuxorLabs/tenki-mcp/issues"
61
+ }
62
+ }