diff --git a/mcp-run-python/README.md b/mcp-run-python/README.md index db70ab514e..2f08823bb6 100644 --- a/mcp-run-python/README.md +++ b/mcp-run-python/README.md @@ -11,16 +11,19 @@ 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|streamable_http|sse|warmup] + -N -R=node_modules,/tmp -W=node_modules,/tmp --node-modules-dir=auto \ + jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup] [--mount ./storage/] ``` where: -- `-N -R=node_modules -W=node_modules` (alias of `--allow-net --allow-read=node_modules --allow-write=node_modules`) - allows network access and read+write access to `./node_modules`. These are required so pyodide can download and cache - the Python standard library and packages +- `-N -R=node_modules,/tmp -W=node_modules,/tmp` (alias of + `--allow-net --allow-read=node_modules,/tmp --allow-write=node_modules,/tmp`) allows network access and read+write + access to `./node_modules` and `/tmp`. These are required so pyodide can download and cache the Python standard + library and packages - `--node-modules-dir=auto` tells deno to use a local `node_modules` directory +- `--mount ./storage`: Optionally, mount a directory to `/home/pyodide/storage` for persist file between pyodide runs. + File can be uploaded and retrieve using the `upload_file_from_uri`and `retrieve_file` tools respectively. - `stdio` runs the server with the [Stdio MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) — suitable for running the process as a subprocess locally @@ -52,8 +55,8 @@ server = MCPServerStdio('deno', args=[ 'run', '-N', - '-R=node_modules', - '-W=node_modules', + '-R=node_modules,/tmp', + '-W=node_modules,/tmp', '--node-modules-dir=auto', 'jsr:@pydantic/mcp-run-python', 'stdio', diff --git a/mcp-run-python/deno.json b/mcp-run-python/deno.json index 32892264a5..dad88fad49 100644 --- a/mcp-run-python/deno.json +++ b/mcp-run-python/deno.json @@ -15,9 +15,11 @@ "imports": { "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.17.4", "@std/cli": "jsr:@std/cli@^1.0.15", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@std/media-types": "jsr:@std/media-types@^1.1.0", "@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", + "pyodide": "npm:pyodide@0.28.2", "zod": "npm:zod@^3.24.2" }, "fmt": { diff --git a/mcp-run-python/deno.lock b/mcp-run-python/deno.lock index 1aa7ea5fea..dc8b216725 100644 --- a/mcp-run-python/deno.lock +++ b/mcp-run-python/deno.lock @@ -1,22 +1,45 @@ { "version": "5", "specifiers": { - "jsr:@std/cli@*": "1.0.15", - "jsr:@std/cli@^1.0.15": "1.0.15", - "jsr:@std/path@*": "1.0.8", - "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/cli@^1.0.15": "1.0.21", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fs@^1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.9": "1.0.10", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/path@^1.0.8": "1.1.2", + "jsr:@std/path@^1.1.1": "1.1.2", "npm:@modelcontextprotocol/sdk@^1.17.4": "1.17.4_express@5.1.0_zod@3.25.76", - "npm:@types/node@*": "22.12.0", + "npm:@types/node@*": "24.2.0", "npm:@types/node@22.12.0": "22.12.0", - "npm:pyodide@0.27.6": "0.27.6", + "npm:pyodide@0.28.2": "0.28.2", "npm:zod@^3.24.2": "3.25.76" }, "jsr": { - "@std/cli@1.0.15": { - "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" + "@std/cli@1.0.21": { + "integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a" }, - "@std/path@1.0.8": { - "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/internal@^1.0.9", + "jsr:@std/path@^1.1.1" + ] + }, + "@std/internal@1.0.10": { + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.1.2": { + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] } }, "npm": { @@ -40,7 +63,13 @@ "@types/node@22.12.0": { "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "dependencies": [ - "undici-types" + "undici-types@6.20.0" + ] + }, + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types@7.10.0" ] }, "accepts@2.0.0": { @@ -363,8 +392,8 @@ "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, - "pyodide@0.27.6": { - "integrity": "sha512-ahiSHHs6iFKl2f8aO1wALINAlMNDLAtb44xCI87GQyH2tLDk8F8VWip3u1ZNIyglGSCYAOSFzWKwS1f9gBFVdg==", + "pyodide@0.28.2": { + "integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==", "dependencies": [ "ws" ] @@ -496,6 +525,9 @@ "undici-types@6.20.0": { "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + }, "unpipe@1.0.0": { "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, @@ -518,8 +550,8 @@ "wrappy@1.0.2": { "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "ws@8.18.2": { - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==" + "ws@8.18.3": { + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" }, "zod-to-json-schema@3.24.6_zod@3.25.76": { "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", @@ -534,9 +566,12 @@ "workspace": { "dependencies": [ "jsr:@std/cli@^1.0.15", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fs@^1.0.19", + "jsr:@std/media-types@^1.1.0", "jsr:@std/path@^1.0.8", "npm:@modelcontextprotocol/sdk@^1.17.4", - "npm:pyodide@0.27.6", + "npm:pyodide@0.28.2", "npm:zod@^3.24.2" ] } diff --git a/mcp-run-python/src/files.ts b/mcp-run-python/src/files.ts new file mode 100644 index 0000000000..e5d714802e --- /dev/null +++ b/mcp-run-python/src/files.ts @@ -0,0 +1,167 @@ +import * as path from '@std/path' +import { exists } from '@std/fs/exists' +import { contentType } from '@std/media-types' +import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' +import z from 'zod' +import { decodeBase64, encodeBase64 } from '@std/encoding/base64' + +/** + * Returns the temporary directory in the local filesystem for file persistence. + */ +export function createRootDir(): string { + return Deno.makeTempDirSync({ prefix: 'mcp_run_python' }) +} + +/** + * Register file related functions to the MCP server. + * @param server The MCP Server + * @param rootDir Directory in the local file system to read/write to. + */ +export function registerFileFunctions(server: McpServer, rootDir: string) { + server.registerTool('upload_file', { + title: 'Upload file.', + description: 'Ingest a file from the given object. Returns a link to the resource that was created.', + inputSchema: { + type: z.union([z.literal('text'), z.literal('bytes')]), + filename: z.string().describe('Name of the file to write.'), + text: z.nullable(z.string().describe('Text content of the file, if the type is "text".')), + blob: z.nullable(z.string().describe('Base 64 encoded content of the file, if the type is "bytes".')), + }, + }, async ({ type, filename, text, blob }) => { + const absPath = path.join(rootDir, filename) + if (type === 'text') { + if (text == null) { + return { content: [{ type: 'text', text: "Type is 'text', but no text provided." }], isError: true } + } + await Deno.writeTextFile(absPath, text) + } else { + if (blob == null) { + return { content: [{ type: 'text', text: "Type is 'bytes', but no blob provided." }], isError: true } + } + await Deno.writeFile(absPath, decodeBase64(blob)) + } + return { + content: [{ + type: 'resource_link', + uri: `file:///${filename}`, + name: filename, + mimeType: contentType(path.extname(absPath)), + }], + } + }) + // File Upload + server.registerTool( + 'upload_file_from_uri', + { + title: 'Upload file from URI', + description: 'Ingest a file by URI and store it. Returns a canonical URL.', + inputSchema: { + uri: z.string().url().describe('file:// or https:// style URL'), + filename: z + .string() + .describe('The name of the file to write.'), + }, + }, + async ({ uri, filename }: { uri: string; filename: string }) => { + const absPath = path.join(rootDir, filename) + const fileResponse = await fetch(uri) + if (fileResponse.body) { + const file = await Deno.open(absPath, { write: true, create: true }) + await fileResponse.body.pipeTo(file.writable) + } + return { + content: [{ + type: 'resource_link', + uri: `file:///${filename}`, + name: filename, + mimeType: contentType(path.extname(absPath)), + }], + } + }, + ) + + // Register all the files in the local directory as resources + server.registerResource( + 'read-file', + new ResourceTemplate('file:///{filename}', { + list: async (_extra) => { + const resources = [] + for await (const dirEntry of Deno.readDir(rootDir)) { + if (!dirEntry.isFile) continue + resources.push({ + uri: `file:///${dirEntry.name}`, + name: dirEntry.name, + mimeType: contentType(path.extname(dirEntry.name)), + }) + } + return { resources: resources } + }, + }), + { + title: 'Read file.', + description: 'Read file from persistent storage', + }, + async (uri, { filename }) => { + const absPath = path.join(rootDir, ...(Array.isArray(filename) ? filename : [filename])) + const mime = contentType(path.extname(absPath)) + const fileBytes = await Deno.readFile(absPath) + + // Check if it's text-based + if (mime && /^(text\/|.*\/json$|.*\/csv$|.*\/javascript$|.*\/xml$)/.test(mime.split(';')[0])) { + const text = new TextDecoder().decode(fileBytes) + return { contents: [{ uri: uri.href, mimeType: mime, text: text }] } + } else { + const base64 = encodeBase64(fileBytes) + return { contents: [{ uri: uri.href, mimeType: mime, blob: base64 }] } + } + }, + ) + + // This functions only checks if the file exits + // Download happens through the registered resource + server.registerTool('retrieve_file', { + title: 'Retrieve a file', + description: 'Retrieve a file from the persistent file store.', + inputSchema: { filename: z.string().describe('The name of the file to read.') }, + }, async ({ filename }) => { + const absPath = path.join(rootDir, filename) + if (await exists(absPath, { isFile: true })) { + return { + content: [{ + type: 'resource_link', + uri: `file:///${filename}`, + name: filename, + mimeType: contentType(path.extname(absPath)), + }], + } + } else { + return { + content: [{ 'type': 'text', 'text': `Failed to retrieve file ${filename}. File not found.` }], + isError: true, + } + } + }) + + // File deletion + server.registerTool('delete_file', { + title: 'Delete a file', + description: 'Delete a file from the persistent file store.', + inputSchema: { filename: z.string().describe('The name of the file to delete.') }, + }, async ({ filename }) => { + const absPath = path.join(rootDir, filename) + if (await exists(absPath, { isFile: true })) { + await Deno.remove(absPath) + return { + content: [{ + type: 'text', + text: `${filename} deleted successfully`, + }], + } + } else { + return { + content: [{ 'type': 'text', 'text': `Failed to delete file ${filename}. File not found.` }], + isError: true, + } + } + }) +} diff --git a/mcp-run-python/src/main.ts b/mcp-run-python/src/main.ts index 826be363fe..2a2b6fed49 100644 --- a/mcp-run-python/src/main.ts +++ b/mcp-run-python/src/main.ts @@ -8,44 +8,42 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' -import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { z } from 'zod' - -import { asXml, runCode } from './runCode.ts' +import { asXml, registerCodeFunctions, runCode } from './runCode.ts' import { Buffer } from 'node:buffer' +import { createRootDir, registerFileFunctions } from './files.ts' const VERSION = '0.0.13' export async function main() { - 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 port = parseInt(flags.port) - runStreamableHttp(port) - } else if (args.length >= 1 && args[0] === 'sse') { - const flags = parseArgs(Deno.args, { - string: ['port'], - default: { port: '3001' }, - }) - const port = parseInt(flags.port) - runSse(port) - } else if (args.length === 1 && args[0] === 'warmup') { + // Parse global flags once, then branch on subcommand + const flags = parseArgs(Deno.args, { + string: ['port', 'mount'], + default: { port: '3001' }, + }) + const mode = (flags._[0] as string | undefined) ?? '' + const port = parseInt(flags.port as string) + const rawMount = flags.mount as string | undefined + const mount: string | boolean = rawMount === undefined ? false : rawMount === '' ? true : rawMount + + if (mode === 'stdio') { + await runStdio(mount) + } else if (mode === 'streamable_http') { + runStreamableHttp(port, mount) + } else if (mode === 'sse') { + runSse(port, mount) + } else if (mode === 'warmup') { await warmup() } else { console.error( `\ 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] +Usage: deno run -N -R=node_modules -W=node_modules --node-modules-dir=auto jsr:@pydantic/mcp-run-python [stdio|streamable_http|sse|warmup] [--port ] [--mount [dir]] options: - --port Port to run the SSE server on (default: 3001)`, + --port Port to run the SSE/HTTP server on (default: 3001) + --mount [dir] Relative or absolute directory path or boolean. If omitted: false; if provided without value: true`, ) Deno.exit(1) } @@ -54,71 +52,28 @@ options: /* * Create an MCP server with the `run_python_code` tool registered. */ -function createServer(): McpServer { +function createServer(rootDir: string | null, mount: string | boolean): McpServer { const server = new McpServer( { name: 'MCP Run Python', version: VERSION, }, { - instructions: 'Call the "run_python_code" tool with the Python code to run.', + instructions: 'Call the "run_python_code" tool with the Python code to run.' + + (rootDir != null ? ` Persistent storage is mounted at: "${rootDir}".` : ''), capabilities: { + resources: {}, + tools: {}, logging: {}, }, }, ) - const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. - -The code may be async, and the value on the last line will be returned as the return value. - -The code will be executed with Python 3.12. - -Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start -with a comment of the form: - -# /// script -# dependencies = ['pydantic'] -# /// -print('python code here') -` - - let setLogLevel: LoggingLevel = 'emergency' - - server.server.setRequestHandler(SetLevelRequestSchema, (request) => { - setLogLevel = request.params.level - return {} - }) + registerCodeFunctions(server, rootDir, mount) + if (rootDir != null) { + registerFileFunctions(server, rootDir) + } - server.registerTool( - 'run_python_code', - { - title: 'Run Python Code', - description: toolDescription, - inputSchema: { python_code: z.string().describe('Python code to run') }, - }, - async ({ python_code }: { python_code: string }) => { - const logPromises: Promise[] = [] - const result = await runCode( - [ - { - name: 'main.py', - content: python_code, - active: true, - }, - ], - (level, data) => { - if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) { - logPromises.push(server.server.sendLoggingMessage({ level, data })) - } - }, - ) - await Promise.all(logPromises) - return { - content: [{ type: 'text', text: asXml(result) }], - } - }, - ) return server } @@ -177,12 +132,44 @@ function httpSetJsonResponse( res.end() } +function addDirCleanupCallback(server: http.Server | StdioServerTransport, dir: string) { + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + try { + Deno.removeSync(dir, { recursive: true }) + } catch { + // ignore + } + } + if (server instanceof http.Server) { + server.on('close', cleanup) + } else { + server.onclose = cleanup + } + const handleSig = () => { + try { + server.close(() => {}) + } catch { + // ignore + } + cleanup() + Deno.exit() + } + Deno.addSignalListener('SIGINT', handleSig) + Deno.addSignalListener('SIGTERM', handleSig) + addEventListener('unload', cleanup) +} + /* * Run the MCP server using the Streamable HTTP transport */ -function runStreamableHttp(port: number) { +function runStreamableHttp(port: number, mount: string | boolean) { + const rootDir = mount !== false ? createRootDir() : null + // https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management - const mcpServer = createServer() + const mcpServer = createServer(rootDir, mount) const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -262,9 +249,14 @@ function runStreamableHttp(port: number) { } }) + // Cleanup root dir on server close and on process signals + if (rootDir != null) { + addDirCleanupCallback(server, rootDir) + } + server.listen(port, () => { console.log( - `Running MCP Run Python version ${VERSION} with Streamable HTTP transport on port ${port}`, + `Running MCP Run Python version ${VERSION} with SSE transport on port ${port}.`, ) }) } @@ -272,8 +264,10 @@ 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, mount: string | boolean) { + const rootDir = mount !== false ? createRootDir() : null + + const mcpServer = createServer(rootDir, mount) const transports: { [sessionId: string]: SSEServerTransport } = {} const server = http.createServer(async (req, res) => { @@ -313,9 +307,14 @@ function runSse(port: number) { } }) + // Cleanup root dir on server close and on process signals + if (rootDir != null) { + addDirCleanupCallback(server, rootDir) + } + server.listen(port, () => { console.log( - `Running MCP Run Python version ${VERSION} with SSE transport on port ${port}`, + `Running MCP Run Python version ${VERSION} with SSE transport on port ${port}.`, ) }) } @@ -323,9 +322,16 @@ function runSse(port: number) { /* * Run the MCP server using the Stdio transport. */ -async function runStdio() { - const mcpServer = createServer() +async function runStdio(mount: string | boolean) { + const rootDir = mount !== false ? createRootDir() : null + const mcpServer = createServer(rootDir, mount) const transport = new StdioServerTransport() + + // Cleanup root dir on server close and on process signals + if (rootDir != null) { + addDirCleanupCallback(transport, rootDir) + } + await mcpServer.connect(transport) } @@ -353,22 +359,12 @@ a (level, data) => // use warn to avoid recursion since console.log is patched in runCode console.error(`${level}: ${data}`), + null, + null, ) console.log('Tool return value:') console.log(asXml(result)) console.log('\nwarmup successful 🎉') } -// list of log levels to use for level comparison -const LogLevels: LoggingLevel[] = [ - 'debug', - 'info', - 'notice', - 'warning', - 'error', - 'critical', - 'alert', - 'emergency', -] - await main() diff --git a/mcp-run-python/src/runCode.ts b/mcp-run-python/src/runCode.ts index 691c3c860e..2e23648d3c 100644 --- a/mcp-run-python/src/runCode.ts +++ b/mcp-run-python/src/runCode.ts @@ -1,7 +1,10 @@ /* eslint @typescript-eslint/no-explicit-any: off */ import { loadPyodide } from 'pyodide' import { preparePythonCode } from './prepareEnvCode.ts' -import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js' +import { type LoggingLevel, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import * as path from '@std/path' +import z from 'zod' export interface CodeFile { name: string @@ -12,12 +15,9 @@ export interface CodeFile { export async function runCode( files: CodeFile[], log: (level: LoggingLevel, data: string) => void, + rootDir: string | null, + mountDir: string | null, ): Promise { - // remove once we can upgrade to pyodide 0.27.7 and console.log is no longer used. - const realConsoleLog = console.log - // deno-lint-ignore no-explicit-any - console.log = (...args: any[]) => log('debug', args.join(' ')) - const output: string[] = [] const pyodide = await loadPyodide({ stdout: (msg) => { @@ -30,6 +30,18 @@ export async function runCode( }, }) + // Mount file system + if (mountDir != null && rootDir != null) { + // Ensure emscriptem directory is created + pyodide.FS.mkdirTree(mountDir) + // Mount local directory + pyodide.FS.mount( + pyodide.FS.filesystems.NODEFS, + { root: rootDir }, + mountDir, + ) + } + // see https://github.com/pyodide/pyodide/discussions/5512 const origLoadPackage = pyodide.loadPackage pyodide.loadPackage = (pkgs, options) => @@ -46,6 +58,7 @@ export async function runCode( await pyodide.loadPackage(['micropip', 'pydantic']) const sys = pyodide.pyimport('sys') + // This is in the virtual in-memory emscriptem file system const dirPath = '/tmp/mcp_run_python' sys.path.append(dirPath) const pathlib = pyodide.pyimport('pathlib') @@ -90,7 +103,6 @@ export async function runCode( } sys.stdout.flush() sys.stderr.flush() - console.log = realConsoleLog return runResult } @@ -169,3 +181,103 @@ interface PreparePyEnv { // deno-lint-ignore no-explicit-any dump_json: (value: any) => string | null } + +// list of log levels to use for level comparison +const LogLevels: LoggingLevel[] = [ + 'debug', + 'info', + 'notice', + 'warning', + 'error', + 'critical', + 'alert', + 'emergency', +] + +/* + * Resolve a mountDir cli option to a specific directory + */ +export function resolveMountDir(mountDir: string): string { + // Base dir created by emscriptem + // See https://emscripten.org/docs/api_reference/Filesystem-API.html#file-system-api + const baseDir = '/home/pyodide' + + if (mountDir.trim() === '') { + return path.join(baseDir, 'persistent') + } + + if (path.isAbsolute(mountDir)) { + return mountDir + } + + // relative path + return path.join(baseDir, mountDir) +} + +export function registerCodeFunctions(server: McpServer, rootDir: string | null, mount: string | boolean) { + // Resolve CLI mount option + let mountDirDescription: string + let mountDir: string | null + if (mount !== false) { + // Resolve mounted directory + mountDir = resolveMountDir(typeof mount === 'string' ? mount : '') + mountDirDescription = `To store files permanently use the directory at: ${mountDir}\n` + } else { + mountDir = null + mountDirDescription = '' + } + + const toolDescription = `Tool to execute Python code and return stdout, stderr, and return value. + +The code may be async, and the value on the last line will be returned as the return value. + +The code will be executed with Python 3.12. +${mountDirDescription} +Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start +with a comment of the form: + +# /// script +# dependencies = ['pydantic'] +# /// +print('python code here') +` + let setLogLevel: LoggingLevel = 'emergency' + + server.server.setRequestHandler(SetLevelRequestSchema, (request) => { + setLogLevel = request.params.level + return {} + }) + + // Main tool to run code + server.registerTool( + 'run_python_code', + { + title: 'Run Python Code', + description: toolDescription, + inputSchema: { python_code: z.string().describe('Python code to run') }, + }, + async ({ python_code }: { python_code: string }) => { + const logPromises: Promise[] = [] + const result = await runCode( + [ + { + name: 'main.py', + content: python_code, + active: true, + }, + ], + (level, data) => { + if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) { + logPromises.push(server.server.sendLoggingMessage({ level, data })) + } + }, + rootDir, + mountDir, + ) + await Promise.all(logPromises) + return { + content: [{ type: 'text', text: asXml(result) }], + } + }, + ) +} diff --git a/mcp-run-python/test_mcp_servers.py b/mcp-run-python/test_mcp_servers.py index 3fd72927f1..74df9fb8d4 100644 --- a/mcp-run-python/test_mcp_servers.py +++ b/mcp-run-python/test_mcp_servers.py @@ -1,11 +1,18 @@ from __future__ import annotations as _annotations import asyncio +import base64 import re +import secrets import subprocess +import tempfile +import threading from collections.abc import AsyncIterator +from contextlib import contextmanager +from enum import StrEnum +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal import pytest from httpx import AsyncClient, HTTPError @@ -14,6 +21,7 @@ from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client +from pydantic import FileUrl if TYPE_CHECKING: from mcp import ClientSession @@ -22,98 +30,178 @@ DENO_ARGS = [ 'run', '-N', - '-R=mcp-run-python/node_modules', - '-W=mcp-run-python/node_modules', + f'-R=mcp-run-python/node_modules,{tempfile.gettempdir()}', + f'-W=mcp-run-python/node_modules,{tempfile.gettempdir()}', '--node-modules-dir=auto', 'mcp-run-python/src/main.ts', ] +class McpTools(StrEnum): + RUN_PYTHON_CODE = 'run_python_code' + UPLOAD_FILE = 'upload_file' + UPLOAD_FILE_FROM_URI = 'upload_file_from_uri' + RETRIEVE_FILE = 'retrieve_file' + DELETE_FILE = 'delete_file' + + +CSV_DATA = """Name,Age,Department,Salary +Alice,25,Engineering,60000 +Bob,32,Marketing,52000 +Charlie,29,Engineering,70000 +Diana,45,HR,65000 +Ethan,35,Marketing,58000 +Fiona,28,Engineering,72000 +George,40,HR,64000 +Hannah,31,Engineering,68000 +Ian,38,Marketing,61000 +Julia,27,HR,59000 +""" + +BASE_64_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAEX0lEQVR4nOzdO8vX9R/HcS/56f8PWotGQkPBBUWESCQYNJR0GjIn6UBTgUMZTiGE4ZgRVKNkuDSEFtgBQqIiKunkEFdkWLmEBQUWiNUQYd2KNwTPx+MGvD7Tk/f2/S7O7tmyatKnJx8b3f/p6EOj+5euu2Z0/+Sxt0f3N++9fHR/+57/j+7vuPuT0f3Vo+vwHycA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQtDr561+gDpzf9PLp/4eNzo/uXzv41uv/BM0+O7h9/bsPo/vqPdo3u7965GN13AUgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSlh5ce+XoA9+eODK6v3r7naP7b31zaHT/4p+3jO4f2/Tb6P7K41tH9zff+8LovgtAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkLb09ZmLow8sb1ke3d92YXR+1dO7PhzdX7f2xtH9Q5fN/t/g2j9eHt3/cc350X0XgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBtcf3eW0cfePTE7Pf1D9yxMrq/4YrR+VWvnN84uv/lvs2j+2v3nx3dv3rT/0b3XQDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmAtKWrzq0ffeD312f339h5ZnT/npsPj+7//cPDo/un739idP/Xg5+P7j/y/G2j+y4AaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQNpi/5FfRh94753XRvcP7F0zuv/V7e+O7t906v3R/WdP/zO6f9/ixdH9G3Z/NrrvApAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkLb25vDL6wLoHjo7ur7z03ej++u+fGt0/vm/2+/dfHF4e3d9xauPo/taN20b3XQDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmAtH8DAAD//9drYGg9ROu9AAAAAElFTkSuQmCC' + + @pytest.fixture def anyio_backend(): return 'asyncio' -@pytest.fixture(name='mcp_session', params=['stdio', 'sse', 'streamable_http']) -async def fixture_mcp_session(request: pytest.FixtureRequest) -> AsyncIterator[ClientSession]: - if request.param == 'stdio': - server_params = StdioServerParameters(command='deno', args=[*DENO_ARGS, 'stdio']) - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - yield session - elif request.param == 'streamable_http': - port = 3101 - p = subprocess.Popen(['deno', *DENO_ARGS, 'streamable_http', f'--port={port}']) - try: - url = f'http://localhost:{port}/mcp' - - async with AsyncClient() as client: - for _ in range(10): - try: - await client.get(url, timeout=0.01) - except HTTPError: - await asyncio.sleep(0.1) - else: - break - - async with streamablehttp_client(url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - yield session +@pytest.fixture +def server_type(request: pytest.FixtureRequest) -> Literal['stdio', 'sse', 'streamable_http']: + """Indirect fixture to accept server type parametrization.""" + return request.param + + +@pytest.fixture +def mount(request: pytest.FixtureRequest) -> bool | str: + """Indirect fixture to accept mount parametrization.""" + return request.param - finally: - p.terminate() - exit_code = p.wait() - if exit_code > 0: - pytest.fail(f'Process exited with code {exit_code}') +@pytest.fixture(name='mcp_session', scope='function') # Function scope to ensure no files are stored +async def fixture_mcp_session( + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, +) -> AsyncIterator[ClientSession]: + # Build mount parameter + if isinstance(mount, bool): + mount_param = ['--mount'] if mount else [] else: - port = 3101 + mount_param = [f'--mount={mount}'] - p = subprocess.Popen(['deno', *DENO_ARGS, 'sse', f'--port={port}']) - try: - url = f'http://localhost:{port}' - async with AsyncClient() as client: - for _ in range(10): - try: - await client.get(url, timeout=0.01) - except HTTPError: - await asyncio.sleep(0.1) - else: - break - - async with sse_client(f'{url}/sse') as (read, write): + match server_type: + case 'stdio': + server_params = StdioServerParameters(command='deno', args=[*DENO_ARGS, 'stdio'] + mount_param) + async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: yield session - finally: - p.terminate() - exit_code = p.wait() - if exit_code > 0: - pytest.fail(f'Process exited with code {exit_code}') - - -async def test_list_tools(mcp_session: ClientSession) -> None: - await mcp_session.initialize() - tools = await mcp_session.list_tools() - assert len(tools.tools) == 1 - tool = tools.tools[0] - assert tool.name == 'run_python_code' - assert tool.description - assert tool.description.startswith('Tool to execute Python code and return stdout, stderr, and return value.') - assert tool.inputSchema['properties'] == snapshot( - {'python_code': {'type': 'string', 'description': 'Python code to run'}} - ) + + case 'streamable_http': + port = 3101 + cmd = ['deno', *DENO_ARGS, 'streamable_http', f'--port={port}'] + mount_param + print(f'Running command: {" ".join(cmd)}') + p = subprocess.Popen(cmd) + try: + url = f'http://localhost:{port}/mcp' + + async with AsyncClient() as client: + for _ in range(10): + try: + await client.get(url, timeout=0.01) + except HTTPError: + await asyncio.sleep(0.1) + else: + break + + async with streamablehttp_client(url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + yield session + + finally: + p.terminate() + exit_code = p.wait() + if exit_code > 0: + pytest.fail(f'Process exited with code {exit_code}') + + case 'sse': + port = 3101 + cmd = ['deno', *DENO_ARGS, 'sse', f'--port={port}'] + mount_param + print(f'Running command: {" ".join(cmd)}') + p = subprocess.Popen(cmd) + try: + url = f'http://localhost:{port}' + async with AsyncClient() as client: + for _ in range(10): + try: + await client.get(url, timeout=0.01) + except HTTPError: + await asyncio.sleep(0.1) + else: + break + + async with sse_client(f'{url}/sse') as (read, write): + async with ClientSession(read, write) as session: + yield session + finally: + p.terminate() + exit_code = p.wait() + if exit_code > 0: + pytest.fail(f'Process exited with code {exit_code}') -@pytest.mark.parametrize( - 'code,expected_output', - [ - pytest.param( - [ - 'x = 4', - "print(f'{x=}')", - 'x', - ], - snapshot("""\ +@pytest.mark.parametrize('server_type', ['stdio', 'sse', 'streamable_http'], indirect=True) +@pytest.mark.parametrize('mount', [False, './storage/'], indirect=True) +class TestMcp: + async def test_list_tools( + self, mcp_session: ClientSession, server_type: Literal['stdio', 'sse', 'streamable_http'], mount: bool | str + ) -> None: + await mcp_session.initialize() + tools = await mcp_session.list_tools() + if mount is False: + assert len(tools.tools) == 1 + tool = tools.tools[0] + assert tool.name == McpTools.RUN_PYTHON_CODE + assert tool.description + assert tool.description.startswith( + 'Tool to execute Python code and return stdout, stderr, and return value.' + ) + assert tool.inputSchema['properties'] == snapshot( + {'python_code': {'type': 'string', 'description': 'Python code to run'}} + ) + else: + # Check tools + assert len(tools.tools) == 5 + # sort tools by their name + sorted_tools = sorted(tools.tools, key=lambda t: t.name) + + # Check tool names + assert set(tool.name for tool in tools.tools) == set(McpTools) + + # Check run python tool + tool = sorted_tools[2] + assert tool.name == McpTools.RUN_PYTHON_CODE + assert tool.description + assert tool.description.startswith( + 'Tool to execute Python code and return stdout, stderr, and return value.' + ) + assert tool.inputSchema['properties'] == snapshot( + {'python_code': {'type': 'string', 'description': 'Python code to run'}} + ) + + # Check resources (no file uploaded) + resources = await mcp_session.list_resources() + assert len(resources.resources) == 0 + + @pytest.mark.parametrize( + 'code,expected_output', + [ + pytest.param( + [ + 'x = 4', + "print(f'{x=}')", + 'x', + ], + snapshot("""\ success x=4 @@ -122,14 +210,14 @@ async def test_list_tools(mcp_session: ClientSession) -> None: 4 \ """), - id='basic-code', - ), - pytest.param( - [ - 'import numpy', - 'numpy.array([1, 2, 3])', - ], - snapshot("""\ + id='basic-code', + ), + pytest.param( + [ + 'import numpy', + 'numpy.array([1, 2, 3])', + ], + snapshot("""\ success ["numpy"] @@ -140,19 +228,19 @@ async def test_list_tools(mcp_session: ClientSession) -> None: ] \ """), - id='import-numpy', - ), - pytest.param( - [ - '# /// script', - '# dependencies = ["pydantic", "email-validator"]', - '# ///', - 'import pydantic', - 'class Model(pydantic.BaseModel):', - ' email: pydantic.EmailStr', - "Model(email='hello@pydantic.dev')", - ], - snapshot("""\ + id='import-numpy', + ), + pytest.param( + [ + '# /// script', + '# dependencies = ["pydantic", "email-validator"]', + '# ///', + 'import pydantic', + 'class Model(pydantic.BaseModel):', + ' email: pydantic.EmailStr', + "Model(email='hello@pydantic.dev')", + ], + snapshot("""\ success ["pydantic","email-validator"] @@ -161,16 +249,18 @@ async def test_list_tools(mcp_session: ClientSession) -> None: } \ """), - id='magic-comment-import', - ), - pytest.param( - [ - 'print(unknown)', - ], - snapshot("""\ + id='magic-comment-import', + ), + pytest.param( + [ + 'print(unknown)', + ], + snapshot("""\ run-error Traceback (most recent call last): + ...<9 lines>... + .run_async(globals, locals) File "main.py", line 1, in print(unknown) ^^^^^^^ @@ -178,17 +268,381 @@ async def test_list_tools(mcp_session: ClientSession) -> None: \ """), - id='undefined-variable', - ), - ], -) -async def test_run_python_code(mcp_session: ClientSession, code: list[str], expected_output: str) -> None: - await mcp_session.initialize() - result = await mcp_session.call_tool('run_python_code', {'python_code': '\n'.join(code)}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, types.TextContent) - assert content.text == expected_output + id='undefined-variable', + ), + ], + ) + async def test_run_python_code( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + code: list[str], + expected_output: str, + ) -> None: + await mcp_session.initialize() + result = await mcp_session.call_tool(McpTools.RUN_PYTHON_CODE, {'python_code': '\n'.join(code)}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert content.text == expected_output + + def get_dir_from_instructions(self, instructions: str | None) -> Path: + if instructions and (match := re.search(r'Persistent storage is mounted at:\s*"([^"]+)"', instructions)): + return Path(match.group(1)) + else: + raise ValueError(f'Could not parse directory from initialize instruction: {instructions}') + + @contextmanager + def serve_file_once(self, data: bytes): + """ + Context manager that exposes `filepath` at a localhost URL. + Yields the URL as a string. When the context exits, the server stops. + + Args: + filepath: Path to the file to serve. + single_use: If True, the server shuts down after the first successful GET. + """ + + token = secrets.token_urlsafe(16) # secret path component: / + ctype = 'application/octet-stream' + length = len(data) + + class _BytesHandler(BaseHTTPRequestHandler): + # Silence default logging + def log_message(self, format: str, *args: Any) -> None: + pass + + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(length)) + self.end_headers() + + # Stream in chunks so it works for large data + chunk_size = 64 * 1024 + for i in range(0, length, chunk_size): + self.wfile.write(data[i : i + chunk_size]) + self.wfile.flush() + + threading.Thread(target=self.server.shutdown, daemon=True).start() + + httpd = ThreadingHTTPServer(('127.0.0.1', 0), _BytesHandler) + # Avoid "address already in use" on quick reuse + httpd.daemon_threads = True + + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + try: + port = httpd.server_address[1] + url = f'http://127.0.0.1:{port}/{token}' + yield url + finally: + # Safe shutdown even if already stopped by single_use + try: + httpd.shutdown() + except Exception: + pass + httpd.server_close() + t.join(timeout=2) + + @pytest.mark.parametrize('file_type', ['text', 'bytes']) + async def test_upload_files( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + file_type: Literal['text', 'bytes'], + tmp_path: Path, + ) -> None: + if mount is False: + pytest.skip('No directory mounted.') + result = await mcp_session.initialize() + + # Extract directory from response + storageDir = self.get_dir_from_instructions(result.instructions) + assert storageDir.is_dir() + + match file_type: + case 'text': + filename = 'data.csv' + ctype = 'text/csv' + result = await mcp_session.call_tool( + McpTools.UPLOAD_FILE, {'type': 'text', 'filename': filename, 'text': CSV_DATA, 'blob': None} + ) + + case 'bytes': + filename = 'image.png' + ctype = 'image/png' + result = await mcp_session.call_tool( + McpTools.UPLOAD_FILE, {'type': 'bytes', 'filename': filename, 'blob': BASE_64_IMAGE, 'text': None} + ) + + assert result.isError is False + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.ResourceLink) + assert str(content.uri) == f'file:///{filename}' + assert content.name == filename + assert content.mimeType is not None + assert content.mimeType.startswith(ctype) + + createdFile = storageDir / filename + assert createdFile.exists() + assert createdFile.is_file() + + match file_type: + case 'text': + assert createdFile.read_text() == CSV_DATA + case 'bytes': + assert base64.b64encode(createdFile.read_bytes()).decode() == BASE_64_IMAGE + + @pytest.mark.parametrize('uri_type', ['http', 'file']) + async def test_upload_files_with_uri( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + uri_type: Literal['http', 'file'], + tmp_path: Path, + ) -> None: + if mount is False: + pytest.skip('No directory mounted.') + result = await mcp_session.initialize() + + # Extract directory from response + storageDir = self.get_dir_from_instructions(result.instructions) + assert storageDir.is_dir() + + match uri_type: + case 'file': + filename = 'data.csv' + data_file = tmp_path / filename + data_file.write_text(CSV_DATA) + + result = await mcp_session.call_tool( + McpTools.UPLOAD_FILE_FROM_URI, {'uri': f'file://{str(data_file)}', 'filename': filename} + ) + + case 'http': + filename = 'data.csv' + with self.serve_file_once(CSV_DATA.encode()) as url: + result = await mcp_session.call_tool( + McpTools.UPLOAD_FILE_FROM_URI, {'uri': url, 'filename': filename} + ) + + assert result.isError is False + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.ResourceLink) + assert str(content.uri) == f'file:///{filename}' + assert content.name == filename + assert content.mimeType is not None + assert content.mimeType.startswith('text/csv') + + createdFile = storageDir / filename + assert createdFile.exists() + assert createdFile.is_file() + assert createdFile.read_text() == CSV_DATA + + @pytest.mark.parametrize('content_type', ['bytes', 'text']) + async def test_download_files( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + content_type: Literal['bytes', 'text'], + ) -> None: + if mount is False: + pytest.skip('No directory mounted.') + result = await mcp_session.initialize() + + # Extract directory from response + storageDir = self.get_dir_from_instructions(result.instructions) + assert storageDir.is_dir() + + match content_type: + case 'bytes': + filename = 'image.png' + ctype = 'image/png' + file_path = storageDir / filename + file_path.write_bytes(base64.b64decode(BASE_64_IMAGE)) + + case 'text': + filename = 'data.csv' + ctype = 'text/csv' + file_path = storageDir / filename + file_path.write_text(CSV_DATA) + + result = await mcp_session.call_tool(McpTools.RETRIEVE_FILE, {'filename': filename}) + assert result.isError is False + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.ResourceLink) + assert str(content.uri) == f'file:///{filename}' + assert content.name == filename + assert content.mimeType is not None + assert content.mimeType.startswith(ctype) + + result = await mcp_session.list_resources() + + assert len(result.resources) == 1 + resource = result.resources[0] + assert resource.name == filename + assert resource.mimeType is not None + assert resource.mimeType.startswith(ctype) + assert str(resource.uri) == f'file:///{filename}' + + result = await mcp_session.read_resource(FileUrl(f'file:///{filename}')) + + assert len(result.contents) == 1 + resource = result.contents[0] + assert str(resource.uri) == f'file:///{filename}' + assert resource.mimeType is not None + assert resource.mimeType.startswith(ctype) + + match content_type: + case 'bytes': + assert isinstance(resource, types.BlobResourceContents) + assert resource.blob == BASE_64_IMAGE + + case 'text': + assert isinstance(resource, types.TextResourceContents) + assert resource.text == CSV_DATA + + async def test_delete_file( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + ): + if mount is False: + pytest.skip('No directory mounted.') + result = await mcp_session.initialize() + + # Extract directory from response + storageDir = self.get_dir_from_instructions(result.instructions) + assert storageDir.is_dir() + + filename = 'data.csv' + file_path = storageDir / filename + file_path.write_text(CSV_DATA) + + result = await mcp_session.call_tool(McpTools.DELETE_FILE, {'filename': filename}) + assert result.isError is False + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert content.text.endswith('deleted successfully') + assert not file_path.exists() + + # Code pieces use hardcoded values of mount + @pytest.mark.parametrize( + 'code,expected_output,content_type', + [ + pytest.param( + [ + '# /// script', + '# dependencies = ["pillow"]', + '# ///', + 'from PIL import Image, ImageFilter', + 'img = Image.open("storage/image.png")', + 'gray_img = img.convert("L")', + 'gray_img.save("storage/image-gray.png")', + 'print(f"Image size: {img.size}")', + ], + snapshot("""\ +success +["pillow"] + +Image size: (256, 256) +\ +"""), + 'bytes', + id='image-transform', + ), + pytest.param( + [ + '# /// script', + '# dependencies = ["pandas"]', + '# ///', + 'import pandas as pd', + 'df = pd.read_csv("storage/data.csv")', + 'df["Age_in_10_years"] = df["Age"] + 10', + 'df.to_csv("storage/data-processed.csv", index=False)', + 'print(df.describe())', + ], + snapshot("""\ +success +["pandas"] + + Age Salary Age_in_10_years +count 10.000000 10.000000 10.000000 +mean 33.000000 62900.000000 43.000000 +std 6.394442 6100.091074 6.394442 +min 25.000000 52000.000000 35.000000 +25% 28.250000 59250.000000 38.250000 +50% 31.500000 62500.000000 41.500000 +75% 37.250000 67250.000000 47.250000 +max 45.000000 72000.000000 55.000000 +\ +"""), + 'text', + id='dataframe-manipulation', + ), + ], + ) + async def test_run_python_code_with_file( + self, + mcp_session: ClientSession, + server_type: Literal['stdio', 'sse', 'streamable_http'], + mount: bool | str, + code: list[str], + expected_output: str, + content_type: Literal['bytes', 'text'], + tmp_path: Path, + ): + if mount is False: + pytest.skip('No directory mounted.') + await mcp_session.initialize() + + match content_type: + case 'bytes': + filename = 'image.png' + output_file = 'image-gray.png' + ctype = 'image/png' + data_file = tmp_path / filename + data_file.write_bytes(base64.b64decode(BASE_64_IMAGE)) + + case 'text': + filename = 'data.csv' + output_file = 'data-processed.csv' + ctype = 'text/csv' + data_file = tmp_path / filename + data_file.write_text(CSV_DATA) + + result = await mcp_session.call_tool( + McpTools.UPLOAD_FILE_FROM_URI, {'uri': f'file://{str(data_file)}', 'filename': filename} + ) + assert result.isError is False + + result = await mcp_session.call_tool(McpTools.RUN_PYTHON_CODE, {'python_code': '\n'.join(code)}) + assert result.isError is False + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert content.text == expected_output + + result = await mcp_session.read_resource(FileUrl(f'file:///{output_file}')) + assert len(result.contents) == 1 + resource = result.contents[0] + assert resource.mimeType is not None + assert resource.mimeType.startswith(ctype) + assert ( + isinstance(resource, types.BlobResourceContents) + if content_type == 'bytes' + else isinstance(resource, types.TextResourceContents) + ) async def test_install_run_python_code() -> None: @@ -225,7 +679,8 @@ async def logging_callback(params: types.LoggingMessageNotificationParams) -> No \ """ assert content.text == expected_output - assert len(logs) >= 18 + assert len(logs) >= 16 assert re.search( - r"debug: Didn't find package numpy\S+?\.whl locally, attempting to load from", '\n'.join(logs) + r"debug: loadPackage: Didn't find package numpy\S*\.whl locally, attempting to load from", + '\n'.join(logs), )