add MCP tools for register/unregister catalog entities
Signed-off-by: gabemontero <gmontero@redhat.com>
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user