feat(mcp): McpService 密钥认证与动态工具集(白名单∩创建者实时权限)

This commit is contained in:
augushong
2026-08-16 21:40:55 +08:00
parent 744a0819f2
commit 1ab276b1e9
3 changed files with 700 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
<?php
namespace base\common\service;
use app\admin\model\SystemMcpKey;
use app\admin\model\SystemMcpKeyNode;
use app\admin\model\SystemMcpLog;
use app\admin\service\NodeService;
use app\common\service\AuthService;
use think\facade\Cache;
use think\facade\Db;
use think\facade\Log;
/**
* MCP 密钥认证与工具集服务(纯业务逻辑层).
*
* 职责:
* 1. authenticateBearer 明文 -> sha256 哈希比对密钥表status=1软删自动排除
* 加载创建者信息(剔除 password
* 2. getCreatorNodes密钥白名单 ∩ 创建者实时权限(复用 AuthService::getAdminAllowedNodes
* 严禁自行写 SQL join auth_node——超管直通 / auth_on / 黑名单等语义必须与后台一致)
* 3. getTools按白名单顺序生成 MCP 工具定义(名称编码 + 标题描述)
* 4. verifyNodetools/call 传入的编码工具名 -> 校验并还原为原始节点
* 5. logCall调用审计落库 + 密钥表原子自增(审计失败仅记录错误日志,不抛出)
*
* 本类不依赖请求上下文、不依赖 mcp/sdk、不发起 HTTP分发是端点层职责
*/
class McpServiceBase
{
/** MCP 规范工具名最大长度(^[a-zA-Z0-9_-]{1,64}$. */
protected const TOOL_NAME_MAX_LENGTH = 64;
/** 超长编码名截断保留长度56 + '_' + 7 位哈希 = 64. */
protected const TOOL_NAME_TRUNCATE_KEEP = 56;
/** 审计日志 arguments JSON 最大长度(字符). */
protected const LOG_ARGUMENTS_MAX_LENGTH = 2000;
/**
* 密钥认证.
*
* 传入 Bearer 明文sha256 后查密钥表;命中且 status=1软删行被 SoftDelete
* 自动排除)时加载创建者行。创建者加载方式与 AuthServiceBase::getAdminInfo
* 保持一致Db::name 直查,不附加 delete_time 条件),避免与权限判定链语义漂移;
* 创建者不存在时无法建立权限基础,视为认证失败。
*
* @param string $bearer 密钥明文
* @return array|null ['key_row' => SystemMcpKey模型, 'creator' => 创建者数组(无password)],失败返回 null
*/
public function authenticate(string $bearer): ?array
{
$bearer = trim($bearer);
if ($bearer === '') {
return null;
}
$keyRow = SystemMcpKey::where('key', hash('sha256', $bearer))
->where('status', 1)
->find();
if (empty($keyRow)) {
return null;
}
$creator = Db::name('system_admin')
->where('id', $keyRow->bind_admin_id)
->find();
if (empty($creator)) {
return null;
}
unset($creator['password']);
return [
'key_row' => $keyRow,
'creator' => $creator,
];
}
/**
* 计算密钥可用节点:白名单 ∩ 创建者实时权限.
*
* 白名单顺序保持getAdminAllowedNodes 对候选逐个判定并保序去重)。
* 创建者权限判定完整复用 AuthService::getAdminAllowedNodesT4
* 超管直通 / auth_on 开关 / 动态黑名单 / 注解 auth / status / auth_ids
* 全部与后台一致,严禁在本类平行实现 SQL join 导致语义漂移。
*
* @param array $auth authenticate() 的返回结构
* @return array 允许的节点列表(白名单顺序)
*/
public function getCreatorNodes(array $auth): array
{
$keyRow = $auth['key_row'] ?? null;
if (empty($keyRow)) {
return [];
}
$whitelist = SystemMcpKeyNode::where('key_id', $keyRow->id)
->column('node');
if (empty($whitelist)) {
return [];
}
$adminId = (int) $keyRow->bind_admin_id;
return $this->newAuthService($adminId)
->getAdminAllowedNodes($whitelist, $adminId);
}
/**
* 构建 AuthService依赖倒置Base 层经 app/ 入口类).
*
* 独立成 protected 钩子是为了可测性auth_on 等判定链配置无法从外部注入
* AuthServiceBase::$config 硬编码),测试子类可覆写本方法返回定制实例。
*/
protected function newAuthService(int $adminId): AuthServiceBase
{
return new AuthService($adminId);
}
/**
* 生成 MCP 工具定义列表.
*
* 工具名 = encodeToolName(节点);描述 = 节点标题 +【原始节点】,
* 标题取自 NodeService::getNodeParis() 的 node=>title 映射(与 AuthServiceBase
* 共用同一 Cache key60 秒缓存),未注册节点回退为节点串本身。
* 全量生成后做工具名唯一性校验(编码对本项目节点字母表单射 + 截断名带
* sha256 后缀,理论不可冲突;命中即数据异常,防御性抛出)。
*
* @param array $auth authenticate() 的返回结构
* @return array [['name' => 编码名, 'description' => 标题【节点】], ...]
*/
public function getTools(array $auth): array
{
$allowedNodes = $this->getCreatorNodes($auth);
if (empty($allowedNodes)) {
return [];
}
$nodeParis = $this->getNodeParisMap();
$tools = [];
$usedNames = [];
foreach ($allowedNodes as $node) {
$name = $this->encodeToolName($node);
if (isset($usedNames[$name])) {
throw new \RuntimeException('MCP tool name collision: ' . $name);
}
$usedNames[$name] = true;
$title = $nodeParis[$node]['title'] ?? '';
if ($title === '' || is_null($title)) {
$title = $node;
}
$tools[] = [
'name' => $name,
'description' => $title . '【' . $node . '】',
];
}
return $tools;
}
/**
* 校验 tools/call 传入的编码工具名并还原原始节点.
*
* 优先按规范逆解码后命中创建者允许集合;超长节点的编码名经过截断 + 哈希
* 后缀,不可逆解码,回退为对允许集合正向重编码逐一比对(集合很小,开销可忽略)。
*
* @param array $auth authenticate() 的返回结构
* @param string $toolName 编码后的工具名
* @return string|null 命中返回原始节点串,未授权/乱码返回 null
*/
public function verifyNode(array $auth, string $toolName): ?string
{
$allowedNodes = $this->getCreatorNodes($auth);
if (empty($allowedNodes)) {
return null;
}
$decoded = $this->decodeToolName($toolName);
if ($decoded !== '' && in_array($decoded, $allowedNodes, true)) {
return $decoded;
}
foreach ($allowedNodes as $node) {
if ($this->encodeToolName($node) === $toolName) {
return $node;
}
}
return null;
}
/**
* 节点串编码为 MCP 工具名.
*
* MCP 工具名规范 ^[a-zA-Z0-9_-]{1,64}$节点串module.controller/action
* 含 '.' 与 '/' 非法,编码规则:先 '/'->'--' 再 '.'->'-'(顺序固定,
* '--' 是 '/' 的唯一标记,保证对实际节点字母表单射可逆)。
* 超过 64 字符时截断至 56 位 + '_' + sha256(原始节点) 前 7 位56+1+7=64
*/
public function encodeToolName(string $node): string
{
$name = str_replace('.', '-', str_replace('/', '--', $node));
if (strlen($name) > self::TOOL_NAME_MAX_LENGTH) {
$name = substr($name, 0, self::TOOL_NAME_TRUNCATE_KEEP)
. '_' . substr(hash('sha256', $node), 0, 7);
}
return $name;
}
/**
* MCP 工具名解码回节点串encodeToolName 的逆变换).
*
* 顺序与编码相反:先 '--'->'/' 再 '-'->'.'。截断名不可逆(有损),
* 调用方verifyNode需对截断场景走正向重编码比对。
*/
public function decodeToolName(string $name): string
{
return str_replace('-', '.', str_replace('--', '/', $name));
}
/**
* 调用审计落库.
*
* 日志行写 system_mcp_logarguments JSON 超长截断);密钥表 use_num 用
* Db::raw 原子自增(禁止读改写),并刷新 last_use_time。
* 审计属于尽力而为:写失败仅 Log::error绝不向上抛出影响主调用流程。
*/
public function logCall(int $keyId, string $node, array $arguments, bool $success, int $costMs): void
{
try {
$argumentsJson = json_encode($arguments, JSON_UNESCAPED_UNICODE);
if ($argumentsJson === false) {
$argumentsJson = '';
}
if (mb_strlen($argumentsJson) > self::LOG_ARGUMENTS_MAX_LENGTH) {
$argumentsJson = mb_substr($argumentsJson, 0, self::LOG_ARGUMENTS_MAX_LENGTH);
}
SystemMcpLog::create([
'key_id' => $keyId,
'node' => $node,
'arguments' => $argumentsJson,
'is_success' => $success ? 1 : 0,
'cost_ms' => $costMs,
]);
SystemMcpKey::where('id', $keyId)->update([
'use_num' => Db::raw('use_num+1'),
'last_use_time' => time(),
]);
} catch (\Throwable $e) {
Log::error('MCP call audit failed: ' . $e->getMessage(), [
'key_id' => $keyId,
'node' => $node,
]);
}
}
/**
* 节点 node=>信息 映射(含 title.
*
* 与 AuthServiceBase::getNodeList 共用同一 Cache keynode_paris60 秒),
* 避免每次 tools/list 重复反射扫描控制器目录。
* @return array
*/
protected function getNodeParisMap(): array
{
$cacheKey = 'node_paris';
$nodeParis = Cache::get($cacheKey);
if (!$nodeParis) {
$nodeParis = (new NodeService())->getNodeParis();
Cache::set($cacheKey, $nodeParis, 60);
}
return $nodeParis;
}
}