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
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.6
#
# mcp-python-interpreter — streamable-HTTP build for fork-publish via GHCR.
#
# Defaults to the streamable-http transport on 0.0.0.0:8000/mcp so the image
# is drop-in compatible with the bifrost MCP registry's expected URL shape.
# Override via MCP_TRANSPORT / MCP_HOST / MCP_PORT env vars.
#
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /src
COPY pyproject.toml README.md ./
COPY mcp_python_interpreter ./mcp_python_interpreter

# Build a wheel + install it into an isolated prefix that we'll copy to the
# runtime stage. Pulls in the mcp>=1.8.0 / fastmcp>=2.0.0 deps declared in
# pyproject.toml.
RUN pip install --prefix=/install .

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
MCP_TRANSPORT=streamable-http \
MCP_HOST=0.0.0.0 \
MCP_PORT=8000 \
MCP_DISABLE_DNS_REBINDING_PROTECTION=true

# Copy the wheel install (site-packages + console scripts) over.
COPY --from=builder /install /usr/local

WORKDIR /work
EXPOSE 8000

# Run as the entry-point console script defined by pyproject.toml.
CMD ["mcp-python-interpreter"]
118 changes: 116 additions & 2 deletions mcp_python_interpreter/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,125 @@
"""Main module for mcp-python-interpreter."""

import os

from mcp_python_interpreter.server import mcp


def main():
"""Run the MCP Python Interpreter server."""
mcp.run(transport='stdio')
"""Run the MCP Python Interpreter server.

Transport selection (env-driven so existing stdio users are unaffected):

MCP_TRANSPORT=stdio (default) — original behavior
MCP_TRANSPORT=streamable-http — HTTP transport on MCP_HOST:MCP_PORT
(defaults 0.0.0.0:8000, path /mcp)
MCP_TRANSPORT=sse — SSE transport on MCP_HOST:MCP_PORT

DNS-rebinding protection:

The MCP SDK ships a transport-security layer that rejects requests
whose Host header isn't in {127.0.0.1:*, localhost:*, [::1]:*} when
using SSE / streamable-http. That's the right default for a server
bound to 127.0.0.1 on a developer laptop, but it kills any remote
MCP deployment behind a gateway / reverse proxy / container name.

Two knobs are exposed via env:

MCP_ALLOWED_HOSTS=h1,h2 — extra Host values to accept
(e.g. "mcp-python:*,my-gateway.local:*")
MCP_DISABLE_DNS_REBINDING_PROTECTION=true — turn the check off
entirely (suitable when
the server is on a
trusted network only).
"""
transport = os.environ.get('MCP_TRANSPORT', 'stdio')

if transport == 'stdio':
mcp.run(transport='stdio')
return

if transport not in ('streamable-http', 'sse'):
raise SystemExit(
f"Unknown MCP_TRANSPORT={transport!r}; expected "
"'stdio', 'streamable-http', or 'sse'."
)

# FastMCP's host/port come from its Settings object (FASTMCP_HOST /
# FASTMCP_PORT env vars or constructor kwargs) — they are NOT accepted
# as kwargs to .run(). Mutate settings in place so users can drive them
# with the same MCP_* env vars used elsewhere.
mcp.settings.host = os.environ.get('MCP_HOST', '0.0.0.0')
mcp.settings.port = int(os.environ.get('MCP_PORT', '8000'))

# streamable-http response framing: SSE-streamed (default) vs single
# application/json reply. Some MCP gateways (e.g. bifrost) only parse
# the JSON form during background tool-discovery, so default to JSON
# when running streamable-http for broader compatibility. Override
# with MCP_JSON_RESPONSE=false to force SSE.
json_resp_env = os.environ.get('MCP_JSON_RESPONSE', '').lower()
if transport == 'streamable-http':
if json_resp_env in ('1', 'true', 'yes', ''):
mcp.settings.json_response = True
elif json_resp_env in ('0', 'false', 'no'):
mcp.settings.json_response = False

# Stateless mode: spawn a fresh transport per request, no session
# tracking required. This is what most MCP gateways (bifrost,
# supergateway, mcp-proxy) actually want when proxying for many
# downstream callers — the gateway handles session continuity, and
# the upstream MCP just needs to answer one request at a time.
# Default ON for streamable-http; override with
# MCP_STATELESS_HTTP=false to keep stateful sessions.
stateless_env = os.environ.get('MCP_STATELESS_HTTP', '').lower()
if stateless_env in ('1', 'true', 'yes', ''):
mcp.settings.stateless_http = True
elif stateless_env in ('0', 'false', 'no'):
mcp.settings.stateless_http = False

# Transport-security (DNS-rebinding) config.
disable_dns_rebinding = os.environ.get(
'MCP_DISABLE_DNS_REBINDING_PROTECTION', ''
).lower() in ('1', 'true', 'yes')
allowed_hosts_env = os.environ.get('MCP_ALLOWED_HOSTS', '').strip()

if disable_dns_rebinding or allowed_hosts_env:
# Only import + construct when actually needed (older SDKs without
# the security layer raise ImportError, which is fine — those users
# are on the default-trusted-localhost path anyway).
from mcp.server.transport_security import TransportSecuritySettings

if disable_dns_rebinding:
tss = TransportSecuritySettings(
enable_dns_rebinding_protection=False,
)
else:
extra = [h.strip() for h in allowed_hosts_env.split(',') if h.strip()]
tss = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=extra,
)
# FastMCP threads transport_security through .settings only on very
# recent SDKs; on older ones we have to drop down a level and pass
# it directly to the async runner.
try:
mcp.settings.transport_security = tss # type: ignore[attr-defined]
mcp.run(transport=transport)
return
except (AttributeError, ValueError):
import anyio
if transport == 'streamable-http':
anyio.run(
lambda: mcp.run_streamable_http_async(
transport_security=tss,
)
)
else:
anyio.run(
lambda: mcp.run_sse_async(transport_security=tss)
)
return

mcp.run(transport=transport)


if __name__ == "__main__":
Expand Down
5 changes: 4 additions & 1 deletion mcp_python_interpreter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,4 +950,7 @@ def debug_python_error(code: str, error_message: str) -> str:

# Run the server
if __name__ == "__main__":
mcp.run(transport='stdio')
# Delegate transport selection to main.py so `python server.py` and the
# console-script entrypoint behave identically.
from mcp_python_interpreter.main import main
main()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
# streamable-http transport requires mcp>=1.8.0; FastMCP itself is
# re-exported from mcp.server.fastmcp so the import path is unchanged.
"mcp>=1.8.0",
"fastmcp>=2.0.0",
]

Expand Down