Skip to content

Commit 8f94895

Browse files
committed
chore: breaking up tool loading functionality
1 parent 7746b32 commit 8f94895

15 files changed

+239
-79
lines changed

jest.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} **/
2+
module.exports = {
3+
testEnvironment: 'node',
4+
transform: {
5+
'^.+\.tsx?$': ['ts-jest', {}],
6+
},
7+
};

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,22 @@
99
"zod": "^3.22.2"
1010
},
1111
"devDependencies": {
12-
"@types/jest": "^29.5.4",
12+
"@jest/globals": "^29.7.0",
13+
"@types/jest": "^29.5.14",
1314
"eslint": "^8.57.0",
1415
"jest": "^29.7.0",
1516
"prettier": "^3.5.3",
16-
"ts-jest": "^29.1.1",
17-
"typescript": "^5.3.3"
17+
"ts-jest": "^29.3.2",
18+
"typescript": "^5.8.3"
1819
},
19-
"main": "dist/server.js",
20+
"main": "dist/index.js",
2021
"name": "mcp-backstage-server",
2122
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
2223
"scripts": {
2324
"build": "tsc",
2425
"lint": "eslint . --ext .ts",
2526
"lint:fix": "npx prettier . --write",
26-
"start": "node dist/server.js",
27+
"start": "node dist/index.js",
2728
"test": "jest --coverage"
2829
},
2930
"version": "1.0.0"

src/server.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3-
import { BackstageCatalogApi } from './api/backstage-catalog-api';
42
import { IToolRegistrationContext } from './types';
3+
import { BackstageCatalogApi } from './api/backstage-catalog-api';
54
import { ToolLoader } from './utils/tool-loader';
5+
import { DefaultToolFactory } from './utils/tool-factory';
6+
import { DefaultToolRegistrar } from './utils/tool-registrar';
7+
import { DefaultToolValidator } from './utils/tool-validator';
8+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9+
import { ReflectToolMetadataProvider } from './utils/tool-metadata';
610

