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 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+
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() {
+
+