diff --git a/app/mcp/controller/Index.php b/app/mcp/controller/Index.php new file mode 100644 index 0000000..ecec592 --- /dev/null +++ b/app/mcp/controller/Index.php @@ -0,0 +1,14 @@ + App::getRuntimePath() . 'login/', ], + // MCP 会话存储(StreamableHttpTransport 会话,经 Psr16ThinkCache 适配 PSR-16) + 'mcp_session' => [ + 'type' => 'File', + 'path' => App::getRuntimePath() . 'mcp_session/', + ], // 更多的缓存连接 ], ]; diff --git a/config/mcp.php b/config/mcp.php new file mode 100644 index 0000000..f85599b --- /dev/null +++ b/config/mcp.php @@ -0,0 +1,21 @@ + Env::get('mcp.enable', true), + + // MCP serverInfo.name(initialize 响应中的服务名) + 'server_name' => Env::get('mcp.server_name', 'ulthon_admin'), + + // DNS rebinding 防护 Host 白名单(不含端口)。 + // 为空(默认):不启用 DNS rebinding 防护中间件——端点已有 Bearer 密钥认证, + // 且生产部署的 Host 是业务域名,SDK 默认白名单(仅 localhost 变体)会导致全部 403。 + // 非空(如本地调试场景):启用 SDK DnsRebindingProtectionMiddleware,仅放行列表内 Host/Origin。 + 'allowed_hosts' => array_filter(array_map('trim', explode(',', (string) Env::get('mcp.allowed_hosts', '')))), +]; diff --git a/extend/base/mcp/controller/IndexBase.php b/extend/base/mcp/controller/IndexBase.php new file mode 100644 index 0000000..9338dd2 --- /dev/null +++ b/extend/base/mcp/controller/IndexBase.php @@ -0,0 +1,268 @@ + 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); + } +} diff --git a/extend/base/mcp/service/McpDispatchBase.php b/extend/base/mcp/service/McpDispatchBase.php new file mode 100644 index 0000000..629e05e --- /dev/null +++ b/extend/base/mcp/service/McpDispatchBase.php @@ -0,0 +1,31 @@ + 校验权限 -> + * 内部 HTTP 调用对应节点 -> McpService::logCall 审计。 + * + * T7 仅提供空壳占位,保证 tools/list 与 tools/call 链路可用: + * call 返回固定错误文案,由 ToolResultFormatter 包装为 TextContent。 + */ +class McpDispatchBase +{ + /** + * 分发一次 MCP 工具调用. + * + * @param array $auth McpService::authenticate() 的返回结构(key_row + creator) + * @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原) + * @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键) + * @return string 工具执行结果(文本) + */ + public function call(array $auth, string $toolName, array $arguments): string + { + // T8 实现真实分发逻辑 + return '[ERROR] 分发器未就绪(T8 实现)'; + } +} diff --git a/extend/base/mcp/service/Psr16ThinkCacheBase.php b/extend/base/mcp/service/Psr16ThinkCacheBase.php new file mode 100644 index 0000000..49d042e --- /dev/null +++ b/extend/base/mcp/service/Psr16ThinkCacheBase.php @@ -0,0 +1,114 @@ +driver = $driver ?? Cache::store($this->defaultStore()); + } + + /** + * 默认 think 缓存 store 名(子类覆写). + */ + protected function defaultStore(): string + { + return 'file'; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->driver->get($key, $default); + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + return (bool) $this->driver->set($key, $value, $this->normalizeTtl($ttl)); + } + + public function delete(string $key): bool + { + return (bool) $this->driver->delete($key); + } + + public function clear(): bool + { + return (bool) $this->driver->clear(); + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $keys = is_array($keys) ? $keys : iterator_to_array($keys, false); + + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->driver->get($key, $default); + } + + return $result; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + $ttl = $this->normalizeTtl($ttl); + + $result = true; + foreach ($values as $key => $value) { + if (!$this->driver->set((string) $key, $value, $ttl)) { + $result = false; + } + } + + return $result; + } + + public function deleteMultiple(iterable $keys): bool + { + $result = true; + foreach ($keys as $key) { + if (!$this->driver->delete($key)) { + $result = false; + } + } + + return $result; + } + + public function has(string $key): bool + { + return (bool) $this->driver->has($key); + } + + /** + * PSR-16 TTL 归一为秒:DateInterval 折算秒数;null 视为 0(think 驱动默认有效期,0=永久). + */ + protected function normalizeTtl(null|int|\DateInterval $ttl): int + { + if ($ttl instanceof \DateInterval) { + $now = new \DateTimeImmutable('@' . time()); + + return max(0, $now->add($ttl)->getTimestamp() - $now->getTimestamp()); + } + + return max(0, (int) ($ttl ?? 0)); + } +} diff --git a/route/app.php b/route/app.php index 91190dd..9733008 100644 --- a/route/app.php +++ b/route/app.php @@ -1,2 +1,6 @@