In this post
What this post covers
How this was tested
GitHub Actions runner (2 vCPU AMD EPYC 7763), Python 3.12.15, mcp 2.3.0 (official Python SDK), httpx2 2.13.1. Corpus: FastAPI English docs, 1,831 heading sections, BM25 search only (no embeddings, no LLM). 10 queries per payload row, 100 calls per latency row. Full code and raw output at the end.
How do you expose your docs to an agent through MCP in Python, and how big are the tool results you are putting into the model’s context?
Short answer: two tools (search_docs and get_section) are about 50 lines on top of the official SDK, and the same server runs over stdio and streamable HTTP unchanged. The surprise is size: a search_docs call with k=5 has 1,818 bytes of text, but the full result object on the wire is 3,976 bytes, 2.19x more, because the SDK sends structured output as well as text blocks.
The setup
- SDK: the official
mcppackage, version 2.3.0 as of 2026-10-16. Version 2 renamedFastMCPtoMCPServer(from mcp.server.mcpserver import MCPServer); importingmcp.server.fastmcpon a 2.x install fails with an error pointing to the migration guide. Many tutorials still show the v1 import. Pinmcp<2if you run v1 code. - Server: BM25 over 1,831 heading sections of the FastAPI docs. Two tools:
search_docs(query, k=5, snippet_chars=200)returns id, file, heading, score and a short snippet per hit.get_section(id)returns the full text of one hit.
- Why two tools: search returns small snippets, and the model only pays for full text on the one or two sections it chooses to open.
- Client: the SDK’s own
Client, connecting to the server by launching it as a subprocess (stdio) and by URL (streamable HTTP). I ran HTTP with bothmode="auto"andmode="legacy"(theinitializehandshake). - Measured: bytes of text content, bytes of the whole result object serialized as JSON, and per-call latency over 100 calls. Bytes are not tokens; divide by roughly 4 for a rough token count in English text, but I did not run a tokenizer.
Results
Payload by k and snippet length, mean over 10 queries (stdio and HTTP gave identical sizes):
| call | text blocks (bytes) | whole result JSON (bytes) | ratio |
|---|---|---|---|
| k=3, no snippet | 501 | 1,262 | 2.52x |
| k=3, 200-char snippet | 1,096 | 2,464 | 2.25x |
| k=5, 200-char snippet | 1,818 | 3,976 | 2.19x |
| k=10, 200-char snippet | 3,626 | 7,761 | 2.14x |
| k=10, 800-char snippet | 7,572 | 15,773 | 2.08x |
| k=20, 800-char snippet | 14,394 | 29,850 | 2.07x |
Other numbers:
tools/list: 1,047 bytes for the two tools in auto mode (963 in legacy mode).get_sectionon the top hit: mean 808 bytes, max 2,521.- Latency, 100
search_docscalls withk=5: median 3.2 ms (stdio), 4.2 ms (HTTP, auto), 5.5 ms (HTTP, legacy handshake). p95 was 4.0, 5.1 and 6.3 ms. - Connect: 858 ms for stdio, which includes starting the Python process and building the BM25 index. HTTP connected in 5 to 6 ms because the server was already running.
What the numbers mean
Snippet length and k are your payload knobs. Going from k=5 to k=20 with 800-char snippets takes the text from 1,818 to 14,394 bytes, 7.9x. The default you ship decides how much context every search burns. Defaults of k=5 with 200-char snippets were enough to pick which section to open in my queries, though I did not measure answer quality here.
The structured copy roughly doubles the wire size. With a return type of list[dict], the server sent each hit as its own text block and also a structured_content field. Whether the model sees both depends on your host application; most pass the text content. If you control the client, check what actually enters the prompt. If you control the server, return one compact string or a smaller model class when bytes matter.
Errors were readable when raised as ToolError. Raising a plain ValueError gave the client a generic “Error executing tool get_section” message. With ToolError (from mcp.server.mcpserver.exceptions), the message reached the client with the hint “use an id from search_docs”, which lets the model retry correctly.
Transport overhead is small next to the retrieval. The gap between stdio and HTTP was about 1 ms (median 3.2 vs 4.2 ms, a 1.31x ratio), and the legacy handshake added about another 1.3 ms. For a docs server, choose by deployment: stdio for a local tool launched by the host, HTTP when several clients share one server.
Caveats
- Latency is from one 2-vCPU runner, with client and server on the same machine. A network hop will dominate these few milliseconds.
- BM25 only. Retrieval quality was not evaluated here; see hybrid search with BM25 and RRF for that.
- The HTTP server had no auth, no TLS and no origin checks. Do not expose it as-is. The free MCP lesson covers the basics.
- Byte counts are of this corpus. No LLM was connected, so I did not test how a model chooses between the two tools.
- The SDK v2 API was young at time of writing and changed names from v1, so expect more movement.
Rule of thumb
Make search cheap and fetch explicit: small snippets, k around 5, a separate tool for full text, and ToolError messages that tell the model what to do next. Measure the full result size, not only your text, before you pick defaults.
Code
docs_server.py is the whole server. Run python docs_server.py for stdio or python docs_server.py http 8765 for streamable HTTP at /mcp. bench_mcp.py is the client that produced the numbers; both are in the Experiment files box below, with results.txt.
mcp = MCPServer("fastapi-docs")
@mcp.tool()
def search_docs(query: str, k: int = 5, snippet_chars: int = 200) -> list[dict]:
"""Search the FastAPI docs. Returns up to k hits. Call get_section(id) for full text."""
...
@mcp.tool()
def get_section(id: str) -> dict:
"""Return the full text of one docs section by id."""
if id not in BY_ID:
raise ToolError(f"unknown id {id!r}; use an id from search_docs")
return BY_ID[id]
mcp.run() # stdio
# mcp.run(transport="streamable-http", port=8765)
# client
async with Client("http://127.0.0.1:8765/mcp") as c:
r = await c.call_tool("search_docs", {"query": "upload a file", "k": 5})
Experiment files
Want the production version of this?
A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests. It runs offline in about 60 seconds.
See ShipRAG or get the free RAG checklist first.