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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def get_application_args():
"""
应用(Agent)工具对模型暴露的入参:固定单个必填 message。

与 chat.mcp.tools.MCPToolHandler.list_tools 的 inputSchema 保持一致,
与 chat.mcp.application.MCPToolHandler.list_tools 的 inputSchema 保持一致,
这样从远端 MCP 代理切换为进程内直调时,模型侧契约不变。
"""
return build_schema(
Expand Down
31 changes: 9 additions & 22 deletions apps/chat/mcp/tools.py → apps/chat/mcp/application.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Application MCP tools: application API key (v2) and chat user API key (v3) handlers."""

import base64
import binascii
import json
Expand All @@ -10,6 +12,7 @@
from django.db.models import QuerySet
from django.utils import timezone

from chat.mcp.common import initialize_result, json_result
from chat.serializers.chat import ChatSerializers
from common.auth.constants.group_constants import Group
from knowledge.models import File, FileSourceType
Expand All @@ -32,8 +35,6 @@
"video_list": ("MP4", "AVI", "MKV", "MOV", "FLV", "WMV"),
}

MCP_PROTOCOL_VERSION = "2025-06-18"

AGENT_TOOL_PREFIX = "agent_"

UPLOAD_FILE_TOOL = "upload_file"
Expand Down Expand Up @@ -167,12 +168,8 @@ def decode_form_data(form_data):
return {}
return form_data

def initialize(self):
return {
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": "maxkb-mcp", "version": "1.0.0"},
"capabilities": {"tools": {}},
}
def initialize(self, params=None):
return initialize_result(params, "maxkb-mcp")

def build_description(self):
"""
Expand Down Expand Up @@ -229,12 +226,8 @@ def __init__(self, chat_user, auth, application_id=None):
# 单应用 MCP 地址只暴露该应用
self.application_id = application_id

def initialize(self):
return {
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": "maxkb-mcp", "version": "1.0.0"},
"capabilities": {"tools": {}},
}
def initialize(self, params=None):
return initialize_result(params, "maxkb-mcp")

def get_application_ids(self):
prefix = f"{Group.CHAT_USER}:r:"
Expand Down Expand Up @@ -360,10 +353,7 @@ def list_agents(self):
{"application_id": str(application.id), "name": application.name, "desc": application.desc or ""}
for application in self.get_applications()
]
return {
"content": [{"type": "text", "text": json.dumps(agents, ensure_ascii=False)}],
"structuredContent": {"agents": agents},
}
return json_result(agents, {"agents": agents})

def check_chat_id(self, chat_id, application_id=None):
"""
Expand Down Expand Up @@ -449,10 +439,7 @@ def upload_file(self, args):
"field": get_chat_file_field(file_name),
"chat_id": chat_id,
}
return {
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
"structuredContent": result,
}
return json_result(result)

def call_tool(self, params):
name = str(params.get("name") or "")
Expand Down
29 changes: 29 additions & 0 deletions apps/chat/mcp/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""MCP protocol helpers shared by the application and knowledge MCP servers."""

import json

PROTOCOL_VERSIONS = ("2025-03-26", "2025-06-18", "2025-11-25")

DEFAULT_PROTOCOL_VERSION = "2025-06-18"


def initialize_result(params, server_name):
# 客户端请求的版本受支持时原样返回,否则返回默认版本,由客户端决定是否继续
version = (params or {}).get("protocolVersion")
return {
"protocolVersion": version if version in PROTOCOL_VERSIONS else DEFAULT_PROTOCOL_VERSION,
"serverInfo": {"name": server_name, "version": "1.0.0"},
"capabilities": {"tools": {}},
}


def json_result(data, structured_content=None):
"""工具结果:文本内容为 data 的 JSON,structuredContent 默认与 data 相同"""
return {
"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False)}],
"structuredContent": data if structured_content is None else structured_content,
}


def tool_error(text):
return {"isError": True, "content": [{"type": "text", "text": text}]}
164 changes: 133 additions & 31 deletions apps/chat/mcp/knowledge.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
"""Knowledge equivalent of the application's MCPToolHandler."""

import json
import uuid

from django.db.models import QuerySet
from rest_framework.exceptions import ValidationError

from chat.mcp.common import initialize_result, json_result, tool_error
from knowledge.models import Knowledge
from knowledge.services.external_retrieval import retrieve
from knowledge.services.retrieval_access import RetrievalError
from knowledge.services.retrieval_access import RetrievalError, authorized_ids

PROTOCOL_VERSIONS = ("2025-03-26", "2025-06-18", "2025-11-25")
# 检索参数,单知识库 MCP 与全局知识库 MCP 的 search_knowledge 共用
RETRIEVAL_INPUT_PROPERTIES = {
"query_text": {"type": "string", "minLength": 1, "maxLength": 8000},
"top_number": {"type": "integer", "minimum": 1, "maximum": 50, "default": 5},
"similarity": {"type": "number", "minimum": 0, "maximum": 1, "default": 0},
"search_mode": {
"type": "string",
"enum": ["embedding", "keywords", "blend"],
"default": "embedding",
},
}

