diff --git a/.changeset/nine-symbols-shout.md b/.changeset/nine-symbols-shout.md new file mode 100644 index 0000000000..87fff3091b --- /dev/null +++ b/.changeset/nine-symbols-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Add MCP actions to 1) register existing components from a code respository, 2) unregister components with the location ID return from a prior register existing components invocation. diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..0c6d35b297 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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 { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createRegisterCatalogEntitiesAction', () => { + it('should successfully register a catalog location with a valid URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const mockLocationId = 'test-location-id-1234'; + mockCatalog.addLocation = jest.fn().mockResolvedValue({ + location: { + id: mockLocationId, + type: 'url', + target: 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + entities: [], + exists: false, + }); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { + locationURL: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }); + + expect(result.output).toEqual({ + locationID: mockLocationId, + error: undefined, + }); + expect(mockCatalog.addLocation).toHaveBeenCalledWith( + { + type: 'url', + target: 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + }); + + it('should return an error if locationURL is not provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: '' }, + }); + + expect(result.output).toEqual({ + error: 'a location URL must be specified', + }); + }); + + it('should return an error if locationURL is not a valid URL', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { locationURL: 'not-a-valid-url' }, + }); + + expect(result.output).toEqual({ + error: 'location URL must be a valid URL string', + }); + }); + + it('should return an error if catalog.addLocation throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const errorMessage = 'Failed to add location'; + mockCatalog.addLocation = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createRegisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:register-catalog-entities', + input: { + locationURL: + 'https://github.com/example/repo/blob/main/catalog-info.yaml', + }, + }); + + expect(result.output).toEqual({ + error: errorMessage, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts new file mode 100644 index 0000000000..167a1bd66a --- /dev/null +++ b/plugins/catalog-backend/src/actions/createRegisterCatalogEntitiesAction.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2025 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 { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createRegisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'register-catalog-entities', + title: 'Register Catalog Entities', + attributes: { + destructive: false, + readOnly: false, + idempotent: true, + }, + description: `Register entities defined remotely (create new Locations). + +This tool is analogous to the "register existing components" function you see in the Backstage dashboard, +where you supply a URL link that allows for the downloading of a 'catalog-info.yaml' file that +defines the various Entities (Components, Systems, Resources, APIs, Users, and Groups) you want to +create or import into the Backstage catalog. + +The action within the catalog results in the creation of a Location entity that is the parent of owner +of all the additional entities defined in the 'catalog-info.yaml' file. + +The dynamically generated, random UUID created to go along with the newly created Location entity is returned +to the caller. That UUID can be later used if you want to unregister or delete the Location entity and all +of its children entities. + +If an error occurred during processing, the 'locationID' field will be blank, and the 'error' field will contain +a message describing the error. + +Example invocation and the output from the invocation: + # Register a Location from a GitHub URL + register-catalog-entities locationURL: https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml + Output: { + "locationID": "aaaa-bbbb-cccc-dddd" + } +`, + schema: { + input: z => + z.object({ + locationURL: z + .string() + .describe( + `URL reference to the catalog-info.yaml file that describes what entities to import.`, + ), + }), + output: z => + z.object({ + locationID: z + .string() + .optional() + .describe('The location ID generated by the registration'), + error: z + .string() + .optional() + .describe('Error message if creation fails'), + }), + }, + action: async ({ input, credentials }) => { + if (!input.locationURL || input.locationURL === '') { + return { + output: { + error: 'a location URL must be specified', + }, + }; + } + + // Validate that the locationURL is a valid URL + try { + // eslint-disable-next-line no-new + new URL(input.locationURL); + } catch { + return { + output: { + error: 'location URL must be a valid URL string', + }, + }; + } + try { + const locReq = { + type: 'url', + target: input.locationURL, + }; + const result = await catalog.addLocation(locReq, { + credentials, + }); + + return { + output: { + locationID: result.location.id, + error: undefined, + }, + }; + } catch (error) { + return { + output: { + error: error.message, + }, + }; + } + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts new file mode 100644 index 0000000000..abf03a2f36 --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2025 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 { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createUnregisterCatalogEntitiesAction', () => { + it('should successfully unregister a catalog location with a valid locationId', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + mockCatalog.removeLocationById = jest.fn().mockResolvedValue(undefined); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: 'test-location-id-1234' }, + }); + + expect(result.output).toEqual({ + error: undefined, + }); + expect(mockCatalog.removeLocationById).toHaveBeenCalledWith( + 'test-location-id-1234', + expect.objectContaining({ + credentials: expect.any(Object), + }), + ); + }); + + it('should return an error if locationId is not provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: '' }, + }); + + expect(result.output).toEqual({ + error: 'a location ID must be specified', + }); + }); + + it('should return an error if catalog.removeLocationById throws an error', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockCatalog = catalogServiceMock(); + + const errorMessage = 'Failed to remove location'; + mockCatalog.removeLocationById = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + createUnregisterCatalogEntitiesAction({ + catalog: mockCatalog, + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:unregister-catalog-entities', + input: { locationId: 'test-location-id-1234' }, + }); + + expect(result.output).toEqual({ + error: errorMessage, + }); + }); +}); diff --git a/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts new file mode 100644 index 0000000000..2164e15f5e --- /dev/null +++ b/plugins/catalog-backend/src/actions/createUnregisterCatalogEntitiesAction.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2025 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 { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createUnregisterCatalogEntitiesAction = ({ + catalog, + actionsRegistry, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'unregister-catalog-entities', + title: 'Unregister Catalog Entities', + attributes: { + destructive: true, + readOnly: false, + idempotent: true, + }, + description: `Unregister a Location and the entities it ownws. + +This tool is analogous to the "unregister location" function you see in the Backstage dashboard, +where you supply the UUID for a Location entity link that allows for the removal of the Location and +the various Entities (Components, Systems, Resources, APIs, Users, and Groups) that were also created +when the Location was imported. + +If an error occurred during processing, the 'error' field will contain a message describing the error. + +Example invocation and the output from the invocation: + # Register a Location from a GitHub URL + unregister-catalog-entities locationId: aaa-bbb-ccc-ddd + Output: {} +`, + schema: { + input: z => + z.object({ + locationId: z + .string() + .describe( + `Location ID returned from the 'register-catalog-entities' call.`, + ), + }), + output: z => + z.object({ + error: z + .string() + .optional() + .describe('Error message if creation fails'), + }), + }, + action: async ({ input, credentials }) => { + if (!input.locationId || input.locationId === '') { + return { + output: { + error: 'a location ID must be specified', + }, + }; + } + + try { + await catalog.removeLocationById(input.locationId, { + credentials, + }); + + return { + output: { + error: undefined, + }, + }; + } catch (error) { + return { + output: { + error: error.message, + }, + }; + } + }, + }); +}; diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index c53f956f62..5940bc32f1 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -17,6 +17,8 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts'; import { createValidateEntityAction } from './createValidateEntityAction.ts'; +import { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts'; +import { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts'; export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; @@ -24,4 +26,6 @@ export const createCatalogActions = (options: { }) => { createGetCatalogEntityAction(options); createValidateEntityAction(options); + createRegisterCatalogEntitiesAction(options); + createUnregisterCatalogEntitiesAction(options); };