python-docs-mcp-server 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. mcp_server_python_docs/__init__.py +17 -0
  2. mcp_server_python_docs/__main__.py +574 -0
  3. mcp_server_python_docs/app_context.py +33 -0
  4. mcp_server_python_docs/data/synonyms.yaml +194 -0
  5. mcp_server_python_docs/detection.py +82 -0
  6. mcp_server_python_docs/diagnostics.py +74 -0
  7. mcp_server_python_docs/errors.py +33 -0
  8. mcp_server_python_docs/ingestion/__init__.py +1 -0
  9. mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
  10. mcp_server_python_docs/ingestion/inventory.py +178 -0
  11. mcp_server_python_docs/ingestion/publish.py +504 -0
  12. mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
  13. mcp_server_python_docs/models.py +216 -0
  14. mcp_server_python_docs/py.typed +0 -0
  15. mcp_server_python_docs/retrieval/__init__.py +31 -0
  16. mcp_server_python_docs/retrieval/budget.py +63 -0
  17. mcp_server_python_docs/retrieval/query.py +183 -0
  18. mcp_server_python_docs/retrieval/ranker.py +369 -0
  19. mcp_server_python_docs/server.py +329 -0
  20. mcp_server_python_docs/services/__init__.py +7 -0
  21. mcp_server_python_docs/services/cache.py +111 -0
  22. mcp_server_python_docs/services/content.py +155 -0
  23. mcp_server_python_docs/services/observability.py +130 -0
  24. mcp_server_python_docs/services/package_docs.py +161 -0
  25. mcp_server_python_docs/services/persistent_cache.py +147 -0
  26. mcp_server_python_docs/services/search.py +111 -0
  27. mcp_server_python_docs/services/version.py +47 -0
  28. mcp_server_python_docs/services/version_resolution.py +79 -0
  29. mcp_server_python_docs/storage/__init__.py +1 -0
  30. mcp_server_python_docs/storage/db.py +146 -0
  31. mcp_server_python_docs/storage/schema.sql +136 -0
  32. python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
  33. python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
  34. python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
  35. python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
  36. python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,111 @@
1
+ """LRU cache wrappers for hot read paths (OPS-04, OPS-05).
2
+
3
+ Process-lifetime-scoped caches with no TTL and no invalidation.
4
+ Users restart the server on rebuild (documented in PUBL-05).
5
+
6
+ The cached functions are created as closures that capture the
7
+ sqlite3.Connection at construction time. This is necessary because
8
+ lru_cache requires hashable arguments, and sqlite3.Connection is
9
+ not hashable.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ from collections.abc import Callable
15
+ from functools import lru_cache
16
+ from typing import NamedTuple
17
+
18
+
19
+ class CachedSection(NamedTuple):
20
+ """Cached section data -- lightweight container for LRU cache."""
21
+
22
+ heading: str
23
+ content_text: str
24
+ anchor: str
25
+ uri: str
26
+ document_id: int
27
+
28
+
29
+ class CachedSymbol(NamedTuple):
30
+ """Cached symbol resolution result."""
31
+
32
+ qualified_name: str
33
+ symbol_type: str
34
+ uri: str
35
+ anchor: str
36
+ module: str
37
+ version: str
38
+
39
+
40
+ def create_section_cache(db: sqlite3.Connection) -> Callable[[int], CachedSection | None]:
41
+ """Create an LRU-cached section lookup function (OPS-04).
42
+
43
+ Args:
44
+ db: Read-only SQLite connection (captured by closure).
45
+
46
+ Returns:
47
+ A function ``get_section(section_id: int) -> CachedSection | None``
48
+ with maxsize=512.
49
+ """
50
+
51
+ @lru_cache(maxsize=512)
52
+ def get_section_cached(section_id: int) -> CachedSection | None:
53
+ row = db.execute(
54
+ """
55
+ SELECT heading, content_text, anchor, uri, document_id
56
+ FROM sections
57
+ WHERE id = ?
58
+ """,
59
+ (section_id,),
60
+ ).fetchone()
61
+ if row is None:
62
+ return None
63
+ return CachedSection(
64
+ heading=row["heading"] or "",
65
+ content_text=row["content_text"] or "",
66
+ anchor=row["anchor"] or "",
67
+ uri=row["uri"] or "",
68
+ document_id=row["document_id"],
69
+ )
70
+
71
+ return get_section_cached
72
+
73
+
74
+ def create_symbol_cache(db: sqlite3.Connection) -> Callable[[str, str], CachedSymbol | None]:
75
+ """Create an LRU-cached symbol resolution function (OPS-04).
76
+
77
+ Args:
78
+ db: Read-only SQLite connection (captured by closure).
79
+
80
+ Returns:
81
+ A function ``resolve_symbol(qualified_name: str, version: str) -> CachedSymbol | None``
82
+ with maxsize=128.
83
+ """
84
+
85
+ @lru_cache(maxsize=128)
86
+ def resolve_symbol_cached(
87
+ qualified_name: str, version: str
88
+ ) -> CachedSymbol | None:
89
+ row = db.execute(
90
+ """
91
+ SELECT s.qualified_name, s.symbol_type, s.uri, s.anchor,
92
+ s.module, d.version
93
+ FROM symbols s
94
+ JOIN doc_sets d ON s.doc_set_id = d.id
95
+ WHERE s.qualified_name = ? AND d.version = ?
96
+ LIMIT 1
97
+ """,
98
+ (qualified_name, version),
99
+ ).fetchone()
100
+ if row is None:
101
+ return None
102
+ return CachedSymbol(
103
+ qualified_name=row["qualified_name"],
104
+ symbol_type=row["symbol_type"] or "symbol",
105
+ uri=row["uri"],
106
+ anchor=row["anchor"] or "",
107
+ module=row["module"] or "",
108
+ version=row["version"],
109
+ )
110
+
111
+ return resolve_symbol_cached
@@ -0,0 +1,155 @@
1
+ """Content service — handles get_docs for page and section retrieval.
2
+
3
+ When anchor is provided, returns just that section.
4
+ When omitted, returns the full page with truncation/pagination.
5
+ No MCP types imported — dependency rule enforced.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sqlite3
10
+
11
+ from mcp_server_python_docs.errors import PageNotFoundError
12
+ from mcp_server_python_docs.models import GetDocsResult
13
+ from mcp_server_python_docs.retrieval.budget import apply_budget
14
+ from mcp_server_python_docs.services.cache import create_section_cache
15
+ from mcp_server_python_docs.services.observability import log_tool_call
16
+ from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
17
+ from mcp_server_python_docs.services.version_resolution import resolve_version_strict
18
+
19
+
20
+ class ContentService:
21
+ """Content retrieval service for get_docs tool.
22
+
23
+ When anchor is provided, returns just that section.
24
+ When omitted, returns the full page with truncation/pagination.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ db: sqlite3.Connection,
30
+ persistent_cache: PersistentDocsCache | None = None,
31
+ ) -> None:
32
+ self._db = db
33
+ self._get_section = create_section_cache(db)
34
+ self._persistent_cache = persistent_cache
35
+
36
+ def _resolve_version(self, version: str | None) -> str:
37
+ """Resolve version to a concrete version string using shared resolution logic.
38
+
39
+ Defaults to the is_default=1 version when None is passed.
40
+ Raises VersionNotFoundError for unknown versions (MVER-03).
41
+ """
42
+ return resolve_version_strict(self._db, version)
43
+
44
+ @log_tool_call("get_docs")
45
+ def get_docs(
46
+ self,
47
+ slug: str,
48
+ version: str | None = None,
49
+ anchor: str | None = None,
50
+ max_chars: int = 8000,
51
+ start_index: int = 0,
52
+ ) -> GetDocsResult:
53
+ """Retrieve documentation content by slug, optionally narrowed to a section by anchor."""
54
+ resolved_version = self._resolve_version(version)
55
+
56
+ if self._persistent_cache is not None:
57
+ cached = self._persistent_cache.get(
58
+ version=resolved_version,
59
+ slug=slug,
60
+ anchor=anchor,
61
+ max_chars=max_chars,
62
+ start_index=start_index,
63
+ )
64
+ if cached is not None:
65
+ return cached
66
+
67
+ # Find the document
68
+ doc_row = self._db.execute(
69
+ """
70
+ SELECT d.id, d.title, d.slug
71
+ FROM documents d
72
+ JOIN doc_sets ds ON d.doc_set_id = ds.id
73
+ WHERE d.slug = ? AND ds.version = ?
74
+ LIMIT 1
75
+ """,
76
+ (slug, resolved_version),
77
+ ).fetchone()
78
+
79
+ if doc_row is None:
80
+ raise PageNotFoundError(
81
+ f"Page {slug!r} not found for version {resolved_version}"
82
+ )
83
+
84
+ doc_id = doc_row["id"]
85
+ doc_title = doc_row["title"]
86
+
87
+ if anchor is not None:
88
+ # Section-level retrieval — use cache for repeat reads (OPS-04)
89
+ id_row = self._db.execute(
90
+ "SELECT id FROM sections WHERE document_id = ? AND anchor = ? LIMIT 1",
91
+ (doc_id, anchor),
92
+ ).fetchone()
93
+
94
+ if id_row is None:
95
+ raise PageNotFoundError(
96
+ f"Section {anchor!r} not found in {slug!r} v{resolved_version}"
97
+ )
98
+
99
+ cached = self._get_section(id_row["id"])
100
+ if cached is not None:
101
+ full_text = cached.content_text
102
+ title = cached.heading or doc_title
103
+ else:
104
+ raise PageNotFoundError(
105
+ f"Section {anchor!r} not found in {slug!r} v{resolved_version}"
106
+ )
107
+ else:
108
+ # Page-level retrieval: concatenate all sections in ordinal order
109
+ section_rows = self._db.execute(
110
+ """
111
+ SELECT heading, content_text
112
+ FROM sections
113
+ WHERE document_id = ?
114
+ ORDER BY ordinal
115
+ """,
116
+ (doc_id,),
117
+ ).fetchall()
118
+
119
+ if not section_rows:
120
+ full_text = ""
121
+ else:
122
+ parts = []
123
+ for row in section_rows:
124
+ heading = row["heading"] or ""
125
+ content = row["content_text"] or ""
126
+ if heading:
127
+ parts.append(f"## {heading}\n\n{content}")
128
+ else:
129
+ parts.append(content)
130
+ full_text = "\n\n".join(parts)
131
+
132
+ title = doc_title
133
+
134
+ # Apply budget enforcement (RETR-08)
135
+ truncated_text, is_truncated, next_idx = apply_budget(
136
+ full_text, max_chars, start_index
137
+ )
138
+
139
+ result = GetDocsResult(
140
+ content=truncated_text,
141
+ slug=slug,
142
+ title=title,
143
+ version=resolved_version,
144
+ anchor=anchor,
145
+ char_count=len(full_text),
146
+ truncated=is_truncated,
147
+ next_start_index=next_idx,
148
+ )
149
+ if self._persistent_cache is not None:
150
+ self._persistent_cache.put(
151
+ result=result,
152
+ max_chars=max_chars,
153
+ start_index=start_index,
154
+ )
155
+ return result
@@ -0,0 +1,130 @@
1
+ """Structured logging for service method calls (OPS-01, OPS-02, OPS-03).
2
+
3
+ Implements per-service-method decorators that log structured key=value (logfmt)
4
+ lines to stderr. NOT FastMCP middleware — the MCP SDK middleware surface is
5
+ unstable, so we instrument at the service layer.
6
+
7
+ Log format (logfmt — D-10 from Phase 1):
8
+ tool=search_docs version=3.13 latency_ms=12 result_count=5
9
+ truncated=false resolution=fts synonym_expansion=yes
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import inspect
15
+ import sys
16
+ import time
17
+ from collections.abc import Callable
18
+ from types import TracebackType
19
+ from typing import Any
20
+
21
+
22
+ def _format_logfmt(**fields: Any) -> str:
23
+ """Format fields as logfmt key=value pairs.
24
+
25
+ Handles None (omitted), bool (lowercase), and strings with spaces (quoted).
26
+ """
27
+ parts = []
28
+ for key, value in fields.items():
29
+ if value is None:
30
+ continue
31
+ if isinstance(value, bool):
32
+ parts.append(f"{key}={str(value).lower()}")
33
+ elif isinstance(value, float):
34
+ parts.append(f"{key}={value:.1f}")
35
+ elif isinstance(value, str) and " " in value:
36
+ escaped = str(value).replace('"', '\\"')
37
+ parts.append(f'{key}="{escaped}"')
38
+ else:
39
+ parts.append(f"{key}={value}")
40
+ return " ".join(parts)
41
+
42
+
43
+ def log_tool_call(tool_name: str) -> Callable:
44
+ """Decorator that logs structured info for every service method call.
45
+
46
+ Extracts version, result_count, truncated, resolution path, and
47
+ synonym_expansion from the method arguments and return value.
48
+
49
+ Note: This decorator only works with synchronous service methods.
50
+ If a method becomes async, the wrapper must be updated to detect
51
+ coroutines and await the result, otherwise timing will be wrong.
52
+
53
+ Args:
54
+ tool_name: Name of the MCP tool (search_docs, get_docs, list_versions).
55
+ """
56
+
57
+ def decorator(fn: Callable) -> Callable:
58
+ @functools.wraps(fn)
59
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
60
+ start = time.monotonic()
61
+ result: Any = None
62
+ error: Exception | None = None
63
+ error_tb: TracebackType | None = None
64
+
65
+ try:
66
+ result = fn(self, *args, **kwargs)
67
+ except Exception as exc:
68
+ error = exc
69
+ error_tb = exc.__traceback__
70
+
71
+ elapsed_ms = (time.monotonic() - start) * 1000
72
+
73
+ # Extract structured fields from args/kwargs and result
74
+ fields: dict[str, Any] = {
75
+ "tool": tool_name,
76
+ "latency_ms": round(elapsed_ms, 1),
77
+ }
78
+
79
+ # Extract version by name via inspect (WR-05: avoids fragile positional indexing)
80
+ try:
81
+ sig = inspect.signature(fn)
82
+ bound = sig.bind(self, *args, **kwargs)
83
+ bound.apply_defaults()
84
+ version_val = bound.arguments.get("version")
85
+ except (TypeError, ValueError):
86
+ version_val = kwargs.get("version")
87
+ fields["version"] = version_val or "default"
88
+
89
+ if error is None:
90
+ # Extract result-specific fields
91
+ if hasattr(result, "hits"):
92
+ # SearchDocsResult
93
+ fields["result_count"] = len(result.hits)
94
+ # Resolution path from service state
95
+ if hasattr(self, "_last_resolution"):
96
+ fields["resolution"] = self._last_resolution
97
+ else:
98
+ fields["resolution"] = "fts"
99
+ fields["truncated"] = False
100
+ elif hasattr(result, "truncated"):
101
+ # GetDocsResult
102
+ fields["result_count"] = 1 if result.content else 0
103
+ fields["truncated"] = result.truncated
104
+ fields["resolution"] = "exact"
105
+ elif hasattr(result, "versions"):
106
+ # ListVersionsResult
107
+ fields["result_count"] = len(result.versions)
108
+ fields["truncated"] = False
109
+ fields["resolution"] = "exact"
110
+
111
+ # Synonym expansion detection from service state
112
+ if hasattr(self, "_last_synonym_expanded"):
113
+ fields["synonym_expansion"] = (
114
+ "yes" if self._last_synonym_expanded else "no"
115
+ )
116
+ else:
117
+ fields["error"] = type(error).__name__
118
+
119
+ # Write logfmt line to stderr (HYGN-01 safe — stderr only)
120
+ log_line = _format_logfmt(**fields)
121
+ print(log_line, file=sys.stderr)
122
+
123
+ if error is not None:
124
+ raise error.with_traceback(error_tb)
125
+
126
+ return result
127
+
128
+ return wrapper
129
+
130
+ return decorator
@@ -0,0 +1,161 @@
1
+ """Controlled package docs lookup using official PyPI JSON metadata.
2
+
3
+ Source: https://docs.pypi.org/api/json/ documents GET /pypi/<project>/json.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from collections.abc import Callable
11
+ from typing import Protocol, cast
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import quote, urlparse
14
+ from urllib.request import Request, urlopen
15
+
16
+ from mcp_server_python_docs.models import (
17
+ PackageDocsResult,
18
+ PackageDocsSource,
19
+ PackageKind,
20
+ )
21
+ from mcp_server_python_docs.services.observability import log_tool_call
22
+
23
+ _ALLOWED = {
24
+ "documentation",
25
+ "docs",
26
+ "homepage",
27
+ "home page",
28
+ "source",
29
+ "source code",
30
+ "repository",
31
+ "repo",
32
+ }
33
+ _PYPI_METADATA_MAX_BYTES = 5 * 1024 * 1024
34
+
35
+
36
+ class _HTTPResponse(Protocol):
37
+ def read(self, size: int = -1) -> bytes: ...
38
+ def __enter__(self) -> "_HTTPResponse": ...
39
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool | None: ...
40
+
41
+
42
+ Fetcher = Callable[[str, float], _HTTPResponse]
43
+
44
+
45
+ def _default_fetcher(url: str, timeout: float) -> _HTTPResponse:
46
+ req = Request(
47
+ url,
48
+ headers={"Accept": "application/json", "User-Agent": "python-docs-mcp-server"},
49
+ )
50
+ return urlopen(req, timeout=timeout)
51
+
52
+
53
+ def _normalize(name: str) -> str:
54
+ return quote(re.sub(r"[-_.]+", "-", name.strip().lower()), safe="-")
55
+
56
+
57
+ def _http_url(url: object) -> str | None:
58
+ if not isinstance(url, str):
59
+ return None
60
+ parsed = urlparse(url.strip())
61
+ return url.strip() if parsed.scheme in {"http", "https"} and parsed.netloc else None
62
+
63
+
64
+ def _source(label: str, url: object, kind: PackageKind) -> PackageDocsSource | None:
65
+ valid = _http_url(url)
66
+ if valid is None:
67
+ return None
68
+ return PackageDocsSource(label=label, url=valid, kind=kind, declared_by="PyPI project metadata")
69
+
70
+
71
+ def _read_limited(response: _HTTPResponse) -> bytes | None:
72
+ data = response.read(_PYPI_METADATA_MAX_BYTES + 1)
73
+ if len(data) > _PYPI_METADATA_MAX_BYTES:
74
+ return None
75
+ return data
76
+
77
+
78
+ def _empty_result(package: str, metadata_source: str, note: str) -> PackageDocsResult:
79
+ return PackageDocsResult(
80
+ package=package,
81
+ version="",
82
+ metadata_source=metadata_source,
83
+ sources=[],
84
+ note=note,
85
+ )
86
+
87
+
88
+ class PackageDocsService:
89
+ """Return package-declared docs/homepage/source URLs from PyPI metadata only."""
90
+
91
+ def __init__(self, fetcher: Fetcher = _default_fetcher, timeout: float = 10.0) -> None:
92
+ self._fetcher = fetcher
93
+ self._timeout = timeout
94
+
95
+ @log_tool_call("lookup_package_docs")
96
+ def lookup(self, package: str) -> PackageDocsResult:
97
+ project = _normalize(package)
98
+ metadata_source = f"https://pypi.org/pypi/{project}/json"
99
+ try:
100
+ with self._fetcher(metadata_source, self._timeout) as response:
101
+ data = _read_limited(response)
102
+ if data is None:
103
+ return _empty_result(
104
+ package, metadata_source, "PyPI metadata exceeded size limit."
105
+ )
106
+ payload = json.loads(data.decode("utf-8"))
107
+ except HTTPError as e:
108
+ note = (
109
+ "Package not found on PyPI." if e.code == 404 else f"PyPI returned HTTP {e.code}."
110
+ )
111
+ return _empty_result(package, metadata_source, note)
112
+ except (URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as e:
113
+ return _empty_result(
114
+ package,
115
+ metadata_source,
116
+ f"Unable to retrieve PyPI metadata: {type(e).__name__}.",
117
+ )
118
+
119
+ info = payload.get("info") if isinstance(payload, dict) else {}
120
+ info = info if isinstance(info, dict) else {}
121
+ sources = [
122
+ s
123
+ for s in (
124
+ _source(
125
+ "PyPI project",
126
+ info.get("project_url") or f"https://pypi.org/project/{project}/",
127
+ "pypi",
128
+ ),
129
+ _source("Documentation", info.get("docs_url"), "docs"),
130
+ _source("Homepage", info.get("home_page"), "homepage"),
131
+ )
132
+ if s is not None
133
+ ]
134
+ skipped: list[str] = []
135
+ project_urls = info.get("project_urls")
136
+ if isinstance(project_urls, dict):
137
+ for label, url in project_urls.items():
138
+ lowered = str(label).strip().lower()
139
+ if lowered in _ALLOWED:
140
+ # Runtime-safe: members of `_ALLOWED` (with spaces → underscores)
141
+ # are exactly the non-pypi entries in the PackageKind Literal.
142
+ derived_kind = cast(PackageKind, lowered.replace(" ", "_"))
143
+ found = _source(str(label), url, derived_kind)
144
+ if found is not None and found not in sources:
145
+ sources.append(found)
146
+ else:
147
+ skipped.append(str(label))
148
+ note = None
149
+ if skipped:
150
+ note = (
151
+ "Ignored project URL labels outside the controlled allowlist: "
152
+ f"{', '.join(sorted(skipped))}."
153
+ )
154
+ return PackageDocsResult(
155
+ package=str(info.get("name") or package),
156
+ version=str(info.get("version") or ""),
157
+ summary=str(info.get("summary") or ""),
158
+ metadata_source=metadata_source,
159
+ sources=sources,
160
+ note=note,
161
+ )