Skip to content

Commit 2bbb08e

Browse files
courtney-simsdeloreyj
authored andcommitted
Update container read tool call (#54)
* Update read tool call * forgot a test * prettier
1 parent 67c10e2 commit 2bbb08e

File tree

9 files changed

+167
-44
lines changed

9 files changed

+167
-44
lines changed

apps/sandbox-container/container/fileUtils.spec.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
import { describe, expect, it } from 'vitest'
1+
import mime from 'mime'
2+
import mock from 'mock-fs'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
24

3-
import { get_file_name_from_path } from './fileUtils'
5+
import { get_file_name_from_path, get_mime_type, list_files_in_directory } from './fileUtils'
6+
7+
vi.mock('mime', () => {
8+
return {
9+
default: {
10+
getType: vi.fn(),
11+
},
12+
}
13+
})
14+
15+
afterEach(async () => {
16+
mock.restore()
17+
vi.restoreAllMocks()
18+
})
419

520
describe('get_file_name_from_path', () => {
621
it('strips files/contents', async () => {
@@ -15,4 +30,50 @@ describe('get_file_name_from_path', () => {
1530
const path = await get_file_name_from_path('/files/contents/birds/')
1631
expect(path).toBe('/birds')
1732
})
18-
})
33+
}),
34+
describe('list_files_in_directory', () => {
35+
it('lists the files in a directory', async () => {
36+
mock({
37+
testDir: {
38+
cats: 'aurora, luna',
39+
dogs: 'penny',
40+
},
41+
})
42+
const listFiles = await list_files_in_directory('testDir')
43+
expect(listFiles).toEqual(['file:///testDir/cats', 'file:///testDir/dogs'])
44+
}),
45+
it('throws an error if path is not a directory', async () => {
46+
mock({
47+
testDir: {
48+
cats: 'aurora, luna',
49+
dogs: 'penny',
50+
},
51+
})
52+
await expect(async () => await list_files_in_directory('testDir/cats')).rejects.toThrow(
53+
'Failed to read directory'
54+
)
55+
}),
56+
it('treats empty strings as cwd', async () => {
57+
mock({
58+
testDir: {
59+
cats: 'aurora, luna',
60+
dogs: 'penny',
61+
},
62+
})
63+
64+
const listFiles = await list_files_in_directory('')
65+
expect(listFiles).toEqual(['file:///../../../../../../testDir'])
66+
})
67+
}),
68+
describe('get_mime_type', async () => {
69+
it("provides the natural mime type when not 'inode/directory'", async () => {
70+
vi.mocked(mime.getType).mockReturnValueOnce('theType')
71+
const mimeType = await get_mime_type('someFile')
72+
expect(mimeType).toEqual('theType')
73+
})
74+
it("overrides mime type for 'inode/directory'", async () => {
75+
vi.mocked(mime.getType).mockReturnValueOnce('inode/directory')
76+
const mimeType = await get_mime_type('someDirectory')
77+
expect(mimeType).toEqual('text/directory')
78+
})
79+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
1+
import * as fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import mime from 'mime'
4+
5+
// this is because there isn't a "real" directory mime type, so we're reusing the "text/directory" mime type
6+
// so claude doesn't give an error
7+
export const DIRECTORY_CONTENT_TYPE = 'text/directory'
8+
19
export async function get_file_name_from_path(path: string): Promise<string> {
210
path = path.replace('/files/contents', '')
311
path = path.endsWith('/') ? path.substring(0, path.length - 1) : path
412

513
return path
614
}
15+
16+
export async function list_files_in_directory(dirPath: string): Promise<string[]> {
17+
const files: string[] = []
18+
try {
19+
const dir = await fs.readdir(path.join(process.cwd(), dirPath), {
20+
withFileTypes: true,
21+
})
22+
for (const dirent of dir) {
23+
const relPath = path.relative(process.cwd(), `${dirPath}/${dirent.name}`)
24+
files.push(`file:///${relPath}`)
25+
}
26+
} catch (error) {
27+
throw new Error('Failed to read directory')
28+
}
29+
30+
return files
31+
}
32+
33+
export async function get_mime_type(path: string): Promise<string | null> {
34+
let mimeType = mime.getType(path)
35+
if (mimeType && mimeType === 'inode/directory') {
36+
mimeType = DIRECTORY_CONTENT_TYPE
37+
}
38+
return mimeType
39+
}

apps/sandbox-container/container/index.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import { Hono } from 'hono'
77
import { streamText } from 'hono/streaming'
88
import mime from 'mime'
99

10-
import { ExecParams, FilesWrite } from '../shared/schema'
11-
import { get_file_name_from_path } from './fileUtils'
10+
import { ExecParams, FilesWrite } from '../shared/schema.ts'
11+
import {
12+
DIRECTORY_CONTENT_TYPE,
13+
get_file_name_from_path,
14+
get_mime_type,
15+
list_files_in_directory,
16+
} from './fileUtils.ts'
1217

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

@@ -65,32 +70,18 @@ app.get('/files/ls', async (c) => {
6570
app.get('/files/contents/*', async (c) => {
6671
const reqPath = await get_file_name_from_path(c.req.path)
6772
try {
68-
const mimeType = mime.getType(reqPath)
73+
const mimeType = await get_mime_type(reqPath)
6974
const headers = mimeType ? { 'Content-Type': mimeType } : undefined
7075
const contents = await fs.readFile(path.join(process.cwd(), reqPath))
7176
return c.newResponse(contents, 200, headers)
7277
} catch (e: any) {
7378
if (e.code) {
74-
// handle directory
7579
if (e.code === 'EISDIR') {
76-
const files: string[] = []
77-
const dir = await fs.readdir(path.join(process.cwd(), reqPath), {
78-
withFileTypes: true,
79-
})
80-
for (const dirent of dir) {
81-
const relPath = path.relative(process.cwd(), `${reqPath}/${dirent.name}`)
82-
if (dirent.isDirectory()) {
83-
files.push(`file:///${relPath}`)
84-
} else {
85-
const mimeType = mime.getType(dirent.name)
86-
files.push(`file:///${relPath}`)
87-
}
88-
}
80+
const files = await list_files_in_directory(reqPath)
8981
return c.newResponse(files.join('\n'), 200, {
90-
'Content-Type': 'inode/directory',
82+
'Content-Type': DIRECTORY_CONTENT_TYPE,
9183
})
9284
}
93-
9485
if (e.code === 'ENOENT') {
9586
return c.notFound()
9687
}

apps/sandbox-container/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
"zod": "^3.24.2"
4141
},
4242
"devDependencies": {
43+
"@types/mock-fs": "^4.13.4",
44+
"mock-fs": "^5.5.0",
4345
"@cloudflare/vitest-pool-workers": "0.8.14",
4446
"ai": "^4.3.6",
4547
"concurrently": "^9.1.2",

apps/sandbox-container/server/containerMcp.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22
import { McpAgent } from 'agents/mcp'
3-
import { z } from 'zod'
43

54
import { OPEN_CONTAINER_PORT } from '../shared/consts'
65
import { ExecParams, FilePathParam, FilesWrite } from '../shared/schema'
76
import { MAX_CONTAINERS, proxyFetch, startAndWaitForPort } from './containerHelpers'
87
import { getContainerManager } from './containerManager'
98
import { BASE_INSTRUCTIONS } from './prompts'
10-
import { fileToBase64 } from './utils'
9+
import { fileToBase64, stripProtocolFromFilePath } from './utils'
1110

1211
import type { FileList } from '../shared/schema'
1312
import type { Env, Props } from '.'
@@ -77,7 +76,8 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
7776
'Delete file and its contents',
7877
{ args: FilePathParam },
7978
async ({ args }) => {
80-
const deleted = await this.container_file_delete(args)
79+
const path = await stripProtocolFromFilePath(args.path)
80+
const deleted = await this.container_file_delete(path)
8181
return {
8282
content: [{ type: 'text', text: `File deleted: ${deleted}.` }],
8383
}
@@ -88,6 +88,7 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
8888
'Write file contents',
8989
{ args: FilesWrite },
9090
async ({ args }) => {
91+
args.path = await stripProtocolFromFilePath(args.path)
9192
return {
9293
content: [{ type: 'text', text: await this.container_files_write(args) }],
9394
}
@@ -117,19 +118,13 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
117118
})
118119
this.server.tool(
119120
'container_file_read',
120-
'Read a specific file',
121-
{ path: z.string() },
122-
async ({ path }) => {
123-
// normalize
124-
path = path.startsWith('file://') ? path.replace('file://', '') : path
125-
let { blob, mimeType } = await this.container_files_read(path)
126-
127-
if (mimeType && (mimeType.startsWith('text') || mimeType === 'inode/directory')) {
128-
// this is because there isn't a "real" directory mime type, so we're reusing the "text/directory" mime type
129-
// so claude doesn't give an error
130-
mimeType = mimeType === 'inode/directory' ? 'text/directory' : mimeType
121+
'Read a specific file or list of files within a specific directory',
122+
{ args: FilePathParam },
123+
async ({ args }) => {
124+
const path = await stripProtocolFromFilePath(args.path)
125+
const { blob, mimeType } = await this.container_file_read(path)
131126

132-
// maybe "inode/directory" should list out multiple files in the contents list?
127+
if (mimeType && mimeType.startsWith('text')) {
133128
return {
134129
content: [
135130
{
@@ -253,7 +248,7 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
253248
)
254249
return res.ok
255250
}
256-
async container_files_read(
251+
async container_file_read(
257252
filePath: string
258253
): Promise<{ blob: Blob; mimeType: string | undefined }> {
259254
const res = await proxyFetch(
@@ -267,15 +262,11 @@ export class ContainerMcpAgent extends McpAgent<Env, Props> {
267262
}
268263
return {
269264
blob: await res.blob(),
270-
mimeType: res.headers.get('content-type') ?? undefined,
265+
mimeType: res.headers.get('Content-Type') ?? undefined,
271266
}
272267
}
273268

274269
async container_files_write(file: FilesWrite): Promise<string> {
275-
if (file.path.startsWith('file://')) {
276-
// normalize just in case the LLM sends the full resource URI
277-
file.path = file.path.replace('file://', '')
278-
}
279270
const res = await proxyFetch(
280271
this.env.ENVIRONMENT,
281272
this.ctx.container,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { stripProtocolFromFilePath } from './utils'
4+
5+
describe('get_file_name_from_path', () => {
6+
it('strips file:// protocol from path', async () => {
7+
const path = await stripProtocolFromFilePath('file:///files/contents/cats')
8+
expect(path).toBe('/files/contents/cats')
9+
}),
10+
it('leaves protocol-less paths untouched', async () => {
11+
const path = await stripProtocolFromFilePath('/files/contents/cats')
12+
expect(path).toBe('/files/contents/cats')
13+
})
14+
})

apps/sandbox-container/server/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@ export async function fileToBase64(blob: Blob): Promise<string> {
1212
// Apply base64 encoding
1313
return btoa(binary)
1414
}
15+
16+
// Used for file related tool calls in case the llm sends a full resource URI
17+
export async function stripProtocolFromFilePath(path: string): Promise<string> {
18+
return path.startsWith('file://') ? path.replace('file://', '') : path
19+
}

apps/sandbox-container/shared/schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ export const FilesWrite = z.object({
1414
})
1515

1616
export type FilePathParam = z.infer<typeof FilePathParam>
17-
export const FilePathParam = z.string()
17+
export const FilePathParam = z.object({
18+
path: z.string(),
19+
})
1820

1921
export type FileList = z.infer<typeof FileList>
2022
export const FileList = z.object({

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)