Starlette+Uvicorn for MCP : silent exceptions on server-side, can't even crash debug the server
19:27 14 May 2026

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
python error-handling uvicorn starlette model-context-protocol