Добавление в существующее приложение
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
mcp.run("streamable-http") запускает веб-сервер за вас. Иногда это не то, что нужно: MCP-сервер — лишь часть более крупного веб-приложения, или у вас уже есть развёрнутое ASGI-приложение.
Для таких случаев mcp.streamable_http_app() возвращает приложение Starlette.
Приложение Starlette — это ASGI-приложение, поэтому разместить MCP-сервер может всё, что умеет запускать ASGI: uvicorn, Hypercorn, другое приложение Starlette, FastAPI.
Приложение
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", ...) получает только запросы, адресованные этому имени хоста, но собственный список разрешённых значений Host у транспорта (Развёртывание и масштабирование) всё равно проверяется первым. Если в нём нет "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, а заголовок, не разрешённый предварительным запросом, — это запрос, который браузер никогда не отправит. (allow_headers=["*"]тоже работает: Starlette отвечает на предварительный запрос тем, что тот запросил.)expose_headers=["Mcp-Session-Id"]— половина, отвечающая за чтение. Streamable HTTP возвращает идентификатор сессии в этом заголовке ответа, а браузеры скрывают заголовки ответа от JavaScript, если CORS не раскрывает их поимённо. Без этого клиент никогда не сможет сделать второй запрос.allow_origins— ваше решение, а не MCP. Будьте точны и продублируйте его вallowed_origins=выше: CORS обеспечивает браузер, но сервер сам проверяетOrigin, и источник, которому транспорт не доверяет, получает403даже после успешного предварительного запроса.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 вместо объекта сервера.