;rgb:0000/0000/0000
gengx
This commit is contained in:
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
namespace app\mcp\service;
|
||||
|
||||
/**
|
||||
* MCP 工具:少量通用工具覆盖目录里的全部资源,另有几个高频统计的快捷工具。
|
||||
* MCP 工具:少量通用工具覆盖目录里的全部资源,另有几个高频统计的快捷工具;业绩类工具见 PerfTools。
|
||||
* 所有工具只读;结果同时给文字摘要 + JSON(很多客户端只把 text 交给模型)。
|
||||
*/
|
||||
class Tools
|
||||
@@ -53,7 +53,7 @@ class Tools
|
||||
$tools[] = self::tool($name, $preset['description'], $preset['args'], $preset['required']);
|
||||
}
|
||||
}
|
||||
return $tools;
|
||||
return array_merge($tools, PerfTools::definitions($identity));
|
||||
}
|
||||
|
||||
/** tools/call,返回 CallToolResult */
|
||||
@@ -72,6 +72,7 @@ class Tools
|
||||
$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'),
|
||||
};
|
||||
@@ -83,8 +84,9 @@ class Tools
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('[ai_mcp] 工具执行异常 ' . $name . ': ' . $e->getMessage());
|
||||
$audit['status'] = 'error';
|
||||
$audit['message'] = '内部错误';
|
||||
$result = self::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') {
|
||||
@@ -168,7 +170,7 @@ class Tools
|
||||
if ($resource['kind'] === 'detail') {
|
||||
throw new McpException('「' . $resource['name'] . '」是详情资源,请用 zyt_get 并提供 id', 'invalid');
|
||||
}
|
||||
$params = self::params($resource, (array) ($args['params'] ?? []));
|
||||
$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);
|
||||
}
|
||||
@@ -187,7 +189,7 @@ class Tools
|
||||
throw new McpException('请提供记录 id', 'invalid');
|
||||
}
|
||||
$idParam = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id');
|
||||
$params = self::params($resource, (array) ($args['params'] ?? []));
|
||||
$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);
|
||||
@@ -210,7 +212,7 @@ class Tools
|
||||
if (!in_array($resource['kind'], ['list', 'table'], true)) {
|
||||
throw new McpException('zyt_count 只用于列表资源', 'invalid');
|
||||
}
|
||||
$params = self::params($resource, (array) ($args['params'] ?? []));
|
||||
$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');
|
||||
@@ -344,8 +346,33 @@ class Tools
|
||||
return self::ok('「' . $resource['name'] . '」统计结果' . 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;
|
||||
}
|
||||
|
||||
/** 取资源并检查开放状态与权限 */
|
||||
private static function resource(Identity $identity, string $key): array
|
||||
public static function resource(Identity $identity, string $key): array
|
||||
{
|
||||
$resource = Catalog::get(trim($key));
|
||||
$denied = Catalog::denialFor($identity, $resource);
|
||||
@@ -356,7 +383,7 @@ class Tools
|
||||
}
|
||||
|
||||
/** 参数白名单 + 类型清洗 + 日期跨度检查 */
|
||||
private static function params(array $resource, array $input, bool $trusted = false): array
|
||||
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'] ?? []));
|
||||
@@ -402,7 +429,7 @@ class Tools
|
||||
return array_merge($params, ['page_no' => $page, 'page_size' => $size, 'page_type' => 1], (array) ($resource['force'] ?? []));
|
||||
}
|
||||
|
||||
private static function assertQuota(Identity $identity, int $rows): void
|
||||
public static function assertQuota(Identity $identity, int $rows): void
|
||||
{
|
||||
if (!RateLimiter::hit('calls_' . $identity->adminId, McpConfig::ratePerMinute(), 60)) {
|
||||
throw new McpException('调用太频繁,请稍后再试(每分钟最多 ' . McpConfig::ratePerMinute() . ' 次)', 'limited');
|
||||
@@ -412,7 +439,7 @@ class Tools
|
||||
}
|
||||
}
|
||||
|
||||
private static function failText(array $resource, array $envelope): string
|
||||
public static function failText(array $resource, array $envelope): string
|
||||
{
|
||||
$msg = trim($envelope['msg']) ?: '查询失败';
|
||||
return '「' . $resource['name'] . '」:' . $msg;
|
||||
@@ -424,7 +451,7 @@ class Tools
|
||||
}
|
||||
|
||||
/** 控制返回体积:超出上限时截掉尾部行或长字段 */
|
||||
private static function fit(array $data): array
|
||||
public static function fit(array $data): array
|
||||
{
|
||||
$limit = McpConfig::maxResponseBytes();
|
||||
$size = strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
@@ -466,7 +493,7 @@ class Tools
|
||||
return ['list' => '列表', 'detail' => '详情', 'report' => '统计/查询', 'table' => '数据表', 'other' => '查询'][$kind] ?? '查询';
|
||||
}
|
||||
|
||||
private static function tool(string $name, string $description, array $properties, array $required = []): array
|
||||
public static function tool(string $name, string $description, array $properties, array $required = []): array
|
||||
{
|
||||
$schema = ['type' => 'object', 'properties' => $properties ?: new \stdClass(), 'additionalProperties' => false];
|
||||
if ($required) {
|
||||
@@ -533,14 +560,6 @@ class Tools
|
||||
'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_stats_performance' => [
|
||||
'resource' => 'stats.yejiStats/overview', 'summary' => '业绩看板',
|
||||
'description' => '业绩看板:一段日期内按部门的线索、挂号、成交、业绩金额等汇总(与后台业绩看板一致)。',
|
||||
'args' => ['start_date' => $date, 'end_date' => $date, 'dept_ids' => ['type' => 'string', '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,
|
||||
'dept_ids' => $a['dept_ids'] ?? null], static fn ($v) => $v !== null && $v !== ''),
|
||||
],
|
||||
'zyt_my_patients' => [
|
||||
'resource' => 'firstvisit.myPatient/lists', 'summary' => '我的患者',
|
||||
'description' => '按姓名/手机号关键字查找“我的患者”(医生看自己接诊过的,医助看自己负责的),返回诊单ID、最近就诊和下次预约。',
|
||||
|
||||
Reference in New Issue
Block a user