MCP Server 入门开发 + 协议调试 + 生产级部署

0 阅读6分钟

从 Hello World 到实用 Server 的距离

第 02 篇的 demo server 能运行,但只有 echoadd 两个玩具工具。生产级 Server 需要解决 5 个额外的工程问题:

  1. 多个工具如何协作,共享数据
  2. 用户输入如何验证(LLM 也会传错参数)
  3. 工具执行失败时如何正确返回错误
  4. 日志写到哪里(写错地方会破坏协议)
  5. 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_issueupdate_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: OpenIn 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 工具。


参考资料


欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页