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: 12 additions & 0 deletions misc/repl-http-mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv
.env
.python-version
86 changes: 86 additions & 0 deletions misc/repl-http-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Modal REPL MCP Integration - HTTP Server


This example shows how to deploy an HTTP server that multiple Modal REPL MCP clients can connect to, with additional auto-stop/snapshot functionality of the backend sandboxes.

## Setup

In your root directory, run
```bash
git clone https://github.com/modal-labs/modal-examples.git
cd modal-examples/misc/repl-http-mcp
uv sync
touch .env
touch ~/.modal_repl_id.txt
```

Then, open the `.env` file and paste in the following line:
```txt
REPL_ID_FILE=~/.modal_repl_id.txt
```


Be sure to setup your Modal account if you haven't yet with `modal setup`.

If you do not have [Claude Desktop](https://claude.ai/download) installed, please install it.

Create the file `~/Library/Application Support/Claude/claude_desktop_config.json` if it does not yet exist.

Add the following MCP server to the `claude_desktop_config.json` file:


```json
...
"modalPythonREPLOverHTTP": {
"command": "uv",
"args": [
"--directory",
"{YourUserRootDirectory}/modal-examples/misc/repl-http-mcp",
"run",
"main.py"
]
}
...

```

For more information on how to configure this file, see the [MCP Docs](https://modelcontextprotocol.info/docs/quickstart/user/).



## Deploying the HTTP Server
In the directory of the cloned repository, run `modal deploy http_server.py`. Then, go to your Modal web dashboard and copy the URL of your newly deployed app. Then, in your `.env` file, paste the following line:

```txt
HTTP_SERVER_URL={YOUR_APP_URL}
```

This ensures the MCP server knows where to send requests.


## Using the MCP

Open your Claude desktop app and ensure `modalPythonREPLOverHTTP` is toggled on in the "Search and Tools menu.

Claude will have access to just a single tool, `exec_cmd`. The HTTP server handles all idle timeout and snapshot restore functionality for you, and it also spins up a new REPL for you upon opening Claude Desktop if you have not done so yet. Your REPL id is stored in `~/.modal_repl_id.txt`, which is also the `REPL_ID_FILE` environment variable. Note that if you redeploy the HTTP server you will need to clear this file before using it again.





Go ahead and try prompting Claude, which now has access to the Modal REPL! You can check your Modal account dashboard to track when the REPL's backend sandboxes are active or terminated.

Here's an example snippet of a response on an analysis of airline safety performance on a public dataset:

![Example Response](./README_utils/example_response.png).


In the Modal Repl, Claude was able to execute a series of complex steps leveraging both the `pandas` and `requests` libraries to analyze airline incident rates.


Here is another example of a response of Claude using the REPL environment with `pandas` and `requests` to do a keyword analysis on shopping sites.

![Example Response 2](./README_utils/shopping_keyword.png).


Note that our HTTP server is built to handle multiple MCP clients, so you can use the same app URL across multiple devices after you deploy it just once. For all of your clients, the server will support an automated idle timeout/snapshot policy so that your sandboxes aren't running if you aren't actively using as the backend of a REPL via Claude desktop.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
162 changes: 162 additions & 0 deletions misc/repl-http-mcp/http_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import asyncio
import logging
import os
from typing import List, Optional

import dotenv
import fastapi
import modal
from fastapi import HTTPException, status
from modal import App, Dict, Image, Secret
from pydantic import BaseModel

dotenv.load_dotenv()


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class ReplMCPCreateRequest(BaseModel):
python_version: str = "3.13"
packages: List[str] = []
port: int = 8000
timeout: float = 1 # num of minutes to keep repl alive


class ReplMCPCreateResponse(BaseModel):
repl_id: str


class ReplMCPExecRequest(BaseModel):
repl_id: str
command: str


class ReplMCPExecResponse(BaseModel):
output: Optional[str]
stdout: Optional[str]
error: Optional[str]


image = Image.debian_slim(python_version="3.13").pip_install(
["fastapi", "pydantic", "uvicorn", "modal", "dotenv", "httpx"]
)
image = image.add_local_file(
local_path=os.path.join(os.path.dirname(__file__), "repl.py"),
remote_path="/root/repl.py",
)
image = image.add_local_file(
local_path=os.path.join(os.path.dirname(__file__), "repl_server.py"),
remote_path="/root/repl_server.py",
)


app = App("repl-http-mcp", image=image)


@app.function(
image=image, secrets=[Secret.from_dotenv()]
) # set .env files in modal func, so can call sandboxes from a function
@modal.asgi_app()
def fastapi_app():
from repl import Repl, ReplMCPExecResponse

# state dicts for auto-stop functionality
aliveRepls: Dict[str, Repl] = Dict.from_name("aliveRepls", create_if_missing=True)
replTimeouts: Dict[str, int] = Dict.from_name(
"replTimeouts", create_if_missing=True
)
replKillTimers: Dict[
str, asyncio.TimerHandle
] = {} # TimerHandle not cloudpickle-able, but does not need to be stored in cloud
replSnapshots: Dict[str, str] = Dict.from_name(
"replSnapshots", create_if_missing=True
)

web_app = fastapi.FastAPI()

# create repl endpoint
@web_app.post("/create_repl", status_code=status.HTTP_201_CREATED)
async def create_repl(request: ReplMCPCreateRequest) -> ReplMCPCreateResponse:
try:
request.packages.extend(["fastapi", "pydantic", "uvicorn"])
repl = await Repl.create(
request.python_version, request.port, request.packages
)
aliveRepls[repl.id] = repl
replTimeouts[repl.id] = request.timeout
reset_repl_timer(repl.id)
logger.info(
f"Repl {repl.id} created with timeout of {replTimeouts[repl.id]} seconds"
)
return ReplMCPCreateResponse(repl_id=repl.id)
except Exception as e:
logger.error(f"Error creating repl: {repr(e)}")
raise HTTPException(status_code=500, detail=repr(e))

# exec command web endpoint. This is the only endpoint the user MCP has explicit access to.
@web_app.post("/exec", status_code=status.HTTP_200_OK)
async def exec_cmd(request: ReplMCPExecRequest) -> ReplMCPExecResponse:
try:
repl = await get_repl(request.repl_id)
commands = Repl.parse_command(request.command)
except ValueError:
logger.error(f"Repl {request.repl_id} not found")
raise HTTPException(
status_code=400, detail=f"Repl {request.repl_id} not found"
)
try:
response = await repl.run(commands)
reset_repl_timer(repl.id)
return response
except HTTPException as e:
logger.error(
f"Error executing command {request.command} for repl {repl.id}: {e}"
)
raise HTTPException(status_code=e.status_code, detail=e.detail)

# Gets REPL if alive or restores from snapshot if not.
async def get_repl(repl_id: str) -> Repl:
if repl_id in aliveRepls:
logger.info(f"Repl {repl_id} found in aliveRepls")
return aliveRepls[repl_id]
elif repl_id in replSnapshots:
logger.info(f"Recreating repl {repl_id} from snapshot")
repl = await Repl.from_snapshot(replSnapshots[repl_id], repl_id)
aliveRepls[repl.id] = repl
del replSnapshots[repl_id]
return repl
logger.error(f"Repl {repl_id} not found")
raise ValueError(f"Repl {repl_id} not found")

# Terminates REPL and saves snapshot id to be restored later.
def terminate_repl(repl_id: str) -> None:
try:
logger.info(f"Terminating repl {repl_id}")
if repl_id in replKillTimers:
replKillTimers[repl_id].cancel()
replKillTimers.pop(repl_id, None)
if repl_id not in aliveRepls:
return
repl = aliveRepls[repl_id]
snapshot_id = repl.kill()
replSnapshots[repl_id] = snapshot_id
del aliveRepls[repl_id]
logger.info(f"Repl {repl_id} terminated")
except KeyError as e:
logger.error(f"KeyError {repr(e)} for repl {repl_id}")
except Exception as e:
logger.error(f"Exception {repr(e)} for repl {repl_id}")

# Resets the repl TTL timer. Called during an exec command or repl creation.
def reset_repl_timer(repl_id: str) -> None:
if repl_id in replTimeouts:
if repl_id in replKillTimers:
replKillTimers[repl_id].cancel()
loop = asyncio.get_running_loop()
replKillTimers[repl_id] = loop.call_later(
replTimeouts[repl_id], terminate_repl, repl_id
)

return web_app
71 changes: 71 additions & 0 deletions misc/repl-http-mcp/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import asyncio
import os
from typing import List, Optional

import dotenv
import httpx
from mcp.server.fastmcp import FastMCP
from repl import ReplMCPExecResponse

dotenv.load_dotenv()

sessionRepl: Optional[str] = None
server_url = os.getenv("HTTP_SERVER_URL")
repl_id_file = os.getenv("REPL_ID_FILE")


mcp = FastMCP("modalrepl")


# repl creation. called upon first use of MCP.
async def create_repl(timeout: int = 30, packages: List[str] = []) -> None:
# default timeout is 30s
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{server_url}/create_repl",
json={"timeout": timeout, "packages": packages},
)
print(response.json())
repl_id = response.json()["repl_id"]
global sessionRepl
sessionRepl = repl_id
except Exception as exc:
print(exc)
raise RuntimeError(
f"HTTP error creating REPL. Your REPL may have timed out. {exc}"
)


# executes arbitrary code in repl. this is the only tool call accessible.
@mcp.tool()
async def exec_cmd(command: str) -> ReplMCPExecResponse:
try:
if sessionRepl is None:
raise RuntimeError("REPL not created")
async with httpx.AsyncClient() as client:
response = await client.post(
f"{server_url}/exec", json={"repl_id": sessionRepl, "command": command}
)
return response.json()
except Exception as exc:
raise RuntimeError(f"HTTP error executing command: {exc}")


# start_session will check the dotfile for a persisted repl id. if none, it will create a new repl.
def start_session() -> None:
repl_id_file_path = os.path.expanduser(repl_id_file)
with open(repl_id_file_path, "r") as f: # check for persisted repl id
repl_id = f.read()
if repl_id:
global sessionRepl
sessionRepl = repl_id
else:
asyncio.run(create_repl())
with open(repl_id_file_path, "w") as f:
f.write(sessionRepl)


if __name__ == "__main__":
start_session()
mcp.run()
14 changes: 14 additions & 0 deletions misc/repl-http-mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "repl-http-mcp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"asyncio>=4.0.0",
"fastapi>=0.116.2",
"mcp[cli]>=1.14.1",
"modal>=1.1.4",
"pydantic>=2.11.9",
"uvicorn>=0.35.0",
]
Loading