SOLFIND
Web Lens
Portal home

The low-level Server - MCP Python SDK

https://py.sdk.modelcontextprotocol.io/advanced/low-level-server/ • 121 KB fetched
Open original page


The low-level Server - MCP Python SDK Skip to content MCP Python SDK The low-level Server * en - English * de - Deutsch * es - español * fr - français * hi - हिन्दी * ja - 日本語 * ko - 한국어 * pt - português (Brasil) * ru - русский язык * tr - Türkçe * uk - українська мова * zh - 简体中文 * zh-hant - 繁體中文 Search modelcontextprotocol/python-sdk MCP Python SDK modelcontextprotocol/python-sdk * MCP Python SDK * What's new in v2 * Get started Get started * Installation * First steps * Connect to a real host * Testing * Servers Servers * Tools * Structured Output * Resources * URI templates * Prompts * Completions * Images, audio & icons * Handling errors * Inside your handler Inside your handler * The Context * Dependencies * Lifespan * Elicitation * Multi-round-trip requests * Sampling and roots * Progress * Logging * Subscriptions * Running your server Running your server * Add to an existing app * Deploy & scale * Authorization * OpenTelemetry * Serving legacy clients * Clients Clients * Callbacks * Transports * OAuth * Identity assertion * Multiple servers * Subscriptions * Caching * Protocol versions * Deprecated features * Advanced Advanced * The low-level Server The low-level Server On this page * The same tool, by hand * Try it * Nothing is checked for you * Two tools, one handler * Structured output, by hand * The dialect is JSON Schema 2020-12 * _meta : for the application, not the model * Capabilities follow your handlers * The lifespan generic * A method of your own * The other handlers * Recap * Pagination * Middleware * Extensions * MCP Apps * Troubleshooting * Translations * Migration Guide * API Reference API Reference * mcp * mcp_types On this page * The same tool, by hand * Try it * Nothing is checked for you * Two tools, one handler * Structured output, by hand * The dialect is JSON Schema 2020-12 * _meta : for the application, not the model * Capabilities follow your handlers * The lifespan generic * A method of your own * The other handlers * Recap * MCP Python SDK * Advanced The low-level Server @mcp.tool() is a layer. Underneath it is a second server class, Server , that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged. MCPServer is built on top of it. You drop down when the convenience layer is in the way: * You need to emit an exact schema (loaded from a file, generated from a database), not one derived from a Python signature. * You need full control of the result: _meta , is_error , every key of structured_content . * You need to handle a method MCP doesn't define. For everything else, stay on MCPServer . The same tool, by hand This is the search_books tool that Tools writes in nine lines of @mcp.tool() , with the sugar removed: server.py 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 ) app = server . streamable_http_app () Three things changed, and they are the whole low-level API: * Handlers are constructor parameters. on_list_tools= and on_call_tool= go into Server(...) . There are no decorators down here, and every handler has the same shape: async (ctx, params) -> result . * You write the input schema. Tool.input_schema is a plain JSON Schema dict . Nobody derives it from type hints, because there are no type hints to derive it from. * You build the result. CallToolResult(content=[TextContent(...)]) , by hand. Nothing is wrapped, converted, or inferred from a return annotation. params is the parsed request: CallToolRequestParams gives you .name and .arguments . ctx is a ServerRequestContext : ctx.session for talking back to the client, ctx.lifespan_context , ctx.request_id , and ctx.meta , the request's inbound _meta . Info If you've used FastAPI, you already know this relationship. MCPServer is the decorators-and-type-hints layer; Server is the Starlette underneath. They are not rivals: MCPServer constructs a Server and registers handlers exactly like these on it. Try it mcp dev and mcp run only accept an MCPServer , so you serve this one yourself. The last line of server.py builds an ordinary ASGI app from it, and uvicorn runs that: uvicorn server:app --port 8000 Point the Inspector, or any client, at http://localhost:8000/mcp : client.py import asyncio from mcp import Client async def main () -> None : async with Client ( "http://localhost:8000/mcp" ) 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)] The same text the @mcp.tool() version produced. Two honest differences: * result.structured_content is None . The high-level server wraps a -> str into {"result": ...} for you; here nobody builds what you didn't build. * list_tools returns the schema you typed, character for character. The high-level version had "title": "Query" on every property and a "title": "search_booksArguments" at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. In a test you skip uvicorn and the port: Client(server) takes a low-level Server in-process exactly like it takes an MCPServer , and Testing is that pattern. Nothing is checked for you MCPServer rejects a bad argument before your function ever runs, validating the call against the schema it generated ( Tools ). Server does not do that. Your input_schema is advertised to the client; it is never applied to params.arguments . Check Call search_books without limit and your args["limit"] raises KeyError . The client sees: MCPError: Internal server error A JSON-RPC error, code -32603 , with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, raise_exceptions=True surfaces the real exception instead; see Testing .) That generalises. An exception raised from a low-level handler is always a protocol error, never an is_error=True tool result. If you want the model to read the failure and recover, validate params.arguments yourself and return CallToolResult(content=[TextContent(...)], is_error=True) . The two kinds of failure are the subject of Handling errors . Two tools, one handler on_call_tool is the single entry point for every tool on the server. You route on params.name : server.py 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 advertises both. call_tool dispatches on the name. * The else branch matters: Server will happily forward a tools/call for a name you never listed straight into your handler. Raising there turns the call into the same -32603 as above. Structured output, by hand Declare output_schema on the Tool and put structured_content on the result. Both are yours: server.py 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 ) Call it and the result carries both representations: { "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" }} } The _meta block is the server's identity stamp: the SDK adds it to every 2026-era result, with the version from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns. The server never compares the two fields. This SDK's Client does: return structured_content that doesn't satisfy the output_schema you declared and call_tool raises a RuntimeError that starts with Invalid structured content returned by tool search_books and goes on to quote the jsonschema failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in Structured Output . The dialect is JSON Schema 2020-12 input_schema and output_schema are JSON Schema, and the MCP specification fixes the dialect: a schema with no $schema key is JSON Schema 2020-12 . The schemas MCPServer generates rely on that default (Pydantic writes 2020-12 and omits the key), and a hand-written dict is held to it too, so the full 2020-12 vocabulary is available: server.py from mcp.server import Server , ServerRequestContext from mcp.types import CallToolRequestParams , CallToolResult , ListToolsResult , PaginatedRequestParams , TextContent , Tool FIND_BOOK = Tool ( name = "find_book" , description = "Find one book by ISBN, or by title and author." , input_schema = { "type" : "object" , "properties" : { "isbn" : { "type" : "string" , "pattern" : "^[0-9] {13} $" }, "title" : { "type" : "string" }, "author" : { "type" : "string" }, }, "oneOf" : [{ "required" : [ "isbn" ]}, { "required" : [ "title" , "author" ]}], "additionalProperties" : False , }, ) async def list_tools ( ctx : ServerRequestContext , params : PaginatedRequestParams | None ) -> ListToolsResult : return ListToolsResult ( tools = [ FIND_BOOK ]) async def call_tool ( ctx : ServerRequestContext , params : CallToolRequestParams ) -> CallToolResult : args = params . arguments or {} found = f "ISBN { args [ 'isbn' ] } " if "isbn" in args else f " { args [ 'title' ] !r} by { args [ 'author' ] } " return CallToolResult ( content = [ TextContent ( type = "text" , text = f "Found { found } on shelf C-3." )]) server = Server ( "Bookshop" , on_list_tools = list_tools , on_call_tool = call_tool ) * The root of input_schema must be "type": "object" . Beside it, oneOf , additionalProperties , anyOf , if / then / else , prefixItems , $defs with local $ref s and the rest of the 2020-12 keywords reach the client exactly as written. * No $schema key is needed. Add one only to opt into an older draft: this SDK's Client , which validates structured_content against a tool's output_schema , picks its validator from $schema and uses 2020-12 when there is none. _meta : for the application, not the model content is the part of the answer the model reads. structured_content is the same answer as typed data. _meta is the third channel: data that rides along with the result for the client application , without being part of the answer at all. Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't: server.py 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 ) * You construct it as _meta= , the wire name. The client reads it back as result.meta . * Namespace your keys ( bookshop/record_ids ). The io.modelcontextprotocol/* keys are reserved by the protocol. Warning _meta is a convention between you and the client application, not a guarantee about what reaches the model. The host decides what it renders. Never put a secret in any part of a tool result. Capabilities follow your handlers A Server advertises exactly the method families you gave it handlers for. The Bookshop above passes on_list_tools and on_call_tool and nothing else, so a client connecting to it sees: { "tools" : { "listChanged" : false }} No resources , no prompts : there is nothing to back them. Pass on_list_prompts and prompts appears; pass on_completion and completions appears. MCPServer always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration is the constructor call. The lifespan generic Server is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces: server.py 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

Links found on this page

  1. Skip to content [direct]
  2. en - English [direct]
  3. de - Deutsch [direct]
  4. es - español [direct]
  5. fr - français [direct]
  6. hi - हिन्दी [direct]
  7. ja - 日本語 [direct]
  8. ko - 한국어 [direct]
  9. pt - português (Brasil) [direct]
  10. ru - русский язык [direct]
  11. tr - Türkçe [direct]
  12. uk - українська мова [direct]
  13. zh - 简体中文 [direct]
  14. zh-hant - 繁體中文 [direct]
  15. modelcontextprotocol/python-sdk [direct]
  16. What's new in v2 [direct]
  17. Get started [direct]
  18. Installation [direct]
  19. First steps [direct]
  20. Connect to a real host [direct]
  21. Testing [direct]
  22. Servers [direct]
  23. Tools [direct]
  24. Structured Output [direct]
  25. Resources [direct]
  26. URI templates [direct]
  27. Prompts [direct]
  28. Completions [direct]
  29. Images, audio & icons [direct]
  30. Handling errors [direct]
  31. Inside your handler [direct]
  32. The Context [direct]
  33. Dependencies [direct]
  34. Lifespan [direct]
  35. Elicitation [direct]
  36. Multi-round-trip requests [direct]
  37. Sampling and roots [direct]
  38. Progress [direct]
  39. Logging [direct]
  40. Subscriptions [direct]
  41. Running your server [direct]
  42. Add to an existing app [direct]
  43. Deploy & scale [direct]
  44. Authorization [direct]
  45. OpenTelemetry [direct]
  46. Serving legacy clients [direct]
  47. Clients [direct]
  48. Callbacks [direct]
  49. Transports [direct]
  50. OAuth [direct]
  51. Identity assertion [direct]
  52. Multiple servers [direct]
  53. Subscriptions [direct]
  54. Caching [direct]
  55. Protocol versions [direct]
  56. Deprecated features [direct]
  57. Advanced [direct]
  58. Pagination [direct]
  59. Middleware [direct]
  60. Extensions [direct]
  61. MCP Apps [direct]
  62. Troubleshooting [direct]
  63. Translations [direct]
  64. Migration Guide [direct]
  65. mcp [direct]
  66. mcp_types [direct]
  67. MCP specification [direct]
  68. Zensical [direct]