# 全局知识库 MCP 地址:先列出可检索的知识库,再按 knowledge_id 检索
LIST_KNOWLEDGE_TOOL = "list_knowledge"

SEARCH_KNOWLEDGE_TOOL = "search_knowledge"


def initialize_knowledge(params):
if (
not isinstance(params.get("protocolVersion"), str)
or not isinstance(params.get("capabilities"), dict)
or not isinstance(params.get("clientInfo"), dict)
):
raise ValidationError("Invalid initialization parameters.")
return initialize_result(params, "maxkb-knowledge-mcp")


def retrieve_result(knowledge_id, identity, arguments):
"""
执行检索并组装工具结果。参数不合法、开关关闭或授权被撤销等以工具错误返回并附原因,
调用方模型可据此修正后重试,而不是让 MCP 会话因 HTTP 错误中断。
"""
try:
output = retrieve(knowledge_id, identity, arguments)
except RetrievalError as e:
return tool_error(e.message)
except ValidationError as e:
return tool_error(json.dumps(e.detail, ensure_ascii=False, default=str))
except Exception:
return tool_error("Knowledge retrieval failed.")
return json_result(output)


class KnowledgeMCPToolHandler:
Expand All @@ -16,39 +61,23 @@ def __init__(self, knowledge, identity):
self.tool_name = f"knowledge_{knowledge.id}"

def initialize(self, params):
version = params.get("protocolVersion")
if (
not isinstance(version, str)
or not isinstance(params.get("capabilities"), dict)
or not isinstance(params.get("clientInfo"), dict)
):
raise ValidationError("Invalid initialization parameters.")
return {
"protocolVersion": version if version in PROTOCOL_VERSIONS else "2025-06-18",
"serverInfo": {"name": "maxkb-knowledge-mcp", "version": "1.0.0"},
"capabilities": {"tools": {}},
}
return initialize_knowledge(params)

def list_tools(self):
# 带上知识库描述,调用方模型才能判断什么问题该检索该知识库
description = f"检索知识库:{self.knowledge.name}"
if self.knowledge.desc:
description = f"{description}\n{self.knowledge.desc}"
return {
"tools": [
{
"name": self.tool_name,
"description": f"检索知识库:{self.knowledge.name}",
"description": description,
"inputSchema": {
"type": "object",
"additionalProperties": False,
"required": ["query_text"],
"properties": {
"query_text": {"type": "string", "minLength": 1, "maxLength": 8000},
"top_number": {"type": "integer", "minimum": 1, "maximum": 50, "default": 5},
"similarity": {"type": "number", "minimum": 0, "maximum": 1, "default": 0},
"search_mode": {
"type": "string",
"enum": ["embedding", "keywords", "blend"],
"default": "embedding",
},
},
"properties": RETRIEVAL_INPUT_PROPERTIES,
},
}
]
Expand All @@ -57,10 +86,83 @@ def list_tools(self):
def call_tool(self, params):
if params.get("name") != self.tool_name:
raise ValidationError("Unknown tool.")
return retrieve_result(self.knowledge.id, self.identity, params.get("arguments", {}))


class ChatUserKnowledgeMCPToolHandler:
"""
全局知识库 MCP:以对话用户 API Key 的身份列出并检索其可访问的知识库,知识库再多工具数量也固定。
可访问与单知识库 MCP 地址的判定一致:外部检索服务已开启,且未开启身份认证或当前对话用户已获授权。
"""

def __init__(self, identity):
self.identity = identity

def initialize(self, params):
return initialize_knowledge(params)

def list_tools(self):
return {
"tools": [
{
"name": LIST_KNOWLEDGE_TOOL,
"description": "List the knowledge bases you can search, with their id, name and description. "
f"Pick the one that fits the question and search it with {SEARCH_KNOWLEDGE_TOOL}.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": SEARCH_KNOWLEDGE_TOOL,
"description": f"Search a knowledge base from {LIST_KNOWLEDGE_TOOL} by knowledge_id. "
"Returns the matching paragraphs with similarity scores and citations.",
"inputSchema": {
"type": "object",
"additionalProperties": False,
"required": ["knowledge_id", "query_text"],
"properties": {
"knowledge_id": {
"type": "string",
"description": f"Id of the knowledge base to search, from {LIST_KNOWLEDGE_TOOL}.",
},
**RETRIEVAL_INPUT_PROPERTIES,
},
},
},
]
}

def get_knowledge(self):
knowledge = list(QuerySet(Knowledge).filter(external_service__enabled=True).order_by("name"))
restricted = [str(item.id) for item in knowledge if item.external_service.get("authentication", False)]
authorized = authorized_ids(self.identity.user_id, restricted) if restricted else set()
return [
item
for item in knowledge
if not item.external_service.get("authentication", False) or str(item.id) in authorized
]

def list_knowledge(self):
knowledge = [
{"knowledge_id": str(item.id), "name": item.name, "desc": item.desc or ""} for item in self.get_knowledge()
]
return json_result(knowledge, {"knowledge": knowledge})

def search_knowledge(self, arguments):
arguments = dict(arguments)
knowledge_id = arguments.pop("knowledge_id", None)
try:
output = retrieve(self.knowledge.id, self.identity, params.get("arguments", {}))
except (RetrievalError, ValidationError):
raise
except Exception:
return {"isError": True, "content": [{"type": "text", "text": "Knowledge retrieval failed."}]}
return {"content": [{"type": "text", "text": json.dumps(output, ensure_ascii=False)}]}
knowledge_id = str(uuid.UUID(str(knowledge_id)))
except ValueError:
return tool_error(f"Unknown knowledge_id: {knowledge_id}")
# retrieve 内部会再次校验开关与授权,并在检索前后复核 API Key
return retrieve_result(knowledge_id, self.identity, arguments)

def call_tool(self, params):
name = params.get("name")
arguments = params.get("arguments") or {}
if name == LIST_KNOWLEDGE_TOOL:
return self.list_knowledge()
if name == SEARCH_KNOWLEDGE_TOOL:
if not isinstance(arguments, dict):
raise ValidationError("Invalid arguments.")
return self.search_knowledge(arguments)
raise ValidationError("Unknown tool.")
18 changes: 9 additions & 9 deletions apps/chat/tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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.mcp.application 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
Expand All @@ -28,7 +28,7 @@

class ChatApplicationTest(SimpleTestCase):
@patch("common.init.init_template.run")
@patch("chat.mcp.tools.ChatSerializers")
@patch("chat.mcp.application.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()
Expand All @@ -50,7 +50,7 @@ def test_chats_as_given_chat_user_in_a_new_chat(self, chat_serializers, _init):
self.assertEqual(result, ("answer", chat_info["chat_id"]))

@patch("common.init.init_template.run")
@patch("chat.mcp.tools.ChatSerializers")
@patch("chat.mcp.application.ChatSerializers")
def test_continues_given_chat(self, chat_serializers, _init):
chat_serializers.return_value.chat.return_value = SimpleNamespace(content=b'{"data": {"content": "a"}}')

Expand Down Expand Up @@ -88,7 +88,7 @@ def test_application_scoped_handler_only_exposes_that_application(self):
def scoped_handler(self):
return ChatUserMCPToolHandler(SimpleNamespace(id="user-id"), Auth(set(), {f"CHAT_USER:r:{APP_ID}": 1}), APP_ID)

@patch("chat.mcp.tools.QuerySet")
@patch("chat.mcp.application.QuerySet")
def test_scoped_list_tools_exposes_the_agent_and_upload(self, query_set):
query_set.return_value.filter.return_value.order_by.return_value = [
SimpleNamespace(id=APP_ID, name="Agent", desc="desc")
Expand Down Expand Up @@ -119,7 +119,7 @@ def test_list_agents_returns_accessible_applications(self, get_applications):
)
self.assertEqual(json.loads(result["content"][0]["text"]), result["structuredContent"]["agents"])

@patch("chat.mcp.tools.chat_application", return_value=("answer", "chat-id"))
@patch("chat.mcp.application.chat_application", return_value=("answer", "chat-id"))
@patch.object(ChatUserMCPToolHandler, "get_applications")
def test_call_agent_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
Expand All @@ -131,7 +131,7 @@ def test_call_agent_tool_chats_as_chat_user_and_returns_chat_id(self, get_applic
self.assertEqual(result["content"][0], {"type": "text", "text": "answer"})
self.assertEqual(result["structuredContent"], {"answer": "answer", "chat_id": "chat-id"})

@patch("chat.mcp.tools.chat_application", return_value=("answer", "chat-id"))
@patch("chat.mcp.application.chat_application", return_value=("answer", "chat-id"))
@patch.object(ChatUserMCPToolHandler, "get_applications")
def test_global_call_agent_uses_application_id(self, get_applications, chat_app):
get_applications.return_value.filter.return_value.exists.return_value = True
Expand All @@ -155,7 +155,7 @@ def test_call_tool_rejects_unknown_tools_and_applications(self, get_applications
with self.subTest(name=name, scoped=bool(handler.application_id)), self.assertRaises(InvalidParamsError):
handler.call_tool({"name": name, "arguments": arguments})

@patch("chat.mcp.tools.QuerySet")
@patch("chat.mcp.application.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
Expand All @@ -177,7 +177,7 @@ def test_check_chat_id(self, query_set):
with self.assertRaises(InvalidParamsError):
self.handler.check_chat_id("not-a-uuid")

@patch("chat.mcp.tools.QuerySet")
@patch("chat.mcp.application.QuerySet")
def test_chat_files_keep_caller_fields_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"),
Expand All @@ -200,7 +200,7 @@ def test_chat_files_keep_caller_fields_and_infer_chat_id(self, query_set):
def test_chat_files_without_attachments(self):
self.assertEqual(self.handler.get_chat_files("chat-1", {"message": "hi"}), ("chat-1", {}))

@patch("chat.mcp.tools.QuerySet")
@patch("chat.mcp.application.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")]

Expand Down
Loading
Loading