Files
zyt/server/app/mcp/service/Tools.php
T
2026-09-24 11:48:20 +08:00

636 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\mcp\service;
/**
* MCP 工具:少量通用工具覆盖目录里的全部资源,另有几个高频统计的快捷工具;业绩类工具见 PerfTools。
* 所有工具只读;结果同时给文字摘要 + JSON(很多客户端只把 text 交给模型)。
*/
class Tools
{
private const READ_ONLY = ['readOnlyHint' => 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 !== ''),
],
];
}
}