Coverage for gco_mcp/tools/docs.py: 95.51%
55 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Documentation discovery MCP tool.
3Wraps three catalogs defined in ``gco_mcp/resources/docs.py``:
4``DOC_METADATA`` (the ``docs/*.md`` guides), ``ROOT_DOC_METADATA`` (selected
5project-level Markdown files at the repository root), and
6``PACKAGE_DOC_METADATA`` (package-level READMEs next to code under
7``gco_mcp/``). It exposes one ``find_docs`` tool with a free-text query plus an
8optional topic filter. The catalogs are merged into one searchable view; each
9result carries the exact resource URI to fetch. Scoring is a deterministic
10weighted sum of topic and summary/name substring matches; results are sorted by
11score descending then name ascending so callers iterating with ``limit`` always
12see a stable ordering.
13"""
15from audit import audit_logged
16from resources.docs import DOC_METADATA, PACKAGE_DOC_METADATA, ROOT_DOC_METADATA
17from server import mcp
20def _catalog() -> dict[str, dict[str, str | list[str]]]:
21 """Return the merged guide, root-document, and package-README catalog.
23 Catalog keys are required to be pairwise disjoint by the docs-index tests,
24 so a plain merge cannot silently replace an entry.
25 """
26 return {**DOC_METADATA, **ROOT_DOC_METADATA, **PACKAGE_DOC_METADATA}
29def _resource_uri(name: str) -> str:
30 """Map a catalog key to the resource URI that serves its content."""
31 if name in ROOT_DOC_METADATA:
32 return f"docs://gco/{name}"
33 if name in PACKAGE_DOC_METADATA:
34 return f"docs://gco/packages/{name}"
35 return f"docs://gco/docs/{name}"
38def _search(query: str | None, topic: str | None) -> list[tuple[str, int]]:
39 """Filter and score docs; return ``[(name, score), ...]`` sorted desc."""
40 results: list[tuple[str, int]] = []
41 q = query.lower() if query else None
42 t = topic.lower() if topic else None
43 for name, meta in _catalog().items():
44 score = 0
45 if t:
46 topics = meta.get("topics", [])
47 if isinstance(topics, list): 47 ↛ 53line 47 didn't jump to line 53 because the condition on line 47 was always true
48 for top in topics:
49 if t in str(top).lower():
50 score += 3
51 # Topic filter is a hard constraint — no match means drop the
52 # entry, even if a query string would have matched the summary.
53 if score == 0:
54 continue
55 if q:
56 # Keyword matches are the strongest free-text signal — every
57 # entry's ``keywords`` list is curated to surface terms a
58 # user is likely to search for (e.g. "vllm", "odcr",
59 # "global accelerator") even when those phrases don't appear
60 # verbatim in the summary.
61 keywords = meta.get("keywords", [])
62 if isinstance(keywords, list): 62 ↛ 66line 62 didn't jump to line 66 because the condition on line 62 was always true
63 for kw in keywords:
64 if q in str(kw).lower():
65 score += 4
66 summary = str(meta.get("summary", "")).lower()
67 if q in summary: 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true
68 score += 1
69 if q in name.lower():
70 score += 1
71 # When the only signal is a query and it didn't hit, drop it.
72 if score == 0 and not t:
73 continue
74 results.append((name, score))
75 results.sort(key=lambda x: (-x[1], x[0]))
76 return results
79def _format(name: str) -> dict[str, object]:
80 """Format a metadata entry for the tool response."""
81 meta = _catalog().get(name, {})
82 return {
83 "name": name,
84 "resource_uri": _resource_uri(name),
85 "summary": meta.get("summary", ""),
86 "topics": meta.get("topics", []),
87 "keywords": meta.get("keywords", []),
88 "related": meta.get("related", []),
89 }
92@mcp.tool(tags={"safe", "docs"})
93@audit_logged
94async def find_docs(
95 query: str | None = None,
96 topic: str | None = None,
97 limit: int = 10,
98) -> list[dict[str, object]]:
99 """`find_docs` — search the docs catalog by topic and free-text query.
101 Searches the ``docs/*.md`` guides, selected root project documents, and
102 package-level READMEs that live next to code under ``gco_mcp/``. Each
103 result carries a ``resource_uri`` naming the exact resource to fetch.
105 Args:
106 query: Free-text query matched against the doc's keywords, summary,
107 and name (case-insensitive substring match).
108 topic: Filter by topic substring (case-insensitive). Acts as a hard
109 filter — entries without a topic match are dropped.
110 limit: Maximum results (default 10). ``limit <= 0`` returns ``[]``.
112 Scoring: topic substring matches contribute 3 pts each; keyword
113 substring matches contribute 4 pts each; summary/name substring
114 matches contribute 1 pt each. Returns the top ``limit`` matches
115 sorted by score descending then name ascending.
116 """
117 if limit <= 0:
118 return []
119 no_filters = not query and not topic
120 if no_filters:
121 # Stable alpha-sorted listing for the no-arg case.
122 names = sorted(_catalog().keys())[:limit]
123 return [_format(name) for name in names]
124 matches = _search(query, topic)
125 return [_format(name) for name, _score in matches[:limit]]