Skip to content

Commit 7aeec23

Browse files
committed
feat: Add local filesystem mount option
1 parent 37a5ddd commit 7aeec23

File tree

7 files changed

+614
-28
lines changed

7 files changed

+614
-28
lines changed

mcp-run-python/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The server can be run with `deno` installed using:
1212
```bash
1313
deno run \
1414
-N -R=node_modules -W=node_modules --node-modules-dir=auto \
15-
jsr:@pydantic/mcp-run-python [stdio|sse|warmup]
15+
jsr:@pydantic/mcp-run-python [stdio|sse|warmup] [--mount local_path:pyodide_path]
1616
```
1717

1818
where:
@@ -29,6 +29,9 @@ where:
2929
running the server as an HTTP server to connect locally or remotely
3030
- `warmup` will run a minimal Python script to download and cache the Python standard library. This is also useful to
3131
check the server is running correctly.
32+
- `--mount local_path:pyodide_path` (optional) mounts a local filesystem directory to the Pyodide filesystem, allowing
33+
Python code to read and write files. Files are automatically synced back to the local filesystem after successful
34+
execution.
3235

3336
Here's an example of using `@pydantic/mcp-run-python` with Pydantic AI:
3437

mcp-run-python/deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"imports": {
1616
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.15.1",
1717
"@std/cli": "jsr:@std/cli@^1.0.15",
18+
"@std/fs": "jsr:@std/fs@^1.0.8",
1819
"@std/path": "jsr:@std/path@^1.0.8",
1920
// do NOT upgrade above this version until there is a workaround for https://github.com/pyodide/pyodide/pull/5621
2021
"pyodide": "npm:[email protected]",

mcp-run-python/deno.lock

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

mcp-run-python/src/filesystem.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/// <reference types="npm:@types/[email protected]" />
2+
3+
import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'
4+
import { walk } from '@std/fs/walk'
5+
import { relative } from '@std/path'
6+
7+
export interface FileInfo {
8+
type: 'text' | 'binary'
9+
content: string
10+
}
11+
12+
export interface MountPathInfo {
13+
localPath: string
14+
pyodidePath: string
15+
}
16+
17+
// Basic interface for Pyodide functionality used in filesystem operations
18+
interface PyodideInterface {
19+
pyimport: (name: string) => unknown
20+
}
21+
22+
// Interface for Python pathlib module
23+
interface PathlibModule {
24+
Path: (path: string) => PathlibPath
25+
}
26+
27+
interface PathlibPath {
28+
mkdir: (options?: { parents?: boolean; exist_ok?: boolean }) => void
29+
parent: PathlibPath
30+
write_text: (content: string) => void
31+
write_bytes: (data: unknown) => void
32+
exists: () => boolean
33+
rglob: (pattern: string) => PathlibPath[]
34+
is_file: () => boolean
35+
relative_to: (other: PathlibPath) => PathlibPath
36+
toString: () => string
37+
read_text: (options?: { encoding?: string }) => string
38+
read_bytes: () => unknown
39+
}
40+
41+
// Interface for Python base64 module
42+
interface Base64Module {
43+
b64decode: (data: string) => unknown
44+
b64encode: (data: unknown) => { decode: (encoding: string) => string }
45+
}
46+
47+
/**
48+
* Parse mount path string in format "local_path:pyodide_path"
49+
* Returns null if format is invalid
50+
*/
51+
export function parseMountPath(mountPath: string): MountPathInfo | null {
52+
const [localPath, pyodidePath] = mountPath.split(':')
53+
if (localPath && pyodidePath) {
54+
return { localPath, pyodidePath }
55+
}
56+
return null
57+
}
58+
59+
/**
60+
* Handle filesystem mounting with path parsing and validation
61+
*/
62+
export async function handleMount(
63+
pyodide: PyodideInterface,
64+
mountPath: string,
65+
log: (level: LoggingLevel, data: string) => void,
66+
) {
67+
const mountInfo = parseMountPath(mountPath)
68+
if (mountInfo) {
69+
await mountFilesToPyodide(pyodide, mountInfo.localPath, mountInfo.pyodidePath, log)
70+
} else {
71+
log('warning', 'Invalid mount path format. Use: local_path:pyodide_path')
72+
}
73+
}
74+
75+
/**
76+
* Handle filesystem sync back with path parsing and validation
77+
*/
78+
export async function handleSyncBack(
79+
pyodide: PyodideInterface,
80+
mountPath: string,
81+
log: (level: LoggingLevel, data: string) => void,
82+
) {
83+
const mountInfo = parseMountPath(mountPath)
84+
if (mountInfo) {
85+
try {
86+
await syncFilesFromPyodide(pyodide, mountInfo.pyodidePath, mountInfo.localPath, log)
87+
} catch (error) {
88+
log('warning', `Failed to sync files back to ${mountInfo.localPath}: ${error}`)
89+
}
90+
}
91+
}
92+
93+
/**
94+
* Read all files from a local directory and return them as a map
95+
* with relative paths as keys and file info as values
96+
*/
97+
export async function readLocalDirectory(localPath: string): Promise<Map<string, FileInfo>> {
98+
const files = new Map<string, FileInfo>()
99+
100+
try {
101+
for await (const entry of walk(localPath, { includeFiles: true, includeDirs: false })) {
102+
if (entry.isFile) {
103+
const relativePath = relative(localPath, entry.path)
104+
105+
try {
106+
// Try to read as text first
107+
const content = await Deno.readTextFile(entry.path)
108+
files.set(relativePath, { type: 'text', content })
109+
} catch {
110+
// If text reading fails, read as binary and encode as base64
111+
const binaryContent = await Deno.readFile(entry.path)
112+
const encodedContent = btoa(String.fromCharCode(...binaryContent))
113+
files.set(relativePath, { type: 'binary', content: encodedContent })
114+
}
115+
}
116+
}
117+
} catch (error) {
118+
throw new Error(`Failed to read directory ${localPath}: ${error}`)
119+
}
120+
121+
return files
122+
}
123+
124+
/**
125+
* Mount local filesystem files to Pyodide filesystem
126+
*/
127+
export async function mountFilesToPyodide(
128+
pyodide: PyodideInterface,
129+
localPath: string,
130+
pyodidePath: string,
131+
log: (level: LoggingLevel, data: string) => void,
132+
) {
133+
try {
134+
// Read the local directory contents
135+
const localFiles = await readLocalDirectory(localPath)
136+
137+
// Import Python modules we need
138+
const pathlib = pyodide.pyimport('pathlib') as PathlibModule
139+
const base64 = pyodide.pyimport('base64') as Base64Module
140+
141+
// Create the mount directory
142+
const mountDir = pathlib.Path(pyodidePath)
143+
mountDir.mkdir({ parents: true, exist_ok: true })
144+
145+
for (const [relativePath, fileInfo] of localFiles) {
146+
const targetPath = `${pyodidePath}/${relativePath}`
147+
const targetPathObj = pathlib.Path(targetPath)
148+
149+
// Ensure parent directory exists
150+
targetPathObj.parent.mkdir({ parents: true, exist_ok: true })
151+
152+
if (fileInfo.type === 'text') {
153+
// Write text file directly
154+
targetPathObj.write_text(fileInfo.content)
155+
} else if (fileInfo.type === 'binary') {
156+
// Decode base64 and write binary file
157+
const binaryData = base64.b64decode(fileInfo.content)
158+
targetPathObj.write_bytes(binaryData)
159+
}
160+
}
161+
162+
log('info', `Mounted ${localPath} to ${pyodidePath}`)
163+
} catch (error) {
164+
log('warning', `Failed to mount ${localPath}: ${error}`)
165+
}
166+
}
167+
168+
/**
169+
* Sync files from Pyodide filesystem back to local filesystem
170+
*/
171+
export async function syncFilesFromPyodide(
172+
pyodide: PyodideInterface,
173+
pyodidePath: string,
174+
localPath: string,
175+
log: (level: LoggingLevel, data: string) => void,
176+
) {
177+
try {
178+
// Import Python modules we need
179+
const pathlib = pyodide.pyimport('pathlib') as PathlibModule
180+
const base64 = pyodide.pyimport('base64') as Base64Module
181+
182+
// Get the mount directory
183+
const mountPath = pathlib.Path(pyodidePath)
184+
185+
if (!mountPath.exists()) {
186+
log('info', `Mount path ${pyodidePath} does not exist, nothing to sync`)
187+
return
188+
}
189+
190+
const filesData: Record<string, FileInfo> = {}
191+
192+
// Iterate through all files in the mount directory
193+
const allFiles = mountPath.rglob('*')
194+
for (const filePath of allFiles) {
195+
if (filePath.is_file()) {
196+
try {
197+
const relativePath = filePath.relative_to(mountPath).toString()
198+
199+
// Try to read as text first
200+
try {
201+
const content = filePath.read_text({ encoding: 'utf-8' })
202+
filesData[relativePath] = {
203+
type: 'text',
204+
content: content,
205+
}
206+
} catch {
207+
// If text reading fails, read as binary and encode as base64
208+
const binaryContent = filePath.read_bytes()
209+
const encodedContent = base64.b64encode(binaryContent).decode('ascii')
210+
filesData[relativePath] = {
211+
type: 'binary',
212+
content: encodedContent,
213+
}
214+
}
215+
} catch (error) {
216+
log('warning', `Error reading file ${filePath}: ${error}`)
217+
}
218+
}
219+
}
220+
221+
// Write each file back to the local filesystem
222+
for (const [relativePath, fileInfo] of Object.entries(filesData)) {
223+
const localFilePath = `${localPath}/${relativePath}`
224+
225+
// Ensure parent directory exists
226+
const parentDir = localFilePath.substring(0, localFilePath.lastIndexOf('/'))
227+
if (parentDir !== localPath) {
228+
await Deno.mkdir(parentDir, { recursive: true })
229+
}
230+
231+
// Write the file based on its type
232+
if (fileInfo.type === 'text') {
233+
await Deno.writeTextFile(localFilePath, fileInfo.content)
234+
} else if (fileInfo.type === 'binary') {
235+
// Decode base64 and write as binary
236+
const binaryData = new Uint8Array(
237+
atob(fileInfo.content)
238+
.split('')
239+
.map((char) => char.charCodeAt(0)),
240+
)
241+
await Deno.writeFile(localFilePath, binaryData)
242+
}
243+
}
244+
245+
const fileCount = Object.keys(filesData).length
246+
if (fileCount > 0) {
247+
log('info', `Synced ${fileCount} files (text and binary) from ${pyodidePath} back to ${localPath}`)
248+
}
249+
} catch (error) {
250+
throw new Error(`Failed to sync files from Pyodide: ${error}`)
251+
}
252+
}

0 commit comments

Comments
 (0)