add MCP tools for register/unregister catalog entities

Signed-off-by: gabemontero <gmontero@redhat.com>
This commit is contained in:
gabemontero
2025-12-05 15:36:11 -05:00
parent 9374d8d602
commit dce18246e4
6 changed files with 443 additions and 0 deletions
+5
View File
@@ -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.
@@ -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,
});
});
});
@@ -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,
},
};
}
},
});
};
@@ -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,
});
});
});
@@ -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,
},
};
}
},
});
};
@@ -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);
};