Skip to content

Commit b0988a8

Browse files
Askirclaude
andcommitted
feat(cloud): add sandbox MCP server for local Claude Code access to remote sandboxes
Instead of using Claude Code inside the browser-based dev UI, users can now interact with their cloud sandbox from local Claude Code via an MCP server that proxies filesystem and bash operations over SSH. New commands: - `crayon mcp sandbox` — starts the sandbox MCP server (runs on cloud machine) - `crayon cloud mcp [app-name]` — registers MCP server and launches local Claude Code `crayon cloud run` now auto-registers the MCP server and drops users into local Claude Code after the sandbox is ready (also opens the dev UI). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d14e1d6 commit b0988a8

File tree

9 files changed

+497
-6
lines changed

9 files changed

+497
-6
lines changed

packages/core/src/cli/cloud-dev.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync, spawnSync } from "node:child_process";
1+
import { execFileSync, execSync, spawnSync } from "node:child_process";
22
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44
import { homedir, userInfo } from "node:os";
@@ -127,11 +127,25 @@ export async function runCloudRun(): Promise<void> {
127127
const existing = existingSandboxes.find((m) => m.app_name === choice);
128128
if (existing?.app_url) {
129129
const devUrl = existing.app_url.replace(/\/$/, "") + "/dev/";
130-
p.log.info(`URL: ${pc.cyan(devUrl)}`);
131-
openInBrowser(devUrl);
130+
p.log.info(`Dev UI: ${pc.cyan(devUrl)}`);
132131
}
133132
p.log.info(`Status: ${pc.bold(existing?.fly_state ?? "unknown")}`);
134-
p.outro(pc.green("Sandbox ready."));
133+
134+
// Open dev UI and register MCP + launch local Claude Code
135+
if (existing?.app_url) openInBrowser(existing.app_url.replace(/\/$/, "") + "/dev/");
136+
137+
const s = p.spinner();
138+
s.start("Registering sandbox MCP server...");
139+
const ok = await registerSandboxMcp(choice as string);
140+
if (ok) {
141+
s.stop(pc.green("MCP server registered"));
142+
p.log.info("Launching Claude Code with sandbox access...");
143+
p.outro("");
144+
launchClaude();
145+
} else {
146+
s.stop(pc.yellow("MCP registration failed"));
147+
p.outro(pc.green("Sandbox ready."));
148+
}
135149
return;
136150
}
137151

@@ -235,9 +249,21 @@ export async function runCloudRun(): Promise<void> {
235249
const baseUrl = statusResult.url ?? createResult.appUrl;
236250
const devUrl = baseUrl.replace(/\/$/, "") + "/dev/";
237251
s.stop(pc.green("Sandbox is running!"));
238-
p.log.info(`URL: ${pc.cyan(devUrl)}`);
252+
p.log.info(`Dev UI: ${pc.cyan(devUrl)}`);
239253
openInBrowser(devUrl);
240-
p.outro(pc.green("Cloud dev environment is ready!"));
254+
255+
// Register MCP and launch local Claude Code
256+
s.start("Registering sandbox MCP server...");
257+
const ok = await registerSandboxMcp(appName as string);
258+
if (ok) {
259+
s.stop(pc.green("MCP server registered"));
260+
p.log.info("Launching Claude Code with sandbox access...");
261+
p.outro("");
262+
launchClaude();
263+
} else {
264+
s.stop(pc.yellow("MCP registration failed"));
265+
p.outro(pc.green("Cloud dev environment is ready!"));
266+
}
241267
return;
242268
}
243269

@@ -515,6 +541,86 @@ export async function handleClaude(extraArgs: string[] = []): Promise<void> {
515541
process.exit(exitCode);
516542
}
517543

