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 )'); } // 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); } }