Commit c9743ed0 by luoqi

refactor(mcp): 评审小瑕疵清理 + 修 list_recall_queue clinicId 静默失效

评审发现的 4 处小瑕疵:
1. 工具清单进程级缓存(McpClientService.toolsCache)— 工具静态、与租户无关,省每轮 tools/list 往返。
2. list_recall_queue 去掉 `as unknown as ListPlansQueryDto` 强转,改类型化字面量;
   过程中发现隐藏 bug:schema 字段是 targetClinicId 而非 clinicId,旧 cast 把 clinicId 静默吞掉
   → 工具的诊所过滤一直没生效。改用 targetClinicId,验证 271→96 条且全为该诊所。
3. McpClientService.url 注释澄清:同进程 loopback,PORT 与 main.ts 监听端口一致(默认 3001)。
4. get_facts 加注释说明经 getTimeline 自守(findFirst{host,tenant}+NotFound),与其它工具显式 assert 等价安全;
   顶部注释据实修正两条隔离路径。

tsc 0;端到端验证:6 工具正常 + clinicId 过滤生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 8bc9927d
......@@ -17,8 +17,12 @@ export interface McpToolDef {
@Injectable()
export class McpClientService {
private readonly logger = new Logger(McpClientService.name);
// 与 MCP server 同进程,走 loopback;PORT 即 main.ts 的监听端口(默认 3001,与之一致)。
// 异常拓扑(反代/独立部署)时用 PAC_MCP_URL 覆盖。
private readonly url =
process.env.PAC_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? '3001'}/pac/v1/mcp`;
// 工具清单是静态的(6 个工具与租户无关,scope 只影响执行)→ 进程级缓存,省每轮 tools/list 往返。
private toolsCache: McpToolDef[] | null = null;
private async rpc(token: string, method: string, params?: unknown): Promise<Record<string, unknown>> {
const res = await fetch(this.url, {
......@@ -39,8 +43,10 @@ export class McpClientService {
}
async listTools(token: string): Promise<McpToolDef[]> {
if (this.toolsCache) return this.toolsCache;
const r = await this.rpc(token, 'tools/list');
return (r.tools as McpToolDef[]) ?? [];
this.toolsCache = (r.tools as McpToolDef[]) ?? [];
return this.toolsCache;
}
/** 调工具 → 返回纯文本结果(MCP content[].text 拼接);isError 时抛出供 agent 看到。 */
......
......@@ -45,8 +45,9 @@ function patientCard(p: {
/**
* McpServerFactory — 每个(已鉴权的)请求构建一个 scoped McpServer。
*
* 6 个**只读**工具,全部薄封装现有 service;按 patientId 的工具先过 assertPatientInScope
* 堵住"persona.getCurrent 不带 scope"的越权洞(defense-in-depth)。
* 6 个**只读**工具,全部薄封装现有 service。租户隔离两条路径(均等价安全):
* - 调不带 scope 的读(persona.getCurrent)的工具 → 先过 assertPatientInScope 堵越权;
* - get_facts → getTimeline 内部 findFirst{host,tenant}+NotFound 自守,无需额外 assert。
*/
@Injectable()
export class McpServerFactory {
......@@ -121,6 +122,8 @@ export class McpServerFactory {
},
},
async ({ patientId, limit }) => {
// 无需单独 assertPatientInScope:getTimeline 内部 findFirst{hostId,tenantId} 且
// 越租户即抛 NotFound —— 已自带租户隔离(与其它 4 个工具的显式 assert 等价安全)。
const t = await this.patient.getTimeline(scope, patientId, limit ?? 50);
return jsonResult({ patient: patientCard(t.patient), facts: t.facts });
},
......@@ -151,12 +154,15 @@ export class McpServerFactory {
},
},
async ({ view, clinicId, limit }) => {
const query = {
// 注:schema 的诊所过滤字段是 targetClinicId(不是 clinicId);以前 cast 成 any 传 clinicId
// 被静默忽略。这里类型化构造,过滤真正生效。
const query: ListPlansQueryDto = {
view: view ?? 'pool',
sort: 'priority_desc',
page: 1,
pageSize: limit ?? 20,
...(clinicId ? { clinicId } : {}),
} as unknown as ListPlansQueryDto;
...(clinicId ? { targetClinicId: clinicId } : {}),
};
return jsonResult(await this.plans.list(scope, query, permissions));
},
);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment