Commit cb405aef by luoqi

feat(mcp): P1 — PAC MCP 只读服务(开放患者工具给 agent)

POST /pac/v1/mcp(Streamable HTTP,无状态)暴露 6 个只读工具,薄封装现有 service:
- find_patient(检索,掩码号)/ get_patient_overview(360 一把梭)/ get_persona(全量画像)
  / get_facts(纯事实时间轴)/ get_recall_plan(召回原因+优先级)/ list_recall_queue(工作台)
- 鉴权:Bearer 平台 JWT(McpAuthService 复用 JwtService)→ 派生 TenantScope,工具继承租户隔离。
- 越权防护:assertPatientInScope 堵住 persona.getCurrent 等不带 scope 的读(defense-in-depth)。
- 集成:SDK 是 NodeNext-typed,经典 moduleResolution=Node 跟不动 → 手写最小 ambient 声明
  (src/types/mcp-sdk.d.ts),运行时走 clean subpath(exports map 放行),类型与运行时解耦。

本地端到端验证(:3101):401 无 token / initialize 返回 pac-patient-tools / tools/list 6 工具 /
find_patient(孙柯,138****7369)/ get_patient_overview(persona v4+召回+20事实)/
跨租户 patientId → isError 不在租户范围内。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 1d8e6bab
......@@ -18,6 +18,7 @@ import { AgentModule } from './modules/agent/agent.module';
import { AiModule } from './modules/ai/ai.module';
import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module';
import { AdminModule } from './modules/admin/admin.module';
import { McpModule } from './modules/mcp/mcp.module';
import { QueuesModule } from './queues/queues.module';
import { QueuesBullBoardModule } from './queues/bull-board.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
......@@ -49,6 +50,7 @@ import { HealthController } from './health.controller';
AgentModule,
AiModule,
RealtimeCoachModule,
McpModule,
AdminModule,
PlanAggregateModule,
],
......
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AccessTokenPayload } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
export interface McpAuthContext {
scope: TenantScopeContext;
permissions: string[];
}
/**
* McpAuthService — MCP 端点的 Bearer 鉴权。
*
* 复用平台 JWT(AuthModule 配置的 jwt.secret)。token payload 带 hostId/tenantId/clinicIds/permissions,
* → 派生 TenantScopeContext,每个工具调用据此**强制租户隔离**(与 REST 同一套边界)。
*/
@Injectable()
export class McpAuthService {
constructor(private readonly jwt: JwtService) {}
async verifyBearer(authHeader: string | undefined): Promise<McpAuthContext> {
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('缺 Bearer token');
}
const token = authHeader.slice('Bearer '.length).trim();
let payload: AccessTokenPayload;
try {
payload = await this.jwt.verifyAsync<AccessTokenPayload>(token);
} catch {
throw new UnauthorizedException('token 无效或已过期');
}
if (!payload?.hostId || !payload?.tenantId) {
throw new UnauthorizedException('token payload 缺 host/tenant');
}
return {
scope: {
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds ?? [],
userId: payload.sub,
},
permissions: payload.permissions ?? [],
};
}
}
import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service';
import { PlanService } from '../plan/plan.service';
import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { McpAuthContext } from './mcp-auth.service';
function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
function maskPhone(phone: string | null): string | null {
if (!phone) return null;
const d = phone.replace(/\D/g, '');
if (d.length < 7) return '***';
return `${d.slice(0, 3)}****${d.slice(-4)}`;
}
/** 患者基础卡(掩码手机号,去掉真号 PII) */
function patientCard(p: {
id: string;
externalId: string;
name: string | null;
phone: string | null;
gender: string | null;
birthDate: string | null;
status: string;
}) {
return {
id: p.id,
externalId: p.externalId,
name: p.name,
phoneMasked: maskPhone(p.phone),
gender: p.gender,
birthDate: p.birthDate,
status: p.status,
};
}
/**
* McpServerFactory — 每个(已鉴权的)请求构建一个 scoped McpServer。
*
* 6 个**只读**工具,全部薄封装现有 service;按 patientId 的工具先过 assertPatientInScope
* 堵住"persona.getCurrent 不带 scope"的越权洞(defense-in-depth)。
*/
@Injectable()
export class McpServerFactory {
constructor(
private readonly prisma: PrismaService,
private readonly patient: PatientService,
private readonly persona: PersonaService,
private readonly plans: PlanService,
) {}
build(ctx: McpAuthContext): McpServer {
const { scope, permissions } = ctx;
const server = new McpServer({ name: 'pac-patient-tools', version: '0.1.0' });
server.registerTool(
'find_patient',
{
description:
'按姓名/手机号/患者号模糊检索患者,返回极简候选卡片(手机号掩码)用于消歧。先用它拿到 patientId,再调其它工具。',
inputSchema: {
query: z.string().describe('姓名 / 手机号 / 患者号(模糊匹配)'),
limit: z.number().int().min(1).max(50).optional(),
},
},
async ({ query, limit }) => jsonResult(await this.patient.search(scope, query, limit ?? 10)),
);
server.registerTool(
'get_patient_overview',
{
description:
'一次拉取患者 360 全景:画像要点 + 近期事实 + 当前召回计划。召回前的首选工具,省去多次往返。',
inputSchema: { patientId: z.string() },
},
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
const [persona, recallPlan, timeline] = await Promise.all([
this.persona.getCurrent(patientId),
this.activeRecallPlan(scope, patientId),
this.patient.getTimeline(scope, patientId, 20),
]);
return jsonResult({
patient: patientCard(timeline.patient),
persona,
recallPlan,
recentFacts: timeline.facts,
});
},
);
server.registerTool(
'get_persona',
{
description:
'患者全量画像:RFM 价值分群 / 生命周期 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等所有 persona 特征(PAC 的推断分析层)。',
inputSchema: { patientId: z.string() },
},
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
return jsonResult(await this.persona.getCurrent(patientId));
},
);
server.registerTool(
'get_facts',
{
description:
'患者客观事实数据(诊断/治疗/预约/收费等 active 版本时间轴)。只给事实,不含推断分析(分析见 get_persona)。',
inputSchema: {
patientId: z.string(),
limit: z.number().int().min(1).max(200).optional(),
},
},
async ({ patientId, limit }) => {
const t = await this.patient.getTimeline(scope, patientId, limit ?? 50);
return jsonResult({ patient: patientCard(t.patient), facts: t.facts });
},
);
server.registerTool(
'get_recall_plan',
{
description:
'患者当前的召回计划:为什么召回(场景/原因)、优先级、目标诊所、状态。无活跃计划则返回 null。',
inputSchema: { patientId: z.string() },
},
async ({ patientId }) => {
await this.assertPatientInScope(scope, patientId);
return jsonResult(await this.activeRecallPlan(scope, patientId));
},
);
server.registerTool(
'list_recall_queue',
{
description:
'召回工作台:列出待跟进的召回计划(view=pool 未分配召回池 / mine 我名下)。回答"现在该联系谁"。',
inputSchema: {
view: z.enum(['pool', 'mine']).optional(),
clinicId: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
},
async ({ view, clinicId, limit }) => {
const query = {
view: view ?? 'pool',
page: 1,
pageSize: limit ?? 20,
...(clinicId ? { clinicId } : {}),
} as unknown as ListPlansQueryDto;
return jsonResult(await this.plans.list(scope, query, permissions));
},
);
return server;
}
/** 越权防护:确认该 patientId 属当前租户(persona.getCurrent 等不带 scope 的读之前必调)。 */
private async assertPatientInScope(scope: TenantScopeContext, patientId: string): Promise<void> {
const p = await this.prisma.patient.findFirst({
where: { id: patientId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { id: true },
});
if (!p) throw new Error(`patient ${patientId} 不在当前租户范围内`);
}
private async activeRecallPlan(scope: TenantScopeContext, patientId: string) {
const plan = await this.prisma.followupPlan.findFirst({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
status: { in: ['active', 'assigned'] },
supersededAt: null,
},
orderBy: { priorityScore: 'desc' },
select: {
id: true,
status: true,
priorityScore: true,
targetClinicId: true,
assigneeUserId: true,
snoozedUntil: true,
},
});
if (!plan) return null;
const reasons = await this.prisma.planReason.findMany({
where: { planId: plan.id },
select: { scenario: true, priorityScore: true, reason: true },
});
return {
planId: plan.id,
status: plan.status,
priorityScore: plan.priorityScore,
targetClinicId: plan.targetClinicId,
assigned: !!plan.assigneeUserId,
snoozedUntil: plan.snoozedUntil ? plan.snoozedUntil.toISOString() : null,
reasons,
};
}
}
import { Controller, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Public } from '../../common/decorators/public.decorator';
import { McpAuthService } from './mcp-auth.service';
import { McpServerFactory } from './mcp-server.factory';
/**
* McpController — PAC MCP Server 入口(Streamable HTTP,无状态)。
*
* 端点:POST /pac/v1/mcp
* 鉴权:`Authorization: Bearer <平台 JWT>`(@Public 跳过全局 JWT guard,本控制器自验并派生 scope)。
*
* 每个请求:验签 → 构建 scoped McpServer(工具继承租户隔离)→ 无状态 transport.handleRequest。
* 无状态(sessionIdGenerator:undefined + enableJsonResponse:true):可过普通 LB,无 session store。
*/
@ApiTags('mcp')
@Controller('mcp')
export class McpController {
constructor(
private readonly auth: McpAuthService,
private readonly factory: McpServerFactory,
) {}
@Post()
@Public()
@ApiOperation({
summary: 'PAC MCP server(Streamable HTTP)— 暴露患者只读工具给 agent;Bearer JWT 鉴权',
})
async handle(@Req() req: Request, @Res() res: Response): Promise<void> {
let ctx;
try {
ctx = await this.auth.verifyBearer(req.headers['authorization']);
} catch {
res
.status(401)
.json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null });
return;
}
const server = this.factory.build(ctx);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on('close', () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PatientModule } from '../patient/patient.module';
import { PersonaModule } from '../persona/persona.module';
import { PlanModule } from '../plan/plan.module';
import { McpController } from './mcp.controller';
import { McpAuthService } from './mcp-auth.service';
import { McpServerFactory } from './mcp-server.factory';
/**
* McpModule — PAC MCP Server(开放患者只读工具给 agent)。
*
* 复用:AuthModule(JwtService 验 Bearer)、PatientModule/PersonaModule/PlanModule(工具背后的 service)。
* PrismaService 全局可注入。
*/
@Module({
imports: [AuthModule, PatientModule, PersonaModule, PlanModule],
controllers: [McpController],
providers: [McpAuthService, McpServerFactory],
})
export class McpModule {}
/**
* 最小 ambient 类型声明:@modelcontextprotocol/sdk 的两个 server 子模块。
*
* 为什么手写:SDK 的 .d.ts 是 NodeNext 风格(相对导入带 `.js` 后缀),本项目用经典
* `moduleResolution: Node`(配 `module: CommonJS`,不能改否则牵动整库),跟不动 SDK 内部
* 的 `.js` 相对导入 → 类型无法解析。运行时则 OK(Node 的 exports map 放行 clean subpath)。
* 故此处只声明我们用到的那几个 API,类型与运行时解耦。升级 SDK 时若签名变,改这里即可。
*/
declare module '@modelcontextprotocol/sdk/server/mcp.js' {
import type { ZodRawShape } from 'zod';
export type McpToolResult = {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
};
export class McpServer {
constructor(info: { name: string; version: string });
registerTool(
name: string,
config: { title?: string; description?: string; inputSchema?: ZodRawShape },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cb: (args: any) => McpToolResult | Promise<McpToolResult>,
): unknown;
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}
}
declare module '@modelcontextprotocol/sdk/server/streamableHttp.js' {
import type { IncomingMessage, ServerResponse } from 'node:http';
export class StreamableHTTPServerTransport {
constructor(options?: {
sessionIdGenerator?: (() => string) | undefined;
enableJsonResponse?: boolean;
});
handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise<void>;
close(): Promise<void>;
}
}
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