711
export async function startServer(): Promise<void> {
812
const server = new McpServer({
@@ -15,8 +19,15 @@ export async function startServer(): Promise<void> {
1519
catalogClient: new BackstageCatalogApi({ baseUrl: '' }),
1620
};
1721

18-
const toolLoader = new ToolLoader('./tools', context);
19-
await toolLoader.registerAllTools();
22+
const toolLoader = new ToolLoader(
23+
'./tools',
24+
new DefaultToolFactory(),
25+
new DefaultToolRegistrar(context),
26+
new DefaultToolValidator(),
27+
new ReflectToolMetadataProvider()
28+
);
29+
30+
await toolLoader.registerAll();
2031

2132
if (process.env.NODE_ENV !== 'production') {
2233
await toolLoader.exportManifest('./tools-manifest.json');

src/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
McpServer,
44
RegisteredTool,
55
} from '@modelcontextprotocol/sdk/server/mcp.js';
6+
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
67
import { z } from 'zod';
78

89
export enum ApiStatus {
@@ -32,3 +33,29 @@ export interface ToolMetadata {
3233
description: string;
3334
paramsSchema: z.ZodTypeAny;
3435
}
36+
37+
export interface Tool {
38+
execute(args: unknown, context: object): Promise<CallToolResult>;
39+
}
40+
41+
export interface ToolMetadataProvider {
42+
getMetadata(toolClass: unknown): ToolMetadata | undefined;
43+
}
44+
45+
export interface ToolValidator {
46+
validate(metadata: ToolMetadata, file: string): void;
47+
}
48+
49+
export interface ToolRegistrar {
50+
register(toolClass: ToolConstructor, metadata: ToolMetadata): void;
51+
}
52+
53+
export interface ToolFactory {
54+
loadTool(filePath: string): Promise<ToolConstructor | undefined>;
55+
}
56+
57+
export type ToolConstructor = {
58+
execute(args: unknown, context: object): Promise<CallToolResult>;
59+
};
60+
61+
export type ToolClass = ToolConstructor | undefined;

src/utils/guards.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1+
/**
2+
*
3+
* @param value
4+
* @returns
5+
*/
16
export function isString(value: unknown): value is string {
27
return typeof value === 'string';
38
}
49

10+
/**
11+
*
12+
* @param value
13+
* @returns
14+
*/
515
export function isFunction(value: unknown): value is Function {
616
return typeof value === 'function';
717
}
18+
19+
/**
20+
*
21+
* @param value
22+
* @returns
23+
*/
24+
export function isBigInt(value: unknown): value is bigint {
25+
return typeof value === 'bigint';
26+
}

src/utils/mapping.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { z, ZodTypeAny, ZodObject, ZodRawShape } from 'zod';
1+
import { ZodTypeAny, ZodObject, ZodRawShape } from 'zod';
22

33
/**
44
* Converts a ZodType into a ZodRawShape if possible.

src/utils/responses.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
22
import { IApiResponse } from '../types';
3+
import { isBigInt } from './guards';
34

45
/**
56
*
@@ -14,6 +15,6 @@ export function JsonToTextResponse<T extends IApiResponse>(
1415
data: T
1516
): CallToolResult {
1617
return TextResponse(
17-
JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))
18+
JSON.stringify(data, (_k, v) => (isBigInt(v) ? v.toString() : v), 2)
1819
);
1920
}

src/utils/tool-factory.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ToolConstructor, ToolFactory } from '../types';
2+
import { isFunction } from '../utils/guards';
3+
4+
export class DefaultToolFactory implements ToolFactory {
5+
async loadTool(filePath: string): Promise<ToolConstructor | undefined> {
6+
try {
7+
const module = await import(filePath);
8+
return (module.default ??
9+
Object.values(module).find(isFunction)) as ToolConstructor;
10+
} catch (error) {
11+
console.error(`Failed to load tool from ${filePath}`, error);
12+
return undefined;
13+
}
14+
}
15+
}

src/utils/tool-loader.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ToolLoader } from './tool-loader';
2+
import { join } from 'path';
3+
4+
describe('ToolLoader', () => {
5+
const validTool = {
6+
execute: jest.fn(),
7+
};
8+
9+
const validMetadata = {
10+
name: 'demoTool',
11+
description: 'Demo',
12+
paramsSchema: { foo: 'bar' },
13+
};
14+
15+
const mockFactory = {
16+
loadTool: jest.fn(),
17+
};
18+
19+
const mockRegistrar = {
20+
register: jest.fn(),
21+
};
22+
23+
const mockValidator = {
24+
validate: jest.fn(),
25+
};
26+
27+
const mockMetadataProvider = {
28+
getMetadata: jest.fn(),
29+
};
30+
31+
const loader = new ToolLoader(
32+
join(__dirname, '__fixtures__'),
33+
mockFactory,
34+
mockRegistrar,
35+
mockValidator,
36+
mockMetadataProvider
37+
);
38+
39+
afterEach(() => jest.clearAllMocks());
40+
41+
it('should skip invalid tool files', async () => {
42+
mockFactory.loadTool.mockResolvedValue(undefined);
43+
await loader.registerAll();
44+
expect(mockRegistrar.register).not.toHaveBeenCalled();
45+
});
46+
47+
it('should skip tools without metadata', async () => {
48+
mockFactory.loadTool.mockResolvedValue(validTool);
49+
mockMetadataProvider.getMetadata.mockReturnValue(undefined);
50+
await loader.registerAll();
51+
expect(mockRegistrar.register).not.toHaveBeenCalled();
52+
});
53+
54+
it('should register valid tools', async () => {
55+
mockFactory.loadTool.mockResolvedValue(validTool);
56+
mockMetadataProvider.getMetadata.mockReturnValue(validMetadata);
57+
await loader.registerAll();
58+
expect(mockValidator.validate).toHaveBeenCalledWith(
59+
validMetadata,
60+
expect.any(String)
61+
);
62+
expect(mockRegistrar.register).toHaveBeenCalledWith(
63+
validTool,
64+
validMetadata
65+
);
66+
});
67+
});

src/utils/tool-loader.ts

Lines changed: 31 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { TOOL_METADATA_KEY } from '../decorators/tool.decorator';
2-
import { IToolRegistrationContext, ToolMetadata } from '../types';
3-
import { isFunction } from './guards';
41
import { join } from 'path';
52
import { readdir, writeFile } from 'fs/promises';
6-
import { validateToolMetadata } from './validate-tool-metadata';
7-
import { toZodRawShape } from './mapping';
3+
4+
import {
5+
ToolFactory,
6+
ToolMetadata,
7+
ToolMetadataProvider,
8+
ToolRegistrar,
9+
ToolValidator,
10+
} from '../types';
811

912
interface ToolManifestEntry {
1013
name: string;
@@ -17,45 +20,34 @@ export class ToolLoader {
1720

1821
constructor(
1922
private readonly directory: string,
20-
private readonly context: IToolRegistrationContext
23+
private readonly factory: ToolFactory,
24+
private readonly registrar: ToolRegistrar,
25+
private readonly validator: ToolValidator,
26+
private readonly metadataProvider: ToolMetadataProvider
2127
) {}
2228

23-
async registerAllTools(): Promise<void> {
24-
const toolFiles = await this.findToolFiles();
25-
26-
for (const file of toolFiles) {
27-
const loadedClass = await this.loadToolClass(join(this.directory, file));
28-
29-
if (!loadedClass) {
30-
this.logInvalidTool(file);
29+
async registerAll(): Promise<void> {
30+
const files = await this.findToolFiles();
31+
for (const file of files) {
32+
const toolPath = join(this.directory, file);
33+
const toolClass = await this.factory.loadTool(toolPath);
34+
if (!toolClass) {
35+
this.warnInvalid(file);
3136
continue;
3237
}
3338

34-
const metadata = Reflect.getMetadata(
35-
TOOL_METADATA_KEY,
36-
loadedClass
37-
) as ToolMetadata;
39+
const metadata = this.metadataProvider.getMetadata(toolClass);
3840
if (!metadata) {
39-
this.logInvalidTool(file);
41+
this.warnInvalid(file);
4042
continue;
4143
}
4244

43-
validateToolMetadata(metadata, file);
44-
45-
this.registerTool(loadedClass, metadata);
45+
this.validator.validate(metadata, file);
46+
this.registrar.register(toolClass, metadata);
4647
this.addToManifest(metadata);
4748
}
4849
}
4950

50-
private addToManifest({
51-
name,
52-
paramsSchema,
53-
description,
54-
}: ToolMetadata): void {
55-
const params = paramsSchema ? Object.keys(paramsSchema) : [];
56-
this.manifest.push({ name, description, params });
57-
}
58-
5951
async exportManifest(filePath: string): Promise<void> {
6052
await writeFile(filePath, JSON.stringify(this.manifest, null, 2), 'utf-8');
6153
}
@@ -67,33 +59,16 @@ export class ToolLoader {
6759
);
6860
}
6961

70-
private async loadToolClass(filePath: string): Promise<any> {
71-
try {
72-
const module = await import(filePath);
73-
const loadedClass =
74-
module.default ?? Object.values(module).find(isFunction);
75-
return loadedClass;
76-
} catch (error) {
77-
console.error(`Failed to load tool from ${filePath}`, error);
78-
return undefined;
79-
}
80-
}
81-
82-
private registerTool(
83-
loadedClass: any,
84-
{ name, description, paramsSchema }: ToolMetadata
85-
) {
86-
this.context.server.tool(
87-
name,
88-
description,
89-
toZodRawShape(paramsSchema),
90-
async (args, extra) => {
91-
return loadedClass.execute(args, { ...this.context, extra });
92-
}
93-
);
62+
private addToManifest({
63+
name,
64+
description,
65+
paramsSchema,
66+
}: ToolMetadata): void {
67+
const params = paramsSchema ? Object.keys(paramsSchema) : [];
68+
this.manifest.push({ name, description, params });
9469
}
9570

96-
private logInvalidTool(file: string): void {
71+
private warnInvalid(file: string): void {
9772
console.warn(`No valid tool class with @Tool decorator found in ${file}`);
9873
}
9974
}

0 commit comments

Comments
 (0)