Search by

dvaknheo / duckmcp

dvaknheo

An MCP (Model Context Protocol) server example built on the DuckPHP framework

Package info

github.com/dvaknheo/duckmcp

Type:project

pkg:composer/dvaknheo/duckmcp

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-10-19 07:11 UTC

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 未实现(示例范围外)

三个必须遵守的约束,示例里都做了:

  1. STDOUT 只放协议帧。 日志走 runtime/mcp_*.log 和 STDERR(stdio 循环内), 框架的异常上报器也写 STDERR;否则一个 PHP Notice 就能让客户端解析失败。 更狠的一层:runStdio() / runHttp() 执行期间,DuckMcpExt 会接管 DuckPHP 的 error_debug / error_500 两个出口——框架默认会把调试页 echo 到 STDOUT,那正好是协议流。运行期外(启动、tools / info 命令、Web 普通路由) 仍然是 DuckPHP 自己的行为。
  2. STDOUT 不缓冲、STDIN 不提前返回。 DuckMcpExt::stdoutStream() 关掉写缓冲, stdinStream() 设成阻塞读,避免一帧被切开或卡住(Windows 上尤其明显)。
  3. HTTP 会话无状态。 见 AGENTS.md 第 5 节:不要引入 session_start()。

6. 换成自己的业务

  1. src/Mcp/、src/Business/、src/Model/ 里删掉示例,留结构;
  2. src/Mcp/ 里的示例工具换成自己的 *Mcp 类(约定发现,不需要登记);
  3. 数据库:runtime/duckmcp.sqlite 只是演示。改成 MySQL/PostgreSQL 时把 config/DuckPhpSettings.config.php 里的 database_list 打开即可(设置文件只放敏感/环境相关的东西,行为开关留在 App.php);
  4. 上线前把 is_debug 关掉,并确认 runtime/ 可写——HTTP 相关的开关逐条对照 第 4 节的检查清单。

约定与踩坑清单见 AGENTS.md; 框架本身的文档在 E:\ProjectGoat\DNMVCS\docs\zh\(指南 47 章 + 参考手册 112 页)。