Низькорівневий Server
Машинний переклад
Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.
@mcp.tool() — це шар. Під ним лежить другий серверний клас, Server, який говорить «сирим» MCP: ви передаєте йому об'єкти протоколу, а він надсилає їх мережею без змін.
MCPServer побудований поверх нього. Спускатися на рівень нижче варто тоді, коли зручний шар заважає:
- Потрібно віддати точну схему (завантажену з файлу, згенеровану з бази даних), а не виведену з сигнатури Python.
- Потрібен повний контроль над результатом:
_meta,is_error, кожен ключstructured_content. - Потрібно обробити метод, якого MCP не визначає.
Для всього іншого залишайтеся на MCPServer.
Той самий інструмент, вручну
Це інструмент search_books, який сторінка Інструменти пише дев'ятьма рядками @mcp.tool(), — тільки без синтаксичного цукру:
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})."
return CallToolResult(content=[TextContent(type="text", text=text)])
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
Змінилися три речі, і вони й складають увесь низькорівневий API:
- Обробники — це параметри конструктора.
on_list_tools=іon_call_tool=передаються вServer(...). Декораторів тут немає, і кожен обробник має однакову форму:async (ctx, params) -> result. - Вхідну схему пишете ви.
Tool.input_schema— це звичайнийdictіз JSON Schema. Ніхто не виводить її з анотацій типів, бо анотацій типів, з яких можна було б її вивести, немає. - Результат будуєте ви.
CallToolResult(content=[TextContent(...)]), вручну. Нічого не загортається, не перетворюється й не виводиться з анотації значення, що повертається.
params — це розібраний запит: CallToolRequestParams дає .name і .arguments. ctx — це ServerRequestContext: ctx.session, щоб звертатися назад до клієнта, ctx.lifespan_context, ctx.request_id і ctx.meta — вхідні _meta запиту.
Info
Якщо ви працювали з FastAPI, це співвідношення вам уже знайоме. MCPServer — це шар декораторів і анотацій типів; Server — це Starlette під ним. Вони не суперники: MCPServer створює Server і реєструє на ньому саме такі обробники.
Спробуйте самі
Inspector тут не допоможе: mcp dev і mcp run приймають лише MCPServer. Client, що працює в пам'яті, до цього байдужий: низькорівневий Server він приймає так само, як і MCPServer:
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
Той самий текст, що його видавала версія з @mcp.tool(). Дві чесні відмінності:
result.structured_contentдорівнюєNone. Високорівневий сервер загортає-> strу{"result": ...}за вас; тут ніхто не будує того, чого не побудували ви.list_toolsповертає схему, яку набрали ви, символ у символ. У високорівневій версії на кожній властивості було"title": "Query", а в корені —"title": "search_booksArguments": артефакти Pydantic. Тут, якщо щось є в переданих даних, — це ви його туди поклали.
Ніхто нічого не перевіряє за вас
MCPServer відхиляє хибний аргумент ще до того, як ваша функція запуститься, перевіряючи виклик за схемою, яку сам згенерував (Інструменти).
Server цього не робить. Ваша input_schema оголошується клієнтові; вона ніколи не застосовується до params.arguments.
Check
Викличте search_books без limit — і ваш args["limit"] викине KeyError. Клієнт побачить:
MCPError: Internal server error
Помилка JSON-RPC з кодом -32603 і навмисно загальним повідомленням: SDK не видасть ваш traceback віддаленій стороні, що викликає. Модель так і не дізнається, що зробила не так, тож не зможе повторити спробу. (У тесті raise_exceptions=True натомість показує справжній виняток; див. Тестування.)
Це узагальнюється. Виняток, викинутий із низькорівневого обробника, — це завжди помилка протоколу й ніколи — результат інструмента з is_error=True. Якщо потрібно, щоб модель прочитала про невдачу й відновилася, перевіряйте params.arguments самі й повертайте CallToolResult(content=[TextContent(...)], is_error=True). Обом видам невдач присвячена сторінка Обробка помилок.
Два інструменти, один обробник
on_call_tool — єдина точка входу для всіх інструментів сервера. Маршрутизуєте за params.name:
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
)
ADD_BOOK = Tool(
name="add_book",
description="Add a book to the catalog.",
input_schema={
"type": "object",
"properties": {"title": {"type": "string"}, "author": {"type": "string"}, "year": {"type": "integer"}},
"required": ["title", "author", "year"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS, ADD_BOOK])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
if params.name == "search_books":
text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})."
elif params.name == "add_book":
text = f"Added {args['title']!r} by {args['author']} ({args['year']})."
else:
raise ValueError(f"Unknown tool: {params.name}")
return CallToolResult(content=[TextContent(type="text", text=text)])
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
list_toolsоголошує обидва.call_toolдиспетчеризує за іменем.- Гілка
elseважлива:Serverспокійно передастьtools/callз іменем, якого ви ніколи не оголошували, просто у ваш обробник. Виняток там перетворює виклик на ту саму-32603, що й вище.
Структурований вивід, вручну
Оголосіть output_schema на Tool і покладіть structured_content у результат. І те, й інше — ваше:
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
output_schema={
"type": "object",
"properties": {"matches": {"type": "integer"}, "query": {"type": "string"}},
"required": ["matches", "query"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
data = {"matches": 3, "query": args["query"]}
return CallToolResult(
content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")],
structured_content=data,
)
server = Server("Bookshop", version="2.0.0", on_list_tools=list_tools, on_call_tool=call_tool)
Викличте його — і результат міститиме обидва подання:
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
Блок _meta — це ідентифікаційна позначка сервера: SDK додає його до кожного результату покоління 2026 разом із version з конструктора (сервер, який її не задав, повідомляє порожній рядок). Сервер, який не повинен себе ідентифікувати, може прибрати цей ключ за допомогою middleware, який володіє результатами, що повертає.
Сервер ніколи не порівнює ці два поля. Client цього SDK — порівнює: поверніть structured_content, що не відповідає оголошеній вами output_schema, і call_tool викине RuntimeError, який починається з Invalid structured content returned by tool search_books і далі цитує помилку jsonschema. Пообіцяти схему легко; дотримати її — ваша справа. Уся драбина типів повернення та схем — на сторінці Структурований вивід.
_meta: для застосунку, а не для моделі
content — це частина відповіді, яку читає модель. structured_content — та сама відповідь у вигляді типізованих даних. _meta — третій канал: дані, що їдуть разом із результатом для клієнтського застосунку і взагалі не є частиною відповіді.
Використовуйте його для ідентифікаторів записів, ідентифікаторів трасування — усього, що потрібно вашому UI, але не потрібно промпту:
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
output_schema={
"type": "object",
"properties": {"matches": {"type": "integer"}, "query": {"type": "string"}},
"required": ["matches", "query"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
data = {"matches": 3, "query": args["query"]}
return CallToolResult(
content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")],
structured_content=data,
_meta={"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]},
)
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
- Конструюєте його як
_meta=— це ім'я в переданих даних. Клієнт зчитує його якresult.meta. - Додавайте до ключів простір імен (
bookshop/record_ids). Ключіio.modelcontextprotocol/*зарезервовано протоколом.
Warning
_meta — це домовленість між вами й клієнтським застосунком, а не гарантія того, що дійде
до моделі. Що показувати, вирішує хост. Ніколи не кладіть секрет у жодну частину результату інструмента.
Можливості випливають з обробників
Server оголошує рівно ті сімейства методів, для яких ви дали йому обробники. Bookshop вище передає on_list_tools і on_call_tool і більше нічого, тож клієнт, що до нього під'єднується, бачить:
{"tools": {"listChanged": false}}
Жодних resources, жодних prompts: за ними нічого не стоїть. Передайте on_list_prompts — і з'явиться prompts; передайте on_completion — і з'явиться completions.
MCPServer завжди оголошує інструменти, ресурси й промпти, зареєстрували ви щось чи ні, бо його менеджери існують завжди. Тут же оголошення — це і є виклик конструктора.
Життєвий цикл як параметр типу
Server узагальнений за типом, який видає його життєвий цикл (lifespan). Анотуйте його один раз — і об'єкт буде типізованим усюди, де з'являється:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
@dataclass
class Catalog:
books: list[str]
def search(self, query: str) -> list[str]:
return [title for title in self.books if query.lower() in title.lower()]
@asynccontextmanager
async def lifespan(server: Server[Catalog]) -> AsyncIterator[Catalog]:
yield Catalog(books=["Dune", "Dune Messiah", "Children of Dune"])
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
)
async def list_tools(ctx: ServerRequestContext[Catalog], params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS])
async def call_tool(ctx: ServerRequestContext[Catalog], params: CallToolRequestParams) -> CallToolResult:
matches = ctx.lifespan_context.search((params.arguments or {})["query"])
text = f"Found {len(matches)} books: {', '.join(matches)}."
return CallToolResult(content=[TextContent(type="text", text=text)])
server = Server("Bookshop", lifespan=lifespan, on_list_tools=list_tools, on_call_tool=call_tool)
- Життєвий цикл — це
Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]];@asynccontextmanagerнадasync-генератором дає саме це. - Усе, що він видає через
yield, стаєctx.lifespan_context, а оскільки обробники анотовано якServerRequestContext[Catalog],.search(...)автодоповнюється й проходить перевірку типів. - У нього входять один раз під час старту сервера й виходять один раз під час зупинки. Запуск, завершення та версія тієї самої ідеї в
MCPServer— на сторінці Життєвий цикл.
Без lifespan= ctx.lifespan_context — порожній dict.
Власний метод
Конструктор покриває методи, які визначає MCP. add_request_handler покриває все інше:
from pydantic import BaseModel
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
RequestParams,
TextContent,
Tool,
)
SEARCH_BOOKS = Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["query", "limit"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[SEARCH_BOOKS])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})."
return CallToolResult(content=[TextContent(type="text", text=text)])
class ReindexParams(RequestParams):
full: bool = False
class ReindexResult(BaseModel):
indexed: int
async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> ReindexResult:
return ReindexResult(indexed=3)
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
server.add_request_handler("bookshop/reindex", ReindexParams, reindex)
- Перший аргумент — рядок методу. Для сповіщень є близнюк —
add_notification_handler. params_type— це модель, за якою вхідніparamsперевіряються до запуску вашого обробника, тож власні методи отримують перевірку, якої інструменти не мають. Успадковуйтеся відRequestParams, щоб поле_metaрозбиралося так само, як у кожного іншого методу.- Обробник повертає
BaseModel,dictабоNone. SDK серіалізує це в результат JSON-RPC.
Одне чесне застереження: високорівневий Client має дієслова лише для методів, які визначає MCP, тож client.reindex() немає. Вендорний метод — для сторони, яка вже знає про його існування: клієнта, який ви теж постачаєте, або іншого вашого сервісу, що говорить JSON-RPC.
Один метод забрати собі не можна:
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
Рукостискання належить засобу запуску сервера. server/discover, ping та всі інші вбудовані методи можна замінювати.
Tip
Server.middleware, згаданий у цій помилці, обгортає кожне вхідне повідомлення, включно з initialize. Якщо мета — спостерігати за трафіком чи переписувати його, а не відповідати на новий метод, почніть із Middleware.
Інші обробники
Кожен із них — одна ідея, для якої у вас тепер є словник; кожна має власну сторінку.
on_call_tool,on_get_promptіon_read_resourceможуть повернутиInputRequiredResultзамість звичайного результату, щоб призупинити виклик і попросити клієнта про введення; див. Багатораундові запити (multi-round-trip). Як і годиться цьому рівню, нічого не встановлюється за вас: якщоMCPServerза замовчуванням запечатуєrequestState, то тут заданий вамиrequest_stateпередається мережею точно так, як написано, доки ви не ввімкнете захист черезserver.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name)): один рядок (обидва імені імпортуються зmcp.server.request_state) — і отримуєте те саме запечатування й перевірку, що їх виконуєMCPServer(ЗахистrequestState).on_list_resources,on_read_resource,on_list_prompts,on_get_prompt,on_completion— та сама форма(ctx, params) -> resultдля інших примітивів.on_subscriptions_listenобслуговує потікsubscriptions/listenверсії 2026-07-28. ПередайтеListenHandler, побудований поверхSubscriptionBus, і публікуйте події в шину з інших обробників; повну композицію див. на сторінці Підписки.server.streamable_http_app()повертає той самий Starlette-застосунок, що й уMCPServer; розгортайте його так, як Запуск сервера розгортає будь-який інший ASGI-застосунок.server.run(transport=...)тут немає:server.run(read_stream, write_stream, server.create_initialization_options())веде одне з'єднання через пару потоків, і цей один рядок — оце й усе.
Підсумки
- Низькорівневий
Serverприймає обробники як параметри конструктораon_*; кожен обробник — цеasync (ctx, params) -> result. - Ви пишете словник
input_schemaі будуєтеCallToolResult. Нічого не виводиться, не загортається й не перевіряється за вас. - Виняток в обробнику — це помилка протоколу
-32603. Помилка інструмента, яку може прочитати модель, — цеCallToolResultзis_error=True, який повертаєте ви. _metaв результаті адресовано клієнтському застосунку, а не моделі.Server[T]узагальнений за тим, що видає його життєвий цикл;ctx.lifespan_context— це типізованийT.add_request_handler(method, params_type, handler)обслуговує будь-який метод.initializeзарезервовано.- Можливості, які оголошує
Server, виводяться з того, які обробники ви зареєстрували.
Client(server) поводився з обома серверами однаково, бо вони і є тим самим протоколом — у цьому й увесь сенс. Наступний шар нижче — взагалі не клас: це Middleware.