From 9bbdbe2b46507b0f33edf80ce93496d1731f6e3f Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:50:54 -0700 Subject: [PATCH] Fix outputSchema to conform to MCP spec's object type requirement (#799) When a stdio server had a tool that didn't return a dict, then: ``` { "code": "invalid_value", "values": [ "object" ], "path": [ "tools", 2, "outputSchema", "type" ], "message": "Invalid input: expected \"object\"" } ``` --- > [!NOTE] > **Medium Risk** > Changes the generated `outputSchema` shape for all non-`json` return types by wrapping them under a `result` property, which may affect clients/tests expecting primitive/array schemas despite being spec-correct. > > **Overview** > Adjusts MCP tool `outputSchema` generation to **always** emit an object schema, per the MCP spec that `structuredContent` must be a JSON object. > > `json` outputs remain a direct object schema, while primitive/array outputs are now wrapped as `{ "type": "object", "properties": { "result": } }` (preserving `enum`/`items`), and tests are expanded to cover these cases. Bumps `arcade-mcp-server` version to `1.18.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7dd13bd33d6fdf6ebb778e1a3d9167ca89806f55. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../arcade_mcp_server/convert.py | 57 +++++---- libs/arcade-mcp-server/pyproject.toml | 2 +- libs/tests/arcade_mcp_server/test_convert.py | 109 +++++++++++++++++- 3 files changed, 145 insertions(+), 23 deletions(-) diff --git a/libs/arcade-mcp-server/arcade_mcp_server/convert.py b/libs/arcade-mcp-server/arcade_mcp_server/convert.py index c0801aa0..c9fbe2e1 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/convert.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/convert.py @@ -229,25 +229,42 @@ def build_input_schema_from_definition(definition: ToolDefinition) -> dict[str, def _build_value_schema_json(value_schema: Any) -> dict[str, Any]: - """Map a ValueSchema to a JSON schema fragment for outputSchema.""" - schema: dict[str, Any] = { - "type": _map_type_to_json_schema_type(getattr(value_schema, "val_type", None)), + """Map a ValueSchema to a JSON Schema ``outputSchema``. + + Per the MCP specification, ``outputSchema.type`` MUST be ``"object"`` + because ``structuredContent`` is always a JSON object. + + * **object** return types (``val_type == "json"``) are emitted directly + as ``{"type": "object", "properties": {…}}``. + * All other return types (primitives, arrays) are wrapped in + ``{"type": "object", "properties": {"result": }}`` to mirror + the wrapping performed at runtime by + :func:`convert_content_to_structured_content`. + """ + val_type = getattr(value_schema, "val_type", None) + + if val_type == "json": + schema: dict[str, Any] = {"type": "object"} + if getattr(value_schema, "properties", None): + schema["properties"] = {} + for prop_name, prop_schema in value_schema.properties.items(): + schema["properties"][prop_name] = { + "type": _map_type_to_json_schema_type(getattr(prop_schema, "val_type", None)) + } + if getattr(prop_schema, "description", None): + schema["properties"][prop_name]["description"] = prop_schema.description + return schema + + inner_schema: dict[str, Any] = { + "type": _map_type_to_json_schema_type(val_type), } if getattr(value_schema, "enum", None): - schema["enum"] = list(value_schema.enum) - if getattr(value_schema, "val_type", None) == "array" and getattr( - value_schema, "inner_val_type", None - ): - schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)} - if getattr(value_schema, "val_type", None) == "json" and getattr( - value_schema, "properties", None - ): - schema["type"] = "object" - schema["properties"] = {} - for prop_name, prop_schema in value_schema.properties.items(): - schema["properties"][prop_name] = { - "type": _map_type_to_json_schema_type(getattr(prop_schema, "val_type", None)) - } - if getattr(prop_schema, "description", None): - schema["properties"][prop_name]["description"] = prop_schema.description - return schema + inner_schema["enum"] = list(value_schema.enum) + if val_type == "array" and getattr(value_schema, "inner_val_type", None): + inner_schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)} + return { + "type": "object", + "properties": { + "result": inner_schema, + }, + } diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 3944565d..05275f2f 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "arcade-mcp-server" -version = "1.17.5" +version = "1.18.0" description = "Model Context Protocol (MCP) server framework for Arcade.dev" readme = "README.md" authors = [{ name = "Arcade.dev" }] diff --git a/libs/tests/arcade_mcp_server/test_convert.py b/libs/tests/arcade_mcp_server/test_convert.py index 3305c3d3..a571c1e5 100644 --- a/libs/tests/arcade_mcp_server/test_convert.py +++ b/libs/tests/arcade_mcp_server/test_convert.py @@ -375,6 +375,111 @@ class TestCreateMCPTool: """Test that output schema is included when definition has one.""" mcp_tool = create_mcp_tool(materialized_tool) - # The fixture's output has value_schema=ValueSchema(val_type="number") + # The fixture's output has value_schema=ValueSchema(val_type="number"). + # Per MCP spec, outputSchema.type must be "object"; non-object return + # types are wrapped in {"result": }. assert mcp_tool.outputSchema is not None - assert mcp_tool.outputSchema["type"] == "number" + assert mcp_tool.outputSchema["type"] == "object" + assert mcp_tool.outputSchema["properties"]["result"]["type"] == "number" + + def _make_tool_with_output(self, value_schema: ValueSchema): + """Helper to create a materialized tool with a given output ValueSchema.""" + tool_def = ToolDefinition( + name="test", + fully_qualified_name="Test.test", + description="Test", + toolkit=ToolkitDefinition(name="Test"), + input=ToolInput(parameters=[]), + output=ToolOutput( + description="Test output", + value_schema=value_schema, + ), + requirements=ToolRequirements(), + ) + + @tool + def f() -> Annotated[str, "result"]: + return "result" + + input_model, output_model = create_func_models(f) + meta = ToolMeta(module=f.__module__, toolkit=tool_def.toolkit.name) + mat_tool = MaterializedTool( + tool=f, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + return create_mcp_tool(mat_tool) + + @pytest.mark.parametrize( + "val_type", + ["string", "integer", "number", "boolean"], + ) + def test_output_schema_primitive_types_wrapped_as_object(self, val_type): + """Primitive output types must be wrapped so outputSchema.type == 'object'.""" + mcp_tool = self._make_tool_with_output(ValueSchema(val_type=val_type)) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + expected_json_type = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + }[val_type] + assert schema["properties"]["result"]["type"] == expected_json_type + + def test_output_schema_array_type_wrapped_as_object(self): + """Array output type must be wrapped so outputSchema.type == 'object'.""" + mcp_tool = self._make_tool_with_output( + ValueSchema(val_type="array", inner_val_type="string") + ) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + result_prop = schema["properties"]["result"] + assert result_prop["type"] == "array" + assert result_prop["items"]["type"] == "string" + + def test_output_schema_enum_preserved_in_wrapper(self): + """Enum values must be preserved inside the wrapped result property.""" + mcp_tool = self._make_tool_with_output( + ValueSchema(val_type="string", enum=["a", "b", "c"]) + ) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + assert schema["properties"]["result"]["enum"] == ["a", "b", "c"] + + def test_output_schema_json_type_not_wrapped(self): + """Object (json) output types are already type 'object', not wrapped.""" + mcp_tool = self._make_tool_with_output( + ValueSchema( + val_type="json", + properties={ + "name": ValueSchema(val_type="string", description="A name"), + "count": ValueSchema(val_type="integer"), + }, + ) + ) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + assert "result" not in schema.get("properties", {}) + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["name"]["description"] == "A name" + assert schema["properties"]["count"]["type"] == "integer" + + def test_output_schema_json_type_without_properties(self): + """Object (json) output type with no properties is a bare object schema.""" + mcp_tool = self._make_tool_with_output(ValueSchema(val_type="json")) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + assert "properties" not in schema