Skip to content

Commit 158195d

Browse files
committed
feat: Enhance darwin_search and hm_show based on agent feedback
- darwin_search: Add relevance scoring to prioritize macOS dock settings over Docker - Exact word matches in option paths get highest priority (100 points) - system.defaults.dock options get 50 bonus points when searching for "dock" - General system.defaults options get 30 points for relevant queries - hm_show: Enhance output to show Type, Default, and Example values - Parse HTML to extract additional metadata beyond basic description - Handle multiline Default/Example values correctly - Clean up HTML tags from descriptions - Fall back gracefully to basic parsing if enhanced parsing fails - Add comprehensive test coverage for both fixes - Test darwin_search dock prioritization - Test enhanced hm_show output - Integration tests with real API calls - Fix existing tests to work with enhanced parsing All tests pass (389 total), linting clean, type checking passes.
1 parent e66fe32 commit 158195d

File tree

7 files changed

+543
-27
lines changed

7 files changed

+543
-27
lines changed

mcp_nixos/server.py

Lines changed: 191 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,21 +1412,167 @@ async def hm_show(
14121412
Plain text with option details (name, type, description) or error with suggestions
14131413
"""
14141414
try:
1415-
# Search more broadly first
1415+
# First try the basic search to check if option exists
14161416
options = parse_html_options(HOME_MANAGER_URL, name, "", 100)
14171417

1418-
# Look for exact match
1418+
# Check for exact match in parsed options
1419+
exact_match = None
14191420
for opt in options:
14201421
if opt["name"] == name:
1421-
info = []
1422-
info.append(f"Option: {name}")
1423-
if opt["type"]:
1424-
info.append(f"Type: {opt['type']}")
1425-
if opt["description"]:
1426-
info.append(f"Description: {opt['description']}")
1427-
return "\n".join(info)
1422+
exact_match = opt
1423+
break
14281424

1429-
# If not found, check if there are similar options to suggest
1425+
# If found, try enhanced parsing for more details
1426+
if exact_match:
1427+
# Try to get more details from full HTML
1428+
try:
1429+
resp = requests.get(HOME_MANAGER_URL, timeout=30)
1430+
resp.raise_for_status()
1431+
soup = BeautifulSoup(resp.text, "html.parser")
1432+
1433+
# Find the specific option by its anchor ID
1434+
anchor_id = f"opt-{name.replace('<name>', '_name_')}"
1435+
anchor = soup.find("a", id=anchor_id)
1436+
1437+
if anchor:
1438+
# Found the anchor, get its parent dt and next dd
1439+
dt = anchor.find_parent("dt")
1440+
if dt:
1441+
dd = dt.find_next_sibling("dd")
1442+
if dd and hasattr(dd, "find_all"): # Ensure dd is a Tag, not NavigableString
1443+
info = []
1444+
info.append(f"Option: {name}")
1445+
1446+
# Extract all available information
1447+
# First, clean up any HTML tags from content
1448+
for tag in dd.find_all("span", class_="filename"):
1449+
tag.decompose() # Remove file references
1450+
for tag in dd.find_all("a", class_="filename"):
1451+
tag.decompose() # Remove file references
1452+
1453+
content = dd.get_text("\n", strip=True)
1454+
lines = content.split("\n")
1455+
1456+
# Parse structured information
1457+
current_section = None
1458+
type_info = ""
1459+
default_value = ""
1460+
example_value = ""
1461+
description_lines = []
1462+
1463+
for i, line in enumerate(lines):
1464+
line_stripped = line.strip()
1465+
if line_stripped.startswith("Type:"):
1466+
type_info = line_stripped[5:].strip()
1467+
current_section = "type"
1468+
elif line_stripped.startswith("Default:"):
1469+
default_value = line_stripped[8:].strip()
1470+
current_section = "default"
1471+
# If empty, capture next non-empty line preserving some formatting
1472+
if not default_value and i + 1 < len(lines):
1473+
for j in range(i + 1, len(lines)):
1474+
next_line = lines[j].strip()
1475+
if next_line and not any(
1476+
next_line.startswith(p)
1477+
for p in ["Type:", "Default:", "Example:", "Declared"]
1478+
):
1479+
default_value = next_line
1480+
break
1481+
elif any(
1482+
next_line.startswith(p)
1483+
for p in ["Type:", "Default:", "Example:", "Declared"]
1484+
):
1485+
break
1486+
elif line_stripped.startswith("Example:"):
1487+
example_value = line_stripped[8:].strip()
1488+
current_section = "example"
1489+
# If empty, capture next non-empty line preserving some formatting
1490+
if not example_value and i + 1 < len(lines):
1491+
for j in range(i + 1, len(lines)):
1492+
next_line = lines[j].strip()
1493+
if next_line and not any(
1494+
next_line.startswith(p)
1495+
for p in ["Type:", "Default:", "Example:", "Declared"]
1496+
):
1497+
example_value = next_line
1498+
break
1499+
elif any(
1500+
next_line.startswith(p)
1501+
for p in ["Type:", "Default:", "Example:", "Declared"]
1502+
):
1503+
break
1504+
elif line_stripped.startswith("Declared"):
1505+
current_section = None # Stop capturing
1506+
elif line_stripped and not any(
1507+
line_stripped.startswith(p) for p in ["Type:", "Default:", "Example:"]
1508+
):
1509+
# Handle multiline values - but only continue if we already started capturing
1510+
# Skip if we just captured this line as the initial value
1511+
if (
1512+
current_section == "default"
1513+
and default_value
1514+
and not default_value.endswith(line_stripped)
1515+
):
1516+
# Only add if it looks like a continuation (e.g., for multi-line JSON)
1517+
if not default_value.endswith("}") and (
1518+
line_stripped.startswith("{")
1519+
or line_stripped.startswith("}")
1520+
or ":" in line_stripped
1521+
):
1522+
default_value += " " + line_stripped
1523+
elif (
1524+
current_section == "example"
1525+
and example_value
1526+
and not example_value.endswith(line_stripped)
1527+
):
1528+
# Only add if it looks like a continuation
1529+
if not example_value.endswith("}") and (
1530+
line_stripped.startswith("{")
1531+
or line_stripped.startswith("}")
1532+
or ":" in line_stripped
1533+
or "=" in line_stripped
1534+
):
1535+
example_value += " " + line_stripped
1536+
elif current_section is None or current_section == "description":
1537+
description_lines.append(line_stripped)
1538+
current_section = "description"
1539+
1540+
# Build formatted output
1541+
if type_info:
1542+
info.append(f"Type: {type_info}")
1543+
1544+
if description_lines:
1545+
desc = " ".join(description_lines[:3]) # First few lines
1546+
# Remove any XML-like tags (except allowed ones)
1547+
import re
1548+
1549+
desc = re.sub(r"<(?!(?:command|package|tool)>)[^>]+>", "", desc)
1550+
if len(desc) > 200:
1551+
desc = desc[:197] + "..."
1552+
info.append(f"Description: {desc}")
1553+
1554+
if default_value and default_value != "null":
1555+
info.append(f"Default: {default_value}")
1556+
1557+
if example_value:
1558+
info.append(f"Example: {example_value}")
1559+
1560+
return "\n".join(info)
1561+
except Exception:
1562+
# If enhanced parsing fails, fall through to basic parsing
1563+
pass
1564+
1565+
# If not found by exact match, still show the basic info
1566+
if exact_match:
1567+
info = []
1568+
info.append(f"Option: {name}")
1569+
if exact_match.get("type"):
1570+
info.append(f"Type: {exact_match['type']}")
1571+
if exact_match.get("description"):
1572+
info.append(f"Description: {exact_match['description']}")
1573+
return "\n".join(info)
1574+
1575+
# If still not found, check if there are similar options to suggest
14301576
if options:
14311577
suggestions = []
14321578
for opt in options[:5]: # Show up to 5 suggestions
@@ -1688,15 +1834,44 @@ async def darwin_search(
16881834
)
16891835

16901836
try:
1691-
options = parse_html_options(DARWIN_URL, query, "", limit)
1837+
# Fetch more results to allow for better sorting
1838+
raw_options = parse_html_options(DARWIN_URL, query, "", limit * 3)
16921839

1693-
if not options:
1840+
if not raw_options:
16941841
return f"No nix-darwin options found matching '{query}'"
16951842

1843+
# Sort by relevance for macOS-specific queries
1844+
query_lower = query.lower()
1845+
1846+
def relevance_score(opt: dict[str, str]) -> tuple[int, str]:
1847+
"""Score options by relevance, especially for macOS system settings."""
1848+
name = opt["name"].lower()
1849+
score = 0
1850+
1851+
# Exact word match in option path gets highest priority
1852+
parts = name.split(".")
1853+
if query_lower in parts:
1854+
score += 100
1855+
1856+
# Prioritize system.defaults for macOS settings
1857+
if query_lower == "dock" and name.startswith("system.defaults.dock"):
1858+
score += 50
1859+
elif name.startswith("system.defaults.") and query_lower in name:
1860+
score += 30
1861+
1862+
# Lower score for partial matches in unrelated contexts
1863+
if query_lower in name:
1864+
score += 10
1865+
1866+
return (-score, name) # Negative for descending sort
1867+
1868+
# Sort and limit results
1869+
sorted_options = sorted(raw_options, key=relevance_score)[:limit]
1870+
16961871
results = []
1697-
results.append(f"Found {len(options)} nix-darwin options matching '{query}':\n")
1872+
results.append(f"Found {len(sorted_options)} nix-darwin options matching '{query}':\n")
16981873

1699-
for opt in options:
1874+
for opt in sorted_options:
17001875
results.append(f"• {opt['name']}")
17011876
if opt["type"]:
17021877
results.append(f" Type: {opt['type']}")
@@ -1707,8 +1882,8 @@ async def darwin_search(
17071882
# Add helpful next steps
17081883
results.append("NEXT STEPS:")
17091884
results.append("━" * 11)
1710-
if len(options) > 0:
1711-
first_opt = options[0]["name"]
1885+
if len(sorted_options) > 0:
1886+
first_opt = sorted_options[0]["name"]
17121887
results.append(f'• Use darwin_show(name="{first_opt}") for full details')
17131888
# Extract prefix for browsing
17141889
prefix = first_opt.split(".")[0] if "." in first_opt else first_opt

0 commit comments

Comments
 (0)