Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions api/core/tools/mcp_tool/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
from collections.abc import Generator, Mapping
from copy import deepcopy
from typing import Any, cast, override

from sqlalchemy.orm import Session
Expand Down Expand Up @@ -36,6 +37,23 @@
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Token"


def schema_accepts_multiple_types(schema: Mapping[str, Any]) -> bool:
"""Whether a JSON schema property accepts more than one JSON type, e.g. `string` or `array`."""
branches = [schema]
for union_key in ("anyOf", "oneOf"):
union_schemas = schema.get(union_key)
if isinstance(union_schemas, list):
branches.extend(branch for branch in union_schemas if isinstance(branch, Mapping))

type_names: set[str] = set()
for branch in branches:
declared = branch.get("type")
for type_name in declared if isinstance(declared, list) else [declared]:
if isinstance(type_name, str) and type_name != "null":
type_names.add("number" if type_name in ("integer", "float") else type_name)
return len(type_names) > 1


class MCPTool(Tool):
def __init__(
self,
Expand Down Expand Up @@ -257,6 +275,21 @@ def fork_tool_runtime(self, runtime: ToolRuntime) -> MCPTool:
identity_mode=self.identity_mode,
)

@override
def _transform_tool_parameters_type(self, tool_parameters: dict[str, Any]) -> dict[str, Any]:
# A multi-type union is declared to Dify as just one of its types, so casting would corrupt values
# of the other types (e.g. str(["light"]) -> "['light']"). Forward them as-is; the server validates.
union_names = {
parameter.name
for parameter in self.entity.parameters or []
if parameter.input_schema is not None and schema_accepts_multiple_types(parameter.input_schema)
}
result = super()._transform_tool_parameters_type(
{name: value for name, value in tool_parameters.items() if name not in union_names}
)
result.update({name: deepcopy(value) for name, value in tool_parameters.items() if name in union_names})
return result

def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
"""
in mcp tool invoke, if the parameter is empty, it will be set to None
Expand Down
4 changes: 3 additions & 1 deletion api/services/tools/tools_transform_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ToolProviderType,
emoji_icon_adapter,
)
from core.tools.mcp_tool.tool import schema_accepts_multiple_types
from core.tools.plugin_tool.provider import PluginToolProviderController
from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
Expand Down Expand Up @@ -526,7 +527,8 @@ def process_properties(
prop_type = resolve_property_type(prop)
if prop_type in type_mapping:
prop_type = type_mapping[prop_type]
input_schema = prop if prop_type in complex_types else None
# Keep the raw schema for multi-type unions so the LLM sees every accepted type, not just prop_type.
input_schema = prop if prop_type in complex_types or schema_accepts_multiple_types(prop) else None
parameters.append(
create_parameter(name, current_description, prop_type, name in required, input_schema)
)
Expand Down
36 changes: 36 additions & 0 deletions api/tests/unit_tests/core/tools/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from core.tools.errors import ToolInvokeError
from core.tools.mcp_tool.tool import MCPTool
from enums import DeploymentEdition
from services.tools.tools_transform_service import ToolTransformService


def _build_mcp_tool(*, with_output_schema: bool = True) -> MCPTool:
Expand Down Expand Up @@ -157,6 +158,41 @@ def test_mcp_tool_handle_none_parameter_filters_empty_values():
assert cleaned == {"a": 1, "e": "ok"}


# Home Assistant's GetLiveContext declares `domain` as `vol.Any(cv.string, [cv.string])`.
_STRING = {"type": "string"}
_STRING_LIST = {"type": "array", "items": {"type": "string"}}


@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
@pytest.mark.parametrize(
"domain_schema",
[
{"anyOf": [_STRING, _STRING_LIST]},
{"anyOf": [_STRING_LIST, _STRING]},
{"oneOf": [_STRING, _STRING_LIST]},
{"oneOf": [_STRING_LIST, _STRING]},
{"type": ["string", "array"], "items": _STRING},
{"type": ["array", "string"], "items": _STRING},
],
)
@pytest.mark.parametrize("domain", [["light", "cover"], "light"])
def test_mcp_tool_invoke_preserves_values_of_mixed_type_union_parameters(
sqlite_session: Session, domain_schema, domain
):
tool = _build_mcp_tool()
schema = {"type": "object", "properties": {"domain": domain_schema, "name": _STRING}, "required": []}
tool.entity.parameters = ToolTransformService.convert_mcp_schema_to_parameter(schema)
result = CallToolResult(content=[TextContent(type="text", text="ok")])

with patch.object(MCPTool, "invoke_remote_mcp_tool", return_value=result) as invoke_remote:
list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={"domain": domain, "name": 1}))

sent = invoke_remote.call_args.args[0]
assert sent["domain"] == domain
# Parameters with a single declared type keep the existing cast.
assert sent["name"] == "1"


# ----- M2/M3 user-identity forwarding ---------------------------------------


Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from unittest.mock import ANY, MagicMock, patch

import pytest

from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import (
Expand Down Expand Up @@ -440,6 +442,37 @@ def test_list_type_uses_first_element(self):
schema = {"type": "object", "properties": {"f": {"type": ["string", "null"]}}, "required": []}
assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].type.value == "string"

@pytest.mark.parametrize(
("prop", "expected_type"),
[
({"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"),
({"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"),
({"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "string"),
({"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}]}, "array"),
({"type": ["string", "array"], "items": {"type": "string"}}, "string"),
({"type": ["array", "string"], "items": {"type": "string"}}, "array"),
({"type": ["string", "array", "null"]}, "string"),
],
)
def test_mixed_type_union_attaches_input_schema(self, prop, expected_type):
schema = {"type": "object", "properties": {"domain": prop}, "required": []}
param = ToolTransformService.convert_mcp_schema_to_parameter(schema)[0]
assert param.type.value == expected_type
assert param.input_schema == prop

@pytest.mark.parametrize(
"prop",
[
{"type": ["string", "null"]},
{"anyOf": [{"type": "string"}, {"type": "null"}]},
{"oneOf": [{"type": "null"}, {"type": "string"}]},
{"oneOf": [{"type": "integer"}, {"type": "number"}]},
],
)
def test_single_type_union_has_no_input_schema(self, prop):
schema = {"type": "object", "properties": {"f": prop}, "required": []}
assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].input_schema is None

def test_missing_description_defaults_empty(self):
schema = {"type": "object", "properties": {"f": {"type": "string"}}, "required": []}
assert ToolTransformService.convert_mcp_schema_to_parameter(schema)[0].llm_description == ""
Expand Down
Loading