给 CRMEB V8 给 MCP 服务加一个分销工具

给 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 这个扩展位的设计取向很清楚:清单靠目录约定和事件,扩展不碰控制器;权限靠账号白名单,和后台菜单权限彻底分家。理解了这两条,新增一个工具就是"写一个类 + 加一行事件"的事。

相关推荐
IT召唤狮13 小时前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php
大鹏说大话19 小时前
PHP 大文件上传解决方案:分片上传完整思路
开发语言·php
niucloud-admin20 小时前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:订单回访
php
大黄说说21 小时前
Composer 常见报错汇总:镜像、依赖冲突、环境坑一站式排查
php·composer
安易算力1 天前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
程序员大雄学编程1 天前
Linux 磁盘满了怎么排查?df / du / find 三步定位大文件
linux·运维·php
fengci.1 天前
CTF2困难部分
开发语言·网络·python·安全·php
大黄说说1 天前
PHP 异步怎么实现?Swoole、Workerman 使用场景对比
开发语言·php·swoole
乐橙开放平台1 天前
跨地域监控难管理?通过 GB28181 把各地海康大华乐橙摄像头注册到云端平台
开发语言·笔记·物联网·自动化·php·音视频·智能家居