544+
// ── MCP sandbox registration ────────────────────────────────
545+
546+
const MCP_SERVER_NAME = "crayon-sandbox";
547+
548+
async function registerSandboxMcp(appName: string): Promise<boolean> {
549+
let sshInfo: SSHKeyInfo;
550+
try {
551+
sshInfo = await getSSHKey(appName);
552+
} catch (err) {
553+
p.log.error(
554+
`Failed to get SSH key: ${err instanceof Error ? err.message : String(err)}`,
555+
);
556+
return false;
557+
}
558+
559+
// Ensure key is cached on disk (getSSHKey already does this)
560+
const keyPath = getCachedKeyPath(appName);
561+
562+
const sshArgs = [
563+
"-i", keyPath,
564+
"-p", String(sshInfo.port),
565+
"-o", "StrictHostKeyChecking=no",
566+
"-o", "UserKnownHostsFile=/dev/null",
567+
"-o", "LogLevel=ERROR",
568+
"-o", "IdentitiesOnly=yes",
569+
`${sshInfo.linuxUser}@${sshInfo.host}`,
570+
"crayon", "mcp", "sandbox",
571+
];
572+
573+
// Register via claude mcp add (removes old one first if exists)
574+
try {
575+
execSync(`claude mcp remove ${MCP_SERVER_NAME} 2>/dev/null`, {
576+
stdio: "ignore",
577+
});
578+
} catch {
579+
// May not exist yet — ignore
580+
}
581+
582+
try {
583+
execFileSync("claude", [
584+
"mcp", "add",
585+
"--transport", "stdio",
586+
MCP_SERVER_NAME,
587+
"--",
588+
"ssh",
589+
...sshArgs,
590+
], { stdio: "ignore" });
591+
return true;
592+
} catch (err) {
593+
p.log.error(
594+
`Failed to register MCP server: ${err instanceof Error ? err.message : String(err)}`,
595+
);
596+
return false;
597+
}
598+
}
599+
600+
function launchClaude(): void {
601+
const result = spawnSync("claude", [], { stdio: "inherit" });
602+
process.exit(result.status ?? 0);
603+
}
604+
605+
export async function handleMcp(appNameArg?: string): Promise<void> {
606+
await ensureAuth();
607+
const appName = appNameArg ?? (await selectMachine({ excludeStopped: true }));
608+
609+
const s = p.spinner();
610+
s.start("Registering sandbox MCP server...");
611+
612+
const ok = await registerSandboxMcp(appName);
613+
if (!ok) {
614+
s.stop(pc.red("Failed to register MCP server"));
615+
process.exit(1);
616+
}
617+
618+
s.stop(pc.green(`MCP server "${MCP_SERVER_NAME}" registered for ${appName}`));
619+
p.log.info("Launching Claude Code with sandbox access...");
620+
p.outro("");
621+
launchClaude();
622+
}
623+
518624
export async function handleSSH(): Promise<void> {
519625
await ensureAuth();
520626
const appName = await selectMachine({ excludeStopped: true });

packages/core/src/cli/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ cloud
192192
await handleSSH();
193193
});
194194

195+
cloud
196+
.command("mcp [app-name]")
197+
.description("Connect local Claude Code to a cloud sandbox via MCP")
198+
.action(async (appName?: string) => {
199+
const { handleMcp } = await import("./cloud-dev.js");
200+
await handleMcp(appName);
201+
});
202+
195203
// ============ Workflow commands ============
196204
const workflow = program.command("workflow").description("Workflow commands");
197205

@@ -642,6 +650,14 @@ mcp
642650
await startMcpServer();
643651
});
644652

