Skip to content

Commit b95c21d

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

File tree

10 files changed

+398
-147
lines changed

10 files changed

+398
-147
lines changed

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,16 @@
6464
"@semantic-release/git": "^10.0.1",
6565
"@semantic-release/npm": "^9.0.1",
6666
"@semantic-release/release-notes-generator": "^10.0.3",
67+
"@types/jsonwebtoken": "^9.0.10",
6768
"@types/node": "^16.18.126",
6869
"commitizen": "^4.2.4",
6970
"cz-conventional-changelog": "^3.3.0",
7071
"cz-customizable": "^6.3.0",
7172
"cz-customizable-ghooks": "^2.0.0",
7273
"eslint": "^8.57.1",
7374
"husky": "^7.0.4",
75+
"loopback4-authentication": "^13.0.4",
76+
"loopback4-authorization": "^8.1.3",
7477
"semantic-release": "^24.2.9",
7578
"source-map-support": "^0.5.21",
7679
"typescript": "~5.2.2"
Lines changed: 142 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,171 @@
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 {expect} from '@loopback/testlab';
82
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
93
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4+
import * as jwt from 'jsonwebtoken';
5+
import {McpHookContext} from '../../interfaces';
6+
import {McpSchemaGeneratorService} from '../../services';
7+
import {McpToolRegistry} from '../../services/mcp-tool-registry.service';
8+
import {isTextMessage} from '../../types';
9+
import {TestingApplication} from '../fixtures/application';
1010

11-
describe('Calculator Tool - Add Method (MCP Streamable HTTP)', () => {
12-
let httpServer: http.Server;
13-
let server: Server;
14-
let transport: StreamableHTTPServerTransport;
15-
let client: Client;
16-
let port: number;
17-
18-
let addSpy: sinon.SinonSpy;
11+
describe('MCP Tool – add (acceptance)', () => {
12+
let app: TestingApplication;
13+
let restServerUrl: string;
1914

2015
before(async () => {
21-
server = new Server(
22-
{name: 'calculator-mcp-server', version: '1.0.0'},
23-
{capabilities: {tools: {}}},
24-
);
16+
app = new TestingApplication();
17+
app.bind('hooks.doubleArgs').to(async ({args}: McpHookContext) => ({
18+
args: {
19+
a: (args.a as number) * 2,
20+
b: (args.b as number) * 2,
21+
},
22+
}));
2523

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-
});
24+
app.bind('hooks.overrideResult').to(async () => ({
25+
result: {
26+
content: [{type: 'text', text: 'OVERRIDDEN'}],
27+
},
28+
}));
29+
app.service(McpSchemaGeneratorService);
3730

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);
31+
await app.start();
32+
33+
const registry = await app.get<McpToolRegistry>('services.McpToolRegistry');
34+
await registry.initialize();
35+
36+
restServerUrl = app.restServer.url ?? '';
37+
});
38+
39+
after(async () => {
40+
await app.stop();
41+
});
42+
43+
async function createClient(token: string) {
44+
const transport = new StreamableHTTPClientTransport(
45+
new URL(`${restServerUrl}/mcp`),
46+
{
47+
requestInit: {
48+
headers: {
49+
Authorization: `Bearer ${token}`,
50+
},
51+
},
5152
},
5253
);
5354

54-
transport = new StreamableHTTPServerTransport({
55-
sessionIdGenerator: () => randomUUID(),
55+
const client = new Client({
56+
name: 'test-client',
57+
version: '1.0.0',
5658
});
5759

58-
await server.connect(transport);
60+
await client.connect(transport);
61+
return client;
62+
}
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+
it('invokes add tool and returns correct sum', async () => {
65+
const token = await generateToken(['mcp.access']);
66+
const client = await createClient(token);
6667

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);
68+
const result = await client.callTool({
69+
name: 'summary_get_add',
70+
arguments: {a: 10, b: 20},
71+
});
72+
if (isTextMessage(result)) {
73+
expect(result.content[0].text).to.equal('The sum of 10 and 20 is 30');
74+
} else {
75+
throw new Error('Unexpected message format');
76+
}
7277

73-
const response = await transport.handleRequest(req, res, message);
78+
await client.close();
79+
});
80+
81+
it('denies execution for unauthorized user', async () => {
82+
const token = await generateToken([]);
83+
const client = await createClient(token);
7484

75-
res.setHeader('Content-Type', 'application/json');
76-
res.end(JSON.stringify(response));
77-
});
85+
const result = await client.callTool({
86+
name: 'summary_get_add',
87+
arguments: {a: 1, b: 2},
7888
});
89+
if (isTextMessage(result)) {
90+
expect(result.isError).to.be.true();
91+
expect(result.content[0].text).to.containEql(
92+
'Access denied for MCP tool',
93+
);
94+
} else {
95+
throw new Error('Unexpected message format');
96+
}
97+
98+
await client.close();
99+
});
79100

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-
});
101+
it('denies execution when permission is different', async () => {
102+
const token = await generateToken(['some.other.permission']);
103+
const client = await createClient(token);
104+
105+
const result = await client.callTool({
106+
name: 'summary_get_add',
107+
arguments: {a: 1, b: 2},
86108
});
87-
client = new Client(
88-
{name: 'calculator-test-client', version: '1.0.0'},
89-
{capabilities: {tools: {}}},
90-
);
91109

92-
await client.connect(
93-
new StreamableHTTPClientTransport(
94-
new URL(`http://localhost:${port}/mcp`),
95-
),
96-
);
110+
if (isTextMessage(result)) {
111+
expect(result.isError).to.be.true();
112+
expect(result.content[0].text).to.containEql(
113+
'Access denied for MCP tool',
114+
);
115+
} else {
116+
throw new Error('Unexpected message format');
117+
}
118+
119+
await client.close();
97120
});
98121

