返回 AiToEarn
mcp.utils.ts
1 import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
2 import type { AiAvailabilityService } from '../../ai-availability'
3 import { createSdkMcpServer, InferShape, McpSdkServerConfigWithInstance, tool } from '@anthropic-ai/claude-agent-sdk'
4 import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5 import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
6 import { Logger } from '@nestjs/common'
7 import { AppException, getErrorMessage } from '@yikart/common'
8 import { z } from 'zod'
9
10 // ==================== SRT 时间戳工具函数 ====================
11
12 /**
13 * 将 SRT 时间戳转换为毫秒
14 * @param srtTimestamp SRT 格式时间戳 (HH:MM:SS,sss)
15 * @returns 毫秒数
16 */
17 export function srtTimestampToMs(srtTimestamp: string): number {
18 const [rest, millisecondsString] = srtTimestamp.split(',')
19 const milliseconds = Number.parseInt(millisecondsString)
20 const [hours, minutes, seconds] = rest.split(':').map(x => Number.parseInt(x))
21 const result = milliseconds * 0.001 + seconds + 60 * minutes + 3600 * hours
22
23 // fix odd JS roundings, e.g. timestamp '00:01:20,460' result is 80.46000000000001
24 return Math.round(result * 1000)
25 }
26
27 /**
28 * 将秒数转换为 SRT 时间戳格式
29 * @param seconds 秒数
30 * @returns SRT 格式时间戳 (HH:MM:SS,sss)
31 */
32 export function secondsToSrtTimestamp(seconds: number): string {
33 const hours = Math.floor(seconds / 3600)
34 const minutes = Math.floor((seconds % 3600) / 60)
35 const secs = Math.floor(seconds % 60)
36 const millis = Math.round((seconds % 1) * 1000)
37 return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${millis.toString().padStart(3, '0')}`
38 }
39
40 type ContentInput = string | unknown[] | Record<string, unknown>
41
42 /**
43 * 将输入内容转换为 CallToolResult 的 content 格式
44 */
45 function normalizeContent(input: ContentInput): CallToolResult['content'] {
46 if (typeof input === 'string') {
47 return [{ type: 'text', text: input }]
48 }
49 if (Array.isArray(input)) {
50 if (input.length > 0 && typeof input[0] === 'object' && input[0] !== null && 'type' in input[0]) {
51 return input as CallToolResult['content']
52 }
53 return [{ type: 'text', text: JSON.stringify(input) }]
54 }
55 if (typeof input === 'object' && input !== null) {
56 return [{ type: 'text', text: JSON.stringify(input) }]
57 }
58 return [{ type: 'text', text: String(input) }]
59 }
60
61 /**
62 * 创建成功结果
63 * @param content 内容,可以是字符串、数组或对象
64 */
65 export function successResult(content: ContentInput): CallToolResult {
66 return {
67 content: normalizeContent(content),
68 }
69 }
70
71 /**
72 * 创建错误结果
73 * @param message 错误消息,可以是字符串、数组或对象
74 */
75 export function errorResult(message: ContentInput): CallToolResult {
76 return {
77 content: normalizeContent(message),
78 isError: true,
79 }
80 }
81
82 /**
83 * 包装工具定义,自动添加日志、错误处理和可用性监控
84 * @param logger Logger 实例
85 * @param toolName 工具名称
86 * @param description 工具描述
87 * @param schema Zod Object schema 用于类型验证
88 * @param handler 业务逻辑处理器
89 * @param aiAvailability AI 可用性监控服务
90 * @returns tool 函数的返回值
91 */
92 export function wrapTool<T extends z.ZodRawShape>(
93 logger: Logger,
94 toolName: string,
95 description: string,
96 schema: T,
97 handler: (params: InferShape<T>) => Promise<CallToolResult>,
98 aiAvailability: AiAvailabilityService,
99 ) {
100 const availabilityContext = { provider: 'mcp', operation: toolName, module: 'agent' }
101
102 return tool(
103 toolName,
104 description,
105 schema,
106 async (params) => {
107 try {
108 const result = await aiAvailability.execute(
109 availabilityContext,
110 () => handler(params),
111 )
112 return result
113 }
114 catch (error) {
115 const errMessage = getErrorMessage(error)
116
117 if (error instanceof AppException) {
118 logger.warn({ toolName, code: error.code }, `Tool business error: ${errMessage}`)
119 return errorResult(errMessage)
120 }
121
122 logger.fatal({ toolName, params }, 'Tool handler error')
123 logger.fatal(error, `Tool handler error ${toolName}`)
124 return errorResult(errMessage)
125 }
126 },
127 )
128 }
129
130 // ==================== 格式化工具函数 ====================
131
132 /**
133 * 需要过滤的字段列表(时间戳和 MongoDB 元数据)
134 */
135 const FILTER_FIELDS = ['createdAt', 'updatedAt', 'deletedAt', '__v', '_id']
136
137 /**
138 * 过滤对象中的不必要字段
139 * @param obj 原始对象
140 * @param keepFields 保留的字段白名单(可选)
141 */
142 function filterFields<T extends Record<string, unknown>>(obj: T, keepFields?: string[]): Partial<T> {
143 if (!obj || typeof obj !== 'object')
144 return obj
145
146 const filtered: Partial<T> = {}
147
148 for (const [key, value] of Object.entries(obj)) {
149 if (keepFields && !keepFields.includes(key))
150 continue
151
152 if (!keepFields && FILTER_FIELDS.includes(key))
153 continue
154
155 filtered[key as keyof T] = value as T[keyof T]
156 }
157
158 return filtered
159 }
160
161 /**
162 * 将对象转换为 YAML 格式字符串(键值对形式)
163 * @param obj 对象
164 * @param keepFields 保留的字段白名单(可选)
165 */
166 export function formatObject<T extends Record<string, unknown>>(obj: T, keepFields?: string[]): string {
167 if (!obj)
168 return ''
169
170 const filtered = filterFields(obj, keepFields)
171 const lines: string[] = []
172
173 for (const [key, value] of Object.entries(filtered)) {
174 if (value === null || value === undefined)
175 continue
176
177 let formattedValue: string
178 if (typeof value === 'object' && !Array.isArray(value)) {
179 formattedValue = JSON.stringify(value)
180 }
181 else if (Array.isArray(value)) {
182 formattedValue = value.join(', ')
183 }
184 else {
185 formattedValue = String(value)
186 }
187
188 lines.push(`${key}: ${formattedValue}`)
189 }
190
191 return lines.join('\n')
192 }
193
194 /**
195 * 将数组转换为格式化列表
196 * @param list 数组
197 * @param formatter 每项的格式化函数
198 */
199 export function formatList<T>(list: T[], formatter?: (item: T, index: number) => string): string {
200 if (!list || list.length === 0)
201 return 'No data'
202
203 const lines: string[] = [`Total ${list.length}:`]
204
205 list.forEach((item, index) => {
206 const formatted = formatter ? formatter(item, index) : String(item)
207 lines.push(`${index + 1}. ${formatted}`)
208 })
209
210 return lines.join('\n')
211 }
212
213 // ==================== HTTP MCP 桥接 ====================
214
215 /**
216 * 创建 HTTP MCP 桥接服务器,将 HTTP MCP 转换为 SDK MCP
217 * @param name - MCP 服务器名称
218 * @param url - HTTP MCP 端点 URL
219 * @param headers - HTTP 请求头(用于认证)
220 * @returns SDK MCP Server 配置
221 */
222 export async function createHttpBridgeServer(
223 name: string,
224 url: string,
225 headers: Record<string, string>,
226 ): Promise<McpSdkServerConfigWithInstance> {
227 const transport = new StreamableHTTPClientTransport(new URL(url), {
228 requestInit: { headers },
229 })
230 const client = new Client(
231 { name, version: '1.0.0' },
232 { capabilities: {} },
233 )
234
235 await client.connect(transport)
236 const { tools } = await client.listTools()
237
238 const sdkTools = tools.map((mcpTool) => {
239 // @ts-expect-error - mcpTool.inputSchema 类型不完全匹配 z.fromJSONSchema 预期的 JSONSchema 类型
240 const zodSchema = z.fromJSONSchema(mcpTool.inputSchema)
241
242 // @ts-expect-error - zodSchema 可能是 ZodObject 或其他类型,我们需要提取 shape
243 const schema: z.ZodRawShape = zodSchema.shape || {}
244
245 return tool(
246 mcpTool.name,
247 mcpTool.description || '',
248 schema,
249 async (params) => {
250 const result = await client.callTool({
251 name: mcpTool.name,
252 arguments: params,
253 })
254 return result as CallToolResult
255 },
256 )
257 })
258
259 return createSdkMcpServer({
260 name,
261 version: '1.0.0',
262 tools: sdkTools,
263 })
264 }
265
265 lines TYPESCRIPT