Merge pull request #1605 from oraios/structured-output

Allow to configure structured tool output on a per-context basis

Resolves #1042
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-06-26 13:40:43 +02:00
commit 348df3a172
6 files changed
+37 -13

No files matched your search

+2
View File
@@ -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
+5
View File
@@ -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"]
+18 -8
View File
@@ -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
@@ -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
@@ -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
single_project: false
# whether to make MCP tools return structured output (null = auto-detect, true = always, false = never)
structured_tool_output: null
+4 -4
View File
@@ -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 = {}