mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 03:13:51 +00:00
Merge pull request #1570 from oraios/mcp-tool-arg-rename
Support aliases of tool parameters, adding class SerenaFastMCPTool
This commit is contained in:
5 files changed
+113
-69
No files matched your search
@@ -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`.
|
||||
|
||||
+96
-69
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user