阶段4(上):LangGraph、Function Calling、MCP(SDK 2.x)、Agent 评测与运维、Agent 设计专题;run_examples 设置 RUN_EXAMPLES=1
This commit is contained in:
1 parent
6650f7eb6d
commit
caaec830b3
38 files changed
+3515
-64
No files matched your search
@@ -0,0 +1,67 @@
|
||||
# requires: mcp, pydantic
|
||||
"""一个内部知识库 / 工单的 MCP 服务:tools(搜索、创建工单)、resources(文档)、prompts(模板)。
|
||||
|
||||
基于 MCP Python SDK 2.x 编写(1.x 中的 FastMCP 在 2.x 改名为 MCPServer),以官方文档为准:
|
||||
https://py.sdk.modelcontextprotocol.io/
|
||||
运行方式:
|
||||
python kb_server.py # stdio 方式启动,供 Cursor / Claude Desktop 等客户端接入
|
||||
python test_kb_server.py # 进程内客户端测试(不需要启动服务)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
DOCS = {
|
||||
"vpn": "VPN 连接失败:先确认账号未过期,再重新安装客户端;仍失败请提交 IT 工单。",
|
||||
"expense": "差旅报销:一线城市住宿标准每晚 600 元,需在出差结束后 30 天内提交。",
|
||||
}
|
||||
TICKETS: dict[str, dict] = {}
|
||||
|
||||
mcp = MCPServer("internal-kb", instructions="公司内部知识库和 IT 工单工具。只读工具可以直接调用,创建工单前先确认问题描述。")
|
||||
|
||||
|
||||
class SearchHit(BaseModel):
|
||||
doc_id: str
|
||||
snippet: str
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_docs(query: str, top_k: int = 3) -> list[SearchHit]:
|
||||
"""在内部知识库中按关键词搜索文档,返回文档 ID 和摘要。用于回答公司制度和 IT 问题。"""
|
||||
hits = [SearchHit(doc_id=k, snippet=v[:40]) for k, v in DOCS.items()
|
||||
if any(w and w in v for w in query.split())]
|
||||
return hits[:top_k]
|
||||
|
||||
|
||||
class TicketIn(BaseModel):
|
||||
title: str = Field(min_length=4, max_length=80)
|
||||
category: str = Field(pattern="^(it|hr|finance)$")
|
||||
request_id: str = Field(description="客户端生成的唯一 ID,用于幂等")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_ticket(ticket: TicketIn) -> dict:
|
||||
"""创建一张工单。会产生副作用:同一个 request_id 只创建一次。"""
|
||||
if ticket.request_id not in TICKETS:
|
||||
TICKETS[ticket.request_id] = {"id": f"T{len(TICKETS) + 1:04d}", **ticket.model_dump()}
|
||||
return TICKETS[ticket.request_id]
|
||||
|
||||
|
||||
@mcp.resource("kb://docs/{doc_id}")
|
||||
def read_doc(doc_id: str) -> str:
|
||||
"""读取一篇知识库文档的全文。"""
|
||||
return DOCS.get(doc_id, "文档不存在")
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def answer_with_citation(question: str) -> str:
|
||||
"""基于知识库回答问题的提示词模板。"""
|
||||
return f"请先调用 search_docs 检索,再根据检索结果回答,并标注 doc_id。问题:{question}"
|
||||
|
||||
|
||||
if __name__ == "__main__" and os.environ.get("RUN_EXAMPLES") != "1":
|
||||
mcp.run() # 默认 stdio;也可以 mcp.run("streamable-http")
|
||||
@@ -0,0 +1,50 @@
|
||||
# requires: mcp, pydantic, pytest, anyio
|
||||
import json
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from kb_server import TICKETS, mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _session():
|
||||
async with Client(mcp) as c: # 测试时直接传入服务对象,进程内通信
|
||||
tools = {t.name: t for t in (await c.list_tools()).tools}
|
||||
assert set(tools) == {"search_docs", "create_ticket"}
|
||||
assert "request_id" in json.dumps(tools["create_ticket"].input_schema)
|
||||
|
||||
r = await c.call_tool("search_docs", {"query": "VPN 失败"})
|
||||
assert not r.is_error and "vpn" in json.dumps(r.structured_content, ensure_ascii=False)
|
||||
|
||||
args = {"ticket": {"title": "VPN 无法连接", "category": "it", "request_id": "req-1"}}
|
||||
first = await c.call_tool("create_ticket", args)
|
||||
second = await c.call_tool("create_ticket", args)
|
||||
assert first.structured_content == second.structured_content and len(TICKETS) == 1
|
||||
|
||||
bad = await c.call_tool("create_ticket", {"ticket": {"title": "x", "category": "other", "request_id": "r"}})
|
||||
assert bad.is_error # 参数校验失败以错误结果返回给客户端
|
||||
|
||||
res = await c.read_resource("kb://docs/expense")
|
||||
assert "600" in res.contents[0].text
|
||||
|
||||
p = await c.get_prompt("answer_with_citation", {"question": "住宿标准"})
|
||||
assert "search_docs" in p.messages[0].content.text
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_kb_server():
|
||||
await _session()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
r = anyio.run(_session)
|
||||
print("search_docs 结构化结果:", r.structured_content)
|
||||
print("工单:", TICKETS)
|
||||
Reference in new issue
Block a user