From 027a8c1b6a55b06deeed2d47fd188c1145b3ede1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sat, 3 Oct 2026 22:23:35 +0800 Subject: [PATCH] feat: add authenticated remote MCP access and complete business workflows --- apps/api/.env.example | 6 + apps/api/package.json | 3 + .../20261003090000_agent_access/migration.sql | 85 ++ .../20261003100000_agent_audit/migration.sql | 15 + .../migration.sql | 50 + apps/api/prisma/schema.prisma | 127 +++ apps/api/scripts/mcp-probe.ts | 38 + apps/api/src/auth.ts | 93 +- apps/api/src/backup.ts | 83 +- apps/api/src/calendar.ts | 21 +- apps/api/src/database.ts | 26 +- apps/api/src/icons.ts | 56 +- apps/api/src/main.ts | 40 +- apps/api/src/mcp/catalogue.ts | 370 ++++++++ apps/api/src/mcp/files.ts | 185 ++++ apps/api/src/mcp/management.ts | 159 ++++ apps/api/src/mcp/oauth.ts | 328 +++++++ apps/api/src/mcp/operations.ts | 349 +++++++ apps/api/src/mcp/transport.ts | 250 +++++ apps/api/src/metals.ts | 45 +- apps/api/src/portfolio.ts | 134 ++- apps/api/src/rates.ts | 35 +- apps/api/src/schedules.ts | 52 +- apps/api/src/transfers.ts | 42 +- apps/api/test/inclusion-metals.test.ts | 4 +- apps/api/test/mcp.test.ts | 875 ++++++++++++++++++ apps/api/test/record-edit.test.ts | 12 +- apps/api/test/update.test.ts | 13 +- apps/web/src/AgentConnections.tsx | 395 ++++++++ apps/web/src/App.tsx | 23 +- apps/web/src/style.css | 53 ++ docs/database-comments.json | 248 ++++- docs/mcp-coverage.md | 66 ++ docs/mcp.md | 135 +++ pnpm-lock.yaml | 197 ++++ 35 files changed, 4430 insertions(+), 183 deletions(-) create mode 100644 apps/api/prisma/migrations/20261003090000_agent_access/migration.sql create mode 100644 apps/api/prisma/migrations/20261003100000_agent_audit/migration.sql create mode 100644 apps/api/prisma/migrations/20261003110000_agent_comments/migration.sql create mode 100644 apps/api/scripts/mcp-probe.ts create mode 100644 apps/api/src/mcp/catalogue.ts create mode 100644 apps/api/src/mcp/files.ts create mode 100644 apps/api/src/mcp/management.ts create mode 100644 apps/api/src/mcp/oauth.ts create mode 100644 apps/api/src/mcp/operations.ts create mode 100644 apps/api/src/mcp/transport.ts create mode 100644 apps/api/test/mcp.test.ts create mode 100644 apps/web/src/AgentConnections.tsx create mode 100644 docs/mcp-coverage.md create mode 100644 docs/mcp.md diff --git a/apps/api/.env.example b/apps/api/.env.example index 8bfb9eb..a63b723 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,3 +3,9 @@ PORT=3100 # Local tunnel testing may use WEB_ORIGIN=*. Production requires an exact origin. WEB_ORIGIN=http://localhost:5173 COOKIE_SECURE=false +# Canonical OAuth protected resource. Production must use HTTPS. +MCP_PUBLIC_URL=http://localhost:3100/mcp +# Web confirmation page, normally the same origin as WEB_ORIGIN. +MCP_WEB_URL=http://localhost:5173 +# Exact browser Origin allowlist; never use *. Non-browser clients may omit Origin. +MCP_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/apps/api/package.json b/apps/api/package.json index f8bba2a..46dea6b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,9 +11,12 @@ "db:status": "node scripts/database.cjs status", "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts", "test:performance": "tsx scripts/performance.ts after", + "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts", + "mcp:probe": "tsx scripts/mcp-probe.ts", "icons:seed": "node scripts/seed-icons.cjs" }, "dependencies": { + "@modelcontextprotocol/sdk": "1.31.0", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", diff --git a/apps/api/prisma/migrations/20261003090000_agent_access/migration.sql b/apps/api/prisma/migrations/20261003090000_agent_access/migration.sql new file mode 100644 index 0000000..8f188c0 --- /dev/null +++ b/apps/api/prisma/migrations/20261003090000_agent_access/migration.sql @@ -0,0 +1,85 @@ +-- CreateTable +CREATE TABLE `AgentPolicy` ( + `userId` CHAR(36) NOT NULL, + `mode` VARCHAR(16) NOT NULL DEFAULT 'draft', + + PRIMARY KEY (`userId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AgentClient` ( + `id` CHAR(36) NOT NULL, + `metadata` JSON NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AgentAuthorization` ( + `id` CHAR(36) NOT NULL, + `userId` CHAR(36) NULL, + `clientId` CHAR(36) NOT NULL, + `parameters` JSON NOT NULL, + `codeDigest` CHAR(64) NULL, + `expiresAt` DATETIME(3) NOT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'pending', + + UNIQUE INDEX `AgentAuthorization_codeDigest_key`(`codeDigest`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AgentGrant` ( + `id` CHAR(36) NOT NULL, + `userId` CHAR(36) NOT NULL, + `clientId` CHAR(36) NULL, + `name` VARCHAR(100) NOT NULL, + `scopes` JSON NOT NULL, + `resource` VARCHAR(500) NOT NULL, + `accessDigest` CHAR(64) NOT NULL, + `refreshDigest` CHAR(64) NULL, + `expiresAt` DATETIME(3) NOT NULL, + `refreshExpiresAt` DATETIME(3) NULL, + `revokedAt` DATETIME(3) NULL, + `sessionId` CHAR(64) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `AgentGrant_accessDigest_key`(`accessDigest`), + UNIQUE INDEX `AgentGrant_refreshDigest_key`(`refreshDigest`), + INDEX `AgentGrant_userId_createdAt_idx`(`userId`, `createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AgentOperation` ( + `id` CHAR(36) NOT NULL, + `userId` CHAR(36) NOT NULL, + `grantId` CHAR(36) NOT NULL, + `key` VARCHAR(128) NOT NULL, + `hash` CHAR(64) NOT NULL, + `tool` VARCHAR(100) NOT NULL, + `parameters` JSON NOT NULL, + `snapshot` CHAR(64) NOT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'pending', + `expiresAt` DATETIME(3) NOT NULL, + `result` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completedAt` DATETIME(3) NULL, + + INDEX `AgentOperation_userId_createdAt_idx`(`userId`, `createdAt`), + UNIQUE INDEX `AgentOperation_userId_key_key`(`userId`, `key`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `AgentPolicy` ADD CONSTRAINT `AgentPolicy_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AgentAuthorization` ADD CONSTRAINT `AgentAuthorization_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AgentGrant` ADD CONSTRAINT `AgentGrant_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AgentOperation` ADD CONSTRAINT `AgentOperation_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20261003100000_agent_audit/migration.sql b/apps/api/prisma/migrations/20261003100000_agent_audit/migration.sql new file mode 100644 index 0000000..a2f4a5c --- /dev/null +++ b/apps/api/prisma/migrations/20261003100000_agent_audit/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE `AgentCall` ( + `id` CHAR(36) NOT NULL, + `userId` CHAR(36) NOT NULL, + `grantId` CHAR(36) NOT NULL, + `tool` VARCHAR(100) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `AgentCall_userId_createdAt_idx`(`userId`, `createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `AgentCall` ADD CONSTRAINT `AgentCall_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20261003110000_agent_comments/migration.sql b/apps/api/prisma/migrations/20261003110000_agent_comments/migration.sql new file mode 100644 index 0000000..919a400 --- /dev/null +++ b/apps/api/prisma/migrations/20261003110000_agent_comments/migration.sql @@ -0,0 +1,50 @@ +ALTER TABLE `AgentPolicy` COMMENT '用户的 Agent 写入策略'; +ALTER TABLE `AgentPolicy` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离'; +ALTER TABLE `AgentPolicy` MODIFY `mode` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '写入策略:readonly 只读、draft 草稿、direct 普通直接写入'; +ALTER TABLE `AgentClient` COMMENT 'OAuth 动态注册客户端元数据'; +ALTER TABLE `AgentClient` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识'; +ALTER TABLE `AgentClient` MODIFY `metadata` JSON NOT NULL COMMENT '公开客户端注册元数据,不保存用户凭据'; +ALTER TABLE `AgentClient` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC'; +ALTER TABLE `AgentAuthorization` COMMENT '短期 OAuth 授权请求及一次性授权码摘要'; +ALTER TABLE `AgentAuthorization` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识'; +ALTER TABLE `AgentAuthorization` MODIFY `userId` CHAR(36) NULL COMMENT '所属用户标识,用于数据隔离'; +ALTER TABLE `AgentAuthorization` MODIFY `clientId` CHAR(36) NOT NULL COMMENT 'OAuth 客户端标识;个人令牌为空'; +ALTER TABLE `AgentAuthorization` MODIFY `parameters` JSON NOT NULL COMMENT '具体操作参数;禁止保存密码及令牌'; +ALTER TABLE `AgentAuthorization` MODIFY `codeDigest` CHAR(64) NULL COMMENT '一次性授权码 SHA-256 摘要'; +ALTER TABLE `AgentAuthorization` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC'; +ALTER TABLE `AgentAuthorization` MODIFY `status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '当前处理状态'; +ALTER TABLE `AgentGrant` COMMENT '用户授权连接、令牌摘要及资源权限'; +ALTER TABLE `AgentGrant` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识'; +ALTER TABLE `AgentGrant` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离'; +ALTER TABLE `AgentGrant` MODIFY `clientId` CHAR(36) NULL COMMENT 'OAuth 客户端标识;个人令牌为空'; +ALTER TABLE `AgentGrant` MODIFY `name` VARCHAR(100) NOT NULL COMMENT '用户可见连接名称'; +ALTER TABLE `AgentGrant` MODIFY `scopes` JSON NOT NULL COMMENT '权限列表:read、draft、write、sensitive'; +ALTER TABLE `AgentGrant` MODIFY `resource` VARCHAR(500) NOT NULL COMMENT '令牌适用的规范 MCP 资源地址'; +ALTER TABLE `AgentGrant` MODIFY `accessDigest` CHAR(64) NOT NULL COMMENT '访问令牌 SHA-256 摘要;完整值仅颁发时返回'; +ALTER TABLE `AgentGrant` MODIFY `refreshDigest` CHAR(64) NULL COMMENT '刷新令牌 SHA-256 摘要,使用后轮换'; +ALTER TABLE `AgentGrant` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC'; +ALTER TABLE `AgentGrant` MODIFY `refreshExpiresAt` DATETIME(3) NULL COMMENT '刷新授权到期时间,UTC'; +ALTER TABLE `AgentGrant` MODIFY `revokedAt` DATETIME(3) NULL COMMENT '撤销时间,UTC;空表示未撤销'; +ALTER TABLE `AgentGrant` MODIFY `sessionId` CHAR(64) NOT NULL COMMENT '本连接的独立业务授权会话标识,不作为 MCP 凭证'; +ALTER TABLE `AgentGrant` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC'; +ALTER TABLE `AgentOperation` COMMENT 'Agent 写入草稿、幂等记录及提交结果'; +ALTER TABLE `AgentOperation` MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识'; +ALTER TABLE `AgentOperation` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离'; +ALTER TABLE `AgentOperation` MODIFY `grantId` CHAR(36) NOT NULL COMMENT '发起操作的授权连接标识'; +ALTER TABLE `AgentOperation` MODIFY `key` VARCHAR(128) NOT NULL COMMENT '同用户唯一的客户端幂等键'; +ALTER TABLE `AgentOperation` MODIFY `hash` CHAR(64) NOT NULL COMMENT '工具和参数的规范摘要;拒绝同键不同参数'; +ALTER TABLE `AgentOperation` MODIFY `tool` VARCHAR(100) NOT NULL COMMENT '业务工具名称'; +ALTER TABLE `AgentOperation` MODIFY `parameters` JSON NOT NULL COMMENT '具体操作参数;禁止保存密码及令牌'; +ALTER TABLE `AgentOperation` MODIFY `snapshot` CHAR(64) NOT NULL COMMENT '确认前的账目版本摘要,防止覆盖并发修改'; +ALTER TABLE `AgentOperation` MODIFY `status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '当前处理状态'; +ALTER TABLE `AgentOperation` MODIFY `expiresAt` DATETIME(3) NOT NULL COMMENT '到期时间,UTC'; +ALTER TABLE `AgentOperation` MODIFY `result` JSON NULL COMMENT '结构化操作结果或最近提交失败原因'; +ALTER TABLE `AgentOperation` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,UTC'; +ALTER TABLE `AgentOperation` MODIFY `completedAt` DATETIME(3) NULL COMMENT '完成或取消时间,UTC'; +ALTER TABLE `AgentCall` COMMENT 'MCP 调用审计,不保存参数、密码、令牌或财务内容'; +ALTER TABLE `AgentCall` MODIFY `id` CHAR(36) NOT NULL COMMENT '审计标识'; +ALTER TABLE `AgentCall` MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离'; +ALTER TABLE `AgentCall` MODIFY `grantId` CHAR(36) NOT NULL COMMENT '调用所属连接标识'; +ALTER TABLE `AgentCall` MODIFY `tool` VARCHAR(100) NOT NULL COMMENT '调用的工具名称'; +ALTER TABLE `AgentCall` MODIFY `status` VARCHAR(16) NOT NULL COMMENT '结果状态:success 或 error'; +ALTER TABLE `AgentCall` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '调用完成时间,UTC'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f9a525a..d78bc40 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -42,6 +42,133 @@ model User { icons Icon[] transfers Transfer[] schedules Schedule[] + agentGrants AgentGrant[] + agentOperations AgentOperation[] + agentAuthorizations AgentAuthorization[] + agentPolicy AgentPolicy? + agentCalls AgentCall[] +} + +/// MCP 调用审计,不保存工具参数、令牌、密码或财务内容 +model AgentCall { + /// 审计记录标识 + id String @id @default(uuid()) @db.Char(36) + /// 可信认证上下文中的用户标识 + userId String @db.Char(36) + /// 所属用户;注销后级联清理 + user User @relation(fields:[userId],references:[id],onDelete:Cascade) + /// 发起调用的连接标识 + grantId String @db.Char(36) + /// 工具名称 + tool String @db.VarChar(100) + /// 结果状态 success 或 error + status String @db.VarChar(16) + /// 调用完成时间,UTC + createdAt DateTime @default(now()) + @@index([userId,createdAt]) +} + +/// 用户的 Agent 写入策略 +model AgentPolicy { + /// 所属用户标识,用于数据隔离 + userId String @id @db.Char(36) + user User @relation(fields:[userId],references:[id],onDelete:Cascade) + /// 写入策略:readonly 只读、draft 草稿、direct 普通直接写入 + mode String @default("draft") @db.VarChar(16) +} + +/// OAuth 动态注册客户端元数据 +model AgentClient { + /// 唯一标识 + id String @id @db.Char(36) + /// 公开客户端注册元数据,不保存用户凭据 + metadata Json + /// 创建时间,UTC + createdAt DateTime @default(now()) +} + +/// 短期 OAuth 授权请求及一次性授权码摘要 +model AgentAuthorization { + /// 唯一标识 + id String @id @default(uuid()) @db.Char(36) + /// 所属用户标识,用于数据隔离 + userId String? @db.Char(36) + user User? @relation(fields:[userId],references:[id],onDelete:Cascade) + /// OAuth 客户端标识;个人令牌为空 + clientId String @db.Char(36) + /// 具体操作参数;禁止保存密码及令牌 + parameters Json + /// 一次性授权码 SHA-256 摘要 + codeDigest String? @unique @db.Char(64) + /// 到期时间,UTC + expiresAt DateTime + /// 当前处理状态 + status String @default("pending") @db.VarChar(16) +} + +/// 用户授权连接、令牌摘要及资源权限 +model AgentGrant { + /// 唯一标识 + id String @id @default(uuid()) @db.Char(36) + /// 所属用户标识,用于数据隔离 + userId String @db.Char(36) + user User @relation(fields:[userId],references:[id],onDelete:Cascade) + /// OAuth 客户端标识;个人令牌为空 + clientId String? @db.Char(36) + /// 用户可见连接名称 + name String @db.VarChar(100) + /// 权限列表:read、draft、write、sensitive + scopes Json + /// 令牌适用的规范 MCP 资源地址 + resource String @db.VarChar(500) + /// 访问令牌 SHA-256 摘要;完整值仅颁发时返回 + accessDigest String @unique @db.Char(64) + /// 刷新令牌 SHA-256 摘要,使用后轮换 + refreshDigest String? @unique @db.Char(64) + /// 到期时间,UTC + expiresAt DateTime + /// 刷新授权到期时间,UTC + refreshExpiresAt DateTime? + /// 撤销时间,UTC;空表示未撤销 + revokedAt DateTime? + /// 本连接的独立业务授权会话标识,不作为 MCP 凭证 + sessionId String @db.Char(64) + /// 创建时间,UTC + createdAt DateTime @default(now()) + @@index([userId,createdAt]) +} + +/// Agent 写入草稿、幂等记录及提交结果 +model AgentOperation { + /// 唯一标识 + id String @id @default(uuid()) @db.Char(36) + /// 所属用户标识,用于数据隔离 + userId String @db.Char(36) + user User @relation(fields:[userId],references:[id],onDelete:Cascade) + /// 发起操作的授权连接标识 + grantId String @db.Char(36) + /// 同用户唯一的客户端幂等键 + key String @db.VarChar(128) + /// 工具和参数的规范摘要;拒绝同键不同参数 + hash String @db.Char(64) + /// 业务工具名称 + tool String @db.VarChar(100) + /// 具体操作参数;禁止保存密码及令牌 + parameters Json + /// 确认前的账目版本摘要,防止覆盖并发修改 + snapshot String @db.Char(64) + /// 当前处理状态 + status String @default("pending") @db.VarChar(16) + /// 到期时间,UTC + expiresAt DateTime + /// 结构化操作结果或最近提交失败原因 + result Json? + /// 创建时间,UTC + createdAt DateTime @default(now()) + /// 完成或取消时间,UTC + completedAt DateTime? + @@unique([userId,key]) + @@index([userId,createdAt]) } /// 登录会话与隐藏项目查看授权 model Session { diff --git a/apps/api/scripts/mcp-probe.ts b/apps/api/scripts/mcp-probe.ts new file mode 100644 index 0000000..20143d8 --- /dev/null +++ b/apps/api/scripts/mcp-probe.ts @@ -0,0 +1,38 @@ +// Protocol diagnostic client, not an AI agent. Token stays in memory and is never printed. +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +async function main() { + if (!process.env.MCP_ACCESS_TOKEN) + throw Error('Set MCP_ACCESS_TOKEN to a WorthPath personal access token'); + const client = new Client({ name: 'WorthPath MCP probe', version: '1.31.0' }); + try { + await client.connect( + new StreamableHTTPClientTransport( + new URL(process.env.MCP_SERVER_URL || 'http://localhost:3100/mcp'), + { requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } } }, + ), + ); + console.log( + JSON.stringify( + { + server: client.getServerVersion(), + tools: (await client.listTools()).tools.map((t) => ({ + name: t.name, + annotations: t.annotations, + })), + connection: await client.callTool({ name: 'connection_info', arguments: {} }), + }, + null, + 2, + ), + ); + } finally { + await client.close(); + } +} +void main().catch(() => { + console.error( + 'MCP probe failed: check server URL, token expiry, revocation and resource configuration.', + ); + process.exitCode = 1; +}); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 165c70a..3c2de6b 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -22,7 +22,13 @@ import { hash, compare } from 'bcryptjs'; import { Database } from './database'; import { credentials, credentialChange, defaultOverviewCards } from './validation'; import { Prisma } from '@prisma/client'; -export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean }; +export type UserRequest = Request & { + userId: string; + sessionId: string; + revealed: boolean; + agent?: boolean; + agentGrantId?: string; +}; const Public = () => SetMetadata('public', true); const digest = (s: string) => createHash('sha256').update(s).digest('hex'); export function allowedOrigin( @@ -44,7 +50,9 @@ export class AuthService { private attempts = new Map(); constructor(private db: Database) {} limit(req: Request) { - const key = req.ip || 'local', + const key = + (req.ip || 'local') + + ('userId' in req && typeof req.userId === 'string' ? ':' + req.userId : ''), now = Date.now(); let v = this.attempts.get(key); if (!v || v.until < now) { @@ -124,20 +132,17 @@ export class AuthGuard implements CanActivate { return true; } } -@Controller('api') -export class AuthController { + +@Injectable() +export class AuthBusinessService { constructor( private db: Database, private auth: AuthService, ) {} - @Public() @Get('health') health() { + health() { return { status: 'ok' }; } - @Public() @Post('auth/register') async register( - @Body() body: unknown, - @Req() req: Request, - @Res({ passthrough: true }) res: Response, - ) { + async register(body: unknown, req: Request, res: Response) { this.auth.limit(req); const v = credentials.parse(body), user = await this.db.user.create({ @@ -146,11 +151,7 @@ export class AuthController { await this.auth.issue(user.id, res); return { username: user.username, baseCurrency: user.baseCurrency }; } - @Public() @Post('auth/login') async login( - @Body() body: unknown, - @Req() req: Request, - @Res({ passthrough: true }) res: Response, - ) { + async login(body: unknown, req: Request, res: Response) { this.auth.limit(req); const v = credentials.parse(body), user = await this.db.user.findUnique({ where: { username: v.username } }); @@ -162,7 +163,7 @@ export class AuthController { await this.auth.issue(user.id, res); return { username: user.username, baseCurrency: user.baseCurrency }; } - @Get('auth/me') async me(@Req() req: UserRequest) { + async me(req: UserRequest) { const user = await this.db.user.findUniqueOrThrow({ where: { id: req.userId }, select: { @@ -191,11 +192,7 @@ export class AuthController { lastActivity: session.lastActivity, }; } - @Patch('auth/credentials') async changeCredentials( - @Req() r: UserRequest, - @Body() body: unknown, - @Res({ passthrough: true }) res: Response, - ) { + async changeCredentials(r: UserRequest, body: unknown, res: Response) { this.auth.limit(r); const v = credentialChange.parse(body); const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); @@ -221,14 +218,14 @@ export class AuthController { this.auth.cookie(token, expiresAt, res); return { ok: true }; } - @Post('auth/activity') async activity(@Req() r: UserRequest) { + async activity(r: UserRequest) { await this.db.session.update({ where: { id: r.sessionId }, data: { lastActivity: new Date() }, }); return { ok: true }; } - @Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) { + async reveal(r: UserRequest, b: unknown) { this.auth.limit(r); const revealUntil = await this.db.serial(async (tx) => { await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`); @@ -243,15 +240,59 @@ export class AuthController { }); return { revealUntil }; } - @Post('auth/lock') async lock(@Req() r: UserRequest) { + async lock(r: UserRequest) { await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } }); return { ok: true }; } + async logout(req: Request, res: Response) { + await this.auth.logout(req, res); + return { ok: true }; + } +} + +@Controller('api') +export class AuthController { + constructor(private service: AuthBusinessService) {} + @Public() @Get('health') health() { + return this.service.health(); + } + @Public() @Post('auth/register') async register( + @Body() body: unknown, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + return this.service.register(body, req, res); + } + @Public() @Post('auth/login') async login( + @Body() body: unknown, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + return this.service.login(body, req, res); + } + @Get('auth/me') async me(@Req() req: UserRequest) { + return this.service.me(req); + } + @Patch('auth/credentials') async changeCredentials( + @Req() r: UserRequest, + @Body() body: unknown, + @Res({ passthrough: true }) res: Response, + ) { + return this.service.changeCredentials(r, body, res); + } + @Post('auth/activity') async activity(@Req() r: UserRequest) { + return this.service.activity(r); + } + @Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) { + return this.service.reveal(r, b); + } + @Post('auth/lock') async lock(@Req() r: UserRequest) { + return this.service.lock(r); + } @Post('auth/logout') async logout( @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { - await this.auth.logout(req, res); - return { ok: true }; + return this.service.logout(req, res); } } diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 2bd26bf..bf7659b 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { metalConfig, metalPriceInput } from './metals'; import { scheduleInput } from './schedules'; import { movementDeltas } from './movement'; @@ -298,8 +299,9 @@ export function validateBackup(raw: unknown) { if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整'); return b; } -@Controller('api/backup') -export class BackupController implements OnModuleDestroy, OnModuleInit { + +@Injectable() +export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { private uploads = new Map< string, { sessionId: string; userId: string; path: string; expires: number } @@ -336,6 +338,20 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { : JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')); } constructor(private db: Database) {} + async snapshot(userId: string) { + return this.fingerprint(await this.data(userId)); + } + async inspectUpload(r: UserRequest, token: string) { + const v = this.uploads.get(token); + if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now()) + throw new BadRequestException('导入预览已失效,请重新上传备份'); + const data = await this.uploadedData(v.path); + return { data, preview: await this.preview(r, data) }; + } + async restoreUpload(r: UserRequest, token: string) { + const prepared = await this.inspectUpload(r, token); + return this.restore(r, { confirmed: true, backup: prepared.data }); + } private async data( userId: string, client: Database | Prisma.TransactionClient = this.db, @@ -472,10 +488,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { })), }); } - @Get() async download(@Req() r: UserRequest, @Res() res: Response) { + async download(r: UserRequest, res: Response, expectedFingerprint?: string) { const b = await this.db.$transaction( async (tx) => { const b = await this.data(r.userId, tx); + if (expectedFingerprint && this.fingerprint(b) !== expectedFingerprint) + throw new ConflictException('账目已变化,请重新确认备份导出'); await tx.session.update({ where: { id: r.sessionId }, data: { @@ -498,17 +516,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { archive.pipe(res); await archive.finalize().catch(() => res.destroy()); } - @Post('upload') - @UseInterceptors( - FileInterceptor('file', { - storage: diskStorage({ - destination: tmpdir(), - filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'), - }), - limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 }, - }), - ) - async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) { + + async upload(r: UserRequest, file?: Express.Multer.File) { if (!file) throw new BadRequestException('请选择 ZIP 备份文件'); try { const b = validateBackup(await this.uploadedData(file.path)); @@ -534,7 +543,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { async onModuleInit() { await this.prune(); } - @Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) { + async importFile(r: UserRequest, raw: unknown) { const { token } = z .object({ confirmed: z.literal(true), token: z.string().uuid() }) .strict() @@ -566,11 +575,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { data.icons?.sort((a, b) => a.id.localeCompare(b.id)); return createHash('sha256').update(JSON.stringify(data)).digest('hex'); } - @Get('clear-status') async clearStatus(@Req() r: UserRequest) { + async clearStatus(r: UserRequest) { const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }); return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() }; } - @Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) { + async clear(r: UserRequest, raw: unknown) { z.object({ confirmation: z.literal('确定清空') }) .strict() .parse(raw); @@ -596,7 +605,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 }, ); } - @Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) { + async preview(r: UserRequest, raw: unknown) { const b = validateBackup(raw), existing = await this.data(r.userId); for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash); @@ -631,7 +640,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { throw new ConflictException('已有同日汇率与备份冲突,未修改数据'); } } - @Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) { + async restore(r: UserRequest, raw: unknown) { const { backup } = z .object({ confirmed: z.literal(true), backup: backupSchema }) .strict() @@ -819,3 +828,39 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { ); } } + +@Controller('api/backup') +export class BackupController { + constructor(private service: BackupBusinessService) {} + @Get() async download(@Req() r: UserRequest, @Res() res: Response) { + return this.service.download(r, res); + } + @Post('upload') + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: tmpdir(), + filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'), + }), + limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 }, + }), + ) + async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) { + return this.service.upload(r, file); + } + @Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) { + return this.service.importFile(r, raw); + } + @Get('clear-status') async clearStatus(@Req() r: UserRequest) { + return this.service.clearStatus(r); + } + @Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) { + return this.service.clear(r, raw); + } + @Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) { + return this.service.preview(r, raw); + } + @Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) { + return this.service.restore(r, raw); + } +} diff --git a/apps/api/src/calendar.ts b/apps/api/src/calendar.ts index b93011f..2138044 100644 --- a/apps/api/src/calendar.ts +++ b/apps/api/src/calendar.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Controller, Get, Query, Req } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import Decimal from 'decimal.js'; @@ -24,8 +25,9 @@ export function calendarMonth(value: unknown) { const last = new Date(+next - 86400000).toISOString().slice(0, 10); return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) }; } -@Controller('api/calendar') -export class CalendarController { + +@Injectable() +export class CalendarBusinessService { constructor(private db: Database) {} private async replay( tx: Prisma.TransactionClient, @@ -113,7 +115,7 @@ export class CalendarController { }), }; } - @Get() async month(@Req() r: UserRequest, @Query('month') input?: string) { + async month(r: UserRequest, input?: string) { const q = calendarMonth(input); return this.db.$transaction( async (tx) => { @@ -144,7 +146,7 @@ export class CalendarController { { timeout: 30000 }, ); } - @Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) { + async day(r: UserRequest, input?: string) { const day = date.parse(input || today()); return this.db.$transaction( async (tx) => { @@ -160,3 +162,14 @@ export class CalendarController { ); } } + +@Controller('api/calendar') +export class CalendarController { + constructor(private service: CalendarBusinessService) {} + @Get() async month(@Req() r: UserRequest, @Query('month') input?: string) { + return this.service.month(r, input); + } + @Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) { + return this.service.day(r, input); + } +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 26ac234..ce3fc38 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -1,13 +1,35 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient, Prisma } from '@prisma/client'; +import { AsyncLocalStorage } from 'node:async_hooks'; +const transactions = new AsyncLocalStorage(); @Injectable() export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy { - async serial(work: (tx: Prisma.TransactionClient) => Promise): Promise { + constructor() { + super(); + return new Proxy(this, { + get(target, key, receiver) { + const tx = transactions.getStore(); + if (tx && key === '$transaction') + return (work: any) => (typeof work === 'function' ? work(tx) : Promise.all(work)); + if (tx && key in tx && key !== '$disconnect' && key !== '$connect') { + const value = (tx as any)[key]; + return typeof value === 'function' ? value.bind(tx) : value; + } + return Reflect.get(target, key, receiver); + }, + }); + } + async atomic(work: () => Promise, timeout = 30000): Promise { + return this.serial((tx) => transactions.run(tx, work), timeout); + } + async serial(work: (tx: Prisma.TransactionClient) => Promise, timeout = 30000): Promise { + const current = transactions.getStore(); + if (current) return work(current); for (let attempt = 0; ; attempt++) { try { return await this.$transaction(work, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - timeout: 30000, + timeout, }); } catch (error) { // Serializable deadlock/write conflict; retry the entire atomic operation. diff --git a/apps/api/src/icons.ts b/apps/api/src/icons.ts index be416dc..17ea9a5 100644 --- a/apps/api/src/icons.ts +++ b/apps/api/src/icons.ts @@ -76,10 +76,11 @@ export class IconsService { throw new BadRequestException('图标不存在或无权使用'); } } -@Controller('api/icons') -export class IconsController { + +@Injectable() +export class IconsBusinessService { constructor(private db: Database) {} - @Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') { + async list(r: UserRequest, q = '', page = '1') { const query = z.string().trim().max(100).parse(q); const index = z.coerce.number().int().min(1).max(100000).parse(page); const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } }; @@ -95,11 +96,7 @@ export class IconsController { ]); return { items, total, page: index }; } - @Get(':id/image') async image( - @Req() r: UserRequest, - @Param('id') id: string, - @Res() res: Response, - ) { + async image(r: UserRequest, id: string, res: Response) { const icon = await this.db.icon.findFirst({ where: { id, OR: [{ shared: true }, { ownerId: r.userId }] }, }); @@ -110,18 +107,8 @@ export class IconsController { // Preserve essential white artwork rather than applying the cutout twice. res.send(Buffer.from(icon.data)); } - @Post('upload') - @UseInterceptors( - FileInterceptor('file', { - storage: memoryStorage(), - limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 }, - }), - ) - async upload( - @Req() r: UserRequest, - @Body() raw: unknown, - @UploadedFile() file?: Express.Multer.File, - ) { + + async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) { const v = z .object({ name: iconName, @@ -145,3 +132,32 @@ export class IconsController { return icon; } } + +@Controller('api/icons') +export class IconsController { + constructor(private service: IconsBusinessService) {} + @Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') { + return this.service.list(r, q, page); + } + @Get(':id/image') async image( + @Req() r: UserRequest, + @Param('id') id: string, + @Res() res: Response, + ) { + return this.service.image(r, id, res); + } + @Post('upload') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 }, + }), + ) + async upload( + @Req() r: UserRequest, + @Body() raw: unknown, + @UploadedFile() file?: Express.Multer.File, + ) { + return this.service.upload(r, raw, file); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 25b6750..8f65eb4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,18 +6,24 @@ import { NestFactory, APP_GUARD } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { json } from 'express'; -import { AuthController, AuthGuard, AuthService } from './auth'; -import { CalendarController } from './calendar'; -import { SchedulesController } from './schedules'; -import { TransfersController } from './transfers'; -import { PortfolioController } from './portfolio'; -import { BackupController } from './backup'; -import { IconsController, IconsService } from './icons'; -import { MetalsService, MetalsController } from './metals'; +import { AuthController, AuthBusinessService, AuthGuard, AuthService } from './auth'; +import { CalendarController, CalendarBusinessService } from './calendar'; +import { SchedulesController, SchedulesBusinessService } from './schedules'; +import { TransfersController, TransfersBusinessService } from './transfers'; +import { PortfolioController, PortfolioBusinessService } from './portfolio'; +import { BackupController, BackupBusinessService } from './backup'; +import { IconsController, IconsBusinessService, IconsService } from './icons'; +import { MetalsService, MetalsController, MetalsBusinessService } from './metals'; import { Database } from './database'; -import { RatesService, SettingsController } from './rates'; +import { RatesService, SettingsController, SettingsBusinessService } from './rates'; import { ZodError } from 'zod'; import { Prisma } from '@prisma/client'; +import { AgentOAuth } from './mcp/oauth'; +import { AgentCatalogue } from './mcp/catalogue'; +import { AgentOperations } from './mcp/operations'; +import { AgentFiles } from './mcp/files'; +import { AgentTransport } from './mcp/transport'; +import { AgentManagementController } from './mcp/management'; @Catch() class SafeErrors implements ExceptionFilter { catch(error: unknown, host: ArgumentsHost) { @@ -53,6 +59,20 @@ class SafeErrors implements ExceptionFilter { RatesService, MetalsService, IconsService, + AuthBusinessService, + CalendarBusinessService, + SchedulesBusinessService, + TransfersBusinessService, + PortfolioBusinessService, + BackupBusinessService, + IconsBusinessService, + MetalsBusinessService, + SettingsBusinessService, + AgentOAuth, + AgentCatalogue, + AgentOperations, + AgentFiles, + AgentTransport, { provide: APP_GUARD, useClass: AuthGuard }, ], controllers: [ @@ -65,6 +85,7 @@ class SafeErrors implements ExceptionFilter { MetalsController, SettingsController, BackupController, + AgentManagementController, ], }) class AppModule {} @@ -83,6 +104,7 @@ async function bootstrap() { res.setHeader('Cache-Control', 'no-store'); next(); }); + app.get(AgentTransport).install(app.getHttpAdapter().getInstance()); app.useGlobalFilters(new SafeErrors()); setupOpenApi(app); app.enableShutdownHooks(); diff --git a/apps/api/src/mcp/catalogue.ts b/apps/api/src/mcp/catalogue.ts new file mode 100644 index 0000000..a656239 --- /dev/null +++ b/apps/api/src/mcp/catalogue.ts @@ -0,0 +1,370 @@ +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; +import { UserRequest } from '../auth'; +import { PortfolioBusinessService } from '../portfolio'; +import { TransfersBusinessService } from '../transfers'; +import { SchedulesBusinessService, scheduleInput } from '../schedules'; +import { CalendarBusinessService } from '../calendar'; +import { SettingsBusinessService } from '../rates'; +import { MetalsBusinessService, metalConfig, metalPriceInput } from '../metals'; +import { IconsBusinessService } from '../icons'; +import { BackupBusinessService } from '../backup'; +import { + positionInput, + positionMeta, + revisionInput, + transferInput, + settingsInput, + date, + currencies, +} from '../validation'; + +const id = z.string().uuid().describe('稳定对象 UUID;名称重名时先搜索,再由用户选择 ID'); +export const empty = z.object({}).strict(); +const pageFields = { + limit: z.number().int().min(1).max(100).default(50).describe('单页上限 100'), + offset: z + .number() + .int() + .min(0) + .max(1000000) + .default(0) + .describe('列表偏移;数据变化后从第一页重读'), +}; +const historyFields = { + limit: pageFields.limit, + cursor: z.string().max(512).optional().describe('上一页 nextCursor'), + from: date.optional(), + to: date.optional(), +}; +const json = (value: unknown) => JSON.parse(JSON.stringify(value)); +export function page(items: T[], p: { limit: number; offset: number }) { + return { + items: items.slice(p.offset, p.offset + p.limit), + total: items.length, + nextOffset: p.offset + p.limit < items.length ? p.offset + p.limit : null, + }; +} +export type ToolDefinition = { + name: string; + description: string; + schema: z.ZodObject; + scope: 'read' | 'write' | 'sensitive'; + destructive?: boolean; + web?: 'credentials' | 'reveal' | 'clear'; + run?: (r: UserRequest, p: any) => Promise; +}; +@Injectable() +export class AgentCatalogue { + readonly tools: ToolDefinition[]; + constructor( + portfolio: PortfolioBusinessService, + transfers: TransfersBusinessService, + schedules: SchedulesBusinessService, + calendar: CalendarBusinessService, + settings: SettingsBusinessService, + metals: MetalsBusinessService, + icons: IconsBusinessService, + backup: BackupBusinessService, + ) { + const read = ( + name: string, + description: string, + schema: z.ZodObject, + run: ToolDefinition['run'], + ): ToolDefinition => ({ name, description, schema, scope: 'read', run }); + const write = ( + name: string, + description: string, + schema: z.ZodObject, + run: ToolDefinition['run'], + destructive = false, + ): ToolDefinition => ({ name, description, schema, scope: 'write', run, destructive }); + this.tools = [ + read( + 'positions_list', + '查询账户、独立资产、借入借出债务。原币余额字符串;负债账户正数为欠款、负数为溢缴。支持名称搜索,重名不自动选择。', + z + .object({ + ...pageFields, + kind: z.enum(['account', 'asset', 'debt']).optional(), + q: z.string().max(100).optional(), + archived: z.boolean().optional(), + side: z.enum(['asset', 'liability']).optional(), + currency: z.enum(currencies).optional(), + groupName: z.string().max(60).optional(), + order: z.enum(['name', 'id']).default('id'), + }) + .strict(), + async (r, p) => { + const rows = (await portfolio.list(r, p.kind)).filter( + (v) => + (!p.q || v.name.includes(p.q)) && + (p.archived === undefined || p.archived === v.archived) && + (!p.side || p.side === v.side) && + (!p.currency || p.currency === v.currency) && + (p.groupName === undefined || p.groupName === v.groupName), + ); + rows.sort( + (a, b) => + (p.order === 'name' ? a.name.localeCompare(b.name) : 0) || a.id.localeCompare(b.id), + ); + return page(rows, p); + }, + ), + read( + 'position_get', + '查询项目详情、配置和当前原币余额。隐藏项目需网页解锁。', + z.object({ id }).strict(), + (r, p) => portfolio.detail(r, p.id), + ), + write( + 'position_create', + '新增账户、独立资产或债务及初始绝对余额。金额为十进制字符串,业务 date 为 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm(UTC+8)。不新增日常消费功能。', + positionInput, + (r, p) => portfolio.create(r, p), + ), + write( + 'position_update', + '修改名称、分类、分组、图标、备注、归档、隐藏、统计开关;币种和资产负债属性固定。须提供完整元数据。', + z.object({ id, data: positionMeta }).strict(), + (r, p) => portfolio.edit(r, p.id, p.data), + ), + write( + 'balance_record', + '新增余额/估值/负债调整/单边还款历史:amount 是变更后的绝对余额,不是增量。资产估值使用 valuation;配对转账和还款使用 movement_create。', + z.object({ id, data: revisionInput }).strict(), + (r, p) => portfolio.revise(r, p.id, p.data), + ), + read( + 'history_list', + '分页查询全局或单项目历史,业务时间降序;返回真实前序余额。', + z.object({ ...historyFields, positionId: id.optional() }).strict(), + (r, p) => portfolio.history(r, p), + ), + write( + 'history_update', + '更正单条非配对历史,自动重放后续金额;配对记录使用 movement_update。', + z.object({ id, revisionId: id, data: revisionInput }).strict(), + (r, p) => portfolio.correct(r, p.id, p.revisionId, p.data), + ), + write( + 'history_delete', + '删除历史并重算;配对历史会撤销完整双边操作。', + z.object({ id, revisionId: id }).strict(), + (r, p) => portfolio.deleteRevision(r, p.id, p.revisionId), + true, + ), + write( + 'debt_links_set', + '替换债务与自己账户/资产的关联,不参与金额求和。', + z.object({ id, targetIds: z.array(id).max(20) }).strict(), + (r, p) => portfolio.link(r, p.id, { targetIds: p.targetIds }), + ), + read( + 'overview_get', + '净资产、资产负债总额、结构及变化归因;遵循逐项目和独立资产纳入设置。明细分页。', + z.object(pageFields).strict(), + async (r, p) => { + const v = await portfolio.overview(r); + return { ...v, ...page(v.items, p) }; + }, + ), + read( + 'trend_get', + '净资产趋势,缺失汇率不绘制完整总额。业务日 YYYY-MM-DD;支持日/周/月粒度。', + z + .object({ + from: date.optional(), + to: date.optional(), + grain: z.enum(['day', 'week', 'month']).optional(), + }) + .strict(), + (r, p) => portfolio.trend(r, p), + ), + read( + 'movements_list', + '查询转账、借入、借出、收款、还款配对记录,分页和业务日期筛选。', + z.object(historyFields).strict(), + (r, p) => transfers.list(r, p), + ), + read( + 'movement_by_revision', + '根据历史 UUID 找到对应双边记录。', + z.object({ revisionId: id }).strict(), + (r, p) => transfers.byRevision(r, p.revisionId), + ), + write( + 'movement_create', + '执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。', + transferInput.safeExtend({ requestId: z.never().optional() }), + (r, p) => transfers.create(r, p), + ), + write( + 'movement_update', + '修改完整双边资金往来并重放后续历史,保持余额一致。', + z + .object({ id, data: transferInput.safeExtend({ requestId: z.never().optional() }) }) + .strict(), + (r, p) => transfers.edit(r, p.id, p.data), + ), + write( + 'movement_delete', + '撤销完整双边转账/借贷/还款并重算后续余额。', + z.object({ id }).strict(), + (r, p) => transfers.remove(r, p.id), + true, + ), + read( + 'calendar_month', + '月日历现金流统计;YYYY-MM,时区 UTC+8。', + z + .object({ + month: z + .string() + .regex(/^\d{4}-\d{2}$/) + .optional(), + }) + .strict(), + (r, p) => calendar.month(r, p.month), + ), + read( + 'calendar_day', + '查询某业务日的收入支出变化明细,分页。', + z.object({ ...pageFields, date: date.optional() }).strict(), + async (r, p) => { + const v = await calendar.day(r, p.date); + return { ...v, ...page(v.items, p) }; + }, + ), + read( + 'schedules_list', + '计划列表及启用、完成状态,按到期时间排序。执行记录也可在 history_list 查询。', + z.object(pageFields).strict(), + async (r, p) => page(await schedules.list(r), p), + ), + write( + 'schedule_create', + '新增现有支出或转账计划。nextAt 为 YYYY-MM-DDTHH:mm(UTC+8),intervalDays=0 表示一次。', + scheduleInput, + (r, p) => schedules.create(r, p), + ), + write( + 'schedule_update', + '修改未完成计划的完整配置。', + z.object({ id, data: scheduleInput }).strict(), + (r, p) => schedules.edit(r, p.id, p.data), + ), + write( + 'schedule_toggle', + '启用或停用计划;已完成一次性计划不可重启。', + z.object({ id, enabled: z.boolean() }).strict(), + (r, p) => schedules.toggle(r, p.id, { enabled: p.enabled }), + ), + write( + 'schedule_delete', + '删除计划;保留已完成的金额历史。', + z.object({ id }).strict(), + (r, p) => schedules.remove(r, p.id), + true, + ), + write( + 'schedules_run', + '以事务执行最多 20 个已到期计划,任一失败回滚本批;可按 hasMore 分批继续。', + empty, + (r) => schedules.run(r, true), + ), + read( + 'settings_get', + '个人资料、本位币、显示菜单、备注、隐私、总览卡片、账户分组顺序及汇率状态;不返回密码或令牌。', + empty, + (r) => settings.settings(r, 'true'), + ), + write( + 'settings_update', + '修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。', + settingsInput, + (r, p) => settings.update(r, p, undefined as any), + ), + write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) => + settings.refresh(r), + ), + read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)), + write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) => + metals.refresh(r), + ), + write( + 'metal_price_set', + '设置指定日期、币种、品种每克价格;price 十进制字符串,可能触发自动估值历史。', + metalPriceInput, + (r, p) => metals.manual(r, p), + ), + write( + 'metal_configure', + '设置金银重量、纯度和自动估值,复用现有估值规则。', + z.object({ id, data: metalConfig }).strict(), + (r, p) => metals.configure(r, p.id, p.data), + ), + write( + 'metal_value', + '按已有报价为指定贵金属资产生成估值历史。', + z.object({ id }).strict(), + (r, p) => metals.value(r, p.id), + ), + read( + 'icons_list', + '搜索私有或共享图标,固定单页 60 个;使用返回 ID 配置项目图标。', + z + .object({ + q: z.string().max(100).default(''), + page: z.number().int().min(1).max(100000).default(1), + }) + .strict(), + (r, p) => icons.list(r, p.q, String(p.page)), + ), + { + name: 'backup_import', + description: + '提交已上传备份的追加恢复。先 file_upload_request → 上传 → import_preview;强制网页展示影响并确认,事务失败不保留部分账目。', + schema: z.object({ token: id }).strict(), + scope: 'sensitive', + destructive: true, + run: (r, p) => backup.restoreUpload(r, p.token), + }, + read( + 'import_preview', + '预检已上传备份并显示追加影响、冲突和条数。', + z.object({ token: id }).strict(), + async (r, p) => (await backup.inspectUpload(r, p.token)).preview, + ), + { + name: 'credentials_change_request', + description: + '发起账号或密码修改,返回网页入口。当前密码及新密码仅在网页输入,不传给 Agent。完成后 operation_get 查询结果。', + schema: empty, + scope: 'sensitive', + web: 'credentials', + }, + { + name: 'hidden_unlock_request', + description: + '发起隐藏项目解锁。网页用户验证密码后本连接解锁 5 分钟;operation_get 查询结果。', + schema: empty, + scope: 'sensitive', + web: 'reveal', + }, + { + name: 'data_clear_request', + description: + '发起清空本账号财务数据。网页须先下载当前备份、验证密码并输入“确定清空”,展示数量;账号保留。', + schema: empty, + scope: 'sensitive', + web: 'clear', + destructive: true, + }, + ]; + } + get(name: string) { + return this.tools.find((t) => t.name === name); + } +} diff --git a/apps/api/src/mcp/files.ts b/apps/api/src/mcp/files.ts new file mode 100644 index 0000000..fc15a1d --- /dev/null +++ b/apps/api/src/mcp/files.ts @@ -0,0 +1,185 @@ +import { + Injectable, + OnModuleDestroy, + BadRequestException, + ForbiddenException, + UnauthorizedException, + HttpException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { Request, Response, Express } from 'express'; +import multer, { diskStorage, memoryStorage } from 'multer'; +import { tmpdir } from 'node:os'; +import { unlink } from 'node:fs/promises'; +import { AgentOAuth, urls } from './oauth'; +import { UserRequest } from '../auth'; +import { BackupBusinessService } from '../backup'; +import { IconsBusinessService } from '../icons'; +import { MAX_UPLOAD_BYTES } from '../zip'; +import { Database } from '../database'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; + +type Ticket = { + userId: string; + grantId: string; + expires: number; + kind: 'backup' | 'icon' | 'download' | 'image'; + snapshot?: string; + iconId?: string; + buffer?: Buffer; + name?: string; + preview?: unknown; + token?: string; +}; +@Injectable() +export class AgentFiles implements OnModuleDestroy { + private tickets = new Map(); + private timer = setInterval(() => { + for (const [id, t] of this.tickets) if (t.expires < Date.now()) this.tickets.delete(id); + }, 60000).unref(); + constructor( + private oauth: AgentOAuth, + private backup: BackupBusinessService, + private icons: IconsBusinessService, + private db: Database, + ) {} + onModuleDestroy() { + clearInterval(this.timer); + this.tickets.clear(); + } + async issue(r: UserRequest, grantId: string, kind: Ticket['kind'], iconId?: string) { + if ( + this.tickets.size >= 1000 || + [...this.tickets.values()].filter((t) => t.userId === r.userId).length >= 20 + ) + throw new BadRequestException('文件请求过多,请等待过期'); + const id = randomUUID(); + this.tickets.set(id, { + userId: r.userId, + grantId, + kind, + iconId, + expires: Date.now() + 600000, + ...(kind === 'download' ? { snapshot: await this.backup.snapshot(r.userId) } : {}), + }); + return { + fileId: id, + url: new URL('/api/agent/files/' + id, urls().issuer).toString(), + method: kind === 'download' || kind === 'image' ? 'GET' : 'POST', + headers: { Authorization: 'Bearer ' }, + expiresAt: new Date(Date.now() + 600000).toISOString(), + maxBytes: kind === 'backup' ? MAX_UPLOAD_BYTES : 2 * 1024 * 1024, + format: + kind === 'backup' + ? 'multipart/form-data; field file; WorthPath ZIP/JSON' + : 'multipart/form-data; field file; image', + }; + } + private ticket(r: UserRequest, grantId: string, id: string) { + const t = this.tickets.get(id); + if (!t || t.userId !== r.userId || t.grantId !== grantId || t.expires < Date.now()) + throw new ForbiddenException('文件入口已失效或不属于此连接'); + return t; + } + async publishIcon(r: UserRequest, grantId: string, id: string, name: string, shared: boolean) { + const t = this.ticket(r, grantId, id); + if (t.kind !== 'icon' || !t.buffer) throw new BadRequestException('请先上传图标'); + return this.icons.upload( + r, + { name, shared: String(shared), ...(shared ? { confirmed: 'true' } : {}) }, + { buffer: t.buffer } as Express.Multer.File, + ); + } + async inspect(r: UserRequest, grantId: string, id: string) { + const t = this.ticket(r, grantId, id); + return { + fileId: id, + uploaded: !!t.buffer || !!t.token, + token: t.token, + preview: t.preview, + expiresAt: new Date(t.expires).toISOString(), + }; + } + async context(req: Request) { + const match = /^Bearer ([A-Za-z0-9_-]+)$/.exec(req.headers.authorization || ''); + if (!match) throw new UnauthorizedException('Bearer token required'); + const auth = await this.oauth.verifyAccessToken(match[1]), + grant = await this.oauth.grant(String(auth.extra.grantId)); + return { + grant, + r: { + ...req, + userId: grant.userId, + sessionId: grant.sessionId, + revealed: false, + agent: true, + } as UserRequest, + }; + } + install(app: Express) { + const disk = multer({ + storage: diskStorage({ + destination: tmpdir(), + filename: (_r, _f, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'), + }), + limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, fields: 0 }, + }).single('file'); + const memory = multer({ + storage: memoryStorage(), + limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 }, + }).single('file'); + app.all('/api/agent/files/:id', async (req, res) => { + try { + const { grant, r } = await this.context(req); + const t = this.ticket(r, grant.id, String(req.params.id)); + if (!['GET', 'POST'].includes(req.method)) { + res.status(405).end(); + return; + } + if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) { + res.status(405).end(); + return; + } + if (req.method === 'GET') { + if (t.kind === 'download') await this.backup.download(r, res, t.snapshot); + else await this.icons.image(r, t.iconId!, res); + return; + } + const selected = grant.scopes as string[]; + if (!selected.includes('draft') && !selected.includes('write')) + throw new ForbiddenException('上传需要 draft 或 write 权限'); + if ( + (await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode === + 'readonly' + ) + throw new ForbiddenException('当前策略为只读'); + await new Promise((resolve, reject) => + (t.kind === 'backup' ? disk : memory)(req, res, (e) => (e ? reject(e) : resolve())), + ); + if (!req.file) throw new BadRequestException('请选择文件'); + try { + if (t.kind === 'backup') { + const v = await this.backup.upload(r, req.file); + t.token = v.token; + t.preview = v; + } else t.buffer = req.file.buffer; + res.json(await this.inspect(r, grant.id, String(req.params.id))); + } catch (e) { + if (req.file.path) await unlink(req.file.path).catch(() => {}); + throw e; + } + } catch (e) { + if (!res.headersSent) + res + .status( + e instanceof HttpException + ? e.getStatus() + : e instanceof InvalidTokenError + ? 401 + : 400, + ) + .json({ message: e instanceof HttpException ? e.message : '文件操作失败或认证已失效' }); + } + }); + } +} diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts new file mode 100644 index 0000000..38b4552 --- /dev/null +++ b/apps/api/src/mcp/management.ts @@ -0,0 +1,159 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Req, + Param, + Body, + Res, + ForbiddenException, + HttpException, +} from '@nestjs/common'; +import { compare } from 'bcryptjs'; +import { Response } from 'express'; +import { z } from 'zod'; +import { Database } from '../database'; +import { AuthService, UserRequest } from '../auth'; +import { AgentOAuth, urls, scopeInput } from './oauth'; +import { AgentOperations } from './operations'; +@Controller('api/agent') +export class AgentManagementController { + constructor( + private db: Database, + private oauth: AgentOAuth, + private operations: AgentOperations, + private auth: AuthService, + ) {} + @Get() async list(@Req() r: UserRequest) { + const grants = await this.db.agentGrant.findMany({ + where: { userId: r.userId }, + select: { + id: true, + name: true, + scopes: true, + expiresAt: true, + createdAt: true, + revokedAt: true, + clientId: true, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + const operations = await this.db.agentOperation.findMany({ + where: { userId: r.userId }, + select: { + id: true, + tool: true, + status: true, + expiresAt: true, + createdAt: true, + completedAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + const calls = await this.db.agentCall.findMany({ + where: { userId: r.userId }, + select: { id: true, tool: true, status: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + return { + mcpUrl: urls().resource.toString(), + mode: + (await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft', + grants, + operations, + calls, + }; + } + @Put('policy') async policy(@Req() r: UserRequest, @Body() raw: unknown) { + const { mode, password } = z + .object({ mode: z.enum(['readonly', 'draft', 'direct']), password: z.string().max(72) }) + .strict() + .parse(raw); + this.auth.limit(r); + const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误'); + await this.db.agentPolicy.upsert({ + where: { userId: r.userId }, + create: { userId: r.userId, mode }, + update: { mode }, + }); + return { mode }; + } + @Post('tokens') async token(@Req() r: UserRequest, @Body() raw: unknown) { + const p = z + .object({ + name: z.string().trim().min(1).max(100), + scopes: scopeInput, + days: z.number().int().min(1).max(90), + password: z.string().max(72), + }) + .strict() + .parse(raw); + this.auth.limit(r); + const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (!(await compare(p.password, u.passwordHash))) throw new ForbiddenException('密码错误'); + return this.db.atomic(async () => { + const v = await this.oauth.issue(r.userId, p.name, p.scopes, p.days); + return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt }; + }); + } + @Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) { + z.string().uuid().parse(id); + await this.db.agentGrant.updateMany({ + where: { id, userId: r.userId }, + data: { revokedAt: new Date() }, + }); + return { ok: true }; + } + @Get('authorizations/:id') pending(@Param('id') id: string) { + return this.oauth.pending(z.string().uuid().parse(id)); + } + @Post('authorizations/:id') async consent( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() raw: unknown, + ) { + const { approve } = z.object({ approve: z.boolean() }).strict().parse(raw); + return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve); + } + @Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) { + return this.operations.preview(r.userId, z.string().uuid().parse(id)); + } + @Post('operations/:id') async confirm( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() raw: unknown, + @Res({ passthrough: true }) res: Response, + ) { + this.auth.limit(r); + z.string().uuid().parse(id); + try { + return await this.operations.confirm(r, id, raw, res); + } catch (e) { + const row = await this.db.agentOperation.findFirst({ + where: { id, userId: r.userId, status: 'pending' }, + }); + if (row) { + await this.db.agentOperation.updateMany({ + where: { id, userId: r.userId, status: 'pending' }, + data: { + result: { + status: 'submission_failed', + message: + e instanceof HttpException ? e.message : '提交失败,账目已回滚;可重试或取消', + }, + }, + }); + await this.db.agentCall.create({ + data: { userId: r.userId, grantId: row.grantId, tool: row.tool, status: 'error' }, + }); + } + throw e; + } + } +} diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts new file mode 100644 index 0000000..5810953 --- /dev/null +++ b/apps/api/src/mcp/oauth.ts @@ -0,0 +1,328 @@ +import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { randomBytes, randomUUID, createHash } from 'node:crypto'; +import { Response } from 'express'; +import { z } from 'zod'; +import { Database } from '../database'; +import { + OAuthServerProvider, + AuthorizationParams, +} from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import { + OAuthClientInformationFull, + OAuthTokens, + OAuthTokenRevocationRequest, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { + InvalidClientMetadataError, + InvalidGrantError, + InvalidScopeError, + InvalidTokenError, + InvalidTargetError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; + +export const scopes = ['read', 'draft', 'write', 'sensitive'] as const; +export const scopeInput = z + .array(z.enum(scopes)) + .min(1) + .max(4) + .refine((v) => v.includes('read') && new Set(v).size === v.length); +export const digest = (s: string) => createHash('sha256').update(s).digest('hex'); +const secret = () => randomBytes(32).toString('base64url'); +export function urls() { + const resource = new URL(process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp'); + if ( + resource.pathname !== '/mcp' || + resource.search || + resource.hash || + resource.username || + resource.password + ) + throw Error('MCP_PUBLIC_URL must be the canonical /mcp URL'); + if ( + resource.protocol !== 'https:' && + !( + process.env.NODE_ENV !== 'production' && + ['localhost', '127.0.0.1', '[::1]'].includes(resource.hostname) + ) + ) + throw Error('MCP requires HTTPS except local development'); + const web = new URL(process.env.MCP_WEB_URL || 'http://localhost:5173'); + if ( + web.protocol !== 'https:' && + !(process.env.NODE_ENV !== 'production' && ['localhost', '127.0.0.1'].includes(web.hostname)) + ) + throw Error('MCP web confirmation requires HTTPS'); + return { resource, issuer: new URL(resource.origin), web }; +} +export function webLink(key: string, id: string) { + const u = new URL(urls().web); + u.searchParams.set(key, id); + return u.toString(); +} + +@Injectable() +export class AgentOAuth implements OAuthServerProvider { + constructor(private db: Database) {} + get clientsStore() { + return { + getClient: async (id: string) => { + const row = await this.db.agentClient.findUnique({ where: { id } }); + return row?.metadata as OAuthClientInformationFull | undefined; + }, + registerClient: async ( + input: Omit, + ) => { + if (input.token_endpoint_auth_method !== 'none') + throw new InvalidClientMetadataError('Only public PKCE clients are supported'); + if (!input.redirect_uris.length || input.redirect_uris.length > 10) + throw new InvalidClientMetadataError('Invalid redirect URIs'); + for (const value of input.redirect_uris) { + const u = new URL(value); + if ( + u.hash || + u.username || + u.password || + !( + u.protocol === 'https:' || + (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)) + ) + ) + throw new InvalidClientMetadataError('HTTPS or loopback redirect required'); + } + if ((await this.db.agentClient.count()) >= 10000) + throw new InvalidClientMetadataError('Client registration limit reached'); + const client = { + ...input, + client_name: z + .string() + .min(1) + .max(100) + .parse(input.client_name || 'MCP client'), + client_id: randomUUID(), + client_id_issued_at: Math.floor(Date.now() / 1000), + }; + await this.db.agentClient.create({ + data: { id: client.client_id, metadata: JSON.parse(JSON.stringify(client)) }, + }); + return client; + }, + }; + } + private resource(resource?: URL) { + if (resource?.toString() !== urls().resource.toString()) + throw new InvalidTargetError('WorthPath resource is required'); + } + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) { + this.resource(params.resource); + const selected = params.scopes?.length ? params.scopes : ['read']; + if (!scopeInput.safeParse(selected).success) throw new InvalidScopeError('Unsupported scope'); + const row = await this.db.agentAuthorization.create({ + data: { + clientId: client.client_id, + parameters: JSON.parse( + JSON.stringify({ ...params, resource: params.resource!.toString(), scopes: selected }), + ), + expiresAt: new Date(Date.now() + 600000), + }, + }); + res.redirect(webLink('agent_authorization', row.id)); + } + async pending(id: string) { + const row = await this.db.agentAuthorization.findUnique({ where: { id } }); + if (!row || row.status !== 'pending' || row.expiresAt <= new Date()) + throw new BadRequestException('授权请求已失效'); + const client = await this.clientsStore.getClient(row.clientId); + const p = row.parameters as any; + return { + id, + name: client?.client_name, + redirectUri: p.redirectUri, + scopes: p.scopes, + resource: p.resource, + }; + } + async consent(userId: string, id: string, approved: boolean) { + return this.db.atomic(async () => { + await this.pending(id); + const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { id } }); + const code = secret(); + const changed = await this.db.agentAuthorization.updateMany({ + where: { id, status: 'pending', expiresAt: { gt: new Date() } }, + data: { + userId, + status: approved ? 'approved' : 'denied', + codeDigest: approved ? digest(code) : null, + }, + }); + if (!changed.count) throw new BadRequestException('授权请求已处理'); + const p = row.parameters as any, + callback = new URL(p.redirectUri); + callback.searchParams.set(approved ? 'code' : 'error', approved ? code : 'access_denied'); + if (p.state) callback.searchParams.set('state', p.state); + return { redirect: callback.toString() }; + }); + } + async challengeForAuthorizationCode(client: OAuthClientInformationFull, code: string) { + const row = await this.db.agentAuthorization.findUnique({ + where: { codeDigest: digest(code) }, + }); + if ( + !row || + row.clientId !== client.client_id || + row.status !== 'approved' || + row.expiresAt <= new Date() + ) + throw new InvalidGrantError('Invalid authorization code'); + return (row.parameters as any).codeChallenge as string; + } + async issue(userId: string, name: string, selected: string[], days: number, clientId?: string) { + const access = secret(), + refresh = clientId ? secret() : undefined, + sessionId = digest(secret()); + const expiresAt = new Date(Date.now() + days * 86400000), + refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null; + await this.db.session.create({ + data: { id: sessionId, userId, expiresAt: refreshExpiresAt || expiresAt }, + }); + const grant = await this.db.agentGrant.create({ + data: { + userId, + name, + clientId, + scopes: selected, + resource: urls().resource.toString(), + accessDigest: digest(access), + refreshDigest: refresh ? digest(refresh) : null, + expiresAt, + refreshExpiresAt, + sessionId, + }, + }); + return { + grant, + tokens: { + access_token: access, + token_type: 'Bearer', + expires_in: Math.floor(days * 86400), + scope: selected.join(' '), + ...(refresh ? { refresh_token: refresh } : {}), + } as OAuthTokens, + }; + } + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + code: string, + verifier?: string, + redirectUri?: string, + resource?: URL, + ) { + this.resource(resource); + return this.db.atomic(async () => { + await this.challengeForAuthorizationCode(client, code); + const row = await this.db.agentAuthorization.findUniqueOrThrow({ + where: { codeDigest: digest(code) }, + }), + p = row.parameters as any; + // SDK tokenHandler validates S256 PKCE before invoking this provider, and passes + // undefined for verifier after successful local validation (skipLocalPkceValidation=false). + if ( + redirectUri !== p.redirectUri || + (verifier && createHash('sha256').update(verifier).digest('base64url') !== p.codeChallenge) + ) + throw new InvalidGrantError('PKCE or redirect mismatch'); + const changed = await this.db.agentAuthorization.updateMany({ + where: { id: row.id, status: 'approved' }, + data: { status: 'used', codeDigest: null }, + }); + if (!changed.count) throw new InvalidGrantError('Code already used'); + return ( + await this.issue( + row.userId!, + client.client_name || 'MCP client', + p.scopes, + 1 / 24, + client.client_id, + ) + ).tokens; + }); + } + async exchangeRefreshToken( + client: OAuthClientInformationFull, + token: string, + selected?: string[], + resource?: URL, + ) { + this.resource(resource); + return this.db.atomic(async () => { + const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } }); + if ( + !row || + row.clientId !== client.client_id || + row.revokedAt || + !row.refreshExpiresAt || + row.refreshExpiresAt <= new Date() + ) + throw new InvalidGrantError('Invalid refresh token'); + const current = row.scopes as string[]; + if ( + selected && + (!scopeInput.safeParse(selected).success || selected.some((s) => !current.includes(s))) + ) + throw new InvalidScopeError('Scope escalation rejected'); + const access = secret(), + refresh = secret(); + const changed = await this.db.agentGrant.updateMany({ + where: { id: row.id, refreshDigest: digest(token), revokedAt: null }, + data: { + accessDigest: digest(access), + refreshDigest: digest(refresh), + expiresAt: new Date(Date.now() + 3600000), + scopes: selected || current, + }, + }); + if (!changed.count) throw new InvalidGrantError('Refresh token already used'); + return { + access_token: access, + refresh_token: refresh, + token_type: 'Bearer', + expires_in: 3600, + scope: (selected || current).join(' '), + }; + }); + } + async verifyAccessToken(token: string) { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new InvalidTokenError('Invalid token'); + const row = await this.db.agentGrant.findUnique({ where: { accessDigest: digest(token) } }); + if ( + !row || + row.revokedAt || + row.expiresAt <= new Date() || + row.resource !== urls().resource.toString() + ) + throw new InvalidTokenError('Expired, revoked or invalid resource token'); + return { + token, + clientId: row.clientId || row.id, + scopes: row.scopes as string[], + expiresAt: Math.floor(+row.expiresAt / 1000), + resource: new URL(row.resource), + extra: { grantId: row.id, userId: row.userId }, + }; + } + async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) { + await this.db.agentGrant.updateMany({ + where: { + clientId: client.client_id, + OR: [{ accessDigest: digest(request.token) }, { refreshDigest: digest(request.token) }], + }, + data: { revokedAt: new Date() }, + }); + } + async grant(id: string, userId?: string) { + const row = await this.db.agentGrant.findFirst({ + where: { id, ...(userId ? { userId } : {}), revokedAt: null, expiresAt: { gt: new Date() } }, + }); + if (!row) throw new ForbiddenException('Agent 连接已过期或撤销'); + return row; + } +} diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts new file mode 100644 index 0000000..0f9cbbe --- /dev/null +++ b/apps/api/src/mcp/operations.ts @@ -0,0 +1,349 @@ +import { + Injectable, + BadRequestException, + ForbiddenException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, AgentGrant } from '@prisma/client'; +import { compare } from 'bcryptjs'; +import { Response } from 'express'; +import { z } from 'zod'; +import { Database } from '../database'; +import { AuthBusinessService, UserRequest } from '../auth'; +import { BackupBusinessService } from '../backup'; +import { AgentOAuth, digest, webLink } from './oauth'; +import { AgentCatalogue, ToolDefinition, empty } from './catalogue'; +import { AgentFiles } from './files'; +export const writing = { + idempotencyKey: z + .string() + .min(8) + .max(128) + .regex(/^[A-Za-z0-9_.:-]+$/) + .describe('同用户唯一幂等键;重试使用相同键和全部参数,改动参数必须换键'), + expectedState: z + .string() + .regex(/^[a-f0-9]{64}$/) + .describe('先 state_get 获取 state,避免覆盖并发修改;状态变化后重新读取并使用新幂等键'), +}; +export const plain = (v: unknown) => JSON.parse(JSON.stringify(v)); +function stable(v: any): string { + return JSON.stringify(v, (_k, x) => + x && typeof x === 'object' && !Array.isArray(x) + ? Object.fromEntries( + Object.keys(x) + .sort() + .map((k) => [k, x[k]]), + ) + : x, + ); +} +@Injectable() +export class AgentOperations { + readonly tools: ToolDefinition[]; + constructor( + private db: Database, + private oauth: AgentOAuth, + catalogue: AgentCatalogue, + private auth: AuthBusinessService, + private backup: BackupBusinessService, + private files: AgentFiles, + ) { + this.tools = [ + ...catalogue.tools, + { + name: 'backup_export', + description: + '创建短期受 Bearer 保护的完整 ZIP 备份下载入口,包含隐藏项目;网页验证密码并确认后 operation_get 获取入口,URL 本身不是凭证。', + schema: empty, + scope: 'sensitive', + run: async (r) => this.files.issue(r, r.agentGrantId!, 'download'), + }, + { + name: 'icon_publish', + description: + '保存已上传图标,shared=true 发布到共享库,须中文名称及网页确认。先 file_upload_request(kind=icon)。', + schema: z + .object({ + fileId: z.string().uuid(), + name: z.string().min(1).max(100), + shared: z.boolean().default(false), + }) + .strict(), + scope: 'write', + run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared), + }, + { + name: 'hidden_lock', + description: '立即锁定本连接的隐藏项目授权。', + schema: empty, + scope: 'write', + run: async (r) => this.auth.lock(r), + }, + ]; + } + get(name: string) { + const t = this.tools.find((t) => t.name === name); + if (!t) throw new BadRequestException('未知工具'); + return t; + } + async context(grant: AgentGrant) { + const s = await this.db.session.findUnique({ where: { id: grant.sessionId } }); + if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权'); + return { + userId: grant.userId, + sessionId: grant.sessionId, + revealed: !!s.revealUntil && +s.revealUntil > Date.now(), + agent: true, + agentGrantId: grant.id, + cookies: {}, + } as UserRequest; + } + async state(userId: string) { + const data = await Promise.all([ + this.db.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + username: true, + baseCurrency: true, + hiddenMenus: true, + showNotes: true, + idleMinutes: true, + accountGroupOrder: true, + sessionHours: true, + requireHiddenPassword: true, + overviewCards: true, + includeIndependentAssets: true, + }, + }), + this.db.position.findMany({ + where: { userId }, + orderBy: { id: 'asc' }, + include: { revisions: { orderBy: { id: 'asc' } }, outgoing: { orderBy: { id: 'asc' } } }, + }), + this.db.transfer.findMany({ where: { userId }, orderBy: { id: 'asc' } }), + this.db.schedule.findMany({ where: { userId }, orderBy: { id: 'asc' } }), + this.db.exchangeRate.findMany({ where: { userId }, orderBy: { id: 'asc' } }), + this.db.metalPrice.findMany({ where: { userId }, orderBy: { id: 'asc' } }), + this.db.icon.findMany({ + where: { ownerId: userId }, + select: { id: true, name: true, hash: true, shared: true }, + orderBy: { id: 'asc' }, + }), + ]); + return digest(stable(plain(data))); + } + private sensitive(t: ToolDefinition, p: any) { + return ( + t.scope === 'sensitive' || + (t.name === 'settings_update' && p.requireHiddenPassword !== undefined) || + (t.name === 'icon_publish' && p.shared) + ); + } + private async permission(grant: AgentGrant, t: ToolDefinition, p: any) { + const selected = grant.scopes as string[], + mode = + (await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode || + 'draft'; + if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限'); + if (t.scope === 'read') return { mode, sensitive: false }; + const sensitive = this.sensitive(t, p); + if (sensitive && !selected.includes('sensitive')) + throw new ForbiddenException('此操作需要 sensitive 权限'); + if (!sensitive && !selected.includes('write') && !selected.includes('draft')) + throw new ForbiddenException('缺少 draft 或 write 权限'); + if (mode === 'readonly' && !['backup_export', 'hidden_unlock_request'].includes(t.name)) + throw new ForbiddenException('当前用户写入策略为只读'); + return { mode, sensitive }; + } + async call(grantId: string, name: string, input: any) { + const t = this.get(name); + const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input); + const { idempotencyKey, expectedState, ...p } = parsed as any; + const grant = await this.oauth.grant(grantId), + permission = await this.permission(grant, t, p); + if (t.scope === 'read') return t.run!(await this.context(grant), p); + const hash = digest(stable({ tool: name, parameters: p, expectedState })); + return this.db.atomic(async () => { + await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${grant.userId} FOR UPDATE`); + const fresh = await this.oauth.grant(grantId), + access = await this.permission(fresh, t, p); + const existing = await this.db.agentOperation.findUnique({ + where: { userId_key: { userId: grant.userId, key: idempotencyKey } }, + }); + if (existing) { + if (existing.hash !== hash || existing.grantId !== grant.id) + throw new ConflictException('幂等键已用于不同参数或连接'); + return this.view(existing); + } + const snapshot = await this.state(grant.userId); + if (snapshot !== expectedState) + throw new ConflictException('账目已变化,请重新读取 state_get 和数据后使用新幂等键'); + const row = await this.db.agentOperation.create({ + data: { + userId: grant.userId, + grantId, + key: idempotencyKey, + hash, + tool: name, + parameters: plain(p), + snapshot, + expiresAt: new Date(Date.now() + 600000), + }, + }); + if ( + access.sensitive || + access.mode === 'draft' || + !(fresh.scopes as string[]).includes('write') + ) + return this.view(row); + const result = await this.execute(t, fresh, p); + return this.view( + await this.db.agentOperation.update({ + where: { id: row.id }, + data: { status: 'completed', result: plain(result), completedAt: new Date() }, + }), + ); + }); + } + private async execute(t: ToolDefinition, grant: AgentGrant, p: any) { + return t.run!(await this.context(grant), p); + } + private view(row: any) { + return { + operationId: row.id, + tool: row.tool, + status: row.status === 'pending' && row.expiresAt < new Date() ? 'expired' : row.status, + expiresAt: row.expiresAt, + result: row.result, + confirmationUrl: row.status === 'pending' ? webLink('agent_operation', row.id) : undefined, + }; + } + async status(grantId: string, id: string) { + const grant = await this.oauth.grant(grantId), + row = await this.db.agentOperation.findFirst({ + where: { id, userId: grant.userId, grantId }, + }); + if (!row) throw new NotFoundException('操作不存在'); + return this.view(row); + } + async preview(userId: string, id: string) { + const row = await this.db.agentOperation.findFirst({ where: { id, userId } }); + if (!row) throw new NotFoundException('操作不存在'); + const t = this.get(row.tool), + grant = await this.oauth.grant(row.grantId, userId); + let impact: unknown = { parameters: row.parameters, message: t.description }; + if (t.name === 'backup_import') + impact = ( + await this.backup.inspectUpload(await this.context(grant), (row.parameters as any).token) + ).preview; + if (t.web === 'clear') + impact = { + positions: await this.db.position.count({ where: { userId } }), + history: await this.db.revision.count({ where: { position: { userId } } }), + schedules: await this.db.schedule.count({ where: { userId } }), + message: '清空账户、资产、债务、历史、私有图标和汇率;保留账号和个人设置。先下载当前备份。', + }; + return { + ...this.view(row), + impact, + web: t.web, + sensitive: this.sensitive(t, row.parameters), + description: t.description, + }; + } + async confirm(r: UserRequest, id: string, raw: unknown, res: Response) { + const input = z + .object({ + approve: z.boolean(), + password: z.string().max(72).optional(), + username: z.string().max(64).optional(), + newPassword: z.string().max(72).optional(), + confirmation: z.string().max(20).optional(), + }) + .strict() + .parse(raw); + return this.db.atomic(async () => { + await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); + const row = await this.db.agentOperation.findFirst({ where: { id, userId: r.userId } }); + if (!row) throw new NotFoundException('操作不存在'); + if (row.status !== 'pending' || row.expiresAt <= new Date()) + throw new ConflictException('操作已完成或失效'); + if (!input.approve) + return this.view( + await this.db.agentOperation.update({ + where: { id }, + data: { status: 'cancelled', completedAt: new Date() }, + }), + ); + const grant = await this.oauth.grant(row.grantId, r.userId), + t = this.get(row.tool), + p = row.parameters as any; + const access = await this.permission(grant, t, p); + if (access.sensitive) { + const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (!input.password || !(await compare(input.password, u.passwordHash))) + throw new ForbiddenException('请验证当前密码'); + } + if ((await this.state(r.userId)) !== row.snapshot) + throw new ConflictException('账目已变化,请取消并重新创建操作'); + let result: unknown; + if (t.web === 'credentials') { + result = await this.auth.changeCredentials( + r, + { + currentPassword: input.password, + username: input.username, + newPassword: input.newPassword, + }, + res, + ); + await this.db.agentGrant.updateMany({ + where: { userId: r.userId, id: { not: grant.id } }, + data: { revokedAt: new Date() }, + }); + await this.db.session.create({ + data: { id: grant.sessionId, userId: r.userId, expiresAt: new Date(Date.now() + 300000) }, + }); + await this.db.agentGrant.update({ + where: { id: grant.id }, + data: { scopes: ['read'], expiresAt: new Date(Date.now() + 300000), refreshDigest: null }, + }); + } else if (t.web === 'reveal') + result = await this.auth.reveal( + Object.assign(Object.create(r), { sessionId: grant.sessionId }), + { password: input.password }, + ); + else if (t.web === 'clear') + result = await this.backup.clear(r, { confirmation: input.confirmation }); + else result = await this.execute(t, grant, p); + return this.view( + await this.db.agentOperation.update({ + where: { id }, + data: { status: 'completed', result: plain(result), completedAt: new Date() }, + }), + ); + }, 300000); + } + async uploadRequest(grantId: string, kind: 'backup' | 'icon') { + const grant = await this.oauth.grant(grantId); + const selected = grant.scopes as string[]; + if (!selected.includes('draft') && !selected.includes('write')) + throw new ForbiddenException('上传需要 draft 或 write 权限'); + if ( + (await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode === + 'readonly' + ) + throw new ForbiddenException('当前策略为只读'); + return this.files.issue(await this.context(grant), grantId, kind); + } + async fileStatus(grantId: string, id: string) { + const g = await this.oauth.grant(grantId); + return this.files.inspect(await this.context(g), grantId, id); + } + async iconImage(grantId: string, id: string) { + const g = await this.oauth.grant(grantId); + return this.files.issue(await this.context(g), grantId, 'image', id); + } +} diff --git a/apps/api/src/mcp/transport.ts b/apps/api/src/mcp/transport.ts new file mode 100644 index 0000000..ff7d84e --- /dev/null +++ b/apps/api/src/mcp/transport.ts @@ -0,0 +1,250 @@ +import { Injectable } from '@nestjs/common'; +import { Express } from 'express'; +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + mcpAuthRouter, + getOAuthProtectedResourceMetadataUrl, +} from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import { AgentOAuth, urls, scopes } from './oauth'; +import { AgentOperations, writing, plain } from './operations'; +import { AgentFiles } from './files'; +import { Database } from '../database'; +import { HttpException } from '@nestjs/common'; +import { ZodError } from 'zod'; + +export function toolResult(value: unknown) { + const data = plain(value); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + summary: data?.status ? `操作状态:${data.status}` : '查询或操作已完成', + data, + }), + }, + ], + structuredContent: { data }, + }; +} +@Injectable() +export class AgentTransport { + constructor( + private oauth: AgentOAuth, + private operations: AgentOperations, + private files: AgentFiles, + private db: Database, + ) {} + install(app: Express) { + const { issuer, resource } = urls(); + app.use( + mcpAuthRouter({ + provider: this.oauth, + issuerUrl: issuer, + resourceServerUrl: resource, + scopesSupported: [...scopes], + resourceName: 'WorthPath', + }), + ); + this.files.install(app); + app.all( + '/mcp', + (req, res, next) => { + const allowed = (process.env.MCP_ALLOWED_ORIGINS || urls().web.origin) + .split(',') + .map((s) => s.trim()); + if (req.headers.origin && !allowed.includes(req.headers.origin)) { + res.status(403).json({ error: 'Untrusted origin' }); + return; + } + const validHosts = [resource.host]; + if (process.env.NODE_ENV !== 'production') + validHosts.push('127.0.0.1:' + resource.port, 'localhost:' + resource.port); + if (!validHosts.includes(req.headers.host || '')) { + res.status(403).json({ error: 'Untrusted host' }); + return; + } + if (req.method === 'OPTIONS') { + if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization, Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id', + ); + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS'); + res.status(204).end(); + return; + } + if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + next(); + }, + requireBearerAuth({ + verifier: this.oauth, + requiredScopes: ['read'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource), + }), + async (req, res) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).end(); + return; + } + const grantId = String(req.auth?.extra?.grantId); + const invoke = async (name: string, work: () => Promise) => { + let status = 'success'; + try { + return toolResult(await work()); + } catch (e) { + status = 'error'; + return { + isError: true, + content: [ + { + type: 'text' as const, + text: + e instanceof HttpException || e instanceof ZodError + ? e.message + : '操作失败,未提交账目变更;请检查参数或稍后重试', + }, + ], + }; + } finally { + const grant = await this.oauth.grant(grantId).catch(() => null); + if (grant) + await this.db.agentCall + .create({ data: { userId: grant.userId, grantId, tool: name, status } }) + .catch(() => {}); + } + }; + const server = new McpServer( + { name: 'WorthPath', version: '1.0.0' }, + { + instructions: + '金额为十进制字符串;业务日期 UTC+8,时间戳 ISO UTC。先 state_get 再写入,所有写入要求幂等键。pending 返回网页入口,用户确认后用 operation_get 查询。只使用稳定 UUID;重名询问用户。', + }, + ); + for (const tool of this.operations.tools) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.scope === 'read' ? tool.schema : tool.schema.safeExtend(writing), + annotations: { + readOnlyHint: tool.scope === 'read', + destructiveHint: !!tool.destructive, + idempotentHint: tool.scope !== 'read', + openWorldHint: ['rates_refresh', 'metals_refresh'].includes(tool.name), + }, + }, + async (p: any) => { + return invoke(tool.name, () => this.operations.call(grantId, tool.name, p)); + }, + ); + } + const register = ( + name: string, + description: string, + schema: z.ZodObject, + run: (p: any) => Promise, + read = true, + ) => + server.registerTool( + name, + { + description, + inputSchema: schema, + annotations: { + readOnlyHint: read, + destructiveHint: false, + idempotentHint: read, + openWorldHint: false, + }, + }, + async (p: any) => invoke(name, () => run(p)), + ); + register( + 'state_get', + '获取当前用户账目并发版本 state,写入时作为 expectedState。', + z.object({}).strict(), + async () => ({ + state: await this.db.$transaction(async () => + this.operations.state((await this.oauth.grant(grantId)).userId), + ), + }), + ); + register( + 'operation_get', + '查询此连接发起的操作状态、过期时间和最终结果。', + z.object({ operationId: z.string().uuid() }).strict(), + (p) => this.operations.status(grantId, p.operationId), + ); + register( + 'file_upload_request', + '创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。', + z.object({ kind: z.enum(['backup', 'icon']) }).strict(), + (p) => this.operations.uploadRequest(grantId, p.kind), + false, + ); + register( + 'file_status', + '查看此连接文件上传状态、预检结果和备份导入 token。', + z.object({ fileId: z.string().uuid() }).strict(), + (p) => this.operations.fileStatus(grantId, p.fileId), + ); + register( + 'icon_image', + '取得受 Bearer 保护的短期 PNG 图标读取入口。', + z.object({ id: z.string().uuid() }).strict(), + (p) => this.operations.iconImage(grantId, p.id), + ); + register( + 'connection_info', + '查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。', + z.object({}).strict(), + async () => { + const g = await this.oauth.grant(grantId); + return { + name: g.name, + scopes: g.scopes, + expiresAt: g.expiresAt, + resource: g.resource, + writePolicy: + (await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode || + 'draft', + }; + }, + ); + register( + 'connection_revoke', + '立即撤销本 Agent 连接并退出授权;不影响网页登录。撤销后无法继续查询,请先保存操作结果。', + z.object({}).strict(), + async () => { + const g = await this.oauth.grant(grantId); + await this.db.agentGrant.update({ + where: { id: g.id }, + data: { revokedAt: new Date() }, + }); + return { revoked: true }; + }, + false, + ); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.once('close', () => { + void transport.close(); + void server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch { + if (!res.headersSent) res.status(500).json({ error: 'MCP request failed' }); + } + }, + ); + } +} diff --git a/apps/api/src/metals.ts b/apps/api/src/metals.ts index a6ed633..f374526 100644 --- a/apps/api/src/metals.ts +++ b/apps/api/src/metals.ts @@ -258,14 +258,15 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy { } } } -@Controller('api/metals') -export class MetalsController { + +@Injectable() +export class MetalsBusinessService { constructor( private db: Database, private metals: MetalsService, ) {} - @Get() async list(@Req() r: UserRequest) { - void this.metals.daily(r.userId); + async list(r: UserRequest) { + if (!r.agent) void this.metals.daily(r.userId); return { status: this.metals.status(r.userId), prices: await this.db.metalPrice.findMany({ @@ -275,10 +276,10 @@ export class MetalsController { }), }; } - @Post('refresh') refresh(@Req() r: UserRequest) { + refresh(r: UserRequest) { return this.metals.refresh(r.userId); } - @Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) { + async manual(r: UserRequest, body: unknown) { const v = metalPriceInput.parse(body); const key = { userId: r.userId, @@ -309,11 +310,7 @@ export class MetalsController { return { message: '贵金属价格已保存' }; }); } - @Put(':id') async configure( - @Req() r: UserRequest, - @Param('id') id: string, - @Body() body: unknown, - ) { + async configure(r: UserRequest, id: string, body: unknown) { const v = metalConfig.parse(body); const result = await this.db.serial(async (tx) => { await tx.$queryRaw( @@ -348,7 +345,31 @@ export class MetalsController { this.metals.invalidate(r.userId); return result; } - @Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) { + value(r: UserRequest, id: string) { return this.db.serial((tx) => this.metals.apply(tx, r.userId, id, r.revealed, true)); } } + +@Controller('api/metals') +export class MetalsController { + constructor(private service: MetalsBusinessService) {} + @Get() async list(@Req() r: UserRequest) { + return this.service.list(r); + } + @Post('refresh') refresh(@Req() r: UserRequest) { + return this.service.refresh(r); + } + @Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) { + return this.service.manual(r, body); + } + @Put(':id') async configure( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() body: unknown, + ) { + return this.service.configure(r, id, body); + } + @Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) { + return this.service.value(r, id); + } +} diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index b507228..286817d 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Controller, Get, @@ -32,8 +33,9 @@ import { MetalsService } from './metals'; import { RatesService } from './rates'; import { captureReplay } from './replay'; import { changeMovement } from './transfers'; -@Controller('api') -export class PortfolioController { + +@Injectable() +export class PortfolioBusinessService { constructor( private db: Database, private fx: RatesService, @@ -45,7 +47,7 @@ export class PortfolioController { if (!p) throw new NotFoundException('项目不存在'); return p; } - @Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) { + async list(r: UserRequest, inputKind?: string) { const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind); return this.db.$transaction(async (tx) => { const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind); @@ -71,18 +73,14 @@ export class PortfolioController { })); }); } - @Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) { + async detail(r: UserRequest, id: string) { const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed); return { ...p, amount: revisions[0]?.amount.toString() || '0' }; } - @Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) { + async history(r: UserRequest, query: unknown) { return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query)); } - @Get('positions/:id/history') async positionHistory( - @Req() r: UserRequest, - @Param('id') id: string, - @Query() query: Record, - ) { + async positionHistory(r: UserRequest, id: string, query: Record) { return this.db.$transaction(async (tx) => { const p = await tx.position.findFirst({ where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, @@ -91,7 +89,7 @@ export class PortfolioController { return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id }); }); } - @Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) { + async trend(r: UserRequest, query: unknown) { const q = trendInput(query); return this.db.$transaction( async (tx) => { @@ -106,7 +104,7 @@ export class PortfolioController { { timeout: 30000 }, ); } - @Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) { + async create(r: UserRequest, b: unknown) { const v = positionInput.parse(b), { amount, date, ...meta } = v; await this.icons.requireVisible(r.userId, meta.iconId); @@ -128,11 +126,7 @@ export class PortfolioController { this.fx.invalidate(r.userId); return created; } - @Patch('positions/:id') async edit( - @Req() r: UserRequest, - @Param('id') id: string, - @Body() b: unknown, - ) { + async edit(r: UserRequest, id: string, b: unknown) { const v = positionMeta.parse(b), p = await this.own(r.userId, id, r.revealed); if ( @@ -153,11 +147,7 @@ export class PortfolioController { }); return { ok: true }; } - @Post('positions/:id/revisions') async revise( - @Req() r: UserRequest, - @Param('id') id: string, - @Body() b: unknown, - ) { + async revise(r: UserRequest, id: string, b: unknown) { const v = revisionInput.parse(b); if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口'); return this.db.serial(async (tx) => { @@ -197,12 +187,7 @@ export class PortfolioController { }); }); } - @Put('positions/:id/revisions/:revisionId') async correct( - @Req() r: UserRequest, - @Param('id') id: string, - @Param('revisionId') revisionId: string, - @Body() b: unknown, - ) { + async correct(r: UserRequest, id: string, revisionId: string, b: unknown) { const v = revisionInput.parse(b); return this.db.serial(async (tx) => { await tx.$queryRaw( @@ -234,11 +219,7 @@ export class PortfolioController { }); } - @Delete('positions/:id/revisions/:revisionId') async deleteRevision( - @Req() r: UserRequest, - @Param('id') id: string, - @Param('revisionId') revisionId: string, - ) { + async deleteRevision(r: UserRequest, id: string, revisionId: string) { return this.db.serial(async (tx) => { const p = await tx.position.findFirst({ where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, @@ -264,11 +245,7 @@ export class PortfolioController { }); } - @Put('positions/:id/links') async link( - @Req() r: UserRequest, - @Param('id') id: string, - @Body() b: unknown, - ) { + async link(r: UserRequest, id: string, b: unknown) { const { targetIds } = z .object({ targetIds: z.array(z.string().uuid()).max(20) }) .strict() @@ -281,7 +258,12 @@ export class PortfolioController { }); if (!source) throw new NotFoundException('债务不存在'); const count = await tx.position.count({ - where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } }, + where: { + id: { in: targetIds }, + userId: r.userId, + kind: { in: ['account', 'asset'] }, + ...(r.revealed ? {} : { hidden: false }), + }, }); if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产'); await tx.positionLink.deleteMany({ where: { sourceId: id } }); @@ -291,9 +273,11 @@ export class PortfolioController { return { ok: true }; }); } - @Get('overview') async overview(@Req() r: UserRequest) { - void this.fx.daily(r.userId); - void this.metals.daily(r.userId); + async overview(r: UserRequest) { + if (!r.agent) { + void this.fx.daily(r.userId); + void this.metals.daily(r.userId); + } return this.db.$transaction(async (tx) => { const user = await tx.user.findUniqueOrThrow({ where: { id: r.userId }, @@ -323,3 +307,69 @@ export class PortfolioController { }); } } + +@Controller('api') +export class PortfolioController { + constructor(private service: PortfolioBusinessService) {} + @Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) { + return this.service.list(r, inputKind); + } + @Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) { + return this.service.detail(r, id); + } + @Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) { + return this.service.history(r, query); + } + @Get('positions/:id/history') async positionHistory( + @Req() r: UserRequest, + @Param('id') id: string, + @Query() query: Record, + ) { + return this.service.positionHistory(r, id, query); + } + @Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) { + return this.service.trend(r, query); + } + @Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) { + return this.service.create(r, b); + } + @Patch('positions/:id') async edit( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.edit(r, id, b); + } + @Post('positions/:id/revisions') async revise( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.revise(r, id, b); + } + @Put('positions/:id/revisions/:revisionId') async correct( + @Req() r: UserRequest, + @Param('id') id: string, + @Param('revisionId') revisionId: string, + @Body() b: unknown, + ) { + return this.service.correct(r, id, revisionId, b); + } + @Delete('positions/:id/revisions/:revisionId') async deleteRevision( + @Req() r: UserRequest, + @Param('id') id: string, + @Param('revisionId') revisionId: string, + ) { + return this.service.deleteRevision(r, id, revisionId); + } + @Put('positions/:id/links') async link( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.link(r, id, b); + } + @Get('overview') async overview(@Req() r: UserRequest) { + return this.service.overview(r); + } +} diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 1022474..616e65b 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -169,14 +169,15 @@ export class RatesService implements OnModuleInit, OnModuleDestroy { } } } -@Controller('api') -export class SettingsController { + +@Injectable() +export class SettingsBusinessService { constructor( private db: Database, private fx: RatesService, private auth: AuthService, ) {} - @Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) { + async settings(r: UserRequest, includeRates?: string) { const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true'; const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId }, @@ -217,11 +218,7 @@ export class SettingsController { : [], }; } - @Patch('settings') async update( - @Req() r: UserRequest, - @Body() b: unknown, - @Res({ passthrough: true }) res: Response, - ) { + async update(r: UserRequest, b: unknown, res: Response) { const data = settingsInput.parse(b); const expiresAt = data.sessionHours === undefined @@ -236,11 +233,29 @@ export class SettingsController { await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } }); if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } }); }); - if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res); + if (expiresAt && !r.agent) this.auth.cookie(r.cookies.wp_session, expiresAt, res); this.fx.invalidate(r.userId); return { ok: true }; } - @Post('rates/refresh') async refresh(@Req() r: UserRequest) { + async refresh(r: UserRequest) { return this.fx.refresh(r.userId); } } + +@Controller('api') +export class SettingsController { + constructor(private service: SettingsBusinessService) {} + @Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) { + return this.service.settings(r, includeRates); + } + @Patch('settings') async update( + @Req() r: UserRequest, + @Body() b: unknown, + @Res({ passthrough: true }) res: Response, + ) { + return this.service.update(r, b, res); + } + @Post('rates/refresh') async refresh(@Req() r: UserRequest) { + return this.service.refresh(r); + } +} diff --git a/apps/api/src/schedules.ts b/apps/api/src/schedules.ts index 69697c1..71439ae 100644 --- a/apps/api/src/schedules.ts +++ b/apps/api/src/schedules.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Controller, Get, @@ -56,8 +57,9 @@ export function occurrenceId(id: string, when: Date) { export function nextOccurrence(when: Date, intervalDays: number) { return new Date(+when + intervalDays * 86400000); } -@Controller('api/schedules') -export class SchedulesController { + +@Injectable() +export class SchedulesBusinessService { constructor(private db: Database) {} private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) { return ( @@ -71,7 +73,7 @@ export class SchedulesController { }) ).map((p) => p.id); } - @Get() async list(@Req() r: UserRequest) { + async list(r: UserRequest) { const ids = await this.visible(r); const rows = await this.db.schedule.findMany({ where: { @@ -83,7 +85,7 @@ export class SchedulesController { }); return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) })); } - @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { + async create(r: UserRequest, body: unknown) { const v = scheduleInput.parse(body); return this.db.serial(async (tx) => { await this.validateAccounts(tx, r, v); @@ -123,7 +125,7 @@ export class SchedulesController { ) throw new BadRequestException('同币种转出与到账金额必须一致'); } - @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) { + async edit(r: UserRequest, id: string, body: unknown) { const v = scheduleInput.parse(body); return this.db.serial(async (tx) => { await tx.$queryRaw( @@ -148,11 +150,7 @@ export class SchedulesController { return { ok: true }; }); } - @Patch(':id') async toggle( - @Req() r: UserRequest, - @Param('id') id: string, - @Body() body: unknown, - ) { + async toggle(r: UserRequest, id: string, body: unknown) { const v = z.object({ enabled: z.boolean() }).strict().parse(body); const ids = await this.visible(r); const row = await this.db.schedule.findFirst({ @@ -176,7 +174,7 @@ export class SchedulesController { if (!result.count) throw new NotFoundException('计划不存在'); return { ok: true }; } - @Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) { + async remove(r: UserRequest, id: string) { const ids = await this.visible(r); const result = await this.db.schedule.deleteMany({ where: { @@ -189,7 +187,7 @@ export class SchedulesController { if (!result.count) throw new NotFoundException('计划不存在'); return { ok: true }; } - @Post('run') async run(@Req() r: UserRequest) { + async run(r: UserRequest, atomicBatch = false) { const ids = await this.visible(r), now = new Date(); const due = await this.db.schedule.findMany({ @@ -281,6 +279,9 @@ export class SchedulesController { }); if (applied) executed++; } catch (e) { + // MCP wraps the complete call plus its idempotency result in one transaction. + // A failed nested plan must abort that batch rather than leave partial work. + if (atomicBatch) throw e; errors.push({ id: candidate.id, message: @@ -297,3 +298,30 @@ export class SchedulesController { }; } } + +@Controller('api/schedules') +export class SchedulesController { + constructor(private service: SchedulesBusinessService) {} + @Get() async list(@Req() r: UserRequest) { + return this.service.list(r); + } + @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { + return this.service.create(r, body); + } + @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) { + return this.service.edit(r, id, body); + } + @Patch(':id') async toggle( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() body: unknown, + ) { + return this.service.toggle(r, id, body); + } + @Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) { + return this.service.remove(r, id); + } + @Post('run') async run(@Req() r: UserRequest) { + return this.service.run(r); + } +} diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts index cb58afa..3b0d29f 100644 --- a/apps/api/src/transfers.ts +++ b/apps/api/src/transfers.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Controller, Get, @@ -21,10 +22,11 @@ import { businessTime } from './calculation'; import { movementDeltas } from './movement'; import { captureReplay } from './replay'; import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries'; -@Controller('api/transfers') -export class TransfersController { + +@Injectable() +export class TransfersBusinessService { constructor(private db: Database) {} - @Get() async list(@Req() r: UserRequest, @Query() query: unknown) { + async list(r: UserRequest, query: unknown) { const q = pageInput(query); const rows = await this.db.$transaction(async (tx) => { const ids = await transferPageIds(tx, r.userId, r.revealed, q); @@ -48,10 +50,7 @@ export class TransfersController { revealed: r.revealed, }; } - @Get('revision/:revisionId') async byRevision( - @Req() r: UserRequest, - @Param('revisionId') revisionId: string, - ) { + async byRevision(r: UserRequest, revisionId: string) { const row = await this.db.transfer.findFirst({ where: { userId: r.userId, @@ -67,11 +66,11 @@ export class TransfersController { const { userId, importedFromId, effectiveDate, ...v } = row; return { ...v, date: businessTime(effectiveDate) }; } - @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) { + async edit(r: UserRequest, id: string, body: unknown) { const v = transferInput.parse(body); return this.change(r, id, v); } - @Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) { + async remove(r: UserRequest, id: string) { return this.change(r, id); } private async change(r: UserRequest, id: string, v?: ReturnType) { @@ -79,7 +78,7 @@ export class TransfersController { return changeMovement(tx, r, id, v); }); } - @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { + async create(r: UserRequest, body: unknown) { const v = transferInput.parse(body); return this.db.serial((tx) => executeMovement(tx, r, v)); } @@ -244,3 +243,26 @@ export async function executeMovement( }); return { id: row.id }; } + +@Controller('api/transfers') +export class TransfersController { + constructor(private service: TransfersBusinessService) {} + @Get() async list(@Req() r: UserRequest, @Query() query: unknown) { + return this.service.list(r, query); + } + @Get('revision/:revisionId') async byRevision( + @Req() r: UserRequest, + @Param('revisionId') revisionId: string, + ) { + return this.service.byRevision(r, revisionId); + } + @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) { + return this.service.edit(r, id, body); + } + @Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) { + return this.service.remove(r, id); + } + @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { + return this.service.create(r, body); + } +} diff --git a/apps/api/test/inclusion-metals.test.ts b/apps/api/test/inclusion-metals.test.ts index 7ae40ea..35155a4 100644 --- a/apps/api/test/inclusion-metals.test.ts +++ b/apps/api/test/inclusion-metals.test.ts @@ -160,7 +160,7 @@ test('all account transfer directions, replay, scheduled transfers and backup ar ).status, 400, ); - const backupController = (await import('../src/backup')).BackupController; + const backupController = (await import('../src/backup')).BackupBusinessService; const controller = new backupController(db as any); const backup = await (controller as any).data(a.id); assert.equal((await import('../src/backup')).validateBackup(backup).transfers?.length, 2); @@ -257,7 +257,7 @@ test('inclusion preferences and precious metal settings, valuation and quotes re date: d, }); assert.equal(await db.revision.count({ where: { positionId: metal } }), count); - const controller = new (await import('../src/backup')).BackupController(db as any); + const controller = new (await import('../src/backup')).BackupBusinessService(db as any); const backup = await (controller as any).data(a.id); const archive = (await import('../src/zip')).archiveBackup(backup); const chunks: Buffer[] = []; diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts new file mode 100644 index 0000000..a7dad92 --- /dev/null +++ b/apps/api/test/mcp.test.ts @@ -0,0 +1,875 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { auth, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { today } from '../src/validation'; +import { readBackupZip } from '../src/zip'; +import sharp from 'sharp'; +const root = process.env.TEST_API_URL?.replace(/\/api$/, '') || 'http://127.0.0.1:3100'; +const resource = process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp'; +const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; + +test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isolation, files and sensitive confirmation', async () => { + const db = new PrismaClient(), + users: string[] = [], + clients: Client[] = []; + async function web(cookie: string, path: string, method = 'GET', body?: unknown) { + const response = await fetch(root + '/api' + path, { + method, + headers: { + Origin: origin, + ...(cookie ? { Cookie: cookie } : {}), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { + status: response.status, + data: await response.json(), + cookie: response.headers.get('set-cookie')?.split(';')[0] || cookie, + }; + } + async function fixture(mode = 'direct', selected = ['read', 'draft', 'write', 'sensitive']) { + const username = 'mcp_test_' + randomUUID().slice(0, 12), + password = randomBytes(20).toString('hex'); + const registered = await web('', '/auth/register', 'POST', { username, password }); + assert.equal(registered.status, 201); + const user = await db.user.findUniqueOrThrow({ where: { username } }); + users.push(user.id); + const cookie = registered.cookie; + assert.equal((await web(cookie, '/agent/policy', 'PUT', { mode, password })).status, 200); + const token = await web(cookie, '/agent/tokens', 'POST', { + name: 'Official SDK integration', + days: 1, + scopes: selected, + password, + }); + assert.equal(token.status, 201); + const client = new Client({ name: 'WorthPath integration', version: '1.31.0' }); + clients.push(client); + await client.connect( + new StreamableHTTPClientTransport(new URL(resource), { + requestInit: { headers: { Authorization: 'Bearer ' + token.data.token } }, + }), + ); + return { + id: user.id, + client, + cookie, + password, + token: token.data.token, + grantId: token.data.id, + }; + } + async function call(a: any, name: string, args: any = {}) { + const v: any = await a.client.callTool({ name, arguments: args }); + assert.ok(!v.isError, JSON.stringify(v)); + return v.structuredContent.data; + } + async function fail(a: any, name: string, args: any = {}) { + const v: any = await a.client.callTool({ name, arguments: args }); + assert.equal(v.isError, true, JSON.stringify(v)); + return v; + } + async function write(a: any, name: string, args: any = {}) { + const state = (await call(a, 'state_get')).state; + return call(a, name, { ...args, expectedState: state, idempotencyKey: randomUUID() }); + } + async function confirm(a: any, operation: any, extra: any = {}) { + const v = await web(a.cookie, '/agent/operations/' + operation.operationId, 'POST', { + approve: true, + password: a.password, + ...extra, + }); + assert.equal(v.status, 201, JSON.stringify(v.data)); + a.cookie = v.cookie; + return call(a, 'operation_get', { operationId: operation.operationId }); + } + const day = today(), + position = { + kind: 'account', + side: 'asset', + name: 'same name', + category: 'cash', + currency: 'CNY', + amount: '1000.87654321', + date: day, + notes: '', + }; + try { + const unauth = await fetch(resource, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + assert.equal(unauth.status, 401); + assert.match(unauth.headers.get('www-authenticate') || '', /resource_metadata/); + const metadata = await (await fetch(root + '/.well-known/oauth-protected-resource/mcp')).json(); + assert.equal(metadata.resource, resource); + const a = await fixture(), + b = await fixture(), + d = await fixture('draft', ['read', 'draft']); + await write(a, 'rates_refresh'); + await write(a, 'metals_refresh'); + await call(a, 'connection_info'); + const discovered = await a.client.listTools(); + assert.ok(discovered.tools.length >= 40); + assert.equal( + discovered.tools.find((t) => t.name === 'positions_list')!.annotations!.readOnlyHint, + true, + ); + const first = await write(a, 'position_create', position), + cash = first.result.id; + const second = (await write(a, 'position_create', { ...position, amount: '0' })).result.id; + const liability = ( + await write(a, 'position_create', { + ...position, + name: 'credit', + side: 'liability', + category: 'credit_card', + amount: '100', + }) + ).result.id; + const debt = ( + await write(a, 'position_create', { + ...position, + kind: 'debt', + side: 'liability', + name: 'loan', + category: 'loan', + amount: '100', + }) + ).result.id; + const metal = ( + await write(a, 'position_create', { + ...position, + kind: 'asset', + category: 'gold', + name: 'gold', + amount: '200', + }) + ).result.id; + assert.equal((await call(a, 'positions_list', { limit: 1, q: 'same name' })).total, 2); + assert.equal((await call(a, 'positions_list', { limit: 1, q: 'same name' })).nextOffset, 1); + await fail(b, 'position_get', { id: cash }); + await fail(a, 'positions_list', { userId: b.id }); + await fail(a, 'position_create', { + ...position, + amount: 12, + idempotencyKey: randomUUID(), + expectedState: (await call(a, 'state_get')).state, + }); + const key = randomUUID(), + state = (await call(a, 'state_get')).state, + args = { ...position, name: 'idempotent', expectedState: state, idempotencyKey: key }; + const once = await call(a, 'position_create', args), + again = await call(a, 'position_create', args); + assert.equal(once.result.id, again.result.id); + await fail(a, 'position_create', { ...args, name: 'changed' }); + await fail(a, 'balance_record', { + id: cash, + data: { amount: '10', date: day }, + idempotencyKey: randomUUID(), + expectedState: state, + }); + const draft = await write(d, 'position_create', position); + assert.equal(draft.status, 'pending'); + assert.equal((await call(d, 'positions_list')).total, 0); + assert.equal((await web(b.cookie, '/agent/operations/' + draft.operationId)).status, 404); + assert.equal( + ( + await web(d.cookie, '/agent/operations/' + draft.operationId, 'POST', { + approve: true, + confirmed: true, + }) + ).status, + 400, + ); + const applied = await confirm(d, draft); + assert.ok(applied.result.id); + assert.equal((await call(d, 'positions_list')).total, 1); + const expired = await write(d, 'position_create', { ...position, name: 'expired' }); + await db.agentOperation.update({ + where: { id: expired.operationId }, + data: { expiresAt: new Date(0) }, + }); + assert.equal( + (await call(d, 'operation_get', { operationId: expired.operationId })).status, + 'expired', + ); + assert.equal( + (await web(d.cookie, '/agent/operations/' + expired.operationId, 'POST', { approve: true })) + .status, + 409, + ); + const cancelled = await write(d, 'position_create', { ...position, name: 'cancelled' }); + assert.equal( + ( + await web(d.cookie, '/agent/operations/' + cancelled.operationId, 'POST', { + approve: false, + }) + ).status, + 201, + ); + const stale = await write(d, 'position_create', { ...position, name: 'stale' }); + await web(d.cookie, '/settings', 'PATCH', { showNotes: false }); + assert.equal( + (await web(d.cookie, '/agent/operations/' + stale.operationId, 'POST', { approve: true })) + .status, + 409, + ); + const mv = ( + await write(a, 'movement_create', { + sourceId: cash, + targetId: second, + amount: '30.00000001', + received: '30.00000001', + fee: '0', + date: day, + }) + ).result; + assert.equal((await call(a, 'position_get', { id: cash })).amount, '970.8765432'); + await write(a, 'movement_update', { + id: mv.id, + data: { sourceId: cash, targetId: second, amount: '40', received: '40', fee: '0', date: day }, + }); + assert.equal((await call(a, 'position_get', { id: second })).amount, '40'); + const movementPage = await call(a, 'movements_list', { limit: 1 }); + await call(a, 'movement_by_revision', { revisionId: movementPage.items[0].sourceRevisionId }); + await write(a, 'movement_delete', { id: mv.id }); + assert.equal((await call(a, 'position_get', { id: cash })).amount, '1000.87654321'); + await write(a, 'movement_create', { + operation: 'repay', + sourceId: cash, + targetId: debt, + amount: '10', + received: '10', + date: day, + }); + assert.equal((await call(a, 'position_get', { id: debt })).amount, '90'); + await write(a, 'movement_create', { + sourceId: cash, + targetId: liability, + amount: '150', + received: '150', + date: day, + }); + assert.equal((await call(a, 'position_get', { id: liability })).amount, '-50'); + await write(a, 'debt_links_set', { id: debt, targetIds: [cash, metal] }); + const rev = ( + await write(a, 'balance_record', { + id: second, + data: { amount: '33.25', date: day, reason: 'balance' }, + }) + ).result; + await write(a, 'history_update', { + id: second, + revisionId: rev.id, + data: { amount: '35.25', date: day }, + }); + assert.equal((await call(a, 'position_get', { id: second })).amount, '35.25'); + await write(a, 'history_delete', { id: second, revisionId: rev.id }); + assert.equal((await call(a, 'position_get', { id: second })).amount, '0'); + const h = await call(a, 'history_list', { limit: 1 }); + assert.equal(h.items.length, 1); + assert.ok(h.nextCursor); + await call(a, 'history_list', { limit: 1, cursor: h.nextCursor }); + await write(a, 'settings_update', { + accountGroupOrder: ['invest', ''], + baseCurrency: 'CNY', + overviewCards: ['net'], + includeIndependentAssets: true, + }); + await write(a, 'position_update', { + id: cash, + data: { + name: 'cash', + category: 'cash', + groupName: 'invest', + notes: 'memo', + archived: false, + hidden: false, + included: true, + }, + }); + await write(a, 'position_update', { + id: metal, + data: { name: 'gold', category: 'gold', notes: '', archived: true, hidden: false }, + }); + await fail(a, 'balance_record', { + id: metal, + data: { amount: '1', date: day }, + expectedState: (await call(a, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + await write(a, 'position_update', { + id: metal, + data: { name: 'gold', category: 'gold', notes: '', archived: false, hidden: false }, + }); + await write(a, 'metal_price_set', { + metalType: 'gold', + currency: 'CNY', + price: '10.876543210987', + date: day, + }); + await write(a, 'metal_configure', { + id: metal, + data: { metalType: 'gold', metalGrams: '2', metalPurity: '1', autoValuation: true }, + }); + assert.equal((await call(a, 'position_get', { id: metal })).amount, '21.75308642'); + await write(a, 'metal_value', { id: metal }); + await call(a, 'metals_prices'); + await call(a, 'settings_get'); + await call(a, 'overview_get', { limit: 1 }); + await call(a, 'trend_get', { from: day, to: day, grain: 'day' }); + await call(a, 'calendar_month', { month: day.slice(0, 7) }); + await call(a, 'calendar_day', { date: day, limit: 1 }); + await call(a, 'icons_list', { q: '', page: 1 }); + const planInput = { + name: 'once', + operation: 'expense', + sourceId: cash, + amount: '1.25', + nextAt: day + 'T00:00', + intervalDays: 0, + }; + const plan = (await write(a, 'schedule_create', planInput)).result.id; + await write(a, 'schedule_update', { id: plan, data: { ...planInput, amount: '2.25' } }); + await write(a, 'schedule_toggle', { id: plan, enabled: false }); + await call(a, 'schedules_list', { limit: 1 }); + await write(a, 'schedule_toggle', { id: plan, enabled: true }); + const before = (await call(a, 'position_get', { id: cash })).amount; + assert.equal((await write(a, 'schedules_run')).result.executed, 1); + assert.notEqual((await call(a, 'position_get', { id: cash })).amount, before); + assert.equal((await write(a, 'schedules_run')).result.executed, 0); + await write(a, 'schedule_delete', { id: plan }); + const hidden = ( + await write(a, 'position_create', { ...position, name: 'hidden', hidden: true }) + ).result.id; + await fail(a, 'position_get', { id: hidden }); + await fail(a, 'debt_links_set', { + id: debt, + targetIds: [hidden], + expectedState: (await call(a, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + await fail(d, 'settings_update', { + requireHiddenPassword: false, + expectedState: (await call(d, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + const unlock = await write(a, 'hidden_unlock_request'); + await confirm(a, unlock); + assert.equal((await call(a, 'position_get', { id: hidden })).id, hidden); + await write(a, 'hidden_lock'); + await fail(a, 'position_get', { id: hidden }); + const exported = await write(a, 'backup_export'); + assert.equal(exported.status, 'pending'); + const out = (await confirm(a, exported)).result; + assert.equal((await fetch(out.url)).status, 401); + assert.equal( + (await fetch(out.url, { headers: { Authorization: 'Bearer ' + b.token } })).status, + 403, + ); + const download = await fetch(out.url, { headers: { Authorization: 'Bearer ' + a.token } }); + assert.equal(download.status, 200); + const zipped = Buffer.from(await download.arrayBuffer()); + const backup: any = await readBackupZip(zipped); + assert.ok(backup.positions.find((p: any) => p.id === hidden)); + assert.equal(JSON.stringify(backup).includes(a.token), false); + const target = await fixture('draft'), + upload = await call(target, 'file_upload_request', { kind: 'backup' }), + form = new FormData(); + form.append('file', new Blob([zipped]), 'backup.zip'); + const uploaded = await fetch(upload.url, { + method: 'POST', + headers: { Authorization: 'Bearer ' + target.token }, + body: form, + }); + assert.equal(uploaded.status, 200); + const info = await uploaded.json(); + assert.ok(info.token); + await call(target, 'file_status', { fileId: upload.fileId }); + await call(target, 'import_preview', { token: info.token }); + const imported = await write(target, 'backup_import', { token: info.token }); + await confirm(target, imported); + assert.equal( + await db.position.count({ where: { userId: target.id } }), + backup.positions.length, + ); + const retry = await write(target, 'backup_import', { token: info.token }); + assert.equal( + ( + await web(target.cookie, '/agent/operations/' + retry.operationId, 'POST', { + approve: true, + password: target.password, + }) + ).status, + 409, + ); + assert.equal( + await db.position.count({ where: { userId: target.id } }), + backup.positions.length, + ); + const clear = await write(target, 'data_clear_request'); + assert.equal( + ( + await web(target.cookie, '/agent/operations/' + clear.operationId, 'POST', { + approve: true, + password: target.password, + confirmation: '确定清空', + }) + ).status, + 400, + ); + const save = await fetch(root + '/api/backup', { headers: { Cookie: target.cookie } }); + assert.equal(save.status, 200); + await save.arrayBuffer(); + await confirm(target, clear, { confirmation: '确定清空' }); + assert.equal(await db.position.count({ where: { userId: target.id } }), 0); + assert.equal( + (await web(a.cookie, '/agent/policy', 'PUT', { mode: 'readonly', password: a.password })) + .status, + 200, + ); + await fail(a, 'position_create', { + ...position, + expectedState: (await call(a, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + await db.agentGrant.update({ where: { id: b.grantId }, data: { expiresAt: new Date(0) } }); + assert.equal( + ( + await fetch(resource, { + method: 'POST', + headers: { Authorization: 'Bearer ' + b.token, 'Content-Type': 'application/json' }, + body: '{}', + }) + ).status, + 401, + ); + await call(d, 'connection_revoke'); + assert.equal( + ( + await fetch(resource, { + method: 'POST', + headers: { Authorization: 'Bearer ' + d.token, 'Content-Type': 'application/json' }, + body: '{}', + }) + ).status, + 401, + ); + const originDenied = await fetch(resource, { + method: 'POST', + headers: { + Origin: 'https://evil.invalid', + Authorization: 'Bearer ' + a.token, + 'Content-Type': 'application/json', + }, + body: '{}', + }); + assert.equal(originDenied.status, 403); + } finally { + for (const c of clients) await c.close().catch(() => {}); + await db.user.deleteMany({ where: { id: { in: users } } }); + await db.$disconnect(); + } +}); + +test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollback and credentials completion', async () => { + const db = new PrismaClient(); + const username = 'mcp_extra_' + randomUUID().slice(0, 10), + password = randomBytes(20).toString('hex'); + let userId = '', + cookie = ''; + const clients: Client[] = []; + const iconIds: string[] = []; + async function web(path: string, method = 'GET', body?: unknown) { + const r = await fetch(root + '/api' + path, { + method, + headers: { + Origin: origin, + ...(cookie ? { Cookie: cookie } : {}), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + cookie = r.headers.get('set-cookie')?.split(';')[0] || cookie; + return { status: r.status, data: await r.json() }; + } + async function tool(c: Client, name: string, args: any = {}) { + return c.callTool({ name, arguments: args }) as Promise; + } + async function call(c: Client, name: string, args: any = {}) { + const r = await tool(c, name, args); + assert.ok(!r.isError, JSON.stringify(r)); + return r.structuredContent.data; + } + async function write(c: Client, name: string, args: any = {}) { + return call(c, name, { + ...args, + expectedState: (await call(c, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + } + try { + assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201); + userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; + await web('/agent/policy', 'PUT', { mode: 'direct', password }); + const grant = ( + await web('/agent/tokens', 'POST', { + name: 'extra', + days: 1, + scopes: ['read', 'draft', 'write', 'sensitive'], + password, + }) + ).data; + const headers = { Authorization: 'Bearer ' + grant.token }; + for (let i = 0; i < 2; i++) { + const c = new Client({ name: 'concurrent', version: '1.31.0' }); + clients.push(c); + await c.connect( + new StreamableHTTPClientTransport(new URL(resource), { requestInit: { headers } }), + ); + } + const c = clients[0], + position = { + kind: 'account', + side: 'asset', + name: 'concurrent', + category: 'cash', + currency: 'CNY', + amount: '100', + date: today(), + }; + const args = { + ...position, + idempotencyKey: randomUUID(), + expectedState: (await call(c, 'state_get')).state, + }; + const [one, two] = await Promise.all( + clients.map((client) => call(client, 'position_create', args)), + ); + assert.equal(one.operationId, two.operationId); + assert.equal(await db.position.count({ where: { userId } }), 1); + const state = (await call(c, 'state_get')).state; + const results = await Promise.all( + clients.map((client) => + tool(client, 'position_create', { + ...position, + name: randomUUID(), + expectedState: state, + idempotencyKey: randomUUID(), + }), + ), + ); + assert.equal(results.filter((r) => !r.isError).length, 1); + assert.equal(await db.position.count({ where: { userId } }), 2); + const upload = await call(c, 'file_upload_request', { kind: 'icon' }), + form = new FormData(); + form.append( + 'file', + new Blob([ + await sharp({ create: { width: 4, height: 4, channels: 4, background: '#33aa88' } }) + .png() + .toBuffer(), + ]), + 'icon.png', + ); + assert.equal((await fetch(upload.url, { method: 'POST', headers, body: form })).status, 200); + const published = ( + await write(c, 'icon_publish', { fileId: upload.fileId, name: '测试私有图标', shared: false }) + ).result; + iconIds.push(published.id); + const image = await call(c, 'icon_image', { id: published.id }); + assert.equal((await fetch(image.url, { headers })).status, 200); + const shared = await write(c, 'icon_publish', { + fileId: upload.fileId, + name: '测试共享图标', + shared: true, + }); + assert.equal(shared.status, 'pending'); + assert.equal( + (await web('/agent/operations/' + shared.operationId, 'POST', { approve: true, password })) + .status, + 201, + ); + iconIds.push((await call(c, 'operation_get', { operationId: shared.operationId })).result.id); + // A failed paired transfer leaves neither side changed, including inside outer + // idempotency transaction and nested service savepoints. + const account = one.result.id, + foreign = randomUUID(), + balance = (await call(c, 'position_get', { id: account })).amount; + const failure = await tool(c, 'movement_create', { + sourceId: account, + targetId: foreign, + amount: '1', + received: '1', + date: today(), + expectedState: (await call(c, 'state_get')).state, + idempotencyKey: randomUUID(), + }); + assert.equal(failure.isError, true); + assert.equal((await call(c, 'position_get', { id: account })).amount, balance); + assert.equal(await db.transfer.count({ where: { userId } }), 0); + const other = (await call(c, 'positions_list')).items.find((v: any) => v.id !== account).id; + const good = ( + await write(c, 'schedule_create', { + name: 'first valid', + operation: 'expense', + sourceId: account, + amount: '2', + nextAt: today() + 'T00:00', + intervalDays: 0, + }) + ).result.id; + const bad = ( + await write(c, 'schedule_create', { + name: 'second archived', + operation: 'expense', + sourceId: other, + amount: '3', + nextAt: today() + 'T00:01', + intervalDays: 0, + }) + ).result.id; + await write(c, 'position_update', { + id: other, + data: { name: 'archived', category: 'cash', notes: '', archived: true, hidden: false }, + }); + const revisions = await db.revision.count({ where: { position: { userId } } }); + const batch = await tool(c, 'schedules_run', { + idempotencyKey: randomUUID(), + expectedState: (await call(c, 'state_get')).state, + }); + assert.equal(batch.isError, true); + assert.equal((await call(c, 'position_get', { id: account })).amount, balance); + assert.equal(await db.revision.count({ where: { position: { userId } } }), revisions); + assert.equal((await db.schedule.findUniqueOrThrow({ where: { id: good } })).completed, false); + await write(c, 'schedule_delete', { id: good }); + await write(c, 'schedule_delete', { id: bad }); + const management = (await web('/agent')).data; + assert.ok(management.calls.some((v: any) => v.status === 'error')); + assert.equal(JSON.stringify(management).includes(grant.token), false); + const operation = await write(c, 'credentials_change_request'); + const replacement = randomBytes(20).toString('hex'); + assert.equal( + ( + await web('/agent/operations/' + operation.operationId, 'POST', { + approve: true, + password, + newPassword: replacement, + }) + ).status, + 201, + ); + assert.equal( + (await call(c, 'operation_get', { operationId: operation.operationId })).status, + 'completed', + ); + assert.equal( + ( + await tool(c, 'position_create', { + ...position, + idempotencyKey: randomUUID(), + expectedState: (await call(c, 'state_get')).state, + }) + ).isError, + true, + ); + assert.equal( + (await web('/auth/login', 'POST', { username, password: replacement })).status, + 201, + ); + } finally { + for (const c of clients) await c.close().catch(() => {}); + await db.icon.deleteMany({ where: { id: { in: iconIds } } }); + if (userId) await db.user.deleteMany({ where: { id: userId } }); + await db.$disconnect(); + } +}); + +test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation and resource validation', async () => { + const db = new PrismaClient(); + const username = 'mcp_oauth_' + randomUUID().slice(0, 10), + password = randomBytes(20).toString('hex'); + let userId = '', + clientId = ''; + let saved: any, + tokens: any, + verifier = '', + authorization: URL | undefined; + const provider: OAuthClientProvider = { + redirectUrl: 'http://127.0.0.1:47891/callback', + clientMetadata: { + client_name: 'WorthPath official OAuth test', + redirect_uris: ['http://127.0.0.1:47891/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'read draft write sensitive', + }, + clientInformation: () => saved, + saveClientInformation: (v) => { + saved = v; + clientId = v.client_id; + }, + tokens: () => tokens, + saveTokens: (v) => { + tokens = v; + }, + redirectToAuthorization: (v) => { + authorization = v; + }, + saveCodeVerifier: (v) => { + verifier = v; + }, + codeVerifier: () => verifier, + state: () => 'test-state', + }; + async function post(path: string, body: any, cookie = '') { + const r = await fetch(root + path, { + method: 'POST', + headers: { + Origin: origin, + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify(body), + }); + return { + status: r.status, + data: await r.json(), + cookie: r.headers.get('set-cookie')?.split(';')[0] || cookie, + }; + } + try { + const registered = await post('/api/auth/register', { username, password }); + assert.equal(registered.status, 201); + userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; + assert.equal( + await auth(provider, { serverUrl: resource, scope: 'read draft write sensitive' }), + 'REDIRECT', + ); + assert.ok(authorization); + const redirected = await fetch(authorization!, { redirect: 'manual' }); + assert.equal(redirected.status, 302); + const location = new URL(redirected.headers.get('location')!); + const id = location.searchParams.get('agent_authorization'); + assert.ok(id); + const consent = await post( + '/api/agent/authorizations/' + id, + { approve: true }, + registered.cookie, + ); + assert.equal(consent.status, 201); + const callback = new URL(consent.data.redirect); + assert.equal(callback.searchParams.get('state'), 'test-state'); + const code = callback.searchParams.get('code')!; + assert.equal( + await auth(provider, { serverUrl: resource, authorizationCode: code }), + 'AUTHORIZED', + ); + assert.ok(tokens.access_token); + const old = tokens; + const client = new Client({ name: 'oauth-client', version: '1.31.0' }); + await client.connect( + new StreamableHTTPClientTransport(new URL(resource), { authProvider: provider }), + ); + assert.ok((await client.listTools()).tools.length >= 40); + await client.close(); + async function exchange(params: Record) { + const r = await fetch(root + '/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + return { status: r.status, data: await r.json() }; + } + assert.equal( + ( + await exchange({ + grant_type: 'authorization_code', + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: String(provider.redirectUrl), + resource, + }) + ).status, + 400, + ); + assert.equal( + ( + await exchange({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: old.refresh_token, + resource: 'https://evil.invalid/mcp', + }) + ).status, + 400, + ); + const refreshed = await exchange({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: old.refresh_token, + resource, + }); + assert.equal(refreshed.status, 200); + assert.notEqual(refreshed.data.refresh_token, old.refresh_token); + assert.equal( + ( + await exchange({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: old.refresh_token, + resource, + }) + ).status, + 400, + ); + assert.equal( + ( + await fetch(resource, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + old.access_token, + 'Content-Type': 'application/json', + }, + body: '{}', + }) + ).status, + 401, + ); + const grant = await db.agentGrant.findFirstOrThrow({ where: { userId } }); + assert.notEqual(grant.accessDigest, refreshed.data.access_token); + const revoke = await fetch(root + '/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, token: refreshed.data.access_token }), + }); + assert.equal(revoke.status, 200); + assert.equal( + ( + await fetch(resource, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + refreshed.data.access_token, + 'Content-Type': 'application/json', + }, + body: '{}', + }) + ).status, + 401, + ); + } finally { + if (userId) await db.user.deleteMany({ where: { id: userId } }); + if (clientId) await db.agentClient.deleteMany({ where: { id: clientId } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/record-edit.test.ts b/apps/api/test/record-edit.test.ts index 8651ab1..62fd3cb 100644 --- a/apps/api/test/record-edit.test.ts +++ b/apps/api/test/record-edit.test.ts @@ -4,17 +4,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { Database } from '../src/database'; -import { TransfersController } from '../src/transfers'; -import { PortfolioController } from '../src/portfolio'; -import { CalendarController } from '../src/calendar'; +import { TransfersBusinessService } from '../src/transfers'; +import { PortfolioBusinessService } from '../src/portfolio'; +import { CalendarBusinessService } from '../src/calendar'; import { toBusinessDate } from '../src/validation'; test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => { const db = new Database(); const users: string[] = []; - const movements = new TransfersController(db); - const portfolio = new PortfolioController(db, {} as any, {} as any); - const calendar = new CalendarController(db); + const movements = new TransfersBusinessService(db); + const portfolio = new PortfolioBusinessService(db, {} as any, {} as any); + const calendar = new CalendarBusinessService(db); try { const user = await db.user.create({ data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' }, diff --git a/apps/api/test/update.test.ts b/apps/api/test/update.test.ts index 77bb8ad..5b356ad 100644 --- a/apps/api/test/update.test.ts +++ b/apps/api/test/update.test.ts @@ -2,10 +2,15 @@ import 'reflect-metadata'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import { cashflowDelta, calendarMonth, CalendarController } from '../src/calendar'; +import { cashflowDelta, calendarMonth, CalendarBusinessService } from '../src/calendar'; import { executeMovement } from '../src/transfers'; import { transferInput, positionInput, revisionInput } from '../src/validation'; -import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules'; +import { + scheduleInput, + occurrenceId, + nextOccurrence, + SchedulesBusinessService, +} from '../src/schedules'; import { trend, totals, type Holding } from '../src/calculation'; import { positionAmount, accountInputAmount, money, type Position } from '../../web/src/api'; const now = new Date(Date.now() - 60000); @@ -221,7 +226,7 @@ test('due expense runs once, updates balance exactly and advances a recurring pl }, }; const db: any = { ...tx, serial: async (work: any) => work(tx) }; - const c = new SchedulesController(db), + const c = new SchedulesBusinessService(db), r: any = { userId: owner, revealed: false }; assert.equal((await c.run(r)).executed, 1); assert.equal(balance, '-2.00000001'); @@ -335,7 +340,7 @@ test('calendar replays only the range, preserves fractional totals, and hides in $queryRaw: async () => [], }; const db: any = { $transaction: async (work: any) => work(tx) }; - const c = new CalendarController(db), + const c = new CalendarBusinessService(db), r: any = { userId: 'owner', revealed: false }; const month = await c.month(r, '2026-09'); assert.equal(month.items[1].income, '0.01'); diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx new file mode 100644 index 0000000..4e8de66 --- /dev/null +++ b/apps/web/src/AgentConnections.tsx @@ -0,0 +1,395 @@ +import { useEffect, useState } from 'react'; +import { api } from './api'; + +type Connection = { + id: string; + name: string; + scopes: string[]; + expiresAt: string; + revokedAt: string | null; + clientId: string | null; +}; +type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string }; +type Management = { + mcpUrl: string; + mode: string; + grants: Connection[]; + operations: Operation[]; + calls: { id: string; tool: string; status: string; createdAt: string }[]; +}; +type Preview = { + operationId: string; + tool: string; + status: string; + description: string; + impact: unknown; + web?: string; + sensitive: boolean; + result?: unknown; +}; +type Consent = { + id: string; + name: string; + scopes: string[]; + redirectUri: string; + resource: string; +}; +export function AgentConnections() { + const [data, setData] = useState(null), + [error, setError] = useState(''), + [message, setMessage] = useState(''), + [token, setToken] = useState(''), + [preview, setPreview] = useState(null), + [consent, setConsent] = useState(null), + [busy, setBusy] = useState(false); + const load = async () => setData(await api('/agent')); + const act = async (work: () => Promise) => { + if (busy) return; + setBusy(true); + setError(''); + setMessage(''); + try { + await work(); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : '操作失败'); + } finally { + setBusy(false); + } + }; + const show = async (id: string) => setPreview(await api('/agent/operations/' + id)); + useEffect(() => { + void act(async () => { + await load(); + const params = new URLSearchParams(location.search); + const authorization = params.get('agent_authorization'), + operation = params.get('agent_operation'); + if (authorization) setConsent(await api('/agent/authorizations/' + authorization)); + if (operation) await show(operation); + }); + }, []); + return ( +
+

