@malek0512/loopback4-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +124 -0
  2. package/dist/component.d.ts +8 -0
  3. package/dist/component.d.ts.map +1 -0
  4. package/dist/component.js +67 -0
  5. package/dist/component.js.map +1 -0
  6. package/dist/controllers/index.d.ts +2 -0
  7. package/dist/controllers/index.d.ts.map +1 -0
  8. package/dist/controllers/index.js +18 -0
  9. package/dist/controllers/index.js.map +1 -0
  10. package/dist/controllers/mcp.controller.d.ts +16 -0
  11. package/dist/controllers/mcp.controller.d.ts.map +1 -0
  12. package/dist/controllers/mcp.controller.js +66 -0
  13. package/dist/controllers/mcp.controller.js.map +1 -0
  14. package/dist/decorators/index.d.ts +2 -0
  15. package/dist/decorators/index.d.ts.map +1 -0
  16. package/dist/decorators/index.js +18 -0
  17. package/dist/decorators/index.js.map +1 -0
  18. package/dist/decorators/mcp-tool.decorator.d.ts +21 -0
  19. package/dist/decorators/mcp-tool.decorator.d.ts.map +1 -0
  20. package/dist/decorators/mcp-tool.decorator.js +30 -0
  21. package/dist/decorators/mcp-tool.decorator.js.map +1 -0
  22. package/dist/execution/context-manager.d.ts +14 -0
  23. package/dist/execution/context-manager.d.ts.map +1 -0
  24. package/dist/execution/context-manager.js +41 -0
  25. package/dist/execution/context-manager.js.map +1 -0
  26. package/dist/execution/index.d.ts +4 -0
  27. package/dist/execution/index.d.ts.map +1 -0
  28. package/dist/execution/index.js +20 -0
  29. package/dist/execution/index.js.map +1 -0
  30. package/dist/execution/result-formatter.d.ts +16 -0
  31. package/dist/execution/result-formatter.d.ts.map +1 -0
  32. package/dist/execution/result-formatter.js +102 -0
  33. package/dist/execution/result-formatter.js.map +1 -0
  34. package/dist/execution/tool-executor.d.ts +12 -0
  35. package/dist/execution/tool-executor.d.ts.map +1 -0
  36. package/dist/execution/tool-executor.js +72 -0
  37. package/dist/execution/tool-executor.js.map +1 -0
  38. package/dist/index.d.ts +6 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +25 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/observers/index.d.ts +2 -0
  43. package/dist/observers/index.d.ts.map +1 -0
  44. package/dist/observers/index.js +18 -0
  45. package/dist/observers/index.js.map +1 -0
  46. package/dist/observers/mcp-boot.observer.d.ts +9 -0
  47. package/dist/observers/mcp-boot.observer.d.ts.map +1 -0
  48. package/dist/observers/mcp-boot.observer.js +36 -0
  49. package/dist/observers/mcp-boot.observer.js.map +1 -0
  50. package/dist/protocol/index.d.ts +4 -0
  51. package/dist/protocol/index.d.ts.map +1 -0
  52. package/dist/protocol/index.js +20 -0
  53. package/dist/protocol/index.js.map +1 -0
  54. package/dist/protocol/json-rpc.handler.d.ts +10 -0
  55. package/dist/protocol/json-rpc.handler.d.ts.map +1 -0
  56. package/dist/protocol/json-rpc.handler.js +63 -0
  57. package/dist/protocol/json-rpc.handler.js.map +1 -0
  58. package/dist/protocol/message-router.d.ts +18 -0
  59. package/dist/protocol/message-router.d.ts.map +1 -0
  60. package/dist/protocol/message-router.js +106 -0
  61. package/dist/protocol/message-router.js.map +1 -0
  62. package/dist/protocol/types.d.ts +227 -0
  63. package/dist/protocol/types.d.ts.map +1 -0
  64. package/dist/protocol/types.js +56 -0
  65. package/dist/protocol/types.js.map +1 -0
  66. package/dist/registry/index.d.ts +4 -0
  67. package/dist/registry/index.d.ts.map +1 -0
  68. package/dist/registry/index.js +20 -0
  69. package/dist/registry/index.js.map +1 -0
  70. package/dist/registry/metadata-scanner.d.ts +13 -0
  71. package/dist/registry/metadata-scanner.d.ts.map +1 -0
  72. package/dist/registry/metadata-scanner.js +101 -0
  73. package/dist/registry/metadata-scanner.js.map +1 -0
  74. package/dist/registry/schema-generator.d.ts +19 -0
  75. package/dist/registry/schema-generator.d.ts.map +1 -0
  76. package/dist/registry/schema-generator.js +90 -0
  77. package/dist/registry/schema-generator.js.map +1 -0
  78. package/dist/registry/tool-registry.d.ts +23 -0
  79. package/dist/registry/tool-registry.d.ts.map +1 -0
  80. package/dist/registry/tool-registry.js +58 -0
  81. package/dist/registry/tool-registry.js.map +1 -0
  82. package/dist/transport/index.d.ts +2 -0
  83. package/dist/transport/index.d.ts.map +1 -0
  84. package/dist/transport/index.js +18 -0
  85. package/dist/transport/index.js.map +1 -0
  86. package/dist/transport/sse.transport.d.ts +9 -0
  87. package/dist/transport/sse.transport.d.ts.map +1 -0
  88. package/dist/transport/sse.transport.js +67 -0
  89. package/dist/transport/sse.transport.js.map +1 -0
  90. package/package.json +26 -0
  91. package/src/component.ts +58 -0
  92. package/src/controllers/index.ts +1 -0
  93. package/src/controllers/mcp.controller.ts +42 -0
  94. package/src/decorators/index.ts +1 -0
  95. package/src/decorators/mcp-tool.decorator.ts +57 -0
  96. package/src/execution/context-manager.ts +44 -0
  97. package/src/execution/index.ts +3 -0
  98. package/src/execution/result-formatter.ts +104 -0
  99. package/src/execution/tool-executor.ts +61 -0
  100. package/src/index.ts +5 -0
  101. package/src/observers/index.ts +1 -0
  102. package/src/observers/mcp-boot.observer.ts +19 -0
  103. package/src/protocol/index.ts +3 -0
  104. package/src/protocol/json-rpc.handler.ts +77 -0
  105. package/src/protocol/message-router.ts +125 -0
  106. package/src/protocol/types.ts +309 -0
  107. package/src/registry/index.ts +3 -0
  108. package/src/registry/metadata-scanner.ts +105 -0
  109. package/src/registry/schema-generator.ts +82 -0
  110. package/src/registry/tool-registry.ts +57 -0
  111. package/src/transport/index.ts +1 -0
  112. package/src/transport/sse.transport.ts +56 -0
