mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
feat(mcp): mcp 独立应用与 /mcp Streamable HTTP 端点(官方 SDK + PSR-7 桥接)
This commit is contained in:
268
extend/base/mcp/controller/IndexBase.php
Normal file
268
extend/base/mcp/controller/IndexBase.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user