Skip to content

Commit 7f3b03a

Browse files
committed
Generate tests for the MCP plugin
Assisted-by: claude-4-sonnet Signed-off-by: John Collier <[email protected]>
1 parent 1e5b2c1 commit 7f3b03a

File tree

2 files changed

+352
-1
lines changed

2 files changed

+352
-1
lines changed
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { fetchCatalogEntities } from './plugin';
17+
import { CatalogService } from '@backstage/plugin-catalog-node';
18+
import { Entity } from '@backstage/catalog-model';
19+
20+
describe('backstageMcpPlugin', () => {
21+
describe('fetchCatalogEntities function', () => {
22+
const mockCatalogService = {
23+
getEntities: jest.fn(),
24+
} as unknown as CatalogService;
25+
26+
const mockAuthService = {
27+
getOwnServiceCredentials: jest.fn(),
28+
};
29+
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it('should fetch catalog entities successfully', async () => {
35+
const mockEntities: Entity[] = [
36+
{
37+
apiVersion: 'backstage.io/v1alpha1',
38+
kind: 'Component',
39+
metadata: {
40+
name: 'my-service',
41+
tags: ['java', 'spring'],
42+
},
43+
},
44+
{
45+
apiVersion: 'backstage.io/v1alpha1',
46+
kind: 'API',
47+
metadata: {
48+
name: 'my-api',
49+
tags: ['rest', 'openapi'],
50+
},
51+
},
52+
{
53+
apiVersion: 'backstage.io/v1alpha1',
54+
kind: 'System',
55+
metadata: {
56+
name: 'my-system',
57+
tags: [],
58+
},
59+
},
60+
];
61+
62+
mockAuthService.getOwnServiceCredentials.mockResolvedValue({
63+
principal: { type: 'service', subject: 'test' },
64+
token: 'test-token',
65+
});
66+
67+
(mockCatalogService.getEntities as jest.Mock).mockResolvedValue({
68+
items: mockEntities,
69+
});
70+
71+
const result = await fetchCatalogEntities(
72+
mockCatalogService,
73+
mockAuthService,
74+
);
75+
76+
expect(mockAuthService.getOwnServiceCredentials).toHaveBeenCalledTimes(1);
77+
expect(mockCatalogService.getEntities).toHaveBeenCalledWith(
78+
{
79+
fields: ['metadata.name', 'kind', 'metadata.tags'],
80+
},
81+
{
82+
credentials: {
83+
principal: { type: 'service', subject: 'test' },
84+
token: 'test-token',
85+
},
86+
},
87+
);
88+
89+
expect(result).toEqual({
90+
entities: [
91+
{
92+
name: 'my-service',
93+
kind: 'Component',
94+
tags: ['java', 'spring'],
95+
},
96+
{
97+
name: 'my-api',
98+
kind: 'API',
99+
tags: ['rest', 'openapi'],
100+
},
101+
{
102+
name: 'my-system',
103+
kind: 'System',
104+
tags: [],
105+
},
106+
],
107+
});
108+
});
109+
110+
it('should handle entities with no tags', async () => {
111+
const mockEntities: Entity[] = [
112+
{
113+
apiVersion: 'backstage.io/v1alpha1',
114+
kind: 'Component',
115+
metadata: {
116+
name: 'service-no-tags',
117+
},
118+
},
119+
];
120+
121+
mockAuthService.getOwnServiceCredentials.mockResolvedValue({
122+
principal: { type: 'service', subject: 'test' },
123+
token: 'test-token',
124+
});
125+
126+
(mockCatalogService.getEntities as jest.Mock).mockResolvedValue({
127+
items: mockEntities,
128+
});
129+
130+
const result = await fetchCatalogEntities(
131+
mockCatalogService,
132+
mockAuthService,
133+
);
134+
135+
expect(result).toEqual({
136+
entities: [
137+
{
138+
name: 'service-no-tags',
139+
kind: 'Component',
140+
tags: [],
141+
},
142+
],
143+
});
144+
});
145+
146+
it('should handle empty catalog', async () => {
147+
mockAuthService.getOwnServiceCredentials.mockResolvedValue({
148+
principal: { type: 'service', subject: 'test' },
149+
token: 'test-token',
150+
});
151+
152+
(mockCatalogService.getEntities as jest.Mock).mockResolvedValue({
153+
items: [],
154+
});
155+
156+
const result = await fetchCatalogEntities(
157+
mockCatalogService,
158+
mockAuthService,
159+
);
160+
161+
expect(result).toEqual({
162+
entities: [],
163+
});
164+
});
165+
166+
it('should handle catalog service errors', async () => {
167+
mockAuthService.getOwnServiceCredentials.mockResolvedValue({
168+
principal: { type: 'service', subject: 'test' },
169+
token: 'test-token',
170+
});
171+
172+
(mockCatalogService.getEntities as jest.Mock).mockRejectedValue(
173+
new Error('Catalog service error'),
174+
);
175+
176+
await expect(
177+
fetchCatalogEntities(mockCatalogService, mockAuthService),
178+
).rejects.toThrow('Catalog service error');
179+
});
180+
181+
it('should handle authentication errors', async () => {
182+
mockAuthService.getOwnServiceCredentials.mockRejectedValue(
183+
new Error('Authentication failed'),
184+
);
185+
186+
await expect(
187+
fetchCatalogEntities(mockCatalogService, mockAuthService),
188+
).rejects.toThrow('Authentication failed');
189+
});
190+
});
191+
192+
describe('MCP Action functions', () => {
193+
describe('greet-user action logic', () => {
194+
it('should generate personalized greeting', async () => {
195+
// Test the core logic of the greet-user action
196+
const greetUserAction = async ({
197+
input,
198+
}: {
199+
input: { name: string };
200+
}) => ({
201+
output: { greeting: `Hello ${input.name}!` },
202+
});
203+
204+
const testCases = [
205+
{ input: { name: 'John Doe' }, expected: 'Hello John Doe!' },
206+
{ input: { name: '' }, expected: 'Hello !' },
207+
{ input: { name: 'José María' }, expected: 'Hello José María!' },
208+
{ input: { name: 'Alice123' }, expected: 'Hello Alice123!' },
209+
];
210+
211+
for (const testCase of testCases) {
212+
const result = await greetUserAction(testCase);
213+
expect(result.output.greeting).toBe(testCase.expected);
214+
}
215+
});
216+
});
217+
218+
describe('fetch-catalog-entities action logic', () => {
219+
it('should use fetchCatalogEntities function correctly', async () => {
220+
const mockCatalogService = {
221+
getEntities: jest.fn().mockResolvedValue({
222+
items: [
223+
{
224+
apiVersion: 'backstage.io/v1alpha1',
225+
kind: 'Component',
226+
metadata: {
227+
name: 'test-component',
228+
tags: ['test'],
229+
},
230+
},
231+
],
232+
}),
233+
} as unknown as CatalogService;
234+
235+
const mockAuthService = {
236+
getOwnServiceCredentials: jest.fn().mockResolvedValue({
237+
principal: { type: 'service', subject: 'test' },
238+
token: 'test-token',
239+
}),
240+
};
241+
242+
// Test the action logic
243+
const fetchCatalogEntitiesAction = async ({}) => ({
244+
output: await fetchCatalogEntities(
245+
mockCatalogService,
246+
mockAuthService,
247+
),
248+
});
249+
250+
const result = await fetchCatalogEntitiesAction({});
251+
252+
expect(result.output).toHaveProperty('entities');
253+
expect(Array.isArray(result.output.entities)).toBe(true);
254+
expect(result.output.entities).toHaveLength(1);
255+
expect(result.output.entities[0]).toEqual({
256+
name: 'test-component',
257+
kind: 'Component',
258+
tags: ['test'],
259+
});
260+
});
261+
});
262+
});
263+
264+
describe('Action schemas and metadata', () => {
265+
it('should have correct greet-user action structure', () => {
266+
const greetUserActionDefinition = {
267+
name: 'greet-user',
268+
title: 'Greet User',
269+
description: 'Generate a personalized greeting',
270+
schema: {
271+
input: (z: any) =>
272+
z.object({
273+
name: z.string().describe('The name of the person to greet'),
274+
}),
275+
output: (z: any) =>
276+
z.object({
277+
greeting: z.string().describe('The generated greeting'),
278+
}),
279+
},
280+
};
281+
282+
expect(greetUserActionDefinition.name).toBe('greet-user');
283+
expect(greetUserActionDefinition.title).toBe('Greet User');
284+
expect(greetUserActionDefinition.description).toBe(
285+
'Generate a personalized greeting',
286+
);
287+
expect(greetUserActionDefinition.schema.input).toBeDefined();
288+
expect(greetUserActionDefinition.schema.output).toBeDefined();
289+
});
290+
291+
it('should have correct fetch-catalog-entities action structure', () => {
292+
const fetchCatalogEntitiesActionDefinition = {
293+
name: 'fetch-catalog-entities',
294+
title: 'Fetch Catalog Entities',
295+
description:
296+
'Retrieve the list of catalog entities from the Backstage server.',
297+
schema: {
298+
input: (z: any) => z.object({}),
299+
output: (z: any) =>
300+
z.object({
301+
entities: z
302+
.array(
303+
z.object({
304+
name: z
305+
.string()
306+
.describe(
307+
'The name field for each Backstage entity in the catalog',
308+
),
309+
kind: z
310+
.string()
311+
.describe(
312+
'The kind/type of the Backstage entity (e.g., Component, API, System)',
313+
),
314+
tags: z
315+
.array(z.string())
316+
.describe(
317+
'The tags associated with the Backstage entity',
318+
),
319+
}),
320+
)
321+
.describe('An array of entities'),
322+
}),
323+
},
324+
};
325+
326+
expect(fetchCatalogEntitiesActionDefinition.name).toBe(
327+
'fetch-catalog-entities',
328+
);
329+
expect(fetchCatalogEntitiesActionDefinition.title).toBe(
330+
'Fetch Catalog Entities',
331+
);
332+
expect(fetchCatalogEntitiesActionDefinition.description).toBe(
333+
'Retrieve the list of catalog entities from the Backstage server.',
334+
);
335+
expect(fetchCatalogEntitiesActionDefinition.schema.input).toBeDefined();
336+
expect(fetchCatalogEntitiesActionDefinition.schema.output).toBeDefined();
337+
});
338+
});
339+
});

