Skip to content

Commit caca4c2

Browse files
committed
feat(jenkins-backend): regiser MCP actions for Jenkins
Signed-off-by: Mattia Dell'Oca <mattia.delloca@supsi.ch>
1 parent e53ced9 commit caca4c2

25 files changed

Lines changed: 2165 additions & 148 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage-community/plugin-jenkins-backend': minor
3+
---
4+
5+
Registered Jenkins backend routes as actions into MCP actions backend. Refactored JenkinsBuilder to delegate to JenkinsService. Fixed response status ordering bug in rebuild endpoint.

workspaces/jenkins/app-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ backend:
2626
database:
2727
client: better-sqlite3
2828
connection: ':memory:'
29+
actions:
30+
pluginSources:
31+
- 'jenkins'
2932

3033
integrations:
3134
github:

workspaces/jenkins/packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@backstage/plugin-auth-backend-module-guest-provider": "backstage:^",
2929
"@backstage/plugin-catalog-backend": "backstage:^",
3030
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "backstage:^",
31+
"@backstage/plugin-mcp-actions-backend": "backstage:^",
3132
"@backstage/plugin-permission-backend": "backstage:^",
3233
"@backstage/plugin-permission-backend-module-allow-all-policy": "backstage:^",
3334
"@backstage/plugin-proxy-backend": "backstage:^",

workspaces/jenkins/packages/backend/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,7 @@ backend.add(import('@backstage/plugin-search-backend-module-techdocs'));
4949
// Jenkins
5050
backend.add(import('@backstage-community/plugin-jenkins-backend'));
5151

