feat(scaffolder): create scaffolder mcp action to execute a template (#33124)

* create mcp action to execute a template

Signed-off-by: Stephanie <yangcao@redhat.com>

* include changeset

Signed-off-by: Stephanie <yangcao@redhat.com>

* address test errors and review comments from copilot

Signed-off-by: Stephanie <yangcao@redhat.com>

* address review comments

Signed-off-by: Stephanie <yangcao@redhat.com>

* add execute-template to mcp actions list

Signed-off-by: Stephanie <yangcao@redhat.com>

* fix: address review feedback for execute-template action

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: Stephanie <yangcao@redhat.com>
Signed-off-by: benjdlambert <ben@blam.sh>
Co-authored-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Stephanie Cao
2026-03-25 11:30:49 -04:00
committed by GitHub
parent 36d205a455
commit 309b7128c4
5 changed files with 221 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added a new `execute-template` actions registry action that executes a scaffolder template with provided input values and returns a task ID for tracking progress.
+1
View File
@@ -27,4 +27,5 @@ This is a (non-exhaustive) list of actions that are known to be part of the Acti
- `scaffolder.dry-run-template` (Dry Run Scaffolder Template): Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.
- `scaffolder.list-scaffolder-actions` (List Scaffolder Actions): Lists all installed Scaffolder actions.
- `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created.
- `scaffolder.execute-template` (Execute Scaffolder Template): Executes a Scaffolder template with its template ref and input parameter values.
- `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task.
@@ -0,0 +1,132 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExecuteTemplateAction } from './createExecuteTemplateAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
describe('createExecuteTemplateAction', () => {
const mockScaffolderService = scaffolderServiceMock.mock();
beforeEach(() => {
jest.resetAllMocks();
});
it('should scaffold a template and return the taskId', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-abc-123',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app', owner: 'team-a' },
},
});
expect(result.output).toEqual({ taskId: 'task-abc-123' });
expect(mockScaffolderService.scaffold).toHaveBeenCalledWith(
{
templateRef: 'template:default/my-template',
values: { name: 'my-app', owner: 'team-a' },
},
{ credentials: expect.anything() },
);
});
it('should forward empty values and pass secrets to the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-with-secrets',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/secret-template',
values: {},
secrets: { apiKey: 'super-secret' },
},
});
expect(result.output).toEqual({ taskId: 'task-with-secrets' });
expect(mockScaffolderService.scaffold).toHaveBeenCalledWith(
{
templateRef: 'template:default/secret-template',
values: {},
secrets: { apiKey: 'super-secret' },
},
{ credentials: expect.anything() },
);
});
it('should not include secrets when not provided', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-no-secrets',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app' },
},
});
const scaffoldCall = mockScaffolderService.scaffold.mock.calls[0][0];
expect(scaffoldCall).not.toHaveProperty('secrets');
});
it('should propagate errors from the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockRejectedValue(
new Error('Permission denied'),
);
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app' },
},
}),
).rejects.toThrow('Permission denied');
});
});
@@ -0,0 +1,81 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { JsonValue } from '@backstage/types';
export const createExecuteTemplateAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'execute-template',
title: 'Execute Scaffolder Template',
attributes: {
destructive: true,
readOnly: false,
idempotent: false,
},
description: `Executes a Scaffolder template with its template ref and input parameter values.
The template is run using the credentials provided to this action, and respects any RBAC permissions associated with those credentials.
Returns a taskId that can be used to track execution progress.
Use the catalog.get-catalog-entity action to fetch the Template entity and discover its parameter schema and secrets definition before calling this action.`,
schema: {
input: z =>
z.object({
templateRef: z
.string()
.describe(
'The template entity reference to execute, e.g. "template:default/my-template"',
),
values: z
.record(z.unknown())
.describe(
'Input parameter values required by the template. Use catalog.get-catalog-entity to discover the required parameters for the template.',
),
secrets: z
.record(z.string())
.optional()
.describe(
'Optional secrets to pass to the template execution. Use catalog.get-catalog-entity to discover the secrets definition on the Template entity.',
),
}),
output: z =>
z.object({
taskId: z
.string()
.describe(
'The task ID for the scaffolder execution. Use this to track progress or retrieve logs.',
),
}),
},
action: async ({ input, credentials }) => {
const { taskId } = await scaffolderService.scaffold(
{
templateRef: input.templateRef,
values: input.values as Record<string, JsonValue>,
...(input.secrets && { secrets: input.secrets }),
},
{ credentials },
);
return { output: { taskId } };
},
});
};
@@ -19,6 +19,7 @@ import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
import { createExecuteTemplateAction } from './createExecuteTemplateAction';
import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction';
export const createScaffolderActions = (options: {
@@ -33,5 +34,6 @@ export const createScaffolderActions = (options: {
});
createDryRunTemplateAction(options);
createListScaffolderActionsAction(options);
createExecuteTemplateAction(options);
createGetScaffolderTaskLogsAction(options);
};