Skip to content

Commit d26dab5

Browse files
author
BrokenDuck
committed
Added file related MCP function
1 parent 5c65995 commit d26dab5

File tree

5 files changed

+404
-138
lines changed

5 files changed

+404
-138
lines changed

mcp-run-python/deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"imports": {
1616
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.17.4",
1717
"@std/cli": "jsr:@std/cli@^1.0.15",
18+
"@std/fs": "jsr:@std/fs@^1.0.19",
19+
"@std/media-types": "jsr:@std/media-types@^1.1.0",
1820
"@std/path": "jsr:@std/path@^1.0.8",
1921
// do NOT upgrade above this version until there is a workaround for https://github.com/pyodide/pyodide/pull/5621
2022
"pyodide": "npm:[email protected]",

mcp-run-python/deno.lock

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

mcp-run-python/src/files.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as path from '@std/path'
2+
import { exists } from '@std/fs/exists'
3+
import { contentType } from '@std/media-types'
4+
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
5+
import z from 'zod'
6+
7+
/**
8+
* Returns the temporary directory in the local filesystem for file persistence.
9+
*/
10+
export function createRootDir(): string {
11+
return Deno.makeTempDirSync({ prefix: 'mcp_run_python' })
12+
}
13+
14+
/**
15+
* Streams a file from an http(s) or file:// URI into targetPath.
16+
* Returns when bytes are fully written.
17+
*/
18+
async function uploadFromUri(
19+
uri: string,
20+
absPath: string,
21+
): Promise<void> {
22+
await Deno.mkdir(
23+
path.dirname(absPath),
24+
{ recursive: true },
25+
)
26+
27+
if (uri.startsWith('http://') || uri.startsWith('https://')) {
28+
const res = await fetch(uri)
29+
if (!res.ok || !res.body) {
30+
throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
31+
}
32+
const file = await Deno.open(absPath, {
33+
write: true,
34+
create: true,
35+
truncate: true,
36+
})
37+
try {
38+
await res.body.pipeTo(file.writable)
39+
} finally {
40+
file.close()
41+
}
42+
return
43+
}
44+
45+
if (uri.startsWith('file://')) {
46+
const src = await Deno.open(path.fromFileUrl(uri), { read: true })
47+
const dest = await Deno.open(absPath, {
48+
write: true,
49+
create: true,
50+
truncate: true,
51+
})
52+
try {
53+
await src.readable.pipeTo(dest.writable)
54+
} finally {
55+
src.close()
56+
dest.close()
57+
}
58+
return
59+
}
60+
61+
throw new Error(`Unsupported URI scheme: ${uri}`)
62+
}
63+
64+
/**
65+
* Register file related functions to the MCP server.
66+
* @param server The MCP Server
67+
* @param rootDir Directory in the local file system to read/write to.
68+
*/
69+
export function registerFileFunctions(server: McpServer, rootDir: string) {
70+
// File Upload
71+
server.registerTool(
72+
'upload_file_from_uri',
73+
{
74+
title: 'Upload file from URI',
75+
description: 'Ingest a file by URI and store it. Returns a canonical URL.',
76+
inputSchema: {
77+
uri: z.string().url().describe('file:// or https:// style URL'),
78+
filename: z
79+
.string()
80+
.describe('The name of the file to write.'),
81+
},
82+
},
83+
async ({ uri, filename }: { uri: string; filename: string }) => {
84+
const absPath = path.join(rootDir, filename)
85+
await uploadFromUri(uri, absPath)
86+
return {
87+
content: [{ type: 'text', text: 'Upload successful.' }],
88+
}
89+
},
90+
)
91+
92+
// Register all the files in the local directory as resources
93+
server.registerResource(
94+
'read-file',
95+
new ResourceTemplate('file:///{filename}', {
96+
list: async (_extra) => {
97+
const resources = []
98+
for await (const dirEntry of Deno.readDir(rootDir)) {
99+
if (!dirEntry.isFile) continue
100+
resources.push({
101+
uri: `file:///${dirEntry.name}`,
102+
name: dirEntry.name,
103+
mimeType: contentType(path.extname(dirEntry.name)),
104+
})
105+
}
106+
return { resources: resources }
107+
},
108+
}),
109+
{
110+
title: 'Read file.',
111+
description: 'Read file from persistent storage',
112+
},
113+
async (uri, { filename }) => {
114+
const absPath = path.join(rootDir, ...(Array.isArray(filename) ? filename : [filename]))
115+
const mime = contentType(absPath)
116+
const fileBytes = await Deno.readFile(absPath)
117+
118+
// Check if it's text-based
119+
if (mime && /^text\/|\/json$|\/csv$|\/javascript$|\/xml$/.test(mime)) {
120+
const text = new TextDecoder().decode(fileBytes)
121+
return { contents: [{ uri: uri.href, name: filename, mimeType: mime, text: text }] }
122+
} else {
123+
const base64 = btoa(String.fromCharCode(...fileBytes))
124+
return { contents: [{ uri: uri.href, name: filename, mimeType: mime, blob: base64 }] }
125+
}
126+
},
127+
)
128+
129+
// This functions only checks if the file exits
130+
// Download happens through the registered resource
131+
server.registerTool('retrieve_file', {
132+
title: 'Retrieve a file',
133+
description: 'Retrieve a file from the persistent file store.',
134+
inputSchema: { filename: z.string().describe('The name of the file to read.') },
135+
}, async ({ filename }) => {
136+
const absPath = path.join(rootDir, filename)
137+
if (await exists(absPath, { isFile: true })) {
138+
return {
139+
content: [{
140+
type: 'resource_link',
141+
uri: `file:///${filename}`,
142+
name: filename,
143+
mimeType: contentType(path.extname(absPath)),
144+
}],
145+
}
146+
} else {
147+
return {
148+
content: [{ 'type': 'text', 'text': `Failed to retrieve file ${filename}. File not found.` }],
149+
isError: true,
150+
}
151+
}
152+
})
153+
}

0 commit comments

Comments
 (0)