Files
ulthon_admin/extend/base/common/service/McpServiceBase.php

282 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}