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:
@@ -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
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user