mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
178 lines
7.2 KiB
PHP
178 lines
7.2 KiB
PHP
<?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 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 $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 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;
|
||
}
|
||
}
|