52+
// MCP Actions
53+
backend.add(import('@backstage/plugin-mcp-actions-backend'));
54+
5255
backend.start();
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2026 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+
17+
import { createGetBuildAction } from './createGetBuildAction';
18+
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
19+
import { JenkinsService } from '../service/jenkinsService';
20+
21+
describe('createGetBuildAction', () => {
22+
const mockJenkinsService = {
23+
getBuild: jest.fn(),
24+
} as unknown as jest.Mocked<JenkinsService>;
25+
26+
it('should return a sanitized build', async () => {
27+
const mockActionsRegistry = actionsRegistryServiceMock();
28+
29+
mockJenkinsService.getBuild.mockResolvedValueOnce({
30+
build: {
31+
building: false,
32+
displayName: '#42',
33+
duration: 5000,
34+
fullDisplayName: 'my-job #42',
35+
number: 42,
36+
result: 'SUCCESS',
37+
timestamp: 1000,
38+
url: 'https://jenkins.example.com/job/my-job/42',
39+
status: 'success',
40+
source: undefined,
41+
tests: undefined,
42+
actions: [{ _class: 'internal' }],
43+
},
44+
} as any);
45+
46+
createGetBuildAction({
47+
actionsRegistry: mockActionsRegistry,
48+
jenkinsService: mockJenkinsService,
49+
});
50+
51+
const result = await mockActionsRegistry.invoke({
52+
id: 'test:get-build',
53+
input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 42 },
54+
});
55+
56+
const output = result.output as { build: Record<string, unknown> };
57+
expect(output.build.number).toBe(42);
58+
expect(output.build).not.toHaveProperty('actions');
59+
});
60+
61+
it('should pass entity ref and build params to service', async () => {
62+
const mockActionsRegistry = actionsRegistryServiceMock();
63+
64+
mockJenkinsService.getBuild.mockResolvedValueOnce({
65+
build: {
66+
building: false,
67+
displayName: '#1',
68+
duration: 0,
69+
fullDisplayName: '',
70+
number: 1,
71+
result: 'SUCCESS',
72+
timestamp: 0,
73+
url: '',
74+
status: 'success',
75+
},
76+
} as any);
77+
78+
createGetBuildAction({
79+
actionsRegistry: mockActionsRegistry,
80+
jenkinsService: mockJenkinsService,
81+
});
82+
83+
await mockActionsRegistry.invoke({
84+
id: 'test:get-build',
85+
input: {
86+
name: 'svc',
87+
kind: 'Component',
88+
namespace: 'prod',
89+
jobFullName: 'folder/job',
90+
buildNumber: 7,
91+
},
92+
});
93+
94+
expect(mockJenkinsService.getBuild).toHaveBeenCalledWith(
95+
expect.objectContaining({
96+
entityRef: { name: 'svc', kind: 'Component', namespace: 'prod' },
97+
jobFullName: 'folder/job',
98+
buildNumber: 7,
99+
}),
100+
);
101+
});
102+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2026 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+
17+
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
18+
import { JenkinsService } from '../service/jenkinsService';
19+
import { sanitizeBuild } from './sanitize';
20+
21+
export const createGetBuildAction = ({
22+
actionsRegistry,
23+
jenkinsService,
24+
}: {
25+
actionsRegistry: ActionsRegistryService;
26+
jenkinsService: JenkinsService;
27+
}) => {
28+
actionsRegistry.register({
29+
name: 'get-build',
30+
title: 'Get Jenkins Build',
31+
description:
32+
'Retrieve details of a specific Jenkins build, including status, test results, and source information.',
33+
attributes: {
34+
readOnly: true,
35+
idempotent: true,
36+
destructive: false,
37+
},
38+
schema: {
39+
input: z =>
40+
z.object({
41+
name: z.string().describe('The name of the catalog entity'),
42+
kind: z
43+
.string()
44+
.optional()
45+
.describe('The kind of the catalog entity'),
46+
namespace: z
47+
.string()
48+
.optional()
49+
.describe('The namespace of the catalog entity'),
50+
jobFullName: z.string().describe('The full name of the Jenkins job'),
51+
buildNumber: z.number().describe('The build number to retrieve'),
52+
}),
53+
output: z =>
54+
z.object({
55+
build: z.object({}).passthrough(),
56+
}),
57+
},
58+
action: async ({ input, credentials }) => {
59+
const result = await jenkinsService.getBuild({
60+
entityRef: {
61+
name: input.name,
62+
kind: input.kind,
63+
namespace: input.namespace,
64+
},
65+
jobFullName: input.jobFullName,
66+
buildNumber: input.buildNumber,
67+
credentials,
68+
});
69+
return {
70+
output: {
71+
build: sanitizeBuild(result.build),
72+
},
73+
};
74+
},
75+
});
76+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright 2026 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+
17+
import { createGetBuildConsoleTextAction } from './createGetBuildConsoleTextAction';
18+
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
19+
import { JenkinsService } from '../service/jenkinsService';
20+
21+
describe('createGetBuildConsoleTextAction', () => {
22+
const mockJenkinsService = {
23+
getBuildConsoleText: jest.fn(),
24+
} as unknown as jest.Mocked<JenkinsService>;
25+
26+
it('should return console text', async () => {
27+
const mockActionsRegistry = actionsRegistryServiceMock();
28+
29+
mockJenkinsService.getBuildConsoleText.mockResolvedValueOnce({
30+
consoleText: 'Build started\nBuild finished',
31+
});
32+
33+
createGetBuildConsoleTextAction({
34+
actionsRegistry: mockActionsRegistry,
35+
jenkinsService: mockJenkinsService,
36+
});
37+
38+
const result = await mockActionsRegistry.invoke({
39+
id: 'test:get-build-console-text',
40+
input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 10 },
41+
});
42+
43+
const output = result.output as { consoleText: string };
44+
expect(output.consoleText).toBe('Build started\nBuild finished');
45+
});
46+
47+
it('should pass all params to service', async () => {
48+
const mockActionsRegistry = actionsRegistryServiceMock();
49+
50+
mockJenkinsService.getBuildConsoleText.mockResolvedValueOnce({
51+
consoleText: '',
52+
});
53+
54+
createGetBuildConsoleTextAction({
55+
actionsRegistry: mockActionsRegistry,
56+
jenkinsService: mockJenkinsService,
57+
});
58+
59+
await mockActionsRegistry.invoke({
60+
id: 'test:get-build-console-text',
61+
input: {
62+
name: 'svc',
63+
kind: 'Component',
64+
namespace: 'ns',
65+
jobFullName: 'folder/job',
66+
buildNumber: 3,
67+
},
68+
});
69+
70+
expect(mockJenkinsService.getBuildConsoleText).toHaveBeenCalledWith(
71+
expect.objectContaining({
72+
entityRef: { name: 'svc', kind: 'Component', namespace: 'ns' },
73+
jobFullName: 'folder/job',
74+
buildNumber: 3,
75+
}),
76+
);
77+
});
78+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 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+
17+
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
18+
import { JenkinsService } from '../service/jenkinsService';
19+
20+
export const createGetBuildConsoleTextAction = ({
21+
actionsRegistry,
22+
jenkinsService,
23+
}: {
24+
actionsRegistry: ActionsRegistryService;
25+
jenkinsService: JenkinsService;
26+
}) => {
27+
actionsRegistry.register({
28+
name: 'get-build-console-text',
29+
title: 'Get Jenkins Build Console Text',
30+
description:
31+
'Retrieve the console output of a specific Jenkins build for log analysis and diagnostics.',
32+
attributes: {
33+
readOnly: true,
34+
idempotent: true,
35+
destructive: false,
36+
},
37+
schema: {
38+
input: z =>
39+
z.object({
40+
name: z.string().describe('The name of the catalog entity'),
41+
kind: z
42+
.string()
43+
.optional()
44+
.describe('The kind of the catalog entity'),
45+
namespace: z
46+
.string()
47+
.optional()
48+
.describe('The namespace of the catalog entity'),
49+
jobFullName: z.string().describe('The full name of the Jenkins job'),
50+
buildNumber: z
51+
.number()
52+
.describe('The build number to retrieve console text for'),
53+
}),
54+
output: z =>
55+
z.object({
56+
consoleText: z.string(),
57+
}),
58+
},
59+
action: async ({ input, credentials }) => {
60+
const result = await jenkinsService.getBuildConsoleText({
61+
entityRef: {
62+
name: input.name,
63+
kind: input.kind,
64+
namespace: input.namespace,
65+
},
66+
jobFullName: input.jobFullName,
67+
buildNumber: input.buildNumber,
68+
credentials,
69+
});
70+
return { output: result };
71+
},
72+
});
73+
};

0 commit comments

Comments
 (0)