true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false]; /** tools/list */ public static function definitions(Identity $identity): array { $tools = [ self::tool('zyt_whoami', '查看当前绑定的甄养堂账号:姓名、角色、数据范围、可查询的资源数量、今日已用额度。回答“我是谁/我能查什么”或排查无权限时使用。', []), self::tool('zyt_catalog', '列出当前账号可以查询的甄养堂数据资源(按业务分组)。先用它找到资源标识 resource,再用 zyt_describe 看参数,用 zyt_query / zyt_get / zyt_count 查询。', [ 'domain' => ['type' => 'string', 'description' => '只看某个业务分组,如“诊单与处方”“订单与收款”'], 'keyword' => ['type' => 'string', 'description' => '按名称或标识过滤,如“处方”“排班”“订单”'], 'include_closed' => ['type' => 'boolean', 'description' => '同时列出暂未开放的资源及原因'], ]), self::tool('zyt_describe', '查看某个数据资源的说明:可用查询参数及含义、类型(列表/详情/统计)、口径说明。', [ 'resource' => ['type' => 'string', 'description' => '资源标识,来自 zyt_catalog,如 doctor.appointment/lists'], ], ['resource']), self::tool('zyt_query', '查询列表或统计类资源,结果与该账号在甄养堂后台看到的一致(按其权限和数据范围)。翻页一律用外层 page/page_size(列表默认每页 20 条、最多 50 条;自带分页的统计明细最多 100 条),结果里有 total 和 has_more(统计明细在 result.paging)。', [ 'resource' => ['type' => 'string', 'description' => '资源标识,如 tcm.diagnosis/lists'], 'params' => ['type' => 'object', 'description' => '查询参数,名称见 zyt_describe;日期用 YYYY-MM-DD', 'additionalProperties' => true], 'page' => ['type' => 'integer', 'minimum' => 1, 'description' => '页码,从 1 开始'], 'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => McpConfig::maxPageSize(), 'description' => '每页条数'], 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => '只返回这些字段(可选,减少篇幅)'], ], ['resource']), self::tool('zyt_get', '查询详情类资源的一条记录(如某个诊单、处方、订单的详情)。会校验这条记录是否在当前账号的数据范围内。', [ 'resource' => ['type' => 'string', 'description' => '详情类资源标识,如 tcm.diagnosis/readonlyDetail'], 'id' => ['type' => 'string', 'description' => '记录 ID(数字写成字符串也可以)'], 'params' => ['type' => 'object', 'description' => '其他参数(可选)', 'additionalProperties' => true], ], ['resource', 'id']), self::tool('zyt_count', '只统计某个列表资源在给定条件下的总条数(不返回明细),适合“有多少”“几个”类问题。', [ 'resource' => ['type' => 'string', 'description' => '列表类资源标识'], 'params' => ['type' => 'object', 'description' => '查询参数', 'additionalProperties' => true], ], ['resource']), self::tool('zyt_file', '读取某条记录里的附件(舌象照片、检查报告等图片或 PDF)。结果里显示“[附件×N…]”时用它读取第 index 个附件。', [ 'resource' => ['type' => 'string', 'description' => '附件所在的详情或列表资源标识'], 'id' => ['type' => 'string', 'description' => '记录 ID(数字写成字符串也可以)'], 'field' => ['type' => 'string', 'description' => '附件字段名,如 tongue_images'], 'index' => ['type' => 'integer', 'minimum' => 0, 'description' => '第几个附件,从 0 开始'], ], ['resource', 'id', 'field']), ]; foreach (self::presets() as $name => $preset) { $resource = Catalog::get($preset['resource']); if ($resource && Catalog::denialFor($identity, $resource) === null) { $tools[] = self::tool($name, $preset['description'], $preset['args'], $preset['required']); } } return array_merge($tools, PerfTools::definitions($identity)); } /** tools/call,返回 CallToolResult */ public static function call(Identity $identity, string $name, array $args, array $context): array { $started = microtime(true); $audit = ['grant_id' => $identity->grant['id'] ?? 0, 'admin_id' => $identity->adminId, 'tool' => $name, 'arguments' => $args, 'client_task_id' => $context['task_id'] ?? '', 'ip' => $context['ip'] ?? '']; try { $presets = self::presets(); $result = match (true) { $name === 'zyt_whoami' => self::whoami($identity), $name === 'zyt_catalog' => self::catalog($identity, $args), $name === 'zyt_describe' => self::describe($identity, $args), $name === 'zyt_query' => self::query($identity, $args, $audit), $name === 'zyt_get' => self::get($identity, $args, $audit), $name === 'zyt_count' => self::count($identity, $args, $audit), $name === 'zyt_file' => self::file($identity, $args, $audit), PerfTools::handles($name) => PerfTools::call($identity, $name, $args, $audit), isset($presets[$name]) => self::preset($identity, $presets[$name], $args, $audit), default => throw new McpException('没有这个工具:' . $name, 'unknown_tool'), }; $audit['status'] = $audit['status'] ?? 'ok'; } catch (McpException $e) { $audit['status'] = in_array($e->reason, ['denied', 'limited', 'invalid'], true) ? $e->reason : 'error'; $audit['message'] = $e->getMessage(); $result = self::error($e->getMessage()); } catch (\Throwable $e) { \think\facade\Log::error('[ai_mcp] 工具执行异常 ' . $name . ': ' . $e->getMessage()); $audit['status'] = 'error'; $type = (new \ReflectionClass($e))->getShortName(); $audit['message'] = '内部错误 ' . $type; $result = self::error('查询失败(内部错误 ' . $type . '),请稍后再试;多次出现请联系管理员查看服务器日志'); } $audit['duration_ms'] = (int) round((microtime(true) - $started) * 1000); if (!in_array($name, ['zyt_whoami', 'zyt_catalog', 'zyt_describe'], true) || $audit['status'] !== 'ok') { AuditLogger::log($audit); } return $result; } private static function whoami(Identity $identity): array { $open = Catalog::openFor($identity); $data = [ 'account' => $identity->publicProfile(), 'data_scope' => $identity->dataScopeText(), 'full_phone_visible' => $identity->seesPhone(), 'full_sensitive_visible' => $identity->seesSensitive(), 'resources_open' => count($open), 'rows_today' => RateLimiter::rowsToday($identity->adminId), 'rows_daily_limit' => McpConfig::dailyRows(), 'grant_expire_at' => date('Y-m-d H:i', (int) $identity->grant['expire_time']), ]; $summary = sprintf('当前账号:%s(%s),数据范围:%s,可查询资源 %d 个。', $data['account']['name'], implode('/', $data['account']['roles']) ?: '无角色', $data['data_scope'], $data['resources_open']); return self::ok($summary, $data); } private static function catalog(Identity $identity, array $args): array { $domain = trim((string) ($args['domain'] ?? '')); $keyword = trim((string) ($args['keyword'] ?? '')); $includeClosed = !empty($args['include_closed']); $groups = []; $closed = []; foreach (Catalog::all() as $key => $r) { if ($domain !== '' && mb_strpos($r['domain'], $domain) === false) { continue; } if ($keyword !== '' && mb_stripos($r['name'] . ' ' . $key, $keyword) === false) { continue; } $denied = Catalog::denialFor($identity, $r); if ($denied === null) { $groups[$r['domain']][] = ['resource' => $key, 'name' => $r['name'], 'kind' => self::kindText($r['kind'])]; } elseif ($includeClosed && $r['status'] !== Catalog::EXCLUDED && ($r['status'] !== Catalog::OPEN || !$r['registered'] || $identity->can($r['perm']))) { $closed[] = ['resource' => $key, 'name' => $r['name'], 'reason' => $r['reason'] ?: '无权限']; } } ksort($groups); $count = array_sum(array_map('count', $groups)); $data = ['domains' => $groups, 'total' => $count]; if ($includeClosed) { $data['not_open'] = array_slice($closed, 0, 200); } return self::ok('可查询的数据资源 ' . $count . ' 个' . ($domain || $keyword ? '(已按条件过滤)' : '') . '。用 zyt_describe 查看参数。', $data); } private static function describe(Identity $identity, array $args): array { $resource = self::resource($identity, (string) ($args['resource'] ?? '')); $data = [ 'resource' => $resource['key'], 'name' => $resource['name'], 'domain' => $resource['domain'], 'kind' => self::kindText($resource['kind']), 'use' => $resource['kind'] === 'detail' ? 'zyt_get' : (in_array($resource['kind'], ['list', 'table'], true) ? 'zyt_query 或 zyt_count' : 'zyt_query'), 'params' => Catalog::paramDocs($resource), 'fixed_params' => (array) ($resource['force'] ?? []), 'note' => (string) ($resource['note'] ?? ''), 'limits' => ['page_size_max' => McpConfig::maxPageSize(), 'date_range_days_max' => McpConfig::maxRangeDays()], ]; if ($resource['kind'] === 'detail') { $data['id_param'] = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id'); } return self::ok('「' . $resource['name'] . '」的查询说明。', $data); } private static function query(Identity $identity, array $args, array &$audit): array { $resource = self::resource($identity, (string) ($args['resource'] ?? '')); $audit['resource'] = $resource['key']; if ($resource['kind'] === 'detail') { throw new McpException('「' . $resource['name'] . '」是详情资源,请用 zyt_get 并提供 id', 'invalid'); } $params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args)); if (in_array($resource['kind'], ['list', 'table'], true)) { return self::runList($identity, $resource, $params, (int) ($args['page'] ?? 1), (int) ($args['page_size'] ?? McpConfig::defaultPageSize()), (array) ($args['fields'] ?? []), $audit); } return self::runReport($identity, $resource, self::sinkPaging($resource, $params, $args), $audit); } /** * 自带分页的统计明细(参数叫 page/page_no 和 page_size,如进线明细、被指派明细):外层的 page/page_size 放进参数。 * 以前只看 params,模型照工具说明用外层 page 翻页时每次拿到的都是第一页,看起来像同一批记录反复出现。 */ private static function sinkPaging(array $resource, array $params, array $args): array { $allowed = array_flip(Catalog::allowedParams($resource)); $pageKey = isset($allowed['page']) ? 'page' : (isset($allowed['page_no']) ? 'page_no' : null); if ($pageKey !== null && !isset($params[$pageKey])) { $params[$pageKey] = max(1, (int) ($args['page'] ?? 1)); } if (isset($allowed['page_size']) && !isset($params['page_size'])) { $params['page_size'] = max(1, min(100, (int) ($args['page_size'] ?? McpConfig::defaultPageSize()))); } return $params; } /** 同一页里 id 相同的行只留一条(后台列表的联表偶尔会把一条记录拆成多行);有行没有 id 时原样返回 */ private static function dropDuplicateIds(array $rows): array { $seen = []; $out = []; foreach ($rows as $row) { if (!is_array($row) || !isset($row['id']) || !is_scalar($row['id'])) { return [$rows, 0]; } if (!isset($seen[(string) $row['id']])) { $seen[(string) $row['id']] = true; $out[] = $row; } } return [$out, count($rows) - count($out)]; } private static function get(Identity $identity, array $args, array &$audit): array { $resource = self::resource($identity, (string) ($args['resource'] ?? '')); $audit['resource'] = $resource['key']; if ($resource['kind'] !== 'detail') { throw new McpException('「' . $resource['name'] . '」不是详情资源,请用 zyt_query', 'invalid'); } $id = $args['id'] ?? null; if (!is_scalar($id) || (string) $id === '') { throw new McpException('请提供记录 id', 'invalid'); } $idParam = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id'); $params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args)); $params[$idParam] = is_numeric($id) ? (int) $id : (string) $id; self::assertQuota($identity, 1); $envelope = Dispatcher::call($identity, $resource, $params); if ($envelope['code'] !== 1) { throw new McpException(self::failText($resource, $envelope), 'denied'); } $policy = FieldPolicy::forIdentity($identity, 20000); $record = $policy->apply($envelope['data']); RateLimiter::addRows($identity->adminId, 1); $audit['result_rows'] = 1; $audit['record_ids'] = [(string) $id]; return self::ok('「' . $resource['name'] . '」ID ' . $id . ' 的详情' . self::maskNote($policy) . '。', self::fit(['resource' => $resource['key'], 'id' => $id, 'record' => $record, 'masked' => $policy->maskedFields()])); } private static function count(Identity $identity, array $args, array &$audit): array { $resource = self::resource($identity, (string) ($args['resource'] ?? '')); $audit['resource'] = $resource['key']; if (!in_array($resource['kind'], ['list', 'table'], true)) { throw new McpException('zyt_count 只用于列表资源', 'invalid'); } $params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args)); $envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, 1, 1)); if ($envelope['code'] !== 1) { throw new McpException(self::failText($resource, $envelope), 'denied'); } $total = (int) ($envelope['data']['count'] ?? 0); $policy = FieldPolicy::forIdentity($identity, 2000); $data = ['resource' => $resource['key'], 'total' => $total, 'params' => $params]; if (!empty($envelope['data']['extend'])) { $data['extend'] = $policy->apply($envelope['data']['extend']); } return self::ok('「' . $resource['name'] . '」符合条件的共 ' . $total . ' 条。', $data); } private static function file(Identity $identity, array $args, array &$audit): array { $resource = self::resource($identity, (string) ($args['resource'] ?? '')); $audit['resource'] = $resource['key']; $id = $args['id'] ?? null; $field = (string) ($args['field'] ?? ''); $index = max(0, (int) ($args['index'] ?? 0)); if (!is_scalar($id) || $field === '') { throw new McpException('请提供 id 和附件字段名 field', 'invalid'); } if ($resource['kind'] === 'detail') { $idParam = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id'); $envelope = Dispatcher::call($identity, $resource, array_merge([$idParam => $id], (array) ($resource['force'] ?? []))); $record = $envelope['code'] === 1 ? (array) $envelope['data'] : []; } else { $filter = !empty($resource['handler']['table']) ? [] : ['id' => $id]; $envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $filter, 1, 50)); $record = []; foreach ((array) ($envelope['data']['lists'] ?? []) as $row) { if ((string) ($row['id'] ?? '') === (string) $id) { $record = $row; } } } if ($envelope['code'] !== 1 || $record === []) { throw new McpException('找不到这条记录,或它不在当前账号的数据范围内', 'denied'); } $urls = FileFetcher::urls(self::dig($record, $field)); if (!isset($urls[$index])) { throw new McpException('字段 ' . $field . ' 没有第 ' . $index . ' 个附件(共 ' . count($urls) . ' 个)', 'invalid'); } $audit['record_ids'] = [(string) $id]; $audit['result_rows'] = 1; return FileFetcher::content($urls[$index], $resource['name'] . ' #' . $id . ' ' . $field . '[' . $index . ']'); } private static function preset(Identity $identity, array $preset, array $args, array &$audit): array { foreach ($preset['required'] as $required) { if (!isset($args[$required]) || $args[$required] === '') { throw new McpException('缺少参数 ' . $required, 'invalid'); } } $resource = self::resource($identity, $preset['resource']); $audit['resource'] = $resource['key']; $params = self::params($resource, ($preset['map'])($args), true); if (($preset['mode'] ?? '') === 'count') { $envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, 1, 1)); if ($envelope['code'] !== 1) { throw new McpException(self::failText($resource, $envelope), 'denied'); } $policy = FieldPolicy::forIdentity($identity, 2000); $data = ['total' => (int) ($envelope['data']['count'] ?? 0), 'extend' => $policy->apply($envelope['data']['extend'] ?? []), 'params' => $params]; return self::ok($preset['summary'] . ':共 ' . $data['total'] . ' 条。' . ($preset['note'] ?? ''), $data); } if ($resource['kind'] === 'list') { return self::runList($identity, $resource, $params, (int) ($args['page'] ?? 1), (int) ($args['page_size'] ?? McpConfig::defaultPageSize()), [], $audit); } return self::runReport($identity, $resource, $params, $audit); } private static function runList(Identity $identity, array $resource, array $params, int $page, int $size, array $fields, array &$audit): array { $page = max(1, $page); $size = max(1, min(McpConfig::maxPageSize(), $size ?: McpConfig::defaultPageSize())); self::assertQuota($identity, $size); $envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, $page, $size)); if ($envelope['code'] !== 1) { throw new McpException(self::failText($resource, $envelope), 'denied'); } [$rows, $duplicates] = self::dropDuplicateIds(array_values((array) ($envelope['data']['lists'] ?? []))); $total = (int) ($envelope['data']['count'] ?? count($rows)); if (count($rows) > $size) { // 个别列表不分页、总是返回全部行:在这里按页切片,避免超出篇幅和每日额度 $rows = array_slice($rows, ($page - 1) * $size, $size); $total = max($total, (int) ($envelope['data']['count'] ?? 0)); } $policy = FieldPolicy::forIdentity($identity, 2000); $rows = $policy->apply($rows); if ($fields) { $keep = array_flip(array_map('strval', $fields)); $rows = array_map(static fn ($row) => is_array($row) ? array_intersect_key($row, $keep + ['id' => 1]) : $row, $rows); } RateLimiter::addRows($identity->adminId, count($rows)); $audit['result_rows'] = count($rows); $audit['record_ids'] = AuditLogger::recordIds($rows); $data = ['resource' => $resource['key'], 'name' => $resource['name'], 'total' => $total, 'page' => $page, 'page_size' => $size, 'has_more' => $page * $size < $total, 'rows' => $rows, 'masked' => $policy->maskedFields()]; if ($duplicates > 0) { $data['duplicates_removed'] = $duplicates; } if (!empty($envelope['data']['extend'])) { $data['extend'] = $policy->apply($envelope['data']['extend']); } if (!empty($resource['note'])) { $data['note'] = $resource['note']; } $data = self::fit($data); $summary = sprintf('「%s」共 %d 条,本页第 %d 页 %d 条%s%s%s。', $resource['name'], $total, $page, count($data['rows']), $data['has_more'] ? ',还有更多(page=' . ($page + 1) . ')' : '', $duplicates > 0 ? ';后台返回的重复记录 ' . $duplicates . ' 条已去掉' : '', self::maskNote($policy)); return self::ok($summary, $data); } private static function runReport(Identity $identity, array $resource, array $params, array &$audit): array { self::assertQuota($identity, 1); $envelope = Dispatcher::call($identity, $resource, $params); if ($envelope['code'] !== 1) { throw new McpException(self::failText($resource, $envelope), 'denied'); } $policy = FieldPolicy::forIdentity($identity, 5000); $result = $policy->apply($envelope['data']); $extra = ''; $hasLists = is_array($result) && isset($result['lists']) && is_array($result['lists']); if ($hasLists) { [$result['lists'], $duplicates] = self::dropDuplicateIds(array_values($result['lists'])); if ($duplicates > 0) { $result['duplicates_removed'] = $duplicates; $extra .= ';后台返回的重复记录 ' . $duplicates . ' 条已去掉'; } // 自带分页的明细:告诉模型总数和下一页怎么取 $total = $result['count'] ?? $result['total'] ?? null; $pageKey = isset($params['page']) ? 'page' : (isset($params['page_no']) ? 'page_no' : null); if (is_numeric($total) && $pageKey !== null && isset($params['page_size'])) { $page = (int) $params[$pageKey]; $hasMore = $page * (int) $params['page_size'] < (int) $total; $result['paging'] = ['total' => (int) $total, 'page' => $page, 'page_size' => (int) $params['page_size'], 'has_more' => $hasMore]; $extra .= sprintf(';共 %d 条,本页第 %d 页 %d 条%s', (int) $total, $page, count($result['lists']), $hasMore ? ',还有更多(page=' . ($page + 1) . ')' : ''); } } $rows = $hasLists ? count($result['lists']) : 1; RateLimiter::addRows($identity->adminId, $rows); $audit['result_rows'] = $rows; if ($hasLists) { $audit['record_ids'] = AuditLogger::recordIds($result['lists']); } $data = self::fit(['resource' => $resource['key'], 'name' => $resource['name'], 'params' => $params, 'result' => $result, 'masked' => $policy->maskedFields(), 'note' => (string) ($resource['note'] ?? '')]); return self::ok('「' . $resource['name'] . '」统计结果' . $extra . self::maskNote($policy) . '。', $data); } /** * 模型常把分页写进 params(如 {"page_size": 50}):列表资源一律提到外层的 page/page_size; * 其他资源若本身不支持这些参数就忽略,避免因为这个白白失败一次。 */ private static function liftPaging(array $resource, array $input, array &$args): array { $isList = in_array($resource['kind'], ['list', 'table'], true); $allowed = array_flip(Catalog::allowedParams($resource)); $names = ['page' => 'page', 'page_no' => 'page', 'pageNo' => 'page', 'page_size' => 'page_size', 'pageSize' => 'page_size', 'limit' => 'page_size', 'size' => 'page_size']; foreach ($names as $name => $target) { if (!array_key_exists($name, $input)) { continue; } // 非列表资源自己支持的分页参数(如部分统计接口的 page_no/page_size)、以及列表里名为 limit/size 的真实筛选条件原样保留 if (isset($allowed[$name]) && (!$isList || in_array($name, ['limit', 'size'], true))) { continue; } if (!isset($args[$target]) && is_numeric($input[$name])) { $args[$target] = (int) $input[$name]; } unset($input[$name]); } return $input; } /** 取资源并检查开放状态与权限 */ public static function resource(Identity $identity, string $key): array { $resource = Catalog::get(trim($key)); $denied = Catalog::denialFor($identity, $resource); if ($denied !== null) { throw new McpException($denied, 'denied'); } return $resource; } /** 参数白名单 + 类型清洗 + 日期跨度检查 */ public static function params(array $resource, array $input, bool $trusted = false): array { $allowed = array_flip(Catalog::allowedParams($resource)); $forbidden = array_merge(Catalog::GLOBAL_FORBID, (array) ($resource['forbid'] ?? [])); $clean = []; $rejected = []; foreach ($input as $name => $value) { $name = (string) $name; // 快捷统计工具的参数由代码拼好(trusted),可超出白名单,但仍不能带全局或资源禁用的参数 if (!isset($allowed[$name]) && !($trusted && !in_array($name, $forbidden, true))) { $rejected[] = $name; continue; } if (is_bool($value)) { $value = $value ? 1 : 0; } if (is_array($value)) { $value = array_values(array_filter($value, 'is_scalar')); $value = array_map(static fn ($v) => is_string($v) ? mb_substr(trim($v), 0, 200) : $v, array_slice($value, 0, 100)); } elseif (is_string($value)) { $value = mb_substr(trim($value), 0, 200); } elseif (!is_int($value) && !is_float($value) && $value !== null) { continue; } $clean[$name] = $value; } if ($rejected) { throw new McpException('「' . $resource['name'] . '」不支持参数:' . implode('、', $rejected) . '。可用参数:' . (implode('、', array_keys($allowed)) ?: '无') . '(用 zyt_describe 查看说明)', 'invalid'); } foreach ([['start_date', 'end_date'], ['start_time', 'end_time'], ['create_time_start', 'create_time_end'], ['begin_date', 'end_date']] as [$from, $to]) { if (!empty($clean[$from]) && !empty($clean[$to]) && is_string($clean[$from]) && is_string($clean[$to])) { $a = strtotime($clean[$from]); $b = strtotime($clean[$to]); if ($a !== false && $b !== false && ($b - $a) / 86400 > McpConfig::maxRangeDays()) { throw new McpException('时间范围超过 ' . McpConfig::maxRangeDays() . ' 天,请缩小范围', 'invalid'); } } } return array_merge($clean, (array) ($resource['force'] ?? [])); } public static function listParams(array $resource, array $params, int $page, int $size): array { return array_merge($params, ['page_no' => $page, 'page_size' => $size, 'page_type' => 1], (array) ($resource['force'] ?? [])); } public static function assertQuota(Identity $identity, int $rows): void { if (!RateLimiter::hit('calls_' . $identity->adminId, McpConfig::ratePerMinute(), 60)) { throw new McpException('调用太频繁,请稍后再试(每分钟最多 ' . McpConfig::ratePerMinute() . ' 次)', 'limited'); } if (RateLimiter::rowsToday($identity->adminId) + $rows > McpConfig::dailyRows()) { throw new McpException('今日通过 AI 查询的数据已达上限(' . McpConfig::dailyRows() . ' 条),如需批量数据请使用后台导出', 'limited'); } } public static function failText(array $resource, array $envelope): string { $msg = trim($envelope['msg']) ?: '查询失败'; return '「' . $resource['name'] . '」:' . $msg; } private static function maskNote(FieldPolicy $policy): string { return $policy->maskedFields() ? '(部分个人信息已按权限脱敏:' . implode('、', array_slice($policy->maskedFields(), 0, 8)) . ')' : ''; } /** 控制返回体积:超出上限时截掉尾部行或长字段 */ public static function fit(array $data): array { $limit = McpConfig::maxResponseBytes(); $size = strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE)); if ($size <= $limit) { return $data; } if (isset($data['rows']) && is_array($data['rows'])) { while ($data['rows'] && strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE)) > $limit) { array_pop($data['rows']); } $data['truncated'] = '内容过长,只返回了前 ' . count($data['rows']) . ' 条;请减小 page_size 或用 fields 指定字段'; return $data; } $json = (string) json_encode($data['record'] ?? $data['result'] ?? $data, JSON_UNESCAPED_UNICODE); $key = isset($data['record']) ? 'record' : (isset($data['result']) ? 'result' : 'data'); $data[$key] = mb_strcut($json, 0, $limit - 2000) . '…'; $data['truncated'] = '内容过长,已截断为文本;请增加筛选条件'; return $data; } private static function dig(array $record, string $field) { if (array_key_exists($field, $record)) { return $record[$field]; } foreach ($record as $value) { if (is_array($value)) { $found = self::dig($value, $field); if ($found !== null) { return $found; } } } return null; } private static function kindText(string $kind): string { return ['list' => '列表', 'detail' => '详情', 'report' => '统计/查询', 'table' => '数据表', 'other' => '查询'][$kind] ?? '查询'; } public static function tool(string $name, string $description, array $properties, array $required = []): array { $schema = ['type' => 'object', 'properties' => $properties ?: new \stdClass(), 'additionalProperties' => false]; if ($required) { $schema['required'] = $required; } return ['name' => $name, 'description' => $description, 'inputSchema' => $schema, 'annotations' => self::READ_ONLY]; } private static function ok(string $summary, array $data): array { return [ 'content' => [['type' => 'text', 'text' => $summary . "\n" . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]], 'structuredContent' => $data ?: new \stdClass(), 'isError' => false, ]; } private static function error(string $message): array { return ['content' => [['type' => 'text', 'text' => $message]], 'isError' => true]; } /** * 高频统计的快捷工具:固定资源 + 友好参数。只有账号能用对应资源时才出现在工具列表里。 */ private static function presets(): array { $date = ['type' => 'string', 'description' => '日期 YYYY-MM-DD']; return [ 'zyt_stats_appointments' => [ 'resource' => 'doctor.appointment/lists', 'mode' => 'count', 'summary' => '挂号/接诊记录', 'description' => '统计一段日期内的挂号/接诊数量,并按状态(已预约/已取消/已完成/已过号)分组计数,可按医生筛选。医生账号自动只统计本人,医助只统计自己的患者。', 'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)'], 'status' => ['type' => 'integer', 'description' => '只统计某状态:1 已预约、2 已取消、3 已完成、4 已过号(可选)']], 'required' => ['start_date', 'end_date'], 'note' => 'extend.status_count 为各状态数量(1 已预约、2 已取消、3 已完成、4 已过号),按预约日期统计。', 'map' => static fn (array $a) => array_filter(['start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null, 'doctor_id' => $a['doctor_id'] ?? null, 'status' => $a['status'] ?? null, 'include_status_counts' => 1], static fn ($v) => $v !== null && $v !== ''), ], 'zyt_stats_doctor_workload' => [ 'resource' => 'doctor.statistics/lists', 'summary' => '医生工作量', 'description' => '按医生统计一段时间的挂号总数、已完成、过号、取消、接诊患者数、成交(开方)数。', 'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '只看某位医生(可选)']], 'required' => ['start_date', 'end_date'], 'map' => static fn (array $a) => array_filter(['time_type' => 'custom', 'start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null, 'doctor_id' => $a['doctor_id'] ?? null], static fn ($v) => $v !== null && $v !== ''), ], 'zyt_stats_orders' => [ 'resource' => 'order.order/orderStats', 'summary' => '收款订单统计', 'description' => '统计截至某日的最近 N 天(1–90)已支付收款订单金额与笔数;order_type:-1 全部已支付、0 退款、1–8 为各费用类型。', 'args' => ['end_date' => $date, 'days' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 90, 'description' => '最近多少天'], 'order_type' => ['type' => 'integer', 'description' => '-1 全部已支付(默认)、0 退款、1–8 费用类型']], 'required' => ['end_date', 'days'], 'map' => static fn (array $a) => ['end_time' => ($a['end_date'] ?? date('Y-m-d')) . ' 23:59:59', 'days' => max(1, min(90, (int) ($a['days'] ?? 7))), 'order_type' => (int) ($a['order_type'] ?? -1)], ], 'zyt_stats_prescription_orders' => [ 'resource' => 'tcm.prescriptionOrder/lists', 'mode' => 'count', 'summary' => '处方业务订单', 'description' => '统计一段时间内处方业务订单的数量和金额(extend 中的 stats_* 字段),可按医生、医助筛选。', 'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)'], 'assistant_id' => ['type' => 'integer', 'description' => '医助ID(可选)']], 'required' => ['start_date', 'end_date'], 'note' => '金额口径以 extend 中 stats_* 字段为准(与后台处方订单列表顶部统计一致)。', 'map' => static fn (array $a) => array_filter(['start_time' => ($a['start_date'] ?? '') . ' 00:00:00', 'end_time' => ($a['end_date'] ?? '') . ' 23:59:59', 'doctor_id' => $a['doctor_id'] ?? null, 'assistant_id' => $a['assistant_id'] ?? null], static fn ($v) => $v !== null && $v !== ''), ], 'zyt_my_patients' => [ 'resource' => 'firstvisit.myPatient/lists', 'summary' => '我的患者', 'description' => '按姓名/手机号关键字查找“我的患者”(医生看自己接诊过的,医助看自己负责的),返回诊单ID、最近就诊和下次预约。', 'args' => ['keyword' => ['type' => 'string', 'description' => '姓名或手机号(可选)'], 'page' => ['type' => 'integer', 'minimum' => 1]], 'required' => [], 'map' => static fn (array $a) => array_filter(['keyword' => $a['keyword'] ?? null], static fn ($v) => $v !== null && $v !== ''), ], 'zyt_roster' => [ 'resource' => 'doctor.roster/lists', 'summary' => '医生排班', 'description' => '查询医生排班:日期、时段、出诊状态(1 出诊、2 停诊、3 休息、4 请假)、号源与已约数。', 'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)']], 'required' => ['start_date', 'end_date'], 'map' => static fn (array $a) => array_filter(['start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null, 'doctor_id' => $a['doctor_id'] ?? null], static fn ($v) => $v !== null && $v !== ''), ], ]; } }