Skip to content

Commit f27d3d5

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

File tree

10 files changed

+401
-147
lines changed

10 files changed

+401
-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: 145 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,174 @@
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';
10+
11+
const MCP_ACCESS_PERMISSION = 'mcp.access';
12+
const UNEXPECTED_MESSAGE_FORMAT = 'Unexpected message format';
13+
14+
async function generateToken(permissions: string[]): Promise<string> {
15+
return jwt.sign(
16+
{
17+
id: 'test-user',
18+
permissions,
19+
},
20+
process.env.JWT_SECRET ?? 'test-secret',
21+
{
22+
issuer: process.env.JWT_ISSUER,
23+
algorithm: 'HS256',
24+
},
25+
);
26+
}
27+
28+
describe('MCP Tool – add (acceptance)', () => {
29+
let app: TestingApplication;
30+
let restServerUrl: string;
1031

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;
32+
before(async () => {
33+
app = new TestingApplication();
34+
app.bind('hooks.doubleArgs').to(async ({args}: McpHookContext) => ({
35+
args: {
36+
a: (args.a as number) * 2,
37+
b: (args.b as number) * 2,
38+
},
39+
}));
1740

18-
let addSpy: sinon.SinonSpy;
41+
app.bind('hooks.overrideResult').to(async () => ({
42+
result: {
43+
content: [{type: 'text', text: 'OVERRIDDEN'}],
44+
},
45+
}));
46+
app.service(McpSchemaGeneratorService);
1947

20-
before(async () => {
21-
server = new Server(
22-
{name: 'calculator-mcp-server', version: '1.0.0'},
23-
{capabilities: {tools: {}}},
24-
);
48+
await app.start();
2549

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-
});
50+
const registry = await app.get<McpToolRegistry>('services.McpToolRegistry');
51+
await registry.initialize();
3752

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);
53+
restServerUrl = app.restServer.url ?? '';
54+
});
55+
56+
after(async () => {
57+
await app.stop();
58+
});
59+
60+
async function createClient(token: string) {
61+
const transport = new StreamableHTTPClientTransport(
62+
new URL(`${restServerUrl}/mcp`),
63+
{
64+
requestInit: {
65+
headers: {
66+
Authorization: `Bearer ${token}`,
67+
},
68+
},
5169
},
5270
);
5371

54-
transport = new StreamableHTTPServerTransport({
55-
sessionIdGenerator: () => randomUUID(),
72+
const client = new Client({
73+
name: 'test-client',
74+
version: '1.0.0',
5675
});
5776

58-
await server.connect(transport);
77+
await client.connect(transport);
78+
return client;
79+
}
5980

60-
httpServer = http.createServer((req, res) => {
61-
if (req.method !== 'POST' || req.url !== '/mcp') {
62-
res.statusCode = 404;
63-
res.end();
64-
return;
65-
}
81+
it('invokes add tool and returns correct sum', async () => {
82+
const token = await generateToken([MCP_ACCESS_PERMISSION]);
83+
const client = await createClient(token);
6684

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);
85+
const result = await client.callTool({
86+
name: 'summary_get_add',
87+
arguments: {a: 10, b: 20},
88+
});
89+
if (isTextMessage(result)) {
90+
expect(result.content[0].text).to.equal('The sum of 10 and 20 is 30');
91+
} else {
92+
throw new Error(UNEXPECTED_MESSAGE_FORMAT);
93+
}
94+
95+
await client.close();
96+
});
7297

73-
const response = await transport.handleRequest(req, res, message);
98+
it('denies execution for unauthorized user', async () => {
99+
const token = await generateToken([]);
100+
const client = await createClient(token);
74101

75-
res.setHeader('Content-Type', 'application/json');
76-
res.end(JSON.stringify(response));
77-
});
102+
const result = await client.callTool({
103+
name: 'summary_get_add',
104+
arguments: {a: 1, b: 2},
78105
});
106+
if (isTextMessage(result)) {
107+
expect(result.isError).to.be.true();
108+
expect(result.content[0].text).to.containEql(
109+
'Access denied for MCP tool',
110+
);
111+
} else {
112+
throw new Error(UNEXPECTED_MESSAGE_FORMAT);
113+
}
79114

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-
});
115+
await client.close();
116+
});
117+
118+
it('denies execution when permission is different', async () => {
119+
const token = await generateToken(['some.other.permission']);
120+
const client = await createClient(token);
121+
122+
const result = await client.callTool({
123+
name: 'summary_get_add',
124+
arguments: {a: 1, b: 2},
86125
});
87-
client = new Client(
88-
{name: 'calculator-test-client', version: '1.0.0'},
89-
{capabilities: {tools: {}}},
90-
);
91126

92-
await client.connect(
93-
new StreamableHTTPClientTransport(
94-
new URL(`http://localhost:${port}/mcp`),
95-
),
96-
);
127+
if (isTextMessage(result)) {
128+
expect(result.isError).to.be.true();
129+
expect(result.content[0].text).to.containEql(
130+
'Access denied for MCP tool',
131+
);
132+
} else {
133+
throw new Error(UNEXPECTED_MESSAGE_FORMAT);
134+
}
135+
136+
await client.close();
97137
});
98138

99-
after(async () => {
100-
sinon.restore();
139+
it('executes pre-hook and modifies arguments', async () => {
140+
const token = await generateToken([MCP_ACCESS_PERMISSION]);
141+
const client = await createClient(token);
142+
143+
const result = await client.callTool({
144+
name: 'summary_add_with_prehook',
145+
arguments: {a: 2, b: 3},
146+
});
147+
if (isTextMessage(result)) {
148+
const response = JSON.parse(result.content[0].text);
149+
expect(response.sum).to.equal(10);
150+
} else {
151+
throw new Error(UNEXPECTED_MESSAGE_FORMAT);
152+
}
153+
101154
await client.close();
102-
await server.close();
103-
await new Promise(resolve => httpServer.close(resolve));
104155
});
105156

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-
);
157+
it('executes post-hook and overrides result', async () => {
158+
const token = await generateToken([MCP_ACCESS_PERMISSION]);
159+
const client = await createClient(token);
160+
161+
const result = await client.callTool({
162+
name: 'summary_add_with_posthook',
163+
arguments: {a: 1, b: 1},
164+
});
124165

125-
expect(result.content[0].text).to.equal('The sum of 5 and 7 is 12');
166+
if (isTextMessage(result)) {
167+
expect(result.content[0].text).to.equal('OVERRIDDEN');
168+
} else {
169+
throw new Error(UNEXPECTED_MESSAGE_FORMAT);
170+
}
126171

127-
sinon.assert.calledOnce(addSpy);
128-
sinon.assert.calledWithMatch(addSpy, {a: 5, b: 7});
172+
await client.close();
129173
});
130174
});

0 commit comments

Comments
 (0)