From 6d0baa9e2f77e7afa3dede35d7d223cd04a3b5f3 Mon Sep 17 00:00:00 2001 From: Richard Lawrence Date: Wed, 19 Mar 2025 11:16:06 -0500 Subject: [PATCH 1/3] Created a separate ReplProcess class. Now handles user input. Fixed some other small bugs. --- src/mcp_python/repl.py | 176 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/mcp_python/repl.py diff --git a/src/mcp_python/repl.py b/src/mcp_python/repl.py new file mode 100644 index 0000000..e78fbd5 --- /dev/null +++ b/src/mcp_python/repl.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""background terminal test.ipynb + +Automatically generated by Colab. + +Original file is located at + https://colab.research.google.com/drive/1O783m0JVGj3cWQBfQyeS7yuxQrreC3EF +""" + +import time +import subprocess +import os +import pty + +class ReplProcess: + def __init__(self): + self.log('init called') + self.command = ['python'] + self.paused_for_input = False + self.shutting_down = False + + # Open pseudo-terminal pairs for communication + self.code_master, self.code_slave = pty.openpty() + self.input_master, self.input_slave = pty.openpty() + self.output_master, self.output_slave = pty.openpty() + self.error_master, self.error_slave = pty.openpty() + + # Spawn the subprocess with the pseudo-terminals + self.process = subprocess.Popen( + self.command, + pass_fds=[self.input_slave], + stdin=self.code_slave, + stdout=self.output_slave, + stderr=self.error_slave, + close_fds=True, + text=True + ) + + # Get file descriptors for stdin, stdout, and stderr + self.stdin_fd = os.fdopen(self.code_master, 'w') + self.input_fd = os.fdopen(self.input_master, 'w') + self.stdout_fd = os.fdopen(self.output_master, 'r') + self.stderr_fd = os.fdopen(self.error_master, 'r') + + self.count = 0 + self.log(f"__START__STREAM__ [{self.count}]") + self.stdin_fd.write( + """ +#define important functions +import json as _json +def names(): + return _json.dumps([item for item in globals() if not item.startswith("_")], indent=None) + +import sys as _sys +def _eprint(*args, **kwargs): + print(*args, file=_sys.stderr, **kwargs) + +def _oeprint(*args, **kwargs): + print(*args, flush=True, **kwargs) + _eprint(*args, flush=True, **kwargs) + +#set input fd +import os as _os +""" + + f"_input_fd=_os.fdopen({self.input_slave}, 'r')" + + """ +def input(prompt): + global _input_fd + print(prompt.strip()) + _oeprint("__PAUSE"+"_FOR"+"_INPUT__") + return _input_fd.readline().rstrip("\\n") + +def exit(*args, **kwargs): + _oeprint("__SHUT"+"DOWN__") + _sys.exit(*args, **kwargs) + +""" + ) + self.stdin_fd.write(f'_oeprint("__END"+"_OF"+"_STREAM__ [{self.count}]") #__SILENT__\n') + self.stdin_fd.flush() + + # Wait for the initialization to complete + time.sleep(0.1) + result = self._read_output() + result = result + self._read_error() + print("Ready.") + + def get_names(self): + self.count += 1 + self.log(f"__START__STREAM__ [{self.count}]") + self.stdin_fd.write(f'print(names()) #__SILENT__\n') + self.stdin_fd.write(f'_oeprint("__END"+"_OF"+"_STREAM__ [{self.count}]") #__SILENT__\n') + self.stdin_fd.flush() + output, errors = self._read_output(), self._read_error() + return output.strip(), errors + + def uninit(self): + self.log(f"uninit() called") + self.shutting_down = True + # Tell process to shut itself down + self.stdin_fd.write(f'exit()\n') + self.stdin_fd.flush() + self._read_output() + + # Close the file descriptors + self.stdin_fd.close() + self.stdout_fd.close() + self.stderr_fd.close() + self.input_fd.close() + + # Wait for the process to complete + self.process.communicate() + + def send_code(self, code): + self.count += 1 + self.log(f"__START__STREAM__ [{self.count}]") + self.stdin_fd.write(code.replace("\\n", "\n") + '\n') + self.stdin_fd.write(f'\n_oeprint("__END"+"_OF"+"_STREAM__ [{self.count}]") #__SILENT__\n') + self.stdin_fd.flush() + return self._read_output().strip(), self._read_error().strip() + + def send_input(self, message): + self.log(message) + self.input_fd.write(message + "\n") + self.input_fd.flush() + return self._read_output().strip(), self._read_error().strip() + + def _read_stream(self, fd): + output = fd.readline() + self.log(output, end="") + result = "" + while True: + if "__END_OF_STREAM__" in output: + break + if "__PAUSE_FOR_INPUT__" in output: + self.paused_for_input = True + break + elif "__SHUTDOWN__" in output: + self.shutting_down = True + break + elif "__SILENT__" in output: + pass + elif output.strip() != ">>>": + result += output + output = fd.readline() + self.log(output, end="") + return result + + def _read_output(self): + return self._read_stream(self.stdout_fd) + + def _read_error(self): + return self._read_stream(self.stderr_fd) + + def log(self, *args, **kwargs): + """Log messages to a file.""" + with open("stream.log", "a") as log_file: + print(*args, file=log_file, **kwargs) + +# Example usage +if __name__ == "__main__": + repl = ReplProcess() + try: + while not repl.shutting_down: + if repl.paused_for_input: + message = input("Input required: ") + repl.paused_for_input = False + out, err = repl.send_input(message) + print(out, err) + else: + code = input("Code required:") + out, err = repl.send_code(code) + print(out, err) + except KeyboardInterrupt: + repl.uninit() + print("Successfully shut down") From 0bc1dbd8f3a3f6baf43da4452aba3930819e8ad2 Mon Sep 17 00:00:00 2001 From: Richard Lawrence Date: Wed, 19 Mar 2025 19:45:24 -0500 Subject: [PATCH 2/3] Integrated ReplProcess into server.py --- src/mcp_python/repl.py | 9 -- src/mcp_python/server.py | 176 +++++++++++++++++++++++++-------------- 2 files changed, 114 insertions(+), 71 deletions(-) diff --git a/src/mcp_python/repl.py b/src/mcp_python/repl.py index e78fbd5..3081896 100644 --- a/src/mcp_python/repl.py +++ b/src/mcp_python/repl.py @@ -1,12 +1,3 @@ -# -*- coding: utf-8 -*- -"""background terminal test.ipynb - -Automatically generated by Colab. - -Original file is located at - https://colab.research.google.com/drive/1O783m0JVGj3cWQBfQyeS7yuxQrreC3EF -""" - import time import subprocess import os diff --git a/src/mcp_python/server.py b/src/mcp_python/server.py index 3ea94b5..45adde7 100644 --- a/src/mcp_python/server.py +++ b/src/mcp_python/server.py @@ -2,21 +2,23 @@ import io import subprocess import re -from contextlib import redirect_stdout, redirect_stderr import traceback from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions import mcp.server.stdio import mcp.types as types +from mcp_python.repl import ReplProcess +import json class PythonREPLServer: def __init__(self): self.server = Server("python-repl") - # Shared namespace for all executions + # Shared namespace for all executions # probably delete this later self.global_namespace = { "__builtins__": __builtins__, } - + # Start repl process + self.repl = ReplProcess() # Set up handlers using decorators @self.server.list_tools() async def handle_list_tools() -> list[types.Tool]: @@ -25,13 +27,14 @@ async def handle_list_tools() -> list[types.Tool]: @self.server.call_tool() async def handle_call_tool(name: str, arguments: dict | None) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: return await self.handle_call_tool(name, arguments) - + + async def handle_list_tools(self) -> list[types.Tool]: """List available tools""" return [ types.Tool( name="execute_python", - description="Execute Python code and return the output. Variables persist between executions.", + description="Execute Python code and return the output. Variables persist between executions, allowing you to build upon previous commands.", inputSchema={ "type": "object", "properties": { @@ -48,9 +51,23 @@ async def handle_list_tools(self) -> list[types.Tool]: "required": ["code"], }, ), + types.Tool( + name="user_input", + description="Send a message to a program that is waiting for user input and return the output. Variables persist between executions", + inputSchema={ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to send to the program as user input", + } + }, + "required": ["message"], + }, + ), types.Tool( name="list_variables", - description="List all variables in the current session", + description="List all variables in the current session.", inputSchema={ "type": "object", "properties": {}, @@ -58,7 +75,7 @@ async def handle_list_tools(self) -> list[types.Tool]: ), types.Tool( name="install_package", - description="Install a Python package using uv", + description="Install a Python package using uv. This package will then be immediately available for import in the REPL.", inputSchema={ "type": "object", "properties": { @@ -76,69 +93,92 @@ async def handle_call_tool( self, name: str, arguments: dict | None ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: """Handle tool execution requests""" - if not arguments: - raise ValueError("Missing arguments") + tool = next((t for t in await self.handle_list_tools() if t.name == name), None) + if not tool: + raise ValueError(f"Unknown tool: {name}") + + if tool.inputSchema.get("required") and not arguments: + raise ValueError("Missing required arguments") + results = {} if name == "execute_python": + if self.repl.paused_for_input: + return [ + types.TextContent( + type="text", + text="Error sending code. The REPL is currently waiting for user input." + ) + ] + code = arguments.get("code") if not code: raise ValueError("Missing code parameter") # Check if we should reset the session if arguments.get("reset", False): - self.global_namespace.clear() - self.global_namespace["__builtins__"] = __builtins__ + self.repl.uninit() + self.repl.__init__() + results["reset"]=True + + try: + # Execute code in REPL + output, errors = self.repl.send_code(code) + + except Exception as e: # noqa: F841 + # Capture and format any exceptions + error_msg = f"Error executing code:\n{traceback.format_exc()}" return [ types.TextContent( type="text", - text="Python session reset. All variables cleared." + text=error_msg ) ] - - # Capture stdout and stderr - stdout = io.StringIO() - stderr = io.StringIO() - - try: - # Execute code with output redirection - with redirect_stdout(stdout), redirect_stderr(stderr): - exec(code, self.global_namespace) - - # Combine output - output = stdout.getvalue() - errors = stderr.getvalue() - - # Format response - result = "" - if output: - result += f"Output:\n{output}" - if errors: - result += f"\nErrors:\n{errors}" - if not output and not errors: - # Try to get the value of the last expression - try: - last_line = code.strip().split('\n')[-1] - last_value = eval(last_line, self.global_namespace) - result = f"Result: {repr(last_value)}" - except (SyntaxError, ValueError, NameError): - result = "Code executed successfully (no output)" - + elif name == "user_input": + if not self.repl.paused_for_input: return [ types.TextContent( type="text", - text=result + text="Error sending message. The REPL is not currently waiting for user input." ) ] + + message = arguments.get("message") + if not message: + raise ValueError("Missing message parameter") + try: + # Execute code in REPL + self.repl.paused_for_input = False + output, errors = self.repl.send_input(message) except Exception as e: # noqa: F841 # Capture and format any exceptions - error_msg = f"Error executing code:\n{traceback.format_exc()}" + error_msg = f"Error sending message:\n{traceback.format_exc()}" return [ types.TextContent( type="text", text=error_msg ) ] + + if name == "user_input" or name == "execute_python": + # Format response + if output: + results["output"]=output.split("\n") + if errors: + results["errors"]=errors.split("\n") + if self.repl.paused_for_input: + results["status"]="Waiting for user input." + if self.repl.shutting_down: + results["status"]="Successfully shut down (requires restart)." + if not results: + results["status"]="Code executed successfully (no output)." + result=json.dumps(results, indent=2) + return [ + types.TextContent( + type="text", + text=result + ) + ] elif name == "install_package": package = arguments.get("package") @@ -197,32 +237,44 @@ async def handle_call_tool( ] elif name == "list_variables": - # Filter out builtins and private variables - vars_dict = { - k: repr(v) for k, v in self.global_namespace.items() - if not k.startswith('_') and k != '__builtins__' - } - - if not vars_dict: + if self.repl.paused_for_input: return [ types.TextContent( type="text", - text="No variables in current session." + text="Error: Cannot check variabled because the REPL is waiting for user input." + ) + ] + try: + output, errors = self.repl.get_names() + vars_list=json.loads(output) + if not vars_list: + return [ + types.TextContent( + type="text", + text="No variables in current session." + ) + ] + + # Format variables list + vars_string = json.dumps({"Current session variables": vars_list}, indent=0) + return [ + types.TextContent( + type="text", + text=vars_string + ) + ] + except Exception as e: # noqa: F841 + # Capture and format any exceptions + error_msg = f"Error checking variables:\n{traceback.format_exc()}" + return [ + types.TextContent( + type="text", + text=error_msg ) ] - - # Format variables list - var_list = "\n".join(f"{k} = {v}" for k, v in vars_dict.items()) - return [ - types.TextContent( - type="text", - text=f"Current session variables:\n\n{var_list}" - ) - ] - else: raise ValueError(f"Unknown tool: {name}") - + async def run(self): """Run the server""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): From ae8802fa96f423cf4e9ca60d08a1c80fe47ddf9c Mon Sep 17 00:00:00 2001 From: Richard Lawrence Date: Wed, 19 Mar 2025 19:46:10 -0500 Subject: [PATCH 3/3] Added examples as resources. --- src/mcp_python/examples/input.md | 54 ++++++++++++++++++++ src/mcp_python/examples/variables.md | 73 ++++++++++++++++++++++++++++ src/mcp_python/server.py | 45 +++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/mcp_python/examples/input.md create mode 100644 src/mcp_python/examples/variables.md diff --git a/src/mcp_python/examples/input.md b/src/mcp_python/examples/input.md new file mode 100644 index 0000000..de23434 --- /dev/null +++ b/src/mcp_python/examples/input.md @@ -0,0 +1,54 @@ +## Demonstration of `input()` Function in Python + +### Step 1: Execute Python Code + +An AI agent wants to use a tool on the python-repl MCP server: + +**Tool:** `execute_python` +**Description:** Execute Python code and return the output. Variables persist between executions. + +**Arguments:** +```json +{ + "code": "while True:\n text=input(\"break?\")\n if text==\"break\":\n break" +} +``` + +**Response:** +```json +{ + "output": [ + ">>> while True:", + "... text=input(\"break?\")", + "... if text==\"break\":", + "... break", + "... ", + "break?" + ], + "status": "Waiting for user input." +} +``` + +The Python REPL process is now paused for user input. It can't execute any more code until some user input is provided. + +### Step 2: Send User Input + +An AI agent wants to use a tool on the python-repl MCP server: + +**Tool:** `user_input` +**Description:** Send a message to program requesting user input and return the output. Variables persist between executions. + +**Arguments:** +```json +{ + "message": "break" +} +``` + +**Response:** +```json +{ + "status": "Code executed successfully (no output)." +} + +The AI agent decided to stop the loop after 1 iteration. \ No newline at end of file diff --git a/src/mcp_python/examples/variables.md b/src/mcp_python/examples/variables.md new file mode 100644 index 0000000..51afc19 --- /dev/null +++ b/src/mcp_python/examples/variables.md @@ -0,0 +1,73 @@ +## Demonstration of Variables in Python + +### Step 1: Execute Python Code + +An AI agent wants to use a tool on the python-repl MCP server: + +**Tool:** `execute_python` +**Description:** Execute Python code and return the output. Variables persist between executions. + +**Arguments:** +```json +{ + "code": "my_variable = \"Hello, world!\"" +} +``` + +**Response:** +```json +{ + "output": [ + ">>> my_variable = \"Hello, world!\"" + ] +} +``` + +The variable `my_variable` has been created. + +### Step 2: List Variables + +An AI agent wants to use a tool on the python-repl MCP server: + +**Tool:** `list_variables` +**Description:** List all variables in the current session. + +**Response:** +```json +{ + "Current session variables": [ + "names", + "input", + "exit", + "my_variable" + ] +} +``` + +The variables in the current session are `names`, `input`, `exit`, and `my_variable`. The `names()` function is the one that is used internally to generate the list of names. `my_variable` is the one the AI agent created. + +### Step 3: Execute Python Code + +An AI agent wants to use a tool on the python-repl MCP server: + +**Tool:** `execute_python` +**Description:** Execute Python code and return the output. Variables persist between executions. + +**Arguments:** +```json +{ + "code": "print(my_variable)" +} +``` + +**Response:** +```json +{ + "output": [ + ">>> print(my_variable)", + "Hello, world!" + ] +} +``` + +The variable `my_variable` still has its value the AI agent set earlier. \ No newline at end of file diff --git a/src/mcp_python/server.py b/src/mcp_python/server.py index 45adde7..e49b5d9 100644 --- a/src/mcp_python/server.py +++ b/src/mcp_python/server.py @@ -9,6 +9,20 @@ import mcp.types as types from mcp_python.repl import ReplProcess import json +from pydantic import AnyUrl +from pathlib import Path + + +fixed_resources = { + "examples/input.md": { + "name": "Input Example", + "description": "An example of sending user input to a REPL session.", + }, + "examples/variables.md": { + "name": "Variables Example", + "description": "An example of using peristent variables in a Python REPL session.", + } +} class PythonREPLServer: def __init__(self): @@ -28,6 +42,13 @@ async def handle_list_tools() -> list[types.Tool]: async def handle_call_tool(name: str, arguments: dict | None) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: return await self.handle_call_tool(name, arguments) + @self.server.list_resources() + async def handle_list_resources() -> list[types.Resource]: + return await self.handle_list_resources() + + @self.server.read_resource() + async def handle_read_resource(uri: AnyUrl) -> str: + return await self.handle_read_resource(uri) async def handle_list_tools(self) -> list[types.Tool]: """List available tools""" @@ -274,6 +295,30 @@ async def handle_call_tool( ] else: raise ValueError(f"Unknown tool: {name}") + + async def handle_list_resources(self) -> list[types.Resource]: + return [ + types.Resource( + uri=AnyUrl(f"file://{key}"), + name=fixed_resources[key]["name"], + description=fixed_resources[key]["description"], + mimeType="text/plain", + ) + for key in fixed_resources.keys() + ] + + async def handle_read_resource(self, uri: AnyUrl) -> str: + if not uri: + raise ValueError("Missing resource_name parameter") + if uri.scheme != "file": + raise ValueError(f"Unsupported URI scheme: {uri.scheme}") + resource_name = str(uri).replace("file://", "") + if resource_name in list(fixed_resources.keys()): + file_path = Path(__file__).resolve().parent / resource_name + with open(file_path, "r") as file: + return file.read() + else: + return "Resource not found." async def run(self): """Run the server"""