From 0d20fd77860816f5cff9847679746e352021b4f0 Mon Sep 17 00:00:00 2001 From: augushong Date: Sun, 16 Aug 2026 22:46:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20tools/call=20=E8=87=AA=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=88=86=E5=8F=91=E3=80=81CSRF=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E8=B1=81=E5=85=8D=E4=B8=8E=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .example.env | 2 + .../admin/middleware/CsrfMiddlewareBase.php | 16 ++ extend/base/mcp/service/McpDispatchBase.php | 168 ++++++++++++++++-- 3 files changed, 175 insertions(+), 11 deletions(-) diff --git a/.example.env b/.example.env index 2ff9e6d..7002724 100644 --- a/.example.env +++ b/.example.env @@ -4,6 +4,8 @@ APP_DEBUG=true DEFAULT_TIMEZONE=Asia/Shanghai AUTO_CACHE_LOG=false AUTO_PARSE_API=true +# MCP/自请求内部分发场景可显式指定(缺省时回退入站请求同源回环) +;APP_HOST = https://your-domain.com [DATABASE] MAIN=main diff --git a/extend/base/admin/middleware/CsrfMiddlewareBase.php b/extend/base/admin/middleware/CsrfMiddlewareBase.php index 149c56d..858286b 100644 --- a/extend/base/admin/middleware/CsrfMiddlewareBase.php +++ b/extend/base/admin/middleware/CsrfMiddlewareBase.php @@ -12,6 +12,22 @@ class CsrfMiddlewareBase { if (env('adminsystem.IS_CSRF', true)) { if (!in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) { + // MCP 内部分发豁免(唯一豁免通道,安全性论证): + // 1. mcp_internal 标记只能由服务端写入 login 缓存(McpDispatch 生成的一次性 + // creator token),攻击者需先具备服务器内部写缓存能力才可伪造; + // 2. 跨站表单/HTML 场景无法携带 Authorization 自定义头(浏览器表单不透传该头), + // 对该头的 CSRF 攻击模型本身不成立; + // 3. 豁免标记与 5 分钟一次性 token 同生命周期:McpDispatch 调用结束即删 + // (try/finally),缓存 TTL 到期自动失效,不残留长期豁免通道。 + $authorization = $request->header('authorization', ''); + $token = (strpos($authorization, ' ') !== false) ? trim(explode(' ', $authorization)[1]) : ''; + if (!empty($token)) { + $cached = \think\facade\Cache::store('login')->get($token); + if (!empty($cached['mcp_internal'])) { + return $next($request); // MCP 内部分发标记,跳过 CSRF(一次性短时 token) + } + } + // 跨域校验 $refererUrl = $request->header('REFERER', null); $refererInfo = parse_url($refererUrl); diff --git a/extend/base/mcp/service/McpDispatchBase.php b/extend/base/mcp/service/McpDispatchBase.php index 629e05e..d71f6b8 100644 --- a/extend/base/mcp/service/McpDispatchBase.php +++ b/extend/base/mcp/service/McpDispatchBase.php @@ -4,28 +4,174 @@ declare(strict_types=1); namespace base\mcp\service; +use app\common\service\McpService; +use GuzzleHttp\Client; +use think\facade\Cache; + /** - * MCP 工具分发器(T8 实现真实分发). + * MCP 工具分发器(以创建者身份自请求 admin 节点). * - * 职责(T8):以 McpService::verifyNode 还原编码工具名 -> 校验权限 -> - * 内部 HTTP 调用对应节点 -> McpService::logCall 审计。 - * - * T7 仅提供空壳占位,保证 tools/list 与 tools/call 链路可用: - * call 返回固定错误文案,由 ToolResultFormatter 包装为 TextContent。 + * 职责链(单次 call): + * 1. McpService::verifyNode:编码工具名还原节点并校验「密钥白名单 ∩ 创建者实时权限」 + * 2. 生成一次性 creator token 写入 login 缓存(admin 认证链 get_session_admin 可识别), + * 携带 mcp_internal 标记(CSRF 中间件的唯一豁免依据) + * 3. 节点串转 admin URL,Guzzle POST 自请求(同进程外、同服务内,走完整 HTTP 中间件栈) + * 4. 响应三态判定(JSON / HTML / 错误),原始文本返回给 SDK 包装为 TextContent + * 5. McpService::logCall 审计 + try/finally 删除一次性 token */ class McpDispatchBase { + /** 一次性内部分发 token 有效期(秒),与 CSRF 豁免标记同生命周期. */ + protected int $internalTokenTtl = 300; + + /** 内部 HTTP 请求超时(秒). */ + protected int $httpTimeout = 30; + /** * 分发一次 MCP 工具调用. * - * @param array $auth McpService::authenticate() 的返回结构(key_row + creator) - * @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原) + * @param array $auth McpService::authenticate() 的返回结构(key_row + creator) + * @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原) * @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键) - * @return string 工具执行结果(文本) + * @return string 工具执行结果文本(JSON 原样 / [HTML页面内容] 前缀 / [ERROR] 前缀) */ public function call(array $auth, string $toolName, array $arguments): string { - // T8 实现真实分发逻辑 - return '[ERROR] 分发器未就绪(T8 实现)'; + $mcpService = new McpService(); + $keyId = (int) ($auth['key_row']->id ?? 0); + + // 1. 权限校验:未授权(含权限已回收的漂移场景)直接拒绝并记失败审计 + $node = $mcpService->verifyNode($auth, $toolName); + if ($node === null) { + $mcpService->logCall($keyId, $toolName, $arguments, false, 0); + + return '[ERROR] 无权限或权限已回收: ' . $toolName; + } + + // 2. base URL 三级策略:显式配置 -> 入站请求同源回环 -> 均不可用报错 + $baseUrl = $this->resolveBaseUrl(); + if ($baseUrl === '') { + $mcpService->logCall($keyId, $node, $arguments, false, 0); + + return '[ERROR] 未配置 app.app_host,无法内部分发'; + } + + // 3. 节点串转 admin URL:system.quick/index -> /admin/system.quick/index + $url = '/admin/' . implode('/', explode('/', $node)); + + // 4. 一次性 creator token:写入 login 缓存即被 admin 认证链接受; + // mcp_internal 标记仅供 CSRF 中间件豁免识别,不落库、不外发 + $token = bin2hex(random_bytes(16)); + $creatorData = $auth['creator']; + $creatorData['expire_time'] = time() + $this->internalTokenTtl; + $creatorData['mcp_internal'] = true; + Cache::store('login')->set($token, $creatorData, $this->internalTokenTtl); + + $startTime = microtime(true); + $success = false; + $result = ''; + + try { + $client = new Client([ + 'timeout' => $this->httpTimeout, + 'verify' => false, + 'http_errors' => false, + ]); + + $jsonBody = json_encode($arguments, JSON_UNESCAPED_UNICODE); + if ($jsonBody === false) { + $jsonBody = '{}'; + } + + $response = $client->post($baseUrl . $url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-Requested-With' => 'XMLHttpRequest', + 'Authorization' => 'Bearer ' . $token, + ], + 'body' => $jsonBody, + ]); + + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + if ($statusCode === 200) { + // 三态判定之一/二:200 + 合法 JSON(layui code=0 与 success code=200 + // 均算成功,不拆信封,原始 JSON 字符串原样返回);200 + 非 JSON + // 视为 HTML 页面,加前缀标识返回(不自动追加 get_page_data=1) + json_decode($body); + if (json_last_error() === JSON_ERROR_NONE) { + $result = $body; + } else { + $result = '[HTML页面内容] ' . $body; + } + $success = true; + } else { + // 三态判定之三:非 200,优先取 JSON 错误信封的 msg + $result = '[ERROR] ' . $this->extractErrorMessage($body, 'HTTP ' . $statusCode); + } + } catch (\Throwable $e) { + // 超时 / 连接失败等(Guzzle 抛出,含 ConnectException) + $result = '[ERROR] ' . $e->getMessage(); + } finally { + // 审计尽力而为(logCall 内部吞异常);一次性 token 无论成败必删, + // 且本身带 TTL 兜底,不残留任何可复用的豁免通道 + $costMs = (int) round((microtime(true) - $startTime) * 1000); + $mcpService->logCall($keyId, $node, $arguments, $success, $costMs); + Cache::store('login')->delete($token); + } + + return $result; + } + + /** + * base URL 三级策略. + * + * a. env('app.app_host') 显式配置优先(生产域名/反代场景明确指定) + * b. 当前入站请求同源回环(scheme + host,缺失端口时用 SERVER_PORT 补齐): + * MCP 端点收到什么域名,内部分发就打什么域名,天然绕开外部映射不确定性 + * c. 两者皆不可用(如 CLI 上下文且未配置)返回空串,由调用方报错 + */ + protected function resolveBaseUrl(): string + { + $appHost = env('app.app_host', ''); + if (!empty($appHost)) { + return rtrim((string) $appHost, '/'); + } + + $request = app('request'); + if ($request instanceof \think\Request) { + $scheme = $request->scheme(); + $host = (string) $request->host(); + if ($scheme !== '' && $host !== '') { + // nginx fastcgi_params 标准实践传 HTTP_HOST $host(已去端口), + // 非标准端口部署需用 SERVER_PORT 补齐,否则自请求会打到 80/443 + if (!str_contains($host, ':') || str_ends_with($host, ']')) { + $port = (string) $request->server('SERVER_PORT', ''); + $defaultPort = ($scheme === 'https') ? '443' : '80'; + if ($port !== '' && $port !== $defaultPort) { + $host .= ':' . $port; + } + } + + return $scheme . '://' . $host; + } + } + + return ''; + } + + /** + * 提取错误响应文案:优先 JSON 错误信封的 msg,否则使用兜底文案. + */ + protected function extractErrorMessage(string $body, string $fallback): string + { + $decoded = json_decode($body, true); + if (is_array($decoded) && !empty($decoded['msg'])) { + return (string) $decoded['msg']; + } + + return $fallback; } }