Files
ulthon_admin/extend/base/mcp/service/McpDispatchBase.php

178 lines
7.2 KiB
PHP
Raw Permalink 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
declare(strict_types=1);
namespace base\mcp\service;
use app\common\service\McpService;
use GuzzleHttp\Client;
use think\facade\Cache;
/**
* MCP 工具分发器(以创建者身份自请求 admin 节点).
*
* 职责链(单次 call
* 1. McpService::verifyNode编码工具名还原节点并校验「密钥白名单 ∩ 创建者实时权限」
* 2. 生成一次性 creator token 写入 login 缓存admin 认证链 get_session_admin 可识别),
* 携带 mcp_internal 标记CSRF 中间件的唯一豁免依据)
* 3. 节点串转 admin URLGuzzle 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 $arguments 客户端原始参数(已剔除 _session/_request 内部键)
* @return string 工具执行结果文本JSON 原样 / [HTML页面内容] 前缀 / [ERROR] 前缀)
*/
public function call(array $auth, string $toolName, array $arguments): string
{
$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 URLsystem.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 + 合法 JSONlayui 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;
}
}