Skip to content

Commit 3dbca1d

Browse files
committed
Add sandbox-container with no auth and basic local development
1 parent 8b60055 commit 3dbca1d

File tree

15 files changed

+926
-0
lines changed

15 files changed

+926
-0
lines changed

apps/sandbox-container/Dockerfile

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Use an official Ubuntu as a base image
2+
FROM ubuntu:20.04
3+
4+
# Set non-interactive mode to avoid prompts during package installation
5+
ARG DEBIAN_FRONTEND=noninteractive
6+
7+
# Use bash for the shell
8+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
9+
10+
# Update and install useful CLI utilities
11+
RUN apt-get update && apt-get install -y \
12+
curl \
13+
git \
14+
htop \
15+
vim \
16+
wget \
17+
net-tools \
18+
build-essential \
19+
nmap \
20+
sudo \
21+
ca-certificates \
22+
lsb-release \
23+
nodejs \
24+
npm \
25+
python3 \
26+
python3-pip \
27+
&& apt-get clean
28+
29+
RUN pip3 install matplotlib numpy pandas
30+
31+
# Install Node.js and Corepack
32+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
33+
&& apt-get install -y nodejs \
34+
&& corepack enable
35+
36+
# ENV NODE_VERSION 22.14.0
37+
38+
# Download and install nvm
39+
# RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | PROFILE="${BASH_ENV}" bash
40+
# RUN echo node > .nvmrc
41+
# RUN nvm install $NODE_VERSION
42+
# RUN nvm use $NODE_VERSION
43+
44+
# Set working directory
45+
WORKDIR /app
46+
47+
# Expose the port your Node.js server will run on
48+
EXPOSE 8080
49+
50+
COPY . ./
51+
52+
RUN npm i
53+
54+
# add node and npm to path so the commands are available
55+
# ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
56+
# ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
57+
58+
# Default command (run your Node.js server here)
59+
CMD ["npm", "run", "start:container"]

apps/sandbox-container/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Container MCP Server
2+
3+
This is a simple MCP-based interface for a sandboxed development environment.
4+
5+
## Local dev
6+
7+
Currently a work in progress. Cloudchamber local dev isn't implemented yet, so we are doing a bit of a hack to just run the server in your local environment.
8+
9+
TODO: replace locally running server with the real docker container.
10+
11+
## Deploying
12+
13+
1. Make sure the docker daemon is running
14+
15+
2. Disable WARP and run
16+
17+
```
18+
npx https://prerelease-registry.devprod.cloudflare.dev/workers-sdk/runs/14387504770/npm-package-wrangler-8740 deploy
19+
```
20+
21+
3. Add to your Claude config. If using with Claude, you'll need to disable WARP:
22+
23+
```
24+
{
25+
"mcpServers": {
26+
"container": {
27+
"command": "npx",
28+
"args": [
29+
"mcp-remote",
30+
// this is my deployed instance
31+
"https://container-starter-2.cmsparks.workers.dev/sse"
32+
]
33+
}
34+
}
35+
}
36+
```
37+
38+
## Tools
39+
40+
- `container_initialize`: (Re)start a container. Containers are intended to be ephemeral and don't save any state. Containers are only guaranteed to last 10m (this is just because I have a max of like ~5 containers per account).
41+
- `container_ping`: Ping a container for connectivity
42+
- `container_exec`: Run a command in the shell
43+
- `container_files_write`: Write to a file
44+
- `container_files_list`: List all files in the work directory
45+
- `container_file_read`: Read the contents of a single file or directory
46+
47+
## Resources
48+
49+
TODO
50+
51+
Tried implementing these, but MCP clients don't support resources well at all.
52+
53+
## Prompts
54+
55+
TODO
56+
57+
## Container support
58+
59+
The container currently runs python and node. It's connected to the internet and LLMs can install whatever packages.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { exec } from 'node:child_process'
2+
import * as fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
import { serve } from '@hono/node-server'
5+
import { zValidator } from '@hono/zod-validator'
6+
import { Hono } from 'hono'
7+
import { streamText } from 'hono/streaming'
8+
import mime from 'mime'
9+
10+
import { ExecParams, FileList, FilesWrite } from '../shared/schema.ts'
11+
12+
process.chdir('workdir')
13+
14+
const app = new Hono()
15+
16+
app.get('/ping', (c) => c.text('pong!'))
17+
18+
/**
19+
* GET /files/ls
20+
*
21+
* Gets all files in a directory
22+
*/
23+
app.get('/files/ls', async (c) => {
24+
const directoriesToRead = ['.']
25+
const files: FileList = { resources: [] }
26+
27+
while (directoriesToRead.length > 0) {
28+
const curr = directoriesToRead.pop()
29+
if (!curr) {
30+
throw new Error('Popped empty stack, error while listing directories')
31+
}
32+
const fullPath = path.join(process.cwd(), curr)
33+
const dir = await fs.readdir(fullPath, { withFileTypes: true })
34+
for (const dirent of dir) {
35+
const relPath = path.relative(process.cwd(), `${fullPath}/${dirent.name}`)
36+
if (dirent.isDirectory()) {
37+
directoriesToRead.push(dirent.name)
38+
files.resources.push({
39+
uri: `file:///${relPath}`,
40+
name: dirent.name,
41+
mimeType: 'inode/directory',
42+
})
43+
} else {
44+
const mimeType = mime.getType(dirent.name)
45+
files.resources.push({
46+
uri: `file:///${relPath}`,
47+
name: dirent.name,
48+
mimeType: mimeType ?? undefined,
49+
})
50+
}
51+
}
52+
}
53+
54+
return c.json(files)
55+
})
56+
57+
/**
58+
* GET /files/contents/{filepath}
59+
*
60+
* Get the contents of a file or directory
61+
*/
62+
app.get('/files/contents/*', async (c) => {
63+
let reqPath = c.req.path.replace('/files/contents', '')
64+
reqPath = reqPath.endsWith('/') ? reqPath.substring(0, reqPath.length - 1) : reqPath
65+
try {
66+
const mimeType = mime.getType(reqPath)
67+
const headers = mimeType ? { 'Content-Type': mimeType } : undefined
68+
const contents = await fs.readFile(path.join(process.cwd(), reqPath))
69+
return c.newResponse(contents, 200, headers)
70+
} catch (e: any) {
71+
if (e.code) {
72+
// handle directory
73+
if (e.code === 'EISDIR') {
74+
const files: string[] = []
75+
const dir = await fs.readdir(path.join(process.cwd(), reqPath), {
76+
withFileTypes: true,
77+
})
78+
for (const dirent of dir) {
79+
const relPath = path.relative(process.cwd(), `${reqPath}/${dirent.name}`)
80+
if (dirent.isDirectory()) {
81+
files.push(`file:///${relPath}`)
82+
} else {
83+
const mimeType = mime.getType(dirent.name)
84+
files.push(`file:///${relPath}`)
85+
}
86+
}
87+
return c.newResponse(files.join('\n'), 200, {
88+
'Content-Type': 'inode/directory',
89+
})
90+
}
91+
92+
if (e.code === 'ENOENT') {
93+
return c.notFound()
94+
}
95+
}
96+
97+
throw e
98+
}
99+
})
100+
101+
/**
102+
* POST /files/contents
103+
*
104+
* Create or update file contents
105+
*/
106+
app.post('/files/contents', zValidator('json', FilesWrite), async (c) => {
107+
const file = c.req.valid('json')
108+
const reqPath = file.path.endsWith('/') ? file.path.substring(0, file.path.length - 1) : file.path
109+
try {
110+
await fs.writeFile(reqPath, file.text)
111+
return c.newResponse(null, 200)
112+
} catch (e) {
113+
return c.newResponse(`Error: ${e}`, 400)
114+
}
115+
})
116+
117+
/**
118+
* POST /exec
119+
*
120+
* Execute a command in a shell
121+
*/
122+
app.post('/exec', zValidator('json', ExecParams), (c) => {
123+
const execParams = c.req.valid('json')
124+
const proc = exec(execParams.args)
125+
return streamText(c, async (stream) => {
126+
return new Promise(async (resolve, reject) => {
127+
if (proc.stdout) {
128+
// Stream data from stdout
129+
proc.stdout.on('data', async (data) => {
130+
await stream.write(data.toString())
131+
})
132+
} else {
133+
await stream.write('WARNING: no stdout stream for process')
134+
}
135+
136+
if (execParams.streamStderr) {
137+
if (proc.stderr) {
138+
proc.stderr.on('data', async (data) => {
139+
await stream.write(data.toString())
140+
})
141+
} else {
142+
await stream.write('WARNING: no stderr stream for process')
143+
}
144+
}
145+
146+
// Handle process exit
147+
proc.on('exit', async (code) => {
148+
await stream.write(`Process exited with code: ${code}`)
149+
if (code === 0) {
150+
stream.close()
151+
resolve()
152+
} else {
153+
console.error(`Process exited with code ${code}`)
154+
reject(new Error(`Process failed with code ${code}`))
155+
}
156+
})
157+
158+
proc.on('error', (err) => {
159+
console.error('Error with process: ', err)
160+
reject(err)
161+
})
162+
})
163+
})
164+
})
165+
166+
serve({
167+
fetch: app.fetch,
168+
port: 8080,
169+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2023"],
4+
"module": "ES2022",
5+
"target": "es2022",
6+
"types": ["@types/node"],
7+
"strict": true,
8+
"esModuleInterop": true,
9+
"skipLibCheck": true,
10+
"moduleResolution": "bundler",
11+
"noEmit": true,
12+
"allowImportingTsExtensions": true
13+
},
14+
"include": ["**/*.ts"],
15+
"exclude": []
16+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "containers-starter",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"deploy": "wrangler deploy",
8+
"dev": "concurrently \"tsx container/index.ts\" \"wrangler dev --var \"ENVIRONMENT:dev\"\"",
9+
"build": "docker build .",
10+
"start": "wrangler dev",
11+
"start:container": "tsx container/index.ts"
12+
},
13+
"dependencies": {
14+
"@cloudflare/workers-types": "^4.20250320.0",
15+
"@hono/node-server": "^1.13.8",
16+
"@hono/zod-validator": "^0.4.3",
17+
"@modelcontextprotocol/sdk": "^1.7.0",
18+
"@types/node": "^22.13.10",
19+
"agents": "^0.0.42",
20+
"cron-schedule": "^5.0.4",
21+
"esbuild": "^0.25.1",
22+
"hono": "^4.7.5",
23+
"mime": "^4.0.6",
24+
"octokit": "^4.1.2",
25+
"partyserver": "^0.0.65",
26+
"tsx": "^4.19.3",
27+
"workers-mcp": "0.1.0-3",
28+
"zod": "^3.24.2"
29+
},
30+
"devDependencies": {
31+
"concurrently": "^9.1.2",
32+
"wrangler": "^4.9.1"
33+
}
34+
}

0 commit comments

Comments
 (0)