连接 Agent

+ {error && ( +

+ {error} +

+ )} + {message &&

{message}

} + {data && ( + <> +

远程 MCP 地址

+ {data.mcpUrl} + +

+ 支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath + 后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30 + 天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。 +

+

+ 已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent + 尚未验证;不会保证任意客户端兼容。 +

+
+ 官方 SDK 的已验证接入配置 +
{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n  requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}
+ +

+ 先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见 + docs/mcp.md;此配置仅针对官方 SDK 1.31.0。 +

+
+
{ + e.preventDefault(); + const f = new FormData(e.currentTarget), + form = e.currentTarget; + void act(async () => { + await api('/agent/policy', 'PUT', { + mode: f.get('mode'), + password: f.get('password'), + }); + form.reset(); + setMessage('写入策略已保存'); + }); + }} + > +

写入策略

+ + + +
+

OAuth 授权与个人令牌

+

+ read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive + 发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。 +

+
{ + e.preventDefault(); + const form = e.currentTarget, + f = new FormData(form); + void act(async () => { + const v = await api<{ token: string }>('/agent/tokens', 'POST', { + name: f.get('name'), + days: Number(f.get('days')), + password: f.get('password'), + scopes: f.getAll('scope'), + }); + setToken(v.token); + form.reset(); + }); + }} + > + + + {['read', 'draft', 'write', 'sensitive'].map((s) => ( + + ))} + + +
+ {token && ( +
+

完整令牌仅显示这一次,请妥善保存。

+ {token} + + +
+ )} +

