返回 AiToEarn
mcp-tools.handler.ts
根目录 / project / aitoearn-backend / libs / nest-mcp / src / services / handlers / mcp-tools.handler.ts
1 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2 import {
3 CallToolRequestSchema,
4 ErrorCode,
5 ListToolsRequestSchema,
6 McpError,
7 } from '@modelcontextprotocol/sdk/types.js'
8 import { Inject, Injectable, Scope } from '@nestjs/common'
9 import { ContextIdFactory, ModuleRef } from '@nestjs/core'
10 import { getErrorMessage, zodToJsonSchemaOptions } from '@yikart/common'
11 import { z, ZodTypeAny } from 'zod'
12 import { ResultTool } from '../../interfaces'
13 import { HttpRequest } from '../../interfaces/http-adapter.interface'
14 import { MCP_TOOL_MONITOR, McpToolMonitor } from '../../interfaces/mcp-tool-monitor.interface'
15 import { McpRegistryService } from '../mcp-registry.service'
16 import { McpHandlerBase } from './mcp-handler.base'
17
18 @Injectable({ scope: Scope.REQUEST })
19 export class McpToolsHandler extends McpHandlerBase {
20 constructor(
21 moduleRef: ModuleRef,
22 registry: McpRegistryService,
23 @Inject('MCP_MODULE_ID') private readonly mcpModuleId: string,
24 @Inject(MCP_TOOL_MONITOR) private readonly toolMonitor: McpToolMonitor,
25 ) {
26 super(moduleRef, registry, McpToolsHandler.name)
27 }
28
29 private buildDefaultContentBlock(result: any) {
30 return [
31 {
32 type: 'text',
33 text: JSON.stringify(result),
34 },
35 ]
36 }
37
38 private formatToolResult(result: any, outputSchema?: ZodTypeAny): any {
39 if (result && typeof result === 'object' && Array.isArray(result.content)) {
40 return result
41 }
42
43 if (outputSchema) {
44 const validation = outputSchema.safeParse(result)
45 if (!validation.success) {
46 throw new McpError(
47 ErrorCode.InternalError,
48 `Tool result does not match outputSchema: ${validation.error.message}`,
49 )
50 }
51 return {
52 structuredContent: result,
53 content: this.buildDefaultContentBlock(result),
54 }
55 }
56
57 return {
58 content: this.buildDefaultContentBlock(result),
59 }
60 }
61
62 registerHandlers(mcpServer: McpServer, httpRequest: HttpRequest) {
63 if (this.registry.getTools(this.mcpModuleId).length === 0) {
64 this.logger.debug('No tools registered, skipping tool handlers')
65 return
66 }
67
68 mcpServer.server.setRequestHandler(ListToolsRequestSchema, () => {
69 const tools = this.registry.getTools(this.mcpModuleId).map((tool) => {
70 // Create base schema
71 const toolSchema: Partial<ResultTool> = {
72 name: tool.metadata.name,
73 description: tool.metadata.description,
74 annotations: tool.metadata.annotations,
75 _meta: tool.metadata._meta,
76 }
77
78 // Add input schema if defined
79 if (tool.metadata.parameters) {
80 toolSchema['inputSchema'] = z.toJSONSchema(tool.metadata.parameters, {
81 ...zodToJsonSchemaOptions,
82 io: 'input',
83 }) as ResultTool['inputSchema']
84 }
85
86 // Add output schema if defined, ensuring it has type: 'object'
87 if (tool.metadata.outputSchema) {
88 const outputSchema = z.toJSONSchema(tool.metadata.outputSchema, {
89 ...zodToJsonSchemaOptions,
90 io: 'output',
91 })
92
93 // Create a new object that explicitly includes type: 'object'
94 const jsonSchema = {
95 ...outputSchema,
96 type: 'object',
97 }
98
99 toolSchema['outputSchema'] = jsonSchema as ResultTool['outputSchema']
100 }
101
102 return toolSchema
103 })
104
105 return {
106 tools,
107 }
108 })
109
110 mcpServer.server.setRequestHandler(
111 CallToolRequestSchema,
112 async (request) => {
113 this.logger.debug('CallToolRequestSchema is being called')
114
115 const toolInfo = this.registry.findTool(
116 this.mcpModuleId,
117 request.params.name,
118 )
119
120 if (!toolInfo) {
121 throw new McpError(
122 ErrorCode.MethodNotFound,
123 `Unknown tool: ${request.params.name}`,
124 )
125 }
126
127 try {
128 // Validate input parameters against the tool's schema
129 if (toolInfo.metadata.parameters) {
130 const validation = toolInfo.metadata.parameters.safeParse(
131 request.params.arguments || {},
132 )
133 if (!validation.success) {
134 throw new McpError(
135 ErrorCode.InvalidParams,
136 `Invalid parameters: ${validation.error.message}`,
137 )
138 }
139 // Use validated arguments to ensure defaults and transformations are applied
140 request.params.arguments = validation.data
141 }
142
143 const contextId = ContextIdFactory.getByRequest(httpRequest)
144 this.moduleRef.registerRequestByContextId(httpRequest, contextId)
145
146 const toolInstance = await this.moduleRef.resolve(
147 toolInfo.providerClass,
148 contextId,
149 { strict: false },
150 )
151
152 const context = this.createContext(mcpServer, request)
153
154 if (!toolInstance) {
155 throw new McpError(
156 ErrorCode.MethodNotFound,
157 `Unknown tool: ${request.params.name}`,
158 )
159 }
160 this.logger.debug({ toolInfo, request })
161
162 const result = await toolInstance[toolInfo.methodName](
163 request.params.arguments,
164 context,
165 httpRequest.raw,
166 )
167
168 const transformedResult = this.formatToolResult(
169 result,
170 toolInfo.metadata.outputSchema,
171 )
172
173 this.logger.debug(transformedResult, 'CallToolRequestSchema result')
174
175 try {
176 await this.toolMonitor.onToolSuccess(request.params.name)
177 }
178 catch (e) {
179 this.logger.error(e, 'Failed to record tool monitor status')
180 }
181
182 return transformedResult
183 }
184 catch (error) {
185 this.logger.error(error)
186
187 try {
188 await this.toolMonitor.onToolError(request.params.name, error)
189 }
190 catch {}
191
192 // Re-throw McpErrors (like validation errors) so they are handled by the MCP protocol layer
193 if (error instanceof McpError) {
194 throw error
195 }
196 const errorMessage = getErrorMessage(error)
197 // For other errors, return formatted error response
198 return {
199 content: [{ type: 'text', text: errorMessage }],
200 isError: true,
201 }
202 }
203 },
204 )
205 }
206 }
207
207 lines TYPESCRIPT