Skip to content

Commit 21e4169

Browse files
authored
Merge pull request #32 from Razpines/feat/mcp-source-coverage-search-diagnostics
feat(mcp): add source coverage status and search diagnostics
2 parents 323f628 + ee4034d commit 21e4169

3 files changed

Lines changed: 149 additions & 11 deletions

File tree

src/unity_docs_mcp/mcp_server.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
import sys
88
from pathlib import Path
9-
from typing import List, Optional
9+
from typing import Any, List, Optional
1010

1111
from mcp.server.fastmcp import FastMCP
1212

@@ -76,17 +76,17 @@ def _response_meta(docstore: DocStore, baked_manifest: Optional[dict] = None) ->
7676
return meta
7777

7878

79-
@app.tool()
80-
def search(
81-
query: str,
82-
k: int = 6,
83-
source_types: Optional[List[str] | str] = None,
84-
) -> List[dict]:
85-
docstore = _get_docstore()
86-
meta = _response_meta(docstore)
79+
def _parse_source_types(source_types: Optional[List[str] | str]) -> Optional[List[str]]:
80+
if source_types is None:
81+
return None
8782
if isinstance(source_types, str):
88-
source_types = [s.strip() for s in source_types.split(",") if s.strip()]
89-
results = docstore.search(query=query, k=k, source_types=source_types)
83+
values = [s.strip().lower() for s in source_types.split(",") if s.strip()]
84+
else:
85+
values = [str(s).strip().lower() for s in source_types if str(s).strip()]
86+
return values or None
87+
88+
89+
def _serialize_search_results(results: list[Any], meta: dict) -> List[dict]:
9090
return [
9191
{
9292
"chunk_id": r.chunk_id,
@@ -104,6 +104,65 @@ def search(
104104
]
105105

106106

107+
@app.tool()
108+
def search(
109+
query: str,
110+
k: int = 6,
111+
source_types: Optional[List[str] | str] = None,
112+
debug: bool = False,
113+
) -> List[dict] | dict:
114+
docstore = _get_docstore()
115+
meta = _response_meta(docstore)
116+
parsed_source_types = _parse_source_types(source_types)
117+
available_source_types = docstore.available_source_types()
118+
known_source_types = docstore.known_source_types()
119+
120+
invalid_source_types = []
121+
unavailable_source_types = []
122+
if parsed_source_types:
123+
requested_set = set(parsed_source_types)
124+
known_set = set(known_source_types)
125+
available_set = set(available_source_types)
126+
invalid_source_types = sorted(requested_set - known_set)
127+
unavailable_source_types = sorted((requested_set & known_set) - available_set)
128+
129+
if invalid_source_types or unavailable_source_types:
130+
message_parts = []
131+
if invalid_source_types:
132+
message_parts.append(f"Unsupported source_types: {', '.join(invalid_source_types)}")
133+
if unavailable_source_types:
134+
message_parts.append(f"Requested source_types not present in this index: {', '.join(unavailable_source_types)}")
135+
return {
136+
"error": "invalid_source_types",
137+
"message": ". ".join(message_parts),
138+
"requested_source_types": parsed_source_types or [],
139+
"invalid_source_types": invalid_source_types,
140+
"unavailable_source_types": unavailable_source_types,
141+
"known_source_types": known_source_types,
142+
"available_source_types": available_source_types,
143+
"results": [],
144+
"meta": meta,
145+
}
146+
147+
results = docstore.search(query=query, k=k, source_types=parsed_source_types)
148+
serialized = _serialize_search_results(results, meta)
149+
if not debug:
150+
return serialized
151+
return {
152+
"results": serialized,
153+
"meta": meta,
154+
"debug": {
155+
"query": query,
156+
"k": k,
157+
"requested_source_types": parsed_source_types or [],
158+
"available_source_types": available_source_types,
159+
"known_source_types": known_source_types,
160+
"retrieval_mode": meta["retrieval_mode"],
161+
"result_count": len(serialized),
162+
},
163+
}
164+
165+
107166
@app.tool()
108167
def open(
109168
doc_id: Optional[str] = None,
@@ -177,13 +236,23 @@ def status() -> dict:
177236
paths = make_paths(config)
178237
baked_manifest = _read_manifest(paths.baked_dir / "manifest.json")
179238
index_manifest = _read_manifest(paths.index_dir / "manifest.json")
239+
available_source_types = docstore.available_source_types()
240+
source_type_counts = docstore.source_type_counts()
241+
coverage_warnings: list[str] = []
242+
if "scriptref" not in set(available_source_types):
243+
coverage_warnings.append(
244+
"scriptref source type is not present in the loaded corpus/index; API symbol lookup coverage may be incomplete"
245+
)
180246
return {
181247
"meta": _response_meta(docstore, baked_manifest=baked_manifest),
182248
"paths": vars(config.paths),
183249
"unity_version": config.unity_version,
184250
"embedder": vars(config.index.embedder),
185251
"baked_manifest": baked_manifest,
186252
"index_manifest": index_manifest,
253+
"available_source_types": available_source_types,
254+
"source_type_counts": source_type_counts,
255+
"coverage_warnings": coverage_warnings,
187256
}
188257

189258

src/unity_docs_mcp/tools/ops.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from unity_docs_mcp.index.search import HybridSearcher
1111
from unity_docs_mcp.paths import make_paths
1212

13+
_DEFAULT_SOURCE_TYPES = ("manual", "scriptref")
14+
1315

1416
@dataclass
1517
class DocRecord:
@@ -26,8 +28,10 @@ def __init__(self, config: Config):
2628
self.config = config
2729
self.paths = make_paths(config)
2830
self.corpus = self._load_corpus(self.paths.baked_dir / "corpus.jsonl")
31+
self._doc_source_type_counts = self._count_source_types(self.corpus.values())
2932
self.link_index = self._load_links(self.paths.baked_dir / "link_graph.jsonl")
3033
self.searcher = HybridSearcher(config, self.paths.index_dir)
34+
self._chunk_source_type_counts = self._count_source_types(getattr(self.searcher, "chunk_meta", {}).values())
3135

3236
def _load_corpus(self, path: Path) -> Dict[str, DocRecord]:
3337
records: Dict[str, DocRecord] = {}
@@ -56,6 +60,19 @@ def _load_links(self, path: Path) -> Dict[str, List[str]]:
5660
links.setdefault(row["from_doc_id"], []).append(row["to_doc_id"])
5761
return links
5862

63+
@staticmethod
64+
def _count_source_types(rows) -> Dict[str, int]:
65+
counts: Dict[str, int] = {}
66+
for row in rows:
67+
if isinstance(row, dict):
68+
source_type = row.get("source_type", "")
69+
else:
70+
source_type = getattr(row, "source_type", "")
71+
if not source_type:
72+
continue
73+
counts[source_type] = counts.get(source_type, 0) + 1
74+
return counts
75+
5976
def open_doc(self, doc_id: Optional[str] = None, path: Optional[str] = None) -> Optional[DocRecord]:
6077
target_id = doc_id
6178
if not target_id and path:
@@ -82,3 +99,16 @@ def related(self, doc_id: str, limit: int = 10) -> List[DocRecord]:
8299

83100
def search(self, query: str, k: int = 6, source_types: Optional[List[str]] = None) -> List:
84101
return self.searcher.search(query=query, k=k, source_types=source_types)
102+
103+
def available_source_types(self) -> List[str]:
104+
all_types = set(self._doc_source_type_counts) | set(self._chunk_source_type_counts)
105+
return sorted(all_types)
106+
107+
def known_source_types(self) -> List[str]:
108+
return sorted(set(_DEFAULT_SOURCE_TYPES) | set(self.available_source_types()))
109+
110+
def source_type_counts(self) -> Dict[str, Dict[str, int]]:
111+
return {
112+
"docs": dict(sorted(self._doc_source_type_counts.items())),
113+
"chunks": dict(sorted(self._chunk_source_type_counts.items())),
114+
}

tests/test_mcp_meta.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
class _FakeDocStore:
88
def __init__(self) -> None:
99
self.config = Config()
10+
self._available_source_types = ["manual"]
1011

1112
def search(self, query: str, k: int = 6, source_types=None):
1213
return [
@@ -57,6 +58,15 @@ def related(self, doc_id: str, limit: int = 10):
5758
)
5859
]
5960

61+
def available_source_types(self):
62+
return list(self._available_source_types)
63+
64+
def known_source_types(self):
65+
return ["manual", "scriptref"]
66+
67+
def source_type_counts(self):
68+
return {"docs": {"manual": 1}, "chunks": {"manual": 1}}
69+
6070

6171
def _install_fake_docstore(monkeypatch):
6272
fake = _FakeDocStore()
@@ -91,6 +101,32 @@ def test_open_includes_meta_even_when_missing(monkeypatch):
91101
assert "doc_id" not in missing
92102

93103

104+
def test_search_invalid_source_types_returns_actionable_error(monkeypatch):
105+
_install_fake_docstore(monkeypatch)
106+
result = mcp_server.search("Rigidbody.AddForce", source_types="scripting")
107+
assert result["error"] == "invalid_source_types"
108+
assert result["invalid_source_types"] == ["scripting"]
109+
assert "manual" in result["available_source_types"]
110+
111+
112+
def test_search_unavailable_source_types_returns_actionable_error(monkeypatch):
113+
_install_fake_docstore(monkeypatch)
114+
result = mcp_server.search("Rigidbody.AddForce", source_types="scriptref")
115+
assert result["error"] == "invalid_source_types"
116+
assert result["invalid_source_types"] == []
117+
assert result["unavailable_source_types"] == ["scriptref"]
118+
119+
120+
def test_search_debug_returns_results_and_debug_block(monkeypatch):
121+
_install_fake_docstore(monkeypatch)
122+
result = mcp_server.search("IJobParallelFor batch size", k=3, debug=True)
123+
assert "results" in result
124+
assert "debug" in result
125+
assert result["debug"]["query"] == "IJobParallelFor batch size"
126+
assert result["debug"]["result_count"] == 1
127+
assert result["results"][0]["doc_id"] == "manual/job-system-parallel-for-jobs"
128+
129+
94130
def test_list_files_and_related_include_meta(monkeypatch):
95131
_install_fake_docstore(monkeypatch)
96132
files = mcp_server.list_files("*parallel-for*")
@@ -118,3 +154,6 @@ def _fake_read_manifest(path):
118154
assert status["meta"]["built_on"] == "2026-02-20"
119155
assert "paths" in status
120156
assert "index_manifest" in status
157+
assert status["available_source_types"] == ["manual"]
158+
assert status["source_type_counts"]["docs"]["manual"] == 1
159+
assert status["coverage_warnings"]

0 commit comments

Comments
 (0)