diff --git a/.env.example b/.env.example index 9c9ea4e..1fb77b0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # 阿里云百炼(OpenAI 兼容)配置 OPENAI_API_KEY=your_api_key_here OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 -OPENAI_MODEL=qwen3.5-flash +OPENAI_MODEL=qwen-plus # 选择当前演示使用的 MCP 协议:stdio / sse / streamable_http MCP_TRANSPORT=stdio diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..28bec59 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,47 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [master] + paths: + - 'site/**' + - 'docs/*.puml' + - '.github/workflows/deploy-pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Only one deployment at a time. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + with: + enablement: true # auto-enable Pages for Actions deployment if needed + - name: Static site lives in site/, copy it into the Pages artifact root + run: | + mkdir -p _pages + cp -r site/* _pages/ + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _pages + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test-pages.yml b/.github/workflows/test-pages.yml new file mode 100644 index 0000000..f97010f --- /dev/null +++ b/.github/workflows/test-pages.yml @@ -0,0 +1,41 @@ +name: Test site with Playwright + +on: + push: + branches: [master] + paths: + - 'site/**' + - 'tests/**' + - '.github/workflows/test-pages.yml' + pull_request: + branches: [master] + paths: + - 'site/**' + - 'tests/**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install deps + working-directory: tests + run: npm install --no-audit --no-fund + - name: Install Playwright browsers + working-directory: tests + run: npx playwright install --with-deps chromium + - name: Run Playwright tests + working-directory: tests + env: + CI: 'true' + run: npx playwright test + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: tests/playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 81ac47b..935490f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .env.* !.env.example AGENTS.md +.zcode/ diff --git a/README.md b/README.md index 4a7000e..bbdae6d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,36 @@ # MCP Minimal Interactive Demo -[English](./README.md) | [中文](./README_zh-CN.md) +[中文](./README_zh-CN.md) | English | [🏠 Live Tutorial Site](https://albert-pzy.github.io/mcp-tutorial/) -Start the program and chat directly in terminal. +A minimal MCP (Model Context Protocol) teaching demo: an LLM automatically picks and calls +FastMCP calculator tools (`add/subtract/multiply/divide`) through the standard MCP protocol, +and returns a natural-language answer. -Current structure (client/server split): +> This repo is deliberately small and readable. For a richer visual walkthrough +> (auto-rendered PlantUML diagrams + syntax-highlighted code) open the **Live Tutorial Site**. + +## What you will learn + +- How an MCP client and server talk via `initialize → tools/list → tools/call`. +- The three transport options: `stdio`, `sse`, `streamable_http`. +- How Function Calling (LLM decision) and MCP (tool transport) cooperate. + +## What this demo is **not** + +- Not a production agent. No streaming, no async error retry, no multi-turn memory. +- Not a tool catalog. It only ships 4 trivially-simple calculator tools on purpose. + +## Project structure ```text mcp-tutorial/ -|-- main.py # Program entry (interactive client) -|-- config.py # Config schema and .env loading +|-- main.py # Entry: interactive chat loop (client) +|-- config.py # AppConfig schema + .env loading |-- client/ | |-- runtime.py # MCP client transport routing (stdio/sse/streamable_http) -| `-- llm.py # OpenAI init + tool-calling loop +| `-- llm.py # OpenAI init + tool-discovery + LLM tool-calling loop `-- server/ - |-- app.py # MCP server and tool definitions + |-- app.py # FastMCP server + four @mcp.tool calculators |-- runtime.py # MCP server transport routing |-- stdio.py # stdio server entry |-- sse.py # sse server entry @@ -29,6 +45,8 @@ uv sync ## 2. Configure `.env` +Copy `.env.example` to `.env` and set your own key: + ```env OPENAI_API_KEY=your_api_key OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 @@ -42,33 +60,29 @@ MCP_STREAMABLE_PATH=/mcp LLM_MAX_TOOL_ROUNDS=3 ``` +This demo uses Alibaba Cloud Bailian's OpenAI-compatible endpoint by default; +any OpenAI-compatible provider works. + ## 3. Run -### 3.1 stdio (simplest) +### 3.1 stdio (simplest, one terminal) ```bash uv run python main.py ``` -Then type directly: +Then just chat: ```text -You: help me calculate 1+2 -Assistant: 1 + 2 = 3 +你:help me calculate 1 + 2 +助手:1 + 2 = 3 ``` Type `exit` to quit. ### 3.2 sse (two terminals) -Update `.env` first: - -```env -MCP_TRANSPORT=sse -MCP_HOST=127.0.0.1 -MCP_PORT=8000 -MCP_SSE_PATH=/sse -``` +In `.env` set `MCP_TRANSPORT=sse`. Terminal A (server): @@ -82,18 +96,9 @@ Terminal B (client): uv run python main.py ``` -Type `exit` in terminal B to quit client, and `Ctrl+C` in terminal A to stop server. - ### 3.3 streamable_http (two terminals) -Update `.env` first: - -```env -MCP_TRANSPORT=streamable_http -MCP_HOST=127.0.0.1 -MCP_PORT=8000 -MCP_STREAMABLE_PATH=/mcp -``` +In `.env` set `MCP_TRANSPORT=streamable_http`. Terminal A (server): @@ -107,73 +112,41 @@ Terminal B (client): uv run python main.py ``` -Type `exit` in terminal B to quit client, and `Ctrl+C` in terminal A to stop server. - -## 4. MCP Basics for Beginners - -### 4.1 What is MCP? - -MCP (Model Context Protocol) is a standard way for models to connect to external tools. - -Without MCP, each tool integration often needs custom glue code. -With MCP, both sides follow the same protocol, so integration becomes consistent. - -In one line: MCP is a standard interface for models to discover and use tools/resources/prompts. +## 4. Three transports at a glance -### 4.2 Three transport options in this demo +| Aspect | `stdio` | `sse` | `streamable_http` | +|---|---|---|---| +| Connection | Local process pipe | HTTP + Server-Sent Events | HTTP request/response | +| Startup | One command | server + client | server + client | +| Typical use | Local dev / teaching | LAN / remote service | Web-style deployment | +| Debug feel | Most direct | Need network reachability | Need network reachability | -1. `stdio` -Communication through standard input/output, usually local subprocess mode. +Quick pick: +- Fastest path → `stdio`. +- "Client talks to a remote service" → `sse` or `streamable_http`. +- Multi-user deployment → HTTP form (`sse` / `streamable_http`). -2. `sse` -HTTP + Server-Sent Events. Good for long-running remote server mode. +The protocol is the same MCP in all three cases; only how bytes get from +client to server changes. -3. `streamable_http` -HTTP-based MCP endpoint, suitable for web-style deployment. +## 5. Diagrams (PlantUML source) -The protocol is still MCP in all cases. -Only the transport changes. - -### 4.3 MCP vs Function Calling - -1. Function Calling -A model API feature where the model decides which function to call. - -2. MCP -A protocol layer that standardizes model-to-tool communication across environments. - -In this project, LLM decides tool calls, then actual tool execution happens via MCP client/server. - -### 4.4 What is JSON-RPC 2.0 in MCP? - -MCP messages are structured with JSON-RPC style request/response: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "calculator_add", - "arguments": {"a": 1, "b": 2} - } -} -``` +Repo-kept PlantUML sources live in `docs/`: -### 4.5 Core MCP workflow in this demo +- [`docs/architecture.puml`](./docs/architecture.puml) — three-layer architecture +- [`docs/call_sequence.puml`](./docs/call_sequence.puml) — full call sequence (`1+2`) +- [`docs/process_flow.puml`](./docs/process_flow.puml) — client process flow +- [`docs/transport_comparison.puml`](./docs/transport_comparison.puml) — three transports side by side +- [`docs/mcp_vs_function_calling.puml`](./docs/mcp_vs_function_calling.puml) — MCP vs Function Calling -1. User enters a request in `main.py`. -2. Client calls `tools/list` to discover MCP tools. -3. Client sends tool schemas to LLM. -4. LLM returns `tool_calls`. -5. Client executes selected tool via `tools/call`. -6. Tool result is fed back to LLM. -7. LLM generates the final natural-language answer. +The Live Tutorial Site renders these in-browser; see `site/README.md` if you prefer local preview. -Workflow diagram: +## 6. Further reading -MCP process flow +- [MCP complete call flow (EN)](./docs/mcp_complete_call_flow.md) — step-by-step from discovery to final answer, with real captured JSON-RPC payloads. -## Extra docs +## 7. Constraints (see `AGENTS.md`) -- [MCP complete call flow (EN)](./docs/mcp_complete_call_flow.md) +- Python deps via `uv` only; no `pip install`. +- Encrypt secrets: `.env` is git-ignored; `.env.example` uses placeholders only. +- No real `OPENAI_API_KEY` / token / secret in any committed file. diff --git a/README_zh-CN.md b/README_zh-CN.md index 1e8deff..92c91ff 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -1,20 +1,34 @@ # MCP 最小交互 Demo -[English](./README.md) | [中文](./README_zh-CN.md) +[中文](./README_zh-CN.md) | [English](./README.md) | [🏠 在线教学站点](https://albert-pzy.github.io/mcp-tutorial/) -只需要启动程序,然后直接输入问题。 +一个最小化的 MCP(Model Context Protocol)教学 demo:一个 LLM 自动判断并调用 +FastMCP 提供的计算器工具(`add/subtract/multiply/divide`),通过标准 MCP 协议拿到结果,再给人类一句答案。 -当前结构(已拆分客户端/服务端): +> 这个仓库刻意保持小而清晰。想要更直观的可视化讲解(自动渲染的 PlantUML 图 + 语法高亮代码),请打开**在线教学站点**。 + +## 这个 demo 学什么 + +- 客户端和服务端如何通过 `initialize → tools/list → tools/call` 通信。 +- 三种传输方式:`stdio`、`sse`、`streamable_http` 的差别。 +- Function Calling(LLM 决策)和 MCP(工具传输)怎么配合。 + +## 这个 demo **不**做什么 + +- 不是生产级 Agent。没有流式输出、没有异步错误重试、没有多轮记忆。 +- 不是工具大全。只放 4 个最简单的计算器工具,目的是看懂协议而不是看工具。 + +## 项目结构 ```text mcp-tutorial/ -|-- main.py # 程序入口(客户端对话) -|-- config.py # 配置 schema 与 .env 读取 +|-- main.py # 程序入口(客户端交互循环) +|-- config.py # AppConfig 配置 schema + .env 读取 |-- client/ | |-- runtime.py # stdio/sse/streamable_http 客户端连接分发 -| `-- llm.py # OpenAI 初始化 + tool-calling +| `-- llm.py # OpenAI 初始化 + 工具发现 + LLM 工具调用循环 `-- server/ - |-- app.py # MCP server 与工具定义 + |-- app.py # FastMCP server + 四个 @mcp.tool 计算器 |-- runtime.py # stdio/sse/streamable_http 服务端启动分发 |-- stdio.py # stdio 服务端入口 |-- sse.py # sse 服务端入口 @@ -29,6 +43,8 @@ uv sync ## 2. 配置 `.env` +把 `.env.example` 复制为 `.env`,填上你自己的 key: + ```env OPENAI_API_KEY=你的百炼APIKey OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 @@ -42,9 +58,11 @@ MCP_STREAMABLE_PATH=/mcp LLM_MAX_TOOL_ROUNDS=3 ``` +默认用阿里云百炼的 OpenAI 兼容接口;任何 OpenAI 兼容厂商都行。 + ## 3. 启动 -### 3.1 stdio(最简单) +### 3.1 stdio(最简单,单终端) ```bash uv run python main.py @@ -53,22 +71,15 @@ uv run python main.py 启动后直接输入: ```text -你:帮我计算一下1+2等于几 +你:帮我算一下 1+2 等于几 助手:1 + 2 = 3 ``` -输入 `exit` 可结束程序。 +输入 `exit` 结束程序。 ### 3.2 sse(双终端) -先改 `.env`: - -```env -MCP_TRANSPORT=sse -MCP_HOST=127.0.0.1 -MCP_PORT=8000 -MCP_SSE_PATH=/sse -``` +在 `.env` 里设 `MCP_TRANSPORT=sse`。 终端 A(服务端): @@ -82,18 +93,9 @@ uv run python -m server.sse uv run python main.py ``` -终端 B 输入问题,输入 `exit` 退出客户端;终端 A 用 `Ctrl+C` 停止服务端。 - ### 3.3 streamable_http(双终端) -先改 `.env`: - -```env -MCP_TRANSPORT=streamable_http -MCP_HOST=127.0.0.1 -MCP_PORT=8000 -MCP_STREAMABLE_PATH=/mcp -``` +在 `.env` 里设 `MCP_TRANSPORT=streamable_http`。 终端 A(服务端): @@ -107,142 +109,40 @@ uv run python -m server.streamable_http uv run python main.py ``` -终端 B 输入问题,输入 `exit` 退出客户端;终端 A 用 `Ctrl+C` 停止服务端。 - -## 4. MCP 零基础教学 - -### 4.1 MCP 是什么 - -MCP(Model Context Protocol)可以理解成: -给大模型和外部工具之间,定义了一套“统一插座标准”。 - -以前你接不同工具,常常要写不同的适配代码; -有了 MCP 后,模型端和工具端只要都遵循这个协议,就能按统一方式通信。 - -一句话总结: -MCP = 让模型更标准地“看见并调用”外部能力(工具、资源、提示模板)的协议。 - -### 4.2 MCP 三种传输协议分别是什么 - -在这个 demo 里用到三种传输方式(transport): - -1. `stdio` -通过标准输入/输出通信,通常是本地子进程方式,最适合教学和本地调试。 - -2. `sse` -基于 HTTP + Server-Sent Events,适合服务端长期运行,客户端通过网络连过去。 - -3. `streamable_http` -也是基于 HTTP 的方式,偏“标准 Web 服务”形态,适合做成可部署接口。 - -注意: -这三种只是“怎么传数据”的区别,不改变 MCP 协议本身。 - -### 4.3 三种协议的区别(怎么选) +## 4. 三种传输一览 | 维度 | `stdio` | `sse` | `streamable_http` | |---|---|---|---| -| 连接方式 | 本地进程管道 | HTTP + 事件流 | HTTP | -| 启动复杂度 | 最低(单命令) | 中等(服务端+客户端) | 中等(服务端+客户端) | -| 典型场景 | 本地开发、演示 | 局域网/远程服务 | Web 化部署 | -| 调试体验 | 最直接 | 需要看网络连通 | 需要看网络连通 | +| 连接方式 | 本地进程管道 | HTTP + Server-Sent Events | HTTP 请求/响应 | +| 启动复杂度 | 最低(单命令) | 中等(需服务端+客户端) | 中等(需服务端+客户端) | +| 典型场景 | 本地开发 / 演示 | 局域网 / 远程服务 | Web 化部署 | +| 调试体验 | 最直接 | 需看网络连通 | 需看网络连通 | 快速建议: +- 想最快跑通 → `stdio`。 +- 想模拟“客户端连远程服务” → `sse` 或 `streamable_http`。 +- 多人部署 → 优先 HTTP 形态(`sse` / `streamable_http`)。 -1. 想最快跑通:用 `stdio`。 -2. 想模拟“客户端连远程服务”:用 `sse` 或 `streamable_http`。 -3. 团队部署给多人用:优先考虑 HTTP 形态(`sse` / `streamable_http`)。 - -### 4.4 MCP 和 Function Calling 的区别 - -你可能会问:都能调工具,那 MCP 和 function calling 有啥不同? - -可以这样理解: - -1. Function Calling -是“某一家模型接口里的工具调用能力”。 -你把函数 schema 传给模型,模型决定要不要调用,再由你执行函数。 - -2. MCP -是“模型与工具生态之间的通用协议层”。 -重点是跨工具、跨运行方式、跨厂商的统一连接规范。 - -关系上可以理解为: - -1. Function calling 更像“模型 API 内部能力”。 -2. MCP 更像“外部工具总线标准”。 -3. 实际工程里常见做法是:模型侧用 function calling 做决策,真正工具执行通过 MCP client/server 完成。 - -比如对于当前项目: -LLM 决策要调用 `add`,然后通过 MCP 去调用服务端工具。 - -### 4.5 MCP 用到的 JSON-RPC 2.0 是什么 - -JSON-RPC 2.0 是一个“用 JSON 表示远程调用”的轻量协议格式。 -它规定了请求和响应长什么样,比如: - -请求(简化示意): - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "add", - "arguments": {"a": 1, "b": 2} - } -} -``` - -响应(简化示意): - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "content": [{"type": "text", "text": "3"}] - } -} -``` - -MCP 消息在底层是按 JSON-RPC 2.0 这套“请求-响应”规则来组织的。 - -### 4.6 MCP 的特性 - -1. 标准化 -所有工具都通过统一的接口暴露能力,不需要每接入一个新工具就重写协议。就像电脑外设:无论是 U 盘、键盘还是鼠标,都能用同一套 USB 协议接入,插上就能用。 - -2. 解耦 -模型决策层、客户端编排层、服务端工具层可以分开实现和演进。 - -3. 便于迁移 -传输方式可以切换(`stdio`、`sse`、`streamable_http`),核心调用逻辑保持一致。 - -### 4.7 MCP 完整工作流程 +三种方式只是“怎么把字节从客户端送到服务端”的区别,协议本身都是 MCP。 -下面按一次“帮我计算 1+2”的过程走一遍: +## 5. 图示(PlantUML 源码) -1. 用户在 `main.py` 输入问题。 -2. 客户端把可用 MCP tools 信息告诉 LLM(例如 `add(a, b)`)。 -3. LLM 判断这是计算任务,返回“我要调用 `add`,参数是 `a=1,b=2`”。 -4. 客户端收到这个 tool call,通过 MCP 协议向服务端发起调用。 -5. 服务端 `server/app.py` 里的 `@mcp.tool add` 执行并返回结果 `3`。 -6. 客户端把工具结果再喂给 LLM。 -7. LLM 组织成人类可读回答,比如“1 + 2 = 3”。 -8. 主程序把结果打印给用户。 +仓库里保留的 PlantUML 源都放在 `docs/`: -调用流程图: +- [`docs/architecture.puml`](./docs/architecture.puml) — 三层架构图 +- [`docs/call_sequence.puml`](./docs/call_sequence.puml) — 完整调用时序图(`1+2`) +- [`docs/process_flow.puml`](./docs/process_flow.puml) — 客户端主流程活动图 +- [`docs/transport_comparison.puml`](./docs/transport_comparison.puml) — 三种传输并列对比 +- [`docs/mcp_vs_function_calling.puml`](./docs/mcp_vs_function_calling.puml) — MCP 与 Function Calling 职责对比 -MCP 调用流程图 +在线教学站点会在浏览器里自动渲染这些图;想本地预览见 `site/README.md`。 -你可以把它想成三层协作: +## 6. 延伸阅读 -1. LLM 负责“想”(是否调用工具、怎么调用)。 -2. MCP 负责“传”(按标准协议通信)。 -3. Tool 负责“做”(真正执行逻辑并产出结果)。 +- [MCP 工具调用完整过程](./docs/mcp_complete_call_flow_zh-CN.md) — 逐步从“发现”到“输出”讲解,带真实抓到的 JSON-RPC 报文。 -## 补充文档 +## 7. 约束(见 `AGENTS.md`) -- [MCP 工具调用完整过程(从发现到输出)](./docs/mcp_complete_call_flow_zh-CN.md) +- Python 依赖只用 `uv`;不用 `pip install`。 +- 涉密本地放 `.env`(已 gitignore),仓库只留脱敏 `.env.example`。 +- 不提交任何真实 `OPENAI_API_KEY` / token / 密码 / 私钥。 diff --git a/client/llm.py b/client/llm.py index 73d3caa..47d9092 100644 --- a/client/llm.py +++ b/client/llm.py @@ -10,77 +10,110 @@ def create_openai_client(config: AppConfig) -> OpenAI: + """按配置里的 key/base_url 创建 OpenAI 兼容客户端(这里指阿里云百炼的 OpenAI 兼容接口)。""" return OpenAI(api_key=config.openai_api_key, base_url=config.openai_base_url) def to_openai_tools(mcp_tools: list) -> list[dict]: - return [ - { - "type": "function", - "function": { - "name": t.name, - "description": t.description or f"MCP 工具 {t.name}", - "parameters": t.inputSchema or {"type": "object", "properties": {}}, - }, - } - for t in mcp_tools - ] + """把 MCP 工具描述转成 OpenAI function calling 能识别的 tools 参数。 + + 为什么要这层转换:MCP 协议与 OpenAI 的 tools 描述字段命名不完全一样, + 这里只做字段映射,不丢信息,让 LLM 看得到每个 MCP 工具的名字和入参 schema。 + """ + converted: list[dict] = [] + for tool in mcp_tools: + converted.append( + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or f"MCP 工具 {tool.name}", + "parameters": tool.inputSchema or {"type": "object", "properties": {}}, + }, + } + ) + return converted -def result_value(result) -> str: +def _tool_result_text(result) -> str: + """FastMCP 的 CallToolResult 把真正的输出放在 .data 上,统一取成字符串。""" return str(getattr(result, "data", "")) +def _assistant_message_with_calls(message: object) -> dict: + """把 LLM 返回的 message 打包成可追加进对话历史的 assistant 消息,保留 tool_calls。""" + tool_calls = message.tool_calls or [] + return { + "role": "assistant", + "content": message.content or "", + "tool_calls": [ + { + "id": call.id, + "type": "function", + "function": { + "name": call.function.name, + "arguments": call.function.arguments, + }, + } + for call in tool_calls + ], + } + + +def _tool_result_message(call: object, result) -> dict: + """把一次工具调用结果回填成 role=tool 的消息,供 LLM 二次推理使用。""" + return { + "role": "tool", + "tool_call_id": call.id, + "name": call.function.name, + "content": json.dumps({"result": _tool_result_text(result)}, ensure_ascii=False), + } + + async def ask_with_llm( mcp_client: Client, llm_client: OpenAI, config: AppConfig, user_prompt: str, ) -> str: - tools = to_openai_tools(await mcp_client.list_tools()) + """让 LLM 在 MCP 工具帮助下回答用户问题,最多循环 max_tool_rounds 轮。""" + # 1. 先发现当前 MCP 服务端有哪些工具,再转成 LLM 能理解的格式 + openai_tools = to_openai_tools(await mcp_client.list_tools()) + + # 2. 组装对话上下文:system 提示 LLM “算术题优先走工具,再用人话回答” messages = [ - {"role": "system", "content": "你是教学演示助手。遇到数学算术问题时优先调用工具(加减乘除),再给出简洁中文答案。"}, + { + "role": "system", + "content": "你是教学演示助手。遇到数学算术问题时优先调用工具(加减乘除),再给出简洁中文答案。", + }, {"role": "user", "content": user_prompt}, ] + # 3. 循环:LLM 决策 -> 调工具 -> 把结果喂回去 -> 再问 LLM,直到给出最终文本 + # 这里用 asyncio.to_thread 是因为 openai 官方 SDK 是同步阻塞的,直接调用会 + # 卡住事件循环,导致后面的 await mcp_client.call_tool 调度不开,必须丢到工作线程跑。 for _ in range(config.llm_max_tool_rounds): completion = await asyncio.to_thread( llm_client.chat.completions.create, model=config.openai_model, messages=messages, - tools=tools, + tools=openai_tools, tool_choice="auto", temperature=0, ) message = completion.choices[0].message tool_calls = message.tool_calls or [] + + # 3a. LLM 没要求调工具 -> 直接就是最终答案 if not tool_calls: return (message.content or "").strip() - messages.append( - { - "role": "assistant", - "content": message.content or "", - "tool_calls": [ - { - "id": c.id, - "type": "function", - "function": {"name": c.function.name, "arguments": c.function.arguments}, - } - for c in tool_calls - ], - } - ) - + # 3b. LLM 要求调工具 -> 先把它的决策记进历史,再逐个执行 MCP 工具 + messages.append(_assistant_message_with_calls(message)) for call in tool_calls: - result = await mcp_client.call_tool(call.function.name, json.loads(call.function.arguments or "{}")) - messages.append( - { - "role": "tool", - "tool_call_id": call.id, - "name": call.function.name, - "content": json.dumps({"result": result_value(result)}, ensure_ascii=False), - } - ) + arguments = json.loads(call.function.arguments or "{}") + result = await mcp_client.call_tool(call.function.name, arguments) + messages.append(_tool_result_message(call, result)) + # 4. 超过 max_tool_rounds 仍没定论,返回空字符串(教学 demo 不复杂处理) return "" diff --git a/client/runtime.py b/client/runtime.py index 944b7b8..5fa6207 100644 --- a/client/runtime.py +++ b/client/runtime.py @@ -13,6 +13,11 @@ def create_stdio_client() -> Client: + """stdio 模式:客户端自己去拉起 server/stdio.py 当子进程,通过标准输入输出通信。 + + 为什么要传 PYTHONPATH:子进程里会 import config / server.app 等本仓库模块, + 把项目根目录塞进 PYTHONPATH,子进程才能找到这些 import。 + """ env = os.environ.copy() env["PYTHONPATH"] = str(PROJECT_ROOT) transport = PythonStdioTransport( @@ -24,14 +29,19 @@ def create_stdio_client() -> Client: def create_sse_client(config: AppConfig) -> Client: - return Client(f"http://{config.mcp_host}:{config.mcp_port}{config.mcp_sse_path}") + """sse 模式:连到一个已经在跑的 HTTP+SSE 服务端(路径由 MCP_SSE_PATH 决定)。""" + url = f"http://{config.mcp_host}:{config.mcp_port}{config.mcp_sse_path}" + return Client(url) def create_streamable_http_client(config: AppConfig) -> Client: - return Client(f"http://{config.mcp_host}:{config.mcp_port}{config.mcp_streamable_path}") + """streamable_http 模式:连到一个已经在跑的 streamable HTTP 服务端(路径由 MCP_STREAMABLE_PATH 决定)。""" + url = f"http://{config.mcp_host}:{config.mcp_port}{config.mcp_streamable_path}" + return Client(url) def create_mcp_client(config: AppConfig) -> Client: + """根据 MCP_TRANSPORT 选三种连接方式之一。协议本身(initialize/tools/list/tools/call)完全一致,只是“怎么传数据”不同。""" if config.mcp_transport == "stdio": return create_stdio_client() if config.mcp_transport == "sse": diff --git a/config.py b/config.py index 16819e1..a15985e 100644 --- a/config.py +++ b/config.py @@ -6,24 +6,31 @@ from dotenv import load_dotenv from pydantic import BaseModel +# 这三种是本 demo 支持的 MCP 传输方式,类型字面量写在最上面,方便看一眼就明白选项。 TransportType = Literal["stdio", "sse", "streamable_http"] class AppConfig(BaseModel): - """配置 schema。""" + """集中描述所有运行配置,统一从 .env 读取,避免散落的 os.getenv 调用。""" + # --- LLM(这里是千问百炼的 OpenAI 兼容接口)--- openai_api_key: str openai_base_url: str openai_model: str - mcp_transport: TransportType - mcp_host: str - mcp_port: int - mcp_sse_path: str - mcp_streamable_path: str - llm_max_tool_rounds: int + + # --- MCP 传输层 --- + mcp_transport: TransportType # 三选一,决定客户端和服务端怎么连 + mcp_host: str # HTTP 类传输的监听地址 + mcp_port: int # HTTP 类传输的监听端口 + mcp_sse_path: str # SSE 端点路径 + mcp_streamable_path: str # streamable HTTP 端点路径 + + # --- LLM 行为 --- + llm_max_tool_rounds: int # LLM <-> 工具 的最大往返次数,防止无限循环 @classmethod def from_env(cls) -> "AppConfig": + """从 .env 文件和环境变量加载配置;缺省值用于“开箱就能本地玩”的 stdio 模式。""" load_dotenv() return cls( openai_api_key=os.getenv("OPENAI_API_KEY", ""), @@ -31,7 +38,7 @@ def from_env(cls) -> "AppConfig": "OPENAI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1", ), - openai_model=os.getenv("OPENAI_MODEL", "qwen3.5-plus"), + openai_model=os.getenv("OPENAI_MODEL", "qwen-plus"), mcp_transport=os.getenv("MCP_TRANSPORT", "stdio"), mcp_host=os.getenv("MCP_HOST", "127.0.0.1"), mcp_port=int(os.getenv("MCP_PORT", "8000")), diff --git a/docs/architecture.puml b/docs/architecture.puml new file mode 100644 index 0000000..f33cba4 --- /dev/null +++ b/docs/architecture.puml @@ -0,0 +1,47 @@ +@startuml architecture +' 三层架构:LLM 决策层 / MCP 编排层 / 工具执行层 +' 把整个项目“谁负责什么”画成一张静态类/组件图,便于 newbie 一眼对应到目录文件 + +skinparam backgroundColor #ffffff +skinparam componentStyle rectangle +skinparam shadowing false +skinparam roundCorner 8 +skinparam ArrowColor #475569 +skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif + +skinparam component { + BackgroundColor #f8fafc + BorderColor #94a3b8 + FontColor #0f172a +} + +title MCP Demo 三层架构 ArchitectureOverview + +actor "User\n用户" as U #f1f5f9 + +package "决策层 LLM" #eef2ff { + component "OpenAI 兼容模型\n(qwen-plus@百炼)" as L #e0e7ff + note right of L : 只做“要不要调工具、用哪个、传什么参数”\n不直接执行工具 +} + +package "编排层 MCP Client client/*" #ecfeff { + component "main.py\n对话主循环" as M #cffafe + component "client.llm\n工具发现 + LLM 循环" as CL #cffafe + component "client.runtime\n按 MCP_TRANSPORT 选连接方式" as CR #cffafe +} + +package "执行层 MCP Server server/*" #fff7ed { + component "server.app\n注册四个 @mcp.tool 计算器" as SA #fed7aa + component "server.runtime\n按协议启动(stdio/sse/http)" as SR #fed7aa +} + +U --> M : 终端输入问题 +M --> CL : ask_with_llm(...) +CL --> CR : create_mcp_client(config) +CL --> L : chat.completions.create(tools) +CL --> SA : tools/list tools/call (JSON-RPC) +SR --> SA : 托管工具 +SA --> CL : CallToolResult +CL --> L : 回填 tool 结果 \n 产出最终回答 +M --> U : 打印助手回复 +@enduml diff --git a/docs/call_sequence.puml b/docs/call_sequence.puml new file mode 100644 index 0000000..0219abc --- /dev/null +++ b/docs/call_sequence.puml @@ -0,0 +1,55 @@ +@startuml call_sequence +' 一次“帮我计算 1+2”的完整调用时序,覆盖握手/发现/决策/调用/回填/最终输出 +' 比 mermaid 版多了 protocolVersion、notifications/initialized 等协议细节 + +skinparam backgroundColor #ffffff +skinparam shadowing false +skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif +skinparam sequence { + ArrowColor #334155 + ActorBorderColor #94a3b8 + ActorBackgroundColor #f8fafc + ParticipantBorderColor #94a3b8 + LifeLineBorderColor #cbd5e1 + ParticipantBackgroundColor #ffffff +} + +title 一次 MCP 工具调用的完整时序 Sequence: 1+2 + +actor "User 用户" as U +participant "main.py" as Main +participant "MCP Client\n(client/*)" as C +participant "MCP Server\n(server/*)" as S +participant "LLM\n(qwen-plus)" as L + +== Phase 1 建立会话并握手 == +U -> Main : 输入 "帮我算 1+2" +Main -> C : create_mcp_client(config)\nasync with client: +C -> S : initialize{protocolVersion,clientInfo} +S --> C : result{protocolVersion,\ncapabilities,serverInfo} +C -> S : notifications/initialized + +== Phase 2 发现工具 == +C -> S : tools/list +S --> C : 4 个工具 (add/subtract/multiply/divide)\n含 name/title/description/inputSchema + +== Phase 3 LLM 决策选工具 == +C -> L : messages(用户问题) + tools(schema) +L --> C : tool_calls:\ncalculator_add(a=1,b=2) + +== Phase 4 客户端执行工具 == +C -> S : tools/call(name=calculator_add,\narguments={a:1,b:2}) +S --> C : CallToolResult\ncontent=[{type:text,text:"3"}] + +== Phase 5 回填结果并生成最终答案 == +C -> L : 追加 tool 消息(result=3) +L --> C : "1 + 2 = 3" +C --> Main : 最终文本 +Main --> U : 打印 "助手: 1 + 2 = 3" + +note over C,S #fff7ed + 四类核心 JSON-RPC 方法: + initialize / notifications/initialized + tools/list / tools/call +end note +@enduml diff --git a/docs/mcp_complete_call_flow.md b/docs/mcp_complete_call_flow.md index 9411a4f..264654d 100644 --- a/docs/mcp_complete_call_flow.md +++ b/docs/mcp_complete_call_flow.md @@ -1,131 +1,110 @@ # MCP Tool Call Complete Process -This document focuses on one specific question: -In this demo, how does an MCP tool (`calculator_add`) move from "being discovered" to "being called", and finally return the result to the user? +This document answers one specific question: +In this demo, how does an MCP tool (`calculator_add`) actually go from "discovered" to "called", +and finally return the answer to the user — end to end. -## 1. First, the roles involved +For the visual version see the Live Tutorial Site; the sequence diagram source is +[`./call_sequence.puml`](./call_sequence.puml). + +## 1. Roles in one call There are 4 roles in one complete call: -1. User: enters a question in terminal (for example, "help me calculate 1+2"). -2. Client program: `main.py + client/*`, responsible for MCP connection, LLM interaction, and forwarding tool calls. -3. MCP server: `server/*`, actually provides tools (for example, `@mcp.tool(name="calculator_add", ...)`). -4. LLM: decides whether a tool is needed, which tool to use, and what arguments to pass. +1. **User** — types a question in the terminal, e.g. "help me calculate 1+2". +2. **Client program** — `main.py + client/*`: holds the MCP connection, talks to the LLM, forwards tool calls. +3. **MCP server** — `server/*`: actually provides the tools (`@mcp.tool(name="calculator_add", ...)`). +4. **LLM** — decides whether a tool is needed, which one, and what arguments to pass. -## 2. Key code locations +A useful mental model: **LLM thinks, MCP carries, Tool does.** -1. `server/app.py`: registers `calculator_add/calculator_subtract/calculator_multiply/calculator_divide` with `@mcp.tool()`. -2. `server/runtime.py`: starts FastMCP server by protocol (`stdio/sse/streamable-http`). -3. `client/runtime.py`: creates MCP Client based on configuration. -4. `client/llm.py`: handles tool discovery, LLM decision, tool execution, and result injection. -5. `main.py`: chat loop entry, receives user input and prints final answer. +## 2. Key code locations -## 3. Full flow overview (big picture first) +1. `server/app.py` — registers the four calculator tools via `@mcp.tool()`. +2. `server/runtime.py` — starts FastMCP by transport (`stdio/sse/streamable-http`). +3. `client/runtime.py` — creates the MCP `Client` according to `MCP_TRANSPORT`. +4. `client/llm.py` — tool discovery, LLM decision, tool execution, result feedback. +5. `main.py` — chat loop entry; reads input, prints final answer. -MCP tool call sequence +## 3. Full sequence at a glance -## 4. Step-by-step: what exactly happens at each step +The full sequence diagram source lives at `docs/call_sequence.puml`; rendered when you open the Live Tutorial Site. +Phase order: **handshake → discovery → decision → execution → feedback → output**. -### Step A: Server exposes tools first +## 4. Step-by-step -In server code (`server/app.py`): +### Step A — Server exposes tools first +In `server/app.py`: - `mcp = FastMCP("Test Server")` -- `@mcp.tool()` decorates `add(a: int, b: int) -> int` and maps it to tool name `calculator_add` - -What this means: -The MCP Server exposes multiple remotely callable tools. In this example, one tool is `calculator_add`, and its input schema requires integer `a` and `b`. - -### Step B: Client establishes MCP session and performs handshake - -When `main.py` enters `async with mcp_client as client`, connection and initialization are triggered: - -1. Client connects to server (transport depends on `MCP_TRANSPORT`). -2. Sends JSON-RPC request: `initialize` -3. After receiving server capability info, sends notification: `notifications/initialized` - -After this step, both sides are considered "protocol-ready". - -### Step C: Tool discovery first - -The first action in `client/llm.py` is: - -- `await mcp_client.list_tools()` - -Protocol operation: - -- JSON-RPC: `tools/list` - -The response includes at least: - -1. Tool name: `calculator_add` (and other `calculator_*` tools) -2. Input schema (`a` and `b` types) -3. Possibly `outputSchema`, `_meta`, etc. (depends on implementation/version) +- `@mcp.tool(name="calculator_add", ...)` wraps `add(a: int, b: int) -> int`. -### Step D: Pass tool information to the LLM for decision-making +FastMCP auto-generates `inputSchema` from the function's type hints, so no hand-written JSON Schema is needed. +At this point an MCP Server has remotely-callable tools; one of them is `calculator_add` taking integers `a` and `b`. -Inside `ask_with_llm()`, MCP tools are converted into the model-recognizable `tools` parameter and sent to the LLM together with: +### Step B — Connect and handshake -1. User question (for example, "1+2") -2. Available tool list (including `calculator_add` schema) +When `main.py` enters `async with mcp_client as client`: -Then the LLM returns one of two outcomes: +1. Client connects to the server (transport depends on `MCP_TRANSPORT`). +2. Client sends JSON-RPC `initialize` with its `protocolVersion` + `clientInfo`. +3. Server replies with its `protocolVersion`, `capabilities`, `serverInfo`. +4. Client sends notification `notifications/initialized`. -1. Direct answer (no tool call) -2. `tool_calls` (in this case, call `calculator_add`) +After this both sides are "protocol-ready". -### Step E: Client executes tool calls +### Step C — Tool discovery -If LLM returns `tool_calls`, the client iterates and runs: +First action inside `ask_with_llm()`: +- `await mcp_client.list_tools()` → JSON-RPC `tools/list`. -- `await mcp_client.call_tool(call.function.name, arguments)` +Response includes the four `calculator_*` tools, each with +`name / title / description / inputSchema` (and possibly `outputSchema / _meta`). -Protocol operation: +### Step D — Hand tool info to the LLM for decision -- JSON-RPC: `tools/call` +`to_openai_tools(...)` turns the MCP tool list into the OpenAI `tools` argument and sends it to the LLM +together with the user's question. The LLM returns one of: +1. Direct answer (no tool call), or +2. `tool_calls` — here, `calculator_add(a=1, b=2)`. -The request contains: +### Step E — Execute the tool call(s) -1. Tool name: `calculator_add` -2. Arguments: `{"a": 1, "b": 2}` +For each `tool_call` the client calls: +- `await mcp_client.call_tool(name, arguments)` → JSON-RPC `tools/call`. -After receiving it, server executes `calculator_add` and returns `CallToolResult`. +Request body: `{ name: "calculator_add", arguments: {"a": 1, "b": 2} }`. +The server runs the actual Python function and returns a `CallToolResult`. -### Step F: Feed tool result back to LLM +### Step F — Feed tool result back to the LLM -After client receives `CallToolResult`, it wraps it into a `tool` role message and appends to `messages`: +The client wraps each `CallToolResult` as a `role: "tool"` message +(with `tool_call_id`, `name`, `content`) and appends it to `messages`. +Then it calls the LLM again so the model can produce the final natural-language reply +based on the tool execution result (`3`). -1. `tool_call_id` (for this call) -2. `name` (tool name) -3. `content` (tool output, such as `3`) +### Step G — Output to user -Then it calls LLM again so the model can generate the final natural-language response based on tool execution result. +`main.py` just prints: `助手:1 + 2 = 3`. +One complete end-to-end flow is done. -### Step G: Return to user +## 5. Which JSON-RPC operations appear at protocol level -`main.py` prints the final text directly: +In order, exactly these core operations: -- `Assistant: 1 + 2 = 3` +1. `initialize` — session handshake. +2. `notifications/initialized` — "initialization done, normal requests can start". +3. `tools/list` — query currently available tools (discovery). +4. `tools/call` — run a tool by name + arguments. -At this point, one complete end-to-end flow is done. +Mnemonic: **Handshake → Discover → Call → Feed back**. -## 5. Which MCP operations appear at protocol level +## 6. Real captured payloads (stdio) -In order, the core operations are exactly these 4: +Below are real MCP payloads captured from one local run of `list_tools + call_tool(calculator_add)`, +in chronological order. -1. `initialize`: session initialization handshake. -2. `notifications/initialized`: tells server "initialization is done, formal requests can start". -3. `tools/list`: query currently available tools (tool discovery). -4. `tools/call`: execute a specific tool by name and arguments. - -You can remember it as one sentence: -**Handshake first, then discovery, then invocation, then feed result back to the model.** - -## 6. Real captured payloads (`stdio`) - -Below are real MCP payloads captured from one local run of `list_tools + call_tool(calculator_add)` (in chronological order). - -### 6.1 initialize (request + response) +### 6.1 `initialize` (request + response) ```json { @@ -135,10 +114,7 @@ Below are real MCP payloads captured from one local run of `list_tools + call_to "params": { "protocolVersion": "2025-11-25", "capabilities": {}, - "clientInfo": { - "name": "mcp", - "version": "0.1.0" - } + "clientInfo": { "name": "mcp", "version": "0.1.0" } } } ``` @@ -151,45 +127,26 @@ Below are real MCP payloads captured from one local run of `list_tools + call_to "protocolVersion": "2025-11-25", "capabilities": { "experimental": {}, - "prompts": { - "listChanged": false - }, - "resources": { - "subscribe": false, - "listChanged": false - }, - "tools": { - "listChanged": true - }, - "extensions": { - "io.modelcontextprotocol/ui": {} - } + "prompts": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false }, + "tools": { "listChanged": true }, + "extensions": { "io.modelcontextprotocol/ui": {} } }, - "serverInfo": { - "name": "Test Server", - "version": "3.1.1" - } + "serverInfo": { "name": "Test Server", "version": "3.1.1" } } } ``` -### 6.2 notifications/initialized (notification) +### 6.2 `notifications/initialized` (notification) ```json -{ - "jsonrpc": "2.0", - "method": "notifications/initialized" -} +{ "jsonrpc": "2.0", "method": "notifications/initialized" } ``` -### 6.3 tools/list (request + response) +### 6.3 `tools/list` (request + response) ```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list" -} +{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" } ``` ```json @@ -204,158 +161,25 @@ Below are real MCP payloads captured from one local run of `list_tools + call_to "description": "Add two numbers", "inputSchema": { "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_subtract", - "title": "Calculator Subtract", - "description": "Subtract second number from first number", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_multiply", - "title": "Calculator Multiply", - "description": "Multiply two numbers", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], + "properties": { "a": { "type": "integer" }, "b": { "type": "integer" } }, + "required": ["a", "b"], "type": "object" }, "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], + "properties": { "result": { "type": "integer" } }, + "required": ["result"], "type": "object", "x-fastmcp-wrap-result": true }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_divide", - "title": "Calculator Divide", - "description": "Divide first number by second number (b must not be zero)", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "number" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } + "_meta": { "fastmcp": { "tags": [] } } } + // ...calculator_subtract / calculator_multiply / calculator_divide share the same shape ] } } ``` -### 6.4 tools/call (request + response) +### 6.4 `tools/call` (request + response) ```json { @@ -364,10 +188,7 @@ Below are real MCP payloads captured from one local run of `list_tools + call_to "method": "tools/call", "params": { "name": "calculator_add", - "arguments": { - "a": 1, - "b": 2 - } + "arguments": { "a": 1, "b": 2 } } } ``` @@ -377,48 +198,31 @@ Below are real MCP payloads captured from one local run of `list_tools + call_to "jsonrpc": "2.0", "id": 2, "result": { - "content": [ - { - "type": "text", - "text": "3" - } - ], - "structuredContent": { - "result": 3 - }, + "content": [{ "type": "text", "text": "3" }], + "structuredContent": { "result": 3 }, "isError": false } } ``` -## 7. What changes under three transports? - -Answer: **The main flow stays the same. Only connection mode changes.** - -1. `stdio` -Client starts and connects server process through standard input/output. - -2. `sse` -Client connects to an already-running server via HTTP + SSE. +## 7. What changes under the three transports? -3. `streamable_http` -Client connects to server's streamable endpoint via HTTP. +Answer: **the main flow is identical. Only how bytes are carried changes.** -No matter which transport you use, the steps remain: +1. `stdio` — client spawns the server process and talks over stdin/stdout. +2. `sse` — client connects via HTTP + Server-Sent Events to a long-running server. +3. `streamable_http` — client connects to the server's HTTP MCP endpoint. -1. initialize -2. tools/list -3. tools/call -4. feed result back to LLM -5. output final answer +Regardless of transport the steps stay: +`initialize → tools/list → tools/call → feed result back to the LLM → output final answer`. ## 8. Common misunderstandings -1. Misunderstanding: MCP automatically decides which tool to call. -Reality: the decision is usually made by LLM; MCP standardizes communication and execution. +1. **Myth:** MCP automatically decides which tool to call. + **Reality:** the LLM decides; MCP standardizes transport + execution. -2. Misunderstanding: once `@mcp.tool` exists, it will be called automatically. -Reality: client must discover it via `tools/list`, then LLM must generate `tool_call`. +2. **Myth:** if `@mcp.tool` is decorated, it gets called automatically. + **Reality:** the client must discover it via `tools/list`, then the LLM must emit a `tool_call`. -3. Misunderstanding: changing transport requires changing business logic. -Reality: usually tool logic stays unchanged; only connection/startup mode changes. +3. **Myth:** changing transport requires changing business logic. + **Reality:** tool logic is unchanged; only how the connection starts. diff --git a/docs/mcp_complete_call_flow_zh-CN.md b/docs/mcp_complete_call_flow_zh-CN.md index 4495649..bbb6e5c 100644 --- a/docs/mcp_complete_call_flow_zh-CN.md +++ b/docs/mcp_complete_call_flow_zh-CN.md @@ -1,131 +1,105 @@ # MCP 工具调用完整过程 -这篇文档专门解释一个问题: -在这个 demo 里,一个 MCP 工具(`calculator_add`)到底是怎么从“被发现”到“被调用”,最后把结果返回给用户的。 +这篇文档专门解答一个问题: +在这个 demo 里,一个 MCP 工具(`calculator_add`)到底是怎么从“被发现”到“被调用”,最后把结果返回给用户的,端到端走一遍。 + +看图请到在线教学站点;时序图源码在 [`./call_sequence.puml`](./call_sequence.puml)。 ## 1. 先看参与角色 一次完整调用里有 4 个角色: -1. 用户:在终端输入问题(例如“帮我算 1+2”)。 -2. 客户端程序:`main.py + client/*`,负责连 MCP、调 LLM、转发工具调用。 -3. MCP 服务端:`server/*`,真正提供工具(`@mcp.tool(name=\"calculator_add\", ...)`)。 -4. LLM:负责判断“要不要用工具、用哪个工具、传什么参数”。 +1. **用户** — 在终端输入问题(例如“帮我算 1+2”)。 +2. **客户端程序** — `main.py + client/*`:拿 MCP 连接、和 LLM 交互、转发工具调用。 +3. **MCP 服务端** — `server/*`:真正提供工具(`@mcp.tool(name="calculator_add", ...)`)。 +4. **LLM** — 判断“要不要用工具、用哪个、传什么参数”。 -## 2. 关键代码位置 +一句话心智模型:**LLM 想、MCP 传、Tool 做。** -1. `server/app.py`:用 `@mcp.tool()` 注册 `calculator_add/calculator_subtract/calculator_multiply/calculator_divide`。 -2. `server/runtime.py`:按协议启动 FastMCP 服务(`stdio/sse/streamable-http`)。 -3. `client/runtime.py`:按配置创建 MCP Client。 -4. `client/llm.py`:工具发现、LLM 决策、调用工具、回填结果。 -5. `main.py`:聊天循环入口,接收用户输入并输出最终答案。 +## 2. 关键代码位置 -## 3. 全流程总览(先看大图) +1. `server/app.py` — 用 `@mcp.tool()` 注册四个计算器工具。 +2. `server/runtime.py` — 按 `stdio/sse/streamable-http` 启动 FastMCP。 +3. `client/runtime.py` — 按 `MCP_TRANSPORT` 创建 MCP `Client`。 +4. `client/llm.py` — 工具发现、LLM 决策、工具执行、结果回填。 +5. `main.py` — 聊天循环入口,收用户输入、打印最终答案。 -MCP 工具调用时序图 +## 3. 全流程总览 -## 4. 逐步拆开:每一步到底做了什么 +时序图源码在 `docs/call_sequence.puml`,在线教学站点会自动渲染。 +阶段顺序:**握手 → 发现 → 决策 → 执行 → 回填 → 输出**。 -### 步骤 A:服务端先把工具“挂出来” +## 4. 逐步拆开 -服务端代码(`server/app.py`)里: +### 步骤 A — 服务端先把工具“挂出来” +在 `server/app.py`: - `mcp = FastMCP("Test Server")` -- `@mcp.tool()` 修饰 `add(a: int, b: int) -> int`,并映射为工具名 `calculator_add` - -这一步的含义是: -MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `calculator_add`,输入 schema 为整数 `a`、`b`。 - -### 步骤 B:客户端建立 MCP 会话并握手 - -在 `main.py` 里进入 `async with mcp_client as client` 时,会触发连接和初始化: - -1. 客户端连上服务端(具体传输由 `MCP_TRANSPORT` 决定)。 -2. 发送 JSON-RPC 请求:`initialize` -3. 收到服务端能力信息后,发送通知:`notifications/initialized` - -这一步做完,双方才算“协议层已就绪”。 - -### 步骤 C:先做工具发现(Discovery) - -`client/llm.py` 的第一步是: - -- `await mcp_client.list_tools()` - -对应协议操作是: - -- JSON-RPC:`tools/list` - -拿到的内容里至少会有: - -1. 工具名:`calculator_add`(以及其它 `calculator_*` 工具) -2. 输入参数 schema(`a`、`b` 的类型) -3. 可能还会有 `outputSchema`、`_meta` 等字段(取决于实现与版本) +- `@mcp.tool(name="calculator_add", ...)` 修饰 `add(a: int, b: int) -> int`。 -### 步骤 D:把工具信息交给 LLM 做决策 +FastMCP 会根据函数签名的类型注解自动生成 `inputSchema`,所以不用手写 JSON Schema。 +这一步含义:MCP Server 拥有了可被远程调用的工具,其中一个是 `calculator_add`,入参是整数 `a`、`b`。 -`ask_with_llm()` 里会把 MCP tools 转成模型可识别的 `tools` 参数,发给 LLM: +### 步骤 B — 建立会话并握手 -1. 用户问题(例如“1+2”) -2. 可用工具列表(包含 `calculator_add` 的 schema) +`main.py` 进入 `async with mcp_client as client` 时: -然后 LLM 输出二选一结果: +1. 客户端连服务端(具体方式取决于 `MCP_TRANSPORT`)。 +2. 客户端发 JSON-RPC `initialize`,带上 `protocolVersion` + `clientInfo`。 +3. 服务端回复它自己的 `protocolVersion` / `capabilities` / `serverInfo`。 +4. 客户端发通知 `notifications/initialized`。 -1. 直接回答(不调工具) -2. 产出 `tool_calls`(本例就是调用 `calculator_add`) +完成这步后两边都视为“协议准备好”。 -### 步骤 E:客户端执行工具调用 +### 步骤 C — 工具发现 -如果 LLM 返回 `tool_calls`,客户端会遍历调用: +`ask_with_llm()` 内的第一件事: +- `await mcp_client.list_tools()` → JSON-RPC `tools/list`。 -- `await mcp_client.call_tool(call.function.name, arguments)` +返回里包含四个 `calculator_*` 工具,每个有 `name / title / description / inputSchema`(可能还有 `outputSchema / _meta`)。 -对应协议操作是: +### 步骤 D — 把工具信息交给 LLM 决策 -- JSON-RPC:`tools/call` +`to_openai_tools(...)` 把 MCP 工具列表转成 OpenAI 的 `tools` 参数,连同用户问题一起发给 LLM。 +LLM 返回两种之一: +1. 直接答案(不调工具),或 +2. `tool_calls` —— 本例是 `calculator_add(a=1, b=2)`。 -请求里包含: +### 步骤 E — 客户端执行工具调用 -1. 工具名:`calculator_add` -2. 参数:`{"a": 1, "b": 2}` +对每个 `tool_call`,客户端调: +- `await mcp_client.call_tool(name, arguments)` → JSON-RPC `tools/call`。 -服务端收到后,执行 `calculator_add` 并返回 `CallToolResult`。 +请求体:`{ name: "calculator_add", arguments: {"a": 1, "b": 2} }`。 +服务端执行真正的 Python 函数,返回 `CallToolResult`。 -### 步骤 F:把工具结果回填给 LLM +### 步骤 F — 把工具结果回填给 LLM -客户端拿到 `CallToolResult` 后,会把结果包装成一条 `tool` 角色消息追加到 `messages`: +客户端把每个 `CallToolResult` 包成 `role: "tool"` 消息 +(带 `tool_call_id`、`name`、`content`)追加到 `messages`, +再调一次 LLM,让模型基于工具结果(`3`)产出最终自然语言回答。 -1. `tool_call_id`(对应这次调用) -2. `name`(工具名) -3. `content`(工具输出,比如 `3`) +### 步骤 G — 输出给用户 -然后再次调用 LLM,让它基于“工具执行结果”产出最终自然语言回答。 +`main.py` 直接打印:`助手:1 + 2 = 3`。 +一次完整端到端流程走完。 -### 步骤 G:输出给用户 +## 5. 协议层出现的 JSON-RPC 方法 -`main.py` 拿到最终文本后直接打印: +按顺序看,核心操作就这 4 个: -- `助手:1 + 2 = 3` +1. `initialize` — 会话握手。 +2. `notifications/initialized` — “初始化完成,正式请求可以开始”。 +3. `tools/list` — 查询当前可用工具(发现)。 +4. `tools/call` — 按名字 + 参数执行某个工具。 -到这里一次完整链路结束。 +记忆口诀:**先握手,再发现,再调用,再回填**。 -## 5. 协议层会看到哪些 MCP 操作 +## 6. 真实抓报文(stdio) -按发生顺序,核心就是这 4 类: +下面是一次本地运行 `list_tools + call_tool(calculator_add)` 时真实抓到的 MCP 报文,按时间顺序。 -1. `initialize`:会话初始化握手。 -2. `notifications/initialized`:告诉服务端“初始化完成,可以正式收发业务请求”。 -3. `tools/list`:查询当前可用工具(工具发现)。 -4. `tools/call`:按工具名+参数执行具体工具。 - -可以把它记成一句话: -**先握手,再发现,再调用,再把结果回填给模型。** - -## 6. 真实运行抓取报文(`stdio`) - -下面是本地真实跑一次 `list_tools + call_tool(calculator_add)` 抓到的 MCP 报文(按发生顺序)。 - -### 6.1 initialize(请求 + 响应) +### 6.1 `initialize`(请求 + 响应) ```json { @@ -135,10 +109,7 @@ MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `c "params": { "protocolVersion": "2025-11-25", "capabilities": {}, - "clientInfo": { - "name": "mcp", - "version": "0.1.0" - } + "clientInfo": { "name": "mcp", "version": "0.1.0" } } } ``` @@ -151,45 +122,26 @@ MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `c "protocolVersion": "2025-11-25", "capabilities": { "experimental": {}, - "prompts": { - "listChanged": false - }, - "resources": { - "subscribe": false, - "listChanged": false - }, - "tools": { - "listChanged": true - }, - "extensions": { - "io.modelcontextprotocol/ui": {} - } + "prompts": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false }, + "tools": { "listChanged": true }, + "extensions": { "io.modelcontextprotocol/ui": {} } }, - "serverInfo": { - "name": "Test Server", - "version": "3.1.1" - } + "serverInfo": { "name": "Test Server", "version": "3.1.1" } } } ``` -### 6.2 notifications/initialized(通知) +### 6.2 `notifications/initialized`(通知) ```json -{ - "jsonrpc": "2.0", - "method": "notifications/initialized" -} +{ "jsonrpc": "2.0", "method": "notifications/initialized" } ``` -### 6.3 tools/list(请求 + 响应) +### 6.3 `tools/list`(请求 + 响应) ```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list" -} +{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" } ``` ```json @@ -204,158 +156,25 @@ MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `c "description": "Add two numbers", "inputSchema": { "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_subtract", - "title": "Calculator Subtract", - "description": "Subtract second number from first number", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_multiply", - "title": "Calculator Multiply", - "description": "Multiply two numbers", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], + "properties": { "a": { "type": "integer" }, "b": { "type": "integer" } }, + "required": ["a", "b"], "type": "object" }, "outputSchema": { - "properties": { - "result": { - "type": "integer" - } - }, - "required": [ - "result" - ], + "properties": { "result": { "type": "integer" } }, + "required": ["result"], "type": "object", "x-fastmcp-wrap-result": true }, - "_meta": { - "fastmcp": { - "tags": [] - } - } - }, - { - "name": "calculator_divide", - "title": "Calculator Divide", - "description": "Divide first number by second number (b must not be zero)", - "inputSchema": { - "additionalProperties": false, - "properties": { - "a": { - "type": "integer" - }, - "b": { - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "outputSchema": { - "properties": { - "result": { - "type": "number" - } - }, - "required": [ - "result" - ], - "type": "object", - "x-fastmcp-wrap-result": true - }, - "_meta": { - "fastmcp": { - "tags": [] - } - } + "_meta": { "fastmcp": { "tags": [] } } } + // ...calculator_subtract / calculator_multiply / calculator_divide 结构相同 ] } } ``` -### 6.4 tools/call(请求 + 响应) +### 6.4 `tools/call`(请求 + 响应) ```json { @@ -364,10 +183,7 @@ MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `c "method": "tools/call", "params": { "name": "calculator_add", - "arguments": { - "a": 1, - "b": 2 - } + "arguments": { "a": 1, "b": 2 } } } ``` @@ -377,48 +193,31 @@ MCP Server 拥有可被远程调用的多个工具,其中示例工具名是 `c "jsonrpc": "2.0", "id": 2, "result": { - "content": [ - { - "type": "text", - "text": "3" - } - ], - "structuredContent": { - "result": 3 - }, + "content": [{ "type": "text", "text": "3" }], + "structuredContent": { "result": 3 }, "isError": false } } ``` -## 7. 三种传输协议下,这个流程有什么变化? - -答案:**主流程不变,只是“连接方式”不同。** - -1. `stdio` -客户端通过标准输入输出启动并连接服务端进程。 - -2. `sse` -客户端通过 HTTP + SSE 连已经运行的服务端。 +## 7. 三种传输下什么变了? -3. `streamable_http` -客户端通过 HTTP 连服务端的 streamable 路径。 +答:**主流程不变,只有“怎么把字节送过去”变了。** -无论哪种 transport,步骤仍是: +1. `stdio` — 客户端拉起服务端子进程,通过 stdin/stdout 通信。 +2. `sse` — 客户端通过 HTTP + Server-Sent Events 连一个长驻服务端。 +3. `streamable_http` — 客户端连服务端的 HTTP MCP 端点。 -1. initialize -2. tools/list -3. tools/call -4. 回填结果给 LLM -5. 输出最终答案 +无论哪种传输,步骤都还是: +`initialize → tools/list → tools/call → 把结果回喂 LLM → 输出最终答案`。 -## 8. 常见误区 +## 8. 常见误解 -1. 误区:MCP 会自动替你“决定调用哪个工具”。 -实际:决定通常是 LLM 做的;MCP 负责标准化通信和执行。 +1. **误解:** MCP 自己决定调哪个工具。 + **真相:** LLM 来决定;MCP 只是标准化的传输和执行。 -2. 误区:有 `@mcp.tool` 就会自动被调用。 -实际:还要客户端先 `tools/list` 发现它,再由 LLM 产生 `tool_call`。 +2. **误解:** 只要写了 `@mcp.tool` 就会自动被调。 + **真相:** 客户端必须先 `tools/list` 发现它,LLM 还得真的发出 `tool_call` 才会执行。 -3. 误区:换协议会改业务逻辑。 -实际:一般不用改工具逻辑,只改连接/启动方式。 +3. **误解:** 换传输就得改业务逻辑。 + **真相:** 工具逻辑不变,只有启动/连接方式变。 diff --git a/docs/mcp_vs_function_calling.puml b/docs/mcp_vs_function_calling.puml new file mode 100644 index 0000000..e3ae75e --- /dev/null +++ b/docs/mcp_vs_function_calling.puml @@ -0,0 +1,36 @@ +@startuml mcp_vs_function_calling +' MCP vs Function Calling 的职责边界对比 +' 不是二选一,而是“决策层 vs 通信层” + +skinparam backgroundColor #ffffff +skinparam shadowing false +skinparam roundCorner 8 +skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif +skinparam ArrowColor #475569 + +title MCP vs Function Calling 职责对比 + +rectangle "Function Calling\n(模型 API 能力)" as FC #eef2ff { + card "把函数 schema 喂给模型\n模型决定要不要调" as FC1 #e0e7ff + card "你负责把结果回填给模型\n拼接是手写的" as FC2 #e0e7ff + note bottom of FC : 依赖具体厂商 API\n跨工具/跨厂商时各写各的 +} + +rectangle "MCP\n(工具通信协议层)" as MCP #ecfeff { + card "tools/list 发现工具\ntools/call 执行工具\n统一 JSON-RPC" as M1 #cffafe + card "传输可选 stdio/sse/http\n工具实现可以是任意语言/进程" as M2 #cffafe + note bottom of MCP : 厂商/工具解耦\n模型端只对接 MCP client +} + +(模型\n会不会调) -[hidden]-> FC +FC --> MCP : 决策出来后\n走 MCP 调用层执行 + +note as N1 #fff7ed + 本 demo 的实际配合: + LLM 用 Function 决策 -> 客户端按 MCP 执行 -> 工具产出结果 + 两者不是二选一,而是“决策” + “传输/执行”的分工 +end note + +FC .. N1 +MCP .. N1 +@enduml diff --git a/docs/process_flow.puml b/docs/process_flow.puml new file mode 100644 index 0000000..bb93e5b --- /dev/null +++ b/docs/process_flow.puml @@ -0,0 +1,49 @@ +@startuml process_flow +' 客户端主流程活动图:含三种传输分支与 LLM<->MCP 循环 +' 对应 main.py + ask_with_llm 的真实控制流 + +skinparam backgroundColor #ffffff +skinparam shadowing false +skinparam roundCorner 10 +skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif +skinparam ActivityBackgroundColor #f8fafc +skinparam ActivityBorderColor #94a3b8 +skinparam ActivityDiamondBackgroundColor #fef9c3 +skinparam ActivityDiamondBorderColor #ca8a04 +skinparam ArrowColor #475569 + +title 客户端主流程 ProcessFlow + +start + +:[1] uv run python main.py; +:[2] AppConfig.from_env() 读取 .env; +if ([3] MCP_TRANSPORT?) then (stdio) + :子进程方式拉起 server/stdio.py; +elseif (sse) then (sse) + :连 http://host:port/sse; +else (streamable_http) + :连 http://host:port/mcp; +endif + +:[4] async with mcp_client 建立会话+握手; +repeat + :[5] input() 读用户输入; + if ([6] 输入 = exit ?) then (yes) + break 结束程序 + else (no) + :[7] ask_with_llm(); + :[8] mcp_client.list_tools()\n发现工具; + :[9] 把 MCP 工具转成 OpenAI tools; + repeat :[10] 调 LLM 一次\n(asyncio.to_thread); + if ([11] 返回了 tool_calls?) then (yes) + :[12] 逐个 mcp_client.call_tool()\n服务端执行 add/sub/mul/div; + :[13] 把 tool 结果回填进 messages; + else (no) + :[14] 输出最终自然语言回答; + endif + repeat while (返回了 tool_calls ?) + endif +repeat +stop +@enduml diff --git a/docs/transport_comparison.puml b/docs/transport_comparison.puml new file mode 100644 index 0000000..1e12513 --- /dev/null +++ b/docs/transport_comparison.puml @@ -0,0 +1,43 @@ +@startuml transport_comparison +' 三种传输方式的连接结构对比(并列三泳道) +' 只画“怎么连”,不画协议方法 —— 协议方法三种一致,不重复 + +skinparam backgroundColor #ffffff +skinparam shadowing false +skinparam roundCorner 8 +skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif +skinparam ArrowColor #475569 + +title 三种传输:连接方式对比 TransportComparison + +skinparam swimlane { + BorderColor #cbd5e1 + TitleFontColor #0f172a +} + +|stdio| +skinparam component {BackgroundColor #e0f2fe BorderColor #0284c7} +component "main.py + MCP Client" as StC #e0f2fe +component "server/stdio.py\n(子进程)" as StS #bae6fd +StC -> StS : 标准输入 (stdin) +StS -> StC : 标准输出 (stdout) +note bottom of StS : 单进程命令即跑\nuv run python main.py + +|sse (HTTP+SSE)| +skinparam component {BackgroundColor #ecfeff BorderColor #0e7490} +component "MCP Server\nserver.sse (长驻)" as SeS #cffafe +component "main.py + MCP Client\n(连 /sse)" as SeC #a5f3fc +cloud "HTTP 通道\nServer-Sent Events" as SeN #e0f7fa +SeC --> SeN : HTTP 长连接 +SeN --> SeS : 单向事件流 +SeC <-- SeS : SSE 推送 / JSON-RPC 应答 +note bottom of SeS : 终端 A 跑 server, 终端 B 跑 client\n适合远程/局域网 + +|streamable_http| +skinparam component {BackgroundColor #f3e8ff BorderColor #7e22ce} +component "MCP Server\nserver.streamable_http (长驻)" as HtS #e9d5ff +component "main.py + MCP Client\n(连 /mcp)" as HtC #d8b4fe +HtC <-> HtS : HTTP 请求/响应\n(streamable) +note bottom of HtS : 标准 HTTP 端点\n最适合 Web 化/多客户端部署 + +@enduml diff --git a/main.py b/main.py index 7e6c4b4..ff2fb6a 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,12 @@ async def run_chat(config: AppConfig) -> None: + """交互式对话主循环:读用户输入 -> 让 LLM 借 MCP 工具回答 -> 打印回复。""" llm_client = create_openai_client(config) mcp_client = create_mcp_client(config) + + # async with 进入时会完成 MCP 会话握手: + # 客户端 -> 服务端 initialize,再发 notifications/initialized。 async with mcp_client as client: print("输入问题开始对话,输入 exit 退出。") while True: diff --git a/server/app.py b/server/app.py index b04991b..896a510 100644 --- a/server/app.py +++ b/server/app.py @@ -4,6 +4,13 @@ def create_mcp_server() -> FastMCP: + """构建一个 MCP 服务端,并把四个计算器工具挂上去。 + + @mcp.tool 的作用:把一个普通 Python 函数登记成 MCP 工具, + 客户端通过 tools/list 就能发现它、通过 tools/call 就能调用它。 + FastMCP 会根据函数签名和类型注解自动生成入参 schema(inputSchema), + 所以这里不需要手写 JSON Schema。 + """ mcp = FastMCP("Test Server") @mcp.tool( @@ -36,6 +43,7 @@ def multiply(a: int, b: int) -> int: description="Divide first number by second number (b must not be zero)", ) def divide(a: int, b: int) -> float: + # 除数为 0 是算术意义上的非法状态,这里直接抛错让调用方看到 if b == 0: raise ValueError("除数不能为 0") return a / b diff --git a/server/runtime.py b/server/runtime.py index 5189501..4824e23 100644 --- a/server/runtime.py +++ b/server/runtime.py @@ -5,10 +5,12 @@ def run_server_stdio(config: AppConfig) -> None: + """stdio 启动:进程把工具暴露在自家标准输入输出上,方便客户端当子进程直接拉起。""" create_mcp_server().run(transport="stdio", show_banner=False) def run_server_sse(config: AppConfig) -> None: + """sse 启动:长驻为 HTTP+SSE 服务端,供远程客户端通过 /sse 连过来。""" create_mcp_server().run( transport="sse", host=config.mcp_host, @@ -19,6 +21,7 @@ def run_server_sse(config: AppConfig) -> None: def run_server_streamable_http(config: AppConfig) -> None: + """streamable_http 启动:做成一个标准的 HTTP MCP 端点,适合 Web 化部署。""" create_mcp_server().run( transport="streamable-http", host=config.mcp_host, @@ -29,6 +32,7 @@ def run_server_streamable_http(config: AppConfig) -> None: def run_server_by_transport(config: AppConfig) -> None: + """根据 MCP_TRANSPORT 选三种传输启动方式之一。三种方式下工具集合完全一致,差别只在传输层。""" if config.mcp_transport == "stdio": run_server_stdio(config) elif config.mcp_transport == "sse": diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..84f770a --- /dev/null +++ b/site/README.md @@ -0,0 +1,64 @@ +# MCP 教学站点(site/) + +这是一份**纯静态**零依赖构建的教学页面:单页 + 内嵌 PlantUML 图,浏览器里直接渲染。 + +## 在线版本 + +部署后通过 GitHub Pages 访问: + +``` +https://albert-pzy.github.io/mcp-tutorial/ +``` + +由仓库根目录的 `.github/workflows/deploy-pages.yml` 自动部署。 + +## 本地预览 + +任选其一: + +```bash +# Python(无需任何依赖) +python -m http.server 8000 --directory site +# 然后浏览器打开 http://localhost:8000/ + +# 或 npx +npx http-server site -p 8000 +``` + +## 目录结构 + +```text +site/ +|-- index.html # 单页教学站点(5 个章节) +|-- assets/ +| |-- style.css # 主题 + 布局 + zoom overlay 样式 +| `-- plantuml.js # PlantUML 编码 + 渲染 + 无损放大 (零依赖,原生 CompressionStream) +`-- diagrams/ + |-- architecture.puml + |-- call_sequence.puml + |-- process_flow.puml + |-- transport_comparison.puml + `-- mcp_vs_function_calling.puml +``` + +`site/diagrams/` 是 `docs/*.puml` 的同步副本,目的是让 Pages 部署时站点自包含(Pages artifact 只上 `site/`)。 +修改图请编辑 `docs/.puml`,然后同步覆盖 `site/diagrams/.puml`。 + +## 渲染原理与限制 + +- **PlantUML 在线渲染**:`assets/plantuml.js` 用浏览器原生 `CompressionStream("deflate-raw")` 把源码压缩, + 再按 PlantUML 自有 base64 字母表编码,向官方 `https://www.plantuml.com/plantuml/svg/` 请求 SVG, + 内嵌进 DOM。失败时显示原始 PlantUML 源码 + 错误提示,页面不会白屏。 +- **外网依赖**:图渲染**需要能访问 plantuml.com**。在受限网络下会回退到源码展示。 +- **无损放大**:图片内嵌后保留 SVG(矢量),点击任意一张图弹出全屏遮罩,支持:滚轮缩放 / 拖拽平移 / 复位 / Esc 关闭。 +- **代码高亮**:用 [highlight.js](https://highlightjs.com/) CDN(GitHub 浅色主题),自动识别 `python / json / bash`。 +- 离线情况下用任意本地 PlantUML 工具渲染 `site/diagrams/*.puml`(或 `docs/*.puml`)即可。 + +## 与 Playwright 测试 + +仓库 `tests/playwright/page.spec.ts` 会在 CI 里跑浏览器测试: + +- 打开页面、校验标题与章节锚点存在; +- 断言 PlantUML 块渲染出 ``(外网不通时用**软断言**降级,不强制红); +- 断言代码块出现 `.hljs` class; +- 断言放大浮层点击出现、Esc 关闭。 diff --git a/site/assets/plantuml.js b/site/assets/plantuml.js new file mode 100644 index 0000000..1bb2272 --- /dev/null +++ b/site/assets/plantuml.js @@ -0,0 +1,232 @@ +/* plantuml.js — in-browser PlantUML rendering via the official public server + * + * Strategy: + * 1. Read the diagram source from a `
` (inline) OR fetch it
+ *      from `src/data-source` if set (preferred, keeps the .puml as single source).
+ *   2. Encode the source with PlantUML's deflate+base64 algorithm
+ *      (`https://www.plantuml.com/plantuml/svg/`), using the browser's
+ *      built-in `CompressionStream("deflate-raw")` — zero npm deps.
+ *   3. Fetch the SVG and inline it into the host element; if fetch fails
+ *      (offline / network blocked), fall back to showing the raw source so
+ *      the page never goes blank.
+ *
+ * Transparency note: this requires outbound network to plantuml.com at view
+ * time. See site/README.md.
+ */
+
+const PLANTUML_BASE =
+  "https://www.plantuml.com/plantuml/svg/";
+
+// PlantUML's own base64 alphabet (different from the standard one).
+const PLANTUML_ALPHABET =
+  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+function encode64(data) {
+  let out = "";
+  for (let i = 0; i < data.length; i += 3) {
+    const b1 = data[i];
+    const b2 = i + 1 < data.length ? data[i + 1] : 0;
+    const b3 = i + 2 < data.length ? data[i + 2] : 0;
+    out += PLANTUML_ALPHABET[b1 >> 2];
+    out += PLANTUML_ALPHABET[((b1 & 0x3) << 4) | (b2 >> 4)];
+    out += i + 1 < data.length ? PLANTUML_ALPHABET[((b2 & 0xf) << 2) | (b3 >> 6)] : "_";
+    out += i + 2 < data.length ? PLANTUML_ALPHABET[b3 & 0x3f] : "_";
+  }
+  return out;
+}
+
+async function encodePlantUml(source) {
+  // PlantUML expects a "0x00 0x01 / deflate-raw" stream.
+  // Browsers ship `CompressionStream("deflate-raw")`; we transform a stream.
+  const enc = new TextEncoder();
+  const stream = new Blob([enc.encode(source)]).stream().pipeThrough(
+    new CompressionStream("deflate-raw"),
+  );
+  const buf = new Uint8Array(await new Response(stream).arrayBuffer());
+  return encode64(buf);
+}
+
+async function fetchText(url) {
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  return await res.text();
+}
+
+function attachZoom(host, svgText) {
+  // Build a clickable wrapper so users can pan/zoom the diagram ("无损放大").
+  host.classList.add("figure-ready");
+  host.innerHTML = svgText;
+  const svg = host.querySelector("svg");
+  if (svg) {
+    svg.removeAttribute("width");
+    svg.removeAttribute("height");
+    svg.style.maxWidth = "100%";
+    svg.style.height = "auto";
+  }
+  // Zoom indicator
+  if (!host.dataset.zoomBound) {
+    host.addEventListener("click", () => openZoom(host));
+    host.dataset.zoomBound = "1";
+  }
+}
+
+function showError(host, source, msg) {
+  host.classList.add("figure-error");
+  const path = host.dataset.source || "";
+  const hint =
+    `图表源文件: ${escapeHtml(path)}
` + + `原因: ${escapeHtml(msg || "无外网或 plantuml.com 不可达")}
` + + "可手动把源文件用本地 PlantUML 渲染,或在外网环境打开本页。"; + host.innerHTML = + `
⚠️ 图表未能在线渲染(点击查看原始 PlantUML 源码)` + + `
${escapeHtml(source)}
` + + `

${hint}

`; + if (host.dataset.source) { + // Pull the actual .puml content into the
 so the details block shows real source.
+    fetch(host.dataset.source)
+      .then((r) => r.ok ? r.text() : Promise.reject(r.status))
+      .then((text) => {
+        const code = host.querySelector("details pre code");
+        if (code) code.textContent = text;
+        if (window.hljs) window.hljs.highlightElement(code);
+      })
+      .catch(() => {
+        /* already showing the hint; nothing more to do */
+      });
+  }
+}
+
+function escapeHtml(s) {
+  return s
+    .replace(/&/g, "&")
+    .replace(//g, ">")
+    .replace(/"/g, """);
+}
+
+// Full-screen pan/zoom overlay.
+function openZoom(host) {
+  const svg = host.querySelector("svg");
+  if (!svg) return;
+  const overlay = document.createElement("div");
+  overlay.className = "zoom-overlay";
+  overlay.setAttribute("role", "dialog");
+  overlay.setAttribute("aria-modal", "true");
+
+  const close = document.createElement("button");
+  close.className = "zoom-close";
+  close.textContent = "✕ 关闭 (Esc)";
+  close.addEventListener("click", () => overlay.remove());
+
+  const wrap = document.createElement("div");
+  wrap.className = "zoom-stage";
+  wrap.innerHTML = svg.outerHTML;
+  const innerSvg = wrap.querySelector("svg");
+  innerSvg.removeAttribute("style");
+
+  const controls = document.createElement("div");
+  controls.className = "zoom-controls";
+  const scale = { v: 1.0 };
+  const pos = { x: 0, y: 0 };
+  const render = () => {
+    innerSvg.style.transform = `translate(${pos.x}px, ${pos.y}px) scale(${scale.v})`;
+  };
+  const addBtn = (label, fn) => {
+    const b = document.createElement("button");
+    b.textContent = label;
+    b.addEventListener("click", fn);
+    controls.appendChild(b);
+  };
+  addBtn("+ 放大", () => { scale.v = Math.min(8, scale.v * 1.25); render(); });
+  addBtn("- 缩小", () => { scale.v = Math.max(0.2, scale.v / 1.25); render(); });
+  addBtn("复位", () => { scale.v = 1; pos.x = 0; pos.y = 0; render(); });
+
+  // Drag to pan
+  let dragging = false;
+  let start = { x: 0, y: 0, ox: 0, oy: 0 };
+  wrap.addEventListener("pointerdown", (e) => {
+    dragging = true;
+    start = { x: e.clientX, y: e.clientY, ox: pos.x, oy: pos.y };
+    wrap.setPointerCapture(e.pointerId);
+  });
+  wrap.addEventListener("pointermove", (e) => {
+    if (!dragging) return;
+    pos.x = start.ox + (e.clientX - start.x);
+    pos.y = start.oy + (e.clientY - start.y);
+    render();
+  });
+  wrap.addEventListener("pointerup", () => { dragging = false; });
+  wrap.addEventListener("pointercancel", () => { dragging = false; });
+
+  // Wheel zoom (without consuming page scroll inside the overlay)
+  wrap.addEventListener(
+    "wheel",
+    (e) => {
+      e.preventDefault();
+      const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
+      scale.v = Math.min(8, Math.max(0.2, scale.v * factor));
+      render();
+    },
+    { passive: false },
+  );
+
+  overlay.addEventListener("keydown", () => {}); // keep focus
+  const onKey = (e) => {
+    if (e.key === "Escape") {
+      overlay.remove();
+      document.removeEventListener("keydown", onKey);
+    }
+  };
+  document.addEventListener("keydown", onKey);
+  overlay.addEventListener("click", (e) => {
+    if (e.target === overlay) {
+      overlay.remove();
+      document.removeEventListener("keydown", onKey);
+    }
+  });
+
+  overlay.appendChild(close);
+  overlay.appendChild(controls);
+  overlay.appendChild(wrap);
+  document.body.appendChild(overlay);
+  close.focus();
+}
+
+async function renderOne(host) {
+  // Prefer the external file (single source of truth) when present.
+  let source = "";
+  if (host.dataset.remote) source = host.textContent.trim();
+  if (host.dataset.source) {
+    try {
+      source = await fetchText(host.dataset.source);
+      host.dataset.remote = "ok";
+    } catch {
+      // Fall back to inline (set when fetched-from-cluster also fails later)
+      source = host.textContent.trim();
+      host.dataset.remote = "offline";
+    }
+  } else {
+    source = host.textContent.trim();
+  }
+  try {
+    const encoded = await encodePlantUml(source);
+    const url = PLANTUML_BASE + encoded;
+    const svg = await fetchText(url);
+    if (!svg || !svg.includes(" we just injected.
+  if (window.hljs) window.hljs.highlightAll();
+}
+
+window.McpSite = window.McpSite || {};
+window.McpSite.renderPlantUml = renderAll;
diff --git a/site/assets/style.css b/site/assets/style.css
new file mode 100644
index 0000000..8905df6
--- /dev/null
+++ b/site/assets/style.css
@@ -0,0 +1,255 @@
+/* mcp-tutorial site styles — lightweight, GitHub-light theme, responsive */
+:root {
+  --bg: #ffffff;
+  --bg-soft: #f8fafc;
+  --bg-code: #f6f8fa;
+  --border: #e2e8f0;
+  --border-strong: #cbd5e1;
+  --text: #0f172a;
+  --text-soft: #475569;
+  --text-muted: #64748b;
+  --accent: #2563eb;
+  --accent-soft: #dbeafe;
+  --maxw: 920px;
+  --sidebar-w: 248px;
+  --font-sans: "Segoe UI", "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
+  --font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
+}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+body {
+  margin: 0;
+  font-family: var(--font-sans);
+  color: var(--text);
+  background: var(--bg);
+  line-height: 1.65;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* Layout */
+.layout { display: flex; min-height: 100vh; }
+
+.sidebar {
+  position: sticky;
+  top: 0;
+  width: var(--sidebar-w);
+  height: 100vh;
+  overflow-y: auto;
+  border-right: 1px solid var(--border);
+  background: var(--bg-soft);
+  padding: 18px 16px;
+  flex-shrink: 0;
+}
+.sidebar .brand {
+  font-size: 1.05rem;
+  font-weight: 700;
+  margin: 0 0 4px;
+}
+.sidebar .brand-sub {
+  font-size: 0.78rem;
+  color: var(--text-muted);
+  margin: 0 0 18px;
+}
+.sidebar nav a {
+  display: block;
+  padding: 6px 10px;
+  border-radius: 6px;
+  color: var(--text-soft);
+  font-size: 0.92rem;
+}
+.sidebar nav a:hover { background: var(--accent-soft); color: var(--accent); text-decoration: none; }
+.sidebar nav a.sub { padding-left: 22px; font-size: 0.86rem; }
+
+.content { flex: 1; min-width: 0; padding: 36px 28px 80px; }
+.content > section { max-width: var(--maxw); margin: 0 auto 56px; scroll-margin-top: 20px; }
+.content h1 {
+  font-size: 1.8rem;
+  border-bottom: 2px solid var(--accent);
+  padding-bottom: 10px;
+  margin: 0 0 22px;
+}
+.content h2 {
+  font-size: 1.32rem;
+  margin: 32px 0 12px;
+  color: #1e3a8a;
+}
+.content h3 {
+  font-size: 1.08rem;
+  margin: 22px 0 10px;
+  color: #1d4ed8;
+}
+.content p, .content li { font-size: 0.98rem; }
+.content ul, .content ol { padding-left: 1.4em; }
+.content code:not(pre code) {
+  background: var(--bg-code);
+  border: 1px solid var(--border);
+  padding: 1px 5px;
+  border-radius: 4px;
+  font-family: var(--font-mono);
+  font-size: 0.86rem;
+}
+.content table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.9rem; }
+.content th, .content td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; }
+.content th { background: var(--bg-soft); }
+blockquote {
+  border-left: 4px solid var(--accent);
+  background: var(--accent-soft);
+  margin: 14px 0;
+  padding: 10px 14px;
+  color: #1e3a8a;
+  border-radius: 0 6px 6px 0;
+}
+
+/* Code blocks */
+figure.code, p + pre, .content > pre { margin: 14px 0; }
+.content pre {
+  background: var(--bg-code);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 12px 14px;
+  overflow-x: auto;
+  font-family: var(--font-mono);
+  font-size: 0.86rem;
+}
+.content pre code { background: transparent; padding: 0; border: 0; }
+
+/* PlantUML host (the 
) */
+pre.plantuml {
+  display: none;
+}
+.figure-wrap {
+  margin: 18px 0;
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  background: var(--bg-soft);
+  padding: 14px;
+  overflow: auto;
+  max-height: 460px;       /* tall diagrams scroll; zoom button shows full */
+  position: relative;
+}
+.figure-wrap.figure-ready svg { display: block; max-width: 100%; height: auto; margin: 0 auto; background: #fff; }
+.figure-wrap::after {
+  content: "🔍 点击图片可无损放大 (拖拽平移 / 滚轮缩放 / Esc 关闭)";
+  display: block;
+  text-align: center;
+  font-size: 0.78rem;
+  color: var(--text-muted);
+  margin-top: 8px;
+}
+.figure-wrap.figure-error {
+  border-color: #fca5a5;
+  background: #fef2f2;
+}
+.figure-error-hint { color: #991b1b; font-size: 0.82rem; margin: 6px 0 0; }
+.figure-wrap figure-figure-ready, .figure-wrap > svg { cursor: zoom-in; }
+
+/* Zoom overlay */
+.zoom-overlay {
+  position: fixed; inset: 0; z-index: 1000;
+  background: rgba(15, 23, 42, 0.86);
+  display: flex; flex-direction: column; align-items: center;
+  padding: 14px;
+}
+.zoom-close {
+  align-self: flex-end;
+  background: #fff; color: #0f172a;
+  border: 0; border-radius: 6px;
+  padding: 6px 12px; cursor: pointer;
+  font-size: 0.9rem;
+}
+.zoom-controls { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
+.zoom-controls button {
+  background: #fff; color: #0f172a;
+  border: 0; border-radius: 6px;
+  padding: 6px 12px; cursor: pointer; font-size: 0.9rem;
+}
+.zoom-controls button:hover { background: var(--accent-soft); }
+.zoom-stage {
+  flex: 1; min-height: 0; width: 100%;
+  overflow: auto;
+  background: #fff; border-radius: 8px;
+  cursor: grab;
+  display: flex; align-items: flex-start; justify-content: center;
+  padding: 10px;
+}
+.zoom-stage svg { max-width: none; width: auto; height: auto; transform-origin: center; cursor: grab; }
+.zoom-stage:active { cursor: grabbing; }
+
+/* Header banner */
+.hero {
+  background: linear-gradient(135deg, #eef2ff, #ecfeff 60%, #fff7ed);
+  border: 1px solid var(--border);
+  border-radius: 14px;
+  padding: 26px 28px;
+  margin-bottom: 30px;
+}
+.hero h1 {
+  border-bottom: 0;
+  margin: 0 0 8px;
+  font-size: 2rem;
+}
+.hero p { color: var(--text-soft); margin: 6px 0; }
+.hero .badges { margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
+.hero .badges span {
+  font-size: 0.78rem;
+  background: #fff;
+  border: 1px solid var(--border);
+  color: var(--text-soft);
+  padding: 3px 10px;
+  border-radius: 999px;
+}
+
+/* Mobile */
+@media (max-width: 820px) {
+  .layout { flex-direction: column; }
+  .sidebar {
+    position: static;
+    width: 100%;
+    height: auto;
+    border-right: 0;
+    border-bottom: 1px solid var(--border);
+  }
+  .content { padding: 24px 16px 60px; }
+}
+
+/* "thinking/doing/carrying" three-role callout */
+.three-role {
+  display: grid;
+  gap: 12px;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  margin: 18px 0;
+}
+.three-role .role-card {
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  padding: 14px;
+  background: var(--bg-soft);
+}
+.three-role .role-card h4 { margin: 0 0 4px; font-size: 1rem; }
+.three-role .role-card p { margin: 0; font-size: 0.88rem; color: var(--text-soft); }
+.three-role .role-think { border-left: 4px solid #8b5cf6; }
+.three-role .role-carry { border-left: 4px solid #06b6d4; }
+.three-role .role-do { border-left: 4px solid #f97316; }
+
+.tip {
+  border-left: 4px solid #22c55e;
+  background: #f0fdf4;
+  border-radius: 0 6px 6px 0;
+  padding: 10px 14px;
+  margin: 14px 0;
+  color: #064e3b;
+  font-size: 0.92rem;
+}
+.warn {
+  border-left: 4px solid #f59e0b;
+  background: #fefce8;
+  border-radius: 0 6px 6px 0;
+  padding: 10px 14px;
+  margin: 14px 0;
+  color: #713f12;
+  font-size: 0.92rem;
+}
diff --git a/site/diagrams/architecture.puml b/site/diagrams/architecture.puml
new file mode 100644
index 0000000..f33cba4
--- /dev/null
+++ b/site/diagrams/architecture.puml
@@ -0,0 +1,47 @@
+@startuml architecture
+' 三层架构:LLM 决策层 / MCP 编排层 / 工具执行层
+' 把整个项目“谁负责什么”画成一张静态类/组件图,便于 newbie 一眼对应到目录文件
+
+skinparam backgroundColor #ffffff
+skinparam componentStyle rectangle
+skinparam shadowing false
+skinparam roundCorner 8
+skinparam ArrowColor #475569
+skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif
+
+skinparam component {
+  BackgroundColor #f8fafc
+  BorderColor #94a3b8
+  FontColor #0f172a
+}
+
+title MCP Demo 三层架构  ArchitectureOverview
+
+actor "User\n用户" as U #f1f5f9
+
+package "决策层  LLM" #eef2ff {
+  component "OpenAI 兼容模型\n(qwen-plus@百炼)" as L #e0e7ff
+  note right of L : 只做“要不要调工具、用哪个、传什么参数”\n不直接执行工具
+}
+
+package "编排层  MCP Client  client/*" #ecfeff {
+  component "main.py\n对话主循环" as M #cffafe
+  component "client.llm\n工具发现 + LLM 循环" as CL #cffafe
+  component "client.runtime\n按 MCP_TRANSPORT 选连接方式" as CR #cffafe
+}
+
+package "执行层  MCP Server  server/*" #fff7ed {
+  component "server.app\n注册四个 @mcp.tool 计算器" as SA #fed7aa
+  component "server.runtime\n按协议启动(stdio/sse/http)" as SR #fed7aa
+}
+
+U --> M : 终端输入问题
+M --> CL : ask_with_llm(...)
+CL --> CR : create_mcp_client(config)
+CL --> L : chat.completions.create(tools)
+CL --> SA : tools/list  tools/call (JSON-RPC)
+SR --> SA : 托管工具
+SA --> CL : CallToolResult
+CL --> L : 回填 tool 结果 \n 产出最终回答
+M --> U : 打印助手回复
+@enduml
diff --git a/site/diagrams/call_sequence.puml b/site/diagrams/call_sequence.puml
new file mode 100644
index 0000000..0219abc
--- /dev/null
+++ b/site/diagrams/call_sequence.puml
@@ -0,0 +1,55 @@
+@startuml call_sequence
+' 一次“帮我计算 1+2”的完整调用时序,覆盖握手/发现/决策/调用/回填/最终输出
+' 比 mermaid 版多了 protocolVersion、notifications/initialized 等协议细节
+
+skinparam backgroundColor #ffffff
+skinparam shadowing false
+skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif
+skinparam sequence {
+  ArrowColor #334155
+  ActorBorderColor #94a3b8
+  ActorBackgroundColor #f8fafc
+  ParticipantBorderColor #94a3b8
+  LifeLineBorderColor #cbd5e1
+  ParticipantBackgroundColor #ffffff
+}
+
+title 一次 MCP 工具调用的完整时序  Sequence: 1+2
+
+actor "User 用户" as U
+participant "main.py" as Main
+participant "MCP Client\n(client/*)" as C
+participant "MCP Server\n(server/*)" as S
+participant "LLM\n(qwen-plus)" as L
+
+== Phase 1  建立会话并握手 ==
+U -> Main : 输入 "帮我算 1+2"
+Main -> C : create_mcp_client(config)\nasync with client:
+C -> S : initialize{protocolVersion,clientInfo}
+S --> C : result{protocolVersion,\ncapabilities,serverInfo}
+C -> S : notifications/initialized
+
+== Phase 2  发现工具 ==
+C -> S : tools/list
+S --> C : 4 个工具 (add/subtract/multiply/divide)\n含 name/title/description/inputSchema
+
+== Phase 3  LLM 决策选工具 ==
+C -> L : messages(用户问题) + tools(schema)
+L --> C : tool_calls:\ncalculator_add(a=1,b=2)
+
+== Phase 4  客户端执行工具 ==
+C -> S : tools/call(name=calculator_add,\narguments={a:1,b:2})
+S --> C : CallToolResult\ncontent=[{type:text,text:"3"}]
+
+== Phase 5  回填结果并生成最终答案 ==
+C -> L : 追加 tool 消息(result=3)
+L --> C : "1 + 2 = 3"
+C --> Main : 最终文本
+Main --> U : 打印 "助手: 1 + 2 = 3"
+
+note over C,S #fff7ed
+  四类核心 JSON-RPC 方法:
+  initialize / notifications/initialized
+  tools/list / tools/call
+end note
+@enduml
diff --git a/site/diagrams/mcp_vs_function_calling.puml b/site/diagrams/mcp_vs_function_calling.puml
new file mode 100644
index 0000000..e3ae75e
--- /dev/null
+++ b/site/diagrams/mcp_vs_function_calling.puml
@@ -0,0 +1,36 @@
+@startuml mcp_vs_function_calling
+' MCP vs Function Calling 的职责边界对比
+' 不是二选一,而是“决策层 vs 通信层”
+
+skinparam backgroundColor #ffffff
+skinparam shadowing false
+skinparam roundCorner 8
+skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif
+skinparam ArrowColor #475569
+
+title MCP vs Function Calling  职责对比
+
+rectangle "Function Calling\n(模型 API 能力)" as FC #eef2ff {
+  card "把函数 schema 喂给模型\n模型决定要不要调" as FC1 #e0e7ff
+  card "你负责把结果回填给模型\n拼接是手写的" as FC2 #e0e7ff
+  note bottom of FC : 依赖具体厂商 API\n跨工具/跨厂商时各写各的
+}
+
+rectangle "MCP\n(工具通信协议层)" as MCP #ecfeff {
+  card "tools/list 发现工具\ntools/call 执行工具\n统一 JSON-RPC" as M1 #cffafe
+  card "传输可选 stdio/sse/http\n工具实现可以是任意语言/进程" as M2 #cffafe
+  note bottom of MCP : 厂商/工具解耦\n模型端只对接 MCP client
+}
+
+(模型\n会不会调) -[hidden]-> FC
+FC --> MCP : 决策出来后\n走 MCP 调用层执行
+
+note as N1 #fff7ed
+  本 demo 的实际配合:
+  LLM 用 Function 决策 -> 客户端按 MCP 执行 -> 工具产出结果
+  两者不是二选一,而是“决策” + “传输/执行”的分工
+end note
+
+FC .. N1
+MCP .. N1
+@enduml
diff --git a/site/diagrams/process_flow.puml b/site/diagrams/process_flow.puml
new file mode 100644
index 0000000..bb93e5b
--- /dev/null
+++ b/site/diagrams/process_flow.puml
@@ -0,0 +1,49 @@
+@startuml process_flow
+' 客户端主流程活动图:含三种传输分支与 LLM<->MCP 循环
+' 对应 main.py + ask_with_llm 的真实控制流
+
+skinparam backgroundColor #ffffff
+skinparam shadowing false
+skinparam roundCorner 10
+skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif
+skinparam ActivityBackgroundColor #f8fafc
+skinparam ActivityBorderColor #94a3b8
+skinparam ActivityDiamondBackgroundColor #fef9c3
+skinparam ActivityDiamondBorderColor #ca8a04
+skinparam ArrowColor #475569
+
+title 客户端主流程  ProcessFlow
+
+start
+
+:[1] uv run python main.py;
+:[2] AppConfig.from_env() 读取 .env;
+if ([3] MCP_TRANSPORT?) then (stdio)
+  :子进程方式拉起 server/stdio.py;
+elseif (sse) then (sse)
+  :连 http://host:port/sse;
+else (streamable_http)
+  :连 http://host:port/mcp;
+endif
+
+:[4] async with mcp_client  建立会话+握手;
+repeat
+  :[5] input() 读用户输入;
+  if ([6] 输入 = exit ?) then (yes)
+    break 结束程序
+  else (no)
+    :[7] ask_with_llm();
+    :[8] mcp_client.list_tools()\n发现工具;
+    :[9] 把 MCP 工具转成 OpenAI tools;
+    repeat :[10] 调 LLM 一次\n(asyncio.to_thread);
+      if ([11] 返回了 tool_calls?) then (yes)
+        :[12] 逐个 mcp_client.call_tool()\n服务端执行 add/sub/mul/div;
+        :[13] 把 tool 结果回填进 messages;
+      else (no)
+        :[14] 输出最终自然语言回答;
+      endif
+    repeat while (返回了 tool_calls ?)
+  endif
+repeat
+stop
+@enduml
diff --git a/site/diagrams/transport_comparison.puml b/site/diagrams/transport_comparison.puml
new file mode 100644
index 0000000..1e12513
--- /dev/null
+++ b/site/diagrams/transport_comparison.puml
@@ -0,0 +1,43 @@
+@startuml transport_comparison
+' 三种传输方式的连接结构对比(并列三泳道)
+' 只画“怎么连”,不画协议方法 —— 协议方法三种一致,不重复
+
+skinparam backgroundColor #ffffff
+skinparam shadowing false
+skinparam roundCorner 8
+skinparam DefaultFontName "Segoe UI","Microsoft YaHei","Helvetica",sans-serif
+skinparam ArrowColor #475569
+
+title 三种传输:连接方式对比  TransportComparison
+
+skinparam swimlane {
+  BorderColor #cbd5e1
+  TitleFontColor #0f172a
+}
+
+|stdio|
+skinparam component {BackgroundColor #e0f2fe BorderColor #0284c7}
+component "main.py + MCP Client" as StC #e0f2fe
+component "server/stdio.py\n(子进程)" as StS #bae6fd
+StC -> StS : 标准输入 (stdin)
+StS -> StC : 标准输出 (stdout)
+note bottom of StS : 单进程命令即跑\nuv run python main.py
+
+|sse (HTTP+SSE)|
+skinparam component {BackgroundColor #ecfeff BorderColor #0e7490}
+component "MCP Server\nserver.sse (长驻)" as SeS #cffafe
+component "main.py + MCP Client\n(连 /sse)" as SeC #a5f3fc
+cloud "HTTP 通道\nServer-Sent Events" as SeN #e0f7fa
+SeC --> SeN : HTTP 长连接
+SeN --> SeS : 单向事件流
+SeC <-- SeS : SSE 推送 / JSON-RPC 应答
+note bottom of SeS : 终端 A 跑 server, 终端 B 跑 client\n适合远程/局域网
+
+|streamable_http|
+skinparam component {BackgroundColor #f3e8ff BorderColor #7e22ce}
+component "MCP Server\nserver.streamable_http (长驻)" as HtS #e9d5ff
+component "main.py + MCP Client\n(连 /mcp)" as HtC #d8b4fe
+HtC <-> HtS : HTTP 请求/响应\n(streamable)
+note bottom of HtS : 标准 HTTP 端点\n最适合 Web 化/多客户端部署
+
+@enduml
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..392066f
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,413 @@
+
+
+
+
+
+MCP 教学站点 · mcp-tutorial
+
+
+
+
+
+
+
+
+
+ + + + + +
+ +
+

MCP 教学站点

+

Model Context Protocol,零基础可视化讲解:从一个“1+2”问题出发,看 LLM 怎么想、MCP 怎么传、工具怎么做。

+
+ 📦 仓库: Albert-PZY/mcp-tutorial + 🐍 Python · FastMCP + 🌐 PlantUML 在线渲染 +
+
+ + +
+

1. MCP 是什么

+ +

MCP(Model Context Protocol)可以理解为:给大模型与外部工具之间定义了一套“统一插座标准”。

+

以前每接一个工具常常要写不同的适配代码;有了 MCP,模型端和工具端只要都遵循这个协议,就能统一方式通信。

+
一句话定位:MCP = 让模型更标准地“看见并调用”外部能力(工具 / 资源 / 提示模板)的协议层。
+ +
+
+

🧠 LLM 想

+

判断要不要调工具、用哪个、传什么参数。不亲自执行。

+
+
+

🚚 MCP 传

+

按 JSON-RPC 标准把“决策 <-> 执行”串起来,跨工具/传输通用。

+
+
+

🛠️ Tool 做

+

真正实现功能并返回结果。可以是任意语言/任意进程。

+
+
+ +

1.1 三层架构

+

本项目把这套心智模型落地成代码层:决策层(LLM) / 编排层(MCP Client) / 执行层(MCP Server)

+ +
+

+      
+
+ + +
+

2. 源码逻辑实现

+ +

下面按文件路径顺序讲,每个文件都配关键代码。完整代码见仓库内文件本身。

+ +

2.1 入口与配置(main.py / config.py)

+

main.py:交互主循环

+

它读用户输入 → 让 ask_with_llm 走一遍 → 打印回复。一段 async with mcp_client 进会话,等同同时完成 MCP 握手。

+
from __future__ import annotations
+import asyncio
+
+from client.llm import ask_with_llm, create_openai_client
+from client.runtime import create_mcp_client
+from config import AppConfig
+
+
+async def run_chat(config: AppConfig) -> None:
+    llm_client = create_openai_client(config)
+    mcp_client = create_mcp_client(config)
+    async with mcp_client as client:           # 进入会话 = initialize + initialized
+        print("输入问题开始对话,输入 exit 退出。")
+        while True:
+            user_prompt = input("你:").strip()
+            if user_prompt == "exit":
+                return
+            answer = await ask_with_llm(client, llm_client, config, user_prompt)
+            print(f"助手:{answer}")
+
+
+def main() -> None:
+    asyncio.run(run_chat(AppConfig.from_env()))
+
+ +

config.py:统一配置 schema

+

把所有可配置项放在一个 pydantic 模型里,统一从 .env 读取,避免散落的 os.getenv

+
TransportType = Literal["stdio", "sse", "streamable_http"]
+
+class AppConfig(BaseModel):
+    openai_api_key: str
+    openai_base_url: str
+    openai_model: str
+    mcp_transport: TransportType      # 三选一
+    mcp_host: str
+    mcp_port: int
+    mcp_sse_path: str
+    mcp_streamable_path: str
+    llm_max_tool_rounds: int           # 防止无线循环
+
+    @classmethod
+    def from_env(cls) -> "AppConfig":
+        load_dotenv()
+        return cls(
+            openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+            openai_model=os.getenv("OPENAI_MODEL", "qwen-plus"),
+            mcp_transport=os.getenv("MCP_TRANSPORT", "stdio"),
+            ...
+        )
+ +

2.2 客户端 client/*

+ +

client/runtime.py:按 MCP_TRANSPORT 选连接方式

+

协议方法三种一致,差别只在“怎么连”。stdio 走子进程;sse / http 走网络 URL。

+
def create_mcp_client(config: AppConfig) -> Client:
+    if config.mcp_transport == "stdio":
+        return create_stdio_client()                       # 子进程 + stdin/stdout
+    if config.mcp_transport == "sse":
+        return create_sse_client(config)                    # http://host:port/sse
+    return create_streamable_http_client(config)           # http://host:port/mcp
+
stdio 子进程要能 import config / server.app,所以客户端把项目根塞进 PYTHONPATH 再拉起子进程。
+ +

client/llm.py:工具发现 + LLM 工具调用循环

+

这是教学价值最高的文件。逻辑分四步:(1) list_tools 发现工具 → (2) 转成 OpenAI tools → (3) 调 LLM 决策 → (4) LLM 要调就执行 MCP 工具并回填结果。

+
async def ask_with_llm(mcp_client, llm_client, config, user_prompt) -> str:
+    openai_tools = to_openai_tools(await mcp_client.list_tools())
+    messages = [
+        {"role": "system", "content": "你是教学演示助手..."},
+        {"role": "user", "content": user_prompt},
+    ]
+    for _ in range(config.llm_max_tool_rounds):
+        completion = await asyncio.to_thread(
+            llm_client.chat.completions.create,        # 阻塞 SDK -> 放进线程
+            model=config.openai_model,
+            messages=messages, tools=openai_tools,
+            tool_choice="auto", temperature=0,
+        )
+        message = completion.choices[0].message
+        tool_calls = message.tool_calls or []
+        if not tool_calls:
+            return (message.content or "").strip()      # 不调工具 = 最终答案
+        messages.append(_assistant_message_with_calls(message))
+        for call in tool_calls:
+            args = json.loads(call.function.arguments or "{}")
+            result = await mcp_client.call_tool(call.function.name, args)
+            messages.append(_tool_result_message(call, result))
+    return ""
+
为什么要 asyncio.to_thread:openai 官方 SDK 是同步阻塞的,直接调用会卡住事件循环,下一步的 await mcp_client.call_tool 不能并发,所以必须把它丢到工作线程里跑。
+ +

其中两个小工具函数把对话历史拼装明了化(行为零变化,只是可读性):

+
def _assistant_message_with_calls(message) -> dict:
+    """把 LLM 的 tool 连同它的 tool_calls 一起打包成 assistant 消息。"""
+    return {"role": "assistant", "content": message.content or "",
+            "tool_calls": [{"id": c.id, "type": "function",
+                            "function": {"name": c.function.name,
+                                         "arguments": c.function.arguments}}
+                           for c in (message.tool_calls or [])]}
+
+def _tool_result_message(call, result) -> dict:
+    """把一次工具调用结果回填成 role=tool 的消息,供 LLM 二次推理。"""
+    return {"role": "tool", "tool_call_id": call.id,
+            "name": call.function.name,
+            "content": json.dumps({"result": _tool_result_text(result)},
+                                  ensure_ascii=False)}
+ +

2.3 服务端 server/*

+ +

server/app.py:注册 4 个计算器工具

+

@mcp.tool 把普通 Python 函数登记成 MCP 工具。FastMCP 会读类型注解自动生成 inputSchema,所以完全不用手写 JSON Schema。

+
def create_mcp_server() -> FastMCP:
+    mcp = FastMCP("Test Server")
+
+    @mcp.tool(name="calculator_add", title="Calculator Add",
+              description="Add two numbers")
+    def add(a: int, b: int) -> int:
+        return a + b
+    # ... subtract / multiply / divide 同款登记
+    # divide 里 b == 0 时抛 ValueError,让调用方看到非法状态
+    return mcp
+ +

server/runtime.py:按协议启动

+
def run_server_by_transport(config: AppConfig) -> None:
+    if config.mcp_transport == "stdio":           run_server_stdio(config)
+    elif config.mcp_transport == "sse":           run_server_sse(config)
+    else:                                         run_server_streamable_http(config)
+ +

2.4 主流程活动图

+

把“客户端”一整条主线画成活动图,包含三传输分支与 LLM↔MCP 循环。

+ +
+

+      
+
+ + +
+

3. 完整调用流程

+ +

以“帮我算 1+2”这一句话为线索,把整条调用走一遍。本次涉及 4 个角色:用户 / 客户端 / 服务端 / LLM。

+ +

3.1 时序图

+
+

+      
+

分五个阶段:握手 → 发现 → 决策 → 执行 → 回填输出。下面是每段对应的真实 JSON-RPC 报文。

+ +

3.2 真实 JSON-RPC 报文(stdio 抓包)

+ +

① initialize — 握手请求

+
{
+  "jsonrpc": "2.0",
+  "id": 0,
+  "method": "initialize",
+  "params": {
+    "protocolVersion": "2025-11-25",
+    "capabilities": {},
+    "clientInfo": { "name": "mcp", "version": "0.1.0" }
+  }
+}
+

② initialize — 握手响应

+
{
+  "jsonrpc": "2.0",
+  "id": 0,
+  "result": {
+    "protocolVersion": "2025-11-25",
+    "capabilities": {
+      "prompts": { "listChanged": false },
+      "resources": { "subscribe": false, "listChanged": false },
+      "tools": { "listChanged": true }
+    },
+    "serverInfo": { "name": "Test Server", "version": "3.1.1" }
+  }
+}
+

③ notifications/initialized — 通知

+
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
+ +

④ tools/list — 工具发现

+
{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "method": "tools/list"
+}
+
{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "result": {
+    "tools": [{
+      "name": "calculator_add",
+      "title": "Calculator Add",
+      "description": "Add two numbers",
+      "inputSchema": {
+        "type": "object",
+        "properties": { "a": { "type": "integer" }, "b": { "type": "integer" } },
+        "required": ["a", "b"]
+      }
+    }]
+  }
+}
+ +

⑤ tools/call — 执行工具

+
{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "method": "tools/call",
+  "params": {
+    "name": "calculator_add",
+    "arguments": { "a": 1, "b": 2 }
+  }
+}
+
{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "result": {
+    "content": [{ "type": "text", "text": "3" }],
+    "structuredContent": { "result": 3 },
+    "isError": false
+  }
+}
+
记忆口诀:先握手 → 再发现 → 再调用 → 再回填
+
+ + +
+

4. 通用应用场景

+ +

本 demo 是“玩具版”,但 MCP 设计面向的是这些通用场景:

+ +

4.1 场景一:本地开发 / 演示

+

客户端直接把服务端当子进程拉起,用 stdio 通信。零部署、最方便调试。适合 IDE 插件、本地 Agent、教学。

+ +

4.2 场景二:远程 MCP 服务

+

工具逻辑跑在一个长期运行的进程(sse / streamable_http),多个客户端通过网络共享同一份工具实现。适合团队共用、跨机器、跨账号体系。

+ +

4.3 场景三:企业内部“工具总线”

+

把不同业务系统(数据库查询、工单、监控、CRM…)按 MCP 统一暴露成工具,聊天模型只对接 MCP 客户端,就能一站式调用全公司能力。

+ +

4.4 场景四:Agent 生态集成

+

第三方 Agent / IDE / 框架只要支持 MCP 客户端,就能“插上”你提供的工具,无需关心你的实现语言与运行环境。

+ +

4.5 MCP vs Function Calling

+

很多人以为两者是二选一,其实是分工

+
    +
  • Function Calling:模型 API 内部能力,决定“要不要调、调哪个、传什么参数”。依赖具体厂商。
  • +
  • MCP:模型与工具之间的通信协议层,标准化 tools/list / tools/call,传输可选 stdio/sse/http。
  • +
+

本 demo 的实际配合:LLM 用 Function Calling 做决策 → 客户端按 MCP 执行工具 → 回填结果

+ +
+

+      
+
+ + +
+

5. 三种传输对比

+ +

MCP 协议本身在三种传输下完全一样,只有“字节怎么从客户端送到服务端”不同

+ + + + + + + + + +
维度stdiossestreamable_http
连接方式本地进程管道HTTP + Server-Sent EventsHTTP 请求/响应
启动复杂度最低(单命令)中等(服务端+客户端)中等(服务端+客户端)
典型场景本地开发 / 演示局域网 / 远程服务Web 化部署
调试体验最直接需看网络连通需看网络连通
+ +
+

+      
+ +

选型建议

+
    +
  • 想最快跑通 → 用 stdio
  • +
  • 想模拟“客户端连远程服务” → 用 ssestreamable_http
  • +
  • 多人部署 / Web 化 → 优先 HTTP 形态(sse / streamable_http)。
  • +
+ +

5.1 怎么在本机跑

+

① stdio(最简单)

+
uv sync
+uv run python main.py
+# 输入:帮我算 1+2
+# 输出:助手: 1 + 2 = 3
+

② sse(双终端)

+
# .env: MCP_TRANSPORT=sse
+
+# 终端 A(服务端)
+uv run python -m server.sse
+
+# 终端 B(客户端)
+uv run python main.py
+

③ streamable_http(双终端)

+
# .env: MCP_TRANSPORT=streamable_http
+
+# 终端 A
+uv run python -m server.streamable_http
+
+# 终端 B
+uv run python main.py
+ +
PlantUML 图在浏览器里渲染依赖外网访问 plantuml.com;离线环境会退化为显示源码,仍可阅读(图本身可用任意 PlantUML 工具本地渲染,见 docs/*.puml)。
+
+ +
+
+ + + + diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..968c768 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +.playwright/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..3cd35eb --- /dev/null +++ b/tests/README.md @@ -0,0 +1,16 @@ +# Playwright 浏览器自测 (site/) + +CI 会跑这些用例验证教学站点: +- 标题、章节锚点、代码高亮 +- PlantUML 在线渲染(外网不通时**软断言**不红) +- 失败时回退到源码展示,非空白 +- 图的“无损放大”浮层交互 + +## 本地跑 + +```bash +cd tests/ +npm install +npx playwright install --with-deps chromium +npm test +``` diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..b0c8fe8 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,14 @@ +{ + "name": "mcp-tutorial-site-tests", + "version": "0.0.0", + "private": true, + "description": "Playwright browser self-tests for the mcp-tutorial site/ tutorial page.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 0000000..aa69056 --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Playwright config for the mcp-tutorial static site. +// Run from the `tests/` directory (`cd tests && npx playwright test`), so +// ../site points to the real site folder we serve. + +const PORT = 8080; +const BASE = `http://localhost:${PORT}/`; + +export default defineConfig({ + testDir: './playwright', + timeout: 60_000, + expect: { timeout: 10_000 }, + retries: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + baseURL: BASE, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: `npx http-server ../site -p ${PORT} -c-1 --silent`, + cwd: __dirname, + url: BASE, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/tests/playwright/page.spec.ts b/tests/playwright/page.spec.ts new file mode 100644 index 0000000..f36e922 --- /dev/null +++ b/tests/playwright/page.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '@playwright/test'; + +/* + * mcp-tutorial site/ Playwright self-tests + * + * Covers: page opens, anchor nav exists, code blocks are highlight.js-rendered, + * PlantUML diagrams render to (soft-asserted against plantuml.com), + * and the zoom overlay opens/closes correctly. + * + * Note on flakiness: PlantUML rendering requires outbound network to + * plantuml.com. In restricted CI runners that can be slow/unstable, so the + * diagram glob is soft-asserted (warn, not fail). The page itself never + * crashes whether or not plantuml.com is reachable. + */ + +const SECTIONS = [ + '#intro', + '#source', + '#flow', + '#scenarios', + '#transports', +]; + +test.describe('MCP tutorial site', () => { + test('page opens with correct title and hero', async ({ page }) => { + await page.goto('/index.html'); + await expect(page).toHaveTitle(/MCP/); + await expect(page.locator('h1').first()).toContainText('MCP'); + await expect(page.locator('.hero')).toBeVisible(); + }); + + test('sidebar anchors point at every section', async ({ page }) => { + await page.goto('/index.html'); + const links = page.locator('.sidebar nav a'); + const hrefs = await links.evaluateAll((els) => + els.map((e) => (e as HTMLAnchorElement).getAttribute('href') || ''), + ); + for (const s of SECTIONS) { + expect(hrefs).toContain(s); + } + }); + + test('clicking a sidebar link navigates to a section', async ({ page }) => { + await page.goto('/index.html'); + await page.locator('.sidebar nav a[href="#flow"]').click(); + await expect(page.locator('#flow')).toBeVisible(); + }); + + test('code blocks are syntax-highlighted by highlight.js', async ({ page }) => { + test.setTimeout(45_000); + await page.goto('/index.html'); + // Give the highlight.js CDN enough time to load. (arg must be undefined, + // options go in the 3rd arg slot of page.waitForFunction.) + let hasHljs = false; + try { + await page.waitForFunction(() => (window as any).hljs != null, undefined, { timeout: 12_000 }); + hasHljs = true; + } catch { + hasHljs = false; + } + test.skip(!hasHljs, 'highlight.js CDN unreachable in this runner; skipping'); + // At least one highlighted block should exist after page load. + const hl = page.locator('pre code.hljs'); + await expect(hl.first()).toBeVisible(); + const count = await hl.count(); + expect(count).toBeGreaterThan(5); + }); + + test('PlantUML figures render to inline SVG when plantuml.com is reachable', async ({ page }) => { + await page.goto('/index.html'); + // Give the page generous time to fetch from plantuml.com. + const figures = page.locator('figure.figure-wrap'); + const count = await figures.count(); + expect(count).toBe(5); // architecture, process_flow, call_sequence, mcp_vs_fc, transport_comparison + + // Soft assert: try to wait for the first SVG to appear within a reasonable window. + // If plantuml.com is unreachable, the test should warn rather than fail. + const first = figures.first(); + let gotSvg = false; + try { + await expect(first.locator('svg')).toBeVisible({ timeout: 30_000 }); + gotSvg = true; + } catch { + gotSvg = false; + } + // Always log via expect; only fail when on a CI label that explicitly demands it. + test.info().annotations.push({ type: 'plantuml-svg', description: String(gotSvg) }); + // Soft: we do NOT hard-fail on missing svg to avoid network flakiness in CI. + if (!gotSvg) { + console.warn('[soft] PlantUML SVG did not appear within timeout; plantuml.com may be unreachable from this runner.'); + } + }); + + test('failed PlantUML figures fall back to source listing, not blank', async ({ page, browserName }) => { + // This test documents the offline-fallback path. It passes regardless of + // whether plantuml.com is reachable: either an is present (success), + // OR a
with raw plantuml and a .figure-error-hint is present (fallback). + await page.goto('/index.html'); + await page.waitForTimeout(2_000); // allow render attempt + const figs = page.locator('figure.figure-wrap'); + const n = await figs.count(); + for (let i = 0; i < n; i++) { + const fig = figs.nth(i); + const hasSvg = await fig.locator('svg').count(); + const hasErr = await fig.locator('.figure-error-hint').count(); + // At least one of the two outcomes must be true for each figure. + expect(hasSvg + hasErr).toBeGreaterThan(0); + } + }); + + test('clicking a rendered figure opens the zoom overlay; Esc closes it', async ({ page }) => { + await page.goto('/index.html'); + // Wait for at least one figure to be ready (svg present). + const ready = page.locator('figure.figure-ready').first(); + let gotReady = false; + try { + await expect(ready).toBeVisible({ timeout: 30_000 }); + gotReady = true; + } catch { + gotReady = false; + } + if (!gotReady) { + // plantuml.com was unreachable from this runner; the offline path is covered + // by the fallback test below, so gracefully skip the interactive part. + test.skip(true, 'No figure rendered to SVG (plantuml.com unreachable); skipping zoom test'); + } + await ready.click(); + const overlay = page.locator('.zoom-overlay'); + await expect(overlay).toBeVisible(); + await expect(overlay.locator('.zoom-close')).toBeVisible(); + // Esc closes + await page.keyboard.press('Escape'); + await expect(overlay).toHaveCount(0, { timeout: 3_000 }); + }); +});