给 MCP 服务加一个分销工具:57 个内置工具之外的扩展位
环境:PHP 8.2+、ThinkPHP 8、表前缀
eb_。文中php think命令在backend/目录下执行;MCP 端点为POST /api/mcp。
一、一个具体缺口
modules/service/mcp 提供的是单端点 JSON-RPC 2.0 服务:POST /api/mcp,只认三个方法------initialize 握手、tools/list 取清单、tools/call 调工具。它默认随系统安装(plugin.json 里 default_install: true),装完就能被 AI 客户端连上。
按当前仓库的代码数一遍:目录约定自动发现的内置工具是 modules/service/mcp/tools/ 下 54 个 *Tool.php,再加上实物商品插件自己在 modules/product/normal/mcp/ 里注册的 3 个(读、建、改),一共 57 个。分类覆盖用户、商品、订单、售后退款、财务、营销、系统设置、消息通知、开发支持九组(McpToolRegistry::CATEGORY_ORDER)。
分销域这边有十几张表:推广员档案 eb_distribution_user、推广关系 eb_distribution_relation、佣金流水 eb_distribution_brokerage、分销订单快照 eb_distribution_order,却没有一个对应的工具。运营在对话框里问"这个月有多少推广员、待结算佣金多少",模型只能干看着------不是它不会算,是它没有可调用的能力。
这篇就按这条真实缺口走一遍:新增一个 crmeb_distribution_overview 工具,从决定代码写在哪,到工具类、注册、自测、后台授权,最后列一遍容易踩的地方。
二、先定代码写在哪,再动手
MCP 工具只有两条接入路径,选错路径会直接撞上架构检查。
路径一:内置工具,放进 tools/ 目录,自动发现。 McpToolRegistry::builtinTools() 干的事很简单:glob(__DIR__ . '/tools/*Tool.php'),拼出类名,再用 is_subclass_of($class, McpToolInterface::class) 过滤一遍。所以新增工具不需要改任何清单文件,文件名必须以 Tool.php 结尾,类必须实现 crmeb\interfaces\McpToolInterface。抽象基类、辅助类不放这个目录就不会被误注册。
路径二:插件工具,监听 McpToolsRegister 事件注册。 内置工具注册完,McpToolRegistry::booted() 会派发一次字符串事件 McpToolsRegister,把注册中心实例作为参数传出去,插件在自己的 event.php 里接住,调用 register() 追加。
那分销工具为什么不直接写进 tools/?因为 php think arch:check 的第 4 条铁律是"modules 层跨模块互引(严重)"。工具一旦落在 modules/service/mcp/tools/,却 use modules\distribution\dao\DistributionUserDao,就是 service 模块引 distribution 模块,检查直接报错。反过来,工具写在分销模块自己的 mcp/ 目录、由分销模块的监听器注册,依赖方向是"分销模块 → 核心契约 + 自身 DAO",完全合法。
这也是 modules/product/normal/mcp/ 那三个工具的存在理由,它们和内置工具在注册中心里地位相同,只是注册时机不同。
目录结构:
text
backend/
├── crmeb/interfaces/McpToolInterface.php # 系统级契约,不依赖任何模块
├── modules/service/mcp/
│ ├── McpToolRegistry.php # 注册中心:自动发现 + 事件派发
│ ├── controller/McpController.php # 端点:认证 + 白名单 + 分发
│ ├── service/McpAccountServices.php # 账号与工具白名单
│ └── tools/ # 内置工具(54 个)
└── modules/distribution/
├── event.php # 挂 McpToolsRegister
├── listener/McpToolsRegisterListener.php # 只做注册
├── mcp/DistributionOverviewTool.php # 本次新增
├── dao/DistributionUserDao.php
└── dao/DistributionOrderDao.php
三、工具类:契约四个方法 + 三个可选方法
McpToolInterface 只有四个必需方法:name()、description()、inputSchema()、execute()。另有三组可选约定,靠 method_exists 判断存在与否,所以老工具不实现也不会坏:interaction() 声明写操作需确认,label() 给后台一个短中文名,category() 决定后台分组。
php
<?php
// backend/modules/distribution/mcp/DistributionOverviewTool.php
declare(strict_types=1);
namespace modules\distribution\mcp;
use crmeb\interfaces\McpToolInterface;
use modules\distribution\dao\DistributionOrderDao;
use modules\distribution\dao\DistributionUserDao;
/**
* MCP 工具:分销概览(推广员规模、可用佣金、区间内分销订单)
*/
class DistributionOverviewTool implements McpToolInterface
{
/** @var DistributionUserDao 分销用户档案 DAO */
private DistributionUserDao $userDao;
/** @var DistributionOrderDao 分销订单快照 DAO */
private DistributionOrderDao $orderDao;
public function __construct(DistributionUserDao $userDao, DistributionOrderDao $orderDao)
{
$this->userDao = $userDao;
$this->orderDao = $orderDao;
}
public function name(): string
{
return 'crmeb_distribution_overview';
}
public function label(): string
{
return '分销概览';
}
public function category(): string
{
return '营销';
}
public function description(): string
{
return '查询分销整体情况:推广员人数、全部推广员可用佣金合计、指定时间窗口内的分销订单数与一级佣金合计。'
. '参数 days 为回看天数,默认 7,允许 1~90,越界自动按边界值处理并在 notice 中说明。'
. '返回的是快照汇总值,不含用户昵称与手机号,也不含逐笔佣金流水;'
. '需要明细请用订单/用户类工具配合查询。仅统计已进入分销链路的订单,未绑定推广关系的订单不计入。';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'days' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 90,
'description' => '统计窗口回看天数,默认 7,允许 1~90',
],
],
];
}
public function execute(array $args): array
{
$days = (int)($args['days'] ?? 7);
$notice = [];
if ($days < 1 || $days > 90) {
$notice[] = 'days 超出 1~90,已按边界值处理';
$days = max(1, min(90, $days));
}
$since = time() - $days * 86400;
$promoterWhere = [['is_promoter', '=', 1]];
$promoterCount = $this->userDao->search($promoterWhere)->count();
$brokerageSum = $this->userDao->sum($promoterWhere, 'brokerage_price');
$orderWhere = [['add_time', '>=', $since]];
$orderCount = $this->orderDao->search($orderWhere)->count();
$oneBrokerage = $this->orderDao->sum($orderWhere, 'one_brokerage');
return [
'window' => [
'days' => $days,
'since' => date('Y-m-d H:i:s', $since),
],
'promoter_count' => $promoterCount,
'brokerage_available_total' => number_format((float)$brokerageSum, 2, '.', ''),
'order_count' => $orderCount,
'order_one_brokerage_total' => number_format((float)$oneBrokerage, 2, '.', ''),
'notice' => $notice,
];
}
}
几个选择是有原因的。
description() 按三段写:查什么/返回什么、参数语义与边界、口径边界(不含什么、和别的工具怎么区分)。这段文字是模型挑工具、拼参数的唯一依据,写一句"查询分销概览"等于没写。
金额统一 number_format(..., 2, '.', '') 转成字符串。表里 brokerage_price、one_brokerage 是 decimal(12,2),而 BaseDao::sum() 透出的是数据库聚合的原始返回值------decimal 列通常回字符串,空集合可能回 null。直接往外扔,同一个字段的类型会随数据状态变化,模型对账时容易误判。定成两位小数字符串最稳。
参数越界不抛异常,是因为插件模块拿不到 McpException。McpController::index() 里 catch (McpException $e) 才把消息透传给客户端,其余 \Throwable 一律记日志后返回"服务器内部错误"。而 McpException 在 modules\service\mcp 下,分销模块 use 它就是跨模块互引。所以插件工具的业务错误只能以结构化数据返回------这里选择钳制参数 + notice 说明,比让模型收到一句"服务器内部错误"强得多。
四、注册:监听器只做注册
php
<?php
// backend/modules/distribution/listener/McpToolsRegisterListener.php
declare(strict_types=1);
namespace modules\distribution\listener;
use crmeb\interfaces\ListenerInterface;
use modules\distribution\mcp\DistributionOverviewTool;
/**
* 向 MCP 服务注册分销域工具
*
* 只注册、不写业务:事件参数按项目规范打包为 [McpToolRegistry 实例],
* 监听器内不写 modules\service\mcp 的类型标注,避免跨模块依赖。
*/
class McpToolsRegisterListener implements ListenerInterface
{
public function handle($event): void
{
[$registry] = $event;
$registry->register(DistributionOverviewTool::class);
}
}
三个细节:事件参数永远是数组,必须先 [$registry] = $event 解构,直接对 $event 调方法会报 Call to a member function register() on array;handle() 的参数不写 McpToolRegistry 类型标注,同样是为了不产生跨模块 use;传类名而不是实例,注册中心内部 $this->app->make($tool) 延迟实例化,构造函数里的两个 DAO 由容器注入。
然后在分销模块自己的 event.php 里加一条。这个文件已有十几条订单、用户域的事件,追加一项即可:
php
// backend/modules/distribution/event.php
return [
'listen' => [
// ......其余监听器保持不变
/** 向 MCP 服务贡献分销概览工具 */
'McpToolsRegister' => [
\modules\distribution\listener\McpToolsRegisterListener::class,
],
],
];
模块的 App::boot() 会 include event.php 再 loadEvent(),插件停用时事件文件不加载,工具自动从清单里消失,不需要写任何清理逻辑。
五、验证:三步 curl,三种失败分支
先拿一把账号密钥。后台「MCP配置」页新建账号时自动生成 32 位 hex 密钥(bin2hex(random_bytes(16))),系统账号的密钥同时镜像到配置项 mcp_appkey。
bash
KEY=<账号密钥>
URL=http://localhost:8011/api/mcp
# 1. 握手
curl -s $URL -H "X-Mcp-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}'
# 2. 清单里应该出现 crmeb_distribution_overview
curl -s $URL -H "X-Mcp-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 3. 实际调用
curl -s $URL -H "X-Mcp-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"crmeb_distribution_overview","arguments":{"days":30}}}'
密钥也可以走 Authorization: Bearer <key>,两种请求头都认,X-Mcp-Key 优先。
第 2 步没看到工具,先别怀疑注册失败。tools/list 的返回被账号白名单过滤过一道(scopedDefinitions()),没勾选就不返回;tools/call 也有一道,未勾选时报的是"当前账号无权访问工具: xxx"。这两句错误信息对应的是不同问题,看消息就能定位。
真正的注册失败,表现为整份清单里都没有 ,且日志里有监听器异常记录。因为 McpToolsRegister 已经收进 SafeEvent::$safeEvents 的安全事件清单,监听器抛异常会被隔离、只 Log::error 记一笔,服务照常响应------工具静默消失,比报 500 更难查。
另外两种失败很直白:总开关关掉返回"MCP 服务未开启"(配置项 mcp_enabled,安装默认开);账号停用返回"该账号已停用,请联系管理员启用",且是每请求查库,切换即时生效、无缓存。
六、后台两个页面会自动认出新工具
不需要改前端。工具清单页 /admin/plugin/mcp/list 和账号配置页 /admin/plugin/mcp/config 都是声明式页面,数据源直接来自注册中心:
ToolListPage取McpToolRegistry::booted()->allDefinitions()渲染列表,label是中文名列,category是分组标签,interaction渲染成「执行方式」列(没声明的留空)。AccountListPage::toolOptions()用同一份定义生成"授权工具"多选框,分组顺序是CATEGORY_ORDER里约定的九类在前、插件自定义分类按出现顺序追加、没声明分类的归入「其他」。所以新工具只要category()返回「营销」,就会自动落到营销分组里。
授权动作在 /admin/plugin/mcp/config → 某账号「编辑权限」→ 勾选「分销概览」→ 保存。保存时 McpAccountServices::buildAccountData() 会把提交的工具名和当前已注册工具名做一次 array_intersect,插件卸载后残留的工具名会被自动剔掉,不会一直躺在白名单里。
这里也没有新增菜单的必要。后台这两个页面的权限点在模块 Installer.php 里,unique_auth 分别是 setting-mcp 和 setting-mcp-tools,属于「设置 → MCP服务」这棵菜单树;工具本身的访问权不在菜单体系里,而在账号白名单里。
七、几个必须知道的坑
1. 老账号不会自动拿到新工具。 这是最容易误判的一条。安装 service_mcp 时,系统账号(is_system=1,给 AI Agent 内置工具源用)会一次性授予当时注册的全部工具;之后新增工具,Installer 只会在"系统账号不存在"或"白名单为空"时补写,不会把新工具追加进去。普通账号更是只认新建/编辑时勾选的那一份。所以 AI Agent 侧看不到新工具,先回去勾一遍权限,别去翻代码。
2. 同名即覆盖。 register() 以 $instance->name() 为键,后注册者直接覆盖先注册者。这是"定制内置工具行为"的合法手段,也是命名冲突的来源。工具名要按 crmeb_{模块标识}_{动作} 拼,crmeb_distribution_overview 里的 distribution 就是模块标识,用来避免和别处撞名。
3. 监听器里不要写业务。 事件虽被隔离,但隔离的代价是"失败无声"。工具注册失败、依赖解析失败,都只留一行日志。注册之外的事情挪到 execute() 里做,出错至少有 JSON-RPC 错误码可看。
4. inputSchema 必须和 execute() 严格对齐。 声明了 minimum/maximum 却不校验,模型传 0 或 999 就直接进 SQL;声明了某个筛选字段却没用上,模型会以为筛过了。反过来,execute() 里能筛的东西尽量写进 schema,模型不会去猜没声明的参数。
5. 读表和写表是两套规矩。 本例只做聚合读,走自己的 DAO 没问题。如果要写(比如调整推广员等级),必须调核心 Service 的公开方法,不能直写系统表;并且要实现 interaction(): string 返回 execute(不可逆删除返回 delete),让客户端在执行前弹确认。写操作的业务校验和授权不会因为"是 AI 调的"而豁免。
6. MCP 端点不走后台那套鉴权。 McpController::initialize() 是空实现,因为它压根不经过 AdminAuthTokenMiddleware / AdminCheckRoleMiddleware。认证只有一条链路:总开关 → 账号密钥 → 停用校验 → 工具白名单。别指望后台菜单里的权限点能管住 AI 调用,它管的是后台页面。
7. 返回结构要能稳定序列化。 tools/call 的 result.content[0].text 是 json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),中文正常,但资源句柄、闭包、循环引用都会让它失败。返回纯标量数组是最省事的做法。
八、落地清单
| 步骤 | 动作 | 判定标准 |
|---|---|---|
| 1 | 定位置 | 依赖跨模块 → 写业务模块 mcp/;只依赖 app 层 → 可进 tools/ |
| 2 | 写工具类 | 实现四方法,label/category 齐全,description 三段式 |
| 3 | 注册 | 监听器只注册;event.php 加 McpToolsRegister |
| 4 | 架构检查 | php think arch:check 无 modules 互引告警 |
| 5 | 自测 | initialize / tools/list / tools/call 三步通 |
| 6 | 后台授权 | /admin/plugin/mcp/config 勾选,AI Agent 侧同步工具 |
| 7 | 对外说明 | 在工具描述里写清口径,避免使用方按错误口径解读数据 |
MCP 这个扩展位的设计取向很清楚:清单靠目录约定和事件,扩展不碰控制器;权限靠账号白名单,和后台菜单权限彻底分家。理解了这两条,新增一个工具就是"写一个类 + 加一行事件"的事。