99-
after(async () => {
100-
sinon.restore();
122+
it('executes pre-hook and modifies arguments', async () => {
123+
const token = await generateToken(['mcp.access']);
124+
const client = await createClient(token);
125+
126+
const result = await client.callTool({
127+
name: 'summary_add_with_prehook',
128+
arguments: {a: 2, b: 3},
129+
});
130+
if (isTextMessage(result)) {
131+
const response = JSON.parse(result.content[0].text);
132+
expect(response.sum).to.equal(10);
133+
} else {
134+
throw new Error('Unexpected message format');
135+
}
136+
101137
await client.close();
102-
await server.close();
103-
await new Promise(resolve => httpServer.close(resolve));
104138
});
105139

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-
),
122-
}),
123-
);
140+
it('executes post-hook and overrides result', async () => {
141+
const token = await generateToken(['mcp.access']);
142+
const client = await createClient(token);
143+
144+
const result = await client.callTool({
145+
name: 'summary_add_with_posthook',
146+
arguments: {a: 1, b: 1},
147+
});
124148

125-
expect(result.content[0].text).to.equal('The sum of 5 and 7 is 12');
149+
if (isTextMessage(result)) {
150+
expect(result.content[0].text).to.equal('OVERRIDDEN');
151+
} else {
152+
throw new Error('Unexpected message format');
153+
}
126154

127-
sinon.assert.calledOnce(addSpy);
128-
sinon.assert.calledWithMatch(addSpy, {a: 5, b: 7});
155+
await client.close();
129156
});
157+
158+
async function generateToken(permissions: string[]): Promise<string> {
159+
return jwt.sign(
160+
{
161+
id: 'test-user',
162+
permissions,
163+
},
164+
process.env.JWT_SECRET ?? 'test-secret',
165+
{
166+
issuer: process.env.JWT_ISSUER,
167+
algorithm: 'HS256',
168+
},
169+
);
170+
}
130171
});

0 commit comments

Comments
 (0)