ARTICLE AD BOX
With all the hype around agentic AI, and in order to skill up on this topic and on developing inter-connected services, I tried to make a local MCP server to connect with a llama.cpp instance, accessed through WebUI, and run some tools.
After various development tribulations, I landed on the solution below which leverages Starlette on Uvicorn to expose the MCP server. It works well, llama.cpp manages to interface with the server, get the tool list, and a local model (Gemma 4) calls the tools when expected.
So what's my problem? Server-side exceptions are completely silent!
I've added an explicitly raised exception on line 53 to demonstrate. When refreshing the MCP server connection on llama.cpp, the tool list isn't loaded - which proves the exception was met on the execution path - but nothing is displayed on the terminal where the MCP server was launched. Log statements also don't show up once the program reaches the exception. WebUI also doesn't display more information regarding the connection, and in particular if ulterior communication included error messages.
(Note : I've removed as much irrelevant lines as possible, including exception handling and log statements, while still reproducing my problem.)
import contextlib import asyncio import json import logging import sys from mcp.server import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.types import Tool from starlette.applications import Starlette from starlette.routing import Mount from starlette.middleware.cors import CORSMiddleware import uvicorn from typing import Dict logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) def read_json_file(file_name): try: with open(file_name, "r") as fic: return json.load(fic) except FileNotFoundError as exc: raise except json.decoder.JSONDecodeError as exc: raise # Sample tool function def echo(message: str = "") -> Dict[str, str]: return {"message": f"~~ECHO~~: {message}"} class ToolsConf: def __init__(self, tools_file: str = "tools.json"): self.tools_file = tools_file self.tools = [] self.tools_data = {} def get_conf(self): self.tools_data = read_json_file(self.tools_file) self.tools = [Tool(**el) for el in self.tools_data] def get_tool_func(self, func_name): # Hardcode for example return echo class ToolExecutionService: def __init__(self, tools_conf: ToolsConf): self.tools_conf = tools_conf async def list_tools(self): raise Exception("this won't crash the server") # Toy exception self.tools_conf.get_conf() return self.tools_conf.tools async def call_tool(self, name, arguments): data_of_tool = None for el in self.tools_conf.tools_data: if el["name"] == name: data_of_tool = el break if not data_of_tool: raise ValueError(f"Unknown tool {name} in tools file {self.tools_conf.tools_file}") tool_to_call = self.tools_conf.get_tool_func(name) return tool_to_call(**arguments) class MCPServerASGIApp: def __init__(self, mcp_server_obj): self.session_manager = self.get_session_manager(mcp_server_obj) def get_session_manager(self, mcp_server_obj): return StreamableHTTPSessionManager( app=mcp_server_obj, json_response=True ) async def handle_streamable_http(self, scope, receive, send) -> None: await self.session_manager.handle_request(scope, receive, send) @contextlib.asynccontextmanager async def lifespan(self, app: Starlette): async with self.session_manager.run(): try: yield finally: logger.info("Application shutting down...") def make_asgi_app(self, mount_point): app = Starlette( debug=True, routes=[Mount(mount_point, app=self.handle_streamable_http)], lifespan=self.lifespan ) return app def add_cors_middleware(self, app, host): client_port = XXXX # Obfuscated allowed_origins = [f"http://127.0.0.1:{client_port}", f"http://localhost:{client_port}"] starlette_app = CORSMiddleware( app, allow_origins=allowed_origins, allow_methods=["GET", "POST"], expose_headers=["Mcp-Session-Id"], ) return starlette_app def build(self, mount_point, host): app = self.make_asgi_app(mount_point) return self.add_cors_middleware(app, host) class MyMCPServer(): def __init__(self, tools_exe_service: ToolExecutionService): self.server = Server("my-mcp-server") self.server.list_tools()(tools_exe_service.list_tools) self.server.call_tool()(tools_exe_service.call_tool) async def main(self, mount_point, host, port): asgi_app = MCPServerASGIApp(self.server) starlette_app = asgi_app.build(mount_point, host) config = uvicorn.Config(starlette_app, host=host, port=port, log_level="debug", access_log=True) server_instance = uvicorn.Server(config) await server_instance.serve() return 0 def entrypoint(): tools_conf = ToolsConf() tool_exe_service = ToolExecutionService(tools_conf) instance = MyMCPServer(tool_exe_service) handle = instance.main("/mcp", "127.0.0.1", YYYY) # Obfuscated port asyncio.run(handle) if __name__ == "__main__": entrypoint()Of course, removing the toy exception restores expected functionality to the server. But this is hindering further development, since I can't tell whether, when, or where an exception gets raised, or some other error occurred.
I've tried adding generic exception handling around every call that seemed to matter, but no visible results in the terminal where I launch the server. The only visibility I have is from PDB, which seems to point towards mcp.server.lowlevel.server._handle_message and mcp.server.lowlevel.server._handle_request that are just above in the stack trace, and also include a raise_exceptions flag. But I'm unsure how to access it, especially since I'm not directly running the MCP server through mcp package. I've combed through documentation but couldn't find the appropriate option to pass to switch raise_exceptions flag. Also looked through Starlette and Uvicorn documentation but honestly, I'm not even sure where to look to find a subtle "miracle" configuration option.
As for client-side, while WebUI is unhelpful, I tried using MCP inspector (Github) to directly connect to the server and get more information, and found that it received an error message : MCP error 0: this won't crash the server. I have no idea what "MCP error 0" means though, and online searches don't seem useful either. At least I can get some visibility with this external tool, but things would be much simpler if errors were visible directly from the server process.
I'm not too experienced in API development, so I might be missing something obvious, but anyway : How can I either catch and handle server exceptions, or at least get a full crash for debugging purposes?
P.S : Package versions in case it's relevant
mcp 1.27.1 starlette 0.41.3 uvicorn 0.46.0