653+
mcp
654+
.command("sandbox")
655+
.description("Start the sandbox MCP server (filesystem + bash tools for remote access)")
656+
.action(async () => {
657+
const { startSandboxMcpServer } = await import("./mcp/sandbox-server.js");
658+
await startSandboxMcpServer();
659+
});
660+
645661
// ============ Install/Uninstall commands ============
646662
program
647663
.command("install")
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { stdioServerFactory } from "@tigerdata/mcp-boilerplate";
2+
import { version } from "./config.js";
3+
import type { ServerContext } from "./types.js";
4+
import { getSandboxApiFactories } from "./sandbox-tools/index.js";
5+
6+
const serverInfo = {
7+
name: "crayon-sandbox-tools",
8+
version,
9+
} as const;
10+
11+
const context: ServerContext = {};
12+
13+
function buildInstructions(): string {
14+
const lines = [
15+
"You are connected to a remote cloud sandbox via MCP.",
16+
"All file and bash operations run via this MCP run on the sandbox.",
17+
"The project root is /data/app.",
18+
];
19+
20+
const flyAppName = process.env.FLY_APP_NAME;
21+
if (flyAppName) {
22+
const publicUrl = `https://${flyAppName}.fly.dev`;
23+
lines.push(
24+
`The sandbox's public URL is: ${publicUrl}`,
25+
`The dev UI is at: ${publicUrl}/dev/`,
26+
"When the app's dev server is running, it is accessible at this public URL you can tell the user to look at it.",
27+
);
28+
}
29+
30+
return lines.join("\n");
31+
}
32+
33+
/**
34+
* Start the sandbox MCP server in stdio mode.
35+
* Exposes filesystem and bash tools for remote sandbox access.
36+
*/
37+
export async function startSandboxMcpServer(): Promise<void> {
38+
const apiFactories = await getSandboxApiFactories();
39+
40+
await stdioServerFactory({
41+
...serverInfo,
42+
context,
43+
apiFactories,
44+
instructions: buildInstructions(),
45+
});
46+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ApiFactory } from "@tigerdata/mcp-boilerplate";
2+
import { execFile } from "node:child_process";
3+
import { z } from "zod";
4+
import type { ServerContext } from "../types.js";
5+
6+
const DEFAULT_TIMEOUT = 120_000;
7+
const DEFAULT_CWD = "/data/app";
8+
9+
const inputSchema = {
10+
command: z.string().describe("Shell command to execute"),
11+
timeout: z
12+
.number()
13+
.optional()
14+
.default(DEFAULT_TIMEOUT)
15+
.describe("Timeout in milliseconds (default: 120000)"),
16+
cwd: z
17+
.string()
18+
.optional()
19+
.default(DEFAULT_CWD)
20+
.describe("Working directory (default: /data/app)"),
21+
} as const;
22+
23+
const outputSchema = {
24+
stdout: z.string().describe("Standard output"),
25+
stderr: z.string().describe("Standard error"),
26+
exit_code: z.number().describe("Exit code (0 = success)"),
27+
} as const;
28+
29+
type OutputSchema = {
30+
stdout: string;
31+
stderr: string;
32+
exit_code: number;
33+
};
34+
35+
export const bashFactory: ApiFactory<
36+
ServerContext,
37+
typeof inputSchema,
38+
typeof outputSchema
39+
> = () => {
40+
return {
41+
name: "bash",
42+
config: {
43+
title: "Bash",
44+
description:
45+
"Execute a shell command on the sandbox. Returns stdout, stderr, and exit code.",
46+
inputSchema,
47+
outputSchema,
48+
},
49+
fn: async ({ command, timeout, cwd }): Promise<OutputSchema> => {
50+
return new Promise((resolve) => {
51+
execFile(
52+
"bash",
53+
["-c", command],
54+
{
55+
timeout,
56+
cwd,
57+
maxBuffer: 10 * 1024 * 1024,
58+
env: process.env,
59+
},
60+
(error, stdout, stderr) => {
61+
const exit_code =
62+
error && "code" in error ? (error.code as number) ?? 1 : error ? 1 : 0;
63+
resolve({ stdout, stderr, exit_code });
64+
},
65+
);
66+
});
67+
},
68+
};
69+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { ApiFactory } from "@tigerdata/mcp-boilerplate";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import { z } from "zod";
4+
import type { ServerContext } from "../types.js";
5+
6+
const inputSchema = {
7+
path: z.string().describe("Absolute path to the file to edit"),
8+
old_string: z.string().describe("The exact text to find and replace"),
9+
new_string: z.string().describe("The replacement text"),
10+
replace_all: z
11+
.boolean()
12+
.optional()
13+
.default(false)
14+
.describe("Replace all occurrences (default: false, requires unique match)"),
15+
} as const;
16+
17+
const outputSchema = {
18+
success: z.boolean().describe("Whether the edit was applied"),
19+
message: z.string().describe("Status message"),
20+
} as const;
21+
22+
type OutputSchema = {
23+
success: boolean;
24+
message: string;
25+
};
26+
27+
export const editFileFactory: ApiFactory<
28+
ServerContext,
29+
typeof inputSchema,
30+
typeof outputSchema
31+
> = () => {
32+
return {
33+
name: "edit_file",
34+
config: {
35+
title: "Edit File",
36+
description:
37+
"Perform exact string replacement in a file. By default, old_string must appear exactly once (for safety). Set replace_all to replace every occurrence.",
38+
inputSchema,
39+
outputSchema,
40+
},
41+
fn: async ({ path, old_string, new_string, replace_all }): Promise<OutputSchema> => {
42+
const content = await readFile(path, "utf-8");
43+
44+
if (!content.includes(old_string)) {
45+
return { success: false, message: `old_string not found in ${path}` };
46+
}
47+
48+
if (!replace_all) {
49+
const first = content.indexOf(old_string);
50+
const second = content.indexOf(old_string, first + 1);
51+
if (second !== -1) {
52+
return {
53+
success: false,
54+
message: `old_string appears multiple times in ${path}. Use replace_all or provide more context to make it unique.`,
55+
};
56+
}
57+
}
58+
59+
const updated = replace_all
60+
? content.replaceAll(old_string, new_string)
61+
: content.replace(old_string, new_string);
62+
63+
await writeFile(path, updated, "utf-8");
64+
return { success: true, message: `Applied edit to ${path}` };
65+
},
66+
};
67+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { readFileFactory } from "./readFile.js";
2+
import { writeFileFactory } from "./writeFile.js";
3+
import { editFileFactory } from "./editFile.js";
4+
import { listDirectoryFactory } from "./listDirectory.js";
5+
import { bashFactory } from "./bash.js";
6+
7+
export async function getSandboxApiFactories() {
8+
return [
9+
readFileFactory,
10+
writeFileFactory,
11+
editFileFactory,
12+
listDirectoryFactory,
13+
bashFactory,
14+
] as const;
15+
}

0 commit comments

Comments
 (0)