diff --git a/README.md b/README.md index 46f0a6d..570b582 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ To use this server, you must have both Python and [Deno](https://deno.com/) inst The server can be run with `deno` installed using `uvx`: ```bash -uvx mcp-run-python [-h] [--version] [--port PORT] [--deps DEPS] {stdio,streamable-http,streamable-http-stateless,example} +uvx mcp-run-python [-h] [--version] [--port PORT] [--deps DEPS] [--index-url URL] {stdio,streamable-http,streamable-http-stateless,example} ``` where: @@ -166,3 +166,13 @@ edit the filesystem. * `deno` is then run with read-only permissions to the `node_modules` directory to run untrusted code. Dependencies must be provided when initializing the server so they can be installed in the first step. + +## Custom Package Indexes + +Use `--index-url` to install dependencies from private registries (can be repeated, tried in order before PyPI): + +```bash +uvx mcp-run-python --index-url https://private.repo.com/simple --deps mypackage stdio +``` + +The Python API accepts `index_urls` in `code_sandbox`, `prepare_deno_env`, and `run_mcp_server`. See [micropip documentation](https://micropip.pyodide.org/en/stable/project/api.html#micropip.install) for index URL requirements. diff --git a/build/prepare_env.py b/build/prepare_env.py index 492101d..57c9f95 100644 --- a/build/prepare_env.py +++ b/build/prepare_env.py @@ -31,7 +31,7 @@ class Error: kind: Literal['error'] = 'error' -async def prepare_env(dependencies: list[str] | None) -> Success | Error: +async def prepare_env(dependencies: list[str] | None, index_urls: list[str] | None = None) -> Success | Error: sys.setrecursionlimit(400) if dependencies: @@ -39,7 +39,7 @@ async def prepare_env(dependencies: list[str] | None) -> Success | Error: with _micropip_logging() as logs_filename: try: - await micropip.install(dependencies, keep_going=True) + await micropip.install(dependencies, keep_going=True, index_urls=index_urls) importlib.invalidate_caches() except Exception: with open(logs_filename) as f: diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index 69bab42..a15a275 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -23,6 +23,13 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: parser.add_argument('--port', type=int, help='Port to run the server on, default 3001.') parser.add_argument('--deps', '--dependencies', help='Comma separated list of dependencies to install') + parser.add_argument( + '--index-url', + action='append', + dest='index_urls', + metavar='URL', + help='Package index URL for installing dependencies (can be repeated, tried in order before PyPI)', + ) parser.add_argument( '--disable-networking', action='store_true', help='Disable networking during execution of python code' ) @@ -47,11 +54,13 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: ) deps: list[str] = args.deps.split(',') if args.deps else [] + index_urls: list[str] = args.index_urls or [] return_code = run_mcp_server( args.mode.replace('-', '_'), allow_networking=not args.disable_networking, http_port=args.port, dependencies=deps, + index_urls=index_urls, deps_log_handler=deps_log_handler, verbose=bool(args.verbose), ) diff --git a/mcp_run_python/code_sandbox.py b/mcp_run_python/code_sandbox.py index f552861..1f09030 100644 --- a/mcp_run_python/code_sandbox.py +++ b/mcp_run_python/code_sandbox.py @@ -54,6 +54,7 @@ async def eval( async def code_sandbox( *, dependencies: list[str] | None = None, + index_urls: list[str] | None = None, log_handler: LogHandler | None = None, allow_networking: bool = True, ) -> AsyncIterator['CodeSandbox']: @@ -61,6 +62,7 @@ async def code_sandbox( Args: dependencies: A list of dependencies to be installed. + index_urls: Package index URLs for installing dependencies (tried in order before PyPI). log_handler: A callback function to handle print statements when code is running. deps_log_handler: A callback function to run on log statements during initial install of dependencies. allow_networking: Whether to allow networking or not while executing python code. @@ -68,6 +70,7 @@ async def code_sandbox( async with async_prepare_deno_env( 'stdio', dependencies=dependencies, + index_urls=index_urls, deps_log_handler=log_handler, return_mode='json', allow_networking=allow_networking, diff --git a/mcp_run_python/deno/src/main.ts b/mcp_run_python/deno/src/main.ts index f71ccbd..eb9fa85 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -20,27 +20,28 @@ const VERSION = '0.0.13' export async function main() { const { args } = Deno const flags = parseArgs(Deno.args, { - string: ['deps', 'return-mode', 'port'], + string: ['deps', 'return-mode', 'port', 'index-urls'], default: { port: '3001', 'return-mode': 'xml' }, }) const deps = flags.deps?.split(',') ?? [] + const indexUrls = flags['index-urls']?.split(',') ?? [] if (args.length >= 1) { if (args[0] === 'stdio') { - await runStdio(deps, flags['return-mode']) + await runStdio(deps, indexUrls, flags['return-mode']) return } else if (args[0] === 'streamable_http') { const port = parseInt(flags.port) - runStreamableHttp(port, deps, flags['return-mode'], false) + runStreamableHttp(port, deps, indexUrls, flags['return-mode'], false) return } else if (args[0] === 'streamable_http_stateless') { const port = parseInt(flags.port) - runStreamableHttp(port, deps, flags['return-mode'], true) + runStreamableHttp(port, deps, indexUrls, flags['return-mode'], true) return } else if (args[0] === 'example') { - await example(deps) + await example(deps, indexUrls) return } else if (args[0] === 'noop') { - await installDeps(deps) + await installDeps(deps, indexUrls) return } } @@ -51,9 +52,10 @@ Invalid arguments: ${args.join(' ')} Usage: deno ... deno/main.ts [stdio|streamable_http|streamable_http_stateless|example|noop] options: ---port Port to run the HTTP server on (default: 3001) ---deps Comma separated list of dependencies to install ---return-mode Return mode for output data (default: xml)`, +--port Port to run the HTTP server on (default: 3001) +--deps Comma separated list of dependencies to install +--index-urls Comma separated list of package index URLs (tried in order before PyPI) +--return-mode Return mode for output data (default: xml)`, ) Deno.exit(1) } @@ -61,7 +63,7 @@ options: /* * Create an MCP server with the `run_python_code` tool registered. */ -function createServer(deps: string[], returnMode: string): McpServer { +function createServer(deps: string[], indexUrls: string[], returnMode: string): McpServer { const runCode = new RunCode() const server = new McpServer( { @@ -106,6 +108,7 @@ The code will be executed with Python 3.13. const logPromises: Promise[] = [] const result = await runCode.run( deps, + indexUrls, (level, data) => { if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) { logPromises.push(server.server.sendLoggingMessage({ level, data })) @@ -171,14 +174,20 @@ function httpSetJsonResponse(res: http.ServerResponse, status: number, text: str /* * Run the MCP server using the Streamable HTTP transport */ -function runStreamableHttp(port: number, deps: string[], returnMode: string, stateless: boolean): void { - const server = (stateless ? createStatelessHttpServer : createStatefulHttpServer)(deps, returnMode) +function runStreamableHttp( + port: number, + deps: string[], + indexUrls: string[], + returnMode: string, + stateless: boolean, +): void { + const server = (stateless ? createStatelessHttpServer : createStatefulHttpServer)(deps, indexUrls, returnMode) server.listen(port, () => { console.log(`Listening on port ${port}`) }) } -function createStatelessHttpServer(deps: string[], returnMode: string): http.Server { +function createStatelessHttpServer(deps: string[], indexUrls: string[], returnMode: string): http.Server { return http.createServer(async (req, res) => { const url = httpGetUrl(req) @@ -188,7 +197,7 @@ function createStatelessHttpServer(deps: string[], returnMode: string): http.Ser } try { - const mcpServer = createServer(deps, returnMode) + const mcpServer = createServer(deps, indexUrls, returnMode) const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }) @@ -211,10 +220,10 @@ function createStatelessHttpServer(deps: string[], returnMode: string): http.Ser }) } -function createStatefulHttpServer(deps: string[], returnMode: string): http.Server { +function createStatefulHttpServer(deps: string[], indexUrls: string[], returnMode: string): http.Server { // Stateful mode with session management // https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#with-session-management - const mcpServer = createServer(deps, returnMode) + const mcpServer = createServer(deps, indexUrls, returnMode) const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} return http.createServer(async (req, res) => { @@ -293,8 +302,8 @@ function createStatefulHttpServer(deps: string[], returnMode: string): http.Serv /* * Run the MCP server using the Stdio transport. */ -async function runStdio(deps: string[], returnMode: string) { - const mcpServer = createServer(deps, returnMode) +async function runStdio(deps: string[], indexUrls: string[], returnMode: string) { + const mcpServer = createServer(deps, indexUrls, returnMode) const transport = new StdioServerTransport() await mcpServer.connect(transport) } @@ -302,10 +311,11 @@ async function runStdio(deps: string[], returnMode: string) { /* * Run pyodide to download and install dependencies. */ -async function installDeps(deps: string[]) { +async function installDeps(deps: string[], indexUrls: string[]) { const runCode = new RunCode() const result = await runCode.run( deps, + indexUrls, (level, data) => console.error(`${level}|${data}`), ) if (result.status !== 'success') { @@ -317,7 +327,7 @@ async function installDeps(deps: string[]) { /* * Run a short example script that requires numpy. */ -async function example(deps: string[]) { +async function example(deps: string[], indexUrls: string[]) { console.error( `Running example script for MCP Run Python version ${VERSION}...`, ) @@ -330,6 +340,7 @@ a const runCode = new RunCode() const result = await runCode.run( deps, + indexUrls, // use warn to avoid recursion since console.log is patched in runCode (level, data) => console.warn(`${level}: ${data}`), { name: 'example.py', content: code }, diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index 1be8681..6ce3691 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -23,6 +23,7 @@ export class RunCode { async run( dependencies: string[], + indexUrls: string[], log: (level: LoggingLevel, data: string) => void, file?: CodeFile, globals?: Record, @@ -38,7 +39,7 @@ export class RunCode { sys = pyodide.pyimport('sys') } else { if (!this.prepPromise) { - this.prepPromise = this.prepEnv(dependencies, log) + this.prepPromise = this.prepEnv(dependencies, indexUrls, log) } // TODO is this safe if the promise has already been accessed? it seems to work fine const prep = await this.prepPromise @@ -83,6 +84,7 @@ export class RunCode { async prepEnv( dependencies: string[], + indexUrls: string[], log: (level: LoggingLevel, data: string) => void, ): Promise { const pyodide = await loadPyodide({ @@ -122,7 +124,9 @@ export class RunCode { const preparePyEnv: PreparePyEnv = pyodide.pyimport(moduleName) - const prepareStatus = await preparePyEnv.prepare_env(pyodide.toPy(dependencies)) + const prepareStatus = indexUrls.length > 0 + ? await preparePyEnv.prepare_env(pyodide.toPy(dependencies), pyodide.toPy(indexUrls)) + : await preparePyEnv.prepare_env(pyodide.toPy(dependencies)) return { pyodide, preparePyEnv, @@ -214,6 +218,6 @@ interface PrepareError { message: string } interface PreparePyEnv { - prepare_env: (files: CodeFile[]) => Promise + prepare_env: (dependencies: any, index_urls?: any) => Promise dump_json: (value: any, always_return_json: boolean) => string | null } diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 1b95224..af9c0de 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -24,6 +24,7 @@ def run_mcp_server( *, http_port: int | None = None, dependencies: list[str] | None = None, + index_urls: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, @@ -35,6 +36,7 @@ def run_mcp_server( mode: The mode to run the server in. http_port: The port to run the server on if mode is `streamable_http`. dependencies: The dependencies to install. + index_urls: Package index URLs for installing dependencies (tried in order before PyPI). return_mode: The mode to return tool results in. deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. @@ -48,6 +50,7 @@ def run_mcp_server( with prepare_deno_env( mode, dependencies=dependencies, + index_urls=index_urls, http_port=http_port, return_mode=return_mode, deps_log_handler=deps_log_handler, @@ -79,6 +82,7 @@ def prepare_deno_env( *, http_port: int | None = None, dependencies: list[str] | None = None, + index_urls: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, @@ -93,6 +97,7 @@ def prepare_deno_env( mode: The mode to run the server in. http_port: The port to run the server on if mode is `streamable_http`. dependencies: The dependencies to install. + index_urls: Package index URLs for installing dependencies (tried in order before PyPI). return_mode: The mode to return tool results in. deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether the prepared DenoEnv should allow networking when running code. @@ -108,7 +113,7 @@ def prepare_deno_env( shutil.copytree(src, cwd, ignore=shutil.ignore_patterns('node_modules')) logger.info('Installing dependencies %s...', dependencies) - args = 'deno', *_deno_install_args(dependencies) + args = 'deno', *_deno_install_args(dependencies, index_urls) p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) stdout: list[str] = [] if p.stdout is not None: @@ -127,6 +132,7 @@ def prepare_deno_env( mode, http_port=http_port, dependencies=dependencies, + index_urls=index_urls, return_mode=return_mode, allow_networking=allow_networking, ) @@ -142,6 +148,7 @@ async def async_prepare_deno_env( *, http_port: int | None = None, dependencies: list[str] | None = None, + index_urls: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, @@ -152,6 +159,7 @@ async def async_prepare_deno_env( mode, http_port=http_port, dependencies=dependencies, + index_urls=index_urls, return_mode=return_mode, deps_log_handler=deps_log_handler, allow_networking=allow_networking, @@ -162,7 +170,7 @@ async def async_prepare_deno_env( await _asyncify(ct.__exit__, None, None, None) -def _deno_install_args(dependencies: list[str] | None = None) -> list[str]: +def _deno_install_args(dependencies: list[str] | None = None, index_urls: list[str] | None = None) -> list[str]: args = [ 'run', '--allow-net', @@ -174,6 +182,8 @@ def _deno_install_args(dependencies: list[str] | None = None) -> list[str]: ] if dependencies is not None: args.append(f'--deps={",".join(dependencies)}') + if index_urls: + args.append(f'--index-urls={",".join(index_urls)}') return args @@ -182,6 +192,7 @@ def _deno_run_args( *, http_port: int | None = None, dependencies: list[str] | None = None, + index_urls: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', allow_networking: bool = True, ) -> list[str]: @@ -197,6 +208,8 @@ def _deno_run_args( ] if dependencies is not None: args.append(f'--deps={",".join(dependencies)}') + if index_urls: + args.append(f'--index-urls={",".join(index_urls)}') if http_port is not None: if mode in ('streamable_http', 'streamable_http_stateless'): args.append(f'--port={http_port}')