Skip to content

Commit dfc1f33

Browse files
authored
feat: add MCPServerManager for safely managing server lifecycle (#2350)
1 parent 3acfa82 commit dfc1f33

File tree

8 files changed

+1125
-1
lines changed

8 files changed

+1125
-1
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# MCP Manager Example (FastAPI)
2+
3+
This example shows how to use `MCPServerManager` to keep MCP server lifecycle
4+
management in a single task inside a FastAPI app with the Streamable HTTP
5+
transport.
6+
7+
## Run the MCP server (Streamable HTTP)
8+
9+
```
10+
uv run python examples/mcp/manager_example/mcp_server.py
11+
```
12+
13+
The server listens at `http://localhost:8000/mcp` by default.
14+
15+
You can override the host/port with:
16+
17+
```
18+
export STREAMABLE_HTTP_HOST=127.0.0.1
19+
export STREAMABLE_HTTP_PORT=8000
20+
```
21+
22+
This example also configures an inactive MCP server at
23+
`http://localhost:8001/mcp` to demonstrate how the manager drops failed
24+
servers. You can override it with:
25+
26+
```
27+
export INACTIVE_MCP_SERVER_URL=http://localhost:8001/mcp
28+
```
29+
30+
## Run the FastAPI app
31+
32+
```
33+
uv run python examples/mcp/manager_example/app.py
34+
```
35+
36+
The app listens at `http://127.0.0.1:9001`.
37+
38+
## Toggle MCP manager usage
39+
40+
By default, the app uses `MCPServerManager`. To disable it:
41+
42+
```
43+
export USE_MCP_MANAGER=0
44+
```
45+
46+
## Try the endpoints
47+
48+
```
49+
curl http://127.0.0.1:9001/health
50+
curl http://127.0.0.1:9001/tools
51+
curl -X POST http://127.0.0.1:9001/add \
52+
-H 'Content-Type: application/json' \
53+
-d '{"a": 2, "b": 3}'
54+
```
55+
56+
Reconnect failed MCP servers (manager must be enabled):
57+
58+
```
59+
curl -X POST http://127.0.0.1:9001/reconnect \
60+
-H 'Content-Type: application/json' \
61+
-d '{"failed_only": true}'
62+
```
63+
64+
To use `/run`, set `OPENAI_API_KEY`:
65+
66+
```
67+
export OPENAI_API_KEY=...
68+
curl -X POST http://127.0.0.1:9001/run \
69+
-H 'Content-Type: application/json' \
70+
-d '{"input": "Add 4 and 9."}'
71+
```
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import os
2+
from contextlib import asynccontextmanager
3+
4+
from fastapi import FastAPI, HTTPException
5+
from pydantic import BaseModel
6+
7+
from agents import Agent, Runner
8+
from agents.mcp import MCPServer, MCPServerManager, MCPServerStreamableHttp
9+
from agents.model_settings import ModelSettings
10+
11+
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp")
12+
INACTIVE_MCP_SERVER_URL = os.getenv("INACTIVE_MCP_SERVER_URL", "http://localhost:8001/mcp")
13+
APP_HOST = "127.0.0.1"
14+
APP_PORT = 9001
15+
USE_MCP_MANAGER = os.getenv("USE_MCP_MANAGER", "1") != "0"
16+
17+
18+
class AddRequest(BaseModel):
19+
a: int
20+
b: int
21+
22+
23+
class RunRequest(BaseModel):
24+
input: str
25+
26+
27+
class ReconnectRequest(BaseModel):
28+
failed_only: bool = True
29+
30+
31+
@asynccontextmanager
32+
async def lifespan(app: FastAPI):
33+
server = MCPServerStreamableHttp({"url": MCP_SERVER_URL})
34+
inactive_server = MCPServerStreamableHttp({"url": INACTIVE_MCP_SERVER_URL})
35+
servers = [server, inactive_server]
36+
if USE_MCP_MANAGER:
37+
async with MCPServerManager(
38+
servers=servers,
39+
connect_in_parallel=True,
40+
) as manager:
41+
app.state.mcp_manager = manager
42+
app.state.mcp_servers = servers
43+
yield
44+
return
45+
46+
await server.connect()
47+
app.state.mcp_servers = servers
48+
app.state.active_servers = [server]
49+
try:
50+
yield
51+
finally:
52+
await server.cleanup()
53+
54+
55+
app = FastAPI(lifespan=lifespan)
56+
57+
58+
@app.get("/health")
59+
async def health() -> dict[str, object]:
60+
if USE_MCP_MANAGER:
61+
manager: MCPServerManager = app.state.mcp_manager
62+
return {
63+
"connected_servers": [server.name for server in manager.active_servers],
64+
"failed_servers": [server.name for server in manager.failed_servers],
65+
}
66+
67+
active_servers = _get_active_servers()
68+
return {
69+
"connected_servers": [server.name for server in active_servers],
70+
"failed_servers": [],
71+
}
72+
73+
74+
@app.get("/tools")
75+
async def list_tools() -> dict[str, object]:
76+
active_servers = _get_active_servers()
77+
if not active_servers:
78+
return {"tools": []}
79+
tools = await active_servers[0].list_tools()
80+
return {"tools": [tool.name for tool in tools]}
81+
82+
83+
@app.post("/add")
84+
async def add(req: AddRequest) -> dict[str, object]:
85+
active_servers = _get_active_servers()
86+
if not active_servers:
87+
raise HTTPException(status_code=503, detail="No MCP servers available")
88+
result = await active_servers[0].call_tool("add", {"a": req.a, "b": req.b})
89+
return {"result": result.model_dump(mode="json")}
90+
91+
92+
@app.post("/run")
93+
async def run_agent(req: RunRequest) -> dict[str, object]:
94+
if not os.getenv("OPENAI_API_KEY"):
95+
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is required")
96+
97+
servers = _get_active_servers()
98+
if not servers:
99+
raise HTTPException(status_code=503, detail="No MCP servers available")
100+
101+
agent = Agent(
102+
name="FastAPI Agent",
103+
instructions="Use the MCP tools when needed.",
104+
mcp_servers=servers,
105+
model_settings=ModelSettings(tool_choice="auto"),
106+
)
107+
result = await Runner.run(starting_agent=agent, input=req.input)
108+
return {"output": result.final_output}
109+
110+
111+
@app.post("/reconnect")
112+
async def reconnect(req: ReconnectRequest) -> dict[str, object]:
113+
if not USE_MCP_MANAGER:
114+
raise HTTPException(status_code=400, detail="MCPServerManager is disabled")
115+
manager: MCPServerManager = app.state.mcp_manager
116+
servers = await manager.reconnect(failed_only=req.failed_only)
117+
return {"connected_servers": [server.name for server in servers]}
118+
119+
120+
def _get_active_servers() -> list[MCPServer]:
121+
if USE_MCP_MANAGER:
122+
manager: MCPServerManager = app.state.mcp_manager
123+
return list(manager.active_servers)
124+
return list(app.state.active_servers)
125+
126+
127+
if __name__ == "__main__":
128+
import uvicorn
129+
130+
uvicorn.run(app, host=APP_HOST, port=APP_PORT)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import os
2+
3+
from mcp.server.fastmcp import FastMCP
4+
5+
STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1")
6+
STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "8000"))
7+
8+
mcp = FastMCP(
9+
"FastAPI Example Server",
10+
host=STREAMABLE_HTTP_HOST,
11+
port=STREAMABLE_HTTP_PORT,
12+
)
13+
14+
15+
@mcp.tool()
16+
def add(a: int, b: int) -> int:
17+
return a + b
18+
19+
20+
@mcp.tool()
21+
def echo(message: str) -> str:
22+
return f"echo: {message}"
23+
24+
25+
if __name__ == "__main__":
26+
mcp.run(transport="streamable-http")

src/agents/agent.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ class AgentBase(Generic[TContext]):
118118
119119
NOTE: You are expected to manage the lifecycle of these servers. Specifically, you must call
120120
`server.connect()` before passing it to the agent, and `server.cleanup()` when the server is no
121-
longer needed.
121+
longer needed. Consider using `MCPServerManager` from `agents.mcp` to keep connect/cleanup
122+
in the same task.
122123
"""
123124

124125
mcp_config: MCPConfig = field(default_factory=lambda: MCPConfig())

src/agents/mcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
try:
2+
from .manager import MCPServerManager
23
from .server import (
34
MCPServer,
45
MCPServerSse,
@@ -28,6 +29,7 @@
2829
"MCPServerStdioParams",
2930
"MCPServerStreamableHttp",
3031
"MCPServerStreamableHttpParams",
32+
"MCPServerManager",
3133
"MCPUtil",
3234
"ToolFilter",
3335
"ToolFilterCallable",

0 commit comments

Comments
 (0)