Der Low-Level-Server
Maschinelle Übersetzung
Diese Seite wurde automatisch aus der englischen Dokumentation übersetzt, und die englische Seite ist die maßgebliche Fassung. Wenn sich etwas falsch liest, erklärt Übersetzungen, wie du es melden kannst.
@mcp.tool() ist eine Schicht. Darunter liegt eine zweite Server-Klasse, Server, die rohes MCP spricht: Du gibst ihr die Protokollobjekte, und sie legt sie unverändert auf die Leitung.
MCPServer ist darauf aufgebaut. Du steigst hinab, wenn die Komfortschicht im Weg ist:
- Du musst ein exaktes Schema ausgeben (aus einer Datei geladen, aus einer Datenbank generiert), nicht eines, das aus einer Python-Signatur abgeleitet ist.
- Du brauchst die volle Kontrolle über das Ergebnis:
_meta,is_error, jeden Schlüssel vonstructured_content. - Du musst eine Methode behandeln, die MCP nicht definiert.
Für alles andere bleib bei MCPServer.
Dasselbe Tool, von Hand
Das ist das Tool search_books, das Tools in neun Zeilen @mcp.tool() schreibt, ohne den Zucker:
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)
Drei Dinge haben sich geändert, und sie sind die ganze Low-Level-API:
- Handler sind Konstruktorparameter.
on_list_tools=undon_call_tool=wandern inServer(...). Hier unten gibt es keine Dekoratoren, und jeder Handler hat dieselbe Form:async (ctx, params) -> result. - Du schreibst das Input-Schema.
Tool.input_schemaist ein schlichtes JSON-Schema-dict. Niemand leitet es aus Type Hints ab, denn es gibt keine Type Hints, aus denen man es ableiten könnte. - Du baust das Ergebnis.
CallToolResult(content=[TextContent(...)]), von Hand. Nichts wird verpackt, konvertiert oder aus einer Rückgabeannotation abgeleitet.
params ist der geparste Request: CallToolRequestParams gibt dir .name und .arguments. ctx ist ein ServerRequestContext: ctx.session zum Zurücksprechen an den Client, ctx.lifespan_context, ctx.request_id und ctx.meta, das eingehende _meta des Requests.
Info
Wenn du FastAPI kennst, kennst du diese Beziehung bereits. MCPServer ist die Schicht aus Dekoratoren und Type Hints; Server ist das Starlette darunter. Sie sind keine Rivalen: MCPServer erzeugt einen Server und registriert darauf genau solche Handler wie diese.
Ausprobieren
Hierfür gibt es keinen Inspector: mcp dev und mcp run akzeptieren nur einen MCPServer. Dem In-Memory-Client ist das egal; er nimmt einen Low-Level-Server genauso wie einen 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)]
Derselbe Text, den die @mcp.tool()-Version erzeugt hat. Zwei ehrliche Unterschiede:
result.structured_contentistNone. Der High-Level-Server verpackt ein-> strfür dich in{"result": ...}; hier baut niemand, was du nicht gebaut hast.list_toolsgibt das Schema zurück, das du getippt hast, Zeichen für Zeichen. Die High-Level-Version hatte"title": "Query"auf jeder Property und ein"title": "search_booksArguments"an der Wurzel: Pydantic-Artefakte. Hier unten gilt: Was auf der Leitung ist, hast du dort hingelegt.
Nichts wird für dich geprüft
MCPServer weist ein fehlerhaftes Argument ab, bevor deine Funktion überhaupt läuft, indem er den Aufruf gegen das generierte Schema validiert (Tools).
Server tut das nicht. Dein input_schema wird dem Client angekündigt; es wird nie auf params.arguments angewendet.
Check
Ruf search_books ohne limit auf, und dein args["limit"] löst einen KeyError aus. Der Client sieht:
MCPError: Internal server error
Ein JSON-RPC-Fehler, Code -32603, mit einer bewusst generischen Meldung: Das SDK gibt deinen Traceback nicht an einen entfernten Aufrufer preis. Das Modell erfährt nie, was es falsch gemacht hat, kann es also nicht erneut versuchen. (In einem Test bringt raise_exceptions=True stattdessen die echte Exception zum Vorschein; siehe Testen.)
Das lässt sich verallgemeinern. Eine Exception, die ein Low-Level-Handler auslöst, ist immer ein Protokollfehler, nie ein Tool-Ergebnis mit is_error=True. Wenn das Modell den Fehlschlag lesen und sich erholen soll, validiere params.arguments selbst und gib CallToolResult(content=[TextContent(...)], is_error=True) zurück. Die beiden Arten von Fehlschlägen sind das Thema von Fehler behandeln.
Zwei Tools, ein Handler
on_call_tool ist der einzige Einstiegspunkt für jedes Tool auf dem Server. Du verzweigst über 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_toolskündigt beide an.call_toolverteilt nach dem Namen.- Der
else-Zweig ist wichtig:Serverleitet eintools/callfür einen Namen, den du nie gelistet hast, bereitwillig direkt in deinen Handler weiter. Löst du dort eine Exception aus, wird aus dem Aufruf dasselbe-32603wie oben.
Strukturierte Ausgabe, von Hand
Deklariere output_schema auf dem Tool und setze structured_content auf das Ergebnis. Beides liegt bei dir:
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)
Ruf es auf, und das Ergebnis trägt beide Darstellungen:
{
"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"}}
}
Der _meta-Block ist der Identitätsstempel des Servers: Das SDK fügt ihn jedem Ergebnis der 2026er-Generation hinzu, mit der version aus dem Konstruktor (ein Server, der keine setzt, meldet einen leeren String). Ein Server, der sich nicht zu erkennen geben darf, kann den Schlüssel mit einer Middleware entfernen, der die Ergebnisse gehören, die sie zurückgibt.
Der Server vergleicht die beiden Felder nie. Der Client dieses SDK schon: Gibst du structured_content zurück, das das von dir deklarierte output_schema nicht erfüllt, löst call_tool einen RuntimeError aus, der mit Invalid structured content returned by tool search_books beginnt und dann den jsonschema-Fehler zitiert. Ein Schema zu versprechen ist billig; es einzuhalten liegt bei dir. Die ganze Stufenleiter der Rückgabetypen und Schemas steht in Strukturierte Ausgabe.
_meta: für die Anwendung, nicht für das Modell
content ist der Teil der Antwort, den das Modell liest. structured_content ist dieselbe Antwort als typisierte Daten. _meta ist der dritte Kanal: Daten, die mit dem Ergebnis für die Client-Anwendung mitreisen, ohne überhaupt Teil der Antwort zu sein.
Nutze es für Datensatz-IDs, Trace-IDs, alles, was deine UI braucht und dein Prompt nicht:
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)
- Du erzeugst es als
_meta=, dem Namen auf der Leitung. Der Client liest es alsresult.metazurück. - Versieh deine Schlüssel mit einem Namensraum (
bookshop/record_ids). Die Schlüsselio.modelcontextprotocol/*sind vom Protokoll reserviert.
Warning
_meta ist eine Konvention zwischen dir und der Client-Anwendung, keine Garantie darüber, was
das Modell erreicht. Der Host entscheidet, was er darstellt. Lege niemals ein Geheimnis in irgendeinen Teil eines Tool-Ergebnisses.
Capabilities folgen deinen Handlern
Ein Server kündigt genau die Methodenfamilien an, für die du ihm Handler gegeben hast. Der Bookshop oben übergibt on_list_tools und on_call_tool und sonst nichts, also sieht ein Client, der sich mit ihm verbindet:
{"tools": {"listChanged": false}}
Kein resources, kein prompts: Es gibt nichts, was dahinter stünde. Übergib on_list_prompts, und prompts erscheint; übergib on_completion, und completions erscheint.
MCPServer kündigt Tools, Ressourcen und Prompts immer an, ob du welche registriert hast oder nicht, weil seine Manager immer existieren. Hier unten ist die Deklaration der Konstruktoraufruf.
Der Lifespan-Generic
Server ist generisch im Typ, den sein Lifespan liefert. Annotiere ihn einmal, und das Objekt ist überall typisiert, wo es auftaucht:
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)
- Der Lifespan ist ein
Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]];@asynccontextmanagerauf einemasync-Generator gibt dir genau das. - Was immer er per
yieldliefert, wird zuctx.lifespan_context, und weil die Handler mitServerRequestContext[Catalog]annotiert sind, funktionieren Autovervollständigung und Typprüfung für.search(...). - Er wird einmal betreten, wenn der Server startet, und einmal verlassen, wenn er stoppt. Start, Abbau und die Variante derselben Idee in
MCPServerstehen in Lifespan.
Ohne ein lifespan= ist ctx.lifespan_context ein leeres dict.
Eine eigene Methode
Der Konstruktor deckt die Methoden ab, die MCP definiert. add_request_handler deckt alles andere ab:
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)
- Das erste Argument ist der Methoden-String. Benachrichtigungen haben ein Gegenstück,
add_notification_handler. params_typeist das Modell, gegen das die eingehendenparamsvalidiert werden, bevor dein Handler läuft – eigene Methoden bekommen also doch die Validierung, die Tools nicht bekommen. Leite vonRequestParamsab, damit das Feld_metaso geparst wird wie bei jeder anderen Methode.- Der Handler gibt ein
BaseModel, eindictoderNonezurück. Das SDK serialisiert es in das JSON-RPC-Ergebnis.
Ein ehrlicher Vorbehalt: Der High-Level-Client hat nur Verben für die Methoden, die MCP definiert, es gibt also kein client.reindex(). Eine Vendor-Methode ist für eine Gegenstelle gedacht, die bereits weiß, dass es sie gibt: ein Client, den du ebenfalls auslieferst, oder ein anderer deiner Dienste, der JSON-RPC spricht.
Eine Methode, die du nicht beanspruchen kannst:
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
Der Handshake gehört dem Runner. server/discover, ping und jeden anderen Built-in darfst du ersetzen.
Tip
Server.middleware, in dieser Fehlermeldung erwähnt, umhüllt jede eingehende Nachricht, initialize eingeschlossen. Wenn du Verkehr beobachten oder umschreiben willst, statt eine neue Methode zu beantworten, fang bei Middleware an.
Die übrigen Handler
Jeder davon ist eine Idee, für die du jetzt das Vokabular hast; jeder hat seine eigene Seite.
on_call_tool,on_get_promptundon_read_resourcedürfen statt ihres normalen Ergebnisses einInputRequiredResultzurückgeben, um den Aufruf anzuhalten und den Client um Eingaben zu bitten; siehe Multi-Roundtrip-Requests (multi-round-trip requests). Getreu dieser Ebene wird nichts für dich installiert: WoMCPServerrequestStatestandardmäßig versiegelt, geht hier derrequest_state, den du setzt, genau so über die Leitung, wie du ihn geschrieben hast, bis du dich mitserver.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))dafür entscheidest: eine Zeile (beide Namen lassen sich ausmcp.server.request_stateimportieren) für genau die Versiegelung und Verifizierung, dieMCPServervornimmt (requestStateschützen).on_list_resources,on_read_resource,on_list_prompts,on_get_prompt,on_completionhaben dieselbe Form(ctx, params) -> resultfür die anderen Primitive.on_subscriptions_listenbedient den Streamsubscriptions/listenaus 2026-07-28. Übergib einenListenHandler, der auf einemSubscriptionBusaufgebaut ist, und veröffentliche Ereignisse aus deinen anderen Handlern auf dem Bus; die vollständige Zusammensetzung steht in Abonnements.server.streamable_http_app()gibt dieselbe Starlette-App zurück wie die vonMCPServer; stelle sie bereit, wie Den Server betreiben jede andere ASGI-App bereitstellt. Hier unten gibt es keinserver.run(transport=...):server.run(read_stream, write_stream, server.create_initialization_options())treibt eine Verbindung über ein Paar Streams, und diese eine Zeile ist alles.
Zusammenfassung
- Der Low-Level-
Servernimmt seine Handler alson_*-Konstruktorparameter; jeder Handler istasync (ctx, params) -> result. - Du schreibst das
input_schema-dict und du baust dasCallToolResult. Nichts wird für dich abgeleitet, verpackt oder validiert. - Eine Exception in einem Handler ist ein
-32603-Protokollfehler. Ein Tool-Fehler, den das Modell lesen kann, ist einCallToolResultmitis_error=True, das du zurückgibst. _metaauf dem Ergebnis richtet sich an die Client-Anwendung, nicht an das Modell.Server[T]ist generisch in dem, was sein Lifespan liefert;ctx.lifespan_contextist ein typisiertesT.add_request_handler(method, params_type, handler)bedient jede Methode.initializeist reserviert.- Die Capabilities, die ein
Serverankündigt, leiten sich davon ab, welche Handler du registriert hast.
Client(server) hat beide Server identisch behandelt, weil sie dasselbe Protokoll sind – und genau darum geht es. Die nächste Schicht darunter ist gar keine Klasse: Es ist Middleware.