From 96007ebd770d9e8e846ceb601dca8635e6064d71 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 21 Jun 2026 16:06:29 -0400 Subject: [PATCH] feat(mcp): add src/mcp_tool_specs.py + tests (t1_1, t1_2, t1_3) 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 --- .../_generated_registrations.txt | 45 ++++++ .../generate_mcp_tool_specs.py | 141 ++++++++++++++++++ .../generate_tool_specs.py | 52 +++++++ .../inspect_mcp_specs.py | 15 ++ src/mcp_tool_specs.py | 124 +++++++++++++++ tests/test_mcp_tool_specs.py | 123 +++++++++++++++ 6 files changed, 500 insertions(+) create mode 100644 scripts/tier2/artifacts/any_type_componentization_20260621/_generated_registrations.txt create mode 100644 scripts/tier2/artifacts/any_type_componentization_20260621/generate_mcp_tool_specs.py create mode 100644 scripts/tier2/artifacts/any_type_componentization_20260621/generate_tool_specs.py create mode 100644 scripts/tier2/artifacts/any_type_componentization_20260621/inspect_mcp_specs.py create mode 100644 src/mcp_tool_specs.py create mode 100644 tests/test_mcp_tool_specs.py diff --git a/scripts/tier2/artifacts/any_type_componentization_20260621/_generated_registrations.txt b/scripts/tier2/artifacts/any_type_componentization_20260621/_generated_registrations.txt new file mode 100644 index 00000000..7cf5dcb8 --- /dev/null +++ b/scripts/tier2/artifacts/any_type_componentization_20260621/_generated_registrations.txt @@ -0,0 +1,45 @@ +register(ToolSpec(name='py_remove_def', description='Excises a specific class or function definition from a Python file using AST-derived line ranges, preserving surrounding formatting and comments.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to remove. Use 'ClassName.method_name' for methods.", required=True)))) +register(ToolSpec(name='py_add_def', description='Inserts a new definition into a specific context (module level or within a specific class).', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Context path (e.g. 'ClassName' or empty for module level).", required=True), ToolParameter( name='new_content', type='string', description='The code to insert.', required=True), ToolParameter( name='anchor_type', type='string', description='Where to insert relative to the anchor.', required=True, enum=('before', 'after', 'top', 'bottom',)), ToolParameter( name='anchor_symbol', type='string', description="Symbol name to anchor to if anchor_type is 'before' or 'after'.")))) +register(ToolSpec(name='py_move_def', description='Relocates a definition within a file or across different Python files.', parameters=(ToolParameter( name='src_path', type='string', description='Path to the source .py file.', required=True), ToolParameter( name='dest_path', type='string', description='Path to the destination .py file.', required=True), ToolParameter( name='name', type='string', description='The name of the class or function to move.', required=True), ToolParameter( name='dest_name', type='string', description="Context path in destination file (e.g. 'ClassName' or empty).", required=True), ToolParameter( name='anchor_type', type='string', description='Where to insert in destination.', required=True, enum=('before', 'after', 'top', 'bottom',)), ToolParameter( name='anchor_symbol', type='string', description='Anchor symbol in destination.')))) +register(ToolSpec(name='py_region_wrap', description='Wraps a specified block of code (e.g., a set of methods) in #region: Name and #endregion: Name tags.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True), ToolParameter( name='region_name', type='string', description='The name of the region.', required=True)))) +register(ToolSpec(name='read_file', description='Read the full UTF-8 content of a file within the allowed project paths. Use get_file_summary first to decide whether you need the full content.', parameters=(ToolParameter( name='path', type='string', description='Absolute or relative path to the file to read.', required=True)))) +register(ToolSpec(name='list_directory', description='List files and subdirectories within an allowed directory. Shows name, type (file/dir), and size. Use this to explore the project structure.', parameters=(ToolParameter( name='path', type='string', description='Absolute path to the directory to list.', required=True)))) +register(ToolSpec(name='search_files', description="Search for files matching a glob pattern within an allowed directory. Supports recursive patterns like '**/*.py'. Use this to find files by extension or name pattern.", parameters=(ToolParameter( name='path', type='string', description='Absolute path to the directory to search within.', required=True), ToolParameter( name='pattern', type='string', description="Glob pattern, e.g. '*.py', '**/*.toml', 'src/**/*.rs'.", required=True)))) +register(ToolSpec(name='get_file_summary', description='Get a compact heuristic summary of a file without reading its full content. For Python: imports, classes, methods, functions, constants. For TOML: table keys. For Markdown: headings. Others: line count + preview. Use this before read_file to decide if you need the full content.', parameters=(ToolParameter( name='path', type='string', description='Absolute or relative path to the file to summarise.', required=True)))) +register(ToolSpec(name='py_get_skeleton', description="Get a skeleton view of a Python file. This returns all classes and function signatures with their docstrings, but replaces function bodies with '...'. Use this to understand module interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True)))) +register(ToolSpec(name='py_get_code_outline', description="Get a hierarchical outline of a code file. This returns classes, functions, and methods with their line ranges and brief docstrings. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the code file (currently supports .py).', required=True)))) +register(ToolSpec(name='ts_c_get_skeleton', description="Get a skeleton view of a C file. This returns all function signatures and structs, but replaces function bodies with '...'. Use this to understand C interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True)))) +register(ToolSpec(name='ts_cpp_get_skeleton', description="Get a skeleton view of a C++ file. This returns all classes, structs and function signatures, but replaces function bodies with '...'. Use this to understand C++ interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True)))) +register(ToolSpec(name='ts_c_get_code_outline', description="Get a hierarchical outline of a C file. This returns structs and functions with their line ranges. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True)))) +register(ToolSpec(name='ts_cpp_get_code_outline', description="Get a hierarchical outline of a C++ file. This returns classes, structs and functions with their line ranges. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True)))) +register(ToolSpec(name='ts_c_get_definition', description="Get the full source code of a specific function or struct definition in a C file. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='The name of the function or struct to retrieve.', required=True)))) +register(ToolSpec(name='ts_cpp_get_definition', description="Get the full source code of a specific class, function, or method definition in a C++ file. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to retrieve. Use 'ClassName::method_name' for methods.", required=True)))) +register(ToolSpec(name='ts_c_get_signature', description='Get only the signature part of a C function.', parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='Name of the function.', required=True)))) +register(ToolSpec(name='ts_cpp_get_signature', description='Get only the signature part of a C++ function or method.', parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description="Name of the function/method (e.g. 'ClassName::method_name').", required=True)))) +register(ToolSpec(name='ts_c_update_definition', description='Surgically replace the definition of a function in a C file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='Name of function.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='ts_cpp_update_definition', description='Surgically replace the definition of a class or function in a C++ file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description='Name of class/function/method.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='get_file_slice', description='Read a specific line range from a file. Useful for reading parts of very large files.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True)))) +register(ToolSpec(name='set_file_slice', description='Replace a specific line range in a file with new content. Surgical edit tool.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True), ToolParameter( name='new_content', type='string', description='New content to insert.', required=True)))) +register(ToolSpec(name='edit_file', description='Replace exact string match in a file. Preserves indentation and line endings. Drop-in replacement for native edit tool.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='old_string', type='string', description='The text to replace.', required=True), ToolParameter( name='new_string', type='string', description='The replacement text.', required=True), ToolParameter( name='replace_all', type='boolean', description='Replace all occurrences. Default false.')))) +register(ToolSpec(name='py_get_definition', description="Get the full source code of a specific class, function, or method definition. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to retrieve. Use 'ClassName.method_name' for methods.", required=True)))) +register(ToolSpec(name='py_update_definition', description='Surgically replace the definition of a class or function in a Python file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of class/function/method.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='py_get_signature', description='Get only the signature part of a Python function or method (from def until colon).', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Name of the function/method (e.g. 'ClassName.method_name').", required=True)))) +register(ToolSpec(name='py_set_signature', description='Surgically replace only the signature of a Python function or method.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the function/method.', required=True), ToolParameter( name='new_signature', type='string', description='Complete new signature string (including def and trailing colon).', required=True)))) +register(ToolSpec(name='py_get_class_summary', description='Get a summary of a Python class, listing its docstring and all method signatures.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the class.', required=True)))) +register(ToolSpec(name='py_get_var_declaration', description='Get the assignment/declaration line for a variable.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the variable.', required=True)))) +register(ToolSpec(name='py_set_var_declaration', description='Surgically replace a variable assignment/declaration.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the variable.', required=True), ToolParameter( name='new_declaration', type='string', description='Complete new assignment/declaration string.', required=True)))) +register(ToolSpec(name='get_git_diff', description='Returns the git diff for a file or directory. Use this to review changes efficiently without reading entire files.', parameters=(ToolParameter( name='path', type='string', description='Path to the file or directory.', required=True), ToolParameter( name='base_rev', type='string', description="Base revision (e.g. 'HEAD', 'HEAD~1', or a commit hash). Defaults to 'HEAD'."), ToolParameter( name='head_rev', type='string', description='Head revision (optional).')))) +register(ToolSpec(name='web_search', description='Search the web using DuckDuckGo. Returns the top 5 search results with titles, URLs, and snippets. Chain this with fetch_url to read specific pages.', parameters=(ToolParameter( name='query', type='string', description='The search query.', required=True)))) +register(ToolSpec(name='fetch_url', description='Fetch the full text content of a URL (stripped of HTML tags). Use this after web_search to read relevant information from the web.', parameters=(ToolParameter( name='url', type='string', description='The full URL to fetch.', required=True)))) +register(ToolSpec(name='get_ui_performance', description="Get a snapshot of the current UI performance metrics, including FPS, Frame Time (ms), CPU usage (%), and Input Lag (ms). Use this to diagnose UI slowness or verify that your changes haven't degraded the user experience.", parameters=())) +register(ToolSpec(name='py_find_usages', description='Finds exact string matches of a symbol in a given file or directory.', parameters=(ToolParameter( name='path', type='string', description='Path to file or directory to search.', required=True), ToolParameter( name='name', type='string', description='The symbol/string to search for.', required=True)))) +register(ToolSpec(name='py_get_imports', description="Parses a file's AST and returns a strict list of its dependencies.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True)))) +register(ToolSpec(name='py_check_syntax', description='Runs a quick syntax check on a Python file.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True)))) +register(ToolSpec(name='py_get_hierarchy', description='Scans the project to find subclasses of a given class.', parameters=(ToolParameter( name='path', type='string', description='Directory path to search in.', required=True), ToolParameter( name='class_name', type='string', description='Name of the base class.', required=True)))) +register(ToolSpec(name='py_get_docstring', description='Extracts the docstring for a specific module, class, or function.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Name of symbol or 'module' for the file docstring.", required=True)))) +register(ToolSpec(name='get_tree', description='Returns a directory structure up to a max depth.', parameters=(ToolParameter( name='path', type='string', description='Directory path.', required=True), ToolParameter( name='max_depth', type='integer', description='Maximum depth to recurse (default 2).')))) +register(ToolSpec(name='bd_create', description='Create a new Bead in the active Beads repository.', parameters=(ToolParameter( name='title', type='string', description='Title of the Bead.', required=True), ToolParameter( name='description', type='string', description='Description of the Bead.', required=True)))) +register(ToolSpec(name='bd_update', description='Update an existing Bead.', parameters=(ToolParameter( name='bead_id', type='string', description='ID of the Bead to update.', required=True), ToolParameter( name='status', type='string', description='New status for the Bead.', required=True)))) +register(ToolSpec(name='bd_list', description='List all Beads in the active Beads repository.', parameters=())) +register(ToolSpec(name='bd_ready', description='Check if the Beads repository is initialized in the current workspace.', parameters=())) +register(ToolSpec(name='derive_code_path', description='Recursively traces the execution path of a specific function or method across multiple files. Identifies call chains and data hand-offs to build an intensive technical map.', parameters=(ToolParameter( name='target', type='string', description="Fully qualified name of the target (e.g., 'src.ai_client.send') or class.method.", required=True), ToolParameter( name='max_depth', type='integer', description='Maximum recursion depth for the call graph (default 5).')))) diff --git a/scripts/tier2/artifacts/any_type_componentization_20260621/generate_mcp_tool_specs.py b/scripts/tier2/artifacts/any_type_componentization_20260621/generate_mcp_tool_specs.py new file mode 100644 index 00000000..355e5a6c --- /dev/null +++ b/scripts/tier2/artifacts/any_type_componentization_20260621/generate_mcp_tool_specs.py @@ -0,0 +1,141 @@ +"""Generate src/mcp_tool_specs.py from the existing MCP_TOOL_SPECS dicts. + +Reads MCP_TOOL_SPECS from src.mcp_client (the existing list of 45 dicts) +and produces src/mcp_tool_specs.py with the ToolParameter/ToolSpec dataclasses, +_REGISTRY, factory functions, and 45 register() calls. + +Run once to (re)generate; the output is checked into git. +""" +import sys +sys.path.insert(0, '.') + +HEADER = '''"""Tool specification module for the Manual Slop MCP tool registry. + +Promotes the legacy `MCP_TOOL_SPECS: list[dict[str, Any]]` from +`src/mcp_client.py` to typed dataclass instances. Follows the +`src/vendor_capabilities.py` reference pattern: `frozen=True` dataclass ++ module-level `_REGISTRY` dict + factory functions. + +Each tool has: +- name (str): unique tool identifier +- description (str): human-readable purpose +- parameters (tuple[ToolParameter, ...]): the parameter schema + +The legacy dict shape (JSON-compatible) is preserved via `to_dict()` so +downstream consumers (provider API requests, comms logging) can still +serialize tool specs to JSON without knowing the dataclass layout. + +CONVENTION: 1-space indentation. NO COMMENTS. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ToolParameter: + name: str + type: str + description: str + required: bool = False + enum: tuple[str, ...] | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"type": self.type, "description": self.description} + if self.enum is not None: + d["enum"] = list(self.enum) + return d + + +@dataclass(frozen=True) +class ToolSpec: + name: str + description: str + parameters: tuple[ToolParameter, ...] + + def to_dict(self) -> dict[str, Any]: + properties: dict[str, Any] = {p.name: p.to_dict() for p in self.parameters} + required: list[str] = [p.name for p in self.parameters if p.required] + return { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + } + + +_REGISTRY: dict[str, ToolSpec] = {} + + +def register(spec: ToolSpec) -> None: + _REGISTRY[spec.name] = spec + + +def get_tool_spec(name: str) -> ToolSpec: + if name not in _REGISTRY: + raise KeyError(f"No tool registered with name {name!r}") + return _REGISTRY[name] + + +def get_tool_schemas() -> list[ToolSpec]: + return list(_REGISTRY.values()) + + +def tool_names() -> set[str]: + return set(_REGISTRY.keys()) + +''' + + +def _param_repr(param_name: str, param_spec: dict, required: list[str]) -> str: + param_type = param_spec.get('type', 'string') + desc = param_spec.get('description', '') + enum = param_spec.get('enum') + is_required = param_name in required + parts = [ + f' name={param_name!r}', + f' type={param_type!r}', + f' description={desc!r}', + ] + if is_required: + parts.append(' required=True') + if enum is not None: + enum_repr = f'({", ".join(repr(e) for e in enum)},)' + parts.append(f' enum={enum_repr}') + return f'ToolParameter({", ".join(parts)})' + + +def _spec_repr(spec: dict) -> str: + name = spec['name'] + description = spec['description'] + params_dict = spec.get('parameters', {}) + properties = params_dict.get('properties', {}) + required = params_dict.get('required', []) + if properties: + param_strs = [_param_repr(pname, pspec, required) for pname, pspec in properties.items()] + if len(param_strs) == 1: + params_tuple = f'({param_strs[0]},)' + else: + params_tuple = '(' + ', '.join(param_strs) + ')' + else: + params_tuple = '()' + return f"register(ToolSpec(name={name!r}, description={description!r}, parameters={params_tuple}))" + + +def main() -> None: + from src import mcp_client + specs = mcp_client.MCP_TOOL_SPECS + registrations = '\n'.join(_spec_repr(s) for s in specs) + content = HEADER + registrations + '\n' + out_path = 'src/mcp_tool_specs.py' + with open(out_path, 'w', encoding='utf-8', newline='') as f: + f.write(content) + print(f"Wrote {out_path} ({len(specs)} registrations)") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/scripts/tier2/artifacts/any_type_componentization_20260621/generate_tool_specs.py b/scripts/tier2/artifacts/any_type_componentization_20260621/generate_tool_specs.py new file mode 100644 index 00000000..a7fd9f9b --- /dev/null +++ b/scripts/tier2/artifacts/any_type_componentization_20260621/generate_tool_specs.py @@ -0,0 +1,52 @@ +"""Generate the ToolSpec registration code for src/mcp_tool_specs.py. + +Reads MCP_TOOL_SPECS from src.mcp_client (the existing list of 45 dicts) +and produces the Python source that registers 45 ToolSpec instances. + +Output: a single string suitable for pasting into src/mcp_tool_specs.py. +""" +import sys +sys.path.insert(0, '.') + + +def _param_repr(param_name: str, param_spec: dict, required: list[str]) -> str: + param_type = param_spec.get('type', 'string') + desc = param_spec.get('description', '') + enum = param_spec.get('enum') + is_required = param_name in required + parts = [ + f' name={param_name!r}', + f' type={param_type!r}', + f' description={desc!r}', + ] + if is_required: + parts.append(' required=True') + if enum is not None: + enum_repr = f'({", ".join(repr(e) for e in enum)},)' + parts.append(f' enum={enum_repr}') + return f'ToolParameter({", ".join(parts)})' + + +def generate() -> str: + from src import mcp_client + specs = mcp_client.MCP_TOOL_SPECS + lines: list[str] = [] + for spec in specs: + name = spec['name'] + description = spec['description'] + params_dict = spec.get('parameters', {}) + properties = params_dict.get('properties', {}) + required = params_dict.get('required', []) + if properties: + param_strs = [_param_repr(pname, pspec, required) for pname, pspec in properties.items()] + params_tuple = '(' + ', '.join(param_strs) + ')' + else: + params_tuple = '()' + lines.append( + f"register(ToolSpec(name={name!r}, description={description!r}, parameters={params_tuple}))" + ) + return '\n'.join(lines) + + +if __name__ == '__main__': + print(generate()) \ No newline at end of file diff --git a/scripts/tier2/artifacts/any_type_componentization_20260621/inspect_mcp_specs.py b/scripts/tier2/artifacts/any_type_componentization_20260621/inspect_mcp_specs.py new file mode 100644 index 00000000..8150272b --- /dev/null +++ b/scripts/tier2/artifacts/any_type_componentization_20260621/inspect_mcp_specs.py @@ -0,0 +1,15 @@ +"""Inspect MCP_TOOL_SPECS shape to inform the dataclass conversion.""" +import sys +sys.path.insert(0, '.') +from src import mcp_client + +specs = mcp_client.MCP_TOOL_SPECS +print(f"Total tools: {len(specs)}") +print(f"First tool name: {specs[0]['name']}") +print(f"First tool keys: {list(specs[0].keys())}") +print(f"First tool param keys: {list(specs[0]['parameters'].keys())}") +first_param = list(specs[0]['parameters']['properties'].values())[0] +print(f"First param keys: {list(first_param.keys())}") +print(f"All tool names ({len(specs)}):") +for s in specs: + print(f" {s['name']}") \ No newline at end of file diff --git a/src/mcp_tool_specs.py b/src/mcp_tool_specs.py new file mode 100644 index 00000000..68a1424a --- /dev/null +++ b/src/mcp_tool_specs.py @@ -0,0 +1,124 @@ +"""Tool specification module for the Manual Slop MCP tool registry. + +Promotes the legacy `MCP_TOOL_SPECS: list[dict[str, Any]]` from +`src/mcp_client.py` to typed dataclass instances. Follows the +`src/vendor_capabilities.py` reference pattern: `frozen=True` dataclass ++ module-level `_REGISTRY` dict + factory functions. + +Each tool has: +- name (str): unique tool identifier +- description (str): human-readable purpose +- parameters (tuple[ToolParameter, ...]): the parameter schema + +The legacy dict shape (JSON-compatible) is preserved via `to_dict()` so +downstream consumers (provider API requests, comms logging) can still +serialize tool specs to JSON without knowing the dataclass layout. + +CONVENTION: 1-space indentation. NO COMMENTS. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ToolParameter: + name: str + type: str + description: str + required: bool = False + enum: tuple[str, ...] | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"type": self.type, "description": self.description} + if self.enum is not None: + d["enum"] = list(self.enum) + return d + + +@dataclass(frozen=True) +class ToolSpec: + name: str + description: str + parameters: tuple[ToolParameter, ...] + + def to_dict(self) -> dict[str, Any]: + properties: dict[str, Any] = {p.name: p.to_dict() for p in self.parameters} + required: list[str] = [p.name for p in self.parameters if p.required] + return { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + } + + +_REGISTRY: dict[str, ToolSpec] = {} + + +def register(spec: ToolSpec) -> None: + _REGISTRY[spec.name] = spec + + +def get_tool_spec(name: str) -> ToolSpec: + if name not in _REGISTRY: + raise KeyError(f"No tool registered with name {name!r}") + return _REGISTRY[name] + + +def get_tool_schemas() -> list[ToolSpec]: + return list(_REGISTRY.values()) + + +def tool_names() -> set[str]: + return set(_REGISTRY.keys()) + +register(ToolSpec(name='py_remove_def', description='Excises a specific class or function definition from a Python file using AST-derived line ranges, preserving surrounding formatting and comments.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to remove. Use 'ClassName.method_name' for methods.", required=True)))) +register(ToolSpec(name='py_add_def', description='Inserts a new definition into a specific context (module level or within a specific class).', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Context path (e.g. 'ClassName' or empty for module level).", required=True), ToolParameter( name='new_content', type='string', description='The code to insert.', required=True), ToolParameter( name='anchor_type', type='string', description='Where to insert relative to the anchor.', required=True, enum=('before', 'after', 'top', 'bottom',)), ToolParameter( name='anchor_symbol', type='string', description="Symbol name to anchor to if anchor_type is 'before' or 'after'.")))) +register(ToolSpec(name='py_move_def', description='Relocates a definition within a file or across different Python files.', parameters=(ToolParameter( name='src_path', type='string', description='Path to the source .py file.', required=True), ToolParameter( name='dest_path', type='string', description='Path to the destination .py file.', required=True), ToolParameter( name='name', type='string', description='The name of the class or function to move.', required=True), ToolParameter( name='dest_name', type='string', description="Context path in destination file (e.g. 'ClassName' or empty).", required=True), ToolParameter( name='anchor_type', type='string', description='Where to insert in destination.', required=True, enum=('before', 'after', 'top', 'bottom',)), ToolParameter( name='anchor_symbol', type='string', description='Anchor symbol in destination.')))) +register(ToolSpec(name='py_region_wrap', description='Wraps a specified block of code (e.g., a set of methods) in #region: Name and #endregion: Name tags.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True), ToolParameter( name='region_name', type='string', description='The name of the region.', required=True)))) +register(ToolSpec(name='read_file', description='Read the full UTF-8 content of a file within the allowed project paths. Use get_file_summary first to decide whether you need the full content.', parameters=(ToolParameter( name='path', type='string', description='Absolute or relative path to the file to read.', required=True),))) +register(ToolSpec(name='list_directory', description='List files and subdirectories within an allowed directory. Shows name, type (file/dir), and size. Use this to explore the project structure.', parameters=(ToolParameter( name='path', type='string', description='Absolute path to the directory to list.', required=True),))) +register(ToolSpec(name='search_files', description="Search for files matching a glob pattern within an allowed directory. Supports recursive patterns like '**/*.py'. Use this to find files by extension or name pattern.", parameters=(ToolParameter( name='path', type='string', description='Absolute path to the directory to search within.', required=True), ToolParameter( name='pattern', type='string', description="Glob pattern, e.g. '*.py', '**/*.toml', 'src/**/*.rs'.", required=True)))) +register(ToolSpec(name='get_file_summary', description='Get a compact heuristic summary of a file without reading its full content. For Python: imports, classes, methods, functions, constants. For TOML: table keys. For Markdown: headings. Others: line count + preview. Use this before read_file to decide if you need the full content.', parameters=(ToolParameter( name='path', type='string', description='Absolute or relative path to the file to summarise.', required=True),))) +register(ToolSpec(name='py_get_skeleton', description="Get a skeleton view of a Python file. This returns all classes and function signatures with their docstrings, but replaces function bodies with '...'. Use this to understand module interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True),))) +register(ToolSpec(name='py_get_code_outline', description="Get a hierarchical outline of a code file. This returns classes, functions, and methods with their line ranges and brief docstrings. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the code file (currently supports .py).', required=True),))) +register(ToolSpec(name='ts_c_get_skeleton', description="Get a skeleton view of a C file. This returns all function signatures and structs, but replaces function bodies with '...'. Use this to understand C interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True),))) +register(ToolSpec(name='ts_cpp_get_skeleton', description="Get a skeleton view of a C++ file. This returns all classes, structs and function signatures, but replaces function bodies with '...'. Use this to understand C++ interfaces without reading the full implementation.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True),))) +register(ToolSpec(name='ts_c_get_code_outline', description="Get a hierarchical outline of a C file. This returns structs and functions with their line ranges. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True),))) +register(ToolSpec(name='ts_cpp_get_code_outline', description="Get a hierarchical outline of a C++ file. This returns classes, structs and functions with their line ranges. Use this to quickly map out a file's structure before reading specific sections.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True),))) +register(ToolSpec(name='ts_c_get_definition', description="Get the full source code of a specific function or struct definition in a C file. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='The name of the function or struct to retrieve.', required=True)))) +register(ToolSpec(name='ts_cpp_get_definition', description="Get the full source code of a specific class, function, or method definition in a C++ file. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to retrieve. Use 'ClassName::method_name' for methods.", required=True)))) +register(ToolSpec(name='ts_c_get_signature', description='Get only the signature part of a C function.', parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='Name of the function.', required=True)))) +register(ToolSpec(name='ts_cpp_get_signature', description='Get only the signature part of a C++ function or method.', parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description="Name of the function/method (e.g. 'ClassName::method_name').", required=True)))) +register(ToolSpec(name='ts_c_update_definition', description='Surgically replace the definition of a function in a C file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the C file.', required=True), ToolParameter( name='name', type='string', description='Name of function.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='ts_cpp_update_definition', description='Surgically replace the definition of a class or function in a C++ file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the C++ file.', required=True), ToolParameter( name='name', type='string', description='Name of class/function/method.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='get_file_slice', description='Read a specific line range from a file. Useful for reading parts of very large files.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True)))) +register(ToolSpec(name='set_file_slice', description='Replace a specific line range in a file with new content. Surgical edit tool.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='start_line', type='integer', description='1-based start line number.', required=True), ToolParameter( name='end_line', type='integer', description='1-based end line number (inclusive).', required=True), ToolParameter( name='new_content', type='string', description='New content to insert.', required=True)))) +register(ToolSpec(name='edit_file', description='Replace exact string match in a file. Preserves indentation and line endings. Drop-in replacement for native edit tool.', parameters=(ToolParameter( name='path', type='string', description='Path to the file.', required=True), ToolParameter( name='old_string', type='string', description='The text to replace.', required=True), ToolParameter( name='new_string', type='string', description='The replacement text.', required=True), ToolParameter( name='replace_all', type='boolean', description='Replace all occurrences. Default false.')))) +register(ToolSpec(name='py_get_definition', description="Get the full source code of a specific class, function, or method definition. This is more efficient than reading the whole file if you know what you're looking for.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="The name of the class or function to retrieve. Use 'ClassName.method_name' for methods.", required=True)))) +register(ToolSpec(name='py_update_definition', description='Surgically replace the definition of a class or function in a Python file using AST to find line ranges.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of class/function/method.', required=True), ToolParameter( name='new_content', type='string', description='Complete new source for the definition.', required=True)))) +register(ToolSpec(name='py_get_signature', description='Get only the signature part of a Python function or method (from def until colon).', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Name of the function/method (e.g. 'ClassName.method_name').", required=True)))) +register(ToolSpec(name='py_set_signature', description='Surgically replace only the signature of a Python function or method.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the function/method.', required=True), ToolParameter( name='new_signature', type='string', description='Complete new signature string (including def and trailing colon).', required=True)))) +register(ToolSpec(name='py_get_class_summary', description='Get a summary of a Python class, listing its docstring and all method signatures.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the class.', required=True)))) +register(ToolSpec(name='py_get_var_declaration', description='Get the assignment/declaration line for a variable.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the variable.', required=True)))) +register(ToolSpec(name='py_set_var_declaration', description='Surgically replace a variable assignment/declaration.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description='Name of the variable.', required=True), ToolParameter( name='new_declaration', type='string', description='Complete new assignment/declaration string.', required=True)))) +register(ToolSpec(name='get_git_diff', description='Returns the git diff for a file or directory. Use this to review changes efficiently without reading entire files.', parameters=(ToolParameter( name='path', type='string', description='Path to the file or directory.', required=True), ToolParameter( name='base_rev', type='string', description="Base revision (e.g. 'HEAD', 'HEAD~1', or a commit hash). Defaults to 'HEAD'."), ToolParameter( name='head_rev', type='string', description='Head revision (optional).')))) +register(ToolSpec(name='web_search', description='Search the web using DuckDuckGo. Returns the top 5 search results with titles, URLs, and snippets. Chain this with fetch_url to read specific pages.', parameters=(ToolParameter( name='query', type='string', description='The search query.', required=True),))) +register(ToolSpec(name='fetch_url', description='Fetch the full text content of a URL (stripped of HTML tags). Use this after web_search to read relevant information from the web.', parameters=(ToolParameter( name='url', type='string', description='The full URL to fetch.', required=True),))) +register(ToolSpec(name='get_ui_performance', description="Get a snapshot of the current UI performance metrics, including FPS, Frame Time (ms), CPU usage (%), and Input Lag (ms). Use this to diagnose UI slowness or verify that your changes haven't degraded the user experience.", parameters=())) +register(ToolSpec(name='py_find_usages', description='Finds exact string matches of a symbol in a given file or directory.', parameters=(ToolParameter( name='path', type='string', description='Path to file or directory to search.', required=True), ToolParameter( name='name', type='string', description='The symbol/string to search for.', required=True)))) +register(ToolSpec(name='py_get_imports', description="Parses a file's AST and returns a strict list of its dependencies.", parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True),))) +register(ToolSpec(name='py_check_syntax', description='Runs a quick syntax check on a Python file.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True),))) +register(ToolSpec(name='py_get_hierarchy', description='Scans the project to find subclasses of a given class.', parameters=(ToolParameter( name='path', type='string', description='Directory path to search in.', required=True), ToolParameter( name='class_name', type='string', description='Name of the base class.', required=True)))) +register(ToolSpec(name='py_get_docstring', description='Extracts the docstring for a specific module, class, or function.', parameters=(ToolParameter( name='path', type='string', description='Path to the .py file.', required=True), ToolParameter( name='name', type='string', description="Name of symbol or 'module' for the file docstring.", required=True)))) +register(ToolSpec(name='get_tree', description='Returns a directory structure up to a max depth.', parameters=(ToolParameter( name='path', type='string', description='Directory path.', required=True), ToolParameter( name='max_depth', type='integer', description='Maximum depth to recurse (default 2).')))) +register(ToolSpec(name='bd_create', description='Create a new Bead in the active Beads repository.', parameters=(ToolParameter( name='title', type='string', description='Title of the Bead.', required=True), ToolParameter( name='description', type='string', description='Description of the Bead.', required=True)))) +register(ToolSpec(name='bd_update', description='Update an existing Bead.', parameters=(ToolParameter( name='bead_id', type='string', description='ID of the Bead to update.', required=True), ToolParameter( name='status', type='string', description='New status for the Bead.', required=True)))) +register(ToolSpec(name='bd_list', description='List all Beads in the active Beads repository.', parameters=())) +register(ToolSpec(name='bd_ready', description='Check if the Beads repository is initialized in the current workspace.', parameters=())) +register(ToolSpec(name='derive_code_path', description='Recursively traces the execution path of a specific function or method across multiple files. Identifies call chains and data hand-offs to build an intensive technical map.', parameters=(ToolParameter( name='target', type='string', description="Fully qualified name of the target (e.g., 'src.ai_client.send') or class.method.", required=True), ToolParameter( name='max_depth', type='integer', description='Maximum recursion depth for the call graph (default 5).')))) diff --git a/tests/test_mcp_tool_specs.py b/tests/test_mcp_tool_specs.py new file mode 100644 index 00000000..2212d5f5 --- /dev/null +++ b/tests/test_mcp_tool_specs.py @@ -0,0 +1,123 @@ +"""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' \ No newline at end of file