diff --git a/workspaces/mcp-integrations/packages/backend/package.json b/workspaces/mcp-integrations/packages/backend/package.json index 242201ba78..8bb3d57ed4 100644 --- a/workspaces/mcp-integrations/packages/backend/package.json +++ b/workspaces/mcp-integrations/packages/backend/package.json @@ -16,7 +16,7 @@ "start": "backstage-cli package start", "build": "backstage-cli package build", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", + "test": "CI=true backstage-cli package test --forceExit --runInBand", "clean": "backstage-cli package clean", "build-image": "docker build ../.. -f Dockerfile --tag backstage" }, @@ -55,7 +55,12 @@ "pg": "^8.11.3" }, "devDependencies": { - "@backstage/cli": "^0.36.0" + "@backstage/backend-plugin-api": "^1.8.0", + "@backstage/backend-test-utils": "^1.11.1", + "@backstage/cli": "^0.36.0", + "@backstage/errors": "^1.2.7", + "@backstage/plugin-catalog-node": "^2.1.0", + "@modelcontextprotocol/sdk": "^1.25.2" }, "files": [ "dist" diff --git a/workspaces/mcp-integrations/packages/backend/src/mcp-tools.integration.test.ts b/workspaces/mcp-integrations/packages/backend/src/mcp-tools.integration.test.ts new file mode 100644 index 0000000000..20efbe9147 --- /dev/null +++ b/workspaces/mcp-integrations/packages/backend/src/mcp-tools.integration.test.ts @@ -0,0 +1,477 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; +import { + createServiceFactory, + coreServices, +} from '@backstage/backend-plugin-api'; +import type { HttpAuthService } from '@backstage/backend-plugin-api'; +import { AuthenticationError } from '@backstage/errors'; +import mcpPlugin from '@backstage/plugin-mcp-actions-backend'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + CallToolResultSchema, + ListToolsResultSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { Server } from 'node:http'; +import type { z } from 'zod'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import softwareCatalogMcpExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-software-catalog-mcp-extras'; +import mcpTechdocsExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-techdocs-mcp-extras'; +import mcpScaffolderExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-scaffolder-mcp-extras'; + +const MCP_TOKEN = 'ci-test-mcp-token-12345'; + +const EXPECTED_EXTRA_TOOLS = [ + 'scaffolder-mcp-extras.execute-template', + 'scaffolder-mcp-extras.fetch-template-metadata', + 'scaffolder-mcp-extras.get-scaffolder-task-logs', + 'scaffolder-mcp-extras.list-scaffolder-actions', + 'scaffolder-mcp-extras.list-scaffolder-tasks', + 'scaffolder-mcp-extras.validate-scaffolder', + 'software-catalog-mcp-extras.query-catalog-entities', + 'techdocs-mcp-extras.analyze-techdocs-coverage', + 'techdocs-mcp-extras.fetch-techdocs', + 'techdocs-mcp-extras.retrieve-techdocs-content', +]; + +const READ_ONLY_TOOLS = [ + 'software-catalog-mcp-extras.query-catalog-entities', + 'techdocs-mcp-extras.fetch-techdocs', + 'techdocs-mcp-extras.analyze-techdocs-coverage', + 'techdocs-mcp-extras.retrieve-techdocs-content', + 'scaffolder-mcp-extras.fetch-template-metadata', + 'scaffolder-mcp-extras.list-scaffolder-tasks', + 'scaffolder-mcp-extras.list-scaffolder-actions', + 'scaffolder-mcp-extras.get-scaffolder-task-logs', + 'scaffolder-mcp-extras.validate-scaffolder', +]; + +const DESTRUCTIVE_TOOLS = ['scaffolder-mcp-extras.execute-template']; + +const TECHDOCS_CONFIG = { + builder: 'local', + generator: { runIn: 'local' }, + publisher: { type: 'local' }, +}; + +type CallToolResult = z.infer; +type McpTestBackend = Awaited>; + +const ALL_PLUGIN_SOURCES = [ + 'software-catalog-mcp-extras', + 'techdocs-mcp-extras', + 'scaffolder-mcp-extras', +] as const; + +const BEARER_TOKEN_PATTERN = /^Bearer +(\S+)$/i; + +function extractBearerToken( + authHeader: string | undefined, +): string | undefined { + if (typeof authHeader !== 'string') { + return undefined; + } + + const match = BEARER_TOKEN_PATTERN.exec(authHeader); + return match?.[1]; +} + +function sortAlphabetically(items: readonly string[]): string[] { + return [...items].sort((a, b) => a.localeCompare(b)); +} + +function createStrictMcpHttpAuthFeature() { + return createServiceFactory({ + service: coreServices.httpAuth, + deps: { plugin: coreServices.pluginMetadata }, + factory: ({ plugin }) => { + const pluginId = plugin.getId(); + const standardHttpAuth = mockServices.httpAuth({ + pluginId, + defaultCredentials: mockCredentials.user('user:default/test'), + }); + + const credentials = (async (req, options) => { + const requestUrl = String(req.originalUrl ?? req.url ?? ''); + + if ( + pluginId === 'mcp-actions' && + requestUrl.includes('/api/mcp-actions') + ) { + const token = extractBearerToken(req.headers.authorization); + + if (!token) { + throw new AuthenticationError('Missing Authorization header'); + } + + if (token !== MCP_TOKEN) { + throw new AuthenticationError('Invalid credentials'); + } + + return mockCredentials.user('user:default/mcp-client'); + } + + const token = extractBearerToken(req.headers.authorization); + + if (token === MCP_TOKEN) { + return mockCredentials.user('user:default/mcp-client'); + } + + return standardHttpAuth.credentials(req, options); + }) as HttpAuthService['credentials']; + + return { + credentials, + issueUserCookie: + standardHttpAuth.issueUserCookie.bind(standardHttpAuth), + }; + }, + }); +} + +type McpBackendOptions = { + pluginSources: string[]; + requireMcpToken?: boolean; +}; + +function createBackendConfig(options: McpBackendOptions) { + return { + backend: { + baseUrl: 'http://localhost:7007', + actions: { + pluginSources: options.pluginSources, + }, + }, + techdocs: TECHDOCS_CONFIG, + }; +} + +function getServerPort(server: Server): number { + const address = server.address(); + if (typeof address !== 'object' || !address || !('port' in address)) { + throw new Error('Test backend server address is unavailable'); + } + return address.port; +} + +async function startMcpBackend(options: McpBackendOptions) { + const features = [ + mcpPlugin, + softwareCatalogMcpExtrasPlugin, + mcpTechdocsExtrasPlugin, + mcpScaffolderExtrasPlugin, + metricsServiceMock.mock().factory, + mockServices.rootConfig.factory({ + data: createBackendConfig(options), + }), + mockServices.auth.factory(), + catalogServiceMock.factory({ entities: [] }), + ]; + + if (options.requireMcpToken) { + features.push(createStrictMcpHttpAuthFeature()); + } else { + features.push( + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + ); + } + + return startTestBackend({ features }); +} + +async function withMcpClient( + server: Server, + run: (client: Client) => Promise, + headers?: Record, +): Promise { + const client = new Client({ + name: 'mcp-integrations-test', + version: '1.0.0', + }); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getServerPort(server)}/api/mcp-actions/v1`), + headers + ? { + requestInit: { + headers, + }, + } + : undefined, + ); + + try { + await client.connect(transport); + return await run(client); + } finally { + await client.close(); + } +} + +function stripMarkdownJsonFence(text: string): string { + let jsonText = text.trim(); + + if (jsonText.toLowerCase().startsWith('```json')) { + jsonText = jsonText.slice('```json'.length); + } + + if (jsonText.endsWith('```')) { + jsonText = jsonText.slice(0, -3); + } + + return jsonText.trim(); +} + +function parseCallToolOutput(result: unknown): unknown { + const callResult = result as CallToolResult; + + if ( + 'structuredContent' in callResult && + callResult.structuredContent !== undefined + ) { + return callResult.structuredContent; + } + + if ('content' in callResult && Array.isArray(callResult.content)) { + for (const item of callResult.content) { + if ( + item.type === 'text' && + 'text' in item && + typeof item.text === 'string' + ) { + return JSON.parse(stripMarkdownJsonFence(item.text)); + } + } + } + + throw new Error('Call tool result did not include parseable output'); +} + +describe('MCP tools integration', () => { + describe('tools/list', () => { + describe('with all plugin sources', () => { + let backend: McpTestBackend; + + beforeAll(async () => { + backend = await startMcpBackend({ + pluginSources: [...ALL_PLUGIN_SOURCES], + }); + }); + + it('exposes all overlay MCP tools via the mcp-actions endpoint', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolNames = sortAlphabetically( + result.tools.map(tool => tool.name), + ); + expect(toolNames).toEqual(sortAlphabetically(EXPECTED_EXTRA_TOOLS)); + expect(result.tools).toHaveLength(EXPECTED_EXTRA_TOOLS.length); + }); + }); + + it('includes description and inputSchema for every tool', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + for (const tool of result.tools) { + expect(tool.description?.trim().length).toBeGreaterThan(0); + expect(tool.inputSchema).toMatchObject({ type: 'object' }); + } + }); + }); + + it('marks read-only and destructive tools with the correct MCP hints', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolsByName = Object.fromEntries( + result.tools.map(tool => [tool.name, tool]), + ); + + for (const toolName of READ_ONLY_TOOLS) { + expect(toolsByName[toolName]?.annotations?.readOnlyHint).toBe(true); + expect(toolsByName[toolName]?.annotations?.destructiveHint).toBe( + false, + ); + } + + for (const toolName of DESTRUCTIVE_TOOLS) { + expect(toolsByName[toolName]?.annotations?.destructiveHint).toBe( + true, + ); + expect(toolsByName[toolName]?.annotations?.readOnlyHint).toBe( + false, + ); + } + }); + }); + }); + + it('only exposes tools from configured pluginSources', async () => { + const backend = await startMcpBackend({ + pluginSources: ['software-catalog-mcp-extras'], + }); + + await withMcpClient(backend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]?.name).toBe( + 'software-catalog-mcp-extras.query-catalog-entities', + ); + }); + }); + }); + + describe('tools/call smoke tests', () => { + let backend: McpTestBackend; + let fetchSpy: jest.SpyInstance; + + beforeAll(async () => { + backend = await startMcpBackend({ + pluginSources: [...ALL_PLUGIN_SOURCES], + }); + }); + + beforeEach(() => { + const originalFetch = globalThis.fetch.bind(globalThis); + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + const url = String(input); + + if (/\/api\/scaffolder(?:\/|\?|$)/.test(url)) { + return { + ok: true, + json: async () => ({ tasks: [], totalTasks: 0 }), + } as Response; + } + + return originalFetch(input, init); + }); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('calls query-catalog-entities and returns an empty entity list', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.callTool( + { + name: 'software-catalog-mcp-extras.query-catalog-entities', + arguments: {}, + }, + CallToolResultSchema, + ); + + expect(parseCallToolOutput(result)).toEqual({ entities: [] }); + }); + }); + + it('calls analyze-techdocs-coverage and returns coverage stats', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.callTool( + { + name: 'techdocs-mcp-extras.analyze-techdocs-coverage', + arguments: {}, + }, + CallToolResultSchema, + ); + + expect(parseCallToolOutput(result)).toMatchObject({ + totalEntities: 0, + entitiesWithDocs: 0, + coveragePercentage: 0, + }); + }); + }); + + it('calls fetch-template-metadata and returns an empty template list', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.callTool( + { + name: 'scaffolder-mcp-extras.fetch-template-metadata', + arguments: {}, + }, + CallToolResultSchema, + ); + + expect(parseCallToolOutput(result)).toEqual({ templates: [] }); + }); + }); + }); + + describe('authentication', () => { + let backend: McpTestBackend; + + beforeAll(async () => { + backend = await startMcpBackend({ + pluginSources: ['software-catalog-mcp-extras'], + requireMcpToken: true, + }); + }); + + it('rejects MCP requests without Authorization when an MCP token is required', async () => { + await expect( + withMcpClient(backend.server, async client => + client.request({ method: 'tools/list' }, ListToolsResultSchema), + ), + ).rejects.toThrow(); + }); + + it('allows tools/list with a valid Bearer token', async () => { + await withMcpClient( + backend.server, + async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]?.name).toBe( + 'software-catalog-mcp-extras.query-catalog-entities', + ); + }, + { Authorization: `Bearer ${MCP_TOKEN}` }, + ); + }); + }); +}); diff --git a/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/actions/createFetchTemplateMetadataAction.ts b/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/actions/createFetchTemplateMetadataAction.ts index 2341f7514d..d5e998bfa7 100644 --- a/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/actions/createFetchTemplateMetadataAction.ts +++ b/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/actions/createFetchTemplateMetadataAction.ts @@ -29,6 +29,11 @@ export const createFetchTemplateMetadataAction = ({ actionsRegistry.register({ name: 'fetch-template-metadata', title: 'Fetch Software Template Metadata', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, description: `Search and retrieve Software Template metadata from the Backstage catalog. This tool retrieves Backstage Software Templates with their configuration details including parameters and steps. diff --git a/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/plugin.integration.test.ts b/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/plugin.integration.test.ts new file mode 100644 index 0000000000..c949b9f584 --- /dev/null +++ b/workspaces/mcp-integrations/plugins/scaffolder-mcp-extras/src/plugin.integration.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { mcpScaffolderExtrasPlugin } from './plugin'; + +const EXPECTED_ACTIONS = [ + 'execute-template', + 'fetch-template-metadata', + 'get-scaffolder-task-logs', + 'list-scaffolder-actions', + 'list-scaffolder-tasks', + 'validate-scaffolder', +]; + +describe('mcpScaffolderExtrasPlugin integration', () => { + let registeredActionNames: string[]; + + beforeAll(async () => { + registeredActionNames = []; + + await startTestBackend({ + features: [ + mcpScaffolderExtrasPlugin, + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { baseUrl: 'http://localhost:7007' }, + }, + }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: [] }), + createServiceFactory({ + service: actionsRegistryServiceRef, + deps: {}, + factory: () => ({ + register: (opts: { name: string }) => { + registeredActionNames.push(opts.name); + }, + }), + }), + ], + }); + }); + + it('registers all expected MCP actions', () => { + const sortedRegistered = [...registeredActionNames].sort((a, b) => + a.localeCompare(b), + ); + const sortedExpected = [...EXPECTED_ACTIONS].sort((a, b) => + a.localeCompare(b), + ); + + expect(sortedRegistered).toEqual(sortedExpected); + expect(registeredActionNames).toHaveLength(EXPECTED_ACTIONS.length); + }); +}); 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 07aca2ff71..4a966dc983 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 @@ -30,6 +30,11 @@ export const createQueryCatalogEntitiesAction = ({ actionsRegistry.register({ name: 'query-catalog-entities', title: 'Fetch Catalog Entities', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, description: `Search and retrieve catalog entities from the Backstage server. List all Backstage entities such as Components, Systems, Resources, APIs, Locations, Users, and Groups. diff --git a/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/plugin.integration.test.ts b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/plugin.integration.test.ts new file mode 100644 index 0000000000..744527675e --- /dev/null +++ b/workspaces/mcp-integrations/plugins/software-catalog-mcp-extras/src/plugin.integration.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { softwareCatalogMcpExtrasPlugin } from './plugin'; + +const EXPECTED_ACTIONS = ['query-catalog-entities']; + +describe('softwareCatalogMcpExtrasPlugin integration', () => { + let registeredActionNames: string[]; + + beforeAll(async () => { + registeredActionNames = []; + + await startTestBackend({ + features: [ + softwareCatalogMcpExtrasPlugin, + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ data: {} }), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: [] }), + createServiceFactory({ + service: actionsRegistryServiceRef, + deps: {}, + factory: () => ({ + register: (opts: { name: string }) => { + registeredActionNames.push(opts.name); + }, + }), + }), + ], + }); + }); + + it('registers all expected MCP actions', () => { + const sortedRegistered = [...registeredActionNames].sort((a, b) => + a.localeCompare(b), + ); + const sortedExpected = [...EXPECTED_ACTIONS].sort((a, b) => + a.localeCompare(b), + ); + + expect(sortedRegistered).toEqual(sortedExpected); + expect(registeredActionNames).toHaveLength(EXPECTED_ACTIONS.length); + }); +}); diff --git a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createAnalyzeTechDocsCoverageAction.ts b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createAnalyzeTechDocsCoverageAction.ts index bb16c3546e..2e1fd47224 100644 --- a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createAnalyzeTechDocsCoverageAction.ts +++ b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createAnalyzeTechDocsCoverageAction.ts @@ -37,6 +37,11 @@ export const createAnalyzeTechDocsCoverageAction = ({ actionsRegistry.register({ name: 'analyze-techdocs-coverage', title: 'Analyze TechDocs Coverage', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, description: `Analyze documentation coverage across Backstage entities to understand what percentage of entities have TechDocs available. It calculates the percentage of entities that have TechDocs configured, helping identify documentation gaps and improve overall documentation coverage. diff --git a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createFetchTechDocsAction.ts b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createFetchTechDocsAction.ts index e6e97e7741..8a89068237 100644 --- a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createFetchTechDocsAction.ts +++ b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createFetchTechDocsAction.ts @@ -37,6 +37,11 @@ export const createFetchTechDocsAction = ({ actionsRegistry.register({ name: 'fetch-techdocs', title: 'Fetch TechDoc Entities', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, description: `Search and retrieve all TechDoc entities from the Backstage Server List all Backstage entities with techdocs. Results are returned in JSON array format, where each diff --git a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createRetrieveTechDocsContentAction.ts b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createRetrieveTechDocsContentAction.ts index 553b2c90ff..78fbf6dacd 100644 --- a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createRetrieveTechDocsContentAction.ts +++ b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/actions/createRetrieveTechDocsContentAction.ts @@ -37,6 +37,11 @@ export const createRetrieveTechDocsContentAction = ({ actionsRegistry.register({ name: 'retrieve-techdocs-content', title: 'Retrieve TechDocs Content', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, description: `Retrieve the actual TechDocs content for a specific entity and optional page. This tool allows AI clients to access documentation content for specific catalog entities. diff --git a/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/plugin.integration.test.ts b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/plugin.integration.test.ts new file mode 100644 index 0000000000..c6fb67325b --- /dev/null +++ b/workspaces/mcp-integrations/plugins/techdocs-mcp-extras/src/plugin.integration.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { mcpTechdocsExtrasPlugin } from './plugin'; + +const EXPECTED_ACTIONS = [ + 'analyze-techdocs-coverage', + 'fetch-techdocs', + 'retrieve-techdocs-content', +]; + +describe('mcpTechdocsExtrasPlugin integration', () => { + let registeredActionNames: string[]; + + beforeAll(async () => { + registeredActionNames = []; + + await startTestBackend({ + features: [ + mcpTechdocsExtrasPlugin, + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { baseUrl: 'http://localhost:7007' }, + techdocs: { + builder: 'local', + generator: { runIn: 'local' }, + publisher: { type: 'local' }, + }, + }, + }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: [] }), + createServiceFactory({ + service: actionsRegistryServiceRef, + deps: {}, + factory: () => ({ + register: (opts: { name: string }) => { + registeredActionNames.push(opts.name); + }, + }), + }), + ], + }); + }); + + it('registers all expected MCP actions', () => { + const sortedRegistered = [...registeredActionNames].sort((a, b) => + a.localeCompare(b), + ); + const sortedExpected = [...EXPECTED_ACTIONS].sort((a, b) => + a.localeCompare(b), + ); + + expect(sortedRegistered).toEqual(sortedExpected); + expect(registeredActionNames).toHaveLength(EXPECTED_ACTIONS.length); + }); +}); diff --git a/workspaces/mcp-integrations/yarn.lock b/workspaces/mcp-integrations/yarn.lock index 4bfe2d303e..0f66b7df45 100644 --- a/workspaces/mcp-integrations/yarn.lock +++ b/workspaces/mcp-integrations/yarn.lock @@ -15110,8 +15110,11 @@ __metadata: resolution: "backend@workspace:packages/backend" dependencies: "@backstage/backend-defaults": "npm:^0.16.0" + "@backstage/backend-plugin-api": "npm:^1.8.0" + "@backstage/backend-test-utils": "npm:^1.11.1" "@backstage/cli": "npm:^0.36.0" "@backstage/config": "npm:^1.3.6" + "@backstage/errors": "npm:^1.2.7" "@backstage/plugin-app-backend": "npm:^0.5.12" "@backstage/plugin-auth-backend": "npm:^0.27.3" "@backstage/plugin-auth-backend-module-github-provider": "npm:^0.5.1" @@ -15120,6 +15123,7 @@ __metadata: "@backstage/plugin-catalog-backend": "npm:^3.5.0" "@backstage/plugin-catalog-backend-module-logs": "npm:^0.1.20" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "npm:^0.2.18" + "@backstage/plugin-catalog-node": "npm:^2.1.0" "@backstage/plugin-kubernetes-backend": "npm:^0.21.2" "@backstage/plugin-mcp-actions-backend": "npm:^0.1.11" "@backstage/plugin-permission-backend": "npm:^0.7.10" @@ -15135,6 +15139,7 @@ __metadata: "@backstage/plugin-search-backend-module-techdocs": "npm:^0.4.12" "@backstage/plugin-search-backend-node": "npm:^1.4.2" "@backstage/plugin-techdocs-backend": "npm:^2.1.6" + "@modelcontextprotocol/sdk": "npm:^1.25.2" "@red-hat-developer-hub/backstage-plugin-scaffolder-mcp-extras": "workspace:^" "@red-hat-developer-hub/backstage-plugin-software-catalog-mcp-extras": "workspace:^" "@red-hat-developer-hub/backstage-plugin-techdocs-mcp-extras": "workspace:^"