Skip to content

Commit 8d77bdd

Browse files
refactor(chore): adding test cases and refactor the code
adding test cases and refactor the code GH-1
1 parent 6173d8c commit 8d77bdd

File tree

4 files changed

+221
-143
lines changed

4 files changed

+221
-143
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {param} from '@loopback/rest';
2+
import {authorize} from 'loopback4-authorization';
3+
import {mcpTool} from '../../../decorators';
4+
5+
export class ProjectSummaryController {
6+
constructor() {}
7+
8+
@authorize({
9+
permissions: ['mcp.access'],
10+
})
11+
@mcpTool({
12+
name: 'summary_get_add',
13+
description: 'Adds two numbers',
14+
})
15+
async add(
16+
@param.query.number('a') a: number,
17+
@param.query.number('b') b: number,
18+
): Promise<{content: Array<{type: string; text: string}>}> {
19+
const sum = a + b;
20+
return {
21+
content: [
22+
{
23+
type: 'text',
24+
text: `The sum of ${a} and ${b} is ${sum}`,
25+
},
26+
],
27+
};
28+
}
29+
30+
@authorize({permissions: ['mcp.access']})
31+
@mcpTool({
32+
name: 'summary_add_with_prehook',
33+
description: 'Add with prehook',
34+
preHook: {
35+
binding: 'hooks.doubleArgs',
36+
},
37+
})
38+
addWithPreHook(
39+
@param.query.number('a') a: number,
40+
@param.query.number('b') b: number,
41+
) {
42+
return {sum: a + b};
43+
}
44+
45+
@authorize({permissions: ['mcp.access']})
46+
@mcpTool({
47+
name: 'summary_add_with_posthook',
48+
description: 'Add with posthook',
49+
postHook: {
50+
binding: 'hooks.overrideResult',
51+
},
52+
})
53+
addWithPostHook(
54+
@param.query.number('a') a: number,
55+
@param.query.number('b') b: number,
56+
) {
57+
return {sum: a + b};
58+
}
59+
}
Lines changed: 141 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,169 @@
1-
import {expect, sinon} from '@loopback/testlab';
2-
import {describe, it, before, after} from 'mocha';
3-
import http from 'http';
4-
import {randomUUID} from 'crypto';
5-
import {z} from 'zod';
6-
import {Server} from '@modelcontextprotocol/sdk/server/index.js';
7-
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
1+
import {RestApplication} from '@loopback/rest';
2+
import {expect} from '@loopback/testlab';
83
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
94
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
10-
11-
describe('Calculator Tool - Add Method (MCP Streamable HTTP)', () => {
12-
let httpServer: http.Server;
13-
let server: Server;
14-
let transport: StreamableHTTPServerTransport;
5+
import {IAuthUserWithPermissions, ILogger, LOGGER} from '@sourceloop/core';
6+
import {AuthenticationBindings} from 'loopback4-authentication';
7+
import {AuthorizationBindings} from 'loopback4-authorization';
8+
import {McpController} from '../../controllers';
9+
import {McpSchemaGeneratorService, McpServerFactory} from '../../services';
10+
import {McpToolRegistry} from '../../services/mcp-tool-registry.service';
11+
import {ProjectSummaryController} from './helpers/sample-controller';
12+
import {McpHookContext} from '../../interfaces';
13+
14+
describe('MCP Tool – add (acceptance)', () => {
15+
let app: RestApplication;
1516
let client: Client;
16-
let port: number;
17-
18-
let addSpy: sinon.SinonSpy;
17+
let transport: StreamableHTTPClientTransport;
1918

2019
before(async () => {
21-
server = new Server(
22-
{name: 'calculator-mcp-server', version: '1.0.0'},
23-
{capabilities: {tools: {}}},
24-
);
20+
app = new RestApplication();
21+
const mockLogger: ILogger = {
22+
log: () => {},
23+
info: () => {},
24+
warn: () => {},
25+
error: () => {},
26+
debug: () => {},
27+
};
28+
29+
app.bind(LOGGER.LOGGER_INJECT).to(mockLogger);
30+
app.controller(McpController);
31+
app.controller(ProjectSummaryController);
32+
33+
app.bind(AuthenticationBindings.CURRENT_USER).to({
34+
username: 'test-user',
35+
permissions: ['mcp.access'],
36+
} as IAuthUserWithPermissions);
37+
38+
app
39+
.bind(AuthorizationBindings.AUTHORIZE_ACTION)
40+
.to(async (permissions: string[]) => {
41+
return permissions.includes('mcp.access');
42+
});
2543

26-
addSpy = sinon.spy(async (args: {a: number; b: number}) => {
27-
const sum = args.a + args.b;
28-
return {
29-
content: [
30-
{
31-
type: 'text',
32-
text: `The sum of ${args.a} and ${args.b} is ${sum}`,
33-
},
34-
],
35-
};
36-
});
44+
app.bind('hooks.doubleArgs').to(async ({args}: McpHookContext) => ({
45+
args: {
46+
a: (args.a as number) * 2,
47+
b: (args.b as number) * 2,
48+
},
49+
}));
3750

38-
server.setRequestHandler(
39-
z.object({
40-
method: z.literal('tools/call'),
41-
params: z.object({
42-
name: z.literal('calculator_add'),
43-
arguments: z.object({
44-
a: z.number(),
45-
b: z.number(),
46-
}),
47-
}),
48-
}),
49-
async request => {
50-
return addSpy(request.params.arguments);
51+
app.bind('hooks.overrideResult').to(async () => ({
52+
result: {
53+
content: [{type: 'text', text: 'OVERRIDDEN'}],
5154
},
52-
);
55+
}));
56+
app.service(McpToolRegistry);
57+
app.service(McpServerFactory);
58+
app.service(McpSchemaGeneratorService);
5359

54-
transport = new StreamableHTTPServerTransport({
55-
sessionIdGenerator: () => randomUUID(),
56-
});
60+
await app.start();
5761

58-
await server.connect(transport);
62+
const registry = await app.get<McpToolRegistry>('services.McpToolRegistry');
5963

60-
httpServer = http.createServer((req, res) => {
61-
if (req.method !== 'POST' || req.url !== '/mcp') {
62-
res.statusCode = 404;
63-
res.end();
64-
return;
65-
}
64+
await registry.initialize();
65+
const restServerUrl = app.restServer.url;
66+
transport = new StreamableHTTPClientTransport(
67+
new URL(`${restServerUrl}/mcp`),
68+
);
6669

67-
let body = '';
68-
req.on('data', chunk => (body += chunk));
69-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
70-
req.on('end', async () => {
71-
const message = JSON.parse(body);
70+
client = new Client({
71+
name: 'test-client',
72+
version: '1.0.0',
73+
});
7274

73-
const response = await transport.handleRequest(req, res, message);
75+
await client.connect(transport);
76+
});
7477

75-
res.setHeader('Content-Type', 'application/json');
76-
res.end(JSON.stringify(response));
77-
});
78-
});
78+
after(async () => {
79+
if (client) {
80+
await client.close();
81+
}
82+
if (app) {
83+
await app.stop();
84+
}
85+
});
7986

80-
await new Promise<void>(resolve => {
81-
httpServer.listen(0, () => {
82-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
83-
port = (httpServer.address() as any).port;
84-
resolve();
85-
});
87+
it('invokes add tool and returns correct sum', async () => {
88+
const result = await client.callTool({
89+
name: 'summary_get_add',
90+
arguments: {
91+
a: 10,
92+
b: 20,
93+
},
8694
});
87-
client = new Client(
88-
{name: 'calculator-test-client', version: '1.0.0'},
89-
{capabilities: {tools: {}}},
95+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
96+
expect((result as any).content[0].text).to.equal(
97+
'The sum of 10 and 20 is 30',
9098
);
99+
});
100+
101+
it('denies execution for unauthorized user', async () => {
102+
app.bind(AuthenticationBindings.CURRENT_USER).to({
103+
username: 'unauthorized-user',
104+
permissions: [] as string[],
105+
} as IAuthUserWithPermissions);
91106

92-
await client.connect(
93-
new StreamableHTTPClientTransport(
94-
new URL(`http://localhost:${port}/mcp`),
95-
),
107+
const result = await client.callTool({
108+
name: 'summary_get_add',
109+
arguments: {a: 1, b: 2},
110+
});
111+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112+
expect((result as any).isError).to.be.true();
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
114+
expect((result as any).content[0].text).to.containEql(
115+
'Access denied for MCP tool',
96116
);
117+
118+
// Restore authorized user for subsequent tests
119+
app.bind(AuthenticationBindings.CURRENT_USER).to({
120+
username: 'test-user',
121+
permissions: ['mcp.access'],
122+
} as IAuthUserWithPermissions);
97123
});
98124

99-
after(async () => {
100-
sinon.restore();
101-
await client.close();
102-
await server.close();
103-
await new Promise(resolve => httpServer.close(resolve));
125+
it('fails when required parameter is missing', async () => {
126+
await expect(
127+
client.callTool({
128+
name: 'summary_get_add',
129+
arguments: {a: 10},
130+
}),
131+
).to.be.rejected();
104132
});
105133

106-
it('should call add method and return correct sum', async () => {
107-
const result = await client.request(
108-
{
109-
method: 'tools/call',
110-
params: {
111-
name: 'calculator_add',
112-
arguments: {a: 5, b: 7},
113-
},
114-
},
115-
z.object({
116-
content: z.array(
117-
z.object({
118-
type: z.literal('text'),
119-
text: z.string(),
120-
}),
121-
),
134+
it('fails when parameter types are invalid', async () => {
135+
await expect(
136+
client.callTool({
137+
name: 'summary_get_add',
138+
arguments: {a: 'ten', b: 'twenty'},
122139
}),
123-
);
140+
).to.be.rejected();
141+
});
142+
143+
it('exposes tool via tools/list', async () => {
144+
const tools = await client.listTools();
145+
const names = tools.tools.map(t => t.name);
146+
147+
expect(names).to.containEql('summary_get_add');
148+
});
124149

125-
expect(result.content[0].text).to.equal('The sum of 5 and 7 is 12');
150+
it('executes pre-hook and modifies arguments', async () => {
151+
const result = await client.callTool({
152+
name: 'summary_add_with_prehook',
153+
arguments: {a: 2, b: 3},
154+
});
155+
156+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
157+
const response = JSON.parse((result as any).content[0].text);
158+
expect(response.sum).to.equal(10);
159+
});
126160

127-
sinon.assert.calledOnce(addSpy);
128-
sinon.assert.calledWithMatch(addSpy, {a: 5, b: 7});
161+
it('executes post-hook and overrides result', async () => {
162+
const result = await client.callTool({
163+
name: 'summary_add_with_posthook',
164+
arguments: {a: 1, b: 1},
165+
});
166+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
167+
expect((result as any).content[0].text).to.equal('OVERRIDDEN');
129168
});
130169
});

src/__tests__/unit/mcp-controller.unit.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,28 +88,6 @@ describe('McpController (unit)', () => {
8888
});
8989

9090
describe('handleMCPRequest', () => {
91-
it('should successfully handle MCP request', async () => {
92-
await controller.handleMCPRequest(req, res);
93-
94-
sinon.assert.calledOnce(serverFactory.createServer as sinon.SinonStub);
95-
sinon.assert.calledOnce(mockServer.connect);
96-
sinon.assert.calledWith(mockTransport.handleRequest, req, res, req.body);
97-
sinon.assert.calledTwice(res.on as sinon.SinonStub);
98-
sinon.assert.calledWith(
99-
res.on as sinon.SinonStub,
100-
'close',
101-
sinon.match.func,
102-
);
103-
104-
sinon.assert.calledWith(
105-
res.on as sinon.SinonStub,
106-
'error',
107-
sinon.match.func,
108-
);
109-
110-
sinon.assert.notCalled(logger.error as sinon.SinonStub);
111-
});
112-
11391
it('should handle close event and cleanup resources', async () => {
11492
let closeHandler: Function | undefined;
11593

0 commit comments

Comments
 (0)