├── .dockerignore ├── phpstan.neon.dist ├── .gitignore ├── src ├── Service │ ├── BaseService.php │ ├── LoggerService.php │ ├── TestService.php │ ├── Base64Service.php │ ├── WeidianBarcodeService.php │ ├── RedisService.php │ ├── MySqlService.php │ ├── TobaccoSearchService.php │ ├── SqliteService.php │ └── JsonConverterService.php ├── Command │ ├── Base64ServerCommand.php │ ├── TestServerCommand.php │ ├── TobaccoSearchCommand.php │ ├── WeidianBarcodeServerCommand.php │ ├── JsonConverterCommand.php │ ├── SqliteServerCommand.php │ ├── RedisServerCommand.php │ ├── MySqlServerCommand.php │ └── AbstractMcpServerCommand.php └── Tool │ └── CommandDiscoverer.php ├── composer.json ├── LICENSE ├── bin └── console ├── Dockerfile ├── README.md └── README.en.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | vendor -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | reportUnmatchedIgnoredErrors: false 3 | excludePaths: 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer dependencies 2 | /vendor/ 3 | composer.lock 4 | .history/ 5 | runtime/ 6 | .env 7 | .cursor/ 8 | .idea/ -------------------------------------------------------------------------------- /src/Service/BaseService.php: -------------------------------------------------------------------------------- 1 | setName('mcp:base64-server') 22 | ->setDescription('运行Base64处理服务器') 23 | ->setHelp('此命令启动一个Base64处理服务器,提供编码、解码和图片转换功能'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Command/TestServerCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:test-server') 26 | ->setDescription('运行MCP测试服务器') 27 | ->setHelp('此命令启动一个MCP测试服务器'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Command/TobaccoSearchCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:tobacco-search') 24 | ->setDescription('运行MCP烟草产品搜索服务器 (etmoc.com via Google)') 25 | ->setHelp('此命令启动一个MCP服务器,提供通过Google搜索etmoc.com烟草产品并抓取信息的功能.'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Command/WeidianBarcodeServerCommand.php: -------------------------------------------------------------------------------- 1 | setName('weidian:barcode-query') 26 | ->setDescription('运行微店条码查询MCP服务器') 27 | ->setHelp('此命令启动一个微店条码查询MCP服务器'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Command/JsonConverterCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:json-converter') 23 | ->setDescription('运行JSON转换工具服务器') 24 | ->setHelp('此命令启动一个JSON转换工具服务器,提供JSON与其他格式之间的转换功能') 25 | ->addOption( 26 | 'debug', 27 | null, 28 | InputOption::VALUE_NONE, 29 | '启用调试模式,输出更多日志信息' 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Logiscape LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Service/LoggerService.php: -------------------------------------------------------------------------------- 1 | setFormatter($formatter); 40 | 41 | // 将处理器添加到日志记录器 42 | $logger->pushHandler($handler); 43 | 44 | return $logger; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Service/TestService.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'type' => 'number', 16 | 'description' => '第一个数字', 17 | 'required' => true 18 | ], 19 | 'num2' => [ 20 | 'type' => 'number', 21 | 'description' => '第二个数字', 22 | 'required' => true 23 | ] 24 | ])] 25 | public function sum(int $num1, int $num2 = 0): int 26 | { 27 | return $num1 + $num2; 28 | } 29 | 30 | #[Prompt( 31 | name: 'greeting', 32 | description: '生成问候语', 33 | arguments: [ 34 | 'name' => ['description' => '要问候的人名', 'required' => true] 35 | ] 36 | )] 37 | public function greeting(string $name): string 38 | { 39 | return "Hello, {$name}!"; 40 | } 41 | 42 | #[Resource( 43 | uri: 'example://greeting', 44 | name: 'Greeting Text', 45 | description: 'A simple greeting message' 46 | )] 47 | public function getGreeting(): string 48 | { 49 | return "Hello from the example MCP server!"; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 50 | })(); 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli 2 | 3 | ARG SW_VERSION 4 | 5 | ENV SW_VERSION=${SW_VERSION:-"develop"} 6 | 7 | # 安装基本依赖和必要的PHP扩展 8 | RUN apt-get update && apt-get install -y \ 9 | git \ 10 | unzip \ 11 | libzip-dev \ 12 | libssl-dev \ 13 | libcurl4-openssl-dev \ 14 | && docker-php-ext-install \ 15 | zip \ 16 | sockets \ 17 | # 下载并安装 swow 扩展 18 | && cd /tmp \ 19 | && curl -SL "https://github.com/swow/swow/archive/${SW_VERSION}.tar.gz" -o swow.tar.gz \ 20 | && mkdir -p swow \ 21 | && tar -xf swow.tar.gz -C swow --strip-components=1 \ 22 | && ( \ 23 | cd swow/ext \ 24 | && phpize \ 25 | && ./configure --enable-swow --enable-swow-ssl --enable-swow-curl \ 26 | && make -s -j$(nproc) && make install \ 27 | ) \ 28 | # 配置 PHP 29 | && echo "extension=swow.so" > /usr/local/etc/php/conf.d/swow.ini \ 30 | && echo "memory_limit=1G" > /usr/local/etc/php/conf.d/memory-limit.ini \ 31 | && echo "max_input_vars=PHP_INT_MAX" >> /usr/local/etc/php/conf.d/memory-limit.ini \ 32 | && echo "opcache.enable_cli=1" >> /usr/local/etc/php/conf.d/opcache.ini \ 33 | # 清理 34 | && rm -rf /tmp/* \ 35 | && rm -rf /var/lib/apt/lists/* 36 | 37 | # 安装Composer 38 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 39 | 40 | # 设置工作目录 41 | WORKDIR /app 42 | 43 | # 复制项目文件 44 | COPY . . 45 | 46 | # 安装依赖 47 | RUN composer install --no-dev && \ 48 | mkdir -p /app/runtime && \ 49 | chmod -R 777 /app/runtime 50 | 51 | # 设置环境变量 52 | ENV PHP_MEMORY_LIMIT=1G \ 53 | PHP_TIMEZONE=PRC 54 | 55 | # 启动命令 56 | ENTRYPOINT ["php", "bin/console"] 57 | -------------------------------------------------------------------------------- /src/Command/SqliteServerCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:sqlite-server') 23 | ->setDescription('运行 SQLite MCP 服务器') 24 | ->setHelp('此命令启动一个 SQLite MCP 服务器,提供数据库操作功能') 25 | ->addOption( 26 | 'db-path', 27 | null, 28 | InputOption::VALUE_REQUIRED, 29 | 'SQLite 数据库文件路径', 30 | ); 31 | } 32 | 33 | protected function configService(mixed $service, ServerRunner $runner, InputInterface $input, OutputInterface $output) 34 | { 35 | $dbPath = getenv('DB_PATH') ?: $input->getOption('db-path') ?: BASE_PATH . '/runtime/sqlite.db'; 36 | /** @var SqliteService $service */ 37 | $service->setConfig($dbPath); 38 | } 39 | 40 | protected function afterServerRun($service, ServerRunner $runner): void 41 | { 42 | $session = $runner->getSession(); 43 | /** @var SqliteService $service */ 44 | $service->setSession($session); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Command/RedisServerCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:redis-server') 23 | ->setDescription('运行 Redis MCP 服务器') 24 | ->setHelp('此命令启动一个 Redis MCP 服务器,提供 Redis 操作功能') 25 | ->addOption( 26 | 'host', 27 | null, 28 | InputOption::VALUE_REQUIRED, 29 | 'Redis 服务器地址', 30 | 'localhost' 31 | ) 32 | ->addOption( 33 | 'db-port', 34 | null, 35 | InputOption::VALUE_REQUIRED, 36 | 'Redis 服务器端口', 37 | '6379' 38 | ) 39 | ->addOption( 40 | 'database', 41 | null, 42 | InputOption::VALUE_REQUIRED, 43 | 'Redis 数据库编号 (0-15)', 44 | '0' 45 | ); 46 | } 47 | 48 | protected function configService(mixed $service, ServerRunner $runner, InputInterface $input, OutputInterface $output) 49 | { 50 | $host = getenv('REDIS_HOST') ?: $input->getOption('host'); 51 | $port = (int)(getenv('REDIS_PORT') ?: $input->getOption('db-port')); 52 | $database = (int)(getenv('REDIS_DATABASE') ?: $input->getOption('database')); 53 | 54 | /** @var RedisService $service */ 55 | $service->setConfig($host, $port, $database); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Service/Base64Service.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'type' => 'string', 21 | 'description' => '要编码的文本', 22 | 'required' => true 23 | ] 24 | ] 25 | )] 26 | public function encode(string $text): string 27 | { 28 | return base64_encode($text); 29 | } 30 | 31 | /** 32 | * Base64解码 33 | */ 34 | #[Tool( 35 | name: 'decode', 36 | description: '将Base64编码转换为文本', 37 | parameters: [ 38 | 'base64' => [ 39 | 'type' => 'string', 40 | 'description' => '要解码的Base64字符串', 41 | 'required' => true 42 | ] 43 | ] 44 | )] 45 | public function decode(string $base64): string 46 | { 47 | $decoded = base64_decode($base64, true); 48 | if ($decoded === false) { 49 | throw new \InvalidArgumentException('无效的Base64编码'); 50 | } 51 | return $decoded; 52 | } 53 | 54 | /** 55 | * Base64转图片 56 | */ 57 | #[Tool( 58 | name: 'to-image', 59 | description: '将Base64编码转换为图片', 60 | parameters: [ 61 | 'base64' => [ 62 | 'type' => 'string', 63 | 'description' => '图片的Base64编码字符串(可以包含data URI scheme前缀)', 64 | 'required' => true 65 | ] 66 | ] 67 | )] 68 | public function toImage(string $base64): ImageContent 69 | { 70 | // 移除data URI scheme前缀 71 | if (preg_match('/^data:image\/(\w+);base64,/', $base64, $matches)) { 72 | $base64 = preg_replace('/^data:image\/(\w+);base64,/', '', $base64); 73 | } 74 | 75 | // 解码Base64 76 | $imageData = base64_decode($base64, true); 77 | if ($imageData === false) { 78 | throw new \InvalidArgumentException('无效的Base64编码'); 79 | } 80 | 81 | // 检测图片类型 82 | $f = finfo_open(); 83 | $mimeType = finfo_buffer($f, $imageData, FILEINFO_MIME_TYPE); 84 | finfo_close($f); 85 | 86 | if (!$mimeType || strpos($mimeType, 'image/') !== 0) { 87 | throw new \InvalidArgumentException('无效的图片数据'); 88 | } 89 | 90 | return new ImageContent($base64, $mimeType); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Command/MySqlServerCommand.php: -------------------------------------------------------------------------------- 1 | setName('mcp:mysql-server') 27 | ->setDescription('运行MySQL工具服务器') 28 | ->setHelp('此命令启动一个MySQL工具服务器,提供数据库查询服务') 29 | ->addOption( 30 | 'host', 31 | null, 32 | InputOption::VALUE_REQUIRED, 33 | '数据库主机', 34 | 'localhost' 35 | ) 36 | ->addOption( 37 | 'db-port', 38 | null, 39 | InputOption::VALUE_REQUIRED, 40 | '数据库端口', 41 | '3306' 42 | ) 43 | ->addOption( 44 | 'username', 45 | 'u', 46 | InputOption::VALUE_REQUIRED, 47 | '数据库用户名', 48 | 'root' 49 | ) 50 | ->addOption( 51 | 'password', 52 | 'p', 53 | InputOption::VALUE_REQUIRED, 54 | '数据库密码', 55 | '' 56 | ) 57 | ->addOption( 58 | 'database', 59 | 'd', 60 | InputOption::VALUE_REQUIRED, 61 | '数据库名称', 62 | 'mysql' 63 | ); 64 | } 65 | 66 | protected function configService(mixed $service, ServerRunner $runner, InputInterface $input, OutputInterface $output) 67 | { 68 | $host = getenv('DB_HOST') ?: $input->getOption('host') ?: 'localhost'; 69 | $port = (int)(getenv('DB_PORT') ?: $input->getOption('db-port') ?: 3306); 70 | $username = getenv('DB_USERNAME') ?: $input->getOption('username') ?: 'root'; 71 | $password = getenv('DB_PASSWORD') ?: $input->getOption('password') ?: ''; 72 | $database = getenv('DB_DATABASE') ?: $input->getOption('database') ?: 'mysql'; 73 | 74 | /** @var MySqlService $service */ 75 | $service->setConfig($host, $username, $password, $database, $port); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Tool/CommandDiscoverer.php: -------------------------------------------------------------------------------- 1 | isAbstract()) { 68 | continue; 69 | } 70 | 71 | // 确保类是Command的子类 72 | if ($reflectionClass->isSubclassOf(Command::class)) { 73 | $commands[] = $fullyQualifiedClassName; 74 | } 75 | } 76 | 77 | return $commands; 78 | } 79 | 80 | /** 81 | * 注册命令到应用程序 82 | * 83 | * @param Application $application Symfony Console应用程序实例 84 | * @param array $commandClasses 命令类列表 85 | * @return void 86 | */ 87 | public static function registerCommands(Application $application, array $commandClasses): void 88 | { 89 | foreach ($commandClasses as $commandClass) { 90 | try { 91 | $command = new $commandClass(); 92 | $application->add($command); 93 | } catch (\Exception $e) { 94 | // 记录错误但继续注册其他命令 95 | error_log("无法加载命令类 {$commandClass}: " . $e->getMessage()); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Command/AbstractMcpServerCommand.php: -------------------------------------------------------------------------------- 1 | addOption('port', null, InputOption::VALUE_OPTIONAL, 'Port to listen on for SSE', 8000) 30 | ->addOption('transport', null, InputOption::VALUE_OPTIONAL, 'Transport type', 'stdio'); 31 | } 32 | 33 | /** 34 | * 35 | * @param ServerRunner $runner 36 | * @param mixed $service 37 | * @param InputInterface $input 38 | * @param OutputInterface $output 39 | * @return void 40 | */ 41 | protected function configService(mixed $service, ServerRunner $runner, InputInterface $input, OutputInterface $output) {} 42 | 43 | /** 44 | * 45 | * @param mixed $service 46 | * @param ServerRunner $runner 47 | */ 48 | protected function afterServerRun($service, ServerRunner $runner): void {} 49 | 50 | protected function execute(InputInterface $input, OutputInterface $output): int 51 | { 52 | $transport = $input->getOption('transport'); 53 | if (!in_array($transport, ['stdio', 'sse'])) { 54 | throw new \Exception('Unsupported transport: ' . $transport); 55 | } 56 | 57 | $port = (int)$input->getOption('port'); 58 | 59 | // 创建日志记录器 60 | $logger = LoggerService::createLogger( 61 | $this->serverName, 62 | $this->logFilePath, 63 | false 64 | ); 65 | 66 | // 创建服务器实例 67 | $server = new Server($this->serverName); 68 | 69 | // 配置服务 70 | $className = $this->serviceClass; 71 | /** @var BaseService $service */ 72 | $service = new $className($logger); 73 | 74 | // 创建运行器并配置服务 75 | $runner = new ServerRunner($logger, $transport, '0.0.0.0', $port); 76 | $this->configService($service, $runner, $input, $output); // 先配置服务 77 | 78 | $registrar = new McpHandlerRegistrar(); 79 | $registrar->registerHandler($server, $service); 80 | 81 | // 创建初始化选项并运行服务器 82 | $initOptions = $server->createInitializationOptions(); 83 | 84 | if (extension_loaded('swoole')) { 85 | $result = \Swoole\Coroutine\run(function () use ($runner, $server, $initOptions, $service, $logger) { 86 | try { 87 | $runner->run($server, $initOptions); 88 | $this->afterServerRun($service, $runner); 89 | } catch (\Throwable $e) { 90 | $logger->error("服务器运行失败", ['exception' => $e]); 91 | } 92 | }); 93 | return $result ? Command::SUCCESS : Command::FAILURE; 94 | } else { 95 | try { 96 | $runner->run($server, $initOptions); 97 | $this->afterServerRun($service, $runner); 98 | return Command::SUCCESS; 99 | } catch (\Throwable $e) { 100 | $logger->error("服务器运行失败", ['exception' => $e]); 101 | return Command::FAILURE; 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Service/WeidianBarcodeService.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'type' => 'string', 20 | 'description' => '商品条码', 21 | 'required' => true 22 | ] 23 | ] 24 | )] 25 | public function queryBarcode(string $barcode): string 26 | { 27 | // 获取环境变量中的 cookies 28 | $cookies = getenv('WEIDIAN_COOKIES'); 29 | if (!$cookies) { 30 | throw new \RuntimeException("未设置 WEIDIAN_COOKIES 环境变量"); 31 | } 32 | 33 | try { 34 | // 查询条码信息 35 | $result = $this->queryBarcodeFromApi($barcode, $cookies); 36 | 37 | // 准备返回内容 38 | $lines = [ 39 | "商品信息:", 40 | "----------------------------------------", 41 | sprintf("条码: %s", $result['barcode'] ?? '未知'), 42 | sprintf("商品名称: %s", $result['itemName'] ?? '未知'), 43 | sprintf("价格: ¥%s", $result['price'] ?? '未知'), 44 | sprintf("销量: %s", $result['sold'] ?? '0'), 45 | sprintf("规格: %s", $result['spec'] ?: '默认'), 46 | ]; 47 | 48 | // 添加建议价格信息 49 | if (!empty($result['suggestPrice'])) { 50 | $lines[] = "\n建议价格:"; 51 | $lines[] = "----------------------------------------"; 52 | foreach ($result['suggestPrice'] as $price) { 53 | $percentage = round(floatval($price['rate']) * 100, 1); 54 | $lines[] = sprintf("¥%s (占比 %s%%)", $price['price'], $percentage); 55 | } 56 | } 57 | 58 | // 添加图片信息 59 | if (!empty($result['imgHead'])) { 60 | $lines[] = "\n商品图片:"; 61 | $lines[] = "----------------------------------------"; 62 | $lines[] = sprintf("图片:%s", $result['imgHead']); 63 | } 64 | 65 | return implode("\n", $lines); 66 | } catch (\Exception $e) { 67 | throw new \RuntimeException($e->getMessage()); 68 | } 69 | } 70 | 71 | /** 72 | * 从API查询条码信息 73 | * 74 | * @param string $barcode 条码 75 | * @param string $cookies Cookie信息 76 | * @return array 查询结果 77 | * @throws \RuntimeException 当请求失败时抛出 78 | */ 79 | private function queryBarcodeFromApi(string $barcode, string $cookies): array 80 | { 81 | $url = sprintf( 82 | "https://thor.weidian.com/retailcore/standardItem.query/1.0?param=%%7B%%22barcode%%22%%3A%%22%s%%22%%2C%%22operateType%%22%%3A%%22create%%22%%7D&wdtoken=04df25a2&_=%d", 83 | $barcode, 84 | time() * 1000 85 | ); 86 | 87 | $ch = curl_init(); 88 | curl_setopt_array($ch, [ 89 | CURLOPT_URL => $url, 90 | CURLOPT_RETURNTRANSFER => true, 91 | CURLOPT_SSL_VERIFYPEER => false, 92 | CURLOPT_HTTPHEADER => [ 93 | 'User-Agent: Mozilla/5.0 (Linux; Android 10; VOG-AL00 Build/HUAWEIVOG-AL00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.6834.163 Mobile Safari/537.36 KDJSBridge2/1.1.0 platform/android WDAPP(WD/9.5.50)', 94 | 'Accept: application/json, */*', 95 | 'Origin: https://h5.weidian.com', 96 | 'X-Requested-With: com.weidian.smartstore', 97 | 'Referer: https://h5.weidian.com/', 98 | 'Cookie: ' . $cookies 99 | ] 100 | ]); 101 | 102 | $response = curl_exec($ch); 103 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 104 | curl_close($ch); 105 | 106 | if ($httpCode !== 200) { 107 | throw new \RuntimeException("HTTP请求失败: $httpCode"); 108 | } 109 | 110 | $data = json_decode($response, true); 111 | if (json_last_error() !== JSON_ERROR_NONE) { 112 | throw new \RuntimeException('解析JSON响应失败'); 113 | } 114 | 115 | if (!isset($data['result'])) { 116 | throw new \RuntimeException('接口返回数据格式错误: ' . $response); 117 | } 118 | 119 | return $data['result']; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Service/RedisService.php: -------------------------------------------------------------------------------- 1 | host = $host; 27 | $this->port = $port; 28 | $this->database = $database; 29 | $this->initRedis(); 30 | } 31 | 32 | private function initRedis(): void 33 | { 34 | $this->redis = new Redis(); 35 | 36 | if (!$this->redis->connect($this->host, $this->port)) { 37 | throw new \RuntimeException("无法连接到 Redis 服务器"); 38 | } 39 | 40 | if ($this->database > 0) { 41 | if (!$this->redis->select($this->database)) { 42 | throw new \RuntimeException("无法切换到数据库 {$this->database}"); 43 | } 44 | } 45 | } 46 | 47 | #[Tool( 48 | name: 'set', 49 | description: '设置 Redis 键值对,可选过期时间', 50 | parameters: [ 51 | 'key' => [ 52 | 'type' => 'string', 53 | 'description' => 'Redis 键', 54 | 'required' => true 55 | ], 56 | 'value' => [ 57 | 'type' => 'string', 58 | 'description' => '要存储的值', 59 | 'required' => true 60 | ], 61 | 'expireSeconds' => [ 62 | 'type' => 'integer', 63 | 'description' => '可选的过期时间(秒)', 64 | 'required' => false 65 | ] 66 | ] 67 | )] 68 | public function set(string $key, string $value, ?int $expireSeconds = null): string 69 | { 70 | try { 71 | if ($expireSeconds !== null) { 72 | $result = $this->redis->setex($key, $expireSeconds, $value); 73 | } else { 74 | $result = $this->redis->set($key, $value); 75 | } 76 | 77 | if (!$result) { 78 | throw new \RuntimeException("设置键值对失败"); 79 | } 80 | 81 | return "成功设置键: {$key}"; 82 | } catch (\Exception $e) { 83 | throw new \RuntimeException("Redis 操作失败: " . $e->getMessage()); 84 | } 85 | } 86 | 87 | #[Tool( 88 | name: 'get', 89 | description: '获取 Redis 键的值', 90 | parameters: [ 91 | 'key' => [ 92 | 'type' => 'string', 93 | 'description' => '要获取的 Redis 键', 94 | 'required' => true 95 | ] 96 | ] 97 | )] 98 | public function get(string $key): string 99 | { 100 | try { 101 | $value = $this->redis->get($key); 102 | 103 | if ($value === false) { 104 | return "未找到键: {$key}"; 105 | } 106 | 107 | return $value; 108 | } catch (\Exception $e) { 109 | throw new \RuntimeException("Redis 操作失败: " . $e->getMessage()); 110 | } 111 | } 112 | 113 | #[Tool( 114 | name: 'delete', 115 | description: '删除一个或多个 Redis 键', 116 | parameters: [ 117 | 'key' => [ 118 | 'type' => ['string', 'array'], 119 | 'description' => '要删除的键或键数组', 120 | 'required' => true 121 | ] 122 | ] 123 | )] 124 | public function delete(string|array $key): string 125 | { 126 | try { 127 | if (is_array($key)) { 128 | $count = $this->redis->del($key); 129 | return "成功删除 {$count} 个键"; 130 | } else { 131 | $count = $this->redis->del($key); 132 | return "成功删除键: {$key}"; 133 | } 134 | } catch (\Exception $e) { 135 | throw new \RuntimeException("Redis 操作失败: " . $e->getMessage()); 136 | } 137 | } 138 | 139 | #[Tool( 140 | name: 'list', 141 | description: '列出匹配模式的 Redis 键', 142 | parameters: [ 143 | 'pattern' => [ 144 | 'type' => 'string', 145 | 'description' => '匹配键的模式(默认:*)', 146 | 'required' => false 147 | ] 148 | ] 149 | )] 150 | public function list(string $pattern = '*'): string 151 | { 152 | try { 153 | $keys = $this->redis->keys($pattern); 154 | 155 | if (empty($keys)) { 156 | return "未找到匹配模式的键"; 157 | } 158 | 159 | return "找到的键:\n" . implode("\n", $keys); 160 | } catch (\Exception $e) { 161 | throw new \RuntimeException("Redis 操作失败: " . $e->getMessage()); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Service/MySqlService.php: -------------------------------------------------------------------------------- 1 | host = $host; 29 | $this->username = $username; 30 | $this->password = $password; 31 | $this->database = $database; 32 | $this->port = $port; 33 | } 34 | 35 | #[Tool( 36 | name: 'list_tables', 37 | description: '列出数据库中的所有表' 38 | )] 39 | public function listTables(): string 40 | { 41 | $pdo = $this->getDatabaseConnection(); 42 | $stmt = $pdo->query('SHOW TABLES'); 43 | $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); 44 | 45 | // 限制结果集大小以防止内存问题 46 | if (count($tables) > 1000) { 47 | $tables = array_slice($tables, 0, 1000); 48 | $tablesText = "数据库表列表 (仅显示前1000个):\n\n"; 49 | } else { 50 | $tablesText = "数据库表列表:\n\n"; 51 | } 52 | 53 | $tablesText .= "| 序号 | 表名 |\n"; 54 | $tablesText .= "|------|------|\n"; 55 | 56 | foreach ($tables as $index => $table) { 57 | $tablesText .= "| " . ($index + 1) . " | " . $table . " |\n"; 58 | } 59 | 60 | return $tablesText; 61 | } 62 | 63 | #[Tool( 64 | name: 'describe-table', 65 | description: '描述指定表的结构', 66 | parameters: [ 67 | 'tableName' => [ 68 | 'type' => 'string', 69 | 'description' => '表名', 70 | 'required' => true 71 | ] 72 | ] 73 | )] 74 | public function describeTable(string $tableName): string 75 | { 76 | $pdo = $this->getDatabaseConnection(); 77 | 78 | // 验证表名以防止SQL注入 79 | if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) { 80 | throw new \InvalidArgumentException("无效的表名"); 81 | } 82 | 83 | $stmt = $pdo->prepare('DESCRIBE ' . $tableName); 84 | $stmt->execute(); 85 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 86 | 87 | if (empty($columns)) { 88 | throw new \Exception("表 '{$tableName}' 不存在或没有列"); 89 | } 90 | 91 | // 将表结构格式化为表格字符串 92 | $tableDesc = "表 '{$tableName}' 的结构:\n\n"; 93 | $tableDesc .= "| 字段 | 类型 | 允许为空 | 键 | 默认值 | 额外 |\n"; 94 | $tableDesc .= "|------|------|----------|-----|--------|------|\n"; 95 | 96 | foreach ($columns as $column) { 97 | $tableDesc .= "| " . $column['Field'] . " | " 98 | . $column['Type'] . " | " 99 | . $column['Null'] . " | " 100 | . $column['Key'] . " | " 101 | . ($column['Default'] === null ? 'NULL' : $column['Default']) . " | " 102 | . $column['Extra'] . " |\n"; 103 | } 104 | 105 | return $tableDesc; 106 | } 107 | 108 | #[Tool( 109 | name: 'read_query', 110 | description: '执行SQL查询并返回结果', 111 | parameters: [ 112 | 'sql' => [ 113 | 'type' => 'string', 114 | 'description' => 'SQL查询语句', 115 | 'required' => true 116 | ] 117 | ] 118 | )] 119 | public function readQuery(string $sql): string 120 | { 121 | $pdo = $this->getDatabaseConnection(); 122 | 123 | // 只允许SELECT查询以确保安全 124 | if (!preg_match('/^SELECT\s/i', trim($sql))) { 125 | throw new \InvalidArgumentException("只允许SELECT查询"); 126 | } 127 | 128 | // 限制查询以防止大型结果集 129 | if (strpos(strtoupper($sql), 'LIMIT') === false) { 130 | $sql .= ' LIMIT 1000'; 131 | $limitAdded = true; 132 | } else { 133 | $limitAdded = false; 134 | } 135 | 136 | $stmt = $pdo->prepare($sql); 137 | $stmt->execute(); 138 | $results = $stmt->fetchAll(PDO::FETCH_ASSOC); 139 | 140 | if (empty($results)) { 141 | return "查询执行成功,但没有返回结果。"; 142 | } 143 | 144 | // 从结果中提取列名 145 | $columns = array_keys($results[0]); 146 | 147 | // 构建表格标题 148 | $resultText = "查询结果"; 149 | if ($limitAdded) { 150 | $resultText .= " (已自动添加LIMIT 1000)"; 151 | } 152 | $resultText .= ":\n\n"; 153 | 154 | $resultText .= "| " . implode(" | ", $columns) . " |\n"; 155 | $resultText .= "| " . implode(" | ", array_map(function ($col) { 156 | return str_repeat("-", mb_strlen($col)); 157 | }, $columns)) . " |\n"; 158 | 159 | // 添加数据行 160 | $rowCount = 0; 161 | $maxRows = 100; // 限制显示的行数 162 | 163 | foreach ($results as $row) { 164 | if ($rowCount++ >= $maxRows) { 165 | break; 166 | } 167 | 168 | $resultText .= "| " . implode(" | ", array_map(function ($val) { 169 | if ($val === null) { 170 | return 'NULL'; 171 | } elseif (is_string($val) && mb_strlen($val) > 100) { 172 | return mb_substr($val, 0, 97) . '...'; 173 | } else { 174 | return (string)$val; 175 | } 176 | }, $row)) . " |\n"; 177 | } 178 | 179 | $totalRows = count($results); 180 | $resultText .= "\n共返回 " . $totalRows . " 条记录"; 181 | 182 | if ($rowCount < $totalRows) { 183 | $resultText .= ",仅显示前 " . $rowCount . " 条"; 184 | } 185 | 186 | return $resultText; 187 | } 188 | 189 | /** 190 | * 获取数据库连接 191 | */ 192 | private function getDatabaseConnection(): PDO 193 | { 194 | // 验证环境变量 195 | if (!$this->username || !$this->database) { 196 | throw new \Exception("数据库连接信息不完整,请设置必要的环境变量"); 197 | } 198 | 199 | try { 200 | $dsn = "mysql:host={$this->host};port={$this->port};dbname={$this->database};charset=utf8mb4"; 201 | $options = [ 202 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 203 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 204 | PDO::ATTR_EMULATE_PREPARES => false, 205 | ]; 206 | return new PDO($dsn, $this->username, $this->password, $options); 207 | } catch (\PDOException $e) { 208 | throw new \Exception("数据库连接失败: " . $e->getMessage()); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP MCP Server 2 | 3 | [English Version](README.en.md) 4 | 5 | 这是一个基于 PHP 实现的 MCP (Model Control Protocol) 服务器框架,支持通过注解优雅地定义 MCP 服务。 6 | 7 | ## 项目概述 8 | 9 | 本项目提供了一个完整的 MCP 服务器实现,特色功能: 10 | 11 | - 基于注解的 MCP 服务定义 12 | - 支持 Tool、Prompt、Resource 三种处理器 13 | - 支持 Stdio、Sse 两种传输方式 14 | - 支持 [Swow](https://github.com/swow/swow) 和 [Swoole](https://github.com/swoole/swoole-src) 两种环境 15 | - 完整的日志系统 16 | - Docker 支持 17 | 18 | ## 系统要求 19 | 20 | - PHP >= 8.1 21 | - Composer 22 | - Swow 扩展 > 1.5 或 Swoole > 5.1 23 | - Docker (可选) 24 | 25 | ## 快速开始 26 | 27 | ### 安装 28 | 29 | ```bash 30 | # 1. 克隆项目 31 | git clone https://github.com/he426100/php-mcp-server 32 | cd php-mcp-server 33 | 34 | # 2. 安装依赖 35 | composer install 36 | 37 | # 3. 可选,安装 Swow 扩展(如果没有) 38 | ./vendor/bin/swow-builder --install 39 | ``` 40 | 41 | > 关于 Swow 扩展的详细安装说明,请参考 [Swow 官方文档](https://github.com/swow/swow) 42 | 43 | ### 运行示例服务器 44 | 45 | ```bash 46 | php bin/console mcp:test-server 47 | ``` 48 | 49 | #### 通用命令参数 50 | 51 | | Parameter | Description | Default Value | Options | 52 | |-----------|-------------|---------------|---------| 53 | | --transport | Transport type | stdio | stdio, sse | 54 | | --port | Port to listen on for SSE | 8000 | | 55 | 56 | 57 | ## 注解使用指南 58 | 59 | 本框架提供三种核心注解用于定义 MCP 服务: 60 | 61 | ### 1. Tool 注解 62 | 63 | 用于定义工具类处理器: 64 | 65 | ```php 66 | use Mcp\Annotation\Tool; 67 | 68 | class MyService { 69 | #[Tool( 70 | name: 'calculate-sum', 71 | description: '计算两个数的和', 72 | parameters: [ 73 | 'num1' => [ 74 | 'type' => 'number', 75 | 'description' => '第一个数字', 76 | 'required' => true 77 | ], 78 | 'num2' => [ 79 | 'type' => 'number', 80 | 'description' => '第二个数字', 81 | 'required' => true 82 | ] 83 | ] 84 | )] 85 | public function sum(int $num1, int $num2): int 86 | { 87 | return $num1 + $num2; 88 | } 89 | } 90 | ``` 91 | 92 | ### 2. Prompt 注解 93 | 94 | 用于定义提示模板处理器: 95 | 96 | ```php 97 | use Mcp\Annotation\Prompt; 98 | 99 | class MyService { 100 | #[Prompt( 101 | name: 'greeting', 102 | description: '生成问候语', 103 | arguments: [ 104 | 'name' => [ 105 | 'description' => '要问候的人名', 106 | 'required' => true 107 | ] 108 | ] 109 | )] 110 | public function greeting(string $name): string 111 | { 112 | return "Hello, {$name}!"; 113 | } 114 | } 115 | ``` 116 | 117 | ### 3. Resource 注解 118 | 119 | 用于定义资源处理器: 120 | 121 | ```php 122 | use Mcp\Annotation\Resource; 123 | 124 | class MyService { 125 | #[Resource( 126 | uri: 'example://greeting', 127 | name: 'Greeting Text', 128 | description: '问候语资源', 129 | mimeType: 'text/plain' 130 | )] 131 | public function getGreeting(): string 132 | { 133 | return "Hello from MCP server!"; 134 | } 135 | } 136 | ``` 137 | 138 | ## 创建自定义服务 139 | 140 | 1. 创建服务类: 141 | 142 | ```php 143 | namespace Your\Namespace; 144 | 145 | use Mcp\Annotation\Tool; 146 | use Mcp\Annotation\Prompt; 147 | use Mcp\Annotation\Resource; 148 | 149 | class CustomService 150 | { 151 | #[Tool(name: 'custom-tool', description: '自定义工具')] 152 | public function customTool(): string 153 | { 154 | return "Custom tool result"; 155 | } 156 | } 157 | ``` 158 | 159 | 2. 创建命令类: 160 | 161 | ```php 162 | namespace Your\Namespace\Command; 163 | 164 | use He426100\McpServer\Command\AbstractMcpServerCommand; 165 | use Your\Namespace\CustomService; 166 | 167 | class CustomServerCommand extends AbstractMcpServerCommand 168 | { 169 | protected string $serverName = 'custom-server'; 170 | protected string $serviceClass = CustomService::class; 171 | 172 | protected function configure(): void 173 | { 174 | parent::configure(); 175 | $this->setName('custom:server') 176 | ->setDescription('运行自定义 MCP 服务器'); 177 | } 178 | } 179 | ``` 180 | 181 | ## 注解参数说明 182 | 183 | ### Tool 注解参数 184 | 185 | | 参数 | 类型 | 说明 | 必填 | 186 | |------|------|------|------| 187 | | name | string | 工具名称 | 是 | 188 | | description | string | 工具描述 | 是 | 189 | | parameters | array | 参数定义 | 否 | 190 | 191 | ### Prompt 注解参数 192 | 193 | | 参数 | 类型 | 说明 | 必填 | 194 | |------|------|------|------| 195 | | name | string | 提示模板名称 | 是 | 196 | | description | string | 提示模板描述 | 是 | 197 | | arguments | array | 参数定义 | 否 | 198 | 199 | ### Resource 注解参数 200 | 201 | | 参数 | 类型 | 说明 | 必填 | 202 | |------|------|------|------| 203 | | uri | string | 资源URI | 是 | 204 | | name | string | 资源名称 | 是 | 205 | | description | string | 资源描述 | 是 | 206 | | mimeType | string | MIME类型 | 否 | 207 | 208 | ## 注解函数返回类型说明 209 | 210 | ### Tool 注解函数支持的返回类型 211 | 212 | | 返回类型 | 说明 | 转换结果 | 213 | |---------|------|---------| 214 | | TextContent/ImageContent/EmbeddedResource | 直接返回内容对象 | 原样保留 | 215 | | TextContent/ImageContent/EmbeddedResource 数组 | 内容对象数组 | 原样保留 | 216 | | ResourceContents | 资源内容对象 | 转换为 EmbeddedResource | 217 | | 字符串或标量类型 | 如 string、int、float、bool | 转换为 TextContent | 218 | | null | 空值 | 转换为空字符串的 TextContent | 219 | | 数组或对象 | 复杂数据结构 | 转换为 JSON 格式的 TextContent | 220 | 221 | ### Prompt 注解函数支持的返回类型 222 | 223 | | 返回类型 | 说明 | 转换结果 | 224 | |---------|------|---------| 225 | | PromptMessage | 消息对象 | 原样保留 | 226 | | PromptMessage 数组 | 消息对象数组 | 原样保留 | 227 | | Content 对象 | TextContent/ImageContent 等 | 转换为用户角色的 PromptMessage | 228 | | 字符串或标量类型 | 如 string、int、float、bool | 转换为带 TextContent 的用户消息 | 229 | | null | 空值 | 转换为空内容的用户消息 | 230 | | 数组或对象 | 复杂数据结构 | 转换为 JSON 格式的用户消息 | 231 | 232 | ### Resource 注解函数支持的返回类型 233 | 234 | | 返回类型 | 说明 | 转换结果 | 235 | |---------|------|---------| 236 | | TextResourceContents/BlobResourceContents | 资源内容对象 | 原样保留 | 237 | | ResourceContents 数组 | 资源内容对象数组 | 原样保留 | 238 | | 字符串或可转字符串对象 | 文本内容 | 根据 MIME 类型转换为对应资源内容 | 239 | | null | 空值 | 转换为空的 TextResourceContents | 240 | | 数组或对象 | 复杂数据结构 | 转换为 JSON 格式的资源内容 | 241 | 242 | 注意事项: 243 | - 对于超过 2MB 的大文件内容会自动截断 244 | - 文本类型 (text/*) 的 MIME 类型会使用 TextResourceContents 245 | - 其他 MIME 类型会使用 BlobResourceContents 246 | 247 | ## 日志配置 248 | 249 | 服务器日志默认保存在 `runtime/server_log.txt`,可通过继承 `AbstractMcpServerCommand` 修改: 250 | 251 | ```php 252 | protected string $logFilePath = '/custom/path/to/log.txt'; 253 | ``` 254 | 255 | ## Docker 支持 256 | 257 | 构建并运行容器: 258 | 259 | ```bash 260 | docker build -t php-mcp-server . 261 | docker run --name=php-mcp-server -p 8000:8000 -itd php-mcp-server mcp:test-server --transport sse 262 | ``` 263 | 264 | sse地址:http://127.0.0.1:8000/sse 265 | 266 | ## 通过 CPX 使用 267 | 268 | 您可以通过 [CPX (Composer Package Executor)](https://github.com/imliam/cpx) 直接运行本项目,无需事先安装: 269 | 270 | ### 前提条件 271 | 272 | 1. 全局安装 CPX: 273 | ```bash 274 | composer global require cpx/cpx 275 | ``` 276 | 277 | 2. 确保 Composer 的全局 bin 目录在您的 PATH 中 278 | 279 | ### 使用方式 280 | 281 | ```bash 282 | # 运行测试服务器 283 | cpx he426100/php-mcp-server mcp:test-server 284 | 285 | # 使用 SSE 传输模式 286 | cpx he426100/php-mcp-server mcp:test-server --transport=sse 287 | 288 | # 查看可用命令 289 | cpx he426100/php-mcp-server list 290 | ``` 291 | 292 | ## 许可证 293 | 294 | [MIT License](LICENSE) 295 | 296 | ## 贡献 297 | 298 | 欢迎提交 Issue 和 Pull Request。 299 | 300 | ## 作者 301 | 302 | [he426100](https://github.com/he426100/) 303 | [logiscape](https://github.com/logiscape/mcp-sdk-php) 304 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # PHP MCP Server 2 | 3 | [中文版本](README.md) 4 | 5 | This is a PHP-based MCP (Model Control Protocol) server framework that supports elegant MCP service definition through annotations. 6 | 7 | ## Project Overview 8 | 9 | This project provides a complete MCP server implementation with the following features: 10 | 11 | - Annotation-based MCP service definition 12 | - Support for Tool, Prompt, and Resource handlers 13 | - Support Stdio、Sse transport 14 | - Support [Swow](https://github.com/swow/swow) or [Swoole](https://github.com/swoole/swoole-src) 15 | - Complete logging system 16 | - Docker support 17 | 18 | ## System Requirements 19 | 20 | - PHP >= 8.1 21 | - Composer 22 | - Swow extension > 1.5 or Swoole > 5.1 23 | - Docker (optional) 24 | 25 | ## Quick Start 26 | 27 | ### Installation 28 | 29 | ```bash 30 | # 1. Clone the project 31 | git clone https://github.com/he426100/php-mcp-server 32 | cd php-mcp-server 33 | 34 | # 2. Install dependencies 35 | composer install 36 | 37 | # 3. optional, Install Swow extension (if not installed) 38 | ./vendor/bin/swow-builder --install 39 | ``` 40 | 41 | > For detailed installation instructions for the Swow extension, please refer to the [Swow Official Documentation](https://github.com/swow/swow) 42 | 43 | ### Run Example Server 44 | 45 | ```bash 46 | php bin/console mcp:test-server 47 | ``` 48 | 49 | #### Server Command Parameters 50 | 51 | | Parameter | Description | Default Value | Options | 52 | |-----------|-------------|---------------|---------| 53 | | --transport | Transport type | stdio | stdio, sse | 54 | | --port | Port to listen on for SSE | 8000 | | 55 | 56 | 57 | ## Annotation Usage Guide 58 | 59 | This framework provides three core annotations for defining MCP services: 60 | 61 | ### 1. Tool Annotation 62 | 63 | Used to define tool-type handlers: 64 | 65 | ```php 66 | use Mcp\Annotation\Tool; 67 | 68 | class MyService { 69 | #[Tool( 70 | name: 'calculate-sum', 71 | description: 'Calculate the sum of two numbers', 72 | parameters: [ 73 | 'num1' => [ 74 | 'type' => 'number', 75 | 'description' => 'First number', 76 | 'required' => true 77 | ], 78 | 'num2' => [ 79 | 'type' => 'number', 80 | 'description' => 'Second number', 81 | 'required' => true 82 | ] 83 | ] 84 | )] 85 | public function sum(int $num1, int $num2): int 86 | { 87 | return $num1 + $num2; 88 | } 89 | } 90 | ``` 91 | 92 | ### 2. Prompt Annotation 93 | 94 | Used to define prompt template handlers: 95 | 96 | ```php 97 | use Mcp\Annotation\Prompt; 98 | 99 | class MyService { 100 | #[Prompt( 101 | name: 'greeting', 102 | description: 'Generate a greeting message', 103 | arguments: [ 104 | 'name' => [ 105 | 'description' => 'Name to greet', 106 | 'required' => true 107 | ] 108 | ] 109 | )] 110 | public function greeting(string $name): string 111 | { 112 | return "Hello, {$name}!"; 113 | } 114 | } 115 | ``` 116 | 117 | ### 3. Resource Annotation 118 | 119 | Used to define resource handlers: 120 | 121 | ```php 122 | use Mcp\Annotation\Resource; 123 | 124 | class MyService { 125 | #[Resource( 126 | uri: 'example://greeting', 127 | name: 'Greeting Text', 128 | description: 'Greeting resource', 129 | mimeType: 'text/plain' 130 | )] 131 | public function getGreeting(): string 132 | { 133 | return "Hello from MCP server!"; 134 | } 135 | } 136 | ``` 137 | 138 | ## Creating Custom Services 139 | 140 | 1. Create a service class: 141 | 142 | ```php 143 | namespace Your\Namespace; 144 | 145 | use Mcp\Annotation\Tool; 146 | use Mcp\Annotation\Prompt; 147 | use Mcp\Annotation\Resource; 148 | 149 | class CustomService 150 | { 151 | #[Tool(name: 'custom-tool', description: 'Custom tool')] 152 | public function customTool(): string 153 | { 154 | return "Custom tool result"; 155 | } 156 | } 157 | ``` 158 | 159 | 2. Create a command class: 160 | 161 | ```php 162 | namespace Your\Namespace\Command; 163 | 164 | use He426100\McpServer\Command\AbstractMcpServerCommand; 165 | use Your\Namespace\CustomService; 166 | 167 | class CustomServerCommand extends AbstractMcpServerCommand 168 | { 169 | protected string $serverName = 'custom-server'; 170 | protected string $serviceClass = CustomService::class; 171 | 172 | protected function configure(): void 173 | { 174 | parent::configure(); 175 | $this->setName('custom:server') 176 | ->setDescription('Run custom MCP server'); 177 | } 178 | } 179 | ``` 180 | 181 | ## Annotation Parameters 182 | 183 | ### Tool Annotation Parameters 184 | 185 | | Parameter | Type | Description | Required | 186 | |-----------|------|-------------|----------| 187 | | name | string | Tool name | Yes | 188 | | description | string | Tool description | Yes | 189 | | parameters | array | Parameter definitions | No | 190 | 191 | ### Prompt Annotation Parameters 192 | 193 | | Parameter | Type | Description | Required | 194 | |-----------|------|-------------|----------| 195 | | name | string | Prompt template name | Yes | 196 | | description | string | Prompt template description | Yes | 197 | | arguments | array | Parameter definitions | No | 198 | 199 | ### Resource Annotation Parameters 200 | 201 | | Parameter | Type | Description | Required | 202 | |-----------|------|-------------|----------| 203 | | uri | string | Resource URI | Yes | 204 | | name | string | Resource name | Yes | 205 | | description | string | Resource description | Yes | 206 | | mimeType | string | MIME type | No | 207 | 208 | ## Annotation Function Return Types 209 | 210 | ### Tool Annotation Function Supported Return Types 211 | 212 | | Return Type | Description | Conversion Result | 213 | |-------------|-------------|-------------------| 214 | | TextContent/ImageContent/EmbeddedResource | Direct content object | Preserved as-is | 215 | | TextContent/ImageContent/EmbeddedResource array | Array of content objects | Preserved as-is | 216 | | ResourceContents | Resource content object | Converted to EmbeddedResource | 217 | | String or scalar type | string, int, float, bool | Converted to TextContent | 218 | | null | Empty value | Converted to TextContent with empty string | 219 | | Array or object | Complex data structure | Converted to TextContent with JSON format | 220 | 221 | ### Prompt Annotation Function Supported Return Types 222 | 223 | | Return Type | Description | Conversion Result | 224 | |-------------|-------------|-------------------| 225 | | PromptMessage | Message object | Preserved as-is | 226 | | PromptMessage array | Array of message objects | Preserved as-is | 227 | | Content object | TextContent/ImageContent etc. | Converted to PromptMessage with user role | 228 | | String or scalar type | string, int, float, bool | Converted to user message with TextContent | 229 | | null | Empty value | Converted to user message with empty content | 230 | | Array or object | Complex data structure | Converted to user message with JSON format | 231 | 232 | ### Resource Annotation Function Supported Return Types 233 | 234 | | Return Type | Description | Conversion Result | 235 | |-------------|-------------|-------------------| 236 | | TextResourceContents/BlobResourceContents | Resource content object | Preserved as-is | 237 | | ResourceContents array | Array of resource content objects | Preserved as-is | 238 | | String or stringable object | Text content | Converted to appropriate resource content based on MIME type | 239 | | null | Empty value | Converted to empty TextResourceContents | 240 | | Array or object | Complex data structure | Converted to resource content with JSON format | 241 | 242 | Notes: 243 | - Content larger than 2MB will be automatically truncated 244 | - MIME types of text/* will use TextResourceContents 245 | - Other MIME types will use BlobResourceContents 246 | 247 | ## Logging Configuration 248 | 249 | Server logs are saved in `runtime/server_log.txt` by default. This can be modified by extending `AbstractMcpServerCommand`: 250 | 251 | ```php 252 | protected string $logFilePath = '/custom/path/to/log.txt'; 253 | ``` 254 | 255 | ## Docker Support 256 | 257 | Build and run container: 258 | 259 | ```bash 260 | docker build -t php-mcp-server . 261 | docker run -i --rm php-mcp-server 262 | ``` 263 | 264 | ## Using with CPX 265 | 266 | You can run this project directly using [CPX (Composer Package Executor)](https://github.com/imliam/cpx) without having to install it first: 267 | 268 | ### Prerequisites 269 | 270 | 1. Install CPX globally: 271 | ```bash 272 | composer global require cpx/cpx 273 | ``` 274 | 275 | 2. Ensure Composer's global bin directory is in your PATH 276 | 277 | ### Usage 278 | 279 | ```bash 280 | # Run the test server 281 | cpx he426100/php-mcp-server mcp:test-server 282 | 283 | # Use SSE transport mode 284 | cpx he426100/php-mcp-server mcp:test-server --transport=sse 285 | 286 | # View available commands 287 | cpx he426100/php-mcp-server list 288 | ``` 289 | 290 | ## License 291 | 292 | [MIT License](LICENSE) 293 | 294 | ## Contributing 295 | 296 | Issues and Pull Requests are welcome. 297 | 298 | ## Authors 299 | 300 | [he426100](https://github.com/he426100/) 301 | [logiscape](https://github.com/logiscape/mcp-sdk-php) 302 | -------------------------------------------------------------------------------- /src/Service/TobaccoSearchService.php: -------------------------------------------------------------------------------- 1 | [ 34 | 'description' => '搜索关键词 (可以是产品名称、条码等)', 35 | 'required' => true, 36 | 'type' => 'string' // Explicitly define type for clarity 37 | ] 38 | ] 39 | )] 40 | public function searchTobaccoProducts(string $keywords): array 41 | { 42 | $this->logger->info('Starting tobacco search for keywords: {keywords}', ['keywords' => $keywords]); 43 | $results = []; 44 | $browser = null; 45 | $page = null; // Main page for search results 46 | 47 | try { 48 | // Connect to Chrome (ensure Chrome is running with --remote-debugging-port=9222) 49 | // Alternatively, use BrowserFactory('/path/to/chrome/or/chromium') to launch 50 | $browserFactory = new BrowserFactory(); 51 | $browser = $browserFactory->createBrowser([ 52 | 'remoteDebuggingPort' => self::CHROME_PORT, 53 | 'windowSize' => [1280, 800], // Optional: Set window size 54 | 'enableImages' => false, // Optional: Disable images for speed 55 | 'keepAlive' => true, // Keep alive after script finishes? Set to false if you want it to close 56 | 'noSandbox' => true, // Often needed when running as root or in containers 57 | 'headless' => true, 58 | 'proxyServer' => '127.0.0.1:7897', 59 | 'userDataDir' => BASE_PATH . '/runtime/chrome-user-data', 60 | 'customFlags' => ['--lang=zh-CN', '--enable-automation=false', '--disable-blink-features=AutomationControlled'], 61 | ]); 62 | 63 | $page = $browser->createPage(); 64 | 65 | // 1. Perform Google Search 66 | $searchUrl = sprintf(self::GOOGLE_SEARCH_URL_TEMPLATE, urlencode($keywords)); 67 | $this->logger->info('Navigating to Google search: {url}', ['url' => $searchUrl]); 68 | $page->navigate($searchUrl)->waitForNavigation(); 69 | 70 | // 2. Extract top etmoc.com product links 71 | // Google selectors can change! This is an example, inspect Google results page if it breaks. 72 | // Common selectors: div.g a, div.yuRUbf a, h3.LC20lb 73 | $this->logger->info('Extracting search result links from Google'); 74 | $jsGetLinks = << { 76 | const links = []; 77 | // Try a few common selectors for Google results 78 | const selectors = ['div.yuRUbf a', 'div.g div.rc a', 'h3.LC20lb a']; 79 | let linkElements = []; 80 | for (const selector of selectors) { 81 | linkElements = Array.from(document.querySelectorAll(selector)); 82 | if (linkElements.length > 0) break; // Stop if found 83 | } 84 | 85 | linkElements.forEach(a => { 86 | // Ensure it's an absolute URL and points to etmoc.com 87 | if (a.href && a.href.startsWith('http') && a.href.includes('etmoc.com')) { 88 | // Sometimes Google uses redirect URLs, try to get the real one from ping attribute or text 89 | let cleanUrl = a.href; 90 | if (a.ping) { 91 | try { 92 | const urlParams = new URLSearchParams(new URL(a.ping).search); 93 | if (urlParams.has('url')) { 94 | cleanUrl = urlParams.get('url'); 95 | } 96 | } catch (e) { /* ignore parsing errors */ } 97 | } 98 | // Further clean up if needed (remove google tracking params) 99 | if (cleanUrl.includes('etmoc.com')) { // Double check 100 | links.push(cleanUrl); 101 | } 102 | } 103 | }); 104 | return links; 105 | })(); 106 | JS; 107 | 108 | $googleLinks = $page->evaluate($jsGetLinks)->getReturnValue(); 109 | $this->logger->debug('Raw links found on Google: {links}', ['links' => $googleLinks]); 110 | 111 | $productLinks = []; 112 | foreach ($googleLinks as $link) { 113 | if (str_starts_with($link, self::TARGET_PRODUCT_URL_PREFIX)) { 114 | $productLinks[] = $link; 115 | if (count($productLinks) >= self::MAX_RESULTS_TO_OPEN) { 116 | break; 117 | } 118 | } 119 | } 120 | 121 | if (empty($productLinks)) { 122 | $this->logger->warning('No relevant product links found on Google search results for keywords: {keywords}', ['keywords' => $keywords]); 123 | return []; // Return early if no suitable links found 124 | } 125 | 126 | $this->logger->info('Found {count} potential product links to process.', ['count' => count($productLinks)]); 127 | 128 | // 3. Process each product link 129 | $isKeywordBarcode = $this->isBarcode($keywords); 130 | $this->logger->info('Keyword "{keywords}" interpreted as {type}', [ 131 | 'keywords' => $keywords, 132 | 'type' => $isKeywordBarcode ? 'Barcode' : 'Text' 133 | ]); 134 | 135 | foreach ($productLinks as $index => $productUrl) { 136 | $this->logger->info('Processing link #{index}: {url}', ['index' => $index + 1, 'url' => $productUrl]); 137 | $productPage = null; // Use a new page/tab for each product for isolation 138 | 139 | try { 140 | $productPage = $browser->createPage(); 141 | $productPage->navigate($productUrl)->waitForNavigation(Page::LOAD, self::BROWSER_TIMEOUT); 142 | $this->logger->debug('Navigated to product page: {url}', ['url' => $productUrl]); 143 | 144 | // Wait for essential elements to be present 145 | $productPage->waitUntilContainsElement('.col98 .brand-title', self::BROWSER_TIMEOUT / 2); 146 | $productPage->waitUntilContainsElement('.col98 .row .col-8', self::BROWSER_TIMEOUT / 2); 147 | $productPage->waitUntilContainsElement('.col98 .row .col-4 img:first-child', self::BROWSER_TIMEOUT / 2); 148 | 149 | // 4. Scrape data 150 | $title = $this->getText($productPage, '.col98 .brand-title'); 151 | $imageUrl = $this->getAttribute($productPage, '.col98 .row .col-4 img:first-child', 'src'); 152 | $infoText = $this->getText($productPage, '.col98 .row .col-8'); 153 | 154 | if ($title === null || $imageUrl === null || $infoText === null) { 155 | $this->logger->warning('Failed to scrape essential data from {url}', ['url' => $productUrl]); 156 | continue; // Skip if core data missing 157 | } 158 | $this->logger->debug('Scraped data: Title="{title}", Image="{img}", Info Length={len}', ['title' => $title, 'img' => $imageUrl, 'len' => strlen($infoText)]); 159 | 160 | 161 | // 5. Filter results 162 | $keepResult = false; 163 | if ($isKeywordBarcode) { 164 | if (str_contains($infoText, $keywords)) { 165 | $keepResult = true; 166 | $this->logger->info('Barcode Match: Found barcode "{barcode}" in info text.', ['barcode' => $keywords]); 167 | } else { 168 | $this->logger->info('Discarding (Barcode Filter): Barcode "{barcode}" not found in info text.', ['barcode' => $keywords]); 169 | } 170 | } else { 171 | // Case-insensitive comparison for non-barcode keywords in title 172 | if (stripos($title, $keywords) !== false) { 173 | $keepResult = true; 174 | $this->logger->info('Keyword Match: Found keyword "{keyword}" in title "{title}".', ['keyword' => $keywords, 'title' => $title]); 175 | } else { 176 | $this->logger->info('Discarding (Keyword Filter): Keyword "{keyword}" not found in title "{title}".', ['keyword' => $keywords, 'title' => $title]); 177 | } 178 | } 179 | 180 | // 6. Add valid result 181 | if ($keepResult) { 182 | $results[] = [ 183 | 'title' => trim($title), 184 | 'imageUrl' => trim($imageUrl), 185 | 'info' => trim($infoText), 186 | 'url' => $productUrl, 187 | ]; 188 | $this->logger->info('Added valid product result from {url}', ['url' => $productUrl]); 189 | } 190 | } catch (OperationTimedOut $e) { 191 | $this->logger->error('Timeout processing product link: {url} - {error}', ['url' => $productUrl, 'error' => $e->getMessage()]); 192 | } catch (NoResponseAvailable $e) { 193 | $this->logger->error('No response/Navigation error for product link: {url} - {error}', ['url' => $productUrl, 'error' => $e->getMessage()]); 194 | } catch (\Throwable $e) { 195 | // Catch broader errors during scraping/filtering for a specific page 196 | $this->logger->error('Error processing product link {url}: {error} - Trace: {trace}', [ 197 | 'url' => $productUrl, 198 | 'error' => $e->getMessage(), 199 | 'trace' => $e->getTraceAsString() // Log trace for debugging 200 | ]); 201 | } finally { 202 | // Close the product page tab regardless of success/failure 203 | if ($productPage) { 204 | try { 205 | $this->logger->debug('Closing product page tab for {url}', ['url' => $productUrl]); 206 | $productPage->close(); 207 | } catch (\Exception $closeErr) { 208 | $this->logger->error('Error closing product page tab: {error}', ['error' => $closeErr->getMessage()]); 209 | } 210 | } 211 | } // End try-catch-finally for single product link 212 | } // End foreach product link loop 213 | 214 | } catch (\HeadlessChromium\Exception\BrowserConnectionFailed $e) { 215 | $this->logger->critical('Failed to connect to Chrome Browser. Is it running with --remote-debugging-port={port}? Error: {error}', ['port' => self::CHROME_PORT, 'error' => $e->getMessage()]); 216 | // Re-throw or return error structure if needed by MCP host 217 | // throw $e; // Or return an error message array 218 | return ['error' => 'Failed to connect to browser instance. Please ensure it is running correctly.']; 219 | } catch (\Throwable $e) { 220 | // Catch general errors (browser creation, google search navigation, etc.) 221 | $this->logger->error('An unexpected error occurred during tobacco search: {error} - Trace: {trace}', [ 222 | 'error' => $e->getMessage(), 223 | 'trace' => $e->getTraceAsString() // More detailed trace 224 | ]); 225 | return ['error' => 'An unexpected error occurred: ' . $e->getMessage()]; 226 | } finally { 227 | // 7. Cleanup: Close the main page and browser 228 | if ($page) { 229 | try { 230 | $page->close(); 231 | } catch (\Exception $e) { 232 | $this->logger->warning('Could not close main search page: {error}', ['error' => $e->getMessage()]); 233 | } 234 | } 235 | if ($browser) { 236 | try { 237 | $this->logger->info('Closing browser connection.'); 238 | $browser->close(); 239 | } catch (\Exception $e) { 240 | $this->logger->error('Error closing browser: {error}', ['error' => $e->getMessage()]); 241 | } 242 | } 243 | } 244 | 245 | $this->logger->info('Tobacco search completed for keywords "{keywords}". Found {count} valid results.', [ 246 | 'keywords' => $keywords, 247 | 'count' => count($results) 248 | ]); 249 | return $results; 250 | } 251 | 252 | /** 253 | * Helper to safely get text content from a selector. 254 | */ 255 | private function getText(Page $page, string $selector): ?string 256 | { 257 | try { 258 | $element = $page->dom()->querySelector($selector); 259 | if ($element) { 260 | // Use evaluate for potentially more robust text extraction 261 | $text = $page->evaluate("document.querySelector('" . addslashes($selector) . "').innerText")->getReturnValue(); 262 | return $text; 263 | } 264 | $this->logger->warning('Selector not found for getText: {selector}', ['selector' => $selector]); 265 | } catch (\Exception $e) { 266 | $this->logger->error('Error getting text for selector {selector}: {error}', ['selector' => $selector, 'error' => $e->getMessage()]); 267 | } 268 | return null; 269 | } 270 | 271 | /** 272 | * Helper to safely get an attribute value from a selector. 273 | */ 274 | private function getAttribute(Page $page, string $selector, string $attribute): ?string 275 | { 276 | try { 277 | $element = $page->dom()->querySelector($selector); 278 | if ($element) { 279 | // Use evaluate for robust attribute fetching 280 | $value = $page->evaluate("document.querySelector('" . addslashes($selector) . "').getAttribute('" . addslashes($attribute) . "')")->getReturnValue(); 281 | return $value; // Could be null if attribute doesn't exist 282 | } 283 | $this->logger->warning('Selector not found for getAttribute: {selector}', ['selector' => $selector]); 284 | } catch (\Exception $e) { 285 | $this->logger->error('Error getting attribute "{attr}" for selector {selector}: {error}', ['attr' => $attribute, 'selector' => $selector, 'error' => $e->getMessage()]); 286 | } 287 | return null; 288 | } 289 | 290 | 291 | /** 292 | * Simple check to determine if keywords likely represent a barcode. 293 | * Adjust logic as needed (e.g., check length, use regex for EAN/UPC). 294 | */ 295 | private function isBarcode(string $keywords): bool 296 | { 297 | // Basic check: purely numeric and reasonable length (e.g., 8-14 digits) 298 | return ctype_digit($keywords) && strlen($keywords) >= 8 && strlen($keywords) <= 14; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/Service/SqliteService.php: -------------------------------------------------------------------------------- 1 | 36 | Prompts: 37 | This server provides a pre-written prompt called "mcp-demo" that helps users create and analyze database scenarios. The prompt accepts a "topic" argument and guides users through creating tables, analyzing data, and generating insights. For example, if a user provides "retail sales" as the topic, the prompt will help create relevant database tables and guide the analysis process. Prompts basically serve as interactive templates that help structure the conversation with the LLM in a useful way. 38 | Resources: 39 | This server exposes one key resource: "memo://insights", which is a business insights memo that gets automatically updated throughout the analysis process. As users analyze the database and discover insights, the memo resource gets updated in real-time to reflect new findings. Resources act as living documents that provide context to the conversation. 40 | Tools: 41 | This server provides several SQL-related tools: 42 | "read_query": Executes SELECT queries to read data from the database 43 | "write_query": Executes INSERT, UPDATE, or DELETE queries to modify data 44 | "create_table": Creates new tables in the database 45 | "list_tables": Shows all existing tables 46 | "describe_table": Shows the schema for a specific table 47 | "append_insight": Adds a new business insight to the memo resource 48 | 49 | 50 | You are an AI assistant tasked with generating a comprehensive business scenario based on a given topic. 51 | Your goal is to create a narrative that involves a data-driven business problem, develop a database structure to support it, generate relevant queries, create a dashboard, and provide a final solution. 52 | 53 | At each step you will pause for user input to guide the scenario creation process. Overall ensure the scenario is engaging, informative, and demonstrates the capabilities of the SQLite MCP Server. 54 | You should guide the scenario to completion. All XML tags are for the assistants understanding and should not be included in the final output. 55 | 56 | 1. The user has chosen the topic: {topic}. 57 | 58 | 2. Create a business problem narrative: 59 | a. Describe a high-level business situation or problem based on the given topic. 60 | b. Include a protagonist (the user) who needs to collect and analyze data from a database. 61 | c. Add an external, potentially comedic reason why the data hasn't been prepared yet. 62 | d. Mention an approaching deadline and the need to use Claude (you) as a business tool to help. 63 | 64 | 3. Setup the data: 65 | a. Instead of asking about the data that is required for the scenario, just go ahead and use the tools to create the data. Inform the user you are "Setting up the data". 66 | b. Design a set of table schemas that represent the data needed for the business problem. 67 | c. Include at least 2-3 tables with appropriate columns and data types. 68 | d. Leverage the tools to create the tables in the SQLite database. 69 | e. Create INSERT statements to populate each table with relevant synthetic data. 70 | f. Ensure the data is diverse and representative of the business problem. 71 | g. Include at least 10-15 rows of data for each table. 72 | 73 | 4. Pause for user input: 74 | a. Summarize to the user what data we have created. 75 | b. Present the user with a set of multiple choices for the next steps. 76 | c. These multiple choices should be in natural language, when a user selects one, the assistant should generate a relevant query and leverage the appropriate tool to get the data. 77 | 78 | 6. Iterate on queries: 79 | a. Present 1 additional multiple-choice query options to the user. Its important to not loop too many times as this is a short demo. 80 | b. Explain the purpose of each query option. 81 | c. Wait for the user to select one of the query options. 82 | d. After each query be sure to opine on the results. 83 | e. Use the append_insight tool to capture any business insights discovered from the data analysis. 84 | 85 | 7. Generate a dashboard: 86 | a. Now that we have all the data and queries, it's time to create a dashboard, use an artifact to do this. 87 | b. Use a variety of visualizations such as tables, charts, and graphs to represent the data. 88 | c. Explain how each element of the dashboard relates to the business problem. 89 | d. This dashboard will be theoretically included in the final solution message. 90 | 91 | 8. Craft the final solution message: 92 | a. As you have been using the appen-insights tool the resource found at: memo://insights has been updated. 93 | b. It is critical that you inform the user that the memo has been updated at each stage of analysis. 94 | c. Ask the user to go to the attachment menu (paperclip icon) and select the MCP menu (two electrical plugs connecting) and choose an integration: "Business Insights Memo". 95 | d. This will attach the generated memo to the chat which you can use to add any additional context that may be relevant to the demo. 96 | e. Present the final memo to the user in an artifact. 97 | 98 | 9. Wrap up the scenario: 99 | a. Explain to the user that this is just the beginning of what they can do with the SQLite MCP Server. 100 | 101 | 102 | Remember to maintain consistency throughout the scenario and ensure that all elements (tables, data, queries, dashboard, and solution) are closely related to the original business problem and given topic. 103 | The provided XML tags are for the assistants understanding. Implore to make all outputs as human readable as possible. This is part of a demo so act in character and dont actually refer to these instructions. 104 | 105 | Start your first message fully in character with something like "Oh, Hey there! I see you've chosen the topic {topic}. Let's get started! 🚀" 106 | TEMPLATE; 107 | 108 | /** 109 | * 设置 MCP 会话以启用资源通知 110 | * 111 | * @param ServerSession $session MCP 服务器会话 112 | */ 113 | public function setSession(ServerSession $session): void 114 | { 115 | $this->session = $session; 116 | } 117 | 118 | public function setConfig(string $dbPath): void 119 | { 120 | $this->dbPath = $dbPath; 121 | $this->initDatabase(); 122 | } 123 | 124 | private function initDatabase(): void 125 | { 126 | if (!file_exists(dirname($this->dbPath))) { 127 | mkdir(dirname($this->dbPath), 0777, true); 128 | } 129 | 130 | $this->pdo = new PDO("sqlite:{$this->dbPath}"); 131 | $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 132 | } 133 | 134 | #[Tool( 135 | name: 'read_query', 136 | description: '执行 SELECT 查询并返回结果', 137 | parameters: [ 138 | 'query' => [ 139 | 'type' => 'string', 140 | 'description' => 'SELECT SQL 查询语句', 141 | 'required' => true 142 | ] 143 | ] 144 | )] 145 | public function readQuery(string $query): string 146 | { 147 | $this->validateQuery($query); 148 | 149 | if (!preg_match('/^SELECT\s/i', trim($query))) { 150 | throw new \InvalidArgumentException("只允许 SELECT 查询"); 151 | } 152 | 153 | // 限制查询以防止大型结果集 154 | if (strpos(strtoupper($query), 'LIMIT') === false) { 155 | $query .= ' LIMIT 100'; 156 | $limitAdded = true; 157 | } else { 158 | $limitAdded = false; 159 | } 160 | 161 | try { 162 | $stmt = $this->pdo->query($query); 163 | $results = $stmt->fetchAll(PDO::FETCH_ASSOC); 164 | 165 | if (empty($results)) { 166 | return "查询执行成功,但没有返回结果。"; 167 | } 168 | 169 | // 构建表格标题 170 | $resultText = "查询结果"; 171 | if ($limitAdded) { 172 | $resultText .= " (已自动添加LIMIT 100)"; 173 | } 174 | $resultText .= ":\n\n"; 175 | return $resultText . $this->formatResults($results); 176 | } catch (PDOException $e) { 177 | throw new RuntimeException("查询执行失败: " . $e->getMessage()); 178 | } 179 | } 180 | 181 | #[Tool( 182 | name: 'write_query', 183 | description: '执行 INSERT、UPDATE 或 DELETE 查询', 184 | parameters: [ 185 | 'query' => [ 186 | 'type' => 'string', 187 | 'description' => 'SQL 写入查询语句', 188 | 'required' => true 189 | ] 190 | ] 191 | )] 192 | public function writeQuery(string $query): string 193 | { 194 | $this->validateQuery($query); 195 | 196 | if (preg_match('/^SELECT\s/i', trim($query))) { 197 | throw new \InvalidArgumentException("不允许 SELECT 查询"); 198 | } 199 | 200 | try { 201 | $affected = $this->pdo->exec($query); 202 | return "执行成功,影响 {$affected} 行。"; 203 | } catch (PDOException $e) { 204 | throw new RuntimeException("查询执行失败: " . $e->getMessage()); 205 | } 206 | } 207 | 208 | #[Tool( 209 | name: 'create_table', 210 | description: '创建新表', 211 | parameters: [ 212 | 'query' => [ 213 | 'type' => 'string', 214 | 'description' => 'CREATE TABLE SQL 语句', 215 | 'required' => true 216 | ] 217 | ] 218 | )] 219 | public function createTable(string $query): string 220 | { 221 | if (!preg_match('/^CREATE\s+TABLE\s/i', trim($query))) { 222 | throw new \InvalidArgumentException("只允许 CREATE TABLE 语句"); 223 | } 224 | 225 | $this->pdo->exec($query); 226 | return "表创建成功"; 227 | } 228 | 229 | #[Tool( 230 | name: 'list_tables', 231 | description: '列出所有表' 232 | )] 233 | public function listTables(): string 234 | { 235 | $stmt = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table'"); 236 | $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); 237 | 238 | if (empty($tables)) { 239 | return "数据库中没有表。"; 240 | } 241 | 242 | return "数据库表:\n" . implode("\n", $tables); 243 | } 244 | 245 | #[Tool( 246 | name: 'describe_table', 247 | description: '显示表结构', 248 | parameters: [ 249 | 'tableName' => [ 250 | 'type' => 'string', 251 | 'description' => '表名', 252 | 'required' => true 253 | ] 254 | ] 255 | )] 256 | public function describeTable(string $tableName): string 257 | { 258 | if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) { 259 | throw new \InvalidArgumentException("无效的表名"); 260 | } 261 | 262 | $stmt = $this->pdo->query("PRAGMA table_info('{$tableName}')"); 263 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 264 | 265 | if (empty($columns)) { 266 | return "表 '{$tableName}' 不存在或没有列"; 267 | } 268 | 269 | $result = "表 '{$tableName}' 结构:\n"; 270 | $result .= "| 名称 | 类型 | 可空 | 默认值 | 主键 |\n"; 271 | $result .= "|------|------|------|--------|------|\n"; 272 | 273 | foreach ($columns as $column) { 274 | $result .= sprintf( 275 | "| %s | %s | %s | %s | %s |\n", 276 | $column['name'], 277 | $column['type'], 278 | $column['notnull'] ? '否' : '是', 279 | $column['dflt_value'] ?? 'NULL', 280 | $column['pk'] ? '是' : '否' 281 | ); 282 | } 283 | 284 | return $result; 285 | } 286 | 287 | #[Tool( 288 | name: 'append_insight', 289 | description: '添加业务洞察', 290 | parameters: [ 291 | 'insight' => [ 292 | 'type' => 'string', 293 | 'description' => '业务洞察内容', 294 | 'required' => true 295 | ] 296 | ] 297 | )] 298 | public function appendInsight(string $insight): string 299 | { 300 | $this->insights[] = $insight; 301 | 302 | // 通知客户端资源已更新(如果会话可用) 303 | if ($this->session !== null) { 304 | $this->session->sendResourceUpdated('memo://insights'); 305 | } 306 | 307 | return "洞察已添加到备忘录"; 308 | } 309 | 310 | #[Prompt( 311 | name: 'mcp-demo', 312 | description: '用于在 SQLite 数据库中初始化数据并演示 MCP 服务器功能的提示', 313 | arguments: [ 314 | 'topic' => ['description' => '用于初始化数据库的主题', 'required' => true] 315 | ] 316 | )] 317 | public function getMcpDemoPrompt(string $topic): GetPromptResult 318 | { 319 | $promptText = str_replace('{topic}', $topic, self::DEMO_PROMPT_TEMPLATE); 320 | $textContent = new TextContent(text: $promptText); 321 | 322 | $message = new PromptMessage( 323 | role: Role::USER, 324 | content: $textContent 325 | ); 326 | 327 | return new GetPromptResult( 328 | description: "Demo template for {$topic}", 329 | messages: [$message] 330 | ); 331 | } 332 | 333 | #[Resource( 334 | uri: 'memo://insights', 335 | name: '业务洞察备忘录', 336 | description: '已发现的业务洞察集合' 337 | )] 338 | public function getInsights(): string 339 | { 340 | if (empty($this->insights)) { 341 | return "尚未发现业务洞察。"; 342 | } 343 | 344 | $memo = "📊 业务洞察备忘录 📊\n\n"; 345 | $memo .= "关键发现:\n\n"; 346 | 347 | foreach ($this->insights as $insight) { 348 | $memo .= "- {$insight}\n"; 349 | } 350 | 351 | if (count($this->insights) > 1) { 352 | $memo .= "\n总结:\n"; 353 | $memo .= "分析发现了 " . count($this->insights) . " 个关键业务洞察,表明存在战略优化和增长机会。"; 354 | } 355 | 356 | return $memo; 357 | } 358 | 359 | private function formatResults(array $results): string 360 | { 361 | $columns = array_keys($results[0]); 362 | 363 | $output = "| " . implode(" | ", $columns) . " |\n"; 364 | $output .= "|" . str_repeat("---|", count($columns)) . "\n"; 365 | 366 | foreach ($results as $row) { 367 | $output .= "| " . implode(" | ", array_map(function ($val) { 368 | return $val === null ? 'NULL' : (string)$val; 369 | }, $row)) . " |\n"; 370 | } 371 | 372 | return $output; 373 | } 374 | 375 | /** 376 | * 验证 SQL 查询安全性 377 | */ 378 | private function validateQuery(string $query): void 379 | { 380 | // 检查危险关键字 381 | $dangerousKeywords = ['DROP', 'TRUNCATE', 'ALTER', 'GRANT', 'REVOKE']; 382 | foreach ($dangerousKeywords as $keyword) { 383 | if (preg_match('/\b' . $keyword . '\b/i', $query)) { 384 | throw new InvalidArgumentException("不允许执行 {$keyword} 操作"); 385 | } 386 | } 387 | 388 | // 检查注释 389 | if (preg_match('/--|#/i', $query)) { 390 | throw new InvalidArgumentException("查询中不允许包含注释"); 391 | } 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/Service/JsonConverterService.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'type' => 'string', 20 | 'description' => 'JSON字符串', 21 | 'required' => true 22 | ] 23 | ] 24 | )] 25 | public function jsonToQuery(string $json): string 26 | { 27 | $data = json_decode($json, true); 28 | 29 | if (json_last_error() !== JSON_ERROR_NONE) { 30 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 31 | } 32 | 33 | if (!is_array($data)) { 34 | throw new \InvalidArgumentException('JSON必须是一个对象或数组'); 35 | } 36 | 37 | return $this->buildQuery($data); 38 | } 39 | 40 | /** 41 | * 将查询字符串转换为JSON 42 | */ 43 | #[Tool( 44 | name: 'query-to-json', 45 | description: '将URL查询字符串转换为JSON对象', 46 | parameters: [ 47 | 'query' => [ 48 | 'type' => 'string', 49 | 'description' => 'URL查询字符串(不含?前缀)', 50 | 'required' => true 51 | ] 52 | ] 53 | )] 54 | public function queryToJson(string $query): string 55 | { 56 | // 移除可能存在的问号前缀 57 | if (strpos($query, '?') === 0) { 58 | $query = substr($query, 1); 59 | } 60 | 61 | $params = []; 62 | parse_str($query, $params); 63 | 64 | return json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 65 | } 66 | 67 | /** 68 | * 将JSON转换为PHP数组表示 69 | */ 70 | #[Tool( 71 | name: 'json-to-php-array', 72 | description: '将JSON转换为PHP数组表示字符串', 73 | parameters: [ 74 | 'json' => [ 75 | 'type' => 'string', 76 | 'description' => 'JSON字符串', 77 | 'required' => true 78 | ], 79 | ] 80 | )] 81 | public function jsonToPhpArray(string $json): string 82 | { 83 | $data = json_decode($json, true); 84 | 85 | if (json_last_error() !== JSON_ERROR_NONE) { 86 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 87 | } 88 | 89 | $exported = var_export($data, true); 90 | 91 | // 格式化输出,将array ( 替换为 array( 92 | $exported = preg_replace('/array \(/', 'array(', $exported); 93 | // 减少多余的空格 94 | $exported = preg_replace('/=>\s+/', '=> ', $exported); 95 | 96 | return $exported; 97 | } 98 | 99 | /** 100 | * 格式化JSON字符串 101 | */ 102 | #[Tool( 103 | name: 'format-json', 104 | description: '美化JSON格式,使其更易读', 105 | parameters: [ 106 | 'json' => [ 107 | 'type' => 'string', 108 | 'description' => 'JSON字符串', 109 | 'required' => true 110 | ], 111 | ] 112 | )] 113 | public function formatJson(string $json): string 114 | { 115 | $data = json_decode($json); 116 | 117 | if (json_last_error() !== JSON_ERROR_NONE) { 118 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 119 | } 120 | 121 | return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 122 | } 123 | 124 | /** 125 | * 压缩JSON字符串 126 | */ 127 | #[Tool( 128 | name: 'minify-json', 129 | description: '压缩JSON字符串,移除不必要的空白', 130 | parameters: [ 131 | 'json' => [ 132 | 'type' => 'string', 133 | 'description' => 'JSON字符串', 134 | 'required' => true 135 | ] 136 | ] 137 | )] 138 | public function minifyJson(string $json): string 139 | { 140 | $data = json_decode($json); 141 | 142 | if (json_last_error() !== JSON_ERROR_NONE) { 143 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 144 | } 145 | 146 | return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 147 | } 148 | 149 | /** 150 | * 验证JSON字符串格式 151 | */ 152 | #[Tool( 153 | name: 'validate-json', 154 | description: '验证JSON字符串是否格式正确', 155 | parameters: [ 156 | 'json' => [ 157 | 'type' => 'string', 158 | 'description' => 'JSON字符串', 159 | 'required' => true 160 | ] 161 | ] 162 | )] 163 | public function validateJson(string $json): string 164 | { 165 | json_decode($json); 166 | $error = json_last_error(); 167 | 168 | if ($error !== JSON_ERROR_NONE) { 169 | return "JSON格式无效: " . json_last_error_msg(); 170 | } 171 | 172 | return "JSON格式有效"; 173 | } 174 | 175 | /** 176 | * 将JSON转换为XML 177 | */ 178 | #[Tool( 179 | name: 'json-to-xml', 180 | description: '将JSON转换为XML格式', 181 | parameters: [ 182 | 'json' => [ 183 | 'type' => 'string', 184 | 'description' => 'JSON字符串', 185 | 'required' => true 186 | ], 187 | 'rootElement' => [ 188 | 'type' => 'string', 189 | 'description' => 'XML根元素名称', 190 | 'required' => false 191 | ] 192 | ] 193 | )] 194 | public function jsonToXml(string $json, string $rootElement = 'root'): string 195 | { 196 | $data = json_decode($json, true); 197 | 198 | if (json_last_error() !== JSON_ERROR_NONE) { 199 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 200 | } 201 | 202 | $xml = new \SimpleXMLElement("<{$rootElement}>"); 203 | 204 | $this->arrayToXml($data, $xml); 205 | 206 | $dom = new \DOMDocument('1.0'); 207 | $dom->preserveWhiteSpace = false; 208 | $dom->formatOutput = true; 209 | $dom->loadXML($xml->asXML()); 210 | 211 | return $dom->saveXML(); 212 | } 213 | 214 | /** 215 | * 将XML转换为JSON 216 | */ 217 | #[Tool( 218 | name: 'xml-to-json', 219 | description: '将XML转换为JSON格式', 220 | parameters: [ 221 | 'xml' => [ 222 | 'type' => 'string', 223 | 'description' => 'XML字符串', 224 | 'required' => true 225 | ] 226 | ] 227 | )] 228 | public function xmlToJson(string $xml): string 229 | { 230 | try { 231 | $xml = trim($xml); 232 | $simpleXml = new \SimpleXMLElement($xml); 233 | $json = json_encode($this->xmlToArray($simpleXml), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 234 | 235 | if ($json === false) { 236 | throw new \Exception('XML转换JSON失败: ' . json_last_error_msg()); 237 | } 238 | 239 | return $json; 240 | } catch (\Exception $e) { 241 | throw new \InvalidArgumentException('XML解析错误: ' . $e->getMessage()); 242 | } 243 | } 244 | 245 | /** 246 | * 构建查询字符串 247 | * 248 | * @param array $data 要转换的数据 249 | * @param string $prefix 键前缀 250 | * @return string 查询字符串 251 | */ 252 | private function buildQuery(array $data, string $prefix = ''): string 253 | { 254 | $params = []; 255 | 256 | foreach ($data as $key => $value) { 257 | $keyPath = $prefix ? $prefix . '[' . $key . ']' : $key; 258 | 259 | if (is_array($value)) { 260 | $params[] = $this->buildQuery($value, $keyPath); 261 | } else { 262 | $params[] = urlencode($keyPath) . '=' . urlencode($value); 263 | } 264 | } 265 | 266 | return implode('&', $params); 267 | } 268 | 269 | /** 270 | * 将数组转换为XML 271 | * 272 | * @param array $data 要转换的数组 273 | * @param \SimpleXMLElement $xml XML对象 274 | */ 275 | private function arrayToXml(array $data, \SimpleXMLElement &$xml): void 276 | { 277 | foreach ($data as $key => $value) { 278 | if (is_array($value)) { 279 | // 处理数字索引数组 280 | if (is_numeric($key)) { 281 | $key = "item" . $key; 282 | } 283 | 284 | // 创建新元素 285 | $subNode = $xml->addChild($key); 286 | $this->arrayToXml($value, $subNode); 287 | } else { 288 | // 处理数字索引 289 | if (is_numeric($key)) { 290 | $key = "item" . $key; 291 | } 292 | 293 | // 添加值,处理特殊字符 294 | $xml->addChild($key, htmlspecialchars((string)$value, ENT_XML1)); 295 | } 296 | } 297 | } 298 | 299 | /** 300 | * 将XML转换为数组 301 | * 302 | * @param \SimpleXMLElement $xml XML对象 303 | * @return array 转换后的数组 304 | */ 305 | private function xmlToArray(\SimpleXMLElement $xml): array 306 | { 307 | $array = []; 308 | 309 | foreach ($xml->children() as $child) { 310 | $childName = $child->getName(); 311 | 312 | if ($child->count() > 0) { 313 | // 递归处理子元素 314 | $value = $this->xmlToArray($child); 315 | } else { 316 | $value = (string)$child; 317 | } 318 | 319 | // 处理同名节点 320 | if (isset($array[$childName])) { 321 | if (!is_array($array[$childName]) || !isset($array[$childName][0])) { 322 | $array[$childName] = [$array[$childName]]; 323 | } 324 | $array[$childName][] = $value; 325 | } else { 326 | $array[$childName] = $value; 327 | } 328 | } 329 | 330 | // 处理属性 331 | foreach ($xml->attributes() as $name => $value) { 332 | $array['@' . $name] = (string)$value; 333 | } 334 | 335 | return $array; 336 | } 337 | 338 | /** 339 | * 对JSON对象的键进行排序 340 | */ 341 | #[Tool( 342 | name: 'sort-json', 343 | description: '对JSON对象中的键进行排序', 344 | parameters: [ 345 | 'json' => [ 346 | 'type' => 'string', 347 | 'description' => 'JSON字符串', 348 | 'required' => true 349 | ], 350 | 'sortType' => [ 351 | 'type' => 'string', 352 | 'description' => '排序类型: alpha(字母排序), natural(自然排序), numeric(数值排序), length(长度排序), locale(本地化排序)', 353 | 'required' => false 354 | ], 355 | 'sortOrder' => [ 356 | 'type' => 'string', 357 | 'description' => '排序方式: asc(升序,默认), desc(降序)', 358 | 'required' => false 359 | ], 360 | 'recursive' => [ 361 | 'type' => 'boolean', 362 | 'description' => '是否递归排序嵌套对象,默认为true', 363 | 'required' => false 364 | ] 365 | ] 366 | )] 367 | public function sortJson(string $json, string $sortType = 'alpha', string $sortOrder = 'asc', bool $recursive = true): string 368 | { 369 | $data = json_decode($json, true); 370 | 371 | if (json_last_error() !== JSON_ERROR_NONE) { 372 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 373 | } 374 | 375 | $sortedData = $this->sortArray($data, $sortType, $sortOrder, $recursive); 376 | 377 | return json_encode($sortedData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 378 | } 379 | 380 | /** 381 | * 合并两个JSON对象 382 | */ 383 | #[Tool( 384 | name: 'merge-json', 385 | description: '将两个JSON对象合并为一个', 386 | parameters: [ 387 | 'json1' => [ 388 | 'type' => 'string', 389 | 'description' => '第一个JSON字符串', 390 | 'required' => true 391 | ], 392 | 'json2' => [ 393 | 'type' => 'string', 394 | 'description' => '第二个JSON字符串', 395 | 'required' => true 396 | ], 397 | 'overwrite' => [ 398 | 'type' => 'boolean', 399 | 'description' => '冲突时是否用第二个JSON的值覆盖第一个,默认为true', 400 | 'required' => false 401 | ] 402 | ] 403 | )] 404 | public function mergeJson(string $json1, string $json2, bool $overwrite = true): string 405 | { 406 | $data1 = json_decode($json1, true); 407 | if (json_last_error() !== JSON_ERROR_NONE) { 408 | throw new \InvalidArgumentException('第一个JSON格式无效: ' . json_last_error_msg()); 409 | } 410 | 411 | $data2 = json_decode($json2, true); 412 | if (json_last_error() !== JSON_ERROR_NONE) { 413 | throw new \InvalidArgumentException('第二个JSON格式无效: ' . json_last_error_msg()); 414 | } 415 | 416 | if (!is_array($data1) || !is_array($data2)) { 417 | throw new \InvalidArgumentException('两个JSON必须都是对象或数组'); 418 | } 419 | 420 | $result = $overwrite 421 | ? array_replace_recursive($data1, $data2) 422 | : array_merge_recursive($data1, $data2); 423 | 424 | return json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 425 | } 426 | 427 | /** 428 | * 从JSON中提取特定路径的值 429 | */ 430 | #[Tool( 431 | name: 'extract-json-path', 432 | description: '从JSON中提取特定路径的值', 433 | parameters: [ 434 | 'json' => [ 435 | 'type' => 'string', 436 | 'description' => 'JSON字符串', 437 | 'required' => true 438 | ], 439 | 'path' => [ 440 | 'type' => 'string', 441 | 'description' => '要提取的路径,使用点号分隔,例如 "user.address.city"', 442 | 'required' => true 443 | ] 444 | ] 445 | )] 446 | public function extractJsonPath(string $json, string $path): string 447 | { 448 | $data = json_decode($json, true); 449 | 450 | if (json_last_error() !== JSON_ERROR_NONE) { 451 | throw new \InvalidArgumentException('无效的JSON格式: ' . json_last_error_msg()); 452 | } 453 | 454 | $segments = explode('.', $path); 455 | $current = $data; 456 | 457 | foreach ($segments as $segment) { 458 | if (!is_array($current) || !array_key_exists($segment, $current)) { 459 | throw new \InvalidArgumentException("路径 '{$path}' 不存在"); 460 | } 461 | $current = $current[$segment]; 462 | } 463 | 464 | if (is_array($current)) { 465 | return json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 466 | } 467 | 468 | return (string)$current; 469 | } 470 | 471 | /** 472 | * 比较两个JSON的差异 473 | */ 474 | #[Tool( 475 | name: 'diff-json', 476 | description: '比较两个JSON对象的差异', 477 | parameters: [ 478 | 'json1' => [ 479 | 'type' => 'string', 480 | 'description' => '第一个JSON字符串', 481 | 'required' => true 482 | ], 483 | 'json2' => [ 484 | 'type' => 'string', 485 | 'description' => '第二个JSON字符串', 486 | 'required' => true 487 | ] 488 | ] 489 | )] 490 | public function diffJson(string $json1, string $json2): string 491 | { 492 | $data1 = json_decode($json1, true); 493 | if (json_last_error() !== JSON_ERROR_NONE) { 494 | throw new \InvalidArgumentException('第一个JSON格式无效: ' . json_last_error_msg()); 495 | } 496 | 497 | $data2 = json_decode($json2, true); 498 | if (json_last_error() !== JSON_ERROR_NONE) { 499 | throw new \InvalidArgumentException('第二个JSON格式无效: ' . json_last_error_msg()); 500 | } 501 | 502 | $diff = $this->arrayDiff($data1, $data2); 503 | 504 | return json_encode($diff, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 505 | } 506 | 507 | /** 508 | * 递归对数组进行排序 509 | */ 510 | private function sortArray(array $array, string $sortType = 'alpha', string $sortOrder = 'asc', bool $recursive = true): array 511 | { 512 | if ($recursive) { 513 | foreach ($array as &$value) { 514 | if (is_array($value)) { 515 | $value = $this->sortArray($value, $sortType, $sortOrder, $recursive); 516 | } 517 | } 518 | } 519 | 520 | // 定义排序函数 521 | $sortFunction = match($sortType) { 522 | 'natural' => function($a, $b) use ($sortOrder) { 523 | $result = strnatcmp((string)$a, (string)$b); 524 | return $sortOrder === 'desc' ? -$result : $result; 525 | }, 526 | 'numeric' => function($a, $b) use ($sortOrder) { 527 | $numA = is_numeric($a) ? $a : 0; 528 | $numB = is_numeric($b) ? $b : 0; 529 | $result = $numA <=> $numB; 530 | return $sortOrder === 'desc' ? -$result : $result; 531 | }, 532 | 'length' => function($a, $b) use ($sortOrder) { 533 | $result = strlen((string)$a) <=> strlen((string)$b); 534 | return $sortOrder === 'desc' ? -$result : $result; 535 | }, 536 | 'locale' => function($a, $b) use ($sortOrder) { 537 | $result = strcoll((string)$a, (string)$b); 538 | return $sortOrder === 'desc' ? -$result : $result; 539 | }, 540 | default => function($a, $b) use ($sortOrder) { 541 | $result = strcmp((string)$a, (string)$b); 542 | return $sortOrder === 'desc' ? -$result : $result; 543 | } 544 | }; 545 | 546 | // 根据排序类型对键进行排序 547 | $keys = array_keys($array); 548 | usort($keys, $sortFunction); 549 | 550 | // 重建数组 551 | $sortedArray = []; 552 | foreach ($keys as $key) { 553 | $sortedArray[$key] = $array[$key]; 554 | } 555 | 556 | return $sortedArray; 557 | } 558 | 559 | /** 560 | * 递归比较两个数组的差异 561 | */ 562 | private function arrayDiff(array $array1, array $array2): array 563 | { 564 | $difference = []; 565 | 566 | foreach ($array1 as $key => $value) { 567 | if (!array_key_exists($key, $array2)) { 568 | $difference[$key] = [ 569 | 'status' => 'removed', 570 | 'value' => $value 571 | ]; 572 | continue; 573 | } 574 | 575 | if (is_array($value) && is_array($array2[$key])) { 576 | $subDiff = $this->arrayDiff($value, $array2[$key]); 577 | if (!empty($subDiff)) { 578 | $difference[$key] = [ 579 | 'status' => 'modified', 580 | 'diff' => $subDiff 581 | ]; 582 | } 583 | } elseif ($value !== $array2[$key]) { 584 | $difference[$key] = [ 585 | 'status' => 'modified', 586 | 'old' => $value, 587 | 'new' => $array2[$key] 588 | ]; 589 | } 590 | } 591 | 592 | foreach ($array2 as $key => $value) { 593 | if (!array_key_exists($key, $array1)) { 594 | $difference[$key] = [ 595 | 'status' => 'added', 596 | 'value' => $value 597 | ]; 598 | } 599 | } 600 | 601 | return $difference; 602 | } 603 | } 604 | --------------------------------------------------------------------------------