From ac44ed815e6bfe273381edabb7b06f7e6bf89666 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 12 Jun 2026 11:31:51 +0200 Subject: [PATCH] Support aliases of tool parameters, adding class SerenaFastMCPTool --- CHANGELOG.md | 1 + src/serena/mcp.py | 165 ++++++++++++++++------------ src/serena/tools/jetbrains_tools.py | 4 + src/serena/tools/symbol_tools.py | 4 + src/serena/tools/tools_base.py | 8 ++ 5 files changed, 113 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba7a287..8bc388a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: JetBrains mode prompt was not provided to agents; The mode is now treated as a (background) base mode in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) + - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 7520cacb..653f064c 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -11,8 +11,10 @@ from typing import Any, Literal, cast import docstring_parser from mcp.server.fastmcp import server -from mcp.server.fastmcp.server import FastMCP, Settings -from mcp.server.fastmcp.tools.base import Tool as MCPTool +from mcp.server.fastmcp.server import Context, FastMCP, Settings +from mcp.server.fastmcp.tools.base import Tool as FastMCPTool +from mcp.server.session import ServerSessionT +from mcp.shared.context import LifespanContextT, RequestT from mcp.types import ToolAnnotations from pydantic_settings import SettingsConfigDict from sensai.util import logging @@ -47,6 +49,94 @@ class SerenaMCPRequestContext: agent: SerenaAgent +class SerenaFastMCPTool(FastMCPTool): + def __init__(self, tool: Tool, openai_tool_compatible: bool): + """ + :param tool: the Serena tool + :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools + (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within Codex. + """ + func_name = tool.get_name() + func_doc = tool.get_apply_docstring() or "" + func_arg_metadata = tool.get_apply_fn_metadata() + is_async = False + parameters = func_arg_metadata.arg_model.model_json_schema() + if openai_tool_compatible: + parameters = SerenaMCPFactory._sanitize_for_openai_tools(parameters) + + docstring = docstring_parser.parse(func_doc) + + # Mount the tool description as a combination of the docstring description and + # the return value description, if it exists. + overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) + + if overridden_description is not None: + func_doc = overridden_description + elif docstring.description: + func_doc = docstring.description + else: + func_doc = "" + func_doc = func_doc.strip().strip(".") + if func_doc: + func_doc += "." + if docstring.returns and (docstring_returns_descr := docstring.returns.description): + # Only add a space before "Returns" if func_doc is not empty + prefix = " " if func_doc else "" + func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." + + # Parse the parameter descriptions from the docstring and add pass its description + # to the parameter schema. + docstring_params = {param.arg_name: param for param in docstring.params} + parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] + for parameter, properties in parameters_properties.items(): + if (param_doc := docstring_params.get(parameter)) and param_doc.description: + param_desc = f"{param_doc.description.strip().strip('.') + '.'}" + properties["description"] = param_desc[0].upper() + param_desc[1:] + + def execute_fn(**kwargs) -> str: # type: ignore + return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + + # Generate human-readable title from snake_case tool name + tool_title = " ".join(word.capitalize() for word in func_name.split("_")) + + # Create annotations with appropriate hints based on tool capabilities + can_edit = tool.can_edit() + annotations = ToolAnnotations( + title=tool_title, + readOnlyHint=not can_edit, + destructiveHint=can_edit, + ) + + super().__init__( + fn=execute_fn, + name=func_name, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this + # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) + context_kwarg="mcp_ctx", + annotations=annotations, + title=tool_title, + ) + + self._param_aliases = tool.get_param_aliases() + + async def run( + self, + arguments: dict[str, Any], + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + convert_result: bool = False, + ) -> Any: + # apply parameter aliases + for param_alias, param_name in self._param_aliases.items(): + if param_alias in arguments and param_name not in arguments: + arguments[param_name] = arguments.pop(param_alias) + + return await super().run(arguments, context, convert_result) + + class SerenaMCPFactory: """ Factory for the creation of the Serena MCP server with an associated SerenaAgent. @@ -181,78 +271,15 @@ class SerenaMCPFactory: return walk(s) @staticmethod - def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> MCPTool: + def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> SerenaFastMCPTool: """ - Create an MCP tool from a Serena Tool instance. + Creates an MCP tool from a Serena Tool instance. - :param tool: The Serena Tool instance to convert. + :param tool: the Serena Tool instance to convert. :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within codex. """ - func_name = tool.get_name() - func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() - is_async = False - parameters = func_arg_metadata.arg_model.model_json_schema() - if openai_tool_compatible: - parameters = SerenaMCPFactory._sanitize_for_openai_tools(parameters) - - docstring = docstring_parser.parse(func_doc) - - # Mount the tool description as a combination of the docstring description and - # the return value description, if it exists. - overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) - - if overridden_description is not None: - func_doc = overridden_description - elif docstring.description: - func_doc = docstring.description - else: - func_doc = "" - func_doc = func_doc.strip().strip(".") - if func_doc: - func_doc += "." - if docstring.returns and (docstring_returns_descr := docstring.returns.description): - # Only add a space before "Returns" if func_doc is not empty - prefix = " " if func_doc else "" - func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." - - # Parse the parameter descriptions from the docstring and add pass its description - # to the parameter schema. - docstring_params = {param.arg_name: param for param in docstring.params} - parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] - for parameter, properties in parameters_properties.items(): - if (param_doc := docstring_params.get(parameter)) and param_doc.description: - param_desc = f"{param_doc.description.strip().strip('.') + '.'}" - properties["description"] = param_desc[0].upper() + param_desc[1:] - - def execute_fn(**kwargs) -> str: # type: ignore - return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) - - # Generate human-readable title from snake_case tool name - tool_title = " ".join(word.capitalize() for word in func_name.split("_")) - - # Create annotations with appropriate hints based on tool capabilities - can_edit = tool.can_edit() - annotations = ToolAnnotations( - title=tool_title, - readOnlyHint=not can_edit, - destructiveHint=can_edit, - ) - - return MCPTool( - fn=execute_fn, - name=func_name, - description=func_doc, - parameters=parameters, - fn_metadata=func_arg_metadata, - is_async=is_async, - # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this - # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) - context_kwarg="mcp_ctx", - annotations=annotations, - title=tool_title, - ) + return SerenaFastMCPTool(tool, openai_tool_compatible) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 74a05359..3e71c45f 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -122,6 +122,10 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): result = self._to_json(grouped_symbols) return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result]) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): """ diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index add9d345..7c11fce4 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -234,6 +234,10 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): result = self._to_json(grouped_symbol_dicts) return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_short_result_relative_path_to_name_paths]) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 89d064c3..44338dfd 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -306,6 +306,14 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + """ + :return: a mapping of parameter aliases for the apply method, where the key is the alias and the value is the actual parameter name. + This can be used to define alternative parameter names for the same parameter. + """ + return {} + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments