diff --git a/mcp-run-python/README.md b/mcp-run-python/README.md index 4492f1eb7..d01384cc7 100644 --- a/mcp-run-python/README.md +++ b/mcp-run-python/README.md @@ -12,7 +12,7 @@ The server can be run with `deno` installed using: ```bash deno run \ -N -R=node_modules -W=node_modules --node-modules-dir=auto \ - jsr:@pydantic/mcp-run-python [stdio|sse|warmup] + jsr:@pydantic/mcp-run-python [stdio|sse|warmup] [--mount local_path:pyodide_path] ``` where: @@ -29,6 +29,9 @@ where: running the server as an HTTP server to connect locally or remotely - `warmup` will run a minimal Python script to download and cache the Python standard library. This is also useful to check the server is running correctly. +- `--mount local_path:pyodide_path` (optional) mounts a local filesystem directory to the Pyodide filesystem, allowing + Python code to read and write files. Files are automatically synced back to the local filesystem after successful + execution. Here's an example of using `@pydantic/mcp-run-python` with Pydantic AI: diff --git a/mcp-run-python/deno.json b/mcp-run-python/deno.json index b84e0546c..89ea7853c 100644 --- a/mcp-run-python/deno.json +++ b/mcp-run-python/deno.json @@ -15,6 +15,7 @@ "imports": { "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.15.1", "@std/cli": "jsr:@std/cli@^1.0.15", + "@std/fs": "jsr:@std/fs@^1.0.8", "@std/path": "jsr:@std/path@^1.0.8", // do NOT upgrade above this version until there is a workaround for https://github.com/pyodide/pyodide/pull/5621 "pyodide": "npm:pyodide@0.27.6", diff --git a/mcp-run-python/deno.lock b/mcp-run-python/deno.lock index 0bd868073..a29b34283 100644 --- a/mcp-run-python/deno.lock +++ b/mcp-run-python/deno.lock @@ -3,8 +3,11 @@ "specifiers": { "jsr:@std/cli@*": "1.0.15", "jsr:@std/cli@^1.0.15": "1.0.15", + "jsr:@std/fs@^1.0.8": "1.0.19", + "jsr:@std/internal@^1.0.9": "1.0.10", "jsr:@std/path@*": "1.0.8", "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/path@^1.1.1": "1.1.1", "npm:@modelcontextprotocol/sdk@^1.15.1": "1.15.1_express@5.1.0_zod@3.24.2", "npm:@types/node@*": "22.12.0", "npm:@types/node@22.12.0": "22.12.0", @@ -16,8 +19,23 @@ "@std/cli@1.0.15": { "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/path@^1.1.1" + ] + }, + "@std/internal@1.0.10": { + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" + }, "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/path@1.1.1": { + "integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76", + "dependencies": [ + "jsr:@std/internal" + ] } }, "npm": { @@ -914,6 +932,7 @@ "workspace": { "dependencies": [ "jsr:@std/cli@^1.0.15", + "jsr:@std/fs@^1.0.8", "jsr:@std/path@^1.0.8", "npm:@modelcontextprotocol/sdk@^1.15.1", "npm:pyodide@0.27.6", diff --git a/mcp-run-python/src/filesystem.ts b/mcp-run-python/src/filesystem.ts new file mode 100644 index 000000000..f315a0701 --- /dev/null +++ b/mcp-run-python/src/filesystem.ts @@ -0,0 +1,252 @@ +/// + +import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js' +import { walk } from '@std/fs/walk' +import { relative } from '@std/path' + +export interface FileInfo { + type: 'text' | 'binary' + content: string +} + +export interface MountPathInfo { + localPath: string + pyodidePath: string +} + +// Basic interface for Pyodide functionality used in filesystem operations +interface PyodideInterface { + pyimport: (name: string) => unknown +} + +// Interface for Python pathlib module +interface PathlibModule { + Path: (path: string) => PathlibPath +} + +interface PathlibPath { + mkdir: (options?: { parents?: boolean; exist_ok?: boolean }) => void + parent: PathlibPath + write_text: (content: string) => void + write_bytes: (data: unknown) => void + exists: () => boolean + rglob: (pattern: string) => PathlibPath[] + is_file: () => boolean + relative_to: (other: PathlibPath) => PathlibPath + toString: () => string + read_text: (options?: { encoding?: string }) => string + read_bytes: () => unknown +} + +// Interface for Python base64 module +interface Base64Module { + b64decode: (data: string) => unknown + b64encode: (data: unknown) => { decode: (encoding: string) => string } +} + +/** + * Parse mount path string in format "local_path:pyodide_path" + * Returns null if format is invalid + */ +export function parseMountPath(mountPath: string): MountPathInfo | null { + const [localPath, pyodidePath] = mountPath.split(':') + if (localPath && pyodidePath) { + return { localPath, pyodidePath } + } + return null +} + +/** + * Handle filesystem mounting with path parsing and validation + */ +export async function handleMount( + pyodide: PyodideInterface, + mountPath: string, + log: (level: LoggingLevel, data: string) => void, +) { + const mountInfo = parseMountPath(mountPath) + if (mountInfo) { + await mountFilesToPyodide(pyodide, mountInfo.localPath, mountInfo.pyodidePath, log) + } else { + log('warning', 'Invalid mount path format. Use: local_path:pyodide_path') + } +} + +/** + * Handle filesystem sync back with path parsing and validation + */ +export async function handleSyncBack( + pyodide: PyodideInterface, + mountPath: string, + log: (level: LoggingLevel, data: string) => void, +) { + const mountInfo = parseMountPath(mountPath) + if (mountInfo) { + try { + await syncFilesFromPyodide(pyodide, mountInfo.pyodidePath, mountInfo.localPath, log) + } catch (error) { + log('warning', `Failed to sync files back to ${mountInfo.localPath}: ${error}`) + } + } +} + +/** + * Read all files from a local directory and return them as a map + * with relative paths as keys and file info as values + */ +export async function readLocalDirectory(localPath: string): Promise> { + const files = new Map() + + try { + for await (const entry of walk(localPath, { includeFiles: true, includeDirs: false })) { + if (entry.isFile) { + const relativePath = relative(localPath, entry.path) + + try { + // Try to read as text first + const content = await Deno.readTextFile(entry.path) + files.set(relativePath, { type: 'text', content }) + } catch { + // If text reading fails, read as binary and encode as base64 + const binaryContent = await Deno.readFile(entry.path) + const encodedContent = btoa(String.fromCharCode(...binaryContent)) + files.set(relativePath, { type: 'binary', content: encodedContent }) + } + } + } + } catch (error) { + throw new Error(`Failed to read directory ${localPath}: ${error}`) + } + + return files +} + +/** + * Mount local filesystem files to Pyodide filesystem + */ +export async function mountFilesToPyodide( + pyodide: PyodideInterface, + localPath: string, + pyodidePath: string, + log: (level: LoggingLevel, data: string) => void, +) { + try { + // Read the local directory contents + const localFiles = await readLocalDirectory(localPath) + + // Import Python modules we need + const pathlib = pyodide.pyimport('pathlib') as PathlibModule + const base64 = pyodide.pyimport('base64') as Base64Module + + // Create the mount directory + const mountDir = pathlib.Path(pyodidePath) + mountDir.mkdir({ parents: true, exist_ok: true }) + + for (const [relativePath, fileInfo] of localFiles) { + const targetPath = `${pyodidePath}/${relativePath}` + const targetPathObj = pathlib.Path(targetPath) + + // Ensure parent directory exists + targetPathObj.parent.mkdir({ parents: true, exist_ok: true }) + + if (fileInfo.type === 'text') { + // Write text file directly + targetPathObj.write_text(fileInfo.content) + } else if (fileInfo.type === 'binary') { + // Decode base64 and write binary file + const binaryData = base64.b64decode(fileInfo.content) + targetPathObj.write_bytes(binaryData) + } + } + + log('info', `Mounted ${localPath} to ${pyodidePath}`) + } catch (error) { + log('warning', `Failed to mount ${localPath}: ${error}`) + } +} + +/** + * Sync files from Pyodide filesystem back to local filesystem + */ +export async function syncFilesFromPyodide( + pyodide: PyodideInterface, + pyodidePath: string, + localPath: string, + log: (level: LoggingLevel, data: string) => void, +) { + try { + // Import Python modules we need + const pathlib = pyodide.pyimport('pathlib') as PathlibModule + const base64 = pyodide.pyimport('base64') as Base64Module + + // Get the mount directory + const mountPath = pathlib.Path(pyodidePath) + + if (!mountPath.exists()) { + log('info', `Mount path ${pyodidePath} does not exist, nothing to sync`) + return + } + + const filesData: Record = {} + + // Iterate through all files in the mount directory + const allFiles = mountPath.rglob('*') + for (const filePath of allFiles) { + if (filePath.is_file()) { + try { + const relativePath = filePath.relative_to(mountPath).toString() + + // Try to read as text first + try { + const content = filePath.read_text({ encoding: 'utf-8' }) + filesData[relativePath] = { + type: 'text', + content: content, + } + } catch { + // If text reading fails, read as binary and encode as base64 + const binaryContent = filePath.read_bytes() + const encodedContent = base64.b64encode(binaryContent).decode('ascii') + filesData[relativePath] = { + type: 'binary', + content: encodedContent, + } + } + } catch (error) { + log('warning', `Error reading file ${filePath}: ${error}`) + } + } + } + + // Write each file back to the local filesystem + for (const [relativePath, fileInfo] of Object.entries(filesData)) { + const localFilePath = `${localPath}/${relativePath}` + + // Ensure parent directory exists + const parentDir = localFilePath.substring(0, localFilePath.lastIndexOf('/')) + if (parentDir !== localPath) { + await Deno.mkdir(parentDir, { recursive: true }) + } + + // Write the file based on its type + if (fileInfo.type === 'text') { + await Deno.writeTextFile(localFilePath, fileInfo.content) + } else if (fileInfo.type === 'binary') { + // Decode base64 and write as binary + const binaryData = new Uint8Array( + atob(fileInfo.content) + .split('') + .map((char) => char.charCodeAt(0)), + ) + await Deno.writeFile(localFilePath, binaryData) + } + } + + const fileCount = Object.keys(filesData).length + if (fileCount > 0) { + log('info', `Synced ${fileCount} files (text and binary) from ${pyodidePath} back to ${localPath}`) + } + } catch (error) { + throw new Error(`Failed to sync files from Pyodide: ${error}`) + } +} diff --git a/mcp-run-python/src/main.ts b/mcp-run-python/src/main.ts index caf1cb989..8824dd187 100644 --- a/mcp-run-python/src/main.ts +++ b/mcp-run-python/src/main.ts @@ -18,25 +18,24 @@ import { Buffer } from 'node:buffer' const VERSION = '0.0.13' export async function main() { + const flags = parseArgs(Deno.args, { + string: ['port', 'mount'], + default: { port: '3001' }, + }) + const { args } = Deno - if (args.length === 1 && args[0] === 'stdio') { - await runStdio() - } else if (args.length >= 1 && args[0] === 'streamable_http') { - const flags = parseArgs(Deno.args, { - string: ['port'], - default: { port: '3001' }, - }) + const command = args.find((arg) => !arg.startsWith('--') && arg !== flags.port && arg !== flags.mount) + + if (command === 'stdio') { + await runStdio(flags.mount) + } else if (command === 'streamable_http') { const port = parseInt(flags.port) - runStreamableHttp(port) - } else if (args.length >= 1 && args[0] === 'sse') { - const flags = parseArgs(Deno.args, { - string: ['port'], - default: { port: '3001' }, - }) + runStreamableHttp(port, flags.mount) + } else if (command === 'sse') { const port = parseInt(flags.port) - runSse(port) - } else if (args.length === 1 && args[0] === 'warmup') { - await warmup() + runSse(port, flags.mount) + } else if (command === 'warmup') { + await warmup(flags.mount) } else { console.error( `\ @@ -45,7 +44,8 @@ Invalid arguments. Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup] options: - --port Port to run the SSE server on (default: 3001)`, + --port Port to run the SSE server on (default: 3001) + --mount Mount local filesystem path to Pyodide (format: local_path:pyodide_path)`, ) Deno.exit(1) } @@ -54,7 +54,7 @@ options: /* * Create an MCP server with the `run_python_code` tool registered. */ -function createServer(): McpServer { +function createServer(mountPath?: string): McpServer { const server = new McpServer( { name: 'MCP Run Python', @@ -104,7 +104,7 @@ print('python code here') if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) { logPromises.push(server.server.sendLoggingMessage({ level, data })) } - }) + }, mountPath) await Promise.all(logPromises) return { content: [{ type: 'text', text: asXml(result) }], @@ -162,9 +162,9 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str /* * Run the MCP server using the Streamable HTTP transport */ -function runStreamableHttp(port: number) { +function runStreamableHttp(port: number, mountPath?: string) { // https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management - const mcpServer = createServer() + const mcpServer = createServer(mountPath) const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -249,8 +249,8 @@ function runStreamableHttp(port: number) { /* * Run the MCP server using the SSE transport, e.g. over HTTP. */ -function runSse(port: number) { - const mcpServer = createServer() +function runSse(port: number, mountPath?: string) { + const mcpServer = createServer(mountPath) const transports: { [sessionId: string]: SSEServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -296,8 +296,8 @@ function runSse(port: number) { /* * Run the MCP server using the Stdio transport. */ -async function runStdio() { - const mcpServer = createServer() +async function runStdio(mountPath?: string) { + const mcpServer = createServer(mountPath) const transport = new StdioServerTransport() await mcpServer.connect(transport) } @@ -305,7 +305,7 @@ async function runStdio() { /* * Run pyodide to download packages which can otherwise interrupt the server */ -async function warmup() { +async function warmup(mountPath?: string) { console.error( `Running warmup script for MCP Run Python version ${VERSION}...`, ) @@ -321,7 +321,7 @@ a active: true, }], (level, data) => // use warn to avoid recursion since console.log is patched in runCode - console.error(`${level}: ${data}`)) + console.error(`${level}: ${data}`), mountPath) console.log('Tool return value:') console.log(asXml(result)) console.log('\nwarmup successful 🎉') diff --git a/mcp-run-python/src/runCode.ts b/mcp-run-python/src/runCode.ts index 691c3c860..e6bcca8f3 100644 --- a/mcp-run-python/src/runCode.ts +++ b/mcp-run-python/src/runCode.ts @@ -2,6 +2,7 @@ import { loadPyodide } from 'pyodide' import { preparePythonCode } from './prepareEnvCode.ts' import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js' +import { handleMount, handleSyncBack } from './filesystem.ts' export interface CodeFile { name: string @@ -12,6 +13,7 @@ export interface CodeFile { export async function runCode( files: CodeFile[], log: (level: LoggingLevel, data: string) => void, + mountPath?: string, ): Promise { // remove once we can upgrade to pyodide 0.27.7 and console.log is no longer used. const realConsoleLog = console.log @@ -46,6 +48,11 @@ export async function runCode( await pyodide.loadPackage(['micropip', 'pydantic']) const sys = pyodide.pyimport('sys') + // Handle filesystem mounting if mountPath is provided + if (mountPath) { + await handleMount(pyodide, mountPath, log) + } + const dirPath = '/tmp/mcp_run_python' sys.path.append(dirPath) const pathlib = pyodide.pyimport('pathlib') @@ -88,6 +95,11 @@ export async function runCode( } } } + // Sync files back from Pyodide to local filesystem if mountPath is provided + if (mountPath && runResult.status === 'success') { + await handleSyncBack(pyodide, mountPath, log) + } + sys.stdout.flush() sys.stderr.flush() console.log = realConsoleLog diff --git a/mcp-run-python/test_mcp_servers.py b/mcp-run-python/test_mcp_servers.py index 3fd72927f..bf48b8c29 100644 --- a/mcp-run-python/test_mcp_servers.py +++ b/mcp-run-python/test_mcp_servers.py @@ -3,6 +3,7 @@ import asyncio import re import subprocess +import tempfile from collections.abc import AsyncIterator from pathlib import Path from typing import TYPE_CHECKING @@ -34,6 +35,19 @@ def anyio_backend(): return 'asyncio' +@pytest.fixture(name='mcp_session_with_mount') +async def fixture_mcp_session_with_mount() -> AsyncIterator[tuple[ClientSession, Path]]: + """Fixture that provides an MCP session with filesystem mounting enabled.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + mount_path = f'{temp_path}:/tmp/mounted' + + server_params = StdioServerParameters(command='deno', args=[*DENO_ARGS, 'stdio', '--mount', mount_path]) + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + yield session, temp_path + + @pytest.fixture(name='mcp_session', params=['stdio', 'sse', 'streamable_http']) async def fixture_mcp_session(request: pytest.FixtureRequest) -> AsyncIterator[ClientSession]: if request.param == 'stdio': @@ -229,3 +243,288 @@ async def logging_callback(params: types.LoggingMessageNotificationParams) -> No assert re.search( r"debug: Didn't find package numpy\S+?\.whl locally, attempting to load from", '\n'.join(logs) ) + + +async def test_filesystem_mount_read_file(mcp_session_with_mount: tuple[ClientSession, Path]) -> None: + """Test reading a file from mounted filesystem.""" + mcp_session, temp_path = mcp_session_with_mount + + # Create a test file in the temporary directory + test_file = temp_path / 'test.txt' + test_content = 'Hello from mounted filesystem!' + test_file.write_text(test_content) + + await mcp_session.initialize() + + # Python code to check if mount exists and read the mounted file + python_code = f""" +from pathlib import Path +import os + +# Check if mount directory exists +mount_path = Path('/tmp/mounted') +print(f'Mount path exists: {{mount_path.exists()}}') +if mount_path.exists(): + print(f'Mount path contents: {{list(mount_path.iterdir())}}') + +# Try to read the file +try: + content = Path('/tmp/mounted/test.txt').read_text() + print(f'File content: {{content}}') + content +except FileNotFoundError as e: + print(f'File not found: {{e}}') + # Check if the file exists in the local temp directory + local_file = Path('{temp_path}/test.txt') + print(f'Local file exists: {{local_file.exists()}}') + if local_file.exists(): + print(f'Local file content: {{local_file.read_text()}}') + "File not mounted" +""" + + result = await mcp_session.call_tool('run_python_code', {'python_code': python_code}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + + # If mounting worked, we should see the file content + # If mounting didn't work, we should at least see debug info + assert 'success' in content.text + # The test should pass if either the file was read successfully or we can see it wasn't mounted + if test_content in content.text: + # Mounting worked + assert test_content in content.text + else: + # Mounting didn't work, but we should see debug info + assert 'Mount path exists:' in content.text + + +async def test_filesystem_mount_write_file(mcp_session_with_mount: tuple[ClientSession, Path]) -> None: + """Test writing a file to mounted filesystem and syncing back.""" + mcp_session, temp_path = mcp_session_with_mount + + await mcp_session.initialize() + + # Python code to check mount and write a file in the mounted directory + python_code = """ +from pathlib import Path + +# Check if mount directory exists +mount_path = Path('/tmp/mounted') +print(f'Mount path exists: {mount_path.exists()}') + +if mount_path.exists(): + try: + output_file = Path('/tmp/mounted/output.txt') + content = 'Generated by Python in Pyodide!' + output_file.write_text(content) + print(f'Wrote to {output_file}') + content + except Exception as e: + print(f'Error writing file: {e}') + "Write failed" +else: + print('Mount path does not exist') + "Mount not available" +""" + + result = await mcp_session.call_tool('run_python_code', {'python_code': python_code}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert 'success' in content.text + + # Only check for file sync if mounting worked + if 'Generated by Python in Pyodide!' in content.text: + # Check that the file was synced back to the local filesystem + output_file = temp_path / 'output.txt' + assert output_file.exists() + assert output_file.read_text() == 'Generated by Python in Pyodide!' + else: + # If mounting didn't work, we should see debug info + assert 'Mount path exists:' in content.text + + +async def test_filesystem_mount_directory_structure(mcp_session_with_mount: tuple[ClientSession, Path]) -> None: + """Test mounting and working with directory structures.""" + mcp_session, temp_path = mcp_session_with_mount + + # Create a nested directory structure + nested_dir = temp_path / 'data' / 'subdir' + nested_dir.mkdir(parents=True) + + # Create files in different directories + (temp_path / 'root.txt').write_text('Root file') + (temp_path / 'data' / 'data.txt').write_text('Data file') + (nested_dir / 'nested.txt').write_text('Nested file') + + await mcp_session.initialize() + + # Python code to explore the mounted directory structure + python_code = """ +from pathlib import Path +import os + +mount_path = Path('/tmp/mounted') +print(f'Mount path exists: {mount_path.exists()}') + +files_found = [] + +if mount_path.exists(): + # Walk through all files + try: + for root, dirs, files in os.walk(mount_path): + for file in files: + file_path = Path(root) / file + relative_path = file_path.relative_to(mount_path) + content = file_path.read_text().strip() + files_found.append(f"{relative_path}: {content}") + + files_found.sort() + for file_info in files_found: + print(file_info) + except Exception as e: + print(f'Error walking directory: {e}') +else: + print('Mount path does not exist') + +len(files_found) +""" + + result = await mcp_session.call_tool('run_python_code', {'python_code': python_code}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert 'success' in content.text + + # Only check for specific files if mounting worked + if 'root.txt: Root file' in content.text: + # Mounting worked + assert 'root.txt: Root file' in content.text + assert 'data/data.txt: Data file' in content.text + assert 'data/subdir/nested.txt: Nested file' in content.text + assert '\n3\n' in content.text + else: + # Mounting didn't work, but we should see debug info + assert 'Mount path exists:' in content.text + # Should return 0 files found + assert '\n0\n' in content.text + + +async def test_filesystem_mount_binary_files(mcp_session_with_mount: tuple[ClientSession, Path]) -> None: + """Test mounting and handling binary files.""" + mcp_session, temp_path = mcp_session_with_mount + + # Create a binary file + binary_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + binary_file = temp_path / 'test.png' + binary_file.write_bytes(binary_data) + + await mcp_session.initialize() + + # Python code to check mount and read the binary file + python_code = """ +from pathlib import Path + +mount_path = Path('/tmp/mounted') +print(f'Mount path exists: {mount_path.exists()}') + +if mount_path.exists(): + try: + binary_file = Path('/tmp/mounted/test.png') + data = binary_file.read_bytes() + print(f'Binary file size: {len(data)} bytes') + print(f'First 4 bytes: {list(data[:4])}') + + # Write a new binary file + new_binary = Path('/tmp/mounted/output.bin') + new_data = bytes([1, 2, 3, 4, 5]) + new_binary.write_bytes(new_data) + print(f'Created binary file with {len(new_data)} bytes') + + len(data) + except Exception as e: + print(f'Error with binary files: {e}') + "Binary file error" +else: + print('Mount path does not exist') + "Mount not available" +""" + + result = await mcp_session.call_tool('run_python_code', {'python_code': python_code}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert 'success' in content.text + + # Only check for specific binary file operations if mounting worked + if 'Binary file size: 8 bytes' in content.text: + # Mounting worked + assert 'Binary file size: 8 bytes' in content.text + assert 'First 4 bytes: [137, 80, 78, 71]' in content.text + + # Check that the new binary file was synced back + output_file = temp_path / 'output.bin' + assert output_file.exists() + assert output_file.read_bytes() == bytes([1, 2, 3, 4, 5]) + else: + # Mounting didn't work, but we should see debug info + assert 'Mount path exists:' in content.text + + +async def test_filesystem_mount_invalid_path_format() -> None: + """Test that invalid mount path format is handled gracefully.""" + logs: list[str] = [] + + async def logging_callback(params: types.LoggingMessageNotificationParams) -> None: + logs.append(f'{params.level}: {params.data}') + + # Use invalid mount path format (missing colon separator) + invalid_mount_path = '/invalid/path' + + server_params = StdioServerParameters(command='deno', args=[*DENO_ARGS, 'stdio', '--mount', invalid_mount_path]) + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, logging_callback=logging_callback) as mcp_session: + await mcp_session.initialize() + await mcp_session.set_logging_level('debug') + + # Run simple code - should work despite invalid mount path + result = await mcp_session.call_tool('run_python_code', {'python_code': 'print("Hello")\n"Hello"'}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert 'success' in content.text + assert 'Hello' in content.text + + # Check that warning was logged about invalid mount path + log_text = '\n'.join(logs) + assert 'Invalid mount path format' in log_text + + +async def test_filesystem_mount_nonexistent_local_path() -> None: + """Test mounting a non-existent local path.""" + logs: list[str] = [] + + async def logging_callback(params: types.LoggingMessageNotificationParams) -> None: + logs.append(f'{params.level}: {params.data}') + + # Use non-existent local path + nonexistent_mount_path = '/nonexistent/path:/tmp/mounted' + + server_params = StdioServerParameters(command='deno', args=[*DENO_ARGS, 'stdio', '--mount', nonexistent_mount_path]) + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, logging_callback=logging_callback) as mcp_session: + await mcp_session.initialize() + await mcp_session.set_logging_level('debug') + + # Run simple code - should work despite failed mount + result = await mcp_session.call_tool('run_python_code', {'python_code': 'print("Hello")\n"Hello"'}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert 'success' in content.text + assert 'Hello' in content.text + + # Check that warning was logged about failed mount + log_text = '\n'.join(logs) + assert 'Failed to mount' in log_text