Skip to content

Commit b8e0b69

Browse files
committed
fix(scripts): tools.json generator emits clean, non-duplicate descriptions
Two bugs surfaced when refreshing tools.json for the docker/mcp-registry PR (#81 / docker/mcp-registry#2563): - ast.walk() recursed into nested function definitions, picking up inner `async def apply()` closures inside register_preview_tool wiring as if they were public tools. This produced 5 duplicate "apply" entries on top of the real 54 tools. Fixed by iterating tree.body directly so only top-level async defs are considered. - _extract_description took only the first non-empty line of the docstring, which truncated mid-sentence whenever the first line wrapped. The most visible victim was `get_inventory_movements` (description ended in a comma) — Copilot flagged it on the upstream PR. Eight other tools had the same issue. Fixed by joining the first paragraph and trimming to the first complete sentence. Regenerating produces 54 clean tool entries, matching the production tool set, with no truncations.
1 parent 94f594e commit b8e0b69

1 file changed

Lines changed: 40 additions & 17 deletions

File tree

scripts/generate_tools_json.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,16 @@ def _extract_from_directory(directory: Path) -> list[dict[str, str]]:
8080
with open(py_file) as f:
8181
tree = ast.parse(f.read(), filename=str(py_file))
8282

83-
# Find all async function definitions that are tools
84-
# Tools are the public async functions (not starting with _)
85-
for node in ast.walk(tree):
83+
# Find all async function definitions that are tools.
84+
# Tools are the public async functions (not starting with _).
85+
# Iterate the module body directly — `ast.walk` recurses into
86+
# nested function definitions (e.g. inner `async def apply()`
87+
# closures inside `register_preview_tool` wiring), which would
88+
# surface duplicates that aren't actually registered tools.
89+
for node in tree.body:
8690
if isinstance(node, ast.AsyncFunctionDef) and not node.name.startswith(
8791
"_"
8892
):
89-
# This is a tool function
9093
description = _extract_description(node)
9194
tools.append({"name": node.name, "description": description})
9295

@@ -103,21 +106,41 @@ def _extract_from_directory(directory: Path) -> list[dict[str, str]]:
103106
def _extract_description(node: ast.AsyncFunctionDef) -> str:
104107
"""Extract description from function docstring.
105108
106-
Args:
107-
node: AST node for async function
108-
109-
Returns:
110-
First non-empty line of docstring or default description
109+
Joins the first paragraph of the docstring (everything up to the first
110+
blank line) into a single string, then trims to the first complete
111+
sentence so we don't ship descriptions that end mid-clause when the
112+
first line of the docstring wraps.
111113
"""
112114
docstring = ast.get_docstring(node)
113-
if docstring:
114-
# Extract first non-empty line
115-
lines = [line.strip() for line in docstring.split("\n") if line.strip()]
116-
if lines:
117-
return lines[0]
118-
119-
# Fallback description
120-
return f"Tool: {node.name}"
115+
if not docstring:
116+
return f"Tool: {node.name}"
117+
118+
paragraph_lines: list[str] = []
119+
for line in docstring.split("\n"):
120+
stripped = line.strip()
121+
if not stripped:
122+
if paragraph_lines:
123+
break
124+
continue
125+
paragraph_lines.append(stripped)
126+
127+
if not paragraph_lines:
128+
return f"Tool: {node.name}"
129+
130+
paragraph = " ".join(paragraph_lines)
131+
132+
# Trim to the first sentence. Look for terminal punctuation followed by
133+
# whitespace, so we don't split on e.g. "id, name," in mid-sentence.
134+
for terminator in (". ", "! ", "? "):
135+
idx = paragraph.find(terminator)
136+
if idx != -1:
137+
return paragraph[: idx + 1]
138+
139+
# No terminator — return the full first paragraph, with a trailing period
140+
# if it doesn't already end in punctuation, so descriptions read cleanly.
141+
if paragraph[-1] not in ".!?":
142+
return paragraph + "."
143+
return paragraph
121144

122145

123146
def validate_tools(tools: list[dict[str, str]]) -> None:

0 commit comments

Comments
 (0)