From 011edec4458901062578c0b05d187a49a430bd93 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 13 Apr 2026 13:53:41 +0200 Subject: [PATCH] Allow to configure structured tool output on a per-context basis * Default is None (behaviour unchanged) * Override is applied for Claude Code, which does not unpack structured output, causing the LLM to see unnecessary escapings in returned tool results #1042 --- CHANGELOG.md | 2 ++ src/serena/config/context_mode.py | 5 ++++ src/serena/mcp.py | 26 +++++++++++++------ .../resources/config/contexts/claude-code.yml | 4 +++ .../config/contexts/context.template.yml | 5 +++- src/serena/tools/tools_base.py | 8 +++--- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7087b497..2fddc9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level - Fix: a registered project whose root directory was deleted while Serena was already running could break `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` + - Allow structured tool output to be configured on a per-context basis, disabling it for Claude Code + (which does not correctly unpack structured output) #1042 * Language Servers: - C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 5d42d650..16ef293a 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -184,6 +184,11 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. """ + structured_tool_output: bool | None = None + """ + whether to use structured output for tools (None = auto) + """ + def _tostring_includes(self) -> list[str]: return ["name"] diff --git a/src/serena/mcp.py b/src/serena/mcp.py index a6cf527c..033533c1 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -51,15 +51,16 @@ class SerenaMCPRequestContext: class SerenaFastMCPTool(FastMCPTool): - def __init__(self, tool: Tool, openai_tool_compatible: bool): + def __init__(self, tool: Tool, openai_tool_compatible: bool, structured_output: bool | None): """ :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. + :param structured_output: whether to use structured output for the tool (None = auto) """ func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() + func_arg_metadata = tool.get_apply_fn_metadata(structured_output=structured_output) is_async = False parameters = func_arg_metadata.arg_model.model_json_schema() if openai_tool_compatible: @@ -275,27 +276,34 @@ class SerenaMCPFactory: return walk(s) @staticmethod - def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> SerenaFastMCPTool: + def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True, structured_output: bool | None = None) -> SerenaFastMCPTool: """ Creates an MCP tool from a Serena Tool instance. :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. + :param structured_output: whether to use structured output for the tool (None = auto) """ - return SerenaFastMCPTool(tool, openai_tool_compatible) + return SerenaFastMCPTool(tool, openai_tool_compatible=openai_tool_compatible, structured_output=structured_output) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None yield from self.agent.get_exposed_tool_instances() # noinspection PyProtectedMember - def _set_mcp_tools(self, mcp: FastMCP, openai_tool_compatible: bool = False) -> None: - """Update the tools in the MCP server""" + def _set_mcp_tools(self, mcp: FastMCP, openai_tool_compatible: bool, structured_output: bool | None) -> None: + """ + Update the tools in the MCP server + + :param mcp: The MCP server to update + :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools + :param structured_output: whether to use structured output for the tools (None = auto) + """ if mcp is not None: mcp._tool_manager._tools = {} for tool in self._iter_tools(): - mcp_tool = self.make_mcp_tool(tool, openai_tool_compatible=openai_tool_compatible) + mcp_tool = self.make_mcp_tool(tool, openai_tool_compatible=openai_tool_compatible, structured_output=structured_output) mcp._tool_manager._tools[tool.get_name()] = mcp_tool log.info(f"Starting MCP server with {len(mcp._tool_manager._tools)} tools: {list(mcp._tool_manager._tools.keys())}") @@ -389,7 +397,9 @@ class SerenaMCPFactory: :param mcp_server: the MCP server instance to configure """ openai_tool_compatible = self.context.name in ["chatgpt", "codex", "oaicompat-agent"] - self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible) + assert self.agent is not None + context = self.agent.get_context() + self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible, structured_output=context.structured_tool_output) log.info("MCP server lifetime setup complete") try: yield diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index e6b27b7d..4a636e42 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -50,3 +50,7 @@ tool_description_overrides: {} # Tools explicitly disabled by the project will not be available at all. # The `activate_project` tool is always disabled in this case, as project switching cannot be allowed. single_project: true + +# Claude Code has a bug (it does not unpack structured tool output, causing a lot of unnecessary escaping +# owing to the wrapped structure), so we disable it here. +structured_tool_output: false diff --git a/src/serena/resources/config/contexts/context.template.yml b/src/serena/resources/config/contexts/context.template.yml index f9873413..ba2fd963 100644 --- a/src/serena/resources/config/contexts/context.template.yml +++ b/src/serena/resources/config/contexts/context.template.yml @@ -15,4 +15,7 @@ tool_description_overrides: {} # If set to true and a project is provided at startup, the set of tools is limited to those required by the project's # concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. # The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. -single_project: false \ No newline at end of file +single_project: false + +# whether to make MCP tools return structured output (null = auto-detect, true = always, false = never) +structured_tool_output: null diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c37daee4..304333f3 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -246,12 +246,12 @@ class Tool(Component): """Gets the docstring for the tool application, used by the MCP server.""" return self.get_apply_docstring_from_cls() - def get_apply_fn_metadata(self) -> FuncMetadata: + def get_apply_fn_metadata(self, structured_output: bool | None = None) -> FuncMetadata: """Gets the metadata for the tool application function, used by the MCP server.""" - return self.get_apply_fn_metadata_from_cls() + return self.get_apply_fn_metadata_from_cls(structured_output=structured_output) @classmethod - def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata: + def get_apply_fn_metadata_from_cls(cls, structured_output: bool | None = None) -> FuncMetadata: """Get the metadata for the apply method from the class (static metadata). Needed for creating MCP tools in a separate process without running into serialization issues. """ @@ -264,7 +264,7 @@ class Tool(Component): if apply_fn is None: raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") - return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME]) + return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME], structured_output=structured_output) def _log_tool_application(self, frame: Any, session_id: str) -> None: params = {}