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 薄适配),
* 适配失败回退 FileSessionStoreruntime/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 响应(含状态码/头/bodyMcp-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 ServerRequestGuzzle 实现).
*
* 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\ServerserverInfo + 全量注册 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 内部键),规避按名注入与
* 工具参数名的潜在冲突;剔除内部键后转 McpDispatchT8 填充实现)。
*/
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);
}
}