feat(scaffolder): Create scaffolder mcp action to list all installed template actions (#32765)

* add scaffolder mcp action to list all installed template actions

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

* add changeset

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

* resolve review comments

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

* cleanup code

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

* resolve pr review comments

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

* remove actionService param in options, as templateActionsRegistry has already been passed in

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

* remove unnecessary required param check

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

* type the mcp action output better

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

* use scaffolderService instead

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

* revert templateActionRegistry rename, keep actionRegistry as-is

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

* clean up list-scaffolder-actions: fix tests, changeset, and description

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-03 02:30:06 -05:00
committed by GitHub
parent a0e4d38f4b
commit 7695dd23d0
5 changed files with 255 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added a new `list-scaffolder-actions` action that returns all installed scaffolder actions with their schemas and examples
+1
View File
@@ -68,6 +68,7 @@ backend:
actions:
pluginSources:
- catalog
- scaffolder
# See README.md in the proxy-backend plugin for information on the configuration format
proxy:
endpoints:
@@ -0,0 +1,164 @@
/*
* 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 { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
import type { ListActionsResponse } from '@backstage/plugin-scaffolder-common';
type ListActionsOutput = {
actions: Array<{
id: string;
description: string;
schema: { input: object; output: object };
examples: Array<{ description: string; example: string }>;
}>;
};
describe('createListScaffolderActionsAction', () => {
it('should list all scaffolder actions sorted by id with full properties', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue(createMockActions());
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toHaveLength(3);
const actionIds = output.actions.map(a => a.id);
expect(actionIds).toEqual([
'catalog:register',
'debug:log',
'fetch:template',
]);
expect(output.actions[0]).toEqual({
id: 'catalog:register',
description: 'Registers entities in the catalog',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [{ description: 'Basic usage', example: 'register entity' }],
});
});
it('should handle actions without descriptions, schemas, or examples', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue([
{ id: 'minimal-action' },
]);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toHaveLength(1);
expect(output.actions[0]).toEqual({
id: 'minimal-action',
description: '',
schema: { input: {}, output: {} },
examples: [],
});
});
it('should return empty array when no actions are registered', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue([]);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toEqual([]);
});
it('should propagate errors from the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockRejectedValue(
new Error('Service unavailable'),
);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
}),
).rejects.toThrow('Service unavailable');
});
});
function createMockActions(): ListActionsResponse {
return [
{
id: 'fetch:template',
description: 'Fetches a template',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [],
},
{
id: 'catalog:register',
description: 'Registers entities in the catalog',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [{ description: 'Basic usage', example: 'register entity' }],
},
{
id: 'debug:log',
description: 'Logs debug information',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [],
},
];
}
@@ -0,0 +1,83 @@
/*
* 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';
export const createListScaffolderActionsAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'list-scaffolder-actions',
title: 'List Scaffolder Actions',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `Lists all installed Scaffolder actions.
Each action includes:
- id: The action identifier
- description: What the action does
- schema: Input and output JSON schemas
- examples: Usage examples when available`,
schema: {
input: z => z.object({}).describe('No input is required'),
output: z =>
z.object({
actions: z.array(
z.object({
id: z.string(),
description: z.string(),
schema: z.object({
input: z
.object({})
.passthrough()
.describe('JSON Schema for input of Action'),
output: z
.object({})
.passthrough()
.describe('JSON Schema for output of Action'),
}),
examples: z.array(z.any()).optional(),
}),
),
}),
},
action: async ({ credentials }) => {
const actions = await scaffolderService.listActions(undefined, {
credentials,
});
const scaffolderActions = actions.map(action => ({
id: action.id,
description: action.description ?? '',
schema: {
input: { ...(action.schema?.input ?? {}) },
output: { ...(action.schema?.output ?? {}) },
},
examples: action.examples ?? [],
}));
return {
output: {
actions: scaffolderActions.sort((a, b) => a.id.localeCompare(b.id)),
},
};
},
});
};
@@ -16,10 +16,12 @@
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
export const createScaffolderActions = (options: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
createDryRunTemplateAction(options);
createListScaffolderActionsAction(options);
};