@@ -0,0 +1,77 @@
1
+ import {bind, BindingScope} from '@loopback/core';
2
+ import {
3
+ JsonRpcRequest,
4
+ JsonRpcResponse,
5
+ JsonRpcError,
6
+ McpErrorCode,
7
+ } from './types';
8
+
9
+ @bind({scope: BindingScope.SINGLETON})
10
+ export class JsonRpcHandler {
11
+ createResponse(id: string | number, result: unknown): JsonRpcResponse {
12
+ return {
13
+ jsonrpc: '2.0',
14
+ id,
15
+ result,
16
+ };
17
+ }
18
+
19
+ createErrorResponse(
20
+ id: string | number | undefined,
21
+ code: McpErrorCode,
22
+ message: string,
23
+ data?: unknown,
24
+ ): JsonRpcResponse {
25
+ return {
26
+ jsonrpc: '2.0',
27
+ id: id ?? (null as any),
28
+ error: {
29
+ code,
30
+ message,
31
+ data,
32
+ },
33
+ };
34
+ }
35
+
36
+ validateRequest(request: unknown): JsonRpcRequest {
37
+ if (!this.isValidRequest(request)) {
38
+ throw this.createError(
39
+ McpErrorCode.INVALID_REQUEST,
40
+ 'Invalid JSON-RPC request',
41
+ );
42
+ }
43
+ return request as JsonRpcRequest;
44
+ }
45
+
46
+ private isValidRequest(request: unknown): boolean {
47
+ if (typeof request !== 'object' || request === null) {
48
+ return false;
49
+ }
50
+
51
+ const req = request as any;
52
+
53
+ return (
54
+ req.jsonrpc === '2.0' &&
55
+ typeof req.method === 'string' &&
56
+ (req.id === undefined ||
57
+ typeof req.id === 'string' ||
58
+ typeof req.id === 'number')
59
+ );
60
+ }
61
+
62
+ createError(
63
+ code: McpErrorCode,
64
+ message: string,
65
+ data?: unknown,
66
+ ): JsonRpcError {
67
+ return {
68
+ code,
69
+ message,
70
+ data,
71
+ };
72
+ }
73
+
74
+ isNotification(request: JsonRpcRequest): boolean {
75
+ return request.id === undefined;
76
+ }
77
+ }
@@ -0,0 +1,125 @@
1
+ import {bind, BindingScope, inject} from '@loopback/core';
2
+ import {
3
+ JsonRpcRequest,
4
+ JsonRpcResponse,
5
+ McpMethod,
6
+ McpErrorCode,
7
+ InitializeRequest,
8
+ InitializeResult,
9
+ ToolsListRequest,
10
+ ToolsListResult,
11
+ ToolCallRequest,
12
+ ToolCallResult,
13
+ } from './types';
14
+ import {JsonRpcHandler} from './json-rpc.handler';
15
+ import {ToolRegistry} from '../registry/tool-registry';
16
+ import {ToolExecutor} from '../execution/tool-executor';
17
+
18
+ @bind({scope: BindingScope.SINGLETON})
19
+ export class MessageRouter {
20
+ private initialized = false;
21
+ private serverInfo = {
22
+ name: 'LoopBack MCP Server',
23
+ version: '1.0.0',
24
+ };
25
+
26
+ constructor(
27
+ @inject('mcp.jsonRpcHandler')
28
+ private jsonRpcHandler: JsonRpcHandler,
29
+ @inject('mcp.toolRegistry')
30
+ private toolRegistry: ToolRegistry,
31
+ @inject('mcp.toolExecutor')
32
+ private toolExecutor: ToolExecutor,
33
+ ) {}
34
+
35
+ async route(request: JsonRpcRequest): Promise<JsonRpcResponse> {
36
+ try {
37
+ this.jsonRpcHandler.validateRequest(request);
38
+
39
+ // Handle initialization separately
40
+ if (request.method === McpMethod.INITIALIZE) {
41
+ return await this.handleInitialize(request as InitializeRequest);
42
+ }
43
+
44
+ // Route to appropriate handler
45
+ switch (request.method) {
46
+ case McpMethod.TOOLS_LIST:
47
+ return await this.handleToolsList(request as ToolsListRequest);
48
+
49
+ case McpMethod.TOOLS_CALL:
50
+ return await this.handleToolCall(request as ToolCallRequest);
51
+
52
+ default:
53
+ return this.jsonRpcHandler.createErrorResponse(
54
+ request.id!,
55
+ McpErrorCode.METHOD_NOT_FOUND,
56
+ `Method '${request.method}' not found`,
57
+ );
58
+ }
59
+ } catch (error) {
60
+ return this.handleError(request.id, error);
61
+ }
62
+ }
63
+
64
+ private async handleInitialize(
65
+ request: InitializeRequest,
66
+ ): Promise<JsonRpcResponse> {
67
+ const result: InitializeResult = {
68
+ protocolVersion: '2024-11-05',
69
+ capabilities: {
70
+ tools: {
71
+ listChanged: true,
72
+ },
73
+ },
74
+ serverInfo: this.serverInfo,
75
+ };
76
+
77
+ this.initialized = true;
78
+
79
+ return this.jsonRpcHandler.createResponse(request.id!, result);
80
+ }
81
+
82
+ private async handleToolsList(
83
+ request: ToolsListRequest,
84
+ ): Promise<JsonRpcResponse> {
85
+ const tools = this.toolRegistry.list();
86
+
87
+ const result: ToolsListResult = {
88
+ tools,
89
+ };
90
+
91
+ return this.jsonRpcHandler.createResponse(request.id!, result);
92
+ }
93
+
94
+ private async handleToolCall(
95
+ request: ToolCallRequest,
96
+ ): Promise<JsonRpcResponse> {
97
+ const {name, arguments: args} = request.params;
98
+
99
+ const result = await this.toolExecutor.execute(name, args || {});
100
+
101
+ return this.jsonRpcHandler.createResponse(request.id!, result);
102
+ }
103
+
104
+ private handleError(
105
+ id: string | number | undefined,
106
+ error: any,
107
+ ): JsonRpcResponse {
108
+ let code = McpErrorCode.INTERNAL_ERROR;
109
+ let message = 'Internal server error';
110
+ let data: unknown;
111
+
112
+ if (error.code && typeof error.code === 'number') {
113
+ code = error.code;
114
+ message = error.message || message;
115
+ data = error.data;
116
+ } else if (error instanceof Error) {
117
+ message = error.message;
118
+ data = {
119
+ stack: error.stack,
120
+ };
121
+ }
122
+
123
+ return this.jsonRpcHandler.createErrorResponse(id, code, message, data);
124
+ }
125
+ }
@@ -0,0 +1,309 @@
1
+ // ============================================================================
2
+ // JSON-RPC 2.0 Base Types
3
+ // ============================================================================
4
+
5
+ export interface JsonRpcRequest {
6
+ jsonrpc: '2.0';
7
+ id?: string | number;
8
+ method: string;
9
+ params?: Record<string, unknown>;
10
+ }
11
+
12
+ export interface JsonRpcResponse {
13
+ jsonrpc: '2.0';
14
+ id: string | number;
15
+ result?: unknown;
16
+ error?: JsonRpcError;
17
+ }
18
+
19
+ export interface JsonRpcError {
20
+ code: number;
21
+ message: string;
22
+ data?: unknown;
23
+ }
24
+
25
+ export interface JsonRpcNotification {
26
+ jsonrpc: '2.0';
27
+ method: string;
28
+ params?: Record<string, unknown>;
29
+ }
30
+
31
+ // ============================================================================
32
+ // MCP Protocol Messages
33
+ // ============================================================================
34
+
35
+ export enum McpMethod {
36
+ // Initialization
37
+ INITIALIZE = 'initialize',
38
+ INITIALIZED = 'initialized',
39
+
40
+ // Tools
41
+ TOOLS_LIST = 'tools/list',
42
+ TOOLS_CALL = 'tools/call',
43
+
44
+ // Resources
45
+ RESOURCES_LIST = 'resources/list',
46
+ RESOURCES_READ = 'resources/read',
47
+ RESOURCES_SUBSCRIBE = 'resources/subscribe',
48
+ RESOURCES_UNSUBSCRIBE = 'resources/unsubscribe',
49
+
50
+ // Prompts
51
+ PROMPTS_LIST = 'prompts/list',
52
+ PROMPTS_GET = 'prompts/get',
53
+
54
+ // Logging
55
+ LOGGING_SET_LEVEL = 'logging/setLevel',
56
+
57
+ // Notifications
58
+ NOTIFICATION_CANCELLED = 'notifications/cancelled',
59
+ NOTIFICATION_PROGRESS = 'notifications/progress',
60
+ NOTIFICATION_MESSAGE = 'notifications/message',
61
+ NOTIFICATION_RESOURCES_UPDATED = 'notifications/resources/updated',
62
+ NOTIFICATION_RESOURCES_LIST_CHANGED = 'notifications/resources/list_changed',
63
+ NOTIFICATION_TOOLS_LIST_CHANGED = 'notifications/tools/list_changed',
64
+ NOTIFICATION_PROMPTS_LIST_CHANGED = 'notifications/prompts/list_changed',
65
+ }
66
+
67
+ // ============================================================================
68
+ // Initialize
69
+ // ============================================================================
70
+
71
+ export interface InitializeRequest extends JsonRpcRequest {
72
+ method: McpMethod.INITIALIZE;
73
+ params: {
74
+ protocolVersion: string;
75
+ capabilities: ClientCapabilities;
76
+ clientInfo: Implementation;
77
+ };
78
+ }
79
+
80
+ export interface InitializeResult {
81
+ protocolVersion: string;
82
+ capabilities: ServerCapabilities;
83
+ serverInfo: Implementation;
84
+ }
85
+
86
+ export interface Implementation {
87
+ name: string;
88
+ version: string;
89
+ }
90
+
91
+ export interface ClientCapabilities {
92
+ experimental?: Record<string, unknown>;
93
+ sampling?: Record<string, unknown>;
94
+ roots?: {
95
+ listChanged?: boolean;
96
+ };
97
+ }
98
+
99
+ export interface ServerCapabilities {
100
+ experimental?: Record<string, unknown>;
101
+ logging?: Record<string, unknown>;
102
+ prompts?: {
103
+ listChanged?: boolean;
104
+ };
105
+ resources?: {
106
+ subscribe?: boolean;
107
+ listChanged?: boolean;
108
+ };
109
+ tools?: {
110
+ listChanged?: boolean;
111
+ };
112
+ }
113
+
114
+ // ============================================================================
115
+ // Tools
116
+ // ============================================================================
117
+
118
+ export interface Tool {
119
+ name: string;
120
+ description?: string;
121
+ inputSchema: {
122
+ type: 'object';
123
+ properties?: Record<string, JsonSchema>;
124
+ required?: string[];
125
+ additionalProperties?: boolean;
126
+ };
127
+ }
128
+
129
+ export interface ToolsListRequest extends JsonRpcRequest {
130
+ method: McpMethod.TOOLS_LIST;
131
+ params?: {
132
+ cursor?: string;
133
+ };
134
+ }
135
+
136
+ export interface ToolsListResult {
137
+ tools: Tool[];
138
+ nextCursor?: string;
139
+ }
140
+
141
+ export interface ToolCallRequest extends JsonRpcRequest {
142
+ method: McpMethod.TOOLS_CALL;
143
+ params: {
144
+ name: string;
145
+ arguments?: Record<string, unknown>;
146
+ };
147
+ }
148
+
149
+ export interface ToolCallResult {
150
+ content: Content[];
151
+ isError?: boolean;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Content Types
156
+ // ============================================================================
157
+
158
+ export type Content = TextContent | ImageContent | EmbeddedResource;
159
+
160
+ export interface TextContent {
161
+ type: 'text';
162
+ text: string;
163
+ }
164
+
165
+ export interface ImageContent {
166
+ type: 'image';
167
+ data: string; // base64
168
+ mimeType: string;
169
+ }
170
+
171
+ export interface EmbeddedResource {
172
+ type: 'resource';
173
+ resource: ResourceContents;
174
+ }
175
+
176
+ // ============================================================================
177
+ // Resources
178
+ // ============================================================================
179
+
180
+ export interface Resource {
181
+ uri: string;
182
+ name: string;
183
+ description?: string;
184
+ mimeType?: string;
185
+ }
186
+
187
+ export interface ResourcesListRequest extends JsonRpcRequest {
188
+ method: McpMethod.RESOURCES_LIST;
189
+ params?: {
190
+ cursor?: string;
191
+ };
192
+ }
193
+
194
+ export interface ResourcesListResult {
195
+ resources: Resource[];
196
+ nextCursor?: string;
197
+ }
198
+
199
+ export interface ResourceReadRequest extends JsonRpcRequest {
200
+ method: McpMethod.RESOURCES_READ;
201
+ params: {
202
+ uri: string;
203
+ };
204
+ }
205
+
206
+ export interface ResourceContents {
207
+ uri: string;
208
+ mimeType?: string;
209
+ text?: string;
210
+ blob?: string; // base64
211
+ }
212
+
213
+ export interface ResourceReadResult {
214
+ contents: ResourceContents[];
215
+ }
216
+
217
+ // ============================================================================
218
+ // Prompts
219
+ // ============================================================================
220
+
221
+ export interface Prompt {
222
+ name: string;
223
+ description?: string;
224
+ arguments?: PromptArgument[];
225
+ }
226
+
227
+ export interface PromptArgument {
228
+ name: string;
229
+ description?: string;
230
+ required?: boolean;
231
+ }
232
+
233
+ export interface PromptsListRequest extends JsonRpcRequest {
234
+ method: McpMethod.PROMPTS_LIST;
235
+ params?: {
236
+ cursor?: string;
237
+ };
238
+ }
239
+
240
+ export interface PromptsListResult {
241
+ prompts: Prompt[];
242
+ nextCursor?: string;
243
+ }
244
+
245
+ export interface PromptGetRequest extends JsonRpcRequest {
246
+ method: McpMethod.PROMPTS_GET;
247
+ params: {
248
+ name: string;
249
+ arguments?: Record<string, string>;
250
+ };
251
+ }
252
+
253
+ export interface PromptMessage {
254
+ role: 'user' | 'assistant';
255
+ content: Content;
256
+ }
257
+
258
+ export interface PromptGetResult {
259
+ description?: string;
260
+ messages: PromptMessage[];
261
+ }
262
+
263
+ // ============================================================================
264
+ // JSON Schema
265
+ // ============================================================================
266
+
267
+ export interface JsonSchema {
268
+ type?: string | string[];
269
+ properties?: Record<string, JsonSchema>;
270
+ items?: JsonSchema | JsonSchema[];
271
+ required?: string[];
272
+ enum?: unknown[];
273
+ const?: unknown;
274
+ description?: string;
275
+ default?: unknown;
276
+ examples?: unknown[];
277
+ format?: string;
278
+ pattern?: string;
279
+ minimum?: number;
280
+ maximum?: number;
281
+ minLength?: number;
282
+ maxLength?: number;
283
+ minItems?: number;
284
+ maxItems?: number;
285
+ uniqueItems?: boolean;
286
+ additionalProperties?: boolean | JsonSchema;
287
+ [key: string]: unknown;
288
+ }
289
+
290
+ // ============================================================================
291
+ // Error Codes
292
+ // ============================================================================
293
+
294
+ export enum McpErrorCode {
295
+ // Standard JSON-RPC errors
296
+ PARSE_ERROR = -32700,
297
+ INVALID_REQUEST = -32600,
298
+ METHOD_NOT_FOUND = -32601,
299
+ INVALID_PARAMS = -32602,
300
+ INTERNAL_ERROR = -32603,
301
+
302
+ // MCP-specific errors
303
+ TOOL_NOT_FOUND = -32001,
304
+ TOOL_EXECUTION_ERROR = -32002,
305
+ RESOURCE_NOT_FOUND = -32003,
306
+ RESOURCE_ACCESS_DENIED = -32004,
307
+ PROMPT_NOT_FOUND = -32005,
308
+ INVALID_TOOL_ARGUMENTS = -32006,
309
+ }
@@ -0,0 +1,3 @@
1
+ export * from './tool-registry';
2
+ export * from './metadata-scanner';
3
+ export * from './schema-generator';
@@ -0,0 +1,105 @@
1
+ import {bind, BindingScope, inject, Application} from '@loopback/core';
2
+ import {ToolRegistry, ToolDescriptor} from './tool-registry';
3
+ import {McpToolMetadata, MCP_TOOL_METADATA_KEY} from '../decorators';
4
+ import {SchemaGenerator} from './schema-generator';
5
+ import {MetadataInspector} from '@loopback/core';
6
+
7
+ @bind({scope: BindingScope.SINGLETON})
8
+ export class MetadataScanner {
9
+ constructor(
10
+ @inject('mcp.toolRegistry')
11
+ private toolRegistry: ToolRegistry,
12
+ @inject('mcp.schemaGenerator')
13
+ private schemaGenerator: SchemaGenerator,
14
+ @inject.context()
15
+ private app: Application,
16
+ ) {}
17
+
18
+ async scan(): Promise<void> {
19
+ const controllerBindings = this.app.findByTag('controller');
20
+ for (const binding of controllerBindings) {
21
+ const controllerClass = binding.valueConstructor;
22
+ if (controllerClass) {
23
+ await this.scanControllerClass(controllerClass);
24
+ }
25
+ }
26
+ }
27
+
28
+ private async scanControllerClass(controllerClass: Function): Promise<void> {
29
+ try {
30
+ const proto = controllerClass.prototype;
31
+ const methodNames = Object.getOwnPropertyNames(proto).filter(
32
+ name => typeof proto[name] === 'function' && name !== 'constructor',
33
+ );
34
+
35
+ for (const methodName of methodNames) {
36
+ const metadata = MetadataInspector.getMethodMetadata<McpToolMetadata>(
37
+ MCP_TOOL_METADATA_KEY,
38
+ proto,
39
+ methodName,
40
+ );
41
+ if (metadata) {
42
+ await this.registerTool(controllerClass, methodName, metadata);
43
+ }
44
+ }
45
+ } catch (error) {
46
+ console.warn(`[MCP] Skipping controller during scan:`, error);
47
+ }
48
+ }
49
+
50
+ private async registerTool(
51
+ controllerClass: Function,
52
+ methodName: string,
53
+ metadata: McpToolMetadata,
54
+ ): Promise<void> {
55
+ // Schema: use LoopBack's own spec generation (resolves $ref, always correct)
56
+ const inputSchema = metadata.inputSchema ?? this.schemaGenerator.generateForMethod(controllerClass, methodName);
57
+
58
+ // Handler arg mapping: read raw LoopBack parameter metadata set at class-definition time
59
+ const proto = controllerClass.prototype;
60
+ const allParamsMeta: Record<string, Array<{name: string; in: string} | null>> =
61
+ Reflect.getMetadata('loopback:openapi-v3:parameters', proto) ?? {};
62
+ const allReqBodyMeta: Record<string, Array<object | null>> =
63
+ Reflect.getMetadata('loopback:openapi-v3:request-body', proto) ?? {};
64
+
65
+ const methodParams = allParamsMeta[methodName] ?? [];
66
+ const methodReqBodies = allReqBodyMeta[methodName] ?? [];
67
+ const maxArgs = Math.max(methodParams.length, methodReqBodies.length);
68
+
69
+ const descriptor: ToolDescriptor = {
70
+ name: metadata.name,
71
+ description: metadata.description,
72
+ inputSchema,
73
+ controller: proto,
74
+ methodName,
75
+ handler: async (args: Record<string, unknown>) => {
76
+ const controller = await this.app.get(`controllers.${controllerClass.name}`);
77
+
78
+ if (maxArgs === 0) {
79
+ return (controller as any)[methodName](args);
80
+ }
81
+
82
+ const positionalArgs: unknown[] = [];
83
+ const usedKeys = new Set<string>();
84
+
85
+ for (let i = 0; i < maxArgs; i++) {
86
+ const param = methodParams[i];
87
+ const reqBody = methodReqBodies[i];
88
+
89
+ if (param) {
90
+ positionalArgs[i] = args[param.name];
91
+ usedKeys.add(param.name);
92
+ } else if (reqBody) {
93
+ positionalArgs[i] = Object.fromEntries(
94
+ Object.entries(args).filter(([k]) => !usedKeys.has(k)),
95
+ );
96
+ }
97
+ }
98
+
99
+ return (controller as any)[methodName](...positionalArgs);
100
+ },
101
+ };
102
+
103
+ this.toolRegistry.register(descriptor);
104
+ }
105
+ }
@@ -0,0 +1,82 @@
1
+ import {bind, BindingScope} from '@loopback/core';
2
+ import {getControllerSpec} from '@loopback/openapi-v3';
3
+ import {JsonSchema} from '../protocol/types';
4
+
5
+ @bind({scope: BindingScope.SINGLETON})
6
+ export class SchemaGenerator {
7
+ /**
8
+ * Build the MCP inputSchema for a controller method using LoopBack's
9
+ * own getControllerSpec(), so $ref resolution and parameter ordering
10
+ * are always correct regardless of decorator evaluation timing.
11
+ *
12
+ * Path params become required top-level properties.
13
+ * Request body properties are spread into the top-level object.
14
+ * Optional query params (filter, where…) are omitted — MCP tools
15
+ * accept them as part of the body object when passed.
16
+ */
17
+ generateForMethod(controllerClass: Function, methodName: string): JsonSchema {
18
+ const spec = getControllerSpec(controllerClass as any);
19
+ const components: Record<string, any> = spec.components?.schemas ?? {};
20
+
21
+ const operation = this.findOperation(spec, methodName);
22
+ if (!operation) {
23
+ return {type: 'object', properties: {}, additionalProperties: false};
24
+ }
25
+
26
+ const properties: Record<string, JsonSchema> = {};
27
+ const required: string[] = [];
28
+ const usedKeys = new Set<string>();
29
+
30
+ // Path parameters (required, strongly typed)
31
+ for (const param of (operation.parameters ?? []) as any[]) {
32
+ if (param.in !== 'path') continue;
33
+ const schema = this.resolveRef(param.schema ?? {type: 'string'}, components);
34
+ properties[param.name] = schema;
35
+ usedKeys.add(param.name);
36
+ required.push(param.name);
37
+ }
38
+
39
+ // Request body: spread its resolved properties into the top-level schema
40
+ const bodyContent = operation.requestBody?.content?.['application/json'];
41
+ if (bodyContent?.schema) {
42
+ const bodySchema = this.resolveRef(bodyContent.schema, components);
43
+ if (bodySchema.properties) {
44
+ for (const [key, val] of Object.entries(bodySchema.properties)) {
45
+ if (!usedKeys.has(key)) {
46
+ properties[key] = this.resolveRef(val as any, components);
47
+ }
48
+ }
49
+ for (const r of bodySchema.required ?? []) {
50
+ if (!required.includes(r)) required.push(r);
51
+ }
52
+ }
53
+ }
54
+
55
+ return {
56
+ type: 'object',
57
+ properties,
58
+ required: required.length > 0 ? required : undefined,
59
+ additionalProperties: false,
60
+ };
61
+ }
62
+
63
+ /** Resolve a $ref schema from components.schemas, or return it as-is. */
64
+ private resolveRef(schema: any, components: Record<string, any>): any {
65
+ if (!schema || typeof schema !== 'object') return schema;
66
+ if (!schema['$ref']) return schema;
67
+ const refName = (schema['$ref'] as string).split('/').pop()!;
68
+ return components[refName] ?? schema;
69
+ }
70
+
71
+ /** Find the OpenAPI operation matching a controller method by x-operation-name. */
72
+ private findOperation(spec: any, methodName: string): any {
73
+ for (const pathItem of Object.values(spec.paths ?? {})) {
74
+ for (const op of Object.values(pathItem as object)) {
75
+ if (op && (op as any)['x-operation-name'] === methodName) {
76
+ return op;
77
+ }
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ }