diff --git a/workspaces/mcp-integrations/.changeset/add-fulltext-search.md b/workspaces/mcp-integrations/.changeset/add-fulltext-search.md new file mode 100644 index 0000000000..db85fe9ffc --- /dev/null +++ b/workspaces/mcp-integrations/.changeset/add-fulltext-search.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-software-catalog-mcp-extras': minor +--- + +Add full-text search parameter to query-catalog-entities action using catalog.queryEntities() with fullTextFilter support for partial matching across entity name, title, and description diff --git a/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.test.ts b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.test.ts index c2b23f6fb5..da81c8809b 100644 --- a/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.test.ts +++ b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.test.ts @@ -23,6 +23,7 @@ describe('createQueryCatalogEntitiesAction', () => { describe('fetchCatalogEntities', () => { const mockCatalogService = { getEntities: jest.fn(), + queryEntities: jest.fn(), } as unknown as CatalogService; const mockCredentials = { @@ -36,6 +37,218 @@ describe('createQueryCatalogEntitiesAction', () => { jest.clearAllMocks(); }); + it('should search entities using fullTextFilter when search is provided', async () => { + const mockEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-gitops-infra', + tags: ['gitops'], + description: 'GitOps infrastructure service', + }, + spec: { + type: 'service', + owner: 'team-platform', + lifecycle: 'production', + }, + }, + ]; + + (mockCatalogService.queryEntities as jest.Mock).mockResolvedValue({ + items: mockEntities, + totalItems: 1, + pageInfo: {}, + }); + + const result = await fetchCatalogEntities( + mockCatalogService, + mockCredentials, + mockLoggerService, + { search: 'gitops' }, + ); + + expect(mockCatalogService.queryEntities).toHaveBeenCalledWith( + { + filter: undefined, + fullTextFilter: { + term: 'gitops', + fields: [ + 'metadata.name', + 'metadata.title', + 'metadata.description', + ], + }, + limit: 500, + fields: [ + 'metadata.name', + 'metadata.title', + 'kind', + 'metadata.tags', + 'metadata.description', + 'spec.type', + 'spec.owner', + 'spec.lifecycle', + 'relations', + ], + }, + { + credentials: mockCredentials, + }, + ); + + expect(mockCatalogService.getEntities).not.toHaveBeenCalled(); + + expect(result).toEqual({ + entities: [ + { + name: 'my-gitops-infra', + title: undefined, + kind: 'Component', + tags: 'gitops', + description: 'GitOps infrastructure service', + lifecycle: 'production', + type: 'service', + owner: 'team-platform', + dependsOn: '', + }, + ], + }); + }); + + it('should combine search with kind filter', async () => { + const mockEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'auth-service', + tags: ['auth'], + description: 'Authentication service', + }, + spec: { + type: 'service', + owner: 'team-platform', + lifecycle: 'production', + }, + }, + ]; + + (mockCatalogService.queryEntities as jest.Mock).mockResolvedValue({ + items: mockEntities, + totalItems: 1, + pageInfo: {}, + }); + + await fetchCatalogEntities( + mockCatalogService, + mockCredentials, + mockLoggerService, + { search: 'auth', kind: 'Component' }, + ); + + expect(mockCatalogService.queryEntities).toHaveBeenCalledWith( + { + filter: { kind: 'Component' }, + fullTextFilter: { + term: 'auth', + fields: [ + 'metadata.name', + 'metadata.title', + 'metadata.description', + ], + }, + limit: 500, + fields: [ + 'metadata.name', + 'metadata.title', + 'kind', + 'metadata.tags', + 'metadata.description', + 'spec.type', + 'spec.owner', + 'spec.lifecycle', + 'relations', + ], + }, + { + credentials: mockCredentials, + }, + ); + }); + + it('should use getEntities when search is not provided', async () => { + (mockCatalogService.getEntities as jest.Mock).mockResolvedValue({ + items: [], + }); + + await fetchCatalogEntities( + mockCatalogService, + mockCredentials, + mockLoggerService, + { kind: 'Component' }, + ); + + expect(mockCatalogService.getEntities).toHaveBeenCalled(); + expect(mockCatalogService.queryEntities).not.toHaveBeenCalled(); + }); + + it('should return verbose results with search', async () => { + const mockEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service', + tags: ['java'], + description: 'A service', + uid: 'component:default/my-service', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-a', + lifecycle: 'production', + }, + }, + ]; + + (mockCatalogService.queryEntities as jest.Mock).mockResolvedValue({ + items: mockEntities, + totalItems: 1, + pageInfo: {}, + }); + + const result = await fetchCatalogEntities( + mockCatalogService, + mockCredentials, + mockLoggerService, + { search: 'service', verbose: true }, + ); + + expect(mockCatalogService.queryEntities).toHaveBeenCalledWith( + { + filter: undefined, + fullTextFilter: { + term: 'service', + fields: [ + 'metadata.name', + 'metadata.title', + 'metadata.description', + ], + }, + limit: 500, + }, + { + credentials: mockCredentials, + }, + ); + + expect(result).toEqual({ + entities: mockEntities, + }); + }); + it('should fetch catalog entities successfully', async () => { const mockEntities: Entity[] = [ { @@ -98,6 +311,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -120,6 +334,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'my-service', + title: undefined, kind: 'Component', tags: 'java,spring', description: 'A Spring-based microservice', @@ -130,6 +345,7 @@ describe('createQueryCatalogEntitiesAction', () => { }, { name: 'my-api', + title: undefined, kind: 'API', tags: 'rest,openapi', description: 'REST API for data access', @@ -140,6 +356,7 @@ describe('createQueryCatalogEntitiesAction', () => { }, { name: 'my-system', + title: undefined, kind: 'System', tags: '', description: 'Core business system', @@ -183,6 +400,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'service-no-tags', + title: undefined, kind: 'Component', tags: '', description: 'Service without tags', @@ -274,6 +492,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -331,6 +550,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -358,6 +578,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'specific-service', + title: undefined, kind: 'Component', tags: 'javascript', description: 'A specific web service', @@ -403,6 +624,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -425,6 +647,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'my-service', + title: undefined, kind: 'Component', tags: 'java,spring', description: 'A Spring-based microservice', @@ -470,6 +693,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -492,6 +716,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'platform-service', + title: undefined, kind: 'Component', tags: 'platform,core', description: 'A platform service', @@ -536,6 +761,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'minimal-service', + title: undefined, kind: 'Component', tags: 'minimal', description: 'A minimal service', @@ -643,6 +869,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -665,6 +892,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'abridged-service', + title: undefined, kind: 'Component', tags: 'java', description: 'An abridged service entity', @@ -709,6 +937,7 @@ describe('createQueryCatalogEntitiesAction', () => { { fields: [ 'metadata.name', + 'metadata.title', 'kind', 'metadata.tags', 'metadata.description', @@ -731,6 +960,7 @@ describe('createQueryCatalogEntitiesAction', () => { entities: [ { name: 'default-service', + title: undefined, kind: 'Component', tags: 'default', description: 'A default service entity', @@ -747,6 +977,7 @@ describe('createQueryCatalogEntitiesAction', () => { describe('MCP Action validation and error handling', () => { const mockCatalogService = { getEntities: jest.fn(), + queryEntities: jest.fn(), } as unknown as CatalogService; const mockCredentials = { @@ -807,6 +1038,54 @@ describe('createQueryCatalogEntitiesAction', () => { expect(result.output.entities).toEqual([]); }); + it('should return error when both name and search are specified', async () => { + const fetchCatalogEntitiesAction = async ({ + input, + }: { + input: { + kind?: string; + type?: string; + name?: string; + search?: string; + owner?: string; + lifecycle?: string; + tags?: string; + verbose?: boolean; + }; + }) => { + if (input.name && input.search) { + return { + output: { + entities: [], + error: + "cannot specify both 'name' (exact match) and 'search' (partial match) together", + }, + }; + } + const result = await fetchCatalogEntities( + mockCatalogService, + mockCredentials, + mockLoggerService, + input, + ); + return { + output: { + ...result, + error: undefined, + }, + }; + }; + + const result = await fetchCatalogEntitiesAction({ + input: { name: 'my-service', search: 'service' }, + }); + + expect(result.output.error).toBe( + "cannot specify both 'name' (exact match) and 'search' (partial match) together", + ); + expect(result.output.entities).toEqual([]); + }); + it('should return entities successfully when both kind and type are specified', async () => { const mockEntities: Entity[] = [ { @@ -873,6 +1152,7 @@ describe('createQueryCatalogEntitiesAction', () => { expect(result.output.entities).toEqual([ { name: 'test-service', + title: undefined, kind: 'Component', tags: 'test', description: 'A test service', @@ -950,6 +1230,7 @@ describe('createQueryCatalogEntitiesAction', () => { expect(result.output.entities).toEqual([ { name: 'test-component', + title: undefined, kind: 'Component', tags: 'test', description: 'A test component', @@ -983,6 +1264,7 @@ describe('createQueryCatalogEntitiesAction', () => { }, ], }), + queryEntities: jest.fn(), } as unknown as CatalogService; const mockCredentials = { @@ -1014,6 +1296,7 @@ describe('createQueryCatalogEntitiesAction', () => { expect(result.output.entities).toHaveLength(1); expect(result.output.entities[0]).toEqual({ name: 'test-component', + title: undefined, kind: 'Component', tags: 'test', description: 'A test component', diff --git a/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.ts b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.ts index 4a966dc983..9017706157 100644 --- a/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.ts +++ b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/actions/createQueryCatalogEntitiesAction.ts @@ -37,12 +37,18 @@ export const createQueryCatalogEntitiesAction = ({ }, description: `Search and retrieve catalog entities from the Backstage server. -List all Backstage entities such as Components, Systems, Resources, APIs, Locations, Users, and Groups. +List all Backstage entities such as Components, Systems, Resources, APIs, Locations, Users, and Groups. By default, results are returned in JSON array format, where each entry in the JSON array is an entity with the following fields: 'name', 'description','type', 'owner', 'tags', 'dependsOn' and 'kind'. Setting 'verbose' to true will return the full Backstage entity objects, but should only be used if the reduced output is not sufficient, as this will significantly impact context usage (especially on smaller models). Note: 'type' can only be filtered on if a specified entity 'kind' is also specified. +Use the 'search' parameter for partial/substring matching across entity name, title, and description. Use the 'name' parameter only when you know the exact entity name. + Example invocations and the output from those invocations: + # Search for entities by partial name or description + query-catalog-entities search:postgres + # Combine search with kind filter + query-catalog-entities kind:Component search:auth # Find all Resources of type storage query-catalog-entities kind:Resource type:storage Output: { @@ -78,7 +84,13 @@ Example invocations and the output from those invocations: .describe( 'Filter entities by type (e.g., ai-model, library, website).', ), - name: z.string().optional().describe('Filter entities by name'), + name: z.string().optional().describe('Filter entities by exact name'), + search: z + .string() + .optional() + .describe( + 'Full-text search term to match against entity name, title, and description. Supports partial/substring matching unlike the exact-match name filter.', + ), owner: z .string() .optional() @@ -115,6 +127,12 @@ Example invocations and the output from those invocations: .describe( 'The name field for each Backstage entity in the catalog', ), + title: z + .string() + .optional() + .describe( + 'The human-friendly display title of the Backstage entity', + ), kind: z .string() .describe( @@ -180,6 +198,15 @@ Example invocations and the output from those invocations: }, }; } + if (input.name && input.search) { + return { + output: { + entities: [], + error: + "cannot specify both 'name' (exact match) and 'search' (partial match) together", + }, + }; + } try { const result = await fetchCatalogEntities( catalog, @@ -209,6 +236,102 @@ Example invocations and the output from those invocations: }); }; +const ABRIDGED_FIELDS = [ + 'metadata.name', + 'metadata.title', + 'kind', + 'metadata.tags', + 'metadata.description', + 'spec.type', + 'spec.owner', + 'spec.lifecycle', + 'relations', +]; + +function buildFilter(input?: { + kind?: string; + type?: string; + name?: string; + owner?: string; + tags?: string; + lifecycle?: string; +}): Record { + const filter: Record = {}; + if (input?.kind) filter.kind = input.kind; + if (input?.type) filter['spec.type'] = input.type; + if (input?.name) filter['metadata.name'] = input.name; + if (input?.owner) filter['spec.owner'] = input.owner; + if (input?.lifecycle) filter['spec.lifecycle'] = input.lifecycle; + if (input?.tags) { + filter['metadata.tags'] = input.tags.split(',').map(tag => tag.trim()); + } + return filter; +} + +function redactFilters(filter: Record): Record { + const redacted = { ...filter }; + if (Object.hasOwn(redacted, 'metadata.name')) { + redacted['metadata.name'] = '[REDACTED]'; + } + if (Object.hasOwn(redacted, 'spec.owner')) { + redacted['spec.owner'] = '[REDACTED]'; + } + return redacted; +} + +function toAbridgedEntity(entity: Entity) { + return { + name: entity.metadata.name, + title: entity.metadata.title, + kind: entity.kind, + tags: entity.metadata.tags?.join(',') || '', + description: entity.metadata.description, + lifecycle: + typeof entity.spec?.lifecycle === 'string' + ? entity.spec.lifecycle + : undefined, + type: + typeof entity.spec?.type === 'string' ? entity.spec.type : undefined, + owner: + typeof entity.spec?.owner === 'string' ? entity.spec.owner : undefined, + dependsOn: + entity.relations + ?.filter(relation => relation.type === 'dependsOn') + .map(relation => relation.targetRef) + .join(',') || '', + }; +} + +async function searchEntities( + catalog: CatalogService, + credentials: any, + search: string, + filter: Record, + fields?: string[], +): Promise { + const queryOptions: any = { + filter: Object.keys(filter).length > 0 ? filter : undefined, + fullTextFilter: { + term: search, + fields: ['metadata.name', 'metadata.title', 'metadata.description'], + }, + limit: 500, + }; + if (fields) { + queryOptions.fields = fields; + } + + const items: Entity[] = []; + let cursor: string | undefined; + do { + const request: any = cursor ? { cursor, limit: 500 } : queryOptions; + const response = await catalog.queryEntities(request, { credentials }); + items.push(...response.items); + cursor = response.pageInfo?.nextCursor; + } while (cursor); + return items; +} + export async function fetchCatalogEntities( catalog: CatalogService, credentials: any, @@ -217,95 +340,44 @@ export async function fetchCatalogEntities( kind?: string; type?: string; name?: string; + search?: string; owner?: string; tags?: string; lifecycle?: string; verbose?: boolean; }, ) { - const filter: any = {}; - if (input?.kind) { - filter.kind = input.kind; - } - if (input?.type) { - filter['spec.type'] = input.type; - } - if (input?.name) { - filter['metadata.name'] = input.name; - } - if (input?.owner) { - filter['spec.owner'] = input.owner; - } - if (input?.lifecycle) { - filter['spec.lifecycle'] = input.lifecycle; - } - if (input?.tags) { - filter['metadata.tags'] = input.tags.split(',').map(tag => tag.trim()); - } + const filter = buildFilter(input); + const fields = input?.verbose ? undefined : ABRIDGED_FIELDS; + const logEntityNames = process.env.LOG_ENTITY_NAMES === 'true'; + const loggedFilters = logEntityNames ? filter : redactFilters(filter); - const getEntitiesOptions: any = { - filter, - }; + let items: Entity[]; - if (!input?.verbose) { - getEntitiesOptions.fields = [ - 'metadata.name', - 'kind', - 'metadata.tags', - 'metadata.description', - 'spec.type', - 'spec.owner', - 'spec.lifecycle', - 'relations', - ]; - } + if (input?.search) { + logger.info( + 'query-catalog-entities: Searching catalog entities with fullTextFilter:', + { search: logEntityNames ? input.search : '[REDACTED]', ...loggedFilters }, + ); + items = await searchEntities(catalog, credentials, input.search, filter, fields); + } else { + logger.info( + 'query-catalog-entities: Fetching catalog entities with options:', + loggedFilters, + ); - const logEntityNames = process.env.LOG_ENTITY_NAMES === 'true'; - const loggedFilters = { - ...getEntitiesOptions.filter, - }; - if (!logEntityNames) { - if (Object.prototype.hasOwnProperty.call(loggedFilters, 'metadata.name')) { - loggedFilters['metadata.name'] = '[REDACTED]'; - } - if (Object.prototype.hasOwnProperty.call(loggedFilters, 'spec.owner')) { - loggedFilters['spec.owner'] = '[REDACTED]'; + const getEntitiesOptions: any = { filter }; + if (fields) { + getEntitiesOptions.fields = fields; } - } - logger.info( - 'query-catalog-entities: Fetching catalog entities with options:', - loggedFilters, - ); - const { items } = await catalog.getEntities(getEntitiesOptions, { - credentials, - }); + const response = await catalog.getEntities(getEntitiesOptions, { + credentials, + }); + items = response.items; + } return { - entities: input?.verbose - ? items - : items.map(entity => ({ - name: entity.metadata.name, - kind: entity.kind, - tags: entity.metadata.tags?.join(',') || '', - description: entity.metadata.description, - lifecycle: - typeof entity.spec?.lifecycle === 'string' - ? entity.spec.lifecycle - : undefined, - type: - typeof entity.spec?.type === 'string' - ? entity.spec.type - : undefined, - owner: - typeof entity.spec?.owner === 'string' - ? entity.spec.owner - : undefined, - dependsOn: - entity.relations - ?.filter(relation => relation.type === 'dependsOn') - .map(relation => relation.targetRef) - .join(',') || '', - })), + entities: input?.verbose ? items : items.map(toAbridgedEntity), }; }