initialize(); $database = (string) config('database.connections.' . config('database.default') . '.database'); aiMcpReadOnlyExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)"); class AiMcpProbeController extends \app\BaseController { public function write() { Db::name('ai_access_log')->insert(['tool' => 'probe-write', 'create_time' => time()]); return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => []]); } public function nested() { Db::startTrans(); Db::name('ai_access_log')->insert(['tool' => 'probe-nested', 'create_time' => time()]); Db::commit(); return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => []]); } public function echo() { return json(['code' => 1, 'show' => 0, 'msg' => '', 'data' => [ 'params' => $this->request->param(), 'post' => $this->request->post(), 'method' => $this->request->method(), 'admin_id' => $this->request->adminId, 'root' => $this->request->adminInfo['root'] ?? null, 'controller' => $this->request->controller(), 'namespace' => app()->getNamespace(), 'authorization' => (string) $this->request->header('authorization', ''), ]]); } } $admin = Admin::order('id', 'asc')->findOrEmpty(); aiMcpReadOnlyExpect(!$admin->isEmpty(), 'test database needs at least one admin row'); $identity = new Identity(['id' => 0, 'expire_time' => time() + 600], $admin); $resource = static fn (string $action) => ['key' => 'probe.test/' . $action, 'controller' => AiMcpProbeController::class, 'http' => 'GET']; $original = $app->request; $namespace = $app->getNamespace(); $result = Dispatcher::call($identity, $resource('write'), []); aiMcpReadOnlyExpect($result['code'] === 0 && str_contains($result['msg'], '只读保护'), 'a write inside an AI call is blocked: ' . json_encode($result, JSON_UNESCAPED_UNICODE)); aiMcpReadOnlyExpect(Db::name('ai_access_log')->where('tool', 'probe-write')->count() === 0, 'blocked write left no row'); $result = Dispatcher::call($identity, $resource('nested'), []); aiMcpReadOnlyExpect($result['code'] === 0, 'a write inside a nested transaction is blocked too'); aiMcpReadOnlyExpect(Db::name('ai_access_log')->where('tool', 'probe-nested')->count() === 0, 'nested blocked write left no row'); $result = Dispatcher::call($identity, $resource('echo'), ['keyword' => '刘', 'page_no' => 1]); aiMcpReadOnlyExpect($result['code'] === 1, 'read-only call succeeds'); $data = $result['data']; aiMcpReadOnlyExpect($data['params'] == ['keyword' => '刘', 'page_no' => '1'], 'controller sees exactly the whitelisted params (strings after the Request trim filter): ' . json_encode($data['params'], JSON_UNESCAPED_UNICODE)); aiMcpReadOnlyExpect($data['post'] === [] && $data['method'] === 'GET', 'synthetic request is a clean GET'); aiMcpReadOnlyExpect((int) $data['admin_id'] === (int) $admin['id'], 'controller sees the bound account'); aiMcpReadOnlyExpect($data['controller'] === 'probe.test' && $data['namespace'] === 'app\\adminapi', 'controller context mirrors adminapi'); aiMcpReadOnlyExpect($data['authorization'] === '', 'the MCP bearer token is not forwarded to business code'); aiMcpReadOnlyExpect($app->request === $original, 'original request restored'); aiMcpReadOnlyExpect($app->getNamespace() === $namespace, 'app namespace restored'); $pdo = Db::connect()->getPdo(); aiMcpReadOnlyExpect(!$pdo->inTransaction(), 'no transaction left open'); $id = Db::name('ai_access_log')->insertGetId(['tool' => 'probe-after', 'create_time' => time()]); aiMcpReadOnlyExpect($id > 0, 'session is writable again after the AI call'); Db::name('ai_access_log')->where('id', $id)->delete(); echo "AiMcpReadOnlyTest OK\n";