initialize(); $database = (string) config('database.connections.' . config('database.default') . '.database'); aiMcpHttpExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)"); // ---------------------------------------------------------------- 夹具 $now = time(); $salt = (string) config('project.unique_identification'); $pwd = create_password('Test@123456', $salt); $roles = [91 => ['医生', 4], 92 => ['医助', 4], 93 => ['经理', 2], 96 => ['下单', 1]]; Db::name('system_role')->whereIn('id', array_keys($roles))->delete(); foreach ($roles as $id => [$name, $scope]) { Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-test', 'sort' => 0, 'data_scope' => $scope, 'create_time' => $now, 'update_time' => $now]); } Db::name('dept')->whereIn('id', [9901, 9902, 9903])->delete(); Db::name('dept')->insertAll([ ['id' => 9901, 'name' => 'AI测试总部', 'pid' => 0, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now], ['id' => 9902, 'name' => 'AI测试一部', 'pid' => 9901, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now], ['id' => 9903, 'name' => 'AI测试二部', 'pid' => 9901, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now], ]); $admins = [ 91001 => ['t_root', 1, null, 9901, 0, 1], 91002 => ['t_doc_a', 0, 91, 9902, 0, 1], 91003 => ['t_doc_b', 0, 91, 9903, 0, 1], 91004 => ['t_asst_c', 0, 92, 9902, 0, 1], 91005 => ['t_mgr_m', 0, 93, 9901, 0, 1], 91006 => ['t_ops_d', 0, 96, 9901, 0, 1], 91007 => ['t_dis_e', 0, 92, 9902, 1, 1], 91008 => ['t_new_f', 0, 92, 9902, 0, 0], 91009 => ['t_asst_g', 0, 92, 9903, 0, 1], 91010 => ['t_lock_h', 0, 92, 9903, 0, 1], ]; Db::name('admin')->whereIn('id', array_keys($admins))->delete(); Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('admin_dept')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete(); foreach ($admins as $id => [$account, $root, $role, $dept, $disable, $isPaw]) { Db::name('admin')->insert(['id' => $id, 'root' => $root, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1, 'is_paw' => $isPaw, 'work_wechat_userid' => '', 'disable' => $disable, 'phone' => '1390000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]); if ($role) { Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]); } Db::name('admin_dept')->insert(['admin_id' => $id, 'dept_id' => $dept]); } $menuId = static function (string $perm) use ($now): int { $id = (int) Db::name('system_menu')->where('perms', $perm)->value('id'); return $id ?: (int) Db::name('system_menu')->insertGetId(['pid' => 0, 'type' => 'A', 'name' => 'AI测试 ' . $perm, 'icon' => '', 'sort' => 0, 'perms' => $perm, 'paths' => '', 'component' => '', 'selected' => '', 'params' => '', 'is_cache' => 0, 'is_show' => 0, 'is_disable' => 0, 'create_time' => $now, 'update_time' => $now]); }; aiMcpHttpExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first'); $grantsByRole = [ 91 => ['doctor.appointment/lists', 'ai.mcp/access'], 92 => ['doctor.appointment/lists', 'ai.mcp/access'], 93 => ['doctor.appointment/lists', 'ai.mcp/access', 'tcm.diagnosis/phonePlain'], 96 => ['doctor.appointment/lists'], ]; Db::name('system_role_menu')->whereIn('role_id', array_keys($grantsByRole))->delete(); foreach ($grantsByRole as $role => $perms) { foreach ($perms as $perm) { Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menuId($perm)]); } } Db::name('tcm_diagnosis')->whereIn('id', [95001, 95002, 95003, 95004])->delete(); foreach ([95001 => ['甲一', 91004], 95002 => ['乙二', 91004], 95003 => ['丙三', 91004], 95004 => ['丁四', 91009]] as $id => [$name, $assistant]) { Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'patient_name' => $name, 'phone' => '1381111' . substr((string) $id, -4), 'id_card' => '11010119900101' . substr((string) $id, -4), 'gender' => 1, 'age' => 40, 'status' => 1, 'assistant_id' => $assistant, 'create_time' => $now, 'update_time' => $now]); } Db::name('doctor_appointment')->whereIn('id', [96101, 96102, 96103, 96104])->delete(); foreach ([96101 => [95001, 91002, 91004, 3], 96102 => [95002, 91002, 91004, 3], 96103 => [95003, 91003, 91004, 3], 96104 => [95004, 91003, 91009, 2]] as $id => [$diag, $doctor, $assistant, $status]) { Db::name('doctor_appointment')->insert(['id' => $id, 'patient_id' => $diag, 'doctor_id' => $doctor, 'assistant_id' => $assistant, 'roster_id' => 0, 'appointment_date' => '2031-01-15', 'period' => 'morning', 'appointment_time' => '09:00:00', 'appointment_type' => 'video', 'status' => $status, 'remark' => '', 'channel_source' => '', 'create_time' => $now, 'update_time' => $now]); } \think\facade\Cache::clear(); // ---------------------------------------------------------------- HTTP 工具 function aiMcpHttp(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; } curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60]); 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); $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); return [$status, json_decode(substr($raw, $headerSize), true), substr($raw, 0, $headerSize)]; } $grant = static function (string $account, string $password = 'Test@123456') use ($base): array { [, $body] = aiMcpHttp('POST', $base . '/mcp/auth/grant', ['account' => $account, 'password' => $password, 'client' => 'xingzhi', 'client_instance' => 'contract-test']); return (array) $body; }; $rpc = static function (string $token, string $method, array $params = [], array $headers = []) use ($base): array { static $id = 0; return aiMcpHttp('POST', $base . '/mcp', ['jsonrpc' => '2.0', 'id' => ++$id, 'method' => $method, 'params' => $params], array_merge(['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'X-Xingzhi-Task-Id' => 'contract-task'], $headers)); }; $tool = static function (string $token, string $name, array $args) use ($rpc): array { [$status, $body] = $rpc($token, 'tools/call', ['name' => $name, 'arguments' => $args]); aiMcpHttpExpect($status === 200 && isset($body['result']), "tools/call {$name} should return a result, got HTTP {$status}"); return $body['result']; }; $ids = static fn (array $result): array => array_map('intval', array_column($result['structuredContent']['rows'] ?? [], 'id')); // ---------------------------------------------------------------- 授权门禁 $tokens = []; foreach (['t_root', 't_doc_a', 't_doc_b', 't_asst_c', 't_asst_g', 't_mgr_m'] as $account) { $body = $grant($account); aiMcpHttpExpect(($body['code'] ?? null) === 1 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'), "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE)); $tokens[$account] = $body['data']['token']; } foreach ([['t_ops_d', 'Test@123456', 'no_ai_permission'], ['t_dis_e', 'Test@123456', 'disabled'], ['t_new_f', 'Test@123456', 'need_change_password'], ['t_doc_a', 'wrong-password', 'invalid_credentials'], ['no_such_account', 'Test@123456', 'invalid_credentials']] as [$account, $password, $reason]) { $body = $grant($account, $password); aiMcpHttpExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === $reason, "grant for {$account} should fail with {$reason}: " . json_encode($body, JSON_UNESCAPED_UNICODE)); } for ($i = 0; $i < 5; $i++) { $grant('t_lock_h', 'bad'); } $body = $grant('t_lock_h'); aiMcpHttpExpect(($body['data']['reason'] ?? '') === 'locked', 'account locks after repeated failures even with the right password'); aiMcpHttpExpect((int) Db::name('ai_grant')->where('admin_id', 91002)->where('status', 1)->count() === 1, 'grant stored once for the account'); aiMcpHttpExpect(Db::name('ai_grant')->where('admin_id', 91002)->value('token_hash') === hash('sha256', $tokens['t_doc_a']), 'only the token hash is stored'); // ---------------------------------------------------------------- 协议 [$status, $body] = $rpc($tokens['t_doc_a'], 'initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => new stdClass(), 'clientInfo' => ['name' => 'contract', 'version' => '1']]); aiMcpHttpExpect($status === 200 && ($body['result']['protocolVersion'] ?? '') === '2025-06-18', 'initialize negotiates the requested version'); aiMcpHttpExpect(isset($body['result']['capabilities']['tools']), 'tools capability advertised'); [$status] = aiMcpHttp('POST', $base . '/mcp', ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], ['Authorization' => 'Bearer ' . $tokens['t_doc_a']]); aiMcpHttpExpect($status === 202, 'notifications return 202'); [$status] = aiMcpHttp('GET', $base . '/mcp', null, ['Authorization' => 'Bearer ' . $tokens['t_doc_a']]); aiMcpHttpExpect($status === 405, 'GET /mcp is 405 (no SSE stream)'); [$status] = $rpc($tokens['t_doc_a'], 'ping', [], ['MCP-Protocol-Version' => '1999-01-01']); aiMcpHttpExpect($status === 400, 'unsupported MCP-Protocol-Version is rejected'); [$status, , $headers] = $rpc('zyt_ai_' . str_repeat('0', 64), 'tools/list'); aiMcpHttpExpect($status === 401 && preg_match('/WWW-Authenticate:\s*Bearer/i', $headers) === 1, 'invalid token is 401 with WWW-Authenticate'); [$status] = $rpc($tokens['t_doc_a'], 'tools/list', [], ['Origin' => 'https://evil.example']); aiMcpHttpExpect($status === 403, 'foreign Origin is rejected'); [$status, $body] = $rpc($tokens['t_doc_a'], 'no/such/method'); aiMcpHttpExpect(($body['error']['code'] ?? 0) === -32601, 'unknown method is -32601'); [, $body] = $rpc($tokens['t_doc_a'], 'tools/list'); $names = array_column($body['result']['tools'] ?? [], 'name'); aiMcpHttpExpect(in_array('zyt_query', $names, true) && in_array('zyt_stats_appointments', $names, true), 'tools/list includes generic and permitted preset tools'); aiMcpHttpExpect(!in_array('zyt_stats_orders', $names, true), 'presets for resources the account cannot use are hidden'); foreach ($body['result']['tools'] as $definition) { aiMcpHttpExpect(($definition['annotations']['readOnlyHint'] ?? false) === true, $definition['name'] . ' is annotated read-only'); } // ---------------------------------------------------------------- 数据范围与脱敏 $range = ['start_date' => '2031-01-01', 'end_date' => '2031-01-31']; $expected = ['t_doc_a' => [96101, 96102], 't_doc_b' => [96103, 96104], 't_asst_c' => [96101, 96102, 96103], 't_asst_g' => [96104], 't_mgr_m' => [96101, 96102, 96103, 96104], 't_root' => [96101, 96102, 96103, 96104]]; foreach ($expected as $account => $wanted) { $result = $tool($tokens[$account], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range, 'page_size' => 50]); aiMcpHttpExpect(empty($result['isError']), "{$account} appointment query succeeds: " . ($result['content'][0]['text'] ?? '')); $got = array_values(array_intersect($ids($result), [96101, 96102, 96103, 96104])); sort($got); aiMcpHttpExpect($got === $wanted, "{$account} sees exactly its appointments: expected " . json_encode($wanted) . ' got ' . json_encode($got)); $count = $tool($tokens[$account], 'zyt_stats_appointments', $range); aiMcpHttpExpect(empty($count['isError']) && (int) ($count['structuredContent']['total'] ?? -1) >= count($wanted), "{$account} appointment stats agree with the list"); } $row = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range])['structuredContent']['rows'][0]; aiMcpHttpExpect(str_contains((string) $row['patient_phone'], '****'), 'doctor sees masked patient phone'); $row = $tool($tokens['t_mgr_m'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => $range])['structuredContent']['rows'][0]; aiMcpHttpExpect(!str_contains((string) $row['patient_phone'], '****'), 'account with tcm.diagnosis/phonePlain sees the full phone'); $result = $tool($tokens['t_asst_c'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['progress_board' => 1]]); aiMcpHttpExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], 'progress_board'), 'scope-widening parameter is rejected'); $result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'order.order/lists']); aiMcpHttpExpect(!empty($result['isError']), 'resource without permission is denied'); $result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'no.such/lists']); aiMcpHttpExpect(!empty($result['isError']), 'unknown resource is denied'); $result = $tool($tokens['t_doc_a'], 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['start_date' => '2020-01-01', 'end_date' => '2031-01-01']]); aiMcpHttpExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '天'), 'overlong date range is rejected'); $catalog = $tool($tokens['t_doc_a'], 'zyt_catalog', []); aiMcpHttpExpect((int) ($catalog['structuredContent']['total'] ?? 0) >= 1, 'catalog lists the permitted resources'); [$status, $body] = aiMcpHttp('GET', $base . '/mcp/auth/whoami', null, ['Authorization' => 'Bearer ' . $tokens['t_asst_c']]); aiMcpHttpExpect($status === 200 && ($body['data']['admin']['account'] ?? '') === 't_asst_c', 'whoami returns the bound account'); // ---------------------------------------------------------------- 审计 $log = Db::name('ai_access_log')->where(['admin_id' => 91002, 'client_task_id' => 'contract-task', 'resource' => 'doctor.appointment/lists', 'status' => 'ok'])->order('id', 'desc')->find(); aiMcpHttpExpect($log && str_contains((string) $log['record_ids'], '96101'), 'audit log records the task id and returned record ids'); aiMcpHttpExpect((int) Db::name('ai_access_log')->where(['admin_id' => 91004, 'status' => 'invalid'])->count() >= 1, 'rejected calls are audited'); // ---------------------------------------------------------------- 失效 [$status] = aiMcpHttp('POST', $base . '/mcp/auth/revoke', [], ['Authorization' => 'Bearer ' . $tokens['t_doc_b']]); aiMcpHttpExpect($status === 200, 'revoke succeeds'); [$status] = $rpc($tokens['t_doc_b'], 'tools/list'); aiMcpHttpExpect($status === 401, 'revoked token is rejected'); Db::name('admin')->where('id', 91003)->update(['password' => create_password('Changed@123', $salt)]); $fresh = $grant('t_doc_b', 'Changed@123')['data']['token'] ?? ''; Db::name('admin')->where('id', 91003)->update(['password' => $pwd]); [$status, $body] = $rpc($fresh, 'tools/list'); aiMcpHttpExpect($status === 401 && ($body['error']['data']['reason'] ?? '') === 'password_changed', 'password change invalidates the grant'); Db::name('admin')->where('id', 91009)->update(['disable' => 1]); [$status] = $rpc($tokens['t_asst_g'], 'tools/list'); Db::name('admin')->where('id', 91009)->update(['disable' => 0]); aiMcpHttpExpect($status === 401, 'disabling the account invalidates the grant'); Db::name('ai_grant')->where('admin_id', 91005)->update(['last_used_time' => $now - 40 * 86400]); [$status, $body] = $rpc($tokens['t_mgr_m'], 'tools/list'); aiMcpHttpExpect($status === 401 && ($body['error']['data']['reason'] ?? '') === 'expired', 'idle grant expires'); Db::name('system_role_menu')->where(['role_id' => 91, 'menu_id' => $menuId('ai.mcp/access')])->delete(); \think\facade\Cache::clear(); [$status] = $rpc($tokens['t_doc_a'], 'tools/list'); aiMcpHttpExpect($status === 401, 'removing the AI permission from the role takes effect immediately'); // ---------------------------------------------------------------- 后台管理接口(后台登录令牌) $sessionToken = 'aimcptest' . bin2hex(random_bytes(8)); Db::name('admin_session')->where('admin_id', 91001)->delete(); Db::name('admin_session')->insert(['admin_id' => 91001, 'terminal' => 1, 'token' => $sessionToken, 'update_time' => $now, 'expire_time' => $now + 3600]); [$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/grants?page_size=50', null, ['token' => $sessionToken]); aiMcpHttpExpect(($body['code'] ?? null) === 1 && (int) ($body['data']['count'] ?? 0) >= 5, 'root sees all grants in the admin page'); [$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/logs?client_task_id=contract-task', null, ['token' => $sessionToken]); aiMcpHttpExpect(($body['code'] ?? null) === 1 && (int) ($body['data']['count'] ?? 0) >= 1, 'root sees the access log'); [$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/catalog?page_size=5', null, ['token' => $sessionToken]); aiMcpHttpExpect(($body['code'] ?? null) === 1 && isset($body['data']['extend']['counts']['open']), 'catalog status page works'); [$status, $body] = aiMcpHttp('GET', $base . '/mcp/admin/grants', null, ['token' => 'not-a-session']); aiMcpHttpExpect(($body['code'] ?? null) === -1, 'admin endpoints require a back-office session'); echo "AiMcpHttpContractTest OK\n";