Docs · App Dev Guide

大模型应用开发

从 Prompt 工程到 AI Agent,一站式覆盖大模型应用开发全链路 —— 让你能在 1 周内跑出生产级 MVP。

MCP · 编写 Server

用 Python 写一个真实可跑的 MCP Server,接入 Claude Desktop 验证。这章是 MCP 学习中最实操的一节。

开发环境

# 用官方 SDK
      uv add mcp[cli]
      
      # 验证
      python -c "import mcp; print(mcp.__version__)"  # ≥ 1.0.0

最小可跑 Server(30 行)

server.py

from mcp.server import Server
      from mcp.server.stdio import stdio_server
      import mcp.types as types
      import asyncio
      
      # 创建 Server
      server = Server("my-first-mcp")
      
      # 定义一个工具
      @server.list_tools()
      async def list_tools() -> list[types.Tool]:
          return [
              types.Tool(
                  name="add",
                  description="计算两个数的和",
                  inputSchema={
                      "type": "object",
                      "properties": {
                          "a": {"type": "number"},
                          "b": {"type": "number"}
                      },
                      "required": ["a", "b"]
                  }
              )
          ]
      
      # 工具执行处理器
      @server.call_tool()
      async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
          if name == "add":
              result = arguments["a"] + arguments["b"]
              return [types.TextContent(type="text", text=f"结果:{result}")]
          raise ValueError(f"Unknown tool: {name}")
      
      # 主循环(stdio transport)
      async def main():
          async with stdio_server() as (read, write):
              await server.run(read, write, server.create_initialization_options())
      
      if __name__ == "__main__":
          asyncio.run(main())

接入 Claude Desktop

编辑 ~/Library/Application Support/Claude/claude_desktop_config.json

{
          "mcpServers": {
              "my-first": {
                  "command": "uv",
                  "args": ["run", "python", "/abs/path/to/server.py"]
              }
          }
      }

重启 Claude Desktop → 在对话里问「2 加 3 等于几?」→ Claude 会调用 add 工具。

添加 Resources

资源 = 模型可读取的数据。

from mcp.server import Server
      import mcp.types as types
      import json
      from pathlib import Path
      
      server = Server("notes-mcp")
      
      # 模拟笔记数据
      NOTES_DIR = Path.home() / "Documents" / "Notes"
      
      @server.list_resources()
      async def list_resources() -> list[types.Resource]:
          """列出所有笔记"""
          return [
              types.Resource(
                  uri=f"notes://{file.stem}",
                  name=file.stem,
                  description=f"Note: {file.stem}",
                  mimeType="text/markdown"
              )
              for file in NOTES_DIR.glob("*.md")
          ]
      
      @server.read_resource()
      async def read_resource(uri: str) -> str:
          """读取笔记内容"""
          # uri 形如 "notes://my-note"
          name = uri.replace("notes://", "")
          file = NOTES_DIR / f"{name}.md"
          if not file.exists():
              raise FileNotFoundError(uri)
          return file.read_text(encoding="utf-8")

模型可以:

  • 通过 resources/list 看到所有笔记
  • 通过 resources/read 读取特定笔记内容
  • 在对话中:"我有哪些笔记?" → "读一下'2024 计划'那篇"

添加 Prompts

Prompts = 预定义的提示词模板。

@server.list_prompts()
      async def list_prompts() -> list[types.Prompt]:
          return [
              types.Prompt(
                  name="summarize_note",
                  description="总结一篇笔记",
                  arguments=[
                      types.PromptArgument(
                          name="note_name",
                          description="笔记名(不含 .md)",
                          required=True
                      )
                  ]
              )
          ]
      
      @server.get_prompt()
      async def get_prompt(name: str, arguments: dict) -> types.GetPromptResult:
          if name == "summarize_note":
              note_name = arguments["note_name"]
              content = (NOTES_DIR / f"{note_name}.md").read_text()
              return types.GetPromptResult(
                  messages=[
                      types.PromptMessage(
                          role="user",
                          content=types.TextContent(
                              type="text",
                              text=f"请用 3 句话总结以下笔记:\n\n{content}"
                          )
                      )
                  ]
              )

在 Claude Desktop 里,用户可以点击 prompt 模板,填入参数后自动生成对话。

实战 Server:GitHub Issue 助手

一个真实有用的 Server —— 帮你管理 GitHub Issues。

import os
      import asyncio
      import httpx
      from mcp.server import Server
      from mcp.server.stdio import stdio_server
      import mcp.types as types
      
      server = Server("github-issues")
      GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
      HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json"}
      
      @server.list_tools()
      async def list_tools():
          return [
              types.Tool(
                  name="list_issues",
                  description="列出仓库的 open issues",
                  inputSchema={
                      "type": "object",
                      "properties": {
                          "repo": {"type": "string", "description": "owner/repo"},
                          "labels": {"type": "array", "items": {"type": "string"}, "description": "按标签筛选(可选)"}
                      },
                      "required": ["repo"]
                  }
              ),
              types.Tool(
                  name="create_issue",
                  description="创建新 issue",
                  inputSchema={
                      "type": "object",
                      "properties": {
                          "repo": {"type": "string"},
                          "title": {"type": "string"},
                          "body": {"type": "string"},
                          "labels": {"type": "array", "items": {"type": "string"}}
                      },
                      "required": ["repo", "title"]
                  }
              ),
              types.Tool(
                  name="add_comment",
                  description="给 issue 添加评论",
                  inputSchema={
                      "type": "object",
                      "properties": {
                          "repo": {"type": "string"},
                          "issue_number": {"type": "integer"},
                          "comment": {"type": "string"}
                      },
                      "required": ["repo", "issue_number", "comment"]
                  }
              )
          ]
      
      @server.call_tool()
      async def call_tool(name: str, arguments: dict):
          async with httpx.AsyncClient(headers=HEADERS) as client:
              if name == "list_issues":
                  params = {"state": "open"}
                  if arguments.get("labels"):
                      params["labels"] = ",".join(arguments["labels"])
      
                  resp = await client.get(
                      f"https://api.github.com/repos/{arguments['repo']}/issues",
                      params=params
                  )
                  issues = resp.json()
                  text = "\n".join([
                      f"#{i['number']}: {i['title']} (by {i['user']['login']})"
                      for i in issues[:20]
                  ])
                  return [types.TextContent(type="text", text=text or "No open issues")]
      
              elif name == "create_issue":
                  body = {
                      "title": arguments["title"],
                      "body": arguments.get("body", ""),
                      "labels": arguments.get("labels", [])
                  }
                  resp = await client.post(
                      f"https://api.github.com/repos/{arguments['repo']}/issues",
                      json=body
                  )
                  if resp.status_code == 201:
                      issue = resp.json()
                      return [types.TextContent(
                          type="text",
                          text=f"✅ Created issue #{issue['number']}: {issue['html_url']}"
                      )]
                  return [types.TextContent(type="text", text=f"❌ Failed: {resp.text}")]
      
              elif name == "add_comment":
                  resp = await client.post(
                      f"https://api.github.com/repos/{arguments['repo']}/issues/{arguments['issue_number']}/comments",
                      json={"body": arguments["comment"]}
                  )
                  return [types.TextContent(
                      type="text",
                      text="✅ Comment added" if resp.status_code == 201 else f"❌ {resp.text}"
                  )]
      
      async def main():
          async with stdio_server() as (read, write):
              await server.run(read, write, server.create_initialization_options())
      
      if __name__ == "__main__":
          asyncio.run(main())

接入 Claude Desktop:

{
          "mcpServers": {
              "github": {
                  "command": "uv",
                  "args": ["run", "python", "/path/to/github_server.py"],
                  "env": {"GITHUB_TOKEN": "ghp_xxx"}
              }
          }
      }

试试:「列出 my-org/my-repo 的所有 bug 标签的 issue」「给 issue #42 加个 'urgent' 标签的备注」。

进阶:错误处理

工具失败时不要抛异常,而是返回 isError

@server.call_tool()
      async def call_tool(name, arguments):
          try:
              result = await do_work(arguments)
              return [types.TextContent(type="text", text=result)]
          except ValidationError as e:
              # 参数错误 → 模型可能改正后重试
              return [types.TextContent(
                  type="text",
                  text=f"❌ 参数错误:{e}"
              )]
          except APIError as e:
              # 外部 API 错误 → 模型可能告知用户
              return [types.TextContent(
                  type="text",
                  text=f"❌ API 调用失败:{e.status_code} {e.message}"
              )]

错误信息让模型「可读、可决策」,例如:「请检查参数后重试」、「服务器暂不可用,稍后再试」。

进阶:参数验证(Pydantic)

from pydantic import BaseModel, Field, ValidationError
      
      class CreateIssueArgs(BaseModel):
          repo: str = Field(pattern=r"^[\w-]+/[\w-]+$", description="owner/repo")
          title: str = Field(min_length=1, max_length=200)
          body: str | None = None
          labels: list[str] = Field(default_factory=list, max_length=10)
      
      @server.call_tool()
      async def call_tool(name, arguments):
          if name == "create_issue":
              try:
                  args = CreateIssueArgs(**arguments)
              except ValidationError as e:
                  return [types.TextContent(type="text", text=f"参数错误: {e}")]
              # ... 用 args.repo, args.title 等

Pydantic 给你自动校验 + 类型转换,比手动检查可靠多了。

进阶:日志输出

stdio transport 下绝对不能用 print() —— 会把 stdout 当成 MCP 消息发出去把 Client 搞崩。

正确做法:用 logging + stderr。

import logging
      import sys
      
      logging.basicConfig(
          level=logging.INFO,
          stream=sys.stderr,  # 关键:用 stderr 不是 stdout
          format="%(asctime)s [%(levelname)s] %(message)s"
      )
      log = logging.getLogger("github-mcp")
      
      @server.call_tool()
      async def call_tool(name, arguments):
          log.info(f"Tool called: {name}")  # 安全
          # print(...)  # ❌ 绝对禁止

Claude Desktop 会把 Server 的 stderr 日志显示在「Developer」面板里。

进阶:sampling(让 Client 帮忙调 LLM)

Server 不需要自己的 API key,可以反向请求 Client 调 LLM

@server.call_tool()
      async def call_tool(name, arguments):
          if name == "summarize_repo":
              readme = fetch_readme(arguments["repo"])
              
              # 让 Client 调 LLM 做摘要
              result = await server.request_context.session.create_message(
                  messages=[{
                      "role": "user",
                      "content": {"type": "text", "text": f"用 3 句话总结:\n{readme}"}
                  }],
                  max_tokens=300
              )
              summary = result.content.text
              return [types.TextContent(type="text", text=summary)]

注意:sampling 需要 Client 支持(Claude Desktop 支持,部分 Client 不支持)。

Server 编写最佳实践

☐ 用 Pydantic 校验参数
      ☐ 工具失败返回 error message,不要抛异常
      ☐ 日志用 stderr 不要用 stdout
      ☐ 工具描述写清楚(模型靠它判断)
      ☐ inputSchema 用 enum / pattern / minLength 等约束
      ☐ 异步操作用 httpx.AsyncClient,不要用 requests
      ☐ 敏感参数(API key)从 env 读,不硬编码
      ☐ 长时间操作发送 progress 通知
      ☐ 编写 README 说明用法 + 配置

调试技巧

方法 1: MCP Inspector

官方调试工具:

npx @modelcontextprotocol/inspector uv run python server.py
      # 浏览器打开 http://localhost:5173
      # 可以可视化测试 tools / resources / prompts

方法 2: 用 Python Client 直接连

from mcp.client.stdio import stdio_client, StdioServerParameters
      from mcp.client.session import ClientSession
      import asyncio
      
      async def test():
          params = StdioServerParameters(
              command="python", args=["server.py"]
          )
          async with stdio_client(params) as (read, write):
              async with ClientSession(read, write) as session:
                  await session.initialize()
                  tools = await session.list_tools()
                  print("Tools:", tools)
                  result = await session.call_tool("add", {"a": 2, "b": 3})
                  print("Result:", result)
      
      asyncio.run(test())

方法 3: Claude Desktop 开发者面板

Settings → Developer → MCP Servers 可以看到:

  • Server 启动日志
  • 实时消息流
  • 错误信息

发布与分享

写好 Server 想分享?

方式 1: PyPI 包

# pyproject.toml
      [project]
      name = "mcp-server-mything"
      [project.scripts]
      mcp-server-mything = "mcp_server_mything:main"

用户安装 + 配置:

pip install mcp-server-mything
{"mcpServers": {"mything": {"command": "mcp-server-mything"}}}

方式 2: npm 包(用 uvx 调)

如果是 TypeScript 实现,发到 npm,用户用 npx 调:

{"mcpServers": {"x": {"command": "npx", "args": ["-y", "mcp-server-x"]}}}

方式 3: 提交到 awesome-mcp-servers

让你的 Server 进入社区列表: github.com/punkpeye/awesome-mcp-servers

下一节:Client 集成与生态 —— 怎么在自己的应用里集成 MCP。