cd715670d7
Phase 1 of any_type_componentization_20260621. Promotes MCP_TOOL_SPECS
(45 dict[str, Any] literals in src/mcp_client.py) to typed dataclasses:
NEW src/mcp_tool_specs.py:
- ToolParameter dataclass (name, type, description, required, enum)
- ToolSpec dataclass (name, description, parameters: tuple)
- _REGISTRY: dict[str, ToolSpec]
- register() / get_tool_spec() / get_tool_schemas() / tool_names()
- to_dict() preserves legacy JSON shape for downstream serialization
- 45 register() calls (one per tool) at module level
- Mirrors src/vendor_capabilities.py reference pattern
NEW tests/test_mcp_tool_specs.py (11 tests, all pass):
- test_module_loads_with_45_registrations
- test_tool_names_set_matches_expected_45
- test_get_tool_spec_returns_correct_instance
- test_get_tool_spec_raises_for_unknown_name
- test_get_tool_schemas_returns_all_specs
- test_tool_spec_is_frozen
- test_tool_parameter_is_frozen
- test_to_dict_round_trip_preserves_shape
- test_tool_parameter_to_dict_includes_enum
- test_tool_names_subset_of_models_agent_tool_names (cross-module invariant)
- test_register_idempotent_replaces_existing (hot-reload support)
NEW scripts/tier2/artifacts/any_type_componentization_20260621/:
- generate_mcp_tool_specs.py: idempotent generator from MCP_TOOL_SPECS
- generate_tool_specs.py: helper that emits registration lines
- inspect_mcp_specs.py: shape inspection
- _generated_registrations.txt: the 45 registration lines
Verified: 11/11 tests pass. The legacy MCP_TOOL_SPECS dict in mcp_client.py
still exists; this commit only ADDS the new module. Migration of call sites
in mcp_client.py + ai_client.py follows in t1_4 + t1_5.
Verified with:
uv run pytest tests/test_mcp_tool_specs.py --timeout=30
11 passed in 3.01s
123 lines
4.4 KiB
Python
123 lines
4.4 KiB
Python
"""Tests for src/mcp_tool_specs.py
|
|
|
|
Phase 1 of any_type_componentization_20260621. Verifies:
|
|
- 45 ToolSpec instances are registered
|
|
- get_tool_spec(name) dispatches correctly
|
|
- tool_names() returns the expected set
|
|
- get_tool_schemas() returns the expected list
|
|
- ToolParameter / ToolSpec dataclasses have correct frozen=True semantics
|
|
- to_dict() round-trip preserves the legacy dict shape
|
|
- Cross-module invariant: tool_names() == models.AGENT_TOOL_NAMES subset
|
|
|
|
CONVENTION: 1-space indentation. NO COMMENTS.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from src import mcp_tool_specs
|
|
from src import models
|
|
|
|
|
|
EXPECTED_TOOLS: set[str] = {
|
|
'py_remove_def', 'py_add_def', 'py_move_def', 'py_region_wrap',
|
|
'read_file', 'list_directory', 'search_files', 'get_file_summary',
|
|
'py_get_skeleton', 'py_get_code_outline',
|
|
'ts_c_get_skeleton', 'ts_cpp_get_skeleton',
|
|
'ts_c_get_code_outline', 'ts_cpp_get_code_outline',
|
|
'ts_c_get_definition', 'ts_cpp_get_definition',
|
|
'ts_c_get_signature', 'ts_cpp_get_signature',
|
|
'ts_c_update_definition', 'ts_cpp_update_definition',
|
|
'get_file_slice', 'set_file_slice', 'edit_file',
|
|
'py_get_definition', 'py_update_definition',
|
|
'py_get_signature', 'py_set_signature',
|
|
'py_get_class_summary', 'py_get_var_declaration', 'py_set_var_declaration',
|
|
'get_git_diff', 'web_search', 'fetch_url', 'get_ui_performance',
|
|
'py_find_usages', 'py_get_imports', 'py_check_syntax',
|
|
'py_get_hierarchy', 'py_get_docstring', 'get_tree',
|
|
'bd_create', 'bd_update', 'bd_list', 'bd_ready',
|
|
'derive_code_path',
|
|
}
|
|
|
|
|
|
def test_module_loads_with_45_registrations() -> None:
|
|
assert len(mcp_tool_specs._REGISTRY) == 45
|
|
|
|
|
|
def test_tool_names_set_matches_expected_45() -> None:
|
|
names = mcp_tool_specs.tool_names()
|
|
assert len(names) == 45
|
|
assert names == EXPECTED_TOOLS
|
|
|
|
|
|
def test_get_tool_spec_returns_correct_instance() -> None:
|
|
spec = mcp_tool_specs.get_tool_spec('py_remove_def')
|
|
assert spec.name == 'py_remove_def'
|
|
assert 'Excises' in spec.description or 'class or function' in spec.description
|
|
assert len(spec.parameters) >= 2
|
|
path_param = next((p for p in spec.parameters if p.name == 'path'), None)
|
|
assert path_param is not None
|
|
assert path_param.required is True
|
|
assert path_param.type == 'string'
|
|
|
|
|
|
def test_get_tool_spec_raises_for_unknown_name() -> None:
|
|
with pytest.raises(KeyError):
|
|
mcp_tool_specs.get_tool_spec('nonexistent_tool_xyz')
|
|
|
|
|
|
def test_get_tool_schemas_returns_all_specs() -> None:
|
|
schemas = mcp_tool_specs.get_tool_schemas()
|
|
assert len(schemas) == 45
|
|
assert all(isinstance(s, mcp_tool_specs.ToolSpec) for s in schemas)
|
|
|
|
|
|
def test_tool_spec_is_frozen() -> None:
|
|
spec = mcp_tool_specs.get_tool_spec('read_file')
|
|
with pytest.raises(Exception):
|
|
spec.name = 'mutated'
|
|
|
|
|
|
def test_tool_parameter_is_frozen() -> None:
|
|
spec = mcp_tool_specs.get_tool_spec('read_file')
|
|
param = spec.parameters[0]
|
|
with pytest.raises(Exception):
|
|
param.name = 'mutated'
|
|
|
|
|
|
def test_to_dict_round_trip_preserves_shape() -> None:
|
|
spec = mcp_tool_specs.get_tool_spec('py_remove_def')
|
|
d = spec.to_dict()
|
|
assert d['name'] == 'py_remove_def'
|
|
assert 'description' in d
|
|
assert d['parameters']['type'] == 'object'
|
|
assert 'path' in d['parameters']['properties']
|
|
assert 'name' in d['parameters']['properties']
|
|
assert 'path' in d['parameters']['required']
|
|
assert 'name' in d['parameters']['required']
|
|
|
|
|
|
def test_tool_parameter_to_dict_includes_enum() -> None:
|
|
spec = mcp_tool_specs.get_tool_spec('py_add_def')
|
|
anchor_param = next((p for p in spec.parameters if p.name == 'anchor_type'), None)
|
|
assert anchor_param is not None
|
|
assert anchor_param.enum is not None
|
|
assert 'before' in anchor_param.enum
|
|
d = anchor_param.to_dict()
|
|
assert 'enum' in d
|
|
assert 'before' in d['enum']
|
|
|
|
|
|
def test_tool_names_subset_of_models_agent_tool_names() -> None:
|
|
"""Cross-module invariant: every MCP tool is also an agent tool."""
|
|
native_names = mcp_tool_specs.tool_names()
|
|
agent_names = set(models.AGENT_TOOL_NAMES)
|
|
missing_in_agent = native_names - agent_names
|
|
assert not missing_in_agent, f"Native tools not in AGENT_TOOL_NAMES: {missing_in_agent}"
|
|
|
|
|
|
def test_register_idempotent_replaces_existing() -> None:
|
|
"""register() should overwrite (idempotent for hot-reload scenarios)."""
|
|
from src.mcp_tool_specs import ToolSpec, ToolParameter, register
|
|
custom = ToolSpec(name='read_file', description='custom', parameters=(ToolParameter(name='x', type='string', description='x'),))
|
|
register(custom)
|
|
assert mcp_tool_specs.get_tool_spec('read_file').description == 'custom' |