Generate command references from live registrations
This commit is contained in:
parent
395d732348
commit
85f6629cb1
2 changed files with 374 additions and 0 deletions
138
tests/test_command_reference.py
Normal file
138
tests/test_command_reference.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.types import Tool
|
||||
|
||||
from docforge.cli import _parser
|
||||
from docforge.command_reference import (
|
||||
cli_command_references,
|
||||
mcp_tool_references,
|
||||
render_cli_reference_markdown,
|
||||
render_command_reference_markdown,
|
||||
render_mcp_reference_markdown,
|
||||
)
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import ALL_TOOLS, APPLICATION_TOOLS, create_server
|
||||
from docforge.project import Project
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class CommandReferenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
def copy_fixture(self, destination: Path) -> Path:
|
||||
root = destination / "alpha"
|
||||
shutil.copytree(FIXTURES / "alpha", root)
|
||||
return root
|
||||
|
||||
def test_cli_reference_is_derived_from_the_real_parser(self) -> None:
|
||||
parser = _parser()
|
||||
references = cli_command_references(parser)
|
||||
subparsers = next(
|
||||
action
|
||||
for action in parser._actions # pyright: ignore[reportPrivateUsage]
|
||||
if isinstance(
|
||||
action,
|
||||
argparse._SubParsersAction, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
)
|
||||
expected = {
|
||||
name: " ".join(command.format_usage().removeprefix("usage: ").split())
|
||||
for name, command in subparsers.choices.items()
|
||||
}
|
||||
|
||||
self.assertEqual(tuple(sorted(expected)), tuple(item.name for item in references))
|
||||
self.assertEqual(expected, {item.name: item.invocation for item in references})
|
||||
rendered = render_cli_reference_markdown(references)
|
||||
self.assertEqual(rendered, render_cli_reference_markdown(reversed(references)))
|
||||
self.assertIn("| `search` | `docforge search [-h] [--limit LIMIT] query` |", rendered)
|
||||
self.assertIn(
|
||||
"| `graph-render-status` | `docforge graph-render-status [-h] [view_id]` |",
|
||||
rendered,
|
||||
)
|
||||
|
||||
async def test_mcp_reference_is_derived_from_registered_tool_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(
|
||||
root,
|
||||
"alpha-editor",
|
||||
canonical_applier_id="alpha-editor",
|
||||
),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
tools = (await session.list_tools()).tools
|
||||
|
||||
expected_names = (*ALL_TOOLS, *APPLICATION_TOOLS)
|
||||
references = mcp_tool_references(tools, expected_names=expected_names)
|
||||
by_name = {item.name: item for item in references}
|
||||
tools_by_name = {tool.name: tool for tool in tools}
|
||||
|
||||
self.assertEqual(set(expected_names), set(by_name))
|
||||
for name, tool in tools_by_name.items():
|
||||
with self.subTest(tool=name):
|
||||
schema_hash = hashlib.sha256(
|
||||
json.dumps(
|
||||
tool.inputSchema,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
properties = set(tool.inputSchema.get("properties", {}))
|
||||
required = set(tool.inputSchema.get("required", []))
|
||||
reference = by_name[name]
|
||||
self.assertEqual(" ".join((tool.description or "").split()), reference.description)
|
||||
self.assertEqual(tuple(sorted(required)), reference.required_arguments)
|
||||
self.assertEqual(
|
||||
tuple(sorted(properties - required)),
|
||||
reference.optional_arguments,
|
||||
)
|
||||
self.assertEqual(schema_hash, reference.input_schema_hash)
|
||||
|
||||
rendered = render_mcp_reference_markdown(references)
|
||||
self.assertEqual(rendered, render_mcp_reference_markdown(reversed(references)))
|
||||
self.assertIn("| read | `docforge_bootstrap` | — | — |", rendered)
|
||||
self.assertIn(
|
||||
"| application | `docforge_apply_changeset` | "
|
||||
"`changeset_id`, `expected_changeset_hash` | — |",
|
||||
rendered,
|
||||
)
|
||||
complete = render_command_reference_markdown(
|
||||
cli_command_references(),
|
||||
references,
|
||||
)
|
||||
self.assertEqual(1, complete.count("## CLI commands"))
|
||||
self.assertEqual(1, complete.count("## MCP tools"))
|
||||
|
||||
def test_mcp_reference_fails_closed_on_registration_drift(self) -> None:
|
||||
known = Tool(
|
||||
name="docforge_bootstrap",
|
||||
description="Bootstrap.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
unknown = Tool(
|
||||
name="docforge_unregistered",
|
||||
description="Unknown.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "no declared capability surface"):
|
||||
mcp_tool_references((unknown,))
|
||||
with self.assertRaisesRegex(ValueError, "missing="):
|
||||
mcp_tool_references((known,), expected_names=ALL_TOOLS)
|
||||
with self.assertRaisesRegex(ValueError, "repeats"):
|
||||
mcp_tool_references((known, known))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue