返回 AiToEarn
mcp-registry.service.ts
根目录 / project / aitoearn-backend / libs / nest-mcp / src / services / mcp-registry.service.ts
1 import {
2 Injectable,
3 InjectionToken,
4 Logger,
5 OnApplicationBootstrap,
6 } from '@nestjs/common'
7 import {
8 DiscoveryService,
9 MetadataScanner,
10 ModulesContainer,
11 } from '@nestjs/core'
12 import { Module } from '@nestjs/core/injector/module'
13 import { match } from 'path-to-regexp'
14 import {
15 MCP_PROMPT_METADATA_KEY,
16 MCP_RESOURCE_METADATA_KEY,
17 MCP_RESOURCE_TEMPLATE_METADATA_KEY,
18 MCP_TOOL_METADATA_KEY,
19 ToolMetadata,
20 } from '../decorators'
21 import { PromptMetadata } from '../decorators/prompt.decorator'
22 import { ResourceTemplateMetadata } from '../decorators/resource-template.decorator'
23 import { ResourceMetadata } from '../decorators/resource.decorator'
24
25 /**
26 * Interface representing a discovered tool
27 */
28 export interface DiscoveredTool<T extends object> {
29 type: 'tool' | 'resource' | 'resource-template' | 'prompt'
30 metadata: T
31 providerClass: InjectionToken
32 methodName: string
33 }
34
35 export type InjectionTokenWithName = InjectionToken & { name: string }
36
37 /**
38 * Singleton service that discovers and registers tools during application bootstrap
39 */
40 @Injectable()
41 export class McpRegistryService implements OnApplicationBootstrap {
42 private readonly logger = new Logger(McpRegistryService.name)
43 private discoveredToolsByMcpModuleId: Map<string, DiscoveredTool<any>[]>
44 = new Map()
45
46 constructor(
47 private readonly discovery: DiscoveryService,
48 private readonly metadataScanner: MetadataScanner,
49 private readonly modulesContainer: ModulesContainer,
50 ) {}
51
52 onApplicationBootstrap() {
53 this.discoverTools()
54 }
55
56 /**
57 * Finds all modules that import the McpModule and then scans the providers and controllers in their subtrees
58 */
59 private discoverTools() {
60 const getImportedMcpModules = (module: Module) =>
61 Array.from(module.imports).filter(
62 m => (m.instance as unknown as Record<string, unknown>)['__isMcpModule'],
63 )
64
65 const pairs = Array.from(this.modulesContainer.values())
66 .map((module): [Module, Module[]] => [
67 module,
68 getImportedMcpModules(module),
69 ])
70 .filter(([, importedMcpModules]) => importedMcpModules.length > 0)
71
72 for (const [rootModule, mcpModules] of pairs) {
73 this.logger.debug(
74 `Discovering tools, resources, resource templates, and prompts for module: ${rootModule.name}`,
75 )
76
77 const subtreeModules = this.collectSubtreeModules(rootModule)
78
79 for (const mcpModule of mcpModules) {
80 const mcpModuleId
81 = mcpModule.getProviderByKey<string>('MCP_MODULE_ID')?.instance
82 this.discoverToolsForModuleSubtree(mcpModuleId, subtreeModules)
83 }
84 }
85 }
86
87 private collectSubtreeModules(root: Module): Module[] {
88 const subtreeModules: Module[] = []
89 const collect = (module: Module) => {
90 subtreeModules.push(module)
91 module.imports.forEach((importedModule) => {
92 if (!subtreeModules.includes(importedModule)) {
93 collect(importedModule)
94 }
95 })
96 }
97 collect(root)
98 return subtreeModules
99 }
100
101 /**
102 * Scans all providers and controllers for @Tool decorators
103 */
104 private discoverToolsForModuleSubtree(
105 mcpModuleId: string,
106 modules: Module[],
107 ) {
108 const providers = this.discovery.getProviders(undefined, modules)
109 const controllers = this.discovery.getControllers(undefined, modules)
110 const allInstances = [...providers, ...controllers]
111 .filter(
112 wrapper =>
113 wrapper.instance
114 && typeof wrapper.instance === 'object'
115 && wrapper.instance !== null,
116 )
117 .map(wrapper => ({
118 instance: wrapper.instance as Record<string, any>,
119 token: wrapper.token,
120 }))
121
122 allInstances.forEach(({ instance, token }) => {
123 this.metadataScanner.getAllMethodNames(instance).forEach((methodName) => {
124 const methodRef = instance[methodName] as object
125 const methodMetaKeys = Reflect.getOwnMetadataKeys(methodRef)
126
127 if (methodMetaKeys.includes(MCP_TOOL_METADATA_KEY)) {
128 this.addDiscoveryTool(
129 mcpModuleId,
130 methodRef,
131 token as InjectionTokenWithName,
132 methodName,
133 )
134 }
135
136 if (methodMetaKeys.includes(MCP_RESOURCE_METADATA_KEY)) {
137 this.addDiscoveryResource(
138 mcpModuleId,
139 methodRef,
140 token as InjectionTokenWithName,
141 methodName,
142 )
143 }
144
145 if (methodMetaKeys.includes(MCP_RESOURCE_TEMPLATE_METADATA_KEY)) {
146 this.addDiscoveryResourceTemplate(
147 mcpModuleId,
148 methodRef,
149 token as InjectionTokenWithName,
150 methodName,
151 )
152 }
153
154 if (methodMetaKeys.includes(MCP_PROMPT_METADATA_KEY)) {
155 this.addDiscoveryPrompt(
156 mcpModuleId,
157 methodRef,
158 token as InjectionTokenWithName,
159 methodName,
160 )
161 }
162 })
163 })
164 }
165
166 /**
167 * Adds a discovered tool to the registry
168 */
169 private addDiscovery<T extends { name?: string }>(
170 type: 'tool' | 'resource' | 'resource-template' | 'prompt',
171 metadataKey: string,
172 mcpModuleId: string,
173 methodRef: object,
174 token: InjectionTokenWithName,
175 methodName: string,
176 ) {
177 const metadata: T = Reflect.getMetadata(metadataKey, methodRef)
178
179 if (!metadata['name']) {
180 metadata['name'] = methodName
181 }
182
183 if (!this.discoveredToolsByMcpModuleId.has(mcpModuleId)) {
184 this.discoveredToolsByMcpModuleId.set(mcpModuleId, [])
185 }
186
187 this.discoveredToolsByMcpModuleId.get(mcpModuleId)?.push({
188 type,
189 metadata,
190 providerClass: token,
191 methodName,
192 })
193 }
194
195 private addDiscoveryPrompt(
196 mcpModuleId: string,
197 methodRef: object,
198 token: InjectionTokenWithName,
199 methodName: string,
200 ) {
201 this.logger.debug(
202 `Prompt discovered: ${token.name}.${methodName} in module: ${mcpModuleId}`,
203 )
204 this.addDiscovery<PromptMetadata>(
205 'prompt',
206 MCP_PROMPT_METADATA_KEY,
207 mcpModuleId,
208 methodRef,
209 token,
210 methodName,
211 )
212 }
213
214 private addDiscoveryTool(
215 mcpModuleId: string,
216 methodRef: object,
217 token: InjectionTokenWithName,
218 methodName: string,
219 ) {
220 this.logger.debug(
221 `Tool discovered: ${token.name}.${methodName} in module: ${mcpModuleId}`,
222 )
223 this.addDiscovery<ToolMetadata>(
224 'tool',
225 MCP_TOOL_METADATA_KEY,
226 mcpModuleId,
227 methodRef,
228 token,
229 methodName,
230 )
231 }
232
233 private addDiscoveryResource(
234 mcpModuleId: string,
235 methodRef: object,
236 token: InjectionTokenWithName,
237 methodName: string,
238 ) {
239 this.logger.debug(
240 `Resource discovered: ${token.name}.${methodName} in module: ${mcpModuleId}`,
241 )
242 this.addDiscovery<ResourceMetadata>(
243 'resource',
244 MCP_RESOURCE_METADATA_KEY,
245 mcpModuleId,
246 methodRef,
247 token,
248 methodName,
249 )
250 }
251
252 private addDiscoveryResourceTemplate(
253 mcpModuleId: string,
254 methodRef: object,
255 token: InjectionTokenWithName,
256 methodName: string,
257 ) {
258 this.logger.debug(
259 `Resource Template discovered: ${token.name}.${methodName} in module: ${mcpModuleId}`,
260 )
261 this.addDiscovery<ResourceTemplateMetadata>(
262 'resource-template',
263 MCP_RESOURCE_TEMPLATE_METADATA_KEY,
264 mcpModuleId,
265 methodRef,
266 token,
267 methodName,
268 )
269 }
270
271 /**
272 * Return all discovered MCP module IDs
273 */
274 getMcpModuleIds(): string[] {
275 return Array.from(this.discoveredToolsByMcpModuleId.keys())
276 }
277
278 /**
279 * Get all discovered tools
280 */
281 getTools(mcpModuleId: string): DiscoveredTool<ToolMetadata>[] {
282 return (
283 this.discoveredToolsByMcpModuleId
284 .get(mcpModuleId)
285 ?.filter(tool => tool.type === 'tool') ?? []
286 )
287 }
288
289 /**
290 * Find a tool by name
291 */
292 findTool(
293 mcpModuleId: string,
294 name: string,
295 ): DiscoveredTool<ToolMetadata> | undefined {
296 return this.getTools(mcpModuleId).find(
297 tool => tool.metadata.name === name,
298 )
299 }
300
301 /**
302 * Get all discovered resources
303 */
304 getResources(mcpModuleId: string): DiscoveredTool<ResourceMetadata>[] {
305 return (
306 this.discoveredToolsByMcpModuleId
307 .get(mcpModuleId)
308 ?.filter(tool => tool.type === 'resource') ?? []
309 )
310 }
311
312 /**
313 * Find a resource by name
314 */
315 findResource(
316 mcpModuleId: string,
317 name: string,
318 ): DiscoveredTool<ResourceMetadata> | undefined {
319 return this.getResources(mcpModuleId).find(
320 tool => tool.metadata.name === name,
321 )
322 }
323
324 /**
325 * Get all discovered resource templates
326 */
327 getResourceTemplates(
328 mcpModuleId: string,
329 ): DiscoveredTool<ResourceTemplateMetadata>[] {
330 return (
331 this.discoveredToolsByMcpModuleId
332 .get(mcpModuleId)
333 ?.filter(tool => tool.type === 'resource-template') ?? []
334 )
335 }
336
337 /**
338 * Find a resource by name
339 */
340 findResourceTemplate(
341 mcpModuleId: string,
342 name: string,
343 ): DiscoveredTool<ResourceTemplateMetadata> | undefined {
344 return this.getResourceTemplates(mcpModuleId).find(
345 tool => tool.metadata.name === name,
346 )
347 }
348
349 /**
350 * Get all discovered prompts
351 */
352 getPrompts(mcpModuleId: string): DiscoveredTool<PromptMetadata>[] {
353 return (
354 this.discoveredToolsByMcpModuleId
355 .get(mcpModuleId)
356 ?.filter(tool => tool.type === 'prompt') ?? []
357 )
358 }
359
360 /**
361 * Find a prompt by name
362 */
363 findPrompt(
364 mcpModuleId: string,
365 name: string,
366 ): DiscoveredTool<PromptMetadata> | undefined {
367 return this.getPrompts(mcpModuleId).find(
368 tool => tool.metadata.name === name,
369 )
370 }
371
372 private convertTemplate(template: string): string {
373 return template?.replace(/\{(\w+)\}/g, ':$1')
374 }
375
376 private convertUri(uri: string): string {
377 if (uri.includes('://')) {
378 return uri.split('://')[1]
379 }
380
381 return uri
382 }
383
384 /**
385 * Find a resource by uri
386 * @returns An object containing the found resource and extracted parameters, or undefined if no resource is found
387 */
388 findResourceByUri(
389 mcpModuleId: string,
390 uri: string,
391 ):
392 | {
393 resource: DiscoveredTool<ResourceMetadata>
394 params: Record<string, string>
395 }
396 | undefined {
397 const resources = this.getResources(mcpModuleId).map(tool => ({
398 name: tool.metadata.name,
399 uri: tool.metadata.uri,
400 }))
401
402 const strippedInputUri = this.convertUri(uri)
403
404 for (const t of resources) {
405 if (!t.uri)
406 continue
407
408 const rawTemplate = t.uri
409 const templatePath = this.convertTemplate(this.convertUri(rawTemplate))
410 const matcher = match(templatePath, { decode: decodeURIComponent })
411 const result = matcher(strippedInputUri)
412
413 if (result) {
414 const foundResource = this.findResource(mcpModuleId, t.name)
415 if (!foundResource)
416 continue
417
418 return {
419 resource: foundResource,
420 params: result.params as Record<string, string>,
421 }
422 }
423 }
424
425 return undefined
426 }
427
428 /**
429 * Find a resource template by uri
430 * @returns An object containing the found resource template and extracted parameters, or undefined if no resource template is found
431 */
432 findResourceTemplateByUri(
433 mcpModuleId: string,
434 uri: string,
435 ):
436 | {
437 resourceTemplate: DiscoveredTool<ResourceTemplateMetadata>
438 params: Record<string, string>
439 }
440 | undefined {
441 const resourceTemplates = this.getResourceTemplates(mcpModuleId).map(
442 tool => ({
443 name: tool.metadata.name,
444 uriTemplate: tool.metadata.uriTemplate,
445 }),
446 )
447
448 const strippedInputUri = this.convertUri(uri)
449
450 for (const t of resourceTemplates) {
451 if (!t.uriTemplate)
452 continue
453
454 const rawTemplate = t.uriTemplate
455 const templatePath = this.convertTemplate(this.convertUri(rawTemplate))
456 const matcher = match(templatePath, { decode: decodeURIComponent })
457 const result = matcher(strippedInputUri)
458
459 if (result) {
460 const foundResourceTemplate = this.findResourceTemplate(
461 mcpModuleId,
462 t.name,
463 )
464 if (!foundResourceTemplate)
465 continue
466
467 return {
468 resourceTemplate: foundResourceTemplate,
469 params: result.params as Record<string, string>,
470 }
471 }
472 }
473
474 return undefined
475 }
476 }
477
477 lines TYPESCRIPT