workspaces/mcp-integrations/plugins/backstage-mcp-plugin-backend/src/plugin.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ export const backstageMcpPlugin = createBackendPlugin({
7777
.describe(
7878
'The name field for each Backstage entity in the catalog',
7979
),
80+
kind: z
81+
.string()
82+
.describe(
83+
'The kind/type of the Backstage entity (e.g., Component, API, System)',
84+
),
85+
tags: z
86+
.array(z.string())
87+
.describe(
88+
'The tags associated with the Backstage entity',
89+
),
8090
}),
8191
)
8292
.describe('An array of entities'),
@@ -96,7 +106,7 @@ export async function fetchCatalogEntities(catalog: CatalogService, auth: any) {
96106
const credentials = await auth.getOwnServiceCredentials();
97107
const { items } = await catalog.getEntities(
98108
{
99-
fields: ['metadata.name'],
109+
fields: ['metadata.name', 'kind', 'metadata.tags'],
100110
},
101111
{
102112
credentials,
@@ -106,6 +116,8 @@ export async function fetchCatalogEntities(catalog: CatalogService, auth: any) {
106116
return {
107117
entities: items.map(entity => ({
108118
name: entity.metadata.name,
119+
kind: entity.kind,
120+
tags: entity.metadata.tags || [],
109121
})),
110122
};
111123
}

0 commit comments

Comments
 (0)