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\""
}
```
<!-- CURSOR_SUMMARY -->
---
> [!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": <inner> } }` (preserving `enum`/`items`), and tests are
expanded to cover these cases. Bumps `arcade-mcp-server` version to
`1.18.0`.
>
> <sup>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).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
parent
3c7d1e1ee1
commit
9bbdbe2b46
3 changed files with 145 additions and 23 deletions
|
|
@ -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]:
|
def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
|
||||||
"""Map a ValueSchema to a JSON schema fragment for outputSchema."""
|
"""Map a ValueSchema to a JSON Schema ``outputSchema``.
|
||||||
schema: dict[str, Any] = {
|
|
||||||
"type": _map_type_to_json_schema_type(getattr(value_schema, "val_type", None)),
|
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": <inner>}}`` 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):
|
if getattr(value_schema, "enum", None):
|
||||||
schema["enum"] = list(value_schema.enum)
|
inner_schema["enum"] = list(value_schema.enum)
|
||||||
if getattr(value_schema, "val_type", None) == "array" and getattr(
|
if val_type == "array" and getattr(value_schema, "inner_val_type", None):
|
||||||
value_schema, "inner_val_type", None
|
inner_schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)}
|
||||||
):
|
return {
|
||||||
schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)}
|
"type": "object",
|
||||||
if getattr(value_schema, "val_type", None) == "json" and getattr(
|
"properties": {
|
||||||
value_schema, "properties", None
|
"result": inner_schema,
|
||||||
):
|
},
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.17.5"
|
version = "1.18.0"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,111 @@ class TestCreateMCPTool:
|
||||||
"""Test that output schema is included when definition has one."""
|
"""Test that output schema is included when definition has one."""
|
||||||
mcp_tool = create_mcp_tool(materialized_tool)
|
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": <inner>}.
|
||||||
assert mcp_tool.outputSchema is not None
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue