Skip to content

Commit b68f129

Browse files
authored
Expose script documentation lookup (#123)
Closes #17
1 parent c6a0254 commit b68f129

7 files changed

Lines changed: 490 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ live under [`docs/`](./docs/).
1515
`Millennium-Dawn/tools/` validators and ports the `MD-VSCode-Utility-Tool`
1616
paradox-script parser. It exposes:
1717

18-
- **29 tools** (`resolve_*`, `find_*`, `parse_*`, `validate*`, `generate_*`,
19-
`check_equipment_variant`, `focus_graph`, `check_refs`, `focus_layout`,
18+
- **30 tools** (`resolve_*`, `find_*`, `parse_*`, `validate*`, `generate_*`,
19+
`check_equipment_variant`, `lookup_docs`, `focus_graph`, `check_refs`, `focus_layout`,
2020
`diff_summary`, `check_encoding`, `lint`, `fix_lint`, `review_branch`,
2121
`list_country_content`)
2222
- **6 resources** under the `md://` URI scheme (`md://focus/{id}` etc.)

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ diffs stay visible to the user.
1212
- Wraps the validators in `Millennium-Dawn/tools/validation/` (auto-discovered,
1313
26 at last count).
1414
- Ports the paradox parser from `MD-VSCode-Utility-Tool/src/hoiformat/`.
15-
- 29 tools and 6 `md://` resources.
15+
- 30 tools and 6 `md://` resources.
1616

1717
For agents working on the server itself: see [`CLAUDE.md`](./CLAUDE.md).
1818

@@ -155,7 +155,7 @@ hand for setup / cache priming.
155155

156156
## Tool & resource catalogue
157157

158-
29 tools, 6 resources. Full reference in [`docs/tools.md`](./docs/tools.md).
158+
30 tools, 6 resources. Full reference in [`docs/tools.md`](./docs/tools.md).
159159

160160
### Resolvers — "where is X defined?"
161161

@@ -178,7 +178,9 @@ fixers in-memory and returns the fixed text for the agent to write).
178178

179179
### Analysis
180180

181-
`find_focuses`, `find_references` (paginated; `files_only` mode collapses to
181+
`lookup_docs` (effect, trigger, or modifier references with exact lookup and
182+
close-match suggestions), `find_focuses`, `find_references` (paginated;
183+
`files_only` mode collapses to
182184
a unique file list), `focus_graph` (tiered `summary`/`ids`/`full`/`paths`),
183185
`check_refs` (scoped dangling-reference audit), `focus_layout` (grid
184186
collisions and relative-position chains), `diff_summary` (kind-filterable,

docs/tools.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Tool & Resource Reference
22

3-
29 tools and 6 resources, grouped by purpose. Output shapes show the
3+
30 tools and 6 resources, grouped by purpose. Output shapes show the
44
**default** behaviour — most tools have detail-tier or `limit` knobs.
55

66
All tools return either `{"ok": True, ...}` or `{"ok": False, "error": "..."}`.
@@ -121,6 +121,29 @@ where `value` is `null | str | num | {symbol: str} | [Node, ...]` (block) — se
121121

122122
---
123123

124+
## Script documentation
125+
126+
### `lookup_docs(kind, key?, limit?, offset?) -> dict`
127+
128+
Look up an exact effect, trigger, or modifier key in the matching
129+
`resources/documentation/*_documentation.md` file. Omit `key` to page the
130+
available keys. `kind` is `effect`, `trigger`, or `modifier`; key matching is
131+
case-sensitive. Missing keys return up to five close-match `suggestions`,
132+
paginated by `limit` and `offset`.
133+
134+
Exact results return every definition for the key in `entries`, preserving
135+
repeated upstream definitions. Each definition includes the Markdown `content`,
136+
`file`, `line`, and `end_line`. List results include key and source location
137+
without content. Both modes return `total`, `returned`, and `truncated`, and
138+
are protected by the normal 100 KB output budget.
139+
140+
Returns `{ok, kind, key?, total, returned, truncated, entries}` on a hit or
141+
list, and `{ok: false, kind, key?, total, returned, truncated, error, suggestions}`
142+
on a miss. A missing upstream documentation file is reported as an error
143+
instead of scanning other sources.
144+
145+
---
146+
124147
## Validation
125148

126149
### `check_equipment_variant(text: str, limit?: int, offset?: int) -> dict`

src/md_mcp/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from .tools.equipment_variant_tools import EquipmentVariantChecker, check_equipment_variant_tool
5353
from .tools.lint_fixers import fix_lint_tool
5454
from .tools.linting_tools import lint_tool, review_branch_tool
55+
from .tools.lookup_docs import lookup_docs_tool
5556
from .tools.parser_tools import parse_file_tool, parse_string_tool
5657
from .tools.resolver_tools import (
5758
resolve_decision_tool,
@@ -167,6 +168,10 @@ def parse_string(text: str) -> dict:
167168
name="find_focuses",
168169
description="Search the focus index by tag, prereq, mutex partner, or kind. Returns a paginated id+file+line list.",
169170
)(_bind_tool(find_focuses_tool, settings, focus_index))
171+
mcp.tool(
172+
name="lookup_docs",
173+
description="Look up an effect, trigger, or modifier in resources/documentation; pass key for exact docs or omit it for a paginated key list, with close-match suggestions on misses.",
174+
)(_bind_tool(lookup_docs_tool, settings))
170175

171176
@mcp.tool(name="find_references")
172177
def _find_references(

src/md_mcp/tools/lookup_docs.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from __future__ import annotations
2+
3+
import difflib
4+
import html
5+
import re
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
from ..config import Settings
10+
from ..util.response import coerce_int, enforce_budget, paginate
11+
12+
_DOC_KINDS = frozenset(("effect", "trigger", "modifier"))
13+
_DOC_RELATIVE_PATHS = {
14+
"effect": Path("resources/documentation/effects_documentation.md"),
15+
"trigger": Path("resources/documentation/triggers_documentation.md"),
16+
"modifier": Path("resources/documentation/modifiers_documentation.md"),
17+
}
18+
_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$")
19+
_SPAN_RE = re.compile(r"<span\b[^>]*>.*?</span>", re.IGNORECASE)
20+
_SCOPE_HEADING_RE = re.compile(r"^(?:effects|triggers|modifiers) for scope\b", re.IGNORECASE)
21+
22+
23+
def lookup_docs_tool(
24+
settings: Settings,
25+
kind: str,
26+
key: Optional[str] = None,
27+
limit: int | float | str | None = 100,
28+
offset: int | float | str | None = 0,
29+
) -> dict:
30+
normalized_kind = kind.lower() if isinstance(kind, str) else kind
31+
result_context = {"kind": kind}
32+
if key is not None:
33+
result_context["key"] = key
34+
35+
if normalized_kind not in _DOC_KINDS:
36+
return enforce_budget(
37+
{
38+
"ok": False,
39+
**result_context,
40+
"error": "kind must be one of: effect, trigger, modifier",
41+
}
42+
)
43+
44+
try:
45+
limit = coerce_int(limit, name="limit", default=100)
46+
offset = coerce_int(offset, name="offset", default=0)
47+
except ValueError as exc:
48+
return enforce_budget({"ok": False, **result_context, "error": str(exc)})
49+
50+
relative_path = _DOC_RELATIVE_PATHS[normalized_kind]
51+
path = settings.mod_root / relative_path
52+
if not path.is_file():
53+
return enforce_budget(
54+
{
55+
"ok": False,
56+
**result_context,
57+
"file": relative_path.as_posix(),
58+
"error": f"Documentation file not found: {relative_path.as_posix()}",
59+
}
60+
)
61+
62+
try:
63+
entries = _read_entries(path, relative_path.as_posix())
64+
except (OSError, UnicodeError) as exc:
65+
return enforce_budget(
66+
{
67+
"ok": False,
68+
**result_context,
69+
"file": relative_path.as_posix(),
70+
"error": f"Could not read documentation: {exc}",
71+
}
72+
)
73+
74+
if key is not None:
75+
definitions = entries.get(key)
76+
if definitions is None:
77+
suggestions = _suggestions(key, entries)
78+
page, truncated, total = paginate(suggestions, offset=offset, limit=limit)
79+
return enforce_budget(
80+
{
81+
"ok": False,
82+
**result_context,
83+
"file": relative_path.as_posix(),
84+
"error": f"No {normalized_kind} documentation found for {key!r}",
85+
"total": total,
86+
"returned": len(page),
87+
"truncated": truncated,
88+
"suggestions": page,
89+
},
90+
heavy_keys=("suggestions",),
91+
)
92+
93+
page, truncated, total = paginate(definitions, offset=offset, limit=limit)
94+
first = definitions[0]
95+
return enforce_budget(
96+
{
97+
"ok": True,
98+
"kind": normalized_kind,
99+
"key": key,
100+
"file": first["file"],
101+
"line": first["line"],
102+
"total": total,
103+
"returned": len(page),
104+
"truncated": truncated,
105+
"entries": page,
106+
},
107+
heavy_keys=("entries",),
108+
)
109+
110+
summaries = [_entry_summary(definitions[0]) for definitions in entries.values()]
111+
page, truncated, total = paginate(summaries, offset=offset, limit=limit)
112+
return enforce_budget(
113+
{
114+
"ok": True,
115+
"kind": normalized_kind,
116+
"total": total,
117+
"returned": len(page),
118+
"truncated": truncated,
119+
"entries": page,
120+
},
121+
heavy_keys=("entries",),
122+
)
123+
124+
125+
def _read_entries(path: Path, relative_path: str) -> dict[str, list[dict]]:
126+
text = path.read_text(encoding="utf-8")
127+
lines = text.splitlines()
128+
headings: list[tuple[int, str, bool]] = []
129+
for index, line in enumerate(lines):
130+
match = _HEADING_RE.match(line)
131+
if match is None:
132+
continue
133+
heading = _clean_heading(match.group(1))
134+
headings.append((index, heading, _is_entry_heading(heading)))
135+
136+
entries: dict[str, list[dict]] = {}
137+
for position, (start, key, is_entry) in enumerate(headings):
138+
if not is_entry:
139+
continue
140+
end = headings[position + 1][0] if position + 1 < len(headings) else len(lines)
141+
body = "\n".join(lines[start + 1 : end]).strip()
142+
entry = {
143+
"key": key,
144+
"content": body,
145+
"file": relative_path,
146+
"line": start + 1,
147+
"end_line": end,
148+
}
149+
entries.setdefault(key, []).append(entry)
150+
return entries
151+
152+
153+
def _entry_summary(entry: dict) -> dict:
154+
return {key: entry[key] for key in ("key", "file", "line", "end_line")}
155+
156+
157+
def _clean_heading(heading: str) -> str:
158+
return html.unescape(_SPAN_RE.sub("", heading)).strip()
159+
160+
161+
def _is_entry_heading(heading: str) -> bool:
162+
return heading.lower() != "table of content" and not _SCOPE_HEADING_RE.match(heading)
163+
164+
165+
def _suggestions(key: str, entries: dict[str, list[dict]]) -> list[str]:
166+
keys = list(entries)
167+
lowered = {candidate.lower(): candidate for candidate in keys}
168+
matches = difflib.get_close_matches(key.lower(), lowered, n=5, cutoff=0.5)
169+
return [lowered[match] for match in matches]

0 commit comments

Comments
 (0)