PHP_AI_MCP_SSO_REDIRECT_URIS=http://127.0.0.1:18787/api/auth/sso/zyt/callback * AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpSsoTest.php * 夹具 ID 段:账号 93201-93204、角色 301-302。 */ require dirname(__DIR__) . '/vendor/autoload.php'; use think\App; use think\cache\driver\File as FileCache; use think\facade\Db; function aiMcpSsoExpect(bool $condition, string $message): void { if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } } $base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/'); $secret = (string) getenv('PHP_AI_MCP_SSO_CLIENT_SECRET'); $redirect = trim(explode(',', (string) getenv('PHP_AI_MCP_SSO_REDIRECT_URIS'))[0]); $clientId = (string) getenv('PHP_AI_MCP_SSO_CLIENT_ID'); if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '' || strlen($secret) < 32 || $redirect === '' || $clientId === '') { echo "AiMcpSsoTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL, PHP_DATABASE_* for a disposable *_test database and the PHP_AI_MCP_SSO_* settings the server runs with)\n"; exit(0); } $app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR); $app->initialize(); $database = (string) config('database.connections.' . config('database.default') . '.database'); aiMcpSsoExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)"); aiMcpSsoExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first'); // ---------------------------------------------------------------- 夹具 $now = time(); $pwd = create_password('Test@123456', (string) config('project.unique_identification')); Db::name('system_role')->whereIn('id', [301, 302])->delete(); foreach ([301 => 'AI 查询', 302 => '无 AI 权限'] as $id => $name) { Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-sso-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]); } Db::name('system_role_menu')->whereIn('role_id', [301, 302])->delete(); Db::name('system_role_menu')->insert(['role_id' => 301, 'menu_id' => (int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->value('id')]); // 账号 => [名称, 角色, is_paw, disable] $admins = [93201 => ['s_doc', '单点医生', 301, 1, 0], 93202 => ['s_noai', '无权限', 302, 1, 0], 93203 => ['s_new', '新账号', 301, 0, 0], 93204 => ['s_off', '已停用', 301, 1, 1]]; Db::name('admin')->whereIn('id', array_keys($admins))->delete(); Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_access_log')->whereIn('tool', ['sso.login', 'sso.approve', 'sso.token'])->where('admin_id', 0)->delete(); foreach ($admins as $id => [$account, $name, $role, $isPaw, $disable]) { Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $name, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1, 'is_paw' => $isPaw, 'work_wechat_userid' => '', 'disable' => $disable, 'phone' => '1370000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]); Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]); } $pcToken = substr(md5('sso-test-pc-' . $now), 0, 32); $aiToken = substr(md5('sso-test-ai-' . $now), 0, 32); $oldToken = substr(md5('sso-test-old-' . $now), 0, 32); Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]); Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 8, 'token' => $aiToken, 'update_time' => $now, 'expire_time' => $now + 3600]); Db::name('admin_session')->insert(['admin_id' => 93203, 'terminal' => 1, 'token' => $oldToken, 'update_time' => $now - 7200, 'expire_time' => $now - 60]); // 服务端(mcp 应用自己的缓存目录)里的错误计数与限流桶,清掉让断言可重复 $cacheOptions = (array) config('cache.stores.file'); $cacheOptions['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'cache'; $serverCache = new FileCache(app(), $cacheOptions); foreach (array_column($admins, 0) as $account) { $serverCache->delete('ai_mcp_grant_fail_' . md5($account)); } foreach (['sso_ip_', 'sso_token_', 'grant_ip_'] as $bucket) { foreach (['127.0.0.1', '::1'] as $ip) { $serverCache->delete('ai_mcp_rl_' . $bucket . md5($ip) . '_' . intdiv(time(), 600)); } } \think\facade\Cache::clear(); // ---------------------------------------------------------------- HTTP 工具 function aiMcpSsoHttp(string $method, string $url, ?array $body, array $headers = []): array { $ch = curl_init($url); $lines = ['Content-Type: application/json']; foreach ($headers as $k => $v) { $lines[] = $k . ': ' . $v; } $responseHeaders = []; curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60, CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$responseHeaders): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $responseHeaders[strtolower(trim($parts[0]))][] = trim($parts[1]); } return strlen($line); }]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); } $raw = (string) curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$status, json_decode($raw, true), $raw, $responseHeaders]; } $state = 'state-' . bin2hex(random_bytes(8)); $ctx = ['client_id' => $clientId, 'redirect_uri' => $redirect, 'state' => $state]; $page = static fn (array $query) => aiMcpSsoHttp('GET', $base . '/mcp/sso/authorize?' . http_build_query($query), null); $sso = static fn (string $action, array $body) => (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/' . $action, $ctx + $body)[1] ?? []); $codeOf = static function (array $body) use ($redirect, $state): string { $url = (string) ($body['data']['redirect'] ?? ''); aiMcpSsoExpect(str_starts_with($url, $redirect . '?'), 'redirects back to the registered address: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); parse_str((string) parse_url($url, PHP_URL_QUERY), $query); aiMcpSsoExpect(($query['state'] ?? '') === $state && preg_match('/^[0-9a-f]{64}$/', (string) ($query['code'] ?? '')) === 1, 'with the state and a one-time code'); return (string) $query['code']; }; $exchange = static fn (string $code, array $override = []) => aiMcpSsoHttp('POST', $base . '/mcp/sso/token', $override + ['client_id' => $clientId, 'client_secret' => $secret, 'code' => $code, 'redirect_uri' => $redirect, 'client_instance' => 'sso-test', 'label' => '行知 · 登录']); // ---------------------------------------------------------------- 登录确认页 [$status, , $html, $headers] = $page($ctx); aiMcpSsoExpect($status === 200 && str_contains($html, '登录「行知」') && str_contains($html, '