From f89a90d3ffa45fe06567e90cda44b61afae5f9b8 Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Mon, 2 Nov 2026 14:04:33 +0800 Subject: [PATCH] feat: Add MCP service configuration for applications --- apps/application/tests/test_workflow_nodes.py | 2 +- apps/chat/mcp/tools.py | 364 ++++++++++++++---- apps/chat/tests/test_mcp.py | 221 +++++++++++ apps/chat/urls.py | 1 + apps/chat/views/v3/mcp.py | 74 ++-- .../McpConfigApplicationAction.vue | 49 +++ .../application-card/action-dropdown/index.ts | 1 + ui/src/views/application/index.vue | 3 + 8 files changed, 608 insertions(+), 107 deletions(-) create mode 100644 apps/chat/tests/test_mcp.py create mode 100644 ui/src/views/application/application-card/action-dropdown/McpConfigApplicationAction.vue diff --git a/apps/application/tests/test_workflow_nodes.py b/apps/application/tests/test_workflow_nodes.py index 72d274dde00..e5b071c16f4 100644 --- a/apps/application/tests/test_workflow_nodes.py +++ b/apps/application/tests/test_workflow_nodes.py @@ -326,7 +326,7 @@ def test_collects_deduplicated_images_from_upstream_knowledge_searches(self): @patch("application.workflow.nodes.ai_chat_node.ai_chat_node._process_images") def test_vision_chat_receives_recalled_knowledge_images(self, process_images, get_model, _authorized): processed_image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,image"}} - process_images.return_value = [processed_image] + process_images.return_value = ([processed_image], [{"url": "./oss/file/image"}]) self.workflow_manage.generate_prompt.side_effect = ["answer with the recalled context", "system prompt"] self.workflow_manage.workflow_type = WorkflowType.APPLICATION self.workflow_manage.get_parameters.return_value = {"workspace_id": "workspace"} diff --git a/apps/chat/mcp/tools.py b/apps/chat/mcp/tools.py index ea9ea8ea9d6..29cd7ec3071 100644 --- a/apps/chat/mcp/tools.py +++ b/apps/chat/mcp/tools.py @@ -1,13 +1,18 @@ import base64 +import binascii import json import re +import uuid as std_uuid import uuid_utils.compat as uuid -from application.models import Application, ApplicationApiKey, ChatSourceChoices, ChatUserType +from application.models import Application, ApplicationApiKey, Chat, ChatSourceChoices, ChatUserType +from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import QuerySet from django.utils import timezone from chat.serializers.chat import ChatSerializers +from common.auth.constants.group_constants import Group +from knowledge.models import File, FileSourceType CHAT_FILE_LIST_FIELDS = ("image_list", "document_list", "audio_list", "video_list", "other_list") @@ -19,6 +24,94 @@ "other_list": "file", } +# 与前端 ui/src/constants/file-type.ts 保持一致,按后缀把文件归到对应的消息附件字段 +CHAT_FILE_EXTENSIONS = { + "image_list": ("JPG", "JPEG", "PNG", "GIF", "BMP"), + "document_list": ("PDF", "DOCX", "TXT", "XLS", "XLSX", "MD", "HTML", "CSV"), + "audio_list": ("MP3", "WAV", "OGG", "AAC", "M4A"), + "video_list": ("MP4", "AVI", "MKV", "MOV", "FLV", "WMV"), +} + +MCP_PROTOCOL_VERSION = "2025-06-18" + +AGENT_TOOL_PREFIX = "agent_" + +UPLOAD_FILE_TOOL = "upload_file" + + +class InvalidParamsError(Exception): + """工具参数不合法,对应 JSON-RPC -32602""" + + +def get_chat_file_field(file_name): + extension = file_name.rsplit(".", 1)[-1].upper() if "." in file_name else "" + for field, extensions in CHAT_FILE_EXTENSIONS.items(): + if extension in extensions: + return field + return "other_list" + + +def build_tool(application, description=None): + return { + "name": f"{AGENT_TOOL_PREFIX}{str(application.id)}", + "description": description or f"{application.name} {application.desc or ''}".strip(), + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "The message to send to the AI."}, + }, + "required": ["message"], + }, + } + + +def build_form_data(message, form_data=None): + """ + 合并父应用透传参数与提示词中的 JSON 参数,提示词参数优先。 + """ + try: + message_form_data = json.loads(message or "{}") + except (TypeError, json.JSONDecodeError): + message_form_data = {} + if not isinstance(message_form_data, dict): + message_form_data = {} + return {**(form_data or {}), **message_form_data} + + +def chat_application( + application_id, chat_user_id, chat_user_type, message, form_data=None, chat_files=None, chat_id=None +): + """ + 以指定对话用户身份同步调用应用,返回 (回答, chat_id)。 + 不传 chat_id 时新建会话;会话行由 ChatSerializers 惰性创建(标题取用户问题),无需先 open。 + """ + from common.init import init_template + + init_template.run() + chat_id = chat_id or str(uuid.uuid7()) + resp = ChatSerializers( + data={ + "chat_id": chat_id, + "application_id": application_id, + "chat_user_id": chat_user_id, + "chat_user_type": chat_user_type, + "ip_address": "-", + "source": {"type": ChatSourceChoices.API_CALL.value}, + "debug": False, + } + ).chat( + { + "message": {"content": message or "", **(chat_files or {})}, + "stream": False, + "re_chat": False, + "form_data": build_form_data(message, form_data), + } + ) + data = (json.loads(resp.content).get("data") or {}).get("content") or "" + # 排除标签 + data = re.sub(r".*?", "", data, flags=re.DOTALL) + return data, chat_id + class MCPToolHandler: def __init__(self, auth_header, chat_files_header=None, form_data=None): @@ -28,6 +121,7 @@ def __init__(self, auth_header, chat_files_header=None, form_data=None): if app_key.is_permanent is False and app_key.expire_time < timezone.now(): raise PermissionError("API Key is expired") + self.app_key = app_key self.application = QuerySet(Application).filter(id=app_key.application_id, is_publish=True).first() if not self.application: raise PermissionError("Application is not found or not published") @@ -70,7 +164,7 @@ def decode_form_data(form_data): def initialize(self): return { - "protocolVersion": "2025-06-18", + "protocolVersion": MCP_PROTOCOL_VERSION, "serverInfo": {"name": "maxkb-mcp", "version": "1.0.0"}, "capabilities": {"tools": {}}, } @@ -100,93 +194,207 @@ def build_description(self): ) def list_tools(self): + return {"tools": [build_tool(self.application, self.build_description())]} + + def call_tool(self, params): + args = params.get("arguments", {}) + answer, _ = chat_application( + self.application.id, + str(self.app_key.id), + ChatUserType.APPLICATION_API_KEY.value, + args.get("message"), + self.form_data, + self.chat_files, + ) + return {"content": [{"type": "text", "text": answer}]} + + +class ChatUserMCPToolHandler: + """ + 对话用户 API Key(chat-xxx)的 MCP 接口:Key 不绑定应用, + 以对话用户身份暴露其有权限访问的所有已发布应用,每个应用一个工具,另加一个上传文件工具。 + 传入上一次返回的 chat_id 即可续聊;上传的文件挂在 chat_id 下,调用应用时通过 file_ids 携带。 + """ + + def __init__(self, chat_user, auth, application_id=None): + self.chat_user = chat_user + self.auth = auth + # 单应用 MCP 地址只暴露该应用 + self.application_id = application_id + + def initialize(self): return { - "tools": [ - { - "name": f"agent_{str(self.application.id)}", - "description": self.build_description(), - "inputSchema": { - "type": "object", - "properties": { - "message": {"type": "string", "description": "The message to send to the AI."}, - }, - "required": ["message"], - }, - } - ] + "protocolVersion": MCP_PROTOCOL_VERSION, + "serverInfo": {"name": "maxkb-mcp", "version": "1.0.0"}, + "capabilities": {"tools": {}}, } - def _get_chat_id(self): - from application.models import ChatUserType - from common.init import init_template + def get_application_ids(self): + prefix = f"{Group.CHAT_USER}:r:" + application_ids = [ + key[len(prefix) :] for key, value in self.auth.permissions.items() if key.startswith(prefix) and value + ] + if self.application_id: + return [app_id for app_id in application_ids if app_id == str(self.application_id)] + return application_ids - from chat.serializers.chat import OpenChatSerializers + def get_applications(self): + return QuerySet(Application).filter(id__in=self.get_application_ids(), is_publish=True).order_by("name") - init_template.run() + @staticmethod + def build_agent_tool(application): + return { + "name": f"{AGENT_TOOL_PREFIX}{str(application.id)}", + "description": f"{application.name} {application.desc or ''}".strip(), + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "The message to send to the AI."}, + "chat_id": { + "type": "string", + "description": "Conversation id returned by a previous call or by upload_file. " + "Pass it to continue the same conversation with history; omit it to start a new one.", + }, + "file_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Ids of files uploaded with upload_file to attach to this message.", + }, + "form_data": { + "type": "object", + "description": "Values for the application's input parameters.", + }, + }, + "required": ["message"], + }, + } - return OpenChatSerializers( - data={ - "application_id": self.application.id, - "chat_user_id": str(uuid.uuid7()), - "chat_user_type": ChatUserType.ANONYMOUS_USER, - "ip_address": "-", - "source": {"type": ChatSourceChoices.ONLINE.value}, - "debug": False, - } - ).open() + @staticmethod + def build_upload_tool(): + return { + "name": UPLOAD_FILE_TOOL, + "description": "Upload a file to attach to a conversation. Returns file_id and chat_id; " + "pass both to an agent tool (file_ids, chat_id) so the AI can read the file.", + "inputSchema": { + "type": "object", + "properties": { + "file_name": {"type": "string", "description": "File name with extension, e.g. report.pdf."}, + "content": {"type": "string", "description": "Base64 encoded file content."}, + "chat_id": { + "type": "string", + "description": "Conversation to attach the file to; omit it to start a new conversation.", + }, + }, + "required": ["file_name", "content"], + }, + } - def build_form_data(self, message): + def list_tools(self): + return { + "tools": [ + *[self.build_agent_tool(application) for application in self.get_applications()], + self.build_upload_tool(), + ] + } + + def check_chat_id(self, chat_id, application_id=None): """ - 合并父应用透传参数与提示词中的 JSON 参数,提示词参数优先。 + 校验调用方传入的 chat_id:已存在的会话必须属于当前对话用户(及当前应用),不存在则视为新会话。 """ + if not chat_id: + return None try: - message_form_data = json.loads(message or "{}") - except (TypeError, json.JSONDecodeError): - message_form_data = {} - if not isinstance(message_form_data, dict): - message_form_data = {} - return {**self.form_data, **message_form_data} + chat_id = str(std_uuid.UUID(str(chat_id))) + except ValueError: + raise InvalidParamsError(f"Invalid chat_id: {chat_id}") + chat = QuerySet(Chat).filter(id=chat_id).first() + if chat is not None and ( + str(chat.chat_user_id) != str(self.chat_user.id) + or (application_id and str(chat.application_id) != str(application_id)) + ): + raise InvalidParamsError(f"Invalid chat_id: {chat_id}") + return chat_id - def call_tool(self, params): - args = params.get("arguments", {}) - message = args.get("message") + def get_chat_files(self, chat_id, file_ids): + """ + 根据 file_ids 查出当前用户上传到该会话的文件,按类型组装成消息附件;未传 chat_id 时取文件所在会话。 + """ + if not file_ids: + return chat_id, {} + if not isinstance(file_ids, list): + raise InvalidParamsError("file_ids must be an array") + files = list( + QuerySet(File).filter( + id__in=[str(file_id) for file_id in file_ids], + source_type=FileSourceType.CHAT, + meta__user_id=str(self.chat_user.id), + ) + ) + source_ids = {str(file.source_id) for file in files} + if len(files) != len(set(map(str, file_ids))) or len(source_ids) != 1 or chat_id not in (None, *source_ids): + raise InvalidParamsError("file_ids must be files uploaded to the same chat_id") + chat_files = {} + for file in files: + chat_files.setdefault(get_chat_file_field(file.file_name), []).append( + {"name": file.file_name, "url": f"./oss/file/{file.id}", "file_id": str(file.id)} + ) + return source_ids.pop(), chat_files - payload = { - "message": message, - "stream": True, - "re_chat": False, - "form_data": self.build_form_data(message), - **self.chat_files, - } - resp = ChatSerializers( + def upload_file(self, args): + from oss.serializers.file import FileSerializer + + file_name = str(args.get("file_name") or "").strip() + content = args.get("content") + if not file_name or not isinstance(content, str) or not content: + raise InvalidParamsError("file_name and content are required") + try: + file_bytes = base64.b64decode(content, validate=True) + except (binascii.Error, ValueError): + raise InvalidParamsError("content must be base64 encoded") + chat_id = self.check_chat_id(args.get("chat_id")) or str(uuid.uuid7()) + file_url = FileSerializer( data={ - "chat_id": self._get_chat_id(), - "chat_user_id": str(uuid.uuid7()), - "chat_user_type": ChatUserType.ANONYMOUS_USER, - "application_id": self.application.id, - "ip_address": "-", - "source": {"type": ChatSourceChoices.ONLINE.value}, - "debug": False, + "file": SimpleUploadedFile(file_name, file_bytes), + "meta": {}, + "source_id": chat_id, + "source_type": FileSourceType.CHAT, } - ).chat(payload) - chunks = [] - for raw_line in resp: - line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") - if not line.startswith("data:"): - continue - payload = line[5:].strip() - if not payload or payload == "[DONE]": - continue - try: - event = json.loads(payload) - except json.JSONDecodeError: - continue - if event.get("operate") is True: - chunks.append(event.get("content", "")) - if event.get("is_end"): - break - - data = "".join(chunks) - # 排除标签 - data = re.sub(r".*?", "", data, flags=re.DOTALL) - return {"content": [{"type": "text", "text": data}]} + ).upload(user_id=str(self.chat_user.id)) + result = {"file_id": file_url.split("/")[-1], "name": file_name, "url": file_url, "chat_id": chat_id} + return { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}], + "structuredContent": result, + } + + def call_tool(self, params): + name = str(params.get("name") or "") + args = params.get("arguments") or {} + if name == UPLOAD_FILE_TOOL: + return self.upload_file(args) + application_id = name[len(AGENT_TOOL_PREFIX) :] if name.startswith(AGENT_TOOL_PREFIX) else "" + if not application_id or not self.get_applications().filter(id=application_id).exists(): + raise InvalidParamsError(f"Unknown tool: {name}") + form_data = args.get("form_data") or {} + if not isinstance(form_data, dict): + raise InvalidParamsError("form_data must be an object") + chat_id, chat_files = self.get_chat_files( + self.check_chat_id(args.get("chat_id"), application_id), args.get("file_ids") + ) + # 文件可能先上传到别的应用的会话,这里再按当前应用校验一次 + chat_id = self.check_chat_id(chat_id, application_id) + answer, chat_id = chat_application( + application_id, + str(self.chat_user.id), + ChatUserType.CHAT_USER.value, + args.get("message"), + form_data, + chat_files, + chat_id, + ) + return { + "content": [ + {"type": "text", "text": answer}, + {"type": "text", "text": json.dumps({"chat_id": chat_id})}, + ], + "structuredContent": {"answer": answer, "chat_id": chat_id}, + } diff --git a/apps/chat/tests/test_mcp.py b/apps/chat/tests/test_mcp.py new file mode 100644 index 00000000000..e74696f8538 --- /dev/null +++ b/apps/chat/tests/test_mcp.py @@ -0,0 +1,221 @@ +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, SimpleTestCase +from django.urls import resolve + +from application.models import ChatSourceChoices, ChatUserType +from chat.mcp.tools import ChatUserMCPToolHandler, InvalidParamsError, chat_application, get_chat_file_field +from chat.views.v3.mcp import mcp_view +from common.auth.struct.auth import Auth +from common.exception.app_exception import AppAuthenticationFailed + + +class ChatApplicationTest(SimpleTestCase): + @patch("common.init.init_template.run") + @patch("chat.mcp.tools.ChatSerializers") + def test_chats_as_given_chat_user_in_a_new_chat(self, chat_serializers, _init): + chat_serializers.return_value.chat.return_value = SimpleNamespace( + content=json.dumps({"data": {"content": "answertool"}}).encode() + ) + + result = chat_application( + "app-id", "user-id", ChatUserType.CHAT_USER.value, "hello", {"a": 1}, {"image_list": [{"file_id": "f"}]} + ) + + chat_info = chat_serializers.call_args.kwargs["data"] + self.assertEqual(chat_info["chat_user_id"], "user-id") + self.assertEqual(chat_info["chat_user_type"], ChatUserType.CHAT_USER.value) + self.assertEqual(chat_info["source"], {"type": ChatSourceChoices.API_CALL.value}) + self.assertTrue(chat_info["chat_id"]) + payload = chat_serializers.return_value.chat.call_args.args[0] + self.assertEqual(payload["message"], {"content": "hello", "image_list": [{"file_id": "f"}]}) + self.assertEqual(payload["form_data"], {"a": 1}) + self.assertFalse(payload["stream"]) + self.assertEqual(result, ("answer", chat_info["chat_id"])) + + @patch("common.init.init_template.run") + @patch("chat.mcp.tools.ChatSerializers") + def test_continues_given_chat(self, chat_serializers, _init): + chat_serializers.return_value.chat.return_value = SimpleNamespace(content=b'{"data": {"content": "a"}}') + + result = chat_application("app-id", "user-id", ChatUserType.CHAT_USER.value, "hi", chat_id="chat-1") + + self.assertEqual(chat_serializers.call_args.kwargs["data"]["chat_id"], "chat-1") + self.assertEqual(result, ("a", "chat-1")) + + def test_classifies_files_by_extension(self): + self.assertEqual(get_chat_file_field("a.PNG"), "image_list") + self.assertEqual(get_chat_file_field("a.pdf"), "document_list") + self.assertEqual(get_chat_file_field("a.mp3"), "audio_list") + self.assertEqual(get_chat_file_field("a.mp4"), "video_list") + self.assertEqual(get_chat_file_field("a.zip"), "other_list") + self.assertEqual(get_chat_file_field("noext"), "other_list") + + +class ChatUserMCPToolHandlerTest(SimpleTestCase): + def setUp(self): + self.handler = ChatUserMCPToolHandler( + SimpleNamespace(id="user-id"), + Auth(set(), {"CHAT_USER:r:app-1": 1, "CHAT_USER:r:app-2": 0, "OTHER:r:app-3": 1}), + ) + + def test_only_permitted_applications_are_queried(self): + self.assertEqual(self.handler.get_application_ids(), ["app-1"]) + + def test_application_scoped_handler_only_exposes_that_application(self): + auth = Auth(set(), {"CHAT_USER:r:app-1": 1, "CHAT_USER:r:app-4": 1}) + self.assertEqual( + ChatUserMCPToolHandler(SimpleNamespace(id="u"), auth, "app-4").get_application_ids(), ["app-4"] + ) + self.assertEqual(ChatUserMCPToolHandler(SimpleNamespace(id="u"), auth, "app-9").get_application_ids(), []) + + @patch("chat.mcp.tools.QuerySet") + def test_list_tools_exposes_one_tool_per_application_and_upload(self, query_set): + query_set.return_value.filter.return_value.order_by.return_value = [ + SimpleNamespace(id="app-1", name="Agent", desc="desc") + ] + + tools = self.handler.list_tools()["tools"] + + self.assertEqual([tool["name"] for tool in tools], ["agent_app-1", "upload_file"]) + self.assertEqual(tools[0]["description"], "Agent desc") + self.assertEqual(set(tools[0]["inputSchema"]["properties"]), {"message", "chat_id", "file_ids", "form_data"}) + + @patch("chat.mcp.tools.chat_application", return_value=("answer", "chat-id")) + @patch.object(ChatUserMCPToolHandler, "get_applications") + def test_call_tool_chats_as_chat_user_and_returns_chat_id(self, get_applications, chat_app): + get_applications.return_value.filter.return_value.exists.return_value = True + + result = self.handler.call_tool({"name": "agent_app-1", "arguments": {"message": "hi"}}) + + get_applications.return_value.filter.assert_called_once_with(id="app-1") + chat_app.assert_called_once_with("app-1", "user-id", ChatUserType.CHAT_USER.value, "hi", {}, {}, None) + self.assertEqual(result["content"][0], {"type": "text", "text": "answer"}) + self.assertEqual(result["structuredContent"], {"answer": "answer", "chat_id": "chat-id"}) + + @patch.object(ChatUserMCPToolHandler, "get_applications") + def test_call_tool_rejects_unpermitted_application(self, get_applications): + get_applications.return_value.filter.return_value.exists.return_value = False + + with self.assertRaises(InvalidParamsError): + self.handler.call_tool({"name": "agent_app-9", "arguments": {"message": "hi"}}) + + @patch("chat.mcp.tools.QuerySet") + def test_check_chat_id(self, query_set): + chat_id = "01a17d06-3bd0-7661-91dc-9d990282d863" + query_set.return_value.filter.return_value.first.return_value = None + self.assertEqual(self.handler.check_chat_id(chat_id, "app-1"), chat_id) + + query_set.return_value.filter.return_value.first.return_value = SimpleNamespace( + chat_user_id="other-user", application_id="app-1" + ) + with self.assertRaises(InvalidParamsError): + self.handler.check_chat_id(chat_id, "app-1") + + query_set.return_value.filter.return_value.first.return_value = SimpleNamespace( + chat_user_id="user-id", application_id="app-2" + ) + self.assertEqual(self.handler.check_chat_id(chat_id), chat_id) + with self.assertRaises(InvalidParamsError): + self.handler.check_chat_id(chat_id, "app-1") + + with self.assertRaises(InvalidParamsError): + self.handler.check_chat_id("not-a-uuid") + + @patch("chat.mcp.tools.QuerySet") + def test_chat_files_are_grouped_and_infer_chat_id(self, query_set): + query_set.return_value.filter.return_value = [ + SimpleNamespace(id="f1", file_name="a.png", source_id="chat-1"), + SimpleNamespace(id="f2", file_name="b.pdf", source_id="chat-1"), + ] + + chat_id, chat_files = self.handler.get_chat_files(None, ["f1", "f2"]) + + self.assertEqual(chat_id, "chat-1") + self.assertEqual( + chat_files, + { + "image_list": [{"name": "a.png", "url": "./oss/file/f1", "file_id": "f1"}], + "document_list": [{"name": "b.pdf", "url": "./oss/file/f2", "file_id": "f2"}], + }, + ) + + @patch("chat.mcp.tools.QuerySet") + def test_chat_files_must_belong_to_the_chat(self, query_set): + query_set.return_value.filter.return_value = [SimpleNamespace(id="f1", file_name="a.png", source_id="chat-1")] + + with self.assertRaises(InvalidParamsError): + self.handler.get_chat_files("chat-2", ["f1"]) + with self.assertRaises(InvalidParamsError): + self.handler.get_chat_files(None, ["f1", "missing"]) + + @patch("oss.serializers.file.FileSerializer") + def test_upload_file_returns_file_and_new_chat_id(self, file_serializer): + file_serializer.return_value.upload.return_value = "./oss/file/file-1" + + result = self.handler.upload_file({"file_name": "a.txt", "content": "aGVsbG8="})["structuredContent"] + + data = file_serializer.call_args.kwargs["data"] + self.assertEqual(data["file"].read(), b"hello") + self.assertEqual(data["source_id"], result["chat_id"]) + file_serializer.return_value.upload.assert_called_once_with(user_id="user-id") + self.assertEqual(result["file_id"], "file-1") + + def test_upload_file_rejects_invalid_content(self): + with self.assertRaises(InvalidParamsError): + self.handler.upload_file({"file_name": "a.txt", "content": "not base64!"}) + with self.assertRaises(InvalidParamsError): + self.handler.upload_file({"content": "aGVsbG8="}) + + +class MCPViewTest(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + + def post(self, body, token="chat-key"): + request = self.factory.post( + "/chat/api/v3/mcp", + data=json.dumps(body), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + return mcp_view(request) + + def test_rejects_non_chat_user_key(self): + response = self.post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, token="application-key") + + self.assertEqual(response.status_code, 401) + self.assertEqual(json.loads(response.content)["error"]["code"], -32001) + + @patch("chat.views.v3.mcp.ChatUserKey.handle", side_effect=AppAuthenticationFailed(500, "Secret key is invalid")) + def test_rejects_invalid_chat_user_key(self, _handle): + response = self.post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + + self.assertEqual(response.status_code, 401) + + @patch("chat.views.v3.mcp.ChatUserMCPToolHandler") + @patch("chat.views.v3.mcp.ChatUserKey.handle") + def test_routes_tools_list_with_chat_user(self, handle, handler_class): + chat_user = SimpleNamespace(id="user-id") + auth = Auth(set(), {}) + handle.return_value = (SimpleNamespace(profile=chat_user), auth) + handler_class.return_value.list_tools.return_value = {"tools": []} + + response = self.post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + + handler_class.assert_called_once_with(chat_user, auth, None) + self.assertEqual(json.loads(response.content), {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}) + + @patch("chat.views.v3.mcp.ChatUserKey.handle", return_value=(SimpleNamespace(profile=None), MagicMock())) + def test_notification_is_accepted_without_body(self, _handle): + response = self.post({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + self.assertEqual(response.status_code, 202) + + def test_application_scoped_route(self): + match = resolve("/chat/api/v3/application/app-1/mcp") + + self.assertIs(match.func, mcp_view) + self.assertEqual(match.kwargs, {"application_id": "app-1"}) diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 70b2272a3be..e0ad7485542 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -27,6 +27,7 @@ path('chat//chat_record//resume_chat_message',v3_views.ResumeStreamView.as_view(), name='v3_resume_chat_message'), path('chat//share_chat',ChatRecordLinkView.as_view(),name='v3_share_chat'), path('chat/',v3_views.HistoricalConversationView.Operate.as_view(), name='v3_historical_conversation_operate'), + path('mcp', v3_views.mcp_view), ])), # ---- 全局(非 application 作用域)---- path('embed', v3_views.ChatEmbedView.as_view()), diff --git a/apps/chat/views/v3/mcp.py b/apps/chat/views/v3/mcp.py index a310250b754..8cc887a2302 100644 --- a/apps/chat/views/v3/mcp.py +++ b/apps/chat/views/v3/mcp.py @@ -2,29 +2,59 @@ from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST -from chat.mcp.tools import MCPToolHandler +from chat.mcp.tools import ChatUserMCPToolHandler, InvalidParamsError +from common.auth.handle.impl.chat_user_key import ChatUserKey +from common.exception.app_exception import AppAuthenticationFailed + + +def rpc_response(request_id, result=None, code=None, message=None, status=200): + data = {"jsonrpc": "2.0", "id": request_id} + data.update({"error": {"code": code, "message": message}} if code is not None else {"result": result}) + response = JsonResponse(data, status=status) + if status == 401: + response["WWW-Authenticate"] = "Bearer" + return response + + +def authenticate(request, application_id=None): + """ + MCP 接口使用对话用户 API Key(chat-xxx)认证;带 application_id 时只暴露该应用 + """ + auth_header = request.headers.get("Authorization", "") + token = auth_header[7:] if auth_header.startswith("Bearer ") else "" + chat_user_key = ChatUserKey() + if not chat_user_key.support(request, token, None): + raise AppAuthenticationFailed(1002, "Chat user API key is required") + principal, auth = chat_user_key.handle(request, token, None) + return ChatUserMCPToolHandler(principal.profile, auth, application_id) @csrf_exempt -def mcp_view(request): +@require_POST +def mcp_view(request, application_id=None): request_id = None try: + handler = authenticate(request, application_id) data = json.loads(request.body) + if not isinstance(data, dict) or not isinstance(data.get("method"), str): + return rpc_response(None, code=-32600, message="Invalid Request") method = data.get("method") - params = data.get("params", {}) + params = data.get("params") or {} request_id = data.get("id") + # 通知消息无需响应 if request_id is None: - return HttpResponse(status=204) - - auth_header = request.headers.get("Authorization", "").replace("Bearer ", "") - handler = MCPToolHandler(auth_header) + return HttpResponse(status=202) # 路由方法 if method == "initialize": result = handler.initialize() + elif method == "ping": + result = {} + elif method == "tools/list": result = handler.list_tools() @@ -32,28 +62,16 @@ def mcp_view(request): result = handler.call_tool(params) else: - return JsonResponse({ - "jsonrpc": "2.0", - "id": request_id, - "error": { - "code": -32601, - "message": f"Method not found: {method}" - } - }) + return rpc_response(request_id, code=-32601, message=f"Method not found: {method}") # 成功响应 - return JsonResponse({ - "jsonrpc": "2.0", - "id": request_id, - "result": result - }) + return rpc_response(request_id, result=result) + except AppAuthenticationFailed as e: + return rpc_response(request_id, code=-32001, message=str(e.message), status=401) + except InvalidParamsError as e: + return rpc_response(request_id, code=-32602, message=str(e)) + except (json.JSONDecodeError, UnicodeDecodeError): + return rpc_response(None, code=-32700, message="Parse error", status=400) except Exception as e: - return JsonResponse({ - "jsonrpc": "2.0", - "id": request_id, - "error": { - "code": -32603, - "message": f"Internal error: {str(e)}" - } - }) + return rpc_response(request_id, code=-32603, message=f"Internal error: {str(e)}") diff --git a/ui/src/views/application/application-card/action-dropdown/McpConfigApplicationAction.vue b/ui/src/views/application/application-card/action-dropdown/McpConfigApplicationAction.vue new file mode 100644 index 00000000000..8f5ecfa6f1c --- /dev/null +++ b/ui/src/views/application/application-card/action-dropdown/McpConfigApplicationAction.vue @@ -0,0 +1,49 @@ + + + diff --git a/ui/src/views/application/application-card/action-dropdown/index.ts b/ui/src/views/application/application-card/action-dropdown/index.ts index 4d64a043c07..0aec7bb6bec 100644 --- a/ui/src/views/application/application-card/action-dropdown/index.ts +++ b/ui/src/views/application/application-card/action-dropdown/index.ts @@ -4,6 +4,7 @@ export { default as PortalApplicationAction } from './PortalApplicationAction.vu export { default as MoveApplicationAction } from './MoveApplicationAction.vue' export { default as SettingApplicationAction } from './SettingApplicationAction.vue' export { default as TriggerApplicationAction } from './TriggerApplicationAction.vue' +export { default as McpConfigApplicationAction } from './McpConfigApplicationAction.vue' export { default as AuthorizeApplicationAction } from './AuthorizeApplicationAction.vue' export { default as RelatedResourcesApplicationAction } from './RelatedResourcesApplicationAction.vue' export { default as CopyApplicationAction } from './copy-application-action/CopyApplicationAction.vue' diff --git a/ui/src/views/application/index.vue b/ui/src/views/application/index.vue index b40593fed72..5c509703ce1 100644 --- a/ui/src/views/application/index.vue +++ b/ui/src/views/application/index.vue @@ -19,6 +19,7 @@ import { DeleteApplicationAction, ExportApplicationAction, PortalApplicationAction, + McpConfigApplicationAction, MoveApplicationAction, SettingApplicationAction, TriggerApplicationAction, @@ -250,6 +251,8 @@ function handleBatchDelete() { + +