Skip to content

Commit bb690bb

Browse files
author
BrokenDuck
committed
Add direct upload tool
1 parent 5856737 commit bb690bb

File tree

2 files changed

+87
-4
lines changed

2 files changed

+87
-4
lines changed

mcp-run-python/src/files.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { exists } from '@std/fs/exists'
33
import { contentType } from '@std/media-types'
44
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
55
import z from 'zod'
6-
import { encodeBase64 } from '@std/encoding/base64'
6+
import { decodeBase64, encodeBase64 } from '@std/encoding/base64'
77

88
/**
99
* Returns the temporary directory in the local filesystem for file persistence.
@@ -18,6 +18,37 @@ export function createRootDir(): string {
1818
* @param rootDir Directory in the local file system to read/write to.
1919
*/
2020
export function registerFileFunctions(server: McpServer, rootDir: string) {
21+
server.registerTool('upload_file', {
22+
title: 'Upload file.',
23+
description: 'Ingest a file from the given object. Returns a link to the resource that was created.',
24+
inputSchema: {
25+
type: z.union([z.literal('text'), z.literal('bytes')]),
26+
filename: z.string().describe('Name of the file to write.'),
27+
text: z.nullable(z.string().describe('Text content of the file, if the type is "text".')),
28+
blob: z.nullable(z.string().describe('Base 64 encoded content of the file, if the type is "bytes".')),
29+
},
30+
}, async ({ type, filename, text, blob }) => {
31+
const absPath = path.join(rootDir, filename)
32+
if (type === 'text') {
33+
if (text == null) {
34+
return { content: [{ type: 'text', text: "Type is 'text', but no text provided." }], isError: true }
35+
}
36+
await Deno.writeTextFile(absPath, text)
37+
} else {
38+
if (blob == null) {
39+
return { content: [{ type: 'text', text: "Type is 'bytes', but no blob provided." }], isError: true }
40+
}
41+
await Deno.writeFile(absPath, decodeBase64(blob))
42+
}
43+
return {
44+
content: [{
45+
type: 'resource_link',
46+
uri: `file:///${filename}`,
47+
name: filename,
48+
mimeType: contentType(path.extname(absPath)),
49+
}],
50+
}
51+
})
2152
// File Upload
2253
server.registerTool(
2354
'upload_file_from_uri',

mcp-run-python/test_mcp_servers.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
class McpTools(StrEnum):
4141
RUN_PYTHON_CODE = 'run_python_code'
42+
UPLOAD_FILE = 'upload_file'
4243
UPLOAD_FILE_FROM_URI = 'upload_file_from_uri'
4344
RETRIEVE_FILE = 'retrieve_file'
4445
DELETE_FILE = 'delete_file'
@@ -169,7 +170,7 @@ async def test_list_tools(
169170
)
170171
else:
171172
# Check tools
172-
assert len(tools.tools) == 4
173+
assert len(tools.tools) == 5
173174
# sort tools by their name
174175
sorted_tools = sorted(tools.tools, key=lambda t: t.name)
175176

@@ -345,8 +346,59 @@ def do_GET(self):
345346
httpd.server_close()
346347
t.join(timeout=2)
347348

348-
@pytest.mark.parametrize('uri_type', ['http', 'file'])
349+
@pytest.mark.parametrize('file_type', ['text', 'bytes'])
349350
async def test_upload_files(
351+
self,
352+
mcp_session: ClientSession,
353+
server_type: Literal['stdio', 'sse', 'streamable_http'],
354+
mount: bool | str,
355+
file_type: Literal['text', 'bytes'],
356+
tmp_path: Path,
357+
) -> None:
358+
if mount is False:
359+
pytest.skip('No directory mounted.')
360+
result = await mcp_session.initialize()
361+
362+
# Extract directory from response
363+
storageDir = self.get_dir_from_instructions(result.instructions)
364+
assert storageDir.is_dir()
365+
366+
match file_type:
367+
case 'text':
368+
filename = 'data.csv'
369+
ctype = 'text/csv'
370+
result = await mcp_session.call_tool(
371+
McpTools.UPLOAD_FILE, {'type': 'text', 'filename': filename, 'text': CSV_DATA, 'blob': None}
372+
)
373+
374+
case 'bytes':
375+
filename = 'image.png'
376+
ctype = 'image/png'
377+
result = await mcp_session.call_tool(
378+
McpTools.UPLOAD_FILE, {'type': 'bytes', 'filename': filename, 'blob': BASE_64_IMAGE, 'text': None}
379+
)
380+
381+
assert result.isError is False
382+
assert len(result.content) == 1
383+
content = result.content[0]
384+
assert isinstance(content, types.ResourceLink)
385+
assert str(content.uri) == f'file:///{filename}'
386+
assert content.name == filename
387+
assert content.mimeType is not None
388+
assert content.mimeType.startswith(ctype)
389+
390+
createdFile = storageDir / filename
391+
assert createdFile.exists()
392+
assert createdFile.is_file()
393+
394+
match file_type:
395+
case 'text':
396+
assert createdFile.read_text() == CSV_DATA
397+
case 'bytes':
398+
assert base64.b64encode(createdFile.read_bytes()).decode() == BASE_64_IMAGE
399+
400+
@pytest.mark.parametrize('uri_type', ['http', 'file'])
401+
async def test_upload_files_with_uri(
350402
self,
351403
mcp_session: ClientSession,
352404
server_type: Literal['stdio', 'sse', 'streamable_http'],
@@ -400,7 +452,7 @@ async def test_download_files(
400452
server_type: Literal['stdio', 'sse', 'streamable_http'],
401453
mount: bool | str,
402454
content_type: Literal['bytes', 'text'],
403-
):
455+
) -> None:
404456
if mount is False:
405457
pytest.skip('No directory mounted.')
406458
result = await mcp_session.initialize()

0 commit comments

Comments
 (0)