chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# MCP Manager Example (FastAPI)
|
||||
|
||||
This example shows how to use `MCPServerManager` to keep MCP server lifecycle management in a single task inside a FastAPI app with the Streamable HTTP transport.
|
||||
|
||||
## Run the MCP server (Streamable HTTP)
|
||||
|
||||
```
|
||||
uv run python examples/mcp/manager_example/mcp_server.py
|
||||
```
|
||||
|
||||
The server listens at `http://localhost:8000/mcp` by default.
|
||||
|
||||
You can override the host/port with:
|
||||
|
||||
```
|
||||
export STREAMABLE_HTTP_HOST=127.0.0.1
|
||||
export STREAMABLE_HTTP_PORT=8000
|
||||
```
|
||||
|
||||
This example also configures an inactive MCP server at `http://localhost:8001/mcp` to demonstrate how the manager drops failed
|
||||
servers. You can override it with:
|
||||
|
||||
```
|
||||
export INACTIVE_MCP_SERVER_URL=http://localhost:8001/mcp
|
||||
```
|
||||
|
||||
## Run the FastAPI app
|
||||
|
||||
```
|
||||
uv run python examples/mcp/manager_example/app.py
|
||||
```
|
||||
|
||||
The app listens at `http://127.0.0.1:9001`.
|
||||
|
||||
## Run the smoke test
|
||||
|
||||
To verify the MCP manager and app integration without calling a model:
|
||||
|
||||
```
|
||||
uv run python -m examples.mcp.manager_example.smoke_test
|
||||
```
|
||||
|
||||
The smoke test starts the local MCP server on a temporary port, points both app MCP server settings at that server, and checks `/health`, `/tools`, and `/add`.
|
||||
|
||||
## Toggle MCP manager usage
|
||||
|
||||
By default, the app uses `MCPServerManager`. To disable it:
|
||||
|
||||
```
|
||||
export USE_MCP_MANAGER=0
|
||||
```
|
||||
|
||||
## Try the endpoints
|
||||
|
||||
```
|
||||
curl http://127.0.0.1:9001/health
|
||||
curl http://127.0.0.1:9001/tools
|
||||
curl -X POST http://127.0.0.1:9001/add \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"a": 2, "b": 3}'
|
||||
```
|
||||
|
||||
Reconnect failed MCP servers (manager must be enabled):
|
||||
|
||||
```
|
||||
curl -X POST http://127.0.0.1:9001/reconnect \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"failed_only": true}'
|
||||
```
|
||||
|
||||
To use `/run`, set `OPENAI_API_KEY`:
|
||||
|
||||
```
|
||||
export OPENAI_API_KEY=...
|
||||
curl -X POST http://127.0.0.1:9001/run \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input": "Add 4 and 9."}'
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import Agent, Runner
|
||||
from agents.mcp import MCPServer, MCPServerManager, MCPServerStreamableHttp
|
||||
from agents.model_settings import ModelSettings
|
||||
|
||||
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp")
|
||||
INACTIVE_MCP_SERVER_URL = os.getenv("INACTIVE_MCP_SERVER_URL", "http://localhost:8001/mcp")
|
||||
APP_HOST = "127.0.0.1"
|
||||
APP_PORT = 9001
|
||||
USE_MCP_MANAGER = os.getenv("USE_MCP_MANAGER", "1") != "0"
|
||||
|
||||
|
||||
class AddRequest(BaseModel):
|
||||
a: int
|
||||
b: int
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
input: str
|
||||
|
||||
|
||||
class ReconnectRequest(BaseModel):
|
||||
failed_only: bool = True
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
server = MCPServerStreamableHttp({"url": MCP_SERVER_URL})
|
||||
inactive_server = MCPServerStreamableHttp({"url": INACTIVE_MCP_SERVER_URL})
|
||||
servers = [server, inactive_server]
|
||||
if USE_MCP_MANAGER:
|
||||
async with MCPServerManager(
|
||||
servers=servers,
|
||||
connect_in_parallel=True,
|
||||
) as manager:
|
||||
app.state.mcp_manager = manager
|
||||
app.state.mcp_servers = servers
|
||||
yield
|
||||
return
|
||||
|
||||
await server.connect()
|
||||
app.state.mcp_servers = servers
|
||||
app.state.active_servers = [server]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await server.cleanup()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, object]:
|
||||
if USE_MCP_MANAGER:
|
||||
manager: MCPServerManager = app.state.mcp_manager
|
||||
return {
|
||||
"connected_servers": [server.name for server in manager.active_servers],
|
||||
"failed_servers": [server.name for server in manager.failed_servers],
|
||||
}
|
||||
|
||||
active_servers = _get_active_servers()
|
||||
return {
|
||||
"connected_servers": [server.name for server in active_servers],
|
||||
"failed_servers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/tools")
|
||||
async def list_tools() -> dict[str, object]:
|
||||
active_servers = _get_active_servers()
|
||||
if not active_servers:
|
||||
return {"tools": []}
|
||||
tools = await active_servers[0].list_tools()
|
||||
return {"tools": [tool.name for tool in tools]}
|
||||
|
||||
|
||||
@app.post("/add")
|
||||
async def add(req: AddRequest) -> dict[str, object]:
|
||||
active_servers = _get_active_servers()
|
||||
if not active_servers:
|
||||
raise HTTPException(status_code=503, detail="No MCP servers available")
|
||||
result = await active_servers[0].call_tool("add", {"a": req.a, "b": req.b})
|
||||
return {"result": result.model_dump(mode="json")}
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
async def run_agent(req: RunRequest) -> dict[str, object]:
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is required")
|
||||
|
||||
servers = _get_active_servers()
|
||||
if not servers:
|
||||
raise HTTPException(status_code=503, detail="No MCP servers available")
|
||||
|
||||
agent = Agent(
|
||||
name="FastAPI Agent",
|
||||
instructions="Use the MCP tools when needed.",
|
||||
mcp_servers=servers,
|
||||
model_settings=ModelSettings(tool_choice="auto"),
|
||||
)
|
||||
result = await Runner.run(starting_agent=agent, input=req.input)
|
||||
return {"output": result.final_output}
|
||||
|
||||
|
||||
@app.post("/reconnect")
|
||||
async def reconnect(req: ReconnectRequest) -> dict[str, object]:
|
||||
if not USE_MCP_MANAGER:
|
||||
raise HTTPException(status_code=400, detail="MCPServerManager is disabled")
|
||||
manager: MCPServerManager = app.state.mcp_manager
|
||||
servers = await manager.reconnect(failed_only=req.failed_only)
|
||||
return {"connected_servers": [server.name for server in servers]}
|
||||
|
||||
|
||||
def _get_active_servers() -> list[MCPServer]:
|
||||
if USE_MCP_MANAGER:
|
||||
manager: MCPServerManager = app.state.mcp_manager
|
||||
return list(manager.active_servers)
|
||||
return list(app.state.active_servers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=APP_HOST, port=APP_PORT)
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1")
|
||||
STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "8000"))
|
||||
|
||||
mcp = FastMCP(
|
||||
"FastAPI Example Server",
|
||||
host=STREAMABLE_HTTP_HOST,
|
||||
port=STREAMABLE_HTTP_PORT,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(message: str) -> str:
|
||||
return f"echo: {message}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Smoke test for the MCP manager example app.
|
||||
|
||||
This script starts the sibling Streamable HTTP MCP server on a temporary local
|
||||
port, loads the app with matching environment variables, and verifies the
|
||||
manager-backed endpoints without calling a model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT_WAIT_SECONDS = 10.0
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((HOST, 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_port(process: subprocess.Popen[str], port: int) -> None:
|
||||
deadline = time.monotonic() + PORT_WAIT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
output, _ = process.communicate(timeout=1)
|
||||
raise RuntimeError(f"MCP server exited before it was ready:\n{output}")
|
||||
try:
|
||||
with socket.create_connection((HOST, port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"MCP server did not listen on {HOST}:{port}.")
|
||||
|
||||
|
||||
def _stop_process(process: subprocess.Popen[str]) -> str:
|
||||
if process.poll() is not None:
|
||||
output, _ = process.communicate(timeout=1)
|
||||
return output
|
||||
|
||||
process.terminate()
|
||||
try:
|
||||
output, _ = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
output, _ = process.communicate(timeout=5)
|
||||
return output
|
||||
|
||||
|
||||
def _start_mcp_server(port: int) -> subprocess.Popen[str]:
|
||||
env = {
|
||||
**os.environ,
|
||||
"STREAMABLE_HTTP_HOST": HOST,
|
||||
"STREAMABLE_HTTP_PORT": str(port),
|
||||
}
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-u", "-m", "examples.mcp.manager_example.mcp_server"],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _load_app_module(mcp_port: int) -> Any:
|
||||
mcp_server_url = f"http://{HOST}:{mcp_port}/mcp"
|
||||
os.environ["MCP_SERVER_URL"] = mcp_server_url
|
||||
# Point both configured MCP servers at the same temporary server so this
|
||||
# smoke test stays on the clean app integration path.
|
||||
os.environ["INACTIVE_MCP_SERVER_URL"] = mcp_server_url
|
||||
os.environ["USE_MCP_MANAGER"] = "1"
|
||||
|
||||
module_name = "examples.mcp.manager_example.app"
|
||||
if module_name in sys.modules:
|
||||
module = importlib.reload(sys.modules[module_name])
|
||||
else:
|
||||
module = importlib.import_module(module_name)
|
||||
return cast(Any, module)
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
async def _exercise_app(mcp_port: int) -> None:
|
||||
app_module = _load_app_module(mcp_port)
|
||||
app = app_module.app
|
||||
expected_server_url = f"http://{HOST}:{mcp_port}/mcp"
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
health_response = await client.get("/health")
|
||||
health_response.raise_for_status()
|
||||
health = health_response.json()
|
||||
_require(
|
||||
any(expected_server_url in name for name in health["connected_servers"]),
|
||||
f"Expected connected MCP server in health response: {health}",
|
||||
)
|
||||
_require(
|
||||
health["failed_servers"] == [],
|
||||
f"Expected no failed MCP servers in health response: {health}",
|
||||
)
|
||||
|
||||
tools_response = await client.get("/tools")
|
||||
tools_response.raise_for_status()
|
||||
tools = tools_response.json()["tools"]
|
||||
_require({"add", "echo"} <= set(tools), f"Expected add and echo tools: {tools}")
|
||||
|
||||
add_response = await client.post("/add", json={"a": 2, "b": 3})
|
||||
add_response.raise_for_status()
|
||||
add_result = add_response.json()["result"]
|
||||
texts = [
|
||||
item.get("text")
|
||||
for item in add_result.get("content", [])
|
||||
if item.get("type") == "text"
|
||||
]
|
||||
_require("5" in texts, f"Expected add tool result to include 5: {add_result}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
mcp_port = _free_port()
|
||||
process = _start_mcp_server(mcp_port)
|
||||
try:
|
||||
_wait_for_port(process, mcp_port)
|
||||
await _exercise_app(mcp_port)
|
||||
finally:
|
||||
_stop_process(process)
|
||||
|
||||
print("MCP manager example smoke test completed successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user