附加请求头(鉴权密钥与自定义头) */ private array $headers; /** @var int 请求超时(秒) */ private int $timeout; /** @var string SSRF 校验模式:内置自调用按 selfcall,其余按 strict(可被系统配置放行内网) */ private string $ssrfMode = SsrfGuard::MODE_STRICT; /** @var string|null Server 返回的会话 ID(initialize 后有效) */ private ?string $sessionId = null; /** @var bool 是否已完成 initialize 握手 */ private bool $initialized = false; /** @var int JSON-RPC 请求自增 id */ private int $requestId = 0; /** * @param string $url Server 接口地址 * @param array $headers 附加请求头键值对 * @param int $timeout 请求超时(秒) */ public function __construct(string $url, array $headers = [], int $timeout = 15) { $this->url = $url; $this->headers = $headers; $this->timeout = $timeout; } /** * 由 eb_ai_mcp_server 记录构建客户端 * @param array $server Server 记录(url/api_key/headers/timeout/server_key 字段) * @return self * @throws \RuntimeException 地址未配置时抛出 */ public static function fromServer(array $server): self { $url = trim((string)($server['url'] ?? '')); if ($url === '') { throw new \RuntimeException('MCP Server 接口地址未配置'); } $headers = []; $apiKey = trim((string)($server['api_key'] ?? '')); if ($apiKey !== '') { $headers['X-Mcp-Key'] = $apiKey; } $extra = json_decode((string)($server['headers'] ?? ''), true); if (is_array($extra)) { foreach ($extra as $name => $value) { if (is_string($name) && $name !== '' && $value !== null) { $headers[$name] = (string)$value; } } } $client = new self($url, $headers, max(3, min(120, (int)($server['timeout'] ?? 15)))); // 内置 MCP 为系统经 site_url 回环自调用,放行内网;自定义 Server 默认仅公网, // 系统配置「允许内网 AI 服务地址」打开后放行内网自建 MCP if ((string)($server['server_key'] ?? '') === 'builtin' || (int) sys_config('ai_allow_private_endpoint', 0) === 1) { $client->ssrfMode = SsrfGuard::MODE_SELFCALL; } return $client; } /** * 拉取远端全量工具清单(含游标分页) * @return array * @throws \RuntimeException 握手或请求失败、平台返回错误时抛出 */ public function listTools(): array { $this->initialize(); $tools = []; $cursor = ''; for ($page = 0; $page < self::MAX_TOOL_PAGES; $page++) { $result = $this->rpc('tools/list', $cursor !== '' ? ['cursor' => $cursor] : []); foreach ($result['tools'] ?? [] as $tool) { $name = trim((string)($tool['name'] ?? '')); if ($name === '') { continue; } $item = [ 'name' => $name, 'description' => (string)($tool['description'] ?? ''), 'inputSchema' => is_array($tool['inputSchema'] ?? null) ? $tool['inputSchema'] : ['type' => 'object', 'properties' => new \stdClass()], ]; // 透传远端交互声明(如 execute=需确认后执行),未声明则不带该字段 if ((string)($tool['interaction'] ?? '') !== '') { $item['interaction'] = (string)$tool['interaction']; } // 透传远端中文显示名(后台工具列表展示用),未声明则不带该字段 if ((string)($tool['label'] ?? '') !== '') { $item['label'] = (string)$tool['label']; } $tools[] = $item; } $cursor = trim((string)($result['nextCursor'] ?? '')); if ($cursor === '') { break; } } return $tools; } /** * 调用远端工具 * @param string $name 远端工具原名(不含 server_key 前缀) * @param array $arguments 工具入参 * @return array MCP result 载荷(content / isError 等) * @throws \RuntimeException 传输失败或远端报告工具执行错误时抛出 */ public function callTool(string $name, array $arguments): array { $this->initialize(); $result = $this->rpc('tools/call', [ 'name' => $name, 'arguments' => $arguments ?: new \stdClass(), ]); if (!empty($result['isError'])) { $text = ''; foreach ($result['content'] ?? [] as $block) { if (($block['type'] ?? '') === 'text') { $text .= (string)($block['text'] ?? ''); } } throw new \RuntimeException('远程 MCP 工具执行失败:' . ($text !== '' ? mb_substr($text, 0, 500) : '未知错误')); } return $result; } /** * MCP 初始化握手(幂等,进程内仅执行一次) * @throws \RuntimeException 握手失败时抛出 */ private function initialize(): void { if ($this->initialized) { return; } $this->rpc('initialize', [ 'protocolVersion' => self::PROTOCOL_VERSION, 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'crmeb-aiagent', 'version' => '1.0.0'], ]); $this->initialized = true; $this->notify('notifications/initialized'); } /** * 发送 JSON-RPC 请求并解析 result * @param string $method RPC 方法名 * @param array $params 方法参数 * @return array result 载荷 * @throws \RuntimeException 传输失败或 JSON-RPC error 时抛出 */ private function rpc(string $method, array $params): array { $payload = json_encode([ 'jsonrpc' => '2.0', 'id' => ++$this->requestId, 'method' => $method, 'params' => $params ?: new \stdClass(), ], JSON_UNESCAPED_UNICODE); [$status, $contentType, $body] = $this->send($payload); $message = $this->decodeMessage($contentType, $body); if ($status >= 400 || isset($message['error'])) { throw new \RuntimeException('MCP Server 错误:' . $this->errorMessage($message, $status)); } return is_array($message['result'] ?? null) ? $message['result'] : []; } /** * 发送 JSON-RPC 通知(无 id,不要求响应体) * @param string $method 通知方法名 * @throws \RuntimeException Server 明确拒绝时抛出 */ private function notify(string $method): void { $payload = json_encode([ 'jsonrpc' => '2.0', 'method' => $method, 'params' => new \stdClass(), ], JSON_UNESCAPED_UNICODE); [$status, , $body] = $this->send($payload); if ($status >= 400) { $message = $this->decodeMessage('', $body); throw new \RuntimeException('MCP Server 错误:' . $this->errorMessage($message, $status)); } } /** * 发起 HTTP POST 并拆分状态码、内容类型与会话头 * @param string $payload 请求体(JSON-RPC 报文) * @return array [HTTP 状态码, 响应 Content-Type, 响应体] * @throws \RuntimeException 网络失败时抛出 */ private function send(string $payload): array { $headers = [ 'Content-Type: application/json', 'Accept: application/json, text/event-stream', ]; foreach ($this->headers as $name => $value) { $headers[] = $name . ': ' . $value; } if ($this->sessionId !== null) { $headers[] = 'Mcp-Session-Id: ' . $this->sessionId; } $ch = curl_init($this->url); // 请求时 SSRF 校验 + 域名解析钉扎:校验失败或解析到受限地址直接拒绝本次请求 SsrfGuard::assertSafeUrl($this->url, $this->ssrfMode); $resolve = SsrfGuard::resolveEntries($this->url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); if ($resolve !== []) { curl_setopt($ch, CURLOPT_RESOLVE, $resolve); } $response = curl_exec($ch); if ($response === false) { $errno = curl_errno($ch); $error = curl_error($ch); curl_close($ch); // 原始 cURL 报错仅入日志,对外抛出按错误码归类后的中文提示 \think\facade\Log::warning('MCP Server 请求失败: ' . ($error !== '' ? $error : 'unknown curl error'), [ 'url' => $this->url, 'errno' => $errno, ]); throw new \RuntimeException($this->transferError($errno)); } $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $rawHeaders = substr($response, 0, $headerSize); $body = trim(substr($response, $headerSize)); $contentType = ''; if (preg_match('/^content-type:\s*([^\r\n;]+)/im', $rawHeaders, $match)) { $contentType = strtolower(trim($match[1])); } // 首次响应携带会话 ID 时记住,后续请求回传 if ($this->sessionId === null && preg_match('/^mcp-session-id:\s*([^\r\n]+)/im', $rawHeaders, $match)) { $this->sessionId = trim($match[1]); } return [$status, $contentType, $body]; } /** * 传输失败的用户可读提示(按 cURL 错误码归类,附带排查方向; * 原始报错已写入日志,此处不再透出英文底层信息) * * @param int $errno cURL 错误码 * @return string */ private function transferError(int $errno): string { $host = (string)(parse_url($this->url, PHP_URL_HOST) ?: $this->url); $port = (int)(parse_url($this->url, PHP_URL_PORT) ?: 0); $target = $port > 0 ? "{$host}:{$port}" : $host; return match (true) { $errno === CURLE_COULDNT_RESOLVE_HOST => "MCP Server 地址不可达:域名 {$target} 无法解析,请检查地址拼写与本机 DNS 配置", $errno === CURLE_COULDNT_CONNECT => "MCP Server 无法连接:{$target} 服务未启动或地址/端口不可达,请检查 MCP Server 地址配置", $errno === CURLE_OPERATION_TIMEDOUT => "MCP Server 请求超时:超过 {$this->timeout} 秒无响应,服务响应缓慢或网络不通", in_array($errno, [35, 58, 59, 60, 77, 90, 91], true) // CURLE_SSL_CONNECT_ERROR / CERTPROBLEM / CIPHER / CACERT / CACERT_BADFILE / // PINNEDPUBKEYNOTMATCH / INVALIDCERTSTATUS(部分 libcurl 构建未定义常量,用字面量) => 'MCP Server HTTPS 证书校验未通过:请确认证书有效,内网自建服务可改用 HTTP 地址', default => 'MCP Server 请求失败:网络异常,请稍后重试', }; } /** * 解析响应载荷为 JSON-RPC 消息(兼容 SSE 格式) * @param string $contentType 响应 Content-Type * @param string $body 响应体 * @return array 解析出的消息,无法解析时为空数组 */ private function decodeMessage(string $contentType, string $body): array { if ($body === '') { return []; } if (str_contains($contentType, 'text/event-stream')) { // SSE 载荷:取首个包含 result/error 的 data: 行 foreach (preg_split('/\r?\n/', $body) as $line) { if (!str_starts_with($line, 'data:')) { continue; } $message = json_decode(trim(substr($line, 5)), true); if (is_array($message) && (isset($message['result']) || isset($message['error']))) { return $message; } } return []; } $message = json_decode($body, true); return is_array($message) ? $message : []; } /** * 从 JSON-RPC 消息或 HTTP 状态提取可读错误信息 * @param array $message 已解析的响应消息 * @param int $status HTTP 状态码 * @return string */ private function errorMessage(array $message, int $status): string { $error = $message['error'] ?? null; if (is_array($error)) { return (string)($error['message'] ?? '未知错误'); } if (is_string($error) && $error !== '') { return $error; } if ($status >= 400) { return 'HTTP ' . $status; } return '响应解析失败'; } }