loopback4-mcp 0.0.1
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.
- package/README.md +87 -0
- package/dist/component.d.ts +11 -0
- package/dist/component.js +22 -0
- package/dist/component.js.map +1 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.js +5 -0
- package/dist/constants/index.js.map +1 -0
- package/dist/constants/mcp-tool.constant.d.ts +1 -0
- package/dist/constants/mcp-tool.constant.js +6 -0
- package/dist/constants/mcp-tool.constant.js.map +1 -0
- package/dist/controllers/index.d.ts +1 -0
- package/dist/controllers/index.js +5 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/mcp.controller.d.ts +10 -0
- package/dist/controllers/mcp.controller.js +121 -0
- package/dist/controllers/mcp.controller.js.map +1 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +5 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/decorators/mcp-tool.decorator.d.ts +2 -0
- package/dist/decorators/mcp-tool.decorator.js +45 -0
- package/dist/decorators/mcp-tool.decorator.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/interfaces/index.js +5 -0
- package/dist/interfaces/index.js.map +1 -0
- package/dist/interfaces/mcp-hook-provider.interface.d.ts +12 -0
- package/dist/interfaces/mcp-hook-provider.interface.js +3 -0
- package/dist/interfaces/mcp-hook-provider.interface.js.map +1 -0
- package/dist/keys.d.ts +1 -0
- package/dist/keys.js +3 -0
- package/dist/keys.js.map +1 -0
- package/dist/observers/index.d.ts +1 -0
- package/dist/observers/index.js +5 -0
- package/dist/observers/index.js.map +1 -0
- package/dist/observers/mcp-tool-registry-boot.observer.d.ts +19 -0
- package/dist/observers/mcp-tool-registry-boot.observer.js +42 -0
- package/dist/observers/mcp-tool-registry-boot.observer.js.map +1 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +7 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/mcp-schema-generator-service.service.d.ts +20 -0
- package/dist/services/mcp-schema-generator-service.service.js +73 -0
- package/dist/services/mcp-schema-generator-service.service.js.map +1 -0
- package/dist/services/mcp-server-factory.service.d.ts +13 -0
- package/dist/services/mcp-server-factory.service.js +51 -0
- package/dist/services/mcp-server-factory.service.js.map +1 -0
- package/dist/services/mcp-tool-registry.service.d.ts +49 -0
- package/dist/services/mcp-tool-registry.service.js +245 -0
- package/dist/services/mcp-tool-registry.service.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/mcp-parameter-extractor.d.ts +9 -0
- package/dist/utils/mcp-parameter-extractor.js +51 -0
- package/dist/utils/mcp-parameter-extractor.js.map +1 -0
- package/package.json +139 -0
- package/src/component.ts +18 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/mcp-tool.constant.ts +2 -0
- package/src/controllers/README.md +6 -0
- package/src/controllers/index.ts +1 -0
- package/src/controllers/mcp.controller.ts +112 -0
- package/src/decorators/README.md +34 -0
- package/src/decorators/index.ts +1 -0
- package/src/decorators/mcp-tool.decorator.ts +68 -0
- package/src/index.ts +5 -0
- package/src/interfaces/index.ts +1 -0
- package/src/interfaces/mcp-hook-provider.interface.ts +11 -0
- package/src/keys.ts +1 -0
- package/src/mixins/README.md +216 -0
- package/src/observers/index.ts +1 -0
- package/src/observers/mcp-tool-registry-boot.observer.ts +40 -0
- package/src/providers/README.md +129 -0
- package/src/repositories/README.md +5 -0
- package/src/services/index.ts +3 -0
- package/src/services/mcp-schema-generator-service.service.ts +80 -0
- package/src/services/mcp-server-factory.service.ts +71 -0
- package/src/services/mcp-tool-registry.service.ts +368 -0
- package/src/types.ts +59 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/mcp-parameter-extractor.ts +67 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Providers
|
|
2
|
+
|
|
3
|
+
This directory contains providers contributing additional bindings, for example
|
|
4
|
+
custom sequence actions.
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A [provider](http://loopback.io/doc/en/lb4/Creating-components.html#providers)
|
|
9
|
+
is a class that provides a `value()` function. This function is called `Context`
|
|
10
|
+
when another entity requests a value to be injected.
|
|
11
|
+
|
|
12
|
+
Here we create a provider for a logging function that can be used as a new
|
|
13
|
+
action in a custom [sequence](http://loopback.io/doc/en/lb4/Sequence.html).
|
|
14
|
+
|
|
15
|
+
The logger will log the URL, the parsed request parameters, and the result. The
|
|
16
|
+
logger is also capable of timing the sequence if you start a timer at the start
|
|
17
|
+
of the sequence using `this.logger.startTimer()`.
|
|
18
|
+
|
|
19
|
+
## Basic Usage
|
|
20
|
+
|
|
21
|
+
### TimerProvider
|
|
22
|
+
|
|
23
|
+
TimerProvider is automatically bound to your Application's
|
|
24
|
+
[Context](http://loopback.io/doc/en/lb4/Context.html) using the LogComponent
|
|
25
|
+
which exports this provider with a binding key of `extension-starter.timer`. You
|
|
26
|
+
can learn more about components in the
|
|
27
|
+
[related resources section](#related-resources).
|
|
28
|
+
|
|
29
|
+
This provider makes available to your application a timer function which given a
|
|
30
|
+
start time _(given as an array [seconds, nanoseconds])_ can give you a total
|
|
31
|
+
time elapsed since the start in milliseconds. The timer can also start timing if
|
|
32
|
+
no start time is given. This is used by LogComponent to allow a user to time a
|
|
33
|
+
Sequence.
|
|
34
|
+
|
|
35
|
+
_NOTE:_ _You can get the start time in the required format by using
|
|
36
|
+
`this.logger.startTimer()`._
|
|
37
|
+
|
|
38
|
+
You can provide your own implementation of the elapsed time function by binding
|
|
39
|
+
it to the binding key (accessible via `ExtensionStarterBindings`) as follows:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
app.bind(ExtensionStarterBindings.TIMER).to(timerFn);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### LogProvider
|
|
46
|
+
|
|
47
|
+
LogProvider can automatically be bound to your Application's Context using the
|
|
48
|
+
LogComponent which exports the provider with a binding key of
|
|
49
|
+
`extension-starter.actions.log`.
|
|
50
|
+
|
|
51
|
+
The key can be accessed by importing `ExtensionStarterBindings` as follows:
|
|
52
|
+
|
|
53
|
+
**Example: Binding Keys**
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import {ExtensionStarterBindings} from 'HelloExtensions';
|
|
57
|
+
// Key can be accessed as follows now
|
|
58
|
+
const key = ExtensionStarterBindings.LOG_ACTION;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
LogProvider gives us a sequence action and a `startTimer` function. In order to
|
|
62
|
+
use the sequence action, you must define your own sequence as shown below.
|
|
63
|
+
|
|
64
|
+
**Example: Sequence**
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
class LogSequence implements SequenceHandler {
|
|
68
|
+
constructor(
|
|
69
|
+
@inject(coreSequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
|
|
70
|
+
@inject(coreSequenceActions.PARSE_PARAMS)
|
|
71
|
+
protected parseParams: ParseParams,
|
|
72
|
+
@inject(coreSequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
|
|
73
|
+
@inject(coreSequenceActions.SEND) protected send: Send,
|
|
74
|
+
@inject(coreSequenceActions.REJECT) protected reject: Reject,
|
|
75
|
+
// We get the logger injected by the LogProvider here
|
|
76
|
+
@inject(ExtensionStarterBindings.LOG_ACTION) protected logger: LogFn,
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
async handle(context: RequestContext) {
|
|
80
|
+
const {request, response} = context;
|
|
81
|
+
|
|
82
|
+
// We define these variable outside so they can be accessed by logger.
|
|
83
|
+
let args: any = [];
|
|
84
|
+
let result: any;
|
|
85
|
+
|
|
86
|
+
// Optionally start timing the sequence using the timer
|
|
87
|
+
// function available via LogFn
|
|
88
|
+
const start = this.logger.startTimer();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const route = this.findRoute(request);
|
|
92
|
+
args = await this.parseParams(request, route);
|
|
93
|
+
result = await this.invoke(route, args);
|
|
94
|
+
this.send(response, result);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
result = error; // so we can log the error message in the logger
|
|
97
|
+
this.reject(context, error);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// We call the logger function given to us by LogProvider
|
|
101
|
+
this.logger(request, args, result, start);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Once a sequence has been written, we can just use that in our Application as
|
|
107
|
+
follows:
|
|
108
|
+
|
|
109
|
+
**Example: Application**
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const app = new Application({
|
|
113
|
+
sequence: LogSequence,
|
|
114
|
+
});
|
|
115
|
+
app.component(LogComponent);
|
|
116
|
+
|
|
117
|
+
// Now all requests handled by our sequence will be logged.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Related Resources
|
|
121
|
+
|
|
122
|
+
You can check out the following resource to learn more about providers,
|
|
123
|
+
components, sequences, and binding keys.
|
|
124
|
+
|
|
125
|
+
- [Providers](http://loopback.io/doc/en/lb4/Creating-components.html#providers)
|
|
126
|
+
- [Creating Components](http://loopback.io/doc/en/lb4/Creating-components.html)
|
|
127
|
+
- [Using Components](http://loopback.io/doc/en/lb4/Components.html)
|
|
128
|
+
- [Sequence](http://loopback.io/doc/en/lb4/Sequence.html)
|
|
129
|
+
- [Binding Keys](http://loopback.io/doc/en/lb4/Decorators.html)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {BindingScope, inject, injectable} from '@loopback/core';
|
|
2
|
+
import {ILogger, LOGGER} from '@sourceloop/core';
|
|
3
|
+
import {z, ZodRawShape} from 'zod';
|
|
4
|
+
import {extractParameterInfo} from '../utils';
|
|
5
|
+
|
|
6
|
+
@injectable({scope: BindingScope.SINGLETON})
|
|
7
|
+
export class McpSchemaGeneratorService {
|
|
8
|
+
constructor(
|
|
9
|
+
@inject(LOGGER.LOGGER_INJECT)
|
|
10
|
+
private readonly logger: ILogger,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create Zod schema for a single parameter using LoopBack type string
|
|
15
|
+
*/
|
|
16
|
+
createZodSchemaForParameterType(paramType: string) {
|
|
17
|
+
switch (paramType) {
|
|
18
|
+
case 'string':
|
|
19
|
+
return z.string();
|
|
20
|
+
case 'number':
|
|
21
|
+
return z.number();
|
|
22
|
+
case 'boolean':
|
|
23
|
+
return z.boolean();
|
|
24
|
+
case 'object':
|
|
25
|
+
return z.object({}).passthrough();
|
|
26
|
+
case 'array':
|
|
27
|
+
return z.array(z.any());
|
|
28
|
+
default:
|
|
29
|
+
return z.any();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate tool schema using LoopBack metadata
|
|
35
|
+
*/
|
|
36
|
+
generateToolSchemaFromLoopBack(
|
|
37
|
+
options: {name: string; description: string; schema?: ZodRawShape},
|
|
38
|
+
target: Object,
|
|
39
|
+
methodName: string,
|
|
40
|
+
) {
|
|
41
|
+
let schema = options.schema;
|
|
42
|
+
|
|
43
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
44
|
+
try {
|
|
45
|
+
const {parameterNames, parameterOptional, parameterTypes} =
|
|
46
|
+
extractParameterInfo(target, methodName);
|
|
47
|
+
|
|
48
|
+
if (parameterNames.length > 0) {
|
|
49
|
+
let combinedSchema = z.object({});
|
|
50
|
+
|
|
51
|
+
parameterTypes.forEach((paramType, index) => {
|
|
52
|
+
const zodSchema = this.createZodSchemaForParameterType(paramType);
|
|
53
|
+
const paramName = parameterNames[index];
|
|
54
|
+
const isOptional = parameterOptional[index];
|
|
55
|
+
|
|
56
|
+
combinedSchema = combinedSchema.merge(
|
|
57
|
+
z.object({
|
|
58
|
+
[paramName]: isOptional ? zodSchema.optional() : zodSchema,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
schema = combinedSchema.shape;
|
|
64
|
+
} else {
|
|
65
|
+
schema = {};
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.logger.warn(
|
|
69
|
+
`Failed to generate schema for tool ${options.name} from LoopBack metadata:`,
|
|
70
|
+
error,
|
|
71
|
+
);
|
|
72
|
+
schema = {};
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
schema = schema ?? {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return schema;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {bind, BindingScope, Context, inject, service} from '@loopback/core';
|
|
2
|
+
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import {McpToolRegistry} from './mcp-tool-registry.service';
|
|
4
|
+
import {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol';
|
|
5
|
+
import {
|
|
6
|
+
ServerNotification,
|
|
7
|
+
ServerRequest,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types';
|
|
9
|
+
|
|
10
|
+
@bind({scope: BindingScope.REQUEST})
|
|
11
|
+
export class McpServerFactory {
|
|
12
|
+
constructor(
|
|
13
|
+
@inject.context()
|
|
14
|
+
private readonly ctx: Context,
|
|
15
|
+
@service(McpToolRegistry)
|
|
16
|
+
private readonly toolRegistry: McpToolRegistry,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a new MCP server instance with tool registration
|
|
21
|
+
* Uses singleton registry with pre-computed tools and controller instances
|
|
22
|
+
*/
|
|
23
|
+
createServer(): McpServer {
|
|
24
|
+
// Create fresh server instance
|
|
25
|
+
const server = new McpServer(
|
|
26
|
+
{
|
|
27
|
+
name: 'Project management MCP server',
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
capabilities: {
|
|
32
|
+
tools: {},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Tool registration from singleton registry
|
|
38
|
+
const toolDefinitions = this.toolRegistry.getToolDefinitions();
|
|
39
|
+
for (const toolDef of toolDefinitions) {
|
|
40
|
+
// Adapt the registry handler to work with the new API signature
|
|
41
|
+
// The new API expects (parameters, extra) instead of (context, args, extras)
|
|
42
|
+
const adaptedHandler = async (
|
|
43
|
+
parameters: Record<string, unknown>,
|
|
44
|
+
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
45
|
+
) => toolDef.handler(this.ctx, parameters, extra);
|
|
46
|
+
|
|
47
|
+
// Use the new registerTool API with type assertion to avoid deep type recursion
|
|
48
|
+
const registerTool = (
|
|
49
|
+
server as unknown as {
|
|
50
|
+
registerTool(
|
|
51
|
+
name: string,
|
|
52
|
+
config: {description: string; inputSchema: unknown},
|
|
53
|
+
handler: Function,
|
|
54
|
+
): void;
|
|
55
|
+
}
|
|
56
|
+
).registerTool;
|
|
57
|
+
|
|
58
|
+
registerTool.call(
|
|
59
|
+
server,
|
|
60
|
+
toolDef.name,
|
|
61
|
+
{
|
|
62
|
+
description: toolDef.description,
|
|
63
|
+
inputSchema: toolDef.schema,
|
|
64
|
+
},
|
|
65
|
+
adaptedHandler,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return server;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Application,
|
|
3
|
+
bind,
|
|
4
|
+
BindingKey,
|
|
5
|
+
BindingScope,
|
|
6
|
+
Constructor,
|
|
7
|
+
Context,
|
|
8
|
+
CoreBindings,
|
|
9
|
+
inject,
|
|
10
|
+
MetadataInspector,
|
|
11
|
+
service,
|
|
12
|
+
} from '@loopback/core';
|
|
13
|
+
import {HttpErrors} from '@loopback/rest';
|
|
14
|
+
import {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol';
|
|
15
|
+
import {
|
|
16
|
+
CallToolResult,
|
|
17
|
+
ServerNotification,
|
|
18
|
+
ServerRequest,
|
|
19
|
+
} from '@modelcontextprotocol/sdk/types';
|
|
20
|
+
import {IAuthUserWithPermissions, ILogger, LOGGER} from '@sourceloop/core';
|
|
21
|
+
import {AuthenticationBindings} from 'loopback4-authentication';
|
|
22
|
+
import {
|
|
23
|
+
AUTHORIZATION_METADATA_ACCESSOR,
|
|
24
|
+
AuthorizationBindings,
|
|
25
|
+
AuthorizationMetadata,
|
|
26
|
+
} from 'loopback4-authorization';
|
|
27
|
+
import {McpHookContext, McpHookFunction} from '../interfaces';
|
|
28
|
+
import {MCP_TOOL_METADATA_KEY} from '../constants';
|
|
29
|
+
import {extractParameterInfo} from '../utils';
|
|
30
|
+
import {McpSchemaGeneratorService} from './mcp-schema-generator-service.service';
|
|
31
|
+
import {McpTool, McpToolMetadata} from '../types';
|
|
32
|
+
|
|
33
|
+
@bind({scope: BindingScope.SINGLETON})
|
|
34
|
+
export class McpToolRegistry {
|
|
35
|
+
private toolDefinitions: McpTool[] = [];
|
|
36
|
+
private isInitialized = false;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
@inject(CoreBindings.APPLICATION_INSTANCE)
|
|
40
|
+
private readonly app: Application,
|
|
41
|
+
@inject(LOGGER.LOGGER_INJECT)
|
|
42
|
+
private readonly logger: ILogger,
|
|
43
|
+
@service(McpSchemaGeneratorService)
|
|
44
|
+
private readonly schemaGenerator: McpSchemaGeneratorService,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get MCP tools from a class using LoopBack MetadataInspector
|
|
49
|
+
*/
|
|
50
|
+
private getMcpToolsFromClass(targetClass: Function): McpToolMetadata[] {
|
|
51
|
+
const processedTools: McpToolMetadata[] = [];
|
|
52
|
+
const prototype = targetClass.prototype;
|
|
53
|
+
|
|
54
|
+
const allTools = MetadataInspector.getAllMethodMetadata<McpToolMetadata>(
|
|
55
|
+
MCP_TOOL_METADATA_KEY,
|
|
56
|
+
prototype,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!allTools || Object.keys(allTools).length === 0) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [methodName, tool] of Object.entries(allTools)) {
|
|
64
|
+
try {
|
|
65
|
+
const {parameterNames} = extractParameterInfo(prototype, methodName);
|
|
66
|
+
|
|
67
|
+
// Create a copy with updated parameter info
|
|
68
|
+
const processedTool: McpToolMetadata = {
|
|
69
|
+
...tool,
|
|
70
|
+
parameterNames,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Generate schema if needed
|
|
74
|
+
if (
|
|
75
|
+
!processedTool.schema ||
|
|
76
|
+
Object.keys(processedTool.schema).length === 0
|
|
77
|
+
) {
|
|
78
|
+
const schema = this.schemaGenerator.generateToolSchemaFromLoopBack(
|
|
79
|
+
{
|
|
80
|
+
name: processedTool.name,
|
|
81
|
+
description: processedTool.description,
|
|
82
|
+
schema: processedTool.schema,
|
|
83
|
+
},
|
|
84
|
+
prototype,
|
|
85
|
+
methodName,
|
|
86
|
+
);
|
|
87
|
+
processedTool.schema = schema;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
processedTools.push(processedTool);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Tool doesn't have LoopBack parameter decorators
|
|
93
|
+
this.logger.warn(
|
|
94
|
+
`Tool ${methodName} missing LoopBack parameter decorators:`,
|
|
95
|
+
error,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return processedTools;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Initialize tool registry at startup - stores metadata without instantiating controllers
|
|
105
|
+
*/
|
|
106
|
+
async initialize(): Promise<void> {
|
|
107
|
+
if (this.isInitialized) return;
|
|
108
|
+
|
|
109
|
+
const controllerBindings = this.app.findByTag('controller');
|
|
110
|
+
|
|
111
|
+
// Process all bindings and collect tool metadata without instantiating controllers
|
|
112
|
+
const toolArrays = controllerBindings.map(binding => {
|
|
113
|
+
const controllerClass = binding.valueConstructor;
|
|
114
|
+
if (!controllerClass) {
|
|
115
|
+
this.logger.info(`No constructor found for binding ${binding.key}`);
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tools = this.getMcpToolsFromClass(controllerClass);
|
|
120
|
+
|
|
121
|
+
if (tools.length === 0) return [];
|
|
122
|
+
|
|
123
|
+
// Create pre-bound tool definitions
|
|
124
|
+
return tools.map(tool => {
|
|
125
|
+
return {
|
|
126
|
+
...tool,
|
|
127
|
+
controllerBinding: BindingKey.create<object>(binding.key), // Store binding key instead of instance
|
|
128
|
+
handler: async (
|
|
129
|
+
requestContext: Context,
|
|
130
|
+
args: {[key: string]: unknown},
|
|
131
|
+
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
132
|
+
): Promise<CallToolResult> => {
|
|
133
|
+
const ctx = await this.setupAuthorizationContext(
|
|
134
|
+
requestContext,
|
|
135
|
+
tool,
|
|
136
|
+
controllerClass,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const hookContext: McpHookContext = {
|
|
140
|
+
toolName: tool.name,
|
|
141
|
+
args: args,
|
|
142
|
+
metadata: tool.postHook?.config,
|
|
143
|
+
};
|
|
144
|
+
await this.executePreHook(ctx, tool, hookContext);
|
|
145
|
+
|
|
146
|
+
let result;
|
|
147
|
+
try {
|
|
148
|
+
result = await this.executeToolMethod(
|
|
149
|
+
ctx,
|
|
150
|
+
binding.key,
|
|
151
|
+
tool,
|
|
152
|
+
hookContext,
|
|
153
|
+
extras,
|
|
154
|
+
);
|
|
155
|
+
hookContext.result = result;
|
|
156
|
+
|
|
157
|
+
await this.executePostHook(ctx, tool, hookContext);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
160
|
+
hookContext.error = error;
|
|
161
|
+
this.logger.error(
|
|
162
|
+
`MCP Tool '${tool.name}' execution failed:`,
|
|
163
|
+
error.message,
|
|
164
|
+
);
|
|
165
|
+
return this.errorToCallToolResult(error);
|
|
166
|
+
} finally {
|
|
167
|
+
ctx.close();
|
|
168
|
+
}
|
|
169
|
+
return (hookContext.result as CallToolResult) ?? result;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.toolDefinitions = toolArrays.flat();
|
|
176
|
+
this.isInitialized = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get all precomputed tool definitions (ultra-fast)
|
|
181
|
+
*/
|
|
182
|
+
getToolDefinitions(): McpTool[] {
|
|
183
|
+
return this.toolDefinitions;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve hook function from provider binding
|
|
188
|
+
*/
|
|
189
|
+
private async resolveHook(
|
|
190
|
+
ctx: Context,
|
|
191
|
+
hookBinding?: BindingKey<McpHookFunction> | string,
|
|
192
|
+
): Promise<McpHookFunction | undefined> {
|
|
193
|
+
if (!hookBinding) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const bindingKey =
|
|
199
|
+
typeof hookBinding === 'string'
|
|
200
|
+
? BindingKey.create<McpHookFunction>(hookBinding)
|
|
201
|
+
: hookBinding;
|
|
202
|
+
return await ctx.get(bindingKey);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Hook binding not found - this is not an error, just log and continue
|
|
205
|
+
this.logger.warn(
|
|
206
|
+
`Hook binding ${hookBinding.toString()} not found:`,
|
|
207
|
+
error,
|
|
208
|
+
);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Set up authorization context and perform authorization check
|
|
215
|
+
*/
|
|
216
|
+
private async setupAuthorizationContext(
|
|
217
|
+
requestContext: Context,
|
|
218
|
+
tool: McpToolMetadata,
|
|
219
|
+
controllerClass: Constructor<object>,
|
|
220
|
+
): Promise<Context> {
|
|
221
|
+
// Get authorization metadata from the controller method
|
|
222
|
+
const authMetadata =
|
|
223
|
+
MetadataInspector.getMethodMetadata<AuthorizationMetadata>(
|
|
224
|
+
AUTHORIZATION_METADATA_ACCESSOR,
|
|
225
|
+
controllerClass.prototype,
|
|
226
|
+
tool.controllerFunction.name,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!authMetadata) {
|
|
230
|
+
this.logger.warn(
|
|
231
|
+
`No authorization metadata found for MCP tool '${tool.name}'`,
|
|
232
|
+
);
|
|
233
|
+
throw new HttpErrors.Forbidden(
|
|
234
|
+
`MCP tool '${tool.name}' is missing authorization configuration.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check authorization first - get authorize function from context
|
|
239
|
+
const ctx = new Context(requestContext);
|
|
240
|
+
|
|
241
|
+
// Bind the authorization metadata so the authorize action provider can access it
|
|
242
|
+
ctx.bind(AuthorizationBindings.METADATA).to(authMetadata);
|
|
243
|
+
|
|
244
|
+
// Bind controller information for authorization
|
|
245
|
+
ctx.bind(CoreBindings.CONTROLLER_CLASS).to(controllerClass);
|
|
246
|
+
ctx
|
|
247
|
+
.bind(CoreBindings.CONTROLLER_METHOD_NAME)
|
|
248
|
+
.to(tool.controllerFunction.name);
|
|
249
|
+
|
|
250
|
+
const user = await ctx.get<IAuthUserWithPermissions>(
|
|
251
|
+
AuthenticationBindings.CURRENT_USER,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const authorizeAction = await ctx.get(
|
|
255
|
+
AuthorizationBindings.AUTHORIZE_ACTION,
|
|
256
|
+
);
|
|
257
|
+
const isAuthorized = await authorizeAction(user.permissions);
|
|
258
|
+
|
|
259
|
+
if (!isAuthorized) {
|
|
260
|
+
this.logger.warn(
|
|
261
|
+
`User ${user.id} is not authorized to access MCP tool '${tool.name}'`,
|
|
262
|
+
);
|
|
263
|
+
throw new HttpErrors.Forbidden(
|
|
264
|
+
`Access denied for MCP tool '${tool.name}'.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return ctx;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Prepare method arguments based on parameter patterns
|
|
273
|
+
*/
|
|
274
|
+
private prepareMethodArguments(
|
|
275
|
+
tool: McpToolMetadata,
|
|
276
|
+
args: {[key: string]: unknown},
|
|
277
|
+
): unknown[] {
|
|
278
|
+
// Extract individual parameter values by name
|
|
279
|
+
if (!tool.parameterNames?.length) {
|
|
280
|
+
// Fallback - pass args object directly if no parameter names
|
|
281
|
+
return [args];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Extract individual values by parameter names
|
|
285
|
+
return tool.parameterNames.map(paramName => args?.[paramName]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Execute pre-hook if configured
|
|
290
|
+
*/
|
|
291
|
+
private async executePreHook(
|
|
292
|
+
ctx: Context,
|
|
293
|
+
tool: McpToolMetadata,
|
|
294
|
+
hookContext: McpHookContext,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const preHook = await this.resolveHook(ctx, tool.preHook?.binding);
|
|
297
|
+
if (preHook) {
|
|
298
|
+
const preHookResult = await preHook(hookContext);
|
|
299
|
+
if (preHookResult) {
|
|
300
|
+
hookContext.args = preHookResult.args;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execute post-hook if configured
|
|
307
|
+
*/
|
|
308
|
+
private async executePostHook(
|
|
309
|
+
ctx: Context,
|
|
310
|
+
tool: McpToolMetadata,
|
|
311
|
+
hookContext: McpHookContext,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const postHook = await this.resolveHook(ctx, tool.postHook?.binding);
|
|
314
|
+
if (postHook) {
|
|
315
|
+
const postHookResult = await postHook(hookContext);
|
|
316
|
+
if (postHookResult) {
|
|
317
|
+
hookContext.result = postHookResult.result;
|
|
318
|
+
hookContext.args = postHookResult.args;
|
|
319
|
+
hookContext.error = postHookResult.error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Execute the tool method with proper result wrapping
|
|
326
|
+
*/
|
|
327
|
+
private async executeToolMethod(
|
|
328
|
+
ctx: Context,
|
|
329
|
+
bindingKey: string,
|
|
330
|
+
tool: McpToolMetadata,
|
|
331
|
+
hookContext: McpHookContext,
|
|
332
|
+
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
333
|
+
): Promise<CallToolResult> {
|
|
334
|
+
const controllerInstance = await ctx.get(bindingKey);
|
|
335
|
+
const methodArgs = this.prepareMethodArguments(tool, hookContext.args);
|
|
336
|
+
|
|
337
|
+
let result = await tool.controllerFunction.call(
|
|
338
|
+
controllerInstance,
|
|
339
|
+
...methodArgs,
|
|
340
|
+
extras,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Automatically wrap result in MCP response format if not already wrapped
|
|
344
|
+
if (result && typeof result === 'object' && !result.content) {
|
|
345
|
+
result = {
|
|
346
|
+
content: [
|
|
347
|
+
{
|
|
348
|
+
type: 'text',
|
|
349
|
+
text: JSON.stringify(result, null, 2),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
} as CallToolResult;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private errorToCallToolResult(error: Error): CallToolResult {
|
|
359
|
+
return {
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: error.message || 'MCP tool execution failed',
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
} as CallToolResult;
|
|
367
|
+
}
|
|
368
|
+
}
|