@rekog/mcp-nest 1.2.0 → 1.3.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 (64) hide show
  1. package/.github/workflows/pipeline.yml +26 -0
  2. package/.prettierrc +4 -0
  3. package/README.md +51 -190
  4. package/dist/controllers/sse.controller.factory.d.ts +4 -6
  5. package/dist/controllers/sse.controller.factory.d.ts.map +1 -1
  6. package/dist/controllers/sse.controller.factory.js +13 -6
  7. package/dist/controllers/sse.controller.factory.js.map +1 -1
  8. package/dist/decorators/constants.d.ts +1 -0
  9. package/dist/decorators/constants.d.ts.map +1 -1
  10. package/dist/decorators/constants.js +2 -1
  11. package/dist/decorators/constants.js.map +1 -1
  12. package/dist/decorators/index.d.ts +1 -0
  13. package/dist/decorators/index.d.ts.map +1 -1
  14. package/dist/decorators/index.js +1 -0
  15. package/dist/decorators/index.js.map +1 -1
  16. package/dist/decorators/resource.decorator.d.ts +9 -0
  17. package/dist/decorators/resource.decorator.d.ts.map +1 -0
  18. package/dist/decorators/resource.decorator.js +10 -0
  19. package/dist/decorators/resource.decorator.js.map +1 -0
  20. package/dist/decorators/tool.decorator.d.ts +4 -3
  21. package/dist/decorators/tool.decorator.d.ts.map +1 -1
  22. package/dist/decorators/tool.decorator.js +2 -6
  23. package/dist/decorators/tool.decorator.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/interfaces/index.d.ts +1 -0
  29. package/dist/interfaces/index.d.ts.map +1 -1
  30. package/dist/interfaces/index.js +1 -0
  31. package/dist/interfaces/index.js.map +1 -1
  32. package/dist/interfaces/mcp-tool.interface.d.ts +15 -0
  33. package/dist/interfaces/mcp-tool.interface.d.ts.map +1 -0
  34. package/dist/interfaces/mcp-tool.interface.js +3 -0
  35. package/dist/interfaces/mcp-tool.interface.js.map +1 -0
  36. package/dist/mcp.module.d.ts.map +1 -1
  37. package/dist/mcp.module.js +8 -25
  38. package/dist/mcp.module.js.map +1 -1
  39. package/dist/services/mcp-executor.service.d.ts +15 -0
  40. package/dist/services/mcp-executor.service.d.ts.map +1 -0
  41. package/dist/services/mcp-executor.service.js +172 -0
  42. package/dist/services/mcp-executor.service.js.map +1 -0
  43. package/dist/services/mcp-registry.service.d.ts +31 -0
  44. package/dist/services/mcp-registry.service.d.ts.map +1 -0
  45. package/dist/services/mcp-registry.service.js +119 -0
  46. package/dist/services/mcp-registry.service.js.map +1 -0
  47. package/dist/services/mcp-registry.service.spec.d.ts +2 -0
  48. package/dist/services/mcp-registry.service.spec.d.ts.map +1 -0
  49. package/dist/services/mcp-registry.service.spec.js +61 -0
  50. package/dist/services/mcp-registry.service.spec.js.map +1 -0
  51. package/eslint.config.mjs +38 -0
  52. package/image.png +0 -0
  53. package/package.json +22 -5
  54. package/playground/README.md +17 -0
  55. package/playground/greeting.resource.ts +25 -0
  56. package/playground/greeting.tool.ts +36 -0
  57. package/playground/server.ts +25 -0
  58. package/tests/mcp-resource.e2e.spec.ts +179 -0
  59. package/tests/{mcp-auth.e2e.spec.ts → mcp-tool-auth.e2e.spec.ts} +72 -83
  60. package/tests/mcp-tool.e2e.spec.ts +235 -0
  61. package/tests/utils.ts +42 -0
  62. package/tsconfig.build.json +11 -0
  63. package/tsconfig.build.tsbuildinfo +1 -0
  64. package/tests/mcp.e2e.spec.ts +0 -115