已授权连接(最近 100 条)

+ {data.grants.map((g) => ( +
+ + {g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '} + {new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''} + + {!g.revokedAt && ( + + )} +
+ ))} +

待确认及最近操作(最近 100 条)

+ + {data.operations.map((o) => ( +
+ + {o.tool} ·{' '} + {o.status === 'pending' && new Date(o.expiresAt) < new Date() + ? 'expired' + : o.status}{' '} + · {new Date(o.createdAt).toLocaleString()} + + +
+ ))} +

最近调用(不保存参数和敏感内容)

+ {data.calls.map((c) => ( +
+ {c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()} +
+ ))} + + )} + {consent && ( +
+

审核 OAuth 连接

+

{consent.name}

+

权限:{consent.scopes.join(', ')}

+

资源:{consent.resource}

+

回调地址:{consent.redirectUri}

+

只批准你正在连接的客户端;请核对回调地址。

+ {[true, false].map((approve) => ( + + ))} +
+ )} + {preview && ( +
+

+ {preview.tool} · {preview.status} +

+

{preview.description}

+
{JSON.stringify(preview.impact, null, 2)}
+ {preview.result !== undefined &&
{JSON.stringify(preview.result, null, 2)}
} + {preview.status === 'pending' && ( +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget), + form = e.currentTarget; + void act(async () => { + await api('/agent/operations/' + preview.operationId, 'POST', { + approve: true, + ...(f.get('password') ? { password: f.get('password') } : {}), + ...(f.get('username') ? { username: f.get('username') } : {}), + ...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}), + ...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}), + }); + form.reset(); + await show(preview.operationId); + setMessage('操作已提交,Agent 可以查询最终结果'); + }); + }} + > + {preview.web === 'clear' && ( + <> + + 先下载当前账号备份 + +

确认备份已保存后输入“确定清空”。

+ + + )} + {preview.web === 'credentials' && ( + <> + + + + )} + {preview.sensitive && ( + + )} + + +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index eca2647..20efc1f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -53,6 +53,7 @@ import { GroupOrderList } from './GroupOrderList'; import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; +import { AgentConnections } from './AgentConnections'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; import { QuickTransfer } from './QuickTransfer'; @@ -229,7 +230,12 @@ export default function App() { const [user, setUser] = useState(null), [boot, setBoot] = useState(true), [register, setRegister] = useState(false), - [page, setPage] = useState('overview'), + [page, setPage] = useState( + new URLSearchParams(location.search).has('agent_authorization') || + new URLSearchParams(location.search).has('agent_operation') + ? 'settings' + : 'overview', + ), [positions, setPositions] = useState([]), [overview, setOverview] = useState(null), [rates, setRates] = useState([]), @@ -297,7 +303,12 @@ export default function App() { [clearConfirmation, setClearConfirmation] = useState(0); useToast(error, 'error'); useToast(success, 'success'); - const [settingsSection, setSettingsSection] = useState('general'), + const [settingsSection, setSettingsSection] = useState( + new URLSearchParams(location.search).has('agent_authorization') || + new URLSearchParams(location.search).has('agent_operation') + ? 'agent' + : 'general', + ), [transfers, setTransfers] = useState([]); const [historyRows, setHistoryRows] = useState([]), [historyCursor, setHistoryCursor] = useState(null), @@ -674,7 +685,11 @@ export default function App() { password: f.get('password'), }); if (session !== sessionGeneration.current) return; - setPage('overview'); + const agentReturn = + new URLSearchParams(location.search).has('agent_authorization') || + new URLSearchParams(location.search).has('agent_operation'); + setPage(agentReturn ? 'settings' : 'overview'); + if (agentReturn) setSettingsSection('agent'); setSelected(null); setModal(null); activityAt.current = Date.now(); @@ -1748,6 +1763,7 @@ export default function App() {