Skip to content

Commit 1e93813

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

File tree

7 files changed

+571
-28
lines changed

7 files changed

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

0 commit comments

Comments
 (0)