Files
ulthon_admin/extend/base/mcp/controller/IndexBase.php

269 lines
9.6 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
declare(strict_types=1);
namespace base\mcp\controller;
use app\BaseController;
use app\common\service\McpService;
use app\mcp\service\McpDispatch;
use app\mcp\service\Psr16ThinkCache;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\ServerRequest;
use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Session\Psr16SessionStore;
use Mcp\Server\Session\SessionStoreInterface;
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use think\facade\Log;
use think\Response;
/**
* MCP 协议端点(官方 mcp/sdk Streamable HTTP 传输 + PSR-7 桥接).
*
* 独立应用 app/mcp(无 middleware.php,无 Session/CSRF),单入口 index():
* 1. SDK 存在性检查(未装 mcp/sdk 的存量项目返回 503 降级提示)
* 2. Bearer 密钥认证(McpService::authenticate,失败 401)
* 3. mcp.enable 开关(关闭 403)
* 4. PSR-7 桥(Guzzle 实现)-> StreamableHttpTransport -> Mcp\Server
* 5. PSR-7 响应转 think Response(状态码/头透传,含 Mcp-Session-Id)
*
* 会话:Psr16SessionStore + Cache::store('mcp_session')(PSR-16 薄适配),
* 适配失败回退 FileSessionStore(runtime/mcp_session/)。
*
* 注意:本端点未初始化 MCP 协议会话时(401/403/503)直接返回 think json,
* 无需 JSON-RPC 信封;进入 SDK 后的错误处理由 SDK 中间件与传输层负责。
*/
class IndexBase extends BaseController
{
/** MCP 会话有效期(秒). */
protected int $sessionTtl = 3600;
/** Psr16SessionStore 会话键前缀. */
protected string $sessionPrefix = 'mcp-session-';
/**
* 空初始化:阻断父类的布局/调试逻辑(Log::debug、StoreValueTools 等)
* 对协议端点的干预,保持最小继承面.
*/
protected function initialize()
{
}
public function index()
{
// a. SDK 存在性检查:未安装 mcp/sdk 的存量项目友好降级
if (!class_exists(Server::class)) {
return $this->mcpErrorJson(503, 'MCP 服务不可用:未安装 mcp/sdk,请先 composer require mcp/sdk');
}
// b. Bearer 提取(防御性解析,不依赖登录态实现)
$bearer = $this->extractBearerToken();
// c. 密钥认证(失败 401,此时无 MCP 会话,直接 think json)
$mcpService = new McpService();
$auth = $bearer === '' ? null : $mcpService->authenticate($bearer);
if ($auth === null) {
return $this->mcpErrorJson(401, '无效的 MCP 密钥(Authorization: Bearer <key>)');
}
// d. 启用开关
if (!config('mcp.enable')) {
return $this->mcpErrorJson(403, 'MCP 服务未启用');
}
try {
$psr7Request = $this->buildPsr7Request();
$server = $this->buildServer($mcpService, $auth);
$transport = new StreamableHttpTransport(
$psr7Request,
new HttpFactory(),
new HttpFactory(),
null,
$this->buildTransportMiddleware()
);
// e. 运行 SDK:返回 PSR-7 响应(含状态码/头/body,Mcp-Session-Id 等)
$psr7Response = $server->run($transport);
} catch (\Throwable $e) {
Log::error('MCP server error: ' . $e->getMessage(), [
'exception' => get_class($e),
'file' => $e->getFile() . ':' . $e->getLine(),
]);
return $this->mcpErrorJson(500, 'MCP 服务内部错误');
}
return $this->toThinkResponse($psr7Response);
}
/**
* 提取 Authorization: Bearer 头中的密钥明文.
*
* 防御性解析:头缺失/格式非法/多值都返回空串(视为未提供),
* 不复用登录态的 read_header_token(认证语义不同:密钥 vs 后台用户).
*/
protected function extractBearerToken(): string
{
$header = $this->request->header('authorization', '');
if (is_array($header)) {
$header = implode(',', $header);
}
$header = trim((string) $header);
if ($header === '') {
return '';
}
if (preg_match('/^Bearer\s+(\S+)$/i', $header, $matches)) {
return $matches[1];
}
return '';
}
/**
* think Request -> PSR-7 ServerRequest(Guzzle 实现).
*
* method/uri/headers 取自当前请求,body 用 getContent() 原始内容
* (避免 php://input 流消费状态的不确定性).
*/
protected function buildPsr7Request(): ServerRequestInterface
{
return new ServerRequest(
strtoupper($this->request->method()),
(string) $this->request->url(true),
$this->request->header(),
(string) $this->request->getContent(),
'1.1'
);
}
/**
* 构造 Mcp\Server:serverInfo + 全量注册 McpService::getTools 工具 + 会话存储.
*/
protected function buildServer(McpService $mcpService, array $auth): Server
{
$builder = Server::builder()
->setServerInfo((string) config('mcp.server_name', 'ulthon_admin'), '1.0.0');
foreach ($mcpService->getTools($auth) as $tool) {
$builder->addTool(
$this->makeToolHandler($auth, $tool['name']),
$tool['name'],
null,
$tool['description'],
null,
['type' => 'object', 'properties' => new \stdClass()]
);
}
$builder->setSession($this->makeSessionStore());
return $builder->build();
}
/**
* 工具处理器:闭包绑定 ReferenceHandler 作用域.
*
* SDK 的 ReferenceHandler 对绑定自身作用域的闭包直传整包参数
* (客户端 arguments + _session/_request 内部键),规避按名注入与
* 工具参数名的潜在冲突;剔除内部键后转 McpDispatch(T8 填充实现)。
*/
protected function makeToolHandler(array $auth, string $toolName): \Closure
{
return \Closure::bind(
function (array $arguments) use ($auth, $toolName): string {
unset($arguments['_session'], $arguments['_request']);
return (new McpDispatch())->call($auth, $toolName, $arguments);
},
null,
ReferenceHandler::class
);
}
/**
* 会话存储:Psr16SessionStore + think mcp_session store,构造/冒烟失败回退 FileSessionStore.
*/
protected function makeSessionStore(): SessionStoreInterface
{
try {
$adapter = new Psr16ThinkCache();
// 冒烟回环:确认 mcp_session store 真实可写可读(配置缺失/目录不可写会抛异常或失败)
$probeKey = '__mcp_session_probe__';
if (!$adapter->set($probeKey, 'ok', 10)
|| $adapter->get($probeKey, '') !== 'ok'
|| !$adapter->delete($probeKey)) {
throw new \RuntimeException('mcp_session cache store roundtrip failed');
}
return new Psr16SessionStore($adapter, $this->sessionPrefix, $this->sessionTtl);
} catch (\Throwable $e) {
Log::error('MCP Psr16SessionStore init failed, fallback to FileSessionStore: ' . $e->getMessage());
return new FileSessionStore(runtime_path() . 'mcp_session/', $this->sessionTtl);
}
}
/**
* 传输层中间件栈:CORS + 协议版本校验必选;
* mcp.allowed_hosts 非空时追加 DNS rebinding 防护(默认不启用,
* SDK 默认白名单仅 localhost 变体,生产域名会全部 403).
*/
protected function buildTransportMiddleware(): array
{
$middleware = [
new CorsMiddleware(),
new ProtocolVersionMiddleware(),
];
$allowedHosts = array_values(array_filter(array_map('strval', (array) config('mcp.allowed_hosts', []))));
if ($allowedHosts !== []) {
$middleware[] = new DnsRebindingProtectionMiddleware($allowedHosts);
}
return $middleware;
}
/**
* PSR-7 响应 -> think Response:状态码/响应头透传(含 Mcp-Session-Id、Content-Type).
*/
protected function toThinkResponse(Psr7ResponseInterface $response): Response
{
$thinkResponse = Response::create((string) $response->getBody(), 'html', $response->getStatusCode());
$headers = [];
foreach ($response->getHeaders() as $name => $values) {
$headers[$this->normalizeHeaderName($name)] = implode(', ', $values);
}
return $thinkResponse->header($headers);
}
/**
* 头名规范化:'mcp-session-id' -> 'Mcp-Session-Id'.
*
* 保证与 think Html 响应预设的 'Content-Type' 键完全同形,
* array_merge 时精确覆盖而非产生大小写重复头.
*/
protected function normalizeHeaderName(string $name): string
{
return implode('-', array_map('ucfirst', explode('-', strtolower($name))));
}
/**
* 协议外错误响应(未初始化 MCP 会话,无 JSON-RPC 信封).
*/
protected function mcpErrorJson(int $code, string $msg): Response
{
return json(['code' => $code, 'msg' => $msg, 'data' => null])->code($code);
}
}