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
2 changes: 1 addition & 1 deletion apps/application/tests/test_workflow_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
364 changes: 286 additions & 78 deletions apps/chat/mcp/tools.py

Large diffs are not rendered by default.

221 changes: 221 additions & 0 deletions apps/chat/tests/test_mcp.py
Original file line number Diff line number Diff line change
@@ -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": "answer<tool_calls_render>tool</tool_calls_render>"}}).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"})
1 change: 1 addition & 0 deletions apps/chat/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
path('chat/<str:chat_id>/chat_record/<str:chat_record_id>/resume_chat_message',v3_views.ResumeStreamView.as_view(), name='v3_resume_chat_message'),
path('chat/<str:chat_id>/share_chat',ChatRecordLinkView.as_view(),name='v3_share_chat'),
path('chat/<str:chat_id>',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()),
Expand Down
74 changes: 46 additions & 28 deletions apps/chat/views/v3/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,76 @@

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()

elif method == "tools/call":
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)}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CHAT_API_BASE_PATH } from '@/api/constants'
import type { ApplicationDetail } from '@/api/types'
import { copyText } from '@/utils/clipboard'

defineOptions({ name: 'McpConfigApplicationAction' })

const props = defineProps<{ application: ApplicationDetail; label: string }>()

/* 单个智能体的 MCP 服务配置:地址带智能体 id,仅暴露该智能体,使用对话用户 API Key 认证 */
const visible = ref(false)
const mcpUrl = computed(() => new URL(`${CHAT_API_BASE_PATH}/v3/application/${props.application.id}/mcp`, window.location.origin).href)
const mcpConfig = computed(() =>
JSON.stringify(
{
[`agent_${props.application.id}`]: {
url: mcpUrl.value,
transport: 'streamable_http',
headers: { Authorization: 'Bearer <CHAT_USER_API_KEY>' },
},
},
null,
2,
),
)

function handleOpenMcpConfig() {
visible.value = true
}
</script>

<template>
<!-- 查看智能体 MCP 服务配置 -->
<MkDropdownItem :disabled="!application.is_publish" @click.stop="handleOpenMcpConfig">
<template #icon><MkIcon name="icon_describe_outlined" /></template>
<span>{{ label }}</span>
</MkDropdownItem>

<MkDialog v-model="visible" title="MCP 服务配置">
<div class="group relative pb-5">
<el-input :model-value="mcpConfig" readonly :autosize="{ minRows: 8, maxRows: 24 }" type="textarea" />
<!-- 复制 MCP 配置 -->
<el-button class="group-hover-visible absolute right-3 top-3 z-10 shadow-md" circle @click="copyText(mcpConfig)">
<MkIcon name="icon_copy_outlined" />
</el-button>
</div>
</MkDialog>
</template>
Loading
Loading