Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions build/prepare_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ 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:
dependencies = _add_extra_dependencies(dependencies)

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:
Expand Down
9 changes: 9 additions & 0 deletions mcp_run_python/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand All @@ -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),
)
Expand Down
3 changes: 3 additions & 0 deletions mcp_run_python/code_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,23 @@ 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']:
"""Create a secure 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.
"""
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,
Expand Down
51 changes: 31 additions & 20 deletions mcp_run_python/deno/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -51,17 +52,18 @@ Invalid arguments: ${args.join(' ')}
Usage: deno ... deno/main.ts [stdio|streamable_http|streamable_http_stateless|example|noop]

options:
--port <port> Port to run the HTTP server on (default: 3001)
--deps <deps> Comma separated list of dependencies to install
--return-mode <xml/json> Return mode for output data (default: xml)`,
--port <port> Port to run the HTTP server on (default: 3001)
--deps <deps> Comma separated list of dependencies to install
--index-urls <urls> Comma separated list of package index URLs (tried in order before PyPI)
--return-mode <xml/json> Return mode for output data (default: xml)`,
)
Deno.exit(1)
}

/*
* 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(
{
Expand Down Expand Up @@ -106,6 +108,7 @@ The code will be executed with Python 3.13.
const logPromises: Promise<void>[] = []
const result = await runCode.run(
deps,
indexUrls,
(level, data) => {
if (LogLevels.indexOf(level) >= LogLevels.indexOf(setLogLevel)) {
logPromises.push(server.server.sendLoggingMessage({ level, data }))
Expand Down Expand Up @@ -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)

Expand All @@ -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,
})
Expand All @@ -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) => {
Expand Down Expand Up @@ -293,19 +302,20 @@ 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)
}

/*
* 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') {
Expand All @@ -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}...`,
)
Expand All @@ -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 },
Expand Down
10 changes: 7 additions & 3 deletions mcp_run_python/deno/src/runCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class RunCode {

async run(
dependencies: string[],
indexUrls: string[],
log: (level: LoggingLevel, data: string) => void,
file?: CodeFile,
globals?: Record<string, any>,
Expand All @@ -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
Expand Down Expand Up @@ -83,6 +84,7 @@ export class RunCode {

async prepEnv(
dependencies: string[],
indexUrls: string[],
log: (level: LoggingLevel, data: string) => void,
): Promise<PrepResult> {
const pyodide = await loadPyodide({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -214,6 +218,6 @@ interface PrepareError {
message: string
}
interface PreparePyEnv {
prepare_env: (files: CodeFile[]) => Promise<PrepareSuccess | PrepareError>
prepare_env: (dependencies: any, index_urls?: any) => Promise<PrepareSuccess | PrepareError>
dump_json: (value: any, always_return_json: boolean) => string | null
}
17 changes: 15 additions & 2 deletions mcp_run_python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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,
)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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


Expand All @@ -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]:
Expand All @@ -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}')
Expand Down