@@ -0,0 +1,25 @@
1
+ import { Injectable, Scope } from '@nestjs/common';
2
+ import { Resource } from '../src';
3
+
4
+ @Injectable({ scope: Scope.REQUEST })
5
+ export class GreetingResource {
6
+ constructor() {}
7
+
8
+ @Resource({
9
+ name: 'hello-world',
10
+ description: 'A simple greeting resource',
11
+ mimeType: 'text/plain',
12
+ uri: 'mcp://hello-world/{name}',
13
+ })
14
+ sayHello({ name }) {
15
+ return {
16
+ contents: [
17
+ {
18
+ uri: 'mcp://hello-world',
19
+ mimeType: 'text/plain',
20
+ text: `Hello ${name}`,
21
+ },
22
+ ],
23
+ };
24
+ }
25
+ }
@@ -0,0 +1,36 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Context, Tool } from '../src';
3
+ import { Progress } from '@modelcontextprotocol/sdk/types.js';
4
+ import { z } from 'zod';
5
+
6
+ @Injectable()
7
+ export class GreetingTool {
8
+ constructor() {}
9
+
10
+ @Tool({
11
+ name: 'hello-world',
12
+ description:
13
+ 'Returns a greeting and simulates a long operation with progress updates',
14
+ parameters: z.object({
15
+ name: z.string().default('World'),
16
+ }),
17
+ })
18
+ async sayHello({ name }, context: Context) {
19
+ const greeting = `Hello, ${name}!`;
20
+
21
+ const totalSteps = 5;
22
+ for (let i = 0; i < totalSteps; i++) {
23
+ await new Promise((resolve) => setTimeout(resolve, 500));
24
+
25
+ // Send a progress update.
26
+ await context.reportProgress({
27
+ progress: (i + 1) * 20,
28
+ total: 100,
29
+ } as Progress);
30
+ }
31
+
32
+ return {
33
+ content: [{ type: 'text', text: greeting }],
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,25 @@
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { Module } from '@nestjs/common';
3
+ import { McpModule } from '../src';
4
+ import { GreetingResource } from './greeting.resource';
5
+ import { GreetingTool } from './greeting.tool';
6
+
7
+ @Module({
8
+ imports: [
9
+ McpModule.forRoot({
10
+ name: 'playground-mcp-server',
11
+ version: '0.0.1',
12
+ }),
13
+ ],
14
+ providers: [GreetingResource, GreetingTool],
15
+ })
16
+ class AppModule {}
17
+
18
+ async function bootstrap() {
19
+ const app = await NestFactory.create(AppModule);
20
+ await app.listen(3030);
21
+
22
+ console.log('MCP server started on port 3030');
23
+ }
24
+
25
+ void bootstrap();
@@ -0,0 +1,179 @@
1
+ import { INestApplication } from '@nestjs/common';
2
+ import { Test, TestingModule } from '@nestjs/testing';
3
+ import { Injectable } from '@nestjs/common';
4
+ import { McpModule } from '../src/mcp.module';
5
+ import { createMCPClient } from './utils';
6
+ import { Resource } from '../src';
7
+
8
+ @Injectable()
9
+ export class GreetingToolResource {
10
+ constructor() {}
11
+
12
+ @Resource({
13
+ name: 'hello-world',
14
+ description: 'A simple greeting resource',
15
+ mimeType: 'text/plain',
16
+ uri: 'mcp://hello-world',
17
+ })
18
+ async sayHello({ uri }) {
19
+ return {
20
+ contents: [
21
+ {
22
+ uri,
23
+ mimeType: 'text/plain',
24
+ text: 'Hello World',
25
+ },
26
+ ],
27
+ };
28
+ }
29
+
30
+ @Resource({
31
+ name: 'hello-world-dynamic',
32
+ description: 'A simple greeting dynamic resource',
33
+ mimeType: 'text/plain',
34
+ uri: 'mcp://hello-world-dynamic/{userName}',
35
+ })
36
+ async sayHelloDynamic({ uri, userName }) {
37
+ return {
38
+ contents: [
39
+ {
40
+ uri: uri,
41
+ mimeType: 'text/plain',
42
+ text: `Hello ${userName}`,
43
+ },
44
+ ],
45
+ };
46
+ }
47
+
48
+ @Resource({
49
+ name: 'hello-world-dynamic-multiple-paths',
50
+ description: 'A simple greeting dynamic resource with multiple paths',
51
+ mimeType: 'text/plain',
52
+ uri: 'mcp://hello-world-dynamic-multiple-paths/{userId}/{userName}',
53
+ })
54
+ async sayHelloMultiplePathsDynamic({ uri, userId, userName }) {
55
+ return {
56
+ contents: [
57
+ {
58
+ uri: uri,
59
+ mimeType: 'text/plain',
60
+ text: `Hello ${userName} from ${userId}`,
61
+ },
62
+ ],
63
+ };
64
+ }
65
+
66
+ @Resource({
67
+ name: 'hello-world-dynamic-multiple-paths-error',
68
+ description: 'A simple greeting dynamic resource with multiple paths',
69
+ mimeType: 'text/plain',
70
+ uri: 'mcp://hello-world-dynamic-multiple-paths-error/{userId}/{userName}',
71
+ })
72
+ async sayHelloMultiplePathsDynamicError() {
73
+ throw new Error('any error');
74
+ }
75
+ }
76
+
77
+ describe('E2E: MCP Resource Server', () => {
78
+ let app: INestApplication;
79
+ let testPort: number;
80
+
81
+ beforeAll(async () => {
82
+ const moduleFixture: TestingModule = await Test.createTestingModule({
83
+ imports: [
84
+ McpModule.forRoot({
85
+ name: 'test-mcp-server',
86
+ version: '0.0.1',
87
+ guards: [],
88
+ }),
89
+ ],
90
+ providers: [GreetingToolResource],
91
+ }).compile();
92
+
93
+ app = moduleFixture.createNestApplication();
94
+ await app.listen(0);
95
+
96
+ const server = app.getHttpServer();
97
+ testPort = server.address().port;
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await app.close();
102
+ });
103
+
104
+ it('should list resources', async () => {
105
+ const client = await createMCPClient(testPort);
106
+ const resources = await client.listResources();
107
+
108
+ expect(resources.resources.find((r) => r.name === 'hello-world')).toEqual({
109
+ name: 'hello-world',
110
+ uri: 'mcp://hello-world',
111
+ description: 'A simple greeting resource',
112
+ mimeType: 'text/plain',
113
+ });
114
+
115
+ expect(
116
+ resources.resources.find((r) => r.name === 'hello-world-dynamic'),
117
+ ).toEqual({
118
+ name: 'hello-world-dynamic',
119
+ uri: 'mcp://hello-world-dynamic/{userName}',
120
+ description: 'A simple greeting dynamic resource',
121
+ mimeType: 'text/plain',
122
+ });
123
+
124
+ await client.close();
125
+ });
126
+
127
+ it('should call the dynamic resource', async () => {
128
+ const client = await createMCPClient(testPort);
129
+
130
+ const result: any = await client.readResource({
131
+ uri: 'mcp://hello-world-dynamic/Raphael_John',
132
+ });
133
+
134
+ expect(result.contents[0].uri).toBe(
135
+ 'mcp://hello-world-dynamic/Raphael_John',
136
+ );
137
+ expect(result.contents[0].mimeType).toBe('text/plain');
138
+ expect(result.contents[0].text).toBe('Hello Raphael_John');
139
+
140
+ await client.close();
141
+ });
142
+
143
+ it('should call the dynamic resource with multiple paths', async () => {
144
+ const client = await createMCPClient(testPort);
145
+
146
+ const result: any = await client.readResource({
147
+ uri: 'mcp://hello-world-dynamic-multiple-paths/123/Raphael_John',
148
+ });
149
+
150
+ expect(result.contents[0].uri).toBe(
151
+ 'mcp://hello-world-dynamic-multiple-paths/123/Raphael_John',
152
+ );
153
+ expect(result.contents[0].mimeType).toBe('text/plain');
154
+ expect(result.contents[0].text).toBe('Hello Raphael_John from 123');
155
+
156
+ await client.close();
157
+ });
158
+
159
+ it('should return an error when the resource is not found', async () => {
160
+ const client = await createMCPClient(testPort);
161
+
162
+ const result: any = await client.readResource({
163
+ uri: 'mcp://hello-world-dynamic-multiple-paths-error/123/Raphael_John',
164
+ });
165
+
166
+ expect(result).toEqual({
167
+ contents: [
168
+ {
169
+ uri: 'mcp://hello-world-dynamic-multiple-paths-error/123/Raphael_John',
170
+ mimeType: 'text/plain',
171
+ text: 'any error',
172
+ },
173
+ ],
174
+ isError: true,
175
+ });
176
+
177
+ await client.close();
178
+ });
179
+ });
@@ -1,28 +1,30 @@
1
- import { INestApplication, Injectable } from "@nestjs/common";
2
- import { Test, TestingModule } from "@nestjs/testing";
3
- import { z } from "zod";
4
- import { Context, Tool } from "../src";
5
- import { McpModule } from "../src/mcp.module";
6
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
7
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
- import { Progress } from "@modelcontextprotocol/sdk/types.js";
9
- import { CanActivate, ExecutionContext } from "@nestjs/common";
1
+ import { INestApplication, Injectable } from '@nestjs/common';
2
+ import { Test, TestingModule } from '@nestjs/testing';
3
+ import { z } from 'zod';
4
+ import { Context, Tool } from '../src';
5
+ import { McpModule } from '../src/mcp.module';
6
+ import { Progress } from '@modelcontextprotocol/sdk/types.js';
7
+ import { CanActivate, ExecutionContext } from '@nestjs/common';
8
+ import { createMCPClient } from './utils';
9
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10
10
 
11
11
  // Mock authentication guard
12
12
  class MockAuthGuard implements CanActivate {
13
13
  canActivate(context: ExecutionContext): boolean {
14
14
  const request = context.switchToHttp().getRequest();
15
15
 
16
-
17
- if (request.headers.authorization && request.headers.authorization.includes("token-xyz")) {
16
+ if (
17
+ request.headers.authorization &&
18
+ request.headers.authorization.includes('token-xyz')
19
+ ) {
18
20
  request.user = {
19
- id: "user123",
20
- name: "Test User",
21
+ id: 'user123',
22
+ name: 'Test User',
21
23
  orgMemberships: [
22
24
  {
23
- orgId: "org123",
25
+ orgId: 'org123',
24
26
  organization: {
25
- name: "Auth Test Org",
27
+ name: 'Auth Test Org',
26
28
  },
27
29
  },
28
30
  ],
@@ -30,6 +32,7 @@ class MockAuthGuard implements CanActivate {
30
32
 
31
33
  return true;
32
34
  }
35
+
33
36
  return false;
34
37
  }
35
38
  }
@@ -38,18 +41,18 @@ class MockAuthGuard implements CanActivate {
38
41
  @Injectable()
39
42
  class MockUserRepository {
40
43
  async findOne() {
41
- return {
42
- id: "userRepo123",
43
- name: "Repository User",
44
+ return Promise.resolve({
45
+ id: 'userRepo123',
46
+ name: 'Repository User',
44
47
  orgMemberships: [
45
48
  {
46
- orgId: "org123",
49
+ orgId: 'org123',
47
50
  organization: {
48
- name: "Repository Org",
51
+ name: 'Repository Org',
49
52
  },
50
53
  },
51
54
  ],
52
- };
55
+ });
53
56
  }
54
57
  }
55
58
 
@@ -59,10 +62,10 @@ export class AuthGreetingTool {
59
62
  constructor(private readonly userRepository: MockUserRepository) {}
60
63
 
61
64
  @Tool({
62
- name: "auth-hello-world",
63
- description: "A sample tool that accesses the authenticated user",
65
+ name: 'auth-hello-world',
66
+ description: 'A sample tool that accesses the authenticated user',
64
67
  parameters: z.object({
65
- name: z.string().default("World"),
68
+ name: z.string().default('World'),
66
69
  }),
67
70
  })
68
71
  async sayHello({ name }, context: Context, request: Request & { user: any }) {
@@ -71,15 +74,13 @@ export class AuthGreetingTool {
71
74
  const authUser = request.user; // Authenticated user from the request
72
75
 
73
76
  // Construct greeting using both data sources
74
- const greeting = `Hello, ${name}! I'm ${authUser.name} from ${
75
- authUser.orgMemberships[0].organization.name
76
- }. Repository user is ${repoUser.name}.`;
77
+ const greeting = `Hello, ${name}! I'm ${authUser.name} from ${authUser.orgMemberships[0].organization.name}. Repository user is ${repoUser.name}.`;
77
78
 
78
79
  // Report progress for demonstration
79
80
  for (let i = 0; i < 5; i++) {
80
81
  await new Promise((resolve) => setTimeout(resolve, 200));
81
82
  await context.reportProgress({
82
- progress: (i+1) * 20,
83
+ progress: (i + 1) * 20,
83
84
  total: 100,
84
85
  } as Progress);
85
86
  }
@@ -87,7 +88,7 @@ export class AuthGreetingTool {
87
88
  return {
88
89
  content: [
89
90
  {
90
- type: "text",
91
+ type: 'text',
91
92
  text: greeting,
92
93
  },
93
94
  ],
@@ -95,7 +96,7 @@ export class AuthGreetingTool {
95
96
  }
96
97
  }
97
98
 
98
- describe("E2E: MCP Server with Authentication", () => {
99
+ describe('E2E: MCP Server Tool with Authentication', () => {
99
100
  let app: INestApplication;
100
101
  let testPort: number;
101
102
 
@@ -103,18 +104,21 @@ describe("E2E: MCP Server with Authentication", () => {
103
104
  const moduleFixture: TestingModule = await Test.createTestingModule({
104
105
  imports: [
105
106
  McpModule.forRoot({
106
- name: "test-auth-mcp-server",
107
- version: "0.0.1",
107
+ name: 'test-auth-mcp-server',
108
+ version: '0.0.1',
108
109
  // Specify the MockAuthGuard to protect the messages endpoint
109
110
  guards: [MockAuthGuard],
110
111
  capabilities: {
112
+ resources: {},
113
+ resourceTemplates: {},
111
114
  tools: {
112
- "auth-hello-world": {
113
- description: "A sample tool that accesses the authenticated user",
115
+ 'auth-hello-world': {
116
+ description:
117
+ 'A sample tool that accesses the authenticated user',
114
118
  input: {
115
119
  name: {
116
- type: "string",
117
- default: "World",
120
+ type: 'string',
121
+ default: 'World',
118
122
  },
119
123
  },
120
124
  },
@@ -136,53 +140,43 @@ describe("E2E: MCP Server with Authentication", () => {
136
140
  await app.close();
137
141
  });
138
142
 
139
- it("should list tools", async () => {
140
- const client = new Client(
141
- { name: "example-client", version: "1.0.0" },
142
- { capabilities: {} },
143
- );
144
- const sseUrl = new URL(`http://localhost:${testPort}/sse`);
145
- const transport = new SSEClientTransport(sseUrl, {
143
+ it('should list tools', async () => {
144
+ const client = await createMCPClient(testPort, {
146
145
  requestInit: {
147
146
  headers: {
148
- Authorization: 'Bearer token-xyz'
149
- }
150
- }
147
+ Authorization: 'Bearer token-xyz',
148
+ },
149
+ },
151
150
  });
152
- await client.connect(transport);
153
151
  const tools = await client.listTools();
154
152
 
155
153
  // Verify that the authenticated tool is available
156
154
  expect(tools.tools.length).toBeGreaterThan(0);
157
- expect(tools.tools.find((t) => t.name === "auth-hello-world")).toBeDefined();
155
+ expect(
156
+ tools.tools.find((t) => t.name === 'auth-hello-world'),
157
+ ).toBeDefined();
158
158
 
159
159
  await client.close();
160
160
  });
161
161
 
162
162
  it('should inject authentication context into the tool', async () => {
163
- const client = new Client(
164
- { name: "example-client", version: "1.0.0" },
165
- { capabilities: {} },
166
- );
167
- const sseUrl = new URL(`http://localhost:${testPort}/sse`);
168
- const transport = new SSEClientTransport(sseUrl, {
163
+ const client = await createMCPClient(testPort, {
169
164
  requestInit: {
170
165
  headers: {
171
- Authorization: 'Bearer token-xyz'
172
- }
173
- }
166
+ Authorization: 'Bearer token-xyz',
167
+ },
168
+ },
174
169
  });
175
- await client.connect(transport);
176
170
 
177
171
  let progressCount = 0;
178
172
  const result: any = await client.callTool(
179
173
  {
180
- name: "auth-hello-world",
181
- arguments: { name: "Authenticated User" },
174
+ name: 'auth-hello-world',
175
+ arguments: { name: 'Authenticated User' },
182
176
  },
183
177
  undefined,
184
178
  {
185
- onprogress: (progress) => {
179
+ onprogress: () => {
186
180
  progressCount++;
187
181
  },
188
182
  },
@@ -192,40 +186,35 @@ describe("E2E: MCP Server with Authentication", () => {
192
186
  expect(progressCount).toBeGreaterThan(0);
193
187
 
194
188
  // Verify that authentication context was available to the tool
195
- expect(result.content[0].type).toBe("text");
196
- expect(result.content[0].text).toContain("Auth Test Org");
197
- expect(result.content[0].text).toContain("Test User");
198
- expect(result.content[0].text).toContain("Repository user is Repository User");
189
+ expect(result.content[0].type).toBe('text');
190
+ expect(result.content[0].text).toContain('Auth Test Org');
191
+ expect(result.content[0].text).toContain('Test User');
192
+ expect(result.content[0].text).toContain(
193
+ 'Repository user is Repository User',
194
+ );
199
195
 
200
196
  await client.close();
201
197
  });
202
198
 
203
199
  it('should reject unauthenticated connections', async () => {
204
- const client = new Client(
205
- { name: "example-client", version: "1.0.0" },
206
- { capabilities: {} },
207
- );
208
- const sseUrl = new URL(`http://localhost:${testPort}/sse`);
209
-
210
- // Using invalid token
211
- const transport = new SSEClientTransport(sseUrl, {
212
- requestInit: {
213
- headers: {
214
- Authorization: 'Bearer invalid-token'
215
- }
216
- }
217
- });
218
-
219
200
  // Connection should be rejected
201
+ let client: Client | undefined;
220
202
  try {
221
- await client.connect(transport);
203
+ client = await createMCPClient(testPort, {
204
+ requestInit: {
205
+ headers: {
206
+ Authorization: 'Bearer invalid-token',
207
+ },
208
+ },
209
+ });
210
+
222
211
  // If we get here, the test should fail
223
212
  fail('Connection should have been rejected');
224
213
  } catch (error) {
225
214
  // We expect an error to be thrown when authentication fails
226
215
  expect(error).toBeDefined();
227
216
  } finally {
228
- await client.close();
217
+ await client?.close();
229
218
  }
230
219
  });
231
- });
220
+ });