sha256 哈希比对密钥表(status=1,软删自动排除), * 加载创建者信息(剔除 password) * 2. getCreatorNodes:密钥白名单 ∩ 创建者实时权限(复用 AuthService::getAdminAllowedNodes, * 严禁自行写 SQL join auth_node——超管直通 / auth_on / 黑名单等语义必须与后台一致) * 3. getTools:按白名单顺序生成 MCP 工具定义(名称编码 + 标题描述) * 4. verifyNode:tools/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::getAdminAllowedNodes(T4): * 超管直通 / 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 key,60 秒缓存),未注册节点回退为节点串本身。 * 全量生成后做工具名唯一性校验(编码对本项目节点字母表单射 + 截断名带 * 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_log(arguments 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 key(node_paris,60 秒), * 避免每次 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; } }