Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ The server currently exposes six MCP tools:
| `detect_python_version` | Detect the user's local Python version and report whether that version has been indexed. |
| `compare_versions` | Diff a Python stdlib symbol between two indexed versions. Returns `change=added|removed|changed|unchanged` with optional `new_in`, `changed_in`, `deprecated_in`, `signature_delta` (advisory heuristic), `see_also_added/removed`, `section_diff`, and `note` deltas. Token-frugal — emits only changed fields, not full content. |

For `search_docs` with `kind="auto"` and an explicit indexed `version`, the
original lookup and full-text search run first. If both return no hits, the
server looks for dotted Python identifiers in the prompt. Exactly one distinct
identifier registered in that version enables a symbol-lookup fallback; repeated
mentions count once and unknown identifiers do not count. Multiple known
identifiers leave the result empty. A result note identifies fallback matches,
which are symbol documentation, not an answer to the complete prompt. Explicit
kinds, searches without a version, nonempty results, and result budgets are
unchanged.

## Why not Context7 or generic docs retrieval?

Use this server when you want precise local Python docs retrieval rather than
Expand Down
23 changes: 23 additions & 0 deletions src/mcp_server_python_docs/services/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""
from __future__ import annotations

import re
import sqlite3

from mcp_server_python_docs.models import SearchDocsResult
Expand All @@ -23,6 +24,8 @@
from mcp_server_python_docs.services.observability import log_tool_call
from mcp_server_python_docs.services.version_resolution import resolve_version_permissive

_DOTTED_IDENTIFIER = re.compile(r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+\b")


class SearchService:
"""Search service dispatching queries through classifier, synonym expansion,
Expand Down Expand Up @@ -108,4 +111,24 @@ def search(
hits = search_symbols(self._db, match_expr, resolved_version, max_results)

self._last_resolution = "fts"
if not hits and kind == "auto" and resolved_version is not None:
# Prompt-only candidates; ambiguity is decided in the requested inventory.
known = {
name
for name in set(_DOTTED_IDENTIFIER.findall(query))
if self._db.execute(
"SELECT 1 FROM symbols s JOIN doc_sets d ON d.id = s.doc_set_id "
"WHERE s.qualified_name = ? AND d.version = ? LIMIT 1",
(name, resolved_version),
).fetchone() is not None
}
if len(known) == 1:
name = known.pop()
hits = lookup_symbols_exact(self._db, name, resolved_version, max_results)
if hits:
self._last_resolution = "exact"
return SearchDocsResult(
hits=hits,
note=f"No full-query results; showing symbol matches for {name!r}.",
)
return SearchDocsResult(hits=hits)
116 changes: 116 additions & 0 deletions tests/test_search_symbol_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Empty-query fallback uses only one known prompt identifier in a requested version."""
from __future__ import annotations

from unittest.mock import Mock, patch

import pytest

from mcp_server_python_docs.retrieval.ranker import lookup_symbols_exact
from mcp_server_python_docs.services.search import SearchService


@pytest.mark.parametrize("prompt", [
"zzmissing Explain `json.dumps`?",
"zzmissing Explain (json.dumps(obj)).",
"zzmissing json.dumps, json.dumps() and json.dumps!",
"zzmissing json.dumps alongside unknown.missing",
"zzmissing Explain [os.path.join()] for Python 3.13?",
])
@pytest.mark.parametrize("max_results", [1, 5])
def test_unique_known_identifier(stability_db, prompt, max_results):
svc = SearchService(stability_db, {})
name = "os.path.join" if "os.path.join" in prompt else "json.dumps"
expected = lookup_symbols_exact(stability_db, name, "3.13", max_results)
assert expected
result = svc.search(prompt, version="3.13", max_results=max_results)
assert result.hits == expected
assert len(result.hits) <= max_results
assert all(hit.version == "3.13" for hit in result.hits)
assert result.note == f"No full-query results; showing symbol matches for {name!r}."
assert svc._last_resolution == "exact"


@pytest.mark.parametrize("prompt", [
"zzmissing json.dumps and json.loads",
"zzmissing json.loads and json.dumps",
"zzmissing unknown.missing",
"zzmissing json.dump", # A partial inventory name must not enable LIKE fallback.
"zzmissing json.dumps.missing",
"zzmissing Python 3.13 or 3.12",
"3.13",
"",
" ",
])
def test_ambiguous_unknown_numeric_or_empty_stays_empty(search_service, prompt):
result = search_service.search(prompt, version="3.13")
assert result.hits == []
assert result.note is None
assert search_service._last_resolution == "fts"


@pytest.mark.parametrize("kind", ["symbol", "section", "example", "page"])
def test_explicit_kinds_do_not_use_fallback(search_service, kind):
result = search_service.search("zzmissing json.dumps", version="3.13", kind=kind)
assert result.hits == []
assert result.note is None


def test_no_version_does_not_use_fallback(search_service):
result = search_service.search("zzmissing json.dumps")
assert result.hits == []
assert result.note is None
# Existing cross-version direct lookup is still available.
assert search_service.search("json.dumps").hits


def test_known_identifiers_are_counted_in_requested_version(stability_db):
stability_db.execute(
"INSERT INTO doc_sets (source, version, language, label, base_url) "
"VALUES ('python-docs', '3.12', 'en', 'Python 3.12', 'https://docs.python.org/3.12/')"
)
stability_db.execute(
"INSERT INTO symbols (doc_set_id, qualified_name, normalized_name, "
"symbol_type, uri, anchor, module) "
"SELECT d.id, s.qualified_name, s.normalized_name, s.symbol_type, "
"s.uri, s.anchor, s.module FROM symbols s CROSS JOIN doc_sets d "
"WHERE s.qualified_name = 'json.dumps' AND d.version = '3.12'"
)
stability_db.commit()
svc = SearchService(stability_db, {})
prompt = "zzmissing json.dumps and json.loads"
assert svc.search(prompt, version="3.13").hits == []
hits = svc.search(prompt, version="3.12").hits
assert hits and all(hit.version == "3.12" for hit in hits)
assert hits[0].title == "json.dumps"
assert svc.search("zzmissing asyncio.TaskGroup", version="3.12").hits == []


def test_original_lookup_and_both_fts_paths_run_before_fallback(stability_db):
from mcp_server_python_docs.services import search

trace = Mock()
with (
patch.object(search, "lookup_symbols_exact", wraps=search.lookup_symbols_exact) as exact,
patch.object(search, "search_sections", wraps=search.search_sections) as sections,
patch.object(search, "search_symbols", wraps=search.search_symbols) as symbols,
):
trace.attach_mock(exact, "exact")
trace.attach_mock(sections, "sections")
trace.attach_mock(symbols, "symbols")
result = SearchService(stability_db, {}).search("zzmissing json.dumps", "3.13")
assert result.hits
assert [call[0] for call in trace.mock_calls] == ["exact", "sections", "symbols", "exact"]
assert exact.call_args_list[0].args[1] == "zzmissing json.dumps"
assert exact.call_args_list[1].args[1] == "json.dumps"


@pytest.mark.parametrize("query,kind", [
("json.dumps", "symbol"),
("json.dumps Serialize", "section"),
])
def test_nonempty_original_results_are_unchanged(search_service, query, kind):
expected = search_service.search(query, version="3.13", kind=kind)
assert expected.hits
actual = search_service.search(query, version="3.13")
assert actual == expected
assert actual.note is None
Loading