Додавання до наявного застосунку
Машинний переклад
Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.
mcp.run("streamable-http") запускає вебсервер за вас. Інколи це не те, що потрібно: MCP-сервер — лише частина більшого вебзастосунку, або ASGI-розгортання у вас уже є.
Для цього mcp.streamable_http_app() повертає застосунок Starlette.
Застосунок Starlette — це ASGI-застосунок, тож будь-що, що вміє розміщувати ASGI (uvicorn, Hypercorn, інший Starlette, FastAPI), може розмістити й ваш MCP-сервер.
Застосунок
from mcp.server import MCPServer
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
app = mcp.streamable_http_app()
app — звичайний ASGI-застосунок. Передайте його будь-якому ASGI-серверу:
uvicorn server:app
Кінцева точка MCP розташована на /mcp, тож клієнт під'єднується до http://127.0.0.1:8000/mcp.
Застосунок уже містить дві речі:
- Один маршрут,
/mcp— кінцева точка Streamable HTTP. - Життєвий цикл (lifespan), який запускає
mcp.session_manager— об'єкт, що відповідає за фонову роботу кожної активної сесії.
Запустіть застосунок окремо (uvicorn server:app) — і про жодне з них думати не доведеться.
Tip
streamable_http_app() приймає ті самі іменовані аргументи, що й mcp.run("streamable-http", ...),
окрім port: порт належить тому, що обслуговує застосунок. host усе ще приймається, але тут
нічого не прив'язує; що він насправді контролює, пояснює Розгортання та масштабування.
Самі параметри описано на сторінці Запуск сервера.
mcp.sse_app() робить те саме для транспорту SSE, який уже витіснено новішим.
Лише localhost, доки не вкажете інше
За замовчуванням застосунок відповідає лише на запити, адресовані localhost. streamable_http_app()
не може знати, за яким іменем хоста його обслуговуватимуть, тому вмикає захист від DNS rebinding із
найбезпечнішим можливим списком дозволених хостів; на вашій машині це саме те, що треба. За розгортання
за справжнім іменем хоста це означає, що кожен запит відхиляється з 421 Misdirected Request, доки
ви не передасте в transport_security= список того, що справді обслуговуєте. До нічого з написаного вами
справа навіть не доходить. Цей список, як і все інше на шляху від робочого застосунку до справжнього
імені хоста, — на сторінці Розгортання та масштабування.
Монтування
Щойно MCP-сервер стає частиною більшого застосунку, ви кладете застосунок усередину Mount. І щойно ви це робите, життєвий цикл стає вашим клопотом:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import MCPServer
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with mcp.session_manager.run():
yield
app = Starlette(
routes=[Mount("/", app=mcp.streamable_http_app())],
lifespan=lifespan,
)
Mount("/", ...)разом із типовим шляхом/mcpзалишає кінцеву точку на/mcp. Starlette перебирає маршрути по черзі, аMount("/")збігається з кожним шляхом, тож власні маршрути ставте в списку перед ним. Усе, що після нього, недосяжне.- Функція
lifespanвходить уmcp.session_manager.run()на весь час життя хост-застосунку. Це той рядок, про який усі забувають. mcp.session_managerіснує лише після викликуstreamable_http_app(). Саме тому маршрути будуються на рівні модуля, а до менеджера звертаються лише всередині життєвого циклу.
Маршрут Host у Starlette працює так само: замініть Mount("/", ...) на Host("mcp.example.com", ...), щоб маршрутизувати за іменем хоста, а не за шляхом. Правило про життєвий цикл не змінюється, як і правило про безпеку транспорту. Маршрут Host("mcp.example.com", ...) отримує лише запити, адресовані цьому імені хоста, але власний список дозволених хостів транспорту (Розгортання та масштабування) усе одно спрацьовує першим. Без "mcp.example.com" у ньому цей маршрут відповідає на кожен із них кодом 421.
Життєвим циклом володіє хост-застосунок
streamable_http_app() під'єднує session_manager.run() до життєвого циклу застосунку Starlette,
який повертає, але життєвий цикл змонтованого підзастосунку ніколи не виконується. Змонтуйте
застосунок — і цей вбудований життєвий цикл стане мертвим кодом. Той застосунок, що стоїть на
вершині вашого ASGI-стека, мусить увійти в mcp.session_manager.run() у власному життєвому циклі.
Check
Видаліть рядок lifespan=lifespan і запустіть сервер. Він запускається. Маршрут розв'язується.
А потім перший запит до /mcp завершується помилкою:
RuntimeError: Task group is not initialized. Make sure to use run().
Ніщо не запускає менеджер сесій, окрім його run().
Два сервери, один застосунок
Кожен MCPServer — це окремий застосунок з окремим менеджером сесій. Монтуйте скільки завгодно; входьте в кожен менеджер з одного життєвого циклу хоста:
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import MCPServer
notes = MCPServer("Notes")
tasks = MCPServer("Tasks")
@notes.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
@tasks.tool()
def add_task(title: str) -> str:
"""Create a task."""
return f"Created: {title}"
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
await stack.enter_async_context(notes.session_manager.run())
await stack.enter_async_context(tasks.session_manager.run())
yield
app = Starlette(
routes=[
Mount("/notes", app=notes.streamable_http_app()),
Mount("/tasks", app=tasks.streamable_http_app()),
],
lifespan=lifespan,
)
AsyncExitStackвходить в обидва менеджери; вони стартують разом і завершуються у зворотному порядку.- Кінцеві точки —
/notes/mcpі/tasks/mcp: префікс монтування плюс типовий шлях.
Зміна шляху
Цей кінцевий /mcp — це streamable_http_path. Задайте йому "/" — і префікс монтування стане повним публічним шляхом:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import MCPServer
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with mcp.session_manager.run():
yield
app = Starlette(
routes=[Mount("/notes", app=mcp.streamable_http_app(streamable_http_path="/"))],
lifespan=lifespan,
)
Тепер клієнти під'єднуються до /notes, а не до /notes/mcp.
CORS для браузерних клієнтів
Браузерному клієнту від вас потрібні два дозволи: надсилати свої заголовки MCP-запитів і читати той, що MCP надсилає у відповідь. І те, і те — налаштування CORS на хост-застосунку, і наведений вище список дозволених значень безпеки транспорту має з ними узгоджуватися:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with mcp.session_manager.run():
yield
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = Starlette(
routes=[Mount("/", app=mcp.streamable_http_app(transport_security=security))],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=[
"Authorization",
"Content-Type",
"Last-Event-ID",
"Mcp-Method",
"Mcp-Name",
"Mcp-Protocol-Version",
"Mcp-Session-Id",
],
expose_headers=["Mcp-Session-Id"],
)
],
lifespan=lifespan,
)
allow_headers— та половина, про яку всі забувають. Браузер надсилає preflight-запит перед кожним MCP-запитом, боContent-Type: application/jsonі заголовки запитуMcp-*не входять до безпечного списку CORS, а заголовок, якого preflight не дозволив, — це запит, який браузер ніколи не надішле. (allow_headers=["*"]теж працює: Starlette відповідає на preflight усім, про що той попросив.)expose_headers=["Mcp-Session-Id"]— половина для читання. Streamable HTTP повертає ідентифікатор сесії в цьому заголовку відповіді, а браузери ховають заголовки відповіді від JavaScript, якщо CORS не розкриває їх поіменно. Без нього клієнт ніколи не зможе зробити другий запит.allow_origins— ваше рішення, а не MCP. Будьте точні й віддзеркальте його вallowed_origins=вище: дотримання CORS забезпечує браузер, але сервер перевіряєOriginсам, і джерело, якому транспорт не довіряє, отримує403навіть після бездоганного preflight.allow_methodsперелічує три методи, які використовує Streamable HTTP:POST, щоб надсилати повідомлення,GET, щоб відкрити потік від сервера до клієнта,DELETE, щоб завершити сесію.
Власні маршрути
@mcp.custom_route() реєструє звичайну HTTP-кінцеву точку в тому самому застосунку — для речей, які потрібні кожному розгорнутому сервісу й не мають нічого спільного з MCP: перевірка стану, колбек OAuth.
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from mcp.server import MCPServer
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> Response:
return JSONResponse({"status": "ok"})
app = mcp.streamable_http_app()
- Обробник — звичайний Starlette:
async-функція зRequestуResponse. streamable_http_app()підхоплює кожен власний маршрут.app.routesтепер —/mcpі/health.GET /healthвідповідає{"status": "ok"}— і жодного MCP поблизу.
Warning
Власні маршрути ніколи не автентифікуються, навіть коли решта сервера — так. Це навмисно: перевірки стану й колбеки OAuth мають бути досяжні ще до появи будь-якого токена. Не ховайте за ними нічого приватного.
Підсумки
mcp.streamable_http_app()повертає застосунок Starlette з одним маршрутом,/mcp. Його може запустити будь-який ASGI-сервер.- За замовчуванням застосунок відповідає лише на запити, адресовані localhost, а за справжнім іменем хоста відхиляє все кодом
421, доки ви не передасте вtransport_security=список дозволених хостів. Цим, як і рештою шляху до робочого середовища, опікується Розгортання та масштабування. Mount(абоHost) розміщує його всередині більшого застосунку Starlette чи FastAPI.- Монтування вимикає вбудований життєвий цикл. Життєвий цикл хост-застосунку мусить увійти в
mcp.session_manager.run(), інакше перший запит завершиться помилкою. - Кілька серверів в одному застосунку — це кілька монтувань і один життєвий цикл, що входить у кожен менеджер сесій.
streamable_http_path="/"переносить кінцеву точку на сам префікс монтування.- Браузерним клієнтам потрібен CORS:
allow_headersдля заголовків запитуMcp-*,expose_headers=["Mcp-Session-Id"]для відповіді. @mcp.custom_route()додає звичайні HTTP-кінцеві точки без автентифікації поруч із/mcp.
Щойно сервер стане досяжним за справжньою URL-адресою, Клієнт під'єднається до нього за цією URL-адресою, а не через об'єкт сервера.