从 Hello World 到实用 Server 的距离
第 02 篇的 demo server 能运行,但只有 echo 和 add 两个玩具工具。生产级 Server 需要解决 5 个额外的工程问题:
- 多个工具如何协作,共享数据
- 用户输入如何验证(LLM 也会传错参数)
- 工具执行失败时如何正确返回错误
- 日志写到哪里(写错地方会破坏协议)
- Resources 如何让 LLM 获取上下文数据
Demo 是一个完整的 Jira Server,内置 mock 数据,不需要真实账号就能运行。
Server 结构总览
demo_jira_server.py
工具(4 个):
search_issues(query, project?, status?, max_results?)
get_issue(issue_key)
create_issue(project, summary, description?, priority?, issue_type?)
update_issue(issue_key, status?, assignee?, priority?)
资源(1 个):
jira://projects → 项目列表(LLM 读取后知道有哪些合法 project key)
Prompts(1 个):
bug_analysis → Bug 分析报告模板
模式 1:多工具协作
4 个工具共享同一个内存数据存储 ISSUES,形成完整的读写闭环:
# 共享的数据层(生产环境对应真实数据库/API)
ISSUES: dict[str, dict] = {
"PROJ-101": {
"key": "PROJ-101",
"summary": "NullPointerException in parseInput() when config is null",
"status": "Open", "priority": "P1", "issue_type": "Bug",
...
},
...
}
# search_issues:查询
# get_issue:精确读取
# create_issue:写入新 Issue(递增 ID)
# update_issue:修改已有 Issue
设计决策:把验证逻辑提取为共用函数 validate_issue_key(),get_issue 和 update_issue 都调用它,不重复写:
def validate_issue_key(key: str) -> str | None:
"""Return error message if key is invalid, None if valid."""
if not key or "-" not in key:
return f"Invalid issue key format: '{key}'. Expected format: PROJECT-123"
project = key.split("-")[0]
if project not in PROJECTS:
return f"Unknown project: '{project}'. Available projects: {', '.join(PROJECTS)}"
if key not in ISSUES:
return f"Issue '{key}' not found"
return None
模式 2:输入验证
LLM 会传错参数:project key 拼错、status 用了非法值、summary 为空。Server 必须验证,否则返回的错误对 LLM 没有意义。
枚举值验证:
VALID_STATUSES = {"Open", "In Progress", "In Review", "Done", "Closed"}
VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
VALID_ISSUE_TYPES = {"Bug", "Story", "Task", "Epic"}
if new_status and new_status not in VALID_STATUSES:
return [TextContent(type="text", text=(
f"Invalid status: '{new_status}'. Valid values: {', '.join(sorted(VALID_STATUSES))}"
), isError=True)]
必填字段验证:
if not summary:
return [TextContent(type="text", text="'summary' is required.", isError=True)]
业务规则验证:
if len(summary) > 255:
return [TextContent(type="text", text="Summary exceeds 255 characters.", isError=True)]
# 至少要更新一个字段
if not any([new_status, new_assignee is not None, new_priority]):
return [TextContent(type="text", text=(
"No fields to update. Provide at least one of: status, assignee, priority."
), isError=True)]
模式 3:结构化错误处理
MCP 有两种"失败"表达方式,选错会影响 LLM 的处理效果:
方式 A:isError=True(硬错误)
return [TextContent(type="text", text="Issue 'PROJ-999' not found", isError=True)]
LLM 读到 isError=true 后调整策略:换一个 issue key 重试,或直接告知用户。
适合:资源不存在、参数非法、权限不足。
方式 B:正常响应(软反馈)
return [TextContent(type="text", text=(
"Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA"
))]
# 注意:没有 isError=True
LLM 收到后认为工具成功执行,只是结果里包含了纠错信息。LLM 会读取建议后自动修正参数重试。
适合:搜索类操作,输入可能模糊,返回提示帮助 LLM 自校正。
测试结果对比:
search 'anything' with project='INVALID'(无 isError):
✓ Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA
→ LLM 读取后会用 PROJ/MOBILE/INFRA 重试
get_issue('PROJ-999')(有 isError=True):
✗ Issue 'PROJ-999' not found
→ LLM 知道这是失败,不会继续用这个 key
模式 4:日志必须写到 stderr
关键约束:stdout 是 JSON-RPC 通信通道。任何写入 stdout 的内容都会破坏协议。
# ✅ 正确:日志写到 stderr
logging.basicConfig(
stream=sys.stderr, # ← 必须是 stderr
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("jira-mcp")
# 工具里的日志
logger.info("tools/call: %s args=%s", name, arguments)
logger.info("Created issue %s", key)
# ❌ 错误:任何 print() 都会破坏协议
print("Created issue PROJ-201") # ← 这会把文字注入 JSON-RPC 流,导致 Client 解析失败
调试时看日志:
# 日志输出到 stderr,不影响 stdout 的 JSON-RPC 流
python demo_jira_server.py 2>server.log & # 后台运行,日志写文件
tail -f server.log # 另开终端实时查看
模式 5:Resources 提供上下文
工具 schema 里写了 "Available: PROJ, MOBILE, INFRA",但这是硬编码在描述文字里的。更好的方式是暴露一个 Resource,让 LLM 可以主动读取最新的项目列表:
@server.list_resources()
async def list_resources() -> list[Resource]:
return [Resource(
uri="jira://projects", # type: ignore[arg-type]
name="Available Projects",
description="List of Jira projects. Read this to know valid project keys.",
mimeType="application/json"
)]
@server.read_resource() # type: ignore[arg-type]
async def read_resource(uri: str) -> str:
if str(uri) == "jira://projects":
data = [{"key": k, "name": v["name"], "lead": v["lead"]}
for k, v in PROJECTS.items()]
return json.dumps(data, indent=2)
效果:LLM 在调用任何 Jira 工具前,可以先读 jira://projects 了解合法的项目 key,不再猜测。适合动态变化的枚举值(项目会增减,但 schema 里的硬编码不会自动更新)。
完整测试结果
======================================================================
Jira MCP Server — Test Suite
======================================================================
[search_issues]
✓ search 'NPE' in PROJ
Found 1 issue(s): [PROJ-101] NullPointerException in parseInput()...
✓ search 'crash' with status=Open
Found 1 issue(s): [MOBILE-55] Crash on Android 14 when opening notification settings...
✓ search with invalid project (友好提示,无 isError)
Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA
[get_issue]
✓ get PROJ-101 (P1 Bug)
Issue: PROJ-101 Type: Bug | Status: Open | Priority: P1 ...
✗ get PROJ-999 (not found → isError=true)
Issue 'PROJ-999' not found
✗ get malformed key 'not-a-key' (→ isError=true)
Unknown project: 'NOT'. Available projects: PROJ, MOBILE, INFRA
[create_issue]
✓ create valid Bug in PROJ
Created issue PROJ-201: Redis connection pool not releasing connections...
✗ create with empty summary (→ isError=true)
'summary' is required.
✗ create with invalid priority 'CRITICAL' (→ isError=true)
Invalid priority: 'CRITICAL'. Valid values: P0, P1, P2, P3
[update_issue]
✓ update PROJ-101 status + assignee
Updated PROJ-101: status: Open → In Progress, assignee: alice → bob
✗ update with no fields (→ isError=true)
No fields to update. Provide at least one of: status, assignee, priority.
✗ update with invalid status 'Shipped' (→ isError=true)
Invalid status: 'Shipped'. Valid values: Closed, Done, In Progress, In Review, Open
[resources]
Available resources: ['jira://projects']
Projects: ['PROJ', 'MOBILE', 'INFRA']
一个值得注意的细节: get_issue('not-a-key') 返回了 "Unknown project: 'NOT'" 而不是 "invalid key format"。验证逻辑先拆 - 取 project 部分,not-a-key 被拆出 NOT,进入了"project 不存在"的分支。对 LLM 来说这个错误信息仍然足够有用(它知道 NOT 不是合法 project),但如果需要更精确,可以在验证前先检查 key 是否匹配 [A-Z]+-\d+ 的格式。
Schema 设计要点
工具 schema 是 LLM 读的文档,比人类代码注释更重要:
Tool(
name="search_issues",
description=(
"Search Jira issues by keyword, project, or status. "
"Use when the user asks about bugs, tasks, tickets, or issues. " # ← 触发时机
"Returns a list of matching issues with key, summary, status, priority." # ← 返回格式
),
inputSchema={
"properties": {
"project": {
"type": "string",
"description": f"Filter by project key. Available: {', '.join(PROJECTS)}"
# ↑ 把合法值内联在描述里,LLM 不需要另外问
},
"max_results": {
"type": "integer",
"description": "Maximum number of results (default: 10, max: 50)",
"default": 10
# ↑ 描述里写 max 值,schema 里写 default
}
}
}
)
接入 Claude Code
把这个 Server 加入 Claude Code:
// .claude/settings.json
{
"mcpServers": {
"jira-demo": {
"command": "python",
"args": ["/path/to/mcp-04-first-server/demo_jira_server.py"]
}
}
}
重启 Claude Code 后,可以直接问:"帮我搜一下 PROJ 项目里所有 P1 的 Bug",Claude 会自动调用 search_issues 工具。
参考资料
- MCP Python SDK 文档
- 本系列完整 Demo 代码:mcp-04-first-server
欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页