Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sandbox-container/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CLOUDFLARE_CLIENT_ID=
CLOUDFLARE_CLIENT_SECRET=
12 changes: 10 additions & 2 deletions apps/sandbox-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ This is a simple MCP-based interface for a sandboxed development environment.

## Local dev

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.
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. Because of this, testing the container(s) and container manager locally is not possible at this time.

TODO: replace locally running server with the real docker container.
Do the following from within the sandbox-container app:

1. Copy the `.dev.vars.example` file to a new `.dev.vars` file.
2. Get the Cloudflare client id and secret from a team member and add them to the `.dev.vars` file.
3. Run `pnpm i` then `pnpm dev` to start the MCP server.
4. Run `pnpx @modelcontextprotocol/inspector` to start the MCP inspector client.
5. Open the inspector client in your browser and connect to the server via `http://localhost:8976/workers/sandbox/sse`.

Note: Temporary files created through files tool calls are stored in the workdir folder of this app.

## Deploying

Expand Down
18 changes: 18 additions & 0 deletions apps/sandbox-container/container/fileUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'

import { get_file_name_from_path } from './fileUtils'

describe('get_file_name_from_path', () => {
it('strips files/contents', async () => {
const path = await get_file_name_from_path('/files/contents/cats')
expect(path).toBe('/cats')
}),
it('works if files/contents is not present', async () => {
const path = await get_file_name_from_path('/dogs')
expect(path).toBe('/dogs')
}),
it('strips a trailing slash', async () => {
const path = await get_file_name_from_path('/files/contents/birds/')
expect(path).toBe('/birds')
})
})
6 changes: 6 additions & 0 deletions apps/sandbox-container/container/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function get_file_name_from_path(path: string): Promise<string> {
path = path.replace('/files/contents', '')
path = path.endsWith('/') ? path.substring(0, path.length - 1) : path

return path
}
33 changes: 29 additions & 4 deletions apps/sandbox-container/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { Hono } from 'hono'
import { streamText } from 'hono/streaming'
import mime from 'mime'

import { ExecParams, FileList, FilesWrite } from '../shared/schema.ts'
import { ExecParams, FilesWrite } from '../shared/schema.ts'
import { get_file_name_from_path } from './fileUtils.ts'

import type { FileList } from '../shared/schema.ts'

process.chdir('workdir')

Expand Down Expand Up @@ -60,8 +63,7 @@ app.get('/files/ls', async (c) => {
* Get the contents of a file or directory
*/
app.get('/files/contents/*', async (c) => {
let reqPath = c.req.path.replace('/files/contents', '')
reqPath = reqPath.endsWith('/') ? reqPath.substring(0, reqPath.length - 1) : reqPath
const reqPath = await get_file_name_from_path(c.req.path)
try {
const mimeType = mime.getType(reqPath)
const headers = mimeType ? { 'Content-Type': mimeType } : undefined
Expand Down Expand Up @@ -105,7 +107,8 @@ app.get('/files/contents/*', async (c) => {
*/
app.post('/files/contents', zValidator('json', FilesWrite), async (c) => {
const file = c.req.valid('json')
const reqPath = file.path.endsWith('/') ? file.path.substring(0, file.path.length - 1) : file.path
const reqPath = await get_file_name_from_path(file.path)

try {
await fs.writeFile(reqPath, file.text)
return c.newResponse(null, 200)
Expand All @@ -114,6 +117,28 @@ app.post('/files/contents', zValidator('json', FilesWrite), async (c) => {
}
})

/**
* DELETE /files/contents/{filepath}
*
* Delete a file or directory
*/
app.delete('/files/contents/*', async (c) => {
const reqPath = await get_file_name_from_path(c.req.path)

try {
await fs.rm(path.join(process.cwd(), reqPath), { recursive: true })
return c.newResponse('ok', 200)
} catch (e: any) {
if (e.code) {
if (e.code === 'ENOENT') {
return c.notFound()
}
}

throw e
}
})

/**
* POST /exec
*
Expand Down
1 change: 1 addition & 0 deletions apps/sandbox-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"start": "wrangler dev",
"start:container": "tsx container/index.ts",
"postinstall": "mkdir -p workdir",
"test": "vitest",
"types": "wrangler types"
},
"dependencies": {
Expand Down
31 changes: 26 additions & 5 deletions apps/sandbox-container/server/containerMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { McpAgent } from 'agents/mcp'
import { z } from 'zod'

import { OPEN_CONTAINER_PORT } from '../shared/consts'
import { ExecParams, FileList, FilesWrite } from '../shared/schema'
import { ExecParams, FilePathParam, FilesWrite } from '../shared/schema'
import { MAX_CONTAINERS, proxyFetch, startAndWaitForPort } from './containerHelpers'
import { getContainerManager } from './containerManager'
import { BASE_INSTRUCTIONS } from './prompts'
import { fileToBase64 } from './utils'
import { Env, Props } from '.'

import type { FileList } from '../shared/schema'
import type { Env, Props } from '.'

export class ContainerMcpAgent extends McpAgent<Env, Props> {
server = new McpServer(
Expand Down Expand Up @@ -70,6 +72,17 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
}
}
)
this.server.tool(
'container_file_delete',
'Delete file and its contents',
{ args: FilePathParam },
async ({ args }) => {
const deleted = await this.container_file_delete(args)
return {
content: [{ type: 'text', text: `File deleted: ${deleted}.` }],
}
}
)
this.server.tool(
'container_files_write',
'Write file contents',
Expand Down Expand Up @@ -229,11 +242,20 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
return json
}

async container_file_delete(filePath: string): Promise<boolean> {
const res = await proxyFetch(
this.env.ENVIRONMENT,
this.ctx.container,
new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents/${filePath}`, {
method: 'DELETE',
}),
OPEN_CONTAINER_PORT
)
return res.ok
}
async container_files_read(
filePath: string
): Promise<{ blob: Blob; mimeType: string | undefined }> {
console.log('reading')
console.log(filePath)
const res = await proxyFetch(
this.env.ENVIRONMENT,
this.ctx.container,
Expand Down Expand Up @@ -269,7 +291,6 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
if (!res || !res.ok) {
throw new Error(`Request to container failed: ${await res.text()}`)
}
const txt = await res.text()
return `Wrote file: ${file.path}`
}
}
3 changes: 3 additions & 0 deletions apps/sandbox-container/shared/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const FilesWrite = z.object({
text: z.string().describe('Full text content of the file you want to write.'),
})

export type FilePathParam = z.infer<typeof FilePathParam>
export const FilePathParam = z.string()

export type FileList = z.infer<typeof FileList>
export const FileList = z.object({
resources: z
Expand Down
Loading
Loading