Skip to content

Commit cb28064

Browse files
committed
Update read tool call
1 parent 7b2f086 commit cb28064

File tree

7 files changed

+128
-49
lines changed

7 files changed

+128
-49
lines changed

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

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
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'
4+
import { get_file_name_from_path, get_mime_type, list_files_in_directory } from './fileUtils'
25

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

519
describe('get_file_name_from_path', () => {
620
it('strips files/contents', async () => {
@@ -15,4 +29,50 @@ describe('get_file_name_from_path', () => {
1529
const path = await get_file_name_from_path('/files/contents/birds/')
1630
expect(path).toBe('/birds')
1731
})
18-
})
32+
}),
33+
describe('list_files_in_directory', () => {
34+
it('lists the files in a directory', async () => {
35+
mock({
36+
testDir: {
37+
"cats": "aurora, luna",
38+
"dogs": "penny",
39+
}
40+
})
41+
const listFiles = await list_files_in_directory("testDir")
42+
expect(listFiles).toEqual(["file:///testDir/cats", "file:///testDir/dogs"])
43+
}),
44+
it('throws an error if path is not a directory', async () => {
45+
mock({
46+
testDir: {
47+
"cats": "aurora, luna",
48+
"dogs": "penny",
49+
}
50+
})
51+
await expect(async () => await list_files_in_directory("testDir/cats")).rejects.toThrow("Failed to read directory")
52+
}),
53+
it('treats empty strings as cwd', async () => {
54+
mock({
55+
testDir: {
56+
"cats": "aurora, luna",
57+
"dogs": "penny",
58+
}
59+
})
60+
61+
const listFiles = await list_files_in_directory("")
62+
expect(listFiles).toEqual(["file:///../../../../../../testDir"])
63+
})
64+
}),
65+
describe("get_mime_type", async () => {
66+
it("provides the natural mime type when not 'inode/directory'", async () => {
67+
vi.mocked(mime.getType).mockReturnValueOnce("theType")
68+
const mimeType = await get_mime_type("someFile")
69+
expect(mimeType).toEqual("theType")
70+
})
71+
it("overrides mime type for inode/directory", async () => {
72+
vi.mocked(mime.getType).mockReturnValueOnce("inode/directory")
73+
const mimeType = await get_mime_type("someDirectory")
74+
expect(mimeType).toEqual("text/directory")
75+
})
76+
}
77+
)
78+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
1+
import mime from 'mime';
2+
import * as fs from 'node:fs/promises';
3+
import path from 'node:path';
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: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { exec } from 'node:child_process'
2-
import * as fs from 'node:fs/promises'
3-
import path from 'node:path'
41
import { serve } from '@hono/node-server'
52
import { zValidator } from '@hono/zod-validator'
63
import { Hono } from 'hono'
74
import { streamText } from 'hono/streaming'
85
import mime from 'mime'
6+
import { exec } from 'node:child_process'
7+
import * as fs from 'node:fs/promises'
8+
import path from 'node:path'
99

1010
import { ExecParams, FilesWrite } from '../shared/schema.ts'
11-
import { get_file_name_from_path } from './fileUtils.ts'
11+
import { DIRECTORY_CONTENT_TYPE, get_file_name_from_path, get_mime_type, list_files_in_directory } from './fileUtils.ts'
1212

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

@@ -65,32 +65,18 @@ app.get('/files/ls', async (c) => {
6565
app.get('/files/contents/*', async (c) => {
6666
const reqPath = await get_file_name_from_path(c.req.path)
6767
try {
68-
const mimeType = mime.getType(reqPath)
68+
const mimeType = await get_mime_type(reqPath)
6969
const headers = mimeType ? { 'Content-Type': mimeType } : undefined
7070
const contents = await fs.readFile(path.join(process.cwd(), reqPath))
7171
return c.newResponse(contents, 200, headers)
7272
} catch (e: any) {
7373
if (e.code) {
74-
// handle directory
7574
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-
}
75+
const files = await list_files_in_directory(reqPath)
8976
return c.newResponse(files.join('\n'), 200, {
90-
'Content-Type': 'inode/directory',
77+
'Content-Type': DIRECTORY_CONTENT_TYPE,
9178
})
9279
}
93-
9480
if (e.code === 'ENOENT') {
9581
return c.notFound()
9682
}

apps/sandbox-container/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,22 @@
2020
"@hono/node-server": "^1.13.8",
2121
"@hono/zod-validator": "^0.4.3",
2222
"@modelcontextprotocol/sdk": "^1.7.0",
23+
"@repo/mcp-common": "workspace:*",
2324
"@types/node": "^22.13.10",
2425
"agents": "^0.0.42",
2526
"cron-schedule": "^5.0.4",
2627
"esbuild": "^0.25.1",
2728
"hono": "^4.7.5",
2829
"mime": "^4.0.6",
30+
"mock-fs": "^5.5.0",
2931
"octokit": "^4.1.2",
3032
"partyserver": "^0.0.65",
3133
"tsx": "^4.19.3",
3234
"workers-mcp": "0.1.0-3",
33-
"zod": "^3.24.2",
34-
"@repo/mcp-common": "workspace:*"
35+
"zod": "^3.24.2"
3536
},
3637
"devDependencies": {
38+
"@types/mock-fs": "^4.13.4",
3739
"concurrently": "^9.1.2",
3840
"wrangler": "^4.9.1"
3941
}

apps/sandbox-container/server/containerMcp.ts

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
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

12-
import type { FileList } from '../shared/schema'
1311
import type { Env, Props } from '.'
12+
import type { FileList } from '../shared/schema'
1413

1514
export class ContainerMcpAgent extends McpAgent<Env, Props> {
1615
server = new McpServer(
@@ -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,

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({

0 commit comments

Comments
 (0)