diff --git a/README.md b/README.md index 46f0a6d..08c1e09 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] [--dep PKG]... [--index-url URL]... {stdio,streamable-http,streamable-http-stateless,example} ``` where: @@ -49,7 +49,7 @@ where: - `streamable-http-stateless` runs the server with [Streamable HTTP MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) in stateless mode and does not support server-to-client notifications - `example` will run a minimal Python script using `numpy`, useful for checking that the package is working, for the code - to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --deps numpy example` + to run successfully, you'll need to install `numpy` using `uvx mcp-run-python --dep numpy example` ## Usage with Pydantic AI @@ -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 --dep 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..7b5901e 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 or None) 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..40f8744 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -3,6 +3,7 @@ import argparse import logging import sys +import warnings from collections.abc import Sequence from . import __version__ @@ -22,7 +23,17 @@ 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( + '--dep', action='append', dest='dep_list', metavar='PKG', help='Dependency to install (can be repeated)' + ) + parser.add_argument('--deps', '--dependencies', help=argparse.SUPPRESS) + 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' ) @@ -46,12 +57,21 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: format='%(message)s', ) - deps: list[str] = args.deps.split(',') if args.deps else [] + deps: list[str] = args.dep_list or [] + if args.deps: + warnings.warn( + '--deps is deprecated, use --dep instead (can be repeated)', + DeprecationWarning, + stacklevel=2, + ) + deps.extend(args.deps.split(',')) + 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..7b131eb 100644 --- a/mcp_run_python/deno/src/main.ts +++ b/mcp_run_python/deno/src/main.ts @@ -21,26 +21,37 @@ export async function main() { const { args } = Deno const flags = parseArgs(Deno.args, { string: ['deps', 'return-mode', 'port'], + collect: ['dep', 'index-url'], default: { port: '3001', 'return-mode': 'xml' }, }) - const deps = flags.deps?.split(',') ?? [] + + // Deprecation warnings for old comma-separated args + if (flags.deps) { + console.warn('Warning: --deps is deprecated, use --dep instead (can be repeated)') + } + // Support both new repeatable args and old comma-separated (backwards compat) + const deps: string[] = [ + ...((flags.dep as string[] | undefined) ?? []), + ...(flags.deps?.split(',').filter(Boolean) ?? []), + ] + const indexUrls: string[] = (flags['index-url'] as string[] | undefined) ?? [] 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 +62,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) +--dep Dependency to install (can be repeated) +--index-url Package index URL (can be repeated, tried before PyPI) +--return-mode Return mode for output data (default: xml)`, ) Deno.exit(1) } @@ -61,7 +73,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 +118,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 +184,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 +207,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 +230,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 +312,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 +321,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 +337,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 +350,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..2871cf4 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,10 @@ export class RunCode { const preparePyEnv: PreparePyEnv = pyodide.pyimport(moduleName) - const prepareStatus = await preparePyEnv.prepare_env(pyodide.toPy(dependencies)) + const prepareStatus = await preparePyEnv.prepare_env( + pyodide.toPy(dependencies), + pyodide.toPy(indexUrls), + ) return { pyodide, preparePyEnv, @@ -214,6 +219,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..5b6e93d 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', @@ -173,7 +181,11 @@ def _deno_install_args(dependencies: list[str] | None = None) -> list[str]: 'noop', ] if dependencies is not None: - args.append(f'--deps={",".join(dependencies)}') + for dep in dependencies: + args.append(f'--dep={dep}') + if index_urls: + for url in index_urls: + args.append(f'--index-url={url}') return args @@ -182,6 +194,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]: @@ -196,7 +209,11 @@ def _deno_run_args( f'--return-mode={return_mode}', ] if dependencies is not None: - args.append(f'--deps={",".join(dependencies)}') + for dep in dependencies: + args.append(f'--dep={dep}') + if index_urls: + for url in index_urls: + args.append(f'--index-url={url}') if http_port is not None: if mode in ('streamable_http', 'streamable_http_stateless'): args.append(f'--port={http_port}') diff --git a/tests/test_cli.py b/tests/test_cli.py index b86bd4c..170f94b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ from __future__ import annotations as _annotations +import warnings + import pytest from mcp_run_python._cli import cli_logic @@ -15,5 +17,30 @@ def test_cli_example_success(): assert cli_logic(['--deps', 'numpy', 'example']) == 0 +def test_cli_dep_repeatable(): + """Test new --dep repeatable flag""" + assert cli_logic(['--dep', 'numpy', 'example']) == 0 + + +def test_cli_dep_multiple(): + """Test multiple --dep flags""" + assert cli_logic(['--dep', 'numpy', '--dep', 'pydantic', 'example']) == 0 + + +def test_cli_dep_and_deps_combined(): + """Test combining --dep and --deps (backwards compat)""" + assert cli_logic(['--dep', 'numpy', '--deps', 'pydantic', 'example']) == 0 + + +def test_cli_deps_deprecation_warning(): + """Test that --deps emits a deprecation warning""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + cli_logic(['--deps', 'numpy', 'example']) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) >= 1 + assert any('--deps is deprecated' in str(x.message) for x in deprecation_warnings) + + def test_cli_example_fail(): assert cli_logic(['example']) == 1