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

1"""Documentation discovery MCP tool. 

2 

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""" 

14 

15from audit import audit_logged 

16from resources.docs import DOC_METADATA, PACKAGE_DOC_METADATA, ROOT_DOC_METADATA 

17from server import mcp 

18 

19 

20def _catalog() -> dict[str, dict[str, str | list[str]]]: 

21 """Return the merged guide, root-document, and package-README catalog. 

22 

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} 

27 

28 

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}" 

36 

37 

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 

77 

78 

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 } 

90 

91 

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. 

100 

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. 

104 

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 ``[]``. 

111 

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]]