dvaknheo / duckmcp
An MCP (Model Context Protocol) server example built on the DuckPHP framework
Requires
- php: >=7.4.0
- phpstan/phpdoc-parser: ^2.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 07:16:40 UTC
README
一个可运行的 MCP(Model Context Protocol) 服务器示例,
框架是 dvaknheo/duckphp(工作副本在 E:\ProjectGoat\DNMVCS)。
它不做玩具:同一个 DuckMcpExt 同时被两种传输驱动,13 个工具全部遵守 DuckPHP 的四层规范,
JSON Schema 从 PHP 签名和 docblock 自动生成,96 条冒烟测试全绿。
MCP 客户端(Claude Desktop / Claude Code / MCP Inspector)
│
├── stdio(换行分隔的 JSON-RPC)──► bin/mcp.php stdio
│
└── Streamable HTTP ─────────────► POST /mcp ──► public/index.php
│
┌────────────────────────┴────────────────────────┐
│ DuckMcp\Ext │
│ DuckMcpExt(入口:钩子 + 命令 + 传输) │
│ McpProtocol(协议核心:方法表 + 信封) │
│ McpToolLister(tools/list:发现 + 分页) │
│ McpToolInvoker(tools/call:解析 + 调用) │
└────────────────────────┬────────────────────────┘
│ 四层规范
Controller(*Mcp) → Business → Service/Model → SQLite
1. 三条命令跑起来
先跑一次 composer install(装 phpstan/phpdoc-parser)。框架不进 vendor:composer.json
的 autoload.psr-4 把 DuckPhp\ 直接映射到 ../DNMVCS/src 这个工作副本,所以改框架源码
立刻生效;两个入口只 require vendor/autoload.php,没有第二套 autoloader。
# 1) 看看这个服务器有哪些工具(不启动 MCP) php bin/mcp.php tools php bin/mcp.php tools --full # 连 inputSchema / outputSchema 一起打印 # 2) 用 stdio 跑(这就是 MCP 客户端要 spawn 的进程) php bin/mcp.php stdio # 3) 用 HTTP 跑(Streamable HTTP 传输,端点是 /mcp) php -S 127.0.0.1:8099 -t public curl -s -X POST http://127.0.0.1:8099/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
冒烟测试(不需要 PHPUnit):
php tests/smoke.php # 协议 + 工具 + 两种传输,126 项断言
2. 接到 MCP 客户端
| 客户端 | 配置文件 | 模板 |
|---|---|---|
| Claude Desktop | %APPDATA%\Claude\claude_desktop_config.json |
examples/claude_desktop_config.json |
| Claude Code | 项目里的 .mcp.json |
examples/claude_code.mcp.json |
| 任意 HTTP 客户端 | —— | examples/mcp_http_config.json |
{
"mcpServers": {
"duckmcp": {
"command": "C:\\APP\\php-7.4\\php.exe",
"args": ["E:\\ProjectGoat\\duckmcp\\bin\\mcp.php", "stdio"]
}
}
}
调试时把
--verbose加进args:握手和每次tools/call会写到 STDERR (STDOUT 永远只放协议帧,混进去一个字符客户端就解析失败)。
想手动点一遍工具列表,用官方 Inspector(需要 Node):
npx @modelcontextprotocol/inspector php E:\ProjectGoat\duckmcp\bin\mcp.php stdio
3. 目录结构(沿用 DuckPHP 的四层规范)
duckmcp/
├── bin/mcp.php # stdio 入口(客户端 spawn 的就是它)
├── public/index.php # web 入口(服务 /mcp)
├── config/DuckPhpSettings.config.php# 敏感/环境配置(数据库、调试开关)
├── tests/smoke.php # 冒烟测试,无第三方依赖
├── examples/ # MCP 客户端配置模板
├── runtime/ # 可写:SQLite、事件日志
├── src/
│ ├── Ext/ # ★ 一个 DuckPHP 扩展:协议核心 + 两种传输 + 入口
│ │ ├── DuckMcpExt.php # ★ 入口:选项、路由钩子、CLI 命令、runStdio()/runHttp()
│ │ │ # 残留 4 个同文件 trait(_Runtime/_Stdio/_Http/_Session)
│ │ ├── McpProtocol.php # ★ 协议核心:固定方法表、信封、身份、资源、会话状态
│ │ ├── McpLogger.php # MCP 日志文件(继承 DuckPhp\Core\Logger),可整体替换
│ │ ├── McpToolLocator.php # 按约定找工具类(namespace + *Mcp 后缀 → 目录 → 类名)
│ │ ├── McpToolLister.php # 工具表:发现、缓存、分页(tools/list)
│ │ ├── McpToolInvoker.php # 名字 → 方法:四道门 + 校验 + 调用(tools/call,无缓存)
│ │ ├── McpToolNaming.php # 名字 ↔ 类名的双向规则(只有这一处)
│ │ ├── McpTypeSchema.php # 纯函数:docblock 与 PHP 类型 → JSON Schema
│ │ ├── McpToolDescriptor.php # 单个工具:元数据 + 参数校验 + fromMethod()(无反射)
│ │ ├── McpToolParameter.php # 单个参数 → JSON Schema 片段
│ │ ├── McpToolResult.php # tools/call 的返回信封
│ │ └── McpException.php # JSON-RPC 错误码
│ ├── System/App.php # ★ 装配处:选项、事件、建表(扩展只登记一行)
│ ├── Controller/
│ │ ├── EventAction.php # 事件监听器(下单后写 runtime/mcp-events.log)
│ │ └── ExceptionAction.php # 异常上报到 STDERR
│ ├── Mcp/ # ★ 示例工具(Controller 层)
│ │ ├── MiscMcp.php # Misc/*:serverInfo / echo / add / now / route
│ │ ├── OrderMcp.php # Order/*:productList / productQuote / stockOverview / placeOrder(走 Business)
│ │ └── NoteMcp.php # Note/*:noteList / noteAdd / noteDelete / dbSummary(走 Business → SQLite)
│ ├── Business/ # 业务规则,没有请求上下文
│ │ ├── Base.php / Service.php
│ │ ├── NoteBusiness.php # 笔记用例
│ │ ├── CheckoutBusiness.php # 下单规则 + 触发事件
│ │ └── CatalogService.php # Business 层复用的 Service
│ └── Model/ # 唯一碰数据库的层
│ ├── Base.php
│ ├── NoteModel.php # note 表(SQLite),含建表
│ └── ProductModel.php # 演示商品表
└── view/ # (本示例不需要视图)
src/Ext/ 是一个完整的 DuckPHP 扩展包(DuckMcpExt 是入口,其余是它带的实现)。
接进一个应用只要一行:
'ext' => [DuckMcpExt::class => true], // 路由钩子和 CLI 命令都由它自己注册
将来抽成 composer 包时把这个目录整个拿走即可,业务层不用动(工具按 *Mcp 约定被发现)。
4. 加一个工具 = 加一个方法
不用登记,也不用起名。 把一个 *Mcp 类放进 src/Mcp/ 就已经是注册了——工具类是按约定找到的
(App::namespace + mcp_tool_namespace 默认 Mcp + mcp_tool_class_postfix 默认 Mcp),
和 Route 找 Controller 是一个路子:
namespace DuckMcp\Mcp; use DuckMcp\Business\NoteBusiness; class ReportMcp { /** * Count the notes matching a keyword. * * @param string $keyword 关键词 * @param int $limit 最多统计多少条 * @return array{keyword:string,count:int} */ public function noteCount(string $keyword = '', int $limit = 20): array { $notes = NoteBusiness::_()->listNotes($keyword, $limit); return ['keyword' => $keyword, 'count' => count($notes)]; } }
上面这个方法就是工具 Report/noteCount:类名去掉 Mcp 后缀 + / + 方法名(区分大小写),
描述取 docblock 第一行 Count the notes matching a keyword.。
- 名字不可变:它由类名和方法名决定,没有第二处可以改它(改方法名就是改 wire 名);
- 名字本身就是地址:
tools/call用Misc/add直接找到方法,不需要先扫一遍工具表 (字符集、类存不存在、是否 public、拼写是否一致这四道检查一道不少); - 描述:
@mcpDescription <一句话>优先,没有就用 docblock 第一行(只有一行,不是整段); - 类的所有 public 方法都是工具,所以辅助方法请放进不以
Mcp结尾的类(或写成protected); - 工具类按类名排序,
tools/list的顺序因此是稳定可复现的; - 目录由 PSR-4 映射推导(
DuckMcp\→src/),命名空间映射到别处也能找到; - 文件不按约定命名就不是工具类;文件按约定命名却没声明对应类,会直接报错—— 那是作者要修的,不静默跳过。
不需要写 schema,也不需要在方法上写任何注解。每个字段要么由代码派生,要么有一个 @mcp*
标签可以覆盖——类型和名字不给口子,其余每个字段一个标签:
| 你写的 | 客户端收到的 | 覆盖方式 |
|---|---|---|
class ReportMcp 里的 noteCount() |
工具名 Report/noteCount(类名去 Mcp + / + 方法名) |
不可覆盖 |
| docblock 第一行 | description |
@mcpDescription |
—(方法名美化:noteCount → Note Count) |
title |
@mcpTitle |
@param int $limit 最多统计多少条 |
{"type":"integer","description":"最多统计多少条"} |
不可覆盖 |
| 没有默认值的参数 | 进入 required |
不可覆盖 |
@return array{keyword:string,count:int} |
outputSchema(支持嵌套 array<int,array{...}>) |
不可覆盖 |
@param 'a'|'b' $mode |
{"type":"string","enum":["a","b"]},且服务端按它校验(超出回 -32602) |
不可覆盖 |
| 方法名里出现 add/delete/save/place… | readOnlyHint:false, destructiveHint:true |
@mcpReadOnlyHint / @mcpDestructiveHint |
| 只读工具 | idempotentHint 跟着 readOnly |
@mcpIdempotentHint |
—(默认 false:本地工具) |
openWorldHint:false |
@mcpOpenWorldHint |
四个 hint 标签的值是布尔(true/false/1/0/yes/no;只写标签不写值等于 true)。
annotations 里只有这四个 hint,title 在顶层——2025-06-18 的 Tool 类型就是这么定的。
自相矛盾的声明会直接报错(例如 @mcpReadOnlyHint true + @mcpDestructiveHint true)。
MiscMcp::add() 就是覆盖的例子:add 会被猜成写操作,但它是算术,所以标了
@mcpReadOnlyHint true。
HTTP 传输只认 /mcp 吗
端点和开关都在 App.php 的选项里:
'mcp_http_enable' => true, // false = 只跑 stdio 'mcp_http_route' => '/mcp', 'mcp_http_require_session' => true, // initialize 之后必须带 Mcp-Session-Id; // false = 无会话也能用(像 stdio 一样信任客户端) 'mcp_http_cors' => '*', // '*'、来源白名单数组、或 false 表示不发 CORS 头 'mcp_http_max_body' => 1048576, // 超过就 413(0 = 不限) 'mcp_http_require_json_content_type' => false, // true = 非 JSON 的 POST 回 415 'mcp_http_secret' => '', // 非空 = 给会话 id 签名,伪造的 id 回 404 'mcp_http_session_ttl' => 0, // 秒,0 = 不过期(仅签名模式) 'mcp_http_session' => false, // true = 用 PHP session 存会话的一点点状态(存在 session.save_path)
会话默认无状态:initialize 铸一个 Mcp-Session-Id,服务端什么都不存,所以任意非空 id
都被接受(想校验就设 mcp_http_secret)。打开 mcp_http_session 后才用 PHP session 存
「协商版本、日志级别、clientInfo」——好处是客户端 logging/setLevel 设完能在下一个请求里
继续生效;代价是需要一个可写的会话目录,并且要守两条纪律(AGENTS.md 第 5 节有实测记录):
- 会话只在响应写出之前配置;
session_start()起不来就降级为无状态并记一条 warning, 绝不让请求 500(PHP 一旦有输出就会拒绝 start,而且会告警); - 会话文件锁只包「读状态」那一段,工具/DB 在锁外跑(否则同一会话的并发请求会排队)。
存储位置就是 PHP 的 session.save_path(部署时在 php.ini / FPM pool 里指定即可,代码不碰它);
与普通 session 的唯一区别是 id 来自 Mcp-Session-Id 头——PHP 只用它派生一个定长文件名,
cookie 也一并关掉。
服务器身份也在选项里(默认值写在 DuckMcpExt 常量上):
mcp_server_name / mcp_server_title / mcp_server_version / mcp_instructions。
日志同样在选项里,落 runtime/mcp_YYYY-MM-DD.log(与框架自己的 log_*.log 分开):
'mcp_log_level' => 'warning', // 阈值;客户端还能用 logging/setLevel 临时提高 'mcp_log_path' => 'runtime', 'mcp_log_file_template' => 'mcp_%Y-%m-%d.log', 'mcp_log_prefix' => 'DuckMcp',
换掉整个日志实现不用加选项——用框架的单例替换钩子即可:
class MyMcpLogger extends \DuckMcp\Ext\McpLogger { /* 覆盖 log() 或选项 */ } \DuckMcp\Ext\McpLogger::_(new MyMcpLogger());
| 请求 | 行为 |
|---|---|
POST /mcp + Accept: application/json |
200,JSON-RPC 应答;initialize 会铸一个新的 Mcp-Session-Id |
POST /mcp 通知(没有 id) |
202,空 body |
POST /mcp + Accept: text/event-stream |
200,把应答包成一个 SSE message 事件 |
GET /mcp |
405(本示例不做服务端推送) |
OPTIONS /mcp |
204 + CORS 预检头(浏览器客户端要用) |
DELETE /mcp + session |
200,结束会话;session 不认识(配了 mcp_http_secret)时 404 |
没带 session 就 tools/list |
400(先 initialize) |
MCP-Protocol-Version 报了不支持的版本 |
400 |
Accept 里没有 application/json / text/event-stream(含 q=0) |
406 |
body 超过 mcp_http_max_body |
413;内容类型不对且开了开关则 415 |
非 /mcp 的 URL |
交给 DuckPHP 正常路由,该 404 就 404 |
上公网前的 HTTP 检查清单
默认值选的是「开箱即用」(方便 Inspector / 浏览器直接连),上公网前逐条过一遍—— 都是配置,不用改代码:
| 开关 | 默认 | 生产建议 | 为什么 |
|---|---|---|---|
mcp_http_secret |
'' |
一个随机长串(32 字符以上) | 空着时任意非空 Mcp-Session-Id 都被接受,未知/已终止的会话无从拒绝;设了之后 id 带 HMAC,伪造或过期回 404 |
mcp_http_session_ttl |
0 |
按会话时长设(如 3600) |
只在签名模式下有效;0 = 铸出去的 id 永不过期 |
mcp_http_require_session |
true |
保持 true |
false 等于「没会话也当成已初始化」,任何人都能直接 tools/call |
mcp_http_cors |
'*' |
来源白名单数组,或 false |
'*' 让任意站点都能跨域调用;白名单会回显 Origin + Vary: Origin(浏览器要带 cookie 时也不能用 '*') |
mcp_http_max_body |
1 MiB | 按业务调 | 超了回 413;工具要收大参数(长文档、base64)就提,否则保持 |
mcp_http_require_json_content_type |
false |
建议 true |
打开后非 application/json 的 POST 回 415(缺 Content-Type 仍放行);curl -d 默认发 form-urlencoded,开了就得自己加头 |
is_debug |
true |
false |
打开时框架会把 notice/异常渲染成调试页。runStdio() / runHttp() 期间已被 DuckMcpExt 接管(漏不进 STDOUT),但启动期与其它路由仍走框架默认 |
三件不是开关的事:
runtime/要可写:SQLite、事件日志、MCP 日志都落在这里;- 开了
mcp_http_session还得管session.save_path:目录必须存在且可写;不可用时每个请求记一条 warning 并降级为无状态(不会 500,但会话状态就没了); - 横向扩容:默认无状态,多 worker / 多机随便加;只有打开
mcp_http_session才需要共享存储(Redis session handler)或粘性路由。
改完拿真实进程压一遍,别只信内存流:
php -S 127.0.0.1:8099 -t public # 期望 200 + 一个 Mcp-Session-Id 响应头 curl -s -D - -o /dev/null -X POST http://127.0.0.1:8099/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' # 期望 400(没带 session,先 initialize) curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8099/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
5. 协议实现到哪一步
| 能力 | 状态 |
|---|---|
| 传输 | stdio(换行分隔)、Streamable HTTP(JSON / SSE 应答、202 通知、DELETE 会话) |
| 协议版本 | 2025-06-18(默认)、2025-03-26、2024-11-05,客户端报旧版本时按规范回落到受支持的版本 |
initialize / notifications/initialized / ping |
✅ 含 capabilities、serverInfo、instructions |
tools/list |
✅ cursor 分页(每页 50)、inputSchema、outputSchema、annotations |
tools/call |
✅ 参数按 schema 校验(-32602)、结果 content + structuredContent、工具异常转 isError |
resources/list / resources/read |
✅ 内置 duckmcp://manifest(服务器 + 工具清单) |
prompts/list |
返回空列表(预留;不宣称 completions,因为没实现) |
logging/setLevel / 日志 |
✅ 真的按等级过滤;写 runtime/mcp_*.log,stdio 循环里同时写 STDERR,绝不出现在 STDOUT |
JSON-RPC 批处理、-32700/-32600/-32601/-32602/-32603 |
✅ 含信封校验(jsonrpc 必须是 2.0、id 只能是字符串/数字/null) |
| 空结果 | ✅ 编成 {}(不是 []),例如 ping |
notifications/cancelled、进度、sampling、roots、elicitation |
未实现(示例范围外) |
三个必须遵守的约束,示例里都做了:
- STDOUT 只放协议帧。 日志走
runtime/mcp_*.log和 STDERR(stdio 循环内), 框架的异常上报器也写 STDERR;否则一个 PHP Notice 就能让客户端解析失败。 更狠的一层:runStdio()/runHttp()执行期间,DuckMcpExt会接管 DuckPHP 的error_debug/error_500两个出口——框架默认会把调试页 echo 到 STDOUT,那正好是协议流。运行期外(启动、tools/info命令、Web 普通路由) 仍然是 DuckPHP 自己的行为。 - STDOUT 不缓冲、STDIN 不提前返回。
DuckMcpExt::stdoutStream()关掉写缓冲,stdinStream()设成阻塞读,避免一帧被切开或卡住(Windows 上尤其明显)。 - HTTP 会话无状态。 见 AGENTS.md 第 5 节:不要引入
session_start()。
6. 换成自己的业务
src/Mcp/、src/Business/、src/Model/里删掉示例,留结构;src/Mcp/里的示例工具换成自己的*Mcp类(约定发现,不需要登记);- 数据库:
runtime/duckmcp.sqlite只是演示。改成 MySQL/PostgreSQL 时把config/DuckPhpSettings.config.php里的database_list打开即可(设置文件只放敏感/环境相关的东西,行为开关留在App.php); - 上线前把
is_debug关掉,并确认runtime/可写——HTTP 相关的开关逐条对照 第 4 节的检查清单。
约定与踩坑清单见 AGENTS.md;
框架本身的文档在 E:\ProjectGoat\DNMVCS\docs\zh\(指南 47 章 + 参考手册 112 页)。