Files
dify/api/core/mcp/server/streamable_http.py
Asuka Minato 68d8328b9c chore: clean Db session from service (#38227)
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-08 03:07:27 +00:00

368 lines
14 KiB
Python

import json
import logging
from collections.abc import Mapping
from typing import Any, NotRequired, TypedDict, cast
from sqlalchemy.orm import Session
from configs import dify_config
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.mcp import types as mcp_types
from graphon.variables.input_entities import VariableEntity, VariableEntityType
from models.model import App, AppMCPServer, AppMode, EndUser
from services.app_generate_service import AppGenerateService
logger = logging.getLogger(__name__)
# Structured tool output (outputSchema + structuredContent) was introduced in MCP 2025-06-18.
STRUCTURED_OUTPUT_MIN_VERSION = "2025-06-18"
def _supports_structured_output(protocol_version: str) -> bool:
"""Return True when the negotiated protocol version supports structured tool output.
MCP protocol versions are YYYY-MM-DD strings, so lexical comparison equals chronological.
"""
return protocol_version >= STRUCTURED_OUTPUT_MIN_VERSION
def negotiate_protocol_version(header_value: str | None, is_initialize: bool) -> str | None:
"""Resolve the negotiated protocol version for an incoming MCP request.
The version is taken from the MCP-Protocol-Version header on post-initialize requests.
Returns the version to use for behavior gating, or None when the client sent an explicit
but unsupported header (the caller should reply with a JSON-RPC INVALID_REQUEST error).
Initialize requests negotiate via the request body, so they always receive
DEFAULT_NEGOTIATED_VERSION and their header is never validated or rejected.
"""
if is_initialize:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
# Treat an absent or empty header as "not specified" -> default version.
if not header_value:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
if header_value not in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
return None
return header_value
class ToolParameterSchemaDict(TypedDict):
type: str
properties: dict[str, Any]
required: list[str]
class ToolArgumentsDict(TypedDict):
query: NotRequired[str]
inputs: dict[str, Any]
def handle_mcp_request(
session: Session,
app: App,
request: mcp_types.ClientRequest,
user_input_form: list[VariableEntity],
mcp_server: AppMCPServer,
end_user: EndUser | None = None,
request_id: int | str = 1,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError:
"""
Handle MCP request and return JSON-RPC response
Args:
app: The Dify app instance
request: The JSON-RPC request message
user_input_form: List of variable entities for the app
mcp_server: The MCP server configuration
end_user: Optional end user
request_id: The request ID
Returns:
JSON-RPC response or error
"""
request_type = type(request.root)
request_root = request.root
def create_success_response(result_data: mcp_types.Result) -> mcp_types.JSONRPCResponse:
"""Create success response with business result data"""
return mcp_types.JSONRPCResponse(
jsonrpc="2.0",
id=request_id,
result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True),
)
def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError:
"""Create error response with error code and message"""
from core.mcp.types import ErrorData
error_data = ErrorData(code=code, message=message)
return mcp_types.JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=error_data,
)
try:
# Dispatch request to appropriate handler based on instance type
match request_root:
case mcp_types.InitializeRequest():
return create_success_response(
handle_initialize(mcp_server.description, request_root.params.protocolVersion)
)
case mcp_types.ListToolsRequest():
return create_success_response(
handle_list_tools(
app.name,
app.mode,
user_input_form,
mcp_server.description,
mcp_server.parameters_dict,
protocol_version,
)
)
case mcp_types.CallToolRequest():
return create_success_response(
handle_call_tool(session, app, request, user_input_form, end_user, protocol_version)
)
case mcp_types.PingRequest():
return create_success_response(handle_ping())
case _:
return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}")
except ValueError as e:
logger.exception("Invalid params")
return create_error_response(mcp_types.INVALID_PARAMS, str(e))
except Exception as e:
logger.exception("Internal server error")
return create_error_response(mcp_types.INTERNAL_ERROR, "Internal server error: " + str(e))
def handle_ping() -> mcp_types.EmptyResult:
"""Handle ping request"""
return mcp_types.EmptyResult()
def handle_initialize(description: str, requested_version: str | int) -> mcp_types.InitializeResult:
"""Handle initialize request, negotiating the protocol version with the client.
Echoes the client's requested version when the server supports it, otherwise returns the
server's latest supported version (per the MCP lifecycle spec).
"""
negotiated_version: str = mcp_types.SERVER_LATEST_PROTOCOL_VERSION
if isinstance(requested_version, str) and requested_version in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
negotiated_version = requested_version
capabilities = mcp_types.ServerCapabilities(
tools=mcp_types.ToolsCapability(listChanged=False),
)
return mcp_types.InitializeResult(
protocolVersion=negotiated_version,
capabilities=capabilities,
serverInfo=mcp_types.Implementation(name="Dify", version=dify_config.project.version),
instructions=description,
)
def handle_list_tools(
app_name: str,
app_mode: str,
user_input_form: list[VariableEntity],
description: str,
parameters_dict: dict[str, str],
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.ListToolsResult:
"""Handle list tools request"""
parameter_schema = build_parameter_schema(app_mode, user_input_form, parameters_dict)
supports_structured = _supports_structured_output(protocol_version)
# For 2025-06-18+ clients, expose an explicit display title and a permissive output
# schema. Both stay None (and are stripped by exclude_none serialization) for older
# clients, so their tool definition is unchanged.
tool = mcp_types.Tool(
name=app_name,
title=app_name if supports_structured else None,
description=description,
inputSchema=cast(dict[str, Any], parameter_schema),
outputSchema={"type": "object"} if supports_structured else None,
)
return mcp_types.ListToolsResult(tools=[tool])
def handle_call_tool(
session: Session,
app: App,
request: mcp_types.ClientRequest,
user_input_form: list[VariableEntity],
end_user: EndUser | None,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.CallToolResult:
"""Handle call tool request"""
request_obj = cast(mcp_types.CallToolRequest, request.root)
args = prepare_tool_arguments(app, request_obj.params.arguments or {})
if not end_user:
raise ValueError("End user not found")
response = AppGenerateService.generate(
session=session,
app_model=app,
user=end_user,
args=args,
invoke_from=InvokeFrom.SERVICE_API,
streaming=app.mode == AppMode.AGENT_CHAT,
)
answer = extract_answer_from_response(app, response)
structured_content = None
if _supports_structured_output(protocol_version):
structured_content = extract_structured_output(app, response, answer)
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(text=answer, type="text")],
structuredContent=structured_content,
)
def build_parameter_schema(
app_mode: str,
user_input_form: list[VariableEntity],
parameters_dict: dict[str, str],
) -> ToolParameterSchemaDict:
"""Build parameter schema for the tool"""
parameters, required = convert_input_form_to_parameters(user_input_form, parameters_dict)
if app_mode in {AppMode.COMPLETION, AppMode.WORKFLOW}:
return {
"type": "object",
"properties": parameters,
"required": required,
}
return {
"type": "object",
"properties": {
"query": {"type": "string", "description": "User Input/Question content"},
**parameters,
},
"required": ["query", *required],
}
def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArgumentsDict:
"""Prepare arguments based on app mode"""
match app.mode:
case AppMode.WORKFLOW:
return {"inputs": arguments}
case AppMode.COMPLETION:
return {"query": "", "inputs": arguments}
case _:
# Chat modes - create a copy to avoid modifying original dict
args_copy = arguments.copy()
query = args_copy.pop("query", "")
return {"query": query, "inputs": args_copy}
def extract_structured_output(app: App, response: Any, answer: str) -> dict[str, Any] | None:
"""Build MCP structured tool output (2025-06-18) from the app response.
WORKFLOW mode exposes the raw outputs mapping; chat/agent/completion modes expose the
answer string under an "answer" key. Returns None when no structured output is available.
"""
match app.mode:
case AppMode.WORKFLOW:
if isinstance(response, Mapping):
data = response.get("data")
if isinstance(data, Mapping):
outputs = data.get("outputs")
# All three guards use Mapping for consistency; coerce to a concrete dict
# because structuredContent must be a JSON object (dict[str, Any]).
if isinstance(outputs, Mapping):
return dict(outputs)
return None
case AppMode.ADVANCED_CHAT | AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
return {"answer": answer}
case _:
return None
def extract_answer_from_response(app: App, response: Any) -> str:
"""Extract answer from app generate response"""
answer = ""
match response:
case RateLimitGenerator():
answer = process_streaming_response(response)
case Mapping():
answer = process_mapping_response(app, response)
case _:
logger.warning("Unexpected response type: %s", type(response))
return answer
def process_streaming_response(response: RateLimitGenerator) -> str:
"""Process streaming response for agent chat mode"""
answer = ""
for item in response.generator:
if isinstance(item, str) and item.startswith("data: "):
try:
json_str = item[6:].strip()
parsed_data = json.loads(json_str)
if parsed_data.get("event") == "agent_thought":
answer += parsed_data.get("thought", "")
except json.JSONDecodeError:
continue
return answer
def process_mapping_response(app: App, response: Mapping) -> str:
"""Process mapping response based on app mode"""
match app.mode:
case AppMode.ADVANCED_CHAT | AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT:
return response.get("answer", "")
case AppMode.WORKFLOW:
return json.dumps(response["data"]["outputs"], ensure_ascii=False)
case _:
raise ValueError("Invalid app mode: " + str(app.mode))
def convert_input_form_to_parameters(
user_input_form: list[VariableEntity],
parameters_dict: dict[str, str],
) -> tuple[dict[str, dict[str, Any]], list[str]]:
"""Convert user input form to parameter schema"""
parameters: dict[str, dict[str, Any]] = {}
required = []
for item in user_input_form:
if item.type in (
VariableEntityType.FILE,
VariableEntityType.FILE_LIST,
VariableEntityType.EXTERNAL_DATA_TOOL,
):
continue
parameters[item.variable] = {}
if item.required:
required.append(item.variable)
# if the workflow republished, the parameters not changed
# we should not raise error here
description = parameters_dict.get(item.variable, "")
parameters[item.variable]["description"] = description
if item.type in (VariableEntityType.TEXT_INPUT, VariableEntityType.PARAGRAPH):
parameters[item.variable]["type"] = "string"
elif item.type == VariableEntityType.SELECT:
parameters[item.variable]["type"] = "string"
parameters[item.variable]["enum"] = item.options
elif item.type == VariableEntityType.NUMBER:
parameters[item.variable]["type"] = "number"
elif item.type == VariableEntityType.CHECKBOX:
parameters[item.variable]["type"] = "boolean"
elif item.type == VariableEntityType.JSON_OBJECT:
parameters[item.variable]["type"] = "object"
if item.json_schema:
for key in ("properties", "required", "additionalProperties"):
if key in item.json_schema:
parameters[item.variable][key] = item.json_schema[key]
return parameters, required