python-docs MCP server
Commit Oct 19, 2026Provides AI agents with live CPython standard library documentation and PyPI package metadata in clean markdown format.
Also known as KEEPEE/python-docs-mcp
- Stars
- 0
- Tools
- 5
- Downloads, 30 days
- —
- Latest version
- —
- Last commit
- Oct 19, 2026
- License
- MIT
What is python-docs MCP?
python-docs provides AI agents with live CPython standard library documentation and PyPI package metadata in clean markdown format. It exposes 5 tools, such as python_docs, python_search, pypi_package.
Summary generated from the project's README
About python-docs MCP server
Providing AI agents with live CPython standard library documentation and PyPI package metadata is what this MCP server enables. It allows coding assistants to access current, real-world APIs in clean markdown format instead of relying on outdated or hallucinated training data. Developers can use it via stdio with clients like Claude Desktop, Cursor, or Cline to ensure their generated Python code targets valid modules and versions.
The server includes tools to resolve specific module identifiers, search through a local index of 229 standard library modules, and fetch PyPI package details including READMEs and version metadata. It features a local SQLite TTL cache and a politeness layer that respects robots.txt and request budgets. Users can install it quickly using uvx or via a local git checkout for development. The system handles complex lookups, such as mapping a general term like ElementTree to its full module path, and provides health checks to monitor cache and network status.
What you can do with it
- 1Retrieve accurate standard library documentationAsk an AI to fetch the markdown documentation for a specific module like json or zipfile to ensure correct API usage.
- 2Search for modules by functionalityFind the correct Python module when you only know the task you want to perform, such as working with zip files.
- 3Inspect PyPI package detailsGet the README, license, and required Python version for a specific third-party package from PyPI.
- 4Verify server and network healthCheck the status of the documentation cache and the connectivity to external documentation sources.
Written from the project's README. Every use case is checked against a quote from it.
python-docs MCP tools 5
Detected from the READMEpython_docspython_searchpypi_packagepython_statushealth_check
| python_docs | — | |
| python_search | — | |
| pypi_package | — | |
| python_status | — | |
| health_check | — |
Configuration
PYTHON_DOCS_MCP_CACHE_DIRPYTHON_DOCS_MCP_POLITENESS_DISABLEDPYTHON_DOCS_MCP_PYPI_JSON_OPT_IN
| Name | Description | |
|---|---|---|
| PYTHON_DOCS_MCP_CACHE_DIR | — | |
| PYTHON_DOCS_MCP_POLITENESS_DISABLED | — | |
| PYTHON_DOCS_MCP_PYPI_JSON_OPT_IN | — |
README
View on GitHubpython-docs-mcp
An MCP (Model Context Protocol) server that gives AI coding agents live CPython standard-library documentation and PyPI package information instead of whatever the model happens to remember. Module pages are scraped from docs.python.org at request time, cached locally, and returned as clean markdown — so generated Python targets real, current APIs rather than deprecated or invented ones.
- Stdlib modules — every module page fetched live from docs.python.org/3/library and converted to markdown (the
/3/line is whatever CPython currently documents — measured3.14.8) - PyPI packages — metadata + README read from the package's robots-allowed project page, optionally pinned to a version (see PyPI source)
- Local search index over all 229 stdlib modules (name + description recorded, so
ElementTreeresolves toxml.etree.elementtreeon its own), rebuilt at most every 7 days with a stale fallback when the network fails - SQLite TTL cache so repeated lookups are instant
- Politeness layer — robots.txt (RFC 9309), per-host throttle,
Retry-After, conditional GET and request budgets (details)
The five tools
| Tool | What it does |
|---|---|
python_docs |
Resolve one identifier (json, xml.etree.ElementTree, ElementTree) to a single stdlib module page as markdown. |
python_search |
Ranked search over the local index of all 229 stdlib modules (name and description). |
pypi_package |
PyPI package metadata + README: resolved version, summary, Requires-Python, license, homepage, project URLs. The README is capped by max_tokens (default 6 000 estimated tokens). |
python_status |
Real health check: index size/age, cache stats, live probes of docs.python.org and pypi.org, and politeness counters. |
health_check |
Server liveness and version. |
Every tool returns a plain dict. Failures come back as {"ok": false, "error": …, "suggestion": …} — a bad lookup never raises into the MCP layer, and no tool delegates to another tool.
Requirements
- Python 3.10 or newer (developed and tested on 3.12)
uvfor the one-command install below (uvxships with it)- The MCP Python SDK 1.x is pinned (
mcp>=1.2.0,<2.0); this package does not support the mcp 2.x rename ofFastMCP
Install & run
One command — no checkout, no token
uvx --from git+https://github.com/KEEPEE/python-docs-mcp.git python-docs
This builds the package in an isolated environment and starts the stdio MCP server. It prints nothing on purpose: stdout is the protocol channel. Stop it with Ctrl-C.
After the repository is updated, force uv to re-resolve the commit:
uvx --refresh --from git+https://github.com/KEEPEE/python-docs-mcp.git python-docs
Local checkout — fastest startup, editable while developing
git clone https://github.com/KEEPEE/python-docs-mcp.git
cd python-docs-mcp
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/python-docs # stdio MCP server
MCP client configuration
Generic stdio client (Claude Desktop, Cursor, Cline, …)
{
"mcpServers": {
"python-docs": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/KEEPEE/python-docs-mcp.git",
"python-docs"
]
}
}
}
With an explicit cache directory (any env you set is passed straight through to the server):
{
"mcpServers": {
"python-docs": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/KEEPEE/python-docs-mcp.git",
"python-docs"
],
"env": {
"PYTHON_DOCS_MCP_CACHE_DIR": "/tmp/python-docs-cache"
}
}
}
}
DeepSeek Harness (cordis-style plugin list)
- id: mcp-python-docs
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: python-docs
transport: stdio
command: uvx
args:
[
'--from',
'git+https://github.com/KEEPEE/python-docs-mcp.git',
'python-docs'
]
To skip the build at client start, point command at the console script of a checkout instead — command: /path/to/python-docs-mcp/.venv/bin/python-docs with args: [].
Tools reference
python_docs(identifier, topic=None, max_tokens=8000)
Resolves an identifier to one module page. Identifier forms, tried in this order:
| Form | Meaning |
|---|---|
xml.etree.ElementTree (any dotted name) |
fetched directly; the docs site only has lowercase filenames, so the lowercased name is tried first and the spelling you gave only as a fallback |
json, zipfile (plain lowercase path) |
fetched directly |
ElementTree (any other plain name) |
resolved through the local index: the unique entry whose full name or last dotted segment matches case-insensitively is fetched; ambiguous or missing matches return an error dict pointing at python_search |
topic keeps only the section whose heading matches it (Basic Usage, Command-line interface, …) plus the page title and description; if nothing matches, the full page is returned with a note listing the available headings. max_tokens is a rough budget (1 token ≈ 4 characters): the markdown is cut at a line boundary and truncated is set.
Returns {"ok", "identifier", "url", "title", "markdown", "truncated", "cached"}, plus an optional note.
// python_docs({ "identifier": "xml.etree.ElementTree", "max_tokens": 250 })
{
"ok": true,
"identifier": "xml.etree.ElementTree",
"url": "https://docs.python.org/3/library/xml.etree.elementtree.html",
"markdown": "# `xml.etree.ElementTree` — The ElementTree XML API **Source code:** [Lib/xml/etree/ElementTree.py](https://github.com/python/cpython/tree/3.14/Lib/xml/etree/ElementTree.py) --- The `xml.etree.ElementTree` module implements a simple and efficient API for pa … [truncated]",
"truncated": true,
"cached": false,
"title": "xml.etree.ElementTree — The ElementTree XML API"
}
A bare name is resolved through the index — same page, no guessing:
// python_docs({ "identifier": "ElementTree", "max_tokens": 160 })
{
"ok": true,
"identifier": "ElementTree",
"url": "https://docs.python.org/3/library/xml.etree.elementtree.html",
"title": "xml.etree.ElementTree — The ElementTree XML API",
"…": "…"
}
A topic that matches nothing tells you what the page does have:
// python_docs({ "identifier": "json", "topic": "nonexistent-section", "max_tokens": 120 })
{
"ok": true,
"identifier": "json",
"url": "https://docs.python.org/3/library/json.html",
"title": "json — JSON encoder and decoder",
"…": "…",
"note": "no section matching 'nonexistent-section'; available headings: `json` — JSON encoder and decoder, Basic Usage, Encoders and Decoders, Let the base class default method raise the TypeError, Exceptions, Standard Compliance and Interoperability, Character Encodings, Infinite and NaN Number Values, Repeated Names Within an Object, Top-level Non-Object, Non-Array Values, Implementation Limitations, Command-line interface, Command-line options"
}
A name that is not a module is an error dict, not an empty page:
// python_docs({ "identifier": "nope-not-a-real-module-xyz" })
{
"ok": false,
"error": "no stdlib module matching 'nope-not-a-real-module-xyz' found in the search index",
"suggestion": "use python_search('nope-not-a-real-module-xyz') to find the right module name"
}
python_search(query, limit=8)
Multi-token ranked search over the local index: every token must match, and both the module name and its one-line description are searched. Returns {"ok", "query", "results": [{module, description, score}], "index_count", "stale"}.
// python_search({ "query": "zip file", "limit": 3 })
{
"ok": true,
"query": "zip file",
"results": [
{ "module": "zipfile", "description": "Work with ZIP archives", "score": 14.0 },
{ "module": "gzip", "description": "Support for gzip files", "score": 9.0 }
],
"index_count": 229,
"stale": false
}
Description matching is what makes this useful when you know the job, not the name:
// python_search({ "query": "element tree", "limit": 3 })
{ "results": [ { "module": "xml.etree.elementtree", "description": "The ElementTree XML API", "score": 12.0 } ], "…": "…" }
// python_search({ "query": "tokeniz", "limit": 3 }) // prefix match
{ "results": [ { "module": "tokenize", "description": "Tokenizer for Python source", "score": 8.0 } ], "…": "…" }
python_search costs no requests while the cached index is fresh; if the index is stale the call rebuilds it (one robots fetch + one library-index page) inside the same budget.
pypi_package(name, version=None, max_tokens=6000)
PyPI metadata + README. name is the distribution name; version pins one release, which is how you read the README a specific version shipped; max_tokens caps the README. Returns {"ok", "name", "version" (resolved), "summary", "requires_python", "license", "homepage", "project_urls", "readme_markdown", "url"} — url is always the human-facing project page — plus truncated when there was a README to cap. Cached 1 day, keyed by name + version.
The README is capped, like python_docs's markdown. A README is the author's own document, and for a lot of packages it is the changelog: measured live 2026-10-07, fetch_pypi_package("pydantic") returns a 109 110-byte README (≈ 27 277 estimated tokens) and zope.interface a 27 KB one. This path had no cap at all while python_docs had one, so one tool call could spill a hundred kilobytes into a context window. max_tokens applies the same rule as python_docs — len(text)//4 estimate, cut at a line boundary, truncated: true, and the same [truncated: showing ~X of ~Y estimated tokens] note inside the body:
// pypi_package({ "name": "pydantic" }) — live output, 2026-10-07
{
"ok": true,
"name": "pydantic",
"version": "2.13.5",
"…": "…",
"readme_markdown": "# Pydantic Validation\n…\n\n[truncated: showing ~5999 of ~27277 estimated tokens]",
"url": "https://pypi.org/project/pydantic/",
"cached": false,
"truncated": true
}
A README that already fits is returned byte-for-byte with truncated: false, and a package with no README makes no truncation claim at all (no truncated key). The cache stores the uncapped document, so a later call with a bigger max_tokens gets more text without another request.
// pypi_package({ "name": "attrs" })
{
"ok": true,
"name": "attrs",
"version": "26.1.0",
"summary": "Classes Without Boilerplate",
"requires_python": ">=3.9",
"license": "MIT",
"homepage": "https://github.com/python-attrs/attrs",
"project_urls": {
"GitHub": "https://github.com/python-attrs/attrs",
"Changelog": "https://www.attrs.org/en/stable/changelog.html",
"Documentation": "https://www.attrs.org/",
"Funding": "https://github.com/sponsors/hynek",
"Tidelift": "https://tidelift.com/subscription/pkg/pypi-attrs?utm_source=pypi-attrs&utm_medium=pypi"
},
"readme_markdown": "[] …",
"url": "https://pypi.org/project/attrs/26.1.0/",
"cached": false
}
A pinned version resolves to exactly that release:
// pypi_package({ "name": "six", "version": "1.16.0" })
{
"ok": true,
"name": "six",
"version": "1.16.0",
"summary": "Python 2 and 3 compatibility utilities",
"requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*",
"license": "MIT License (MIT)",
"homepage": "https://github.com/benjaminp/six",
"url": "https://pypi.org/project/six/1.16.0/",
"…": "…"
}
An unknown name is an error dict — and on PyPI the honest error message is two-sided, because PyPI does not answer unknown names with a plain 404 (see the challenge note):
// pypi_package({ "name": "nope-not-a-real-package-xyz" })
{
"ok": false,
"error": "pypi.org answered with a JavaScript bot-verification page (HTTP 200, not a 404) instead of the project page. Either PyPI is serving its anti-bot challenge for this URL, or the package does not exist — PyPI also answers unknown package names with that page, so a challenge is not proof the name is valid",
"suggestion": "retry later (PyPI serves that page for some cold URLs), check the name (case-sensitive) or drop the version pin, or use the PYTHON_DOCS_MCP_PYPI_JSON_OPT_IN=1 opt-in knowing that PyPI's robots.txt disallows the JSON API"
}
python_status()
Probes docs.python.org and pypi.org with a light GET (both on robots-allowed paths) and reports the index and cache state. overall is ok, degraded or error. The politeness block is diagnostics only and never changes overall.
// python_status()
{
"server": "python-docs-mcp",
"version": "0.2.0",
"checks": {
"search_index": { "status": "ok", "entries": 229, "built_at": "2026-10-07T08:04:39.802131+00:00", "stale": false },
"cache": { "status": "ok", "entries": 11, "expired": 0 },
"docs_python_org": { "status": "ok", "http_status": 200 },
"pypi_org": { "status": "ok", "http_status": 200 }
},
"politeness": {
"status": "ok", "disabled": false,
"requests": 10, "robots_requests": 2, "robots_rows": 2, "robots_fetches": 2, "robots_cache_hits": 7,
"blocked_by_robots": 0, "throttle_waits": 8, "throttle_sleep_s": 3.626,
"challenge_detected": 2, "challenge_retries": 1,
"host_delays": { "docs.python.org": 0.0, "pypi.org": 0.913 },
"budgets": { "tool:pypi_package": [2, 6], "tool:python_status": [2, 8] },
"budget_denied": 0, "conditional": 1, "conditional_skipped": 0, "revalidated_304": 1,
"retries_429": 0, "retries_transport": 0, "stalls": 0
},
"overall": "ok"
}
health_check()
{"status": "ok", "server": "python-docs-mcp", "version": "0.2.0"} — no network, no cache; safe as a liveness probe.
PyPI source (and robots.txt)
This is the one place where this server deliberately does not use the API you might expect, and it is worth understanding before you change it.
PyPI's robots.txt (325 bytes, 12 Disallow lines for User-agent: *) says:
Disallow: /simple/
Disallow: /packages/
Disallow: /_includes/authed/
Disallow: /project/*/submit-malware-report/
Disallow: /pypi/*/json
Disallow: /pypi/*/*/json
Disallow: /pypi*?
Disallow: /search*
Disallow: /_/
Disallow: /integrity/
Disallow: /account/
Disallow: /admin/
pypi_package() reads its metadata from the robots-allowed project page, https://pypi.org/project/<name>/ (or /project/<name>/<version>/), because Disallow: /pypi/*/json and Disallow: /pypi/*/*/json forbid the JSON API this package used to call. /simple/ is not a way around it — it is disallowed too, and it carries no README or project URLs. (Python's own urllib.robotparser did not catch the original violation: on Python < 3.14 it reads /pypi/*/json as a literal prefix and reports that URL as allowed. The RFC 9309 matcher in politeness.py reports it as blocked — verified live: can_fetch("https://pypi.org/pypi/attrs/json") → False, can_fetch("https://pypi.org/project/attrs/") → True.)
The page parser pulls the same fields out of the rendered HTML — name, resolved version, summary, Requires-Python, license (both the modern "License expression" block and the legacy "License" sidebar entry), homepage, project URLs and the README (RST → markdown) — and pypi_package returns exactly the same keys as before, so nothing downstream changed.
The JSON API is opt-in only
export PYTHON_DOCS_MCP_PYPI_JSON_OPT_IN=1
With that set, pypi_package uses https://pypi.org/pypi/<name>/json again. That endpoint is disallowed by robots.txt, so using it is your responsibility, and every result produced that way carries a notice field saying so — verbatim:
{
"…": "…",
"notice": "PyPI's robots.txt disallows /pypi/*/json and /pypi/*/*/json for every user-agent. The JSON API was used because PYTHON_DOCS_MCP_PYPI_JSON_OPT_IN=1 is set; that is a deliberate, user-authorised bypass of the politeness layer and it is the user's responsibility."
}
With the opt-in off (the default) the JSON endpoint is never requested — not even as a fallback when the project page fails, and the politeness layer blocks that URL independently, so the violation cannot come back through another door. With the opt-in on, the project page is never requested. The two sources never fall back into one another.
Known trade-off: the JS "Client Challenge"
PyPI's CDN (Fastly) answers some project-page URLs with a ~3 KB JavaScript Client Challenge instead of the page — HTTP 200, not a 404. Measured on this repo's fixtures: the challenge page is 3,038 bytes, a real attrs project page is 130,507 bytes.
This is a real trade-off of reading the allowed HTML page instead of the JSON API, and it has a sharp edge you should know about:
A package that does not exist can look exactly like a bot challenge. PyPI answers unknown package names with that same challenge page — reproduced live on 2026-10-07:
pypi.org/project/nope-not-a-real-package-xyz/returned HTTP 200 with the challenge body. A challenge is therefore not proof the name is valid, and a 404 is not the only way a name can be wrong.
The tool reports both possibilities instead of guessing:
// the error text, verbatim
"pypi.org answered with a JavaScript bot-verification page (HTTP 200, not a 404) instead of the project page. Either PyPI is serving its anti-bot challenge for this URL, or the package does not exist — PyPI also answers unknown package names with that page, so a challenge is not proof the name is valid"
What the code does about it:
- the politeness layer's optional
challenge_detectorhook is wired to a PyPI-specific predicate (fetchers.pypi_challenge_detector): small body + HTML + a challenge marker (_fs-ch-, "Client Challenge") + none of the markers a real project page always carries (project-header__name,package-header__name,pip-command); - when it fires, the layer waits (escalated per-host delay) and retries that same URL once — never another endpoint. Measured:
challenge_detected: 2,challenge_retries: 1, and the whole lookup spends 3 of its 6 budget units; - if the answer is still a challenge, the body is never parsed into a fake package, never cached, and never answered with a fallback to the robots-disallowed JSON endpoint. A genuine page is fetched exactly once — the detector does not add a request for it (covered by
tests/test_politeness_wiring.py).
Caching & politeness
Cache
Everything lives in one directory: ~/.cache/python-docs-mcp by default, overridable with PYTHON_DOCS_MCP_CACHE_DIR.
| File | Contents | TTL |
|---|---|---|
cache.db |
module pages (parsed result + raw body + ETag / Last-Modified), the search index, PyPI metadata |
module docs 7 days, search index 7 days, PyPI metadata 1 day |
robots.db |
the politeness layer's robots.txt cache | 7 days per host |
Delete the files to force a full refresh. A cache problem is never a tool failure: if the directory is unwritable the server simply runs without a cache and says so in python_status().checks.cache.
python-docs MCP questions
What tools does python-docs MCP provide?
It exposes 5 tools, including python_docs, python_search, pypi_package, python_status, health_check.
How does the search function work for standard library modules?
It uses a local ranked search index over all 229 stdlib modules, searching both module names and their descriptions.
Does the server handle large PyPI READMEs?
Yes, the README content is capped by a max_tokens parameter to prevent overwhelming the AI's context window.
Is there a cache to speed up repeated requests?
Yes, it uses a SQLite TTL cache so that repeated lookups are instant.