From b8a38a90fed9c410a7e6092b0697a95aaf1f7c57 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 2 Sep 2026 23:15:41 +0530 Subject: [PATCH 1/4] test(mcp-integrations): add Kubernetes MCP actions integration coverage Add plugin and MCP protocol integration tests for kubernetes-mcp-extras, aligned with Orchestrator and Scorecard MCP patterns from PR #4426. Signed-off-by: HusneShabbir Co-authored-by: Cursor --- .../packages/backend/package.json | 2 +- .../kubernetes-mcp-tools.integration.test.ts | 260 ++++++++++++++++++ .../backend/src/mcp-tools.integration.test.ts | 7 + .../src/plugin.integration.test.ts | 72 +++++ 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts create mode 100644 workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts diff --git a/workspaces/mcp-integrations/packages/backend/package.json b/workspaces/mcp-integrations/packages/backend/package.json index 3f05fa2ebcc..1a998beac75 100644 --- a/workspaces/mcp-integrations/packages/backend/package.json +++ b/workspaces/mcp-integrations/packages/backend/package.json @@ -17,7 +17,7 @@ "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", - "test:integration": "backstage-cli package test src/mcp-tools.integration.test.ts --watch=false", + "test:integration": "backstage-cli package test --testPathPatterns=integration\\.test --watch=false", "clean": "backstage-cli package clean", "build-image": "docker build ../.. -f Dockerfile --tag backstage" }, diff --git a/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts new file mode 100644 index 00000000000..c399e915470 --- /dev/null +++ b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts @@ -0,0 +1,260 @@ +/* + * 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 mcpPlugin from '@backstage/plugin-mcp-actions-backend'; +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'; +import mcpKubernetesExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-kubernetes-mcp-extras'; +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'; + +type CallToolResult = { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; +}; + +type StartKubernetesMcpBackendOptions = { + pluginSources?: string[]; +}; + +const TECHDOCS_CONFIG = { + builder: 'local', + generator: { runIn: 'local' }, + publisher: { type: 'local' }, +}; + +const MCP_TRANSPORT_RECONNECTION_OPTIONS = { + initialReconnectionDelay: 0, + maxReconnectionDelay: 0, + reconnectionDelayGrowFactor: 1, + maxRetries: 0, +} as const; + +const KUBERNETES_TOOL_NAMES = [ + 'kubernetes-mcp-extras.get-kubernetes-clusters', + 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', +] as const; + +const READ_ONLY_TOOL_NAMES = [...KUBERNETES_TOOL_NAMES]; + +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; +} + +function createBackendConfig(pluginSources: string[]) { + return { + backend: { + baseUrl: 'http://localhost:7007', + actions: { + pluginSources, + }, + }, + techdocs: TECHDOCS_CONFIG, + }; +} + +async function startKubernetesMcpBackend({ + pluginSources = ['kubernetes-mcp-extras'], +}: StartKubernetesMcpBackendOptions = {}) { + return startTestBackend({ + features: [ + mcpPlugin, + softwareCatalogMcpExtrasPlugin, + mcpTechdocsExtrasPlugin, + mcpScaffolderExtrasPlugin, + mcpKubernetesExtrasPlugin, + metricsServiceMock.mock().factory, + mockServices.rootConfig.factory({ + data: createBackendConfig(pluginSources), + }), + mockServices.auth.factory(), + catalogServiceMock.factory({ entities: [] }), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + ], + }); +} + +function createMcpTransport(server: Server): StreamableHTTPClientTransport { + return new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getServerPort(server)}/api/mcp-actions/v1`), + { + reconnectionOptions: { ...MCP_TRANSPORT_RECONNECTION_OPTIONS }, + }, + ); +} + +async function closeMcpConnection( + client: Client, + transport: StreamableHTTPClientTransport, +): Promise { + try { + await transport.terminateSession(); + } catch { + // MCP servers may respond with 405 when session termination is unsupported. + } + + await client.close(); +} + +async function withMcpClient( + server: Server, + run: (client: Client) => Promise, +): Promise { + const client = new Client({ + name: 'kubernetes-mcp-integration-test', + version: '1.0.0', + }); + + const transport = createMcpTransport(server); + + try { + await client.connect(transport); + return await run(client); + } finally { + await closeMcpConnection(client, transport); + } +} + +function parseCallToolError(result: unknown): string { + const callResult = result as CallToolResult; + const messages: string[] = []; + + if ('content' in callResult && Array.isArray(callResult.content)) { + for (const item of callResult.content) { + if (item.type === 'text' && typeof item.text === 'string') { + messages.push(item.text); + } + } + } + + return messages.join('\n'); +} + +type McpTestBackend = Awaited>; + +describe('Kubernetes MCP tools integration', () => { + let backend: McpTestBackend; + + beforeAll(async () => { + backend = await startKubernetesMcpBackend(); + }); + + it('exposes both kubernetes tools through MCP tools/list', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolNames = result.tools.map(tool => tool.name); + expect(toolNames).toEqual( + expect.arrayContaining([...KUBERNETES_TOOL_NAMES]), + ); + }); + }); + + it('marks kubernetes tools as read-only in MCP metadata', 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_TOOL_NAMES) { + expect(toolsByName[toolName]?.annotations?.readOnlyHint).toBe(true); + expect(toolsByName[toolName]?.annotations?.destructiveHint).toBe(false); + expect(toolsByName[toolName]?.inputSchema).toMatchObject({ + type: 'object', + }); + expect( + toolsByName[toolName]?.description?.trim().length, + ).toBeGreaterThan(0); + } + }); + }); + + it('hides kubernetes tools when kubernetes-mcp-extras is not in pluginSources', async () => { + const filteredBackend = await startKubernetesMcpBackend({ + pluginSources: [], + }); + + await withMcpClient(filteredBackend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolNames = result.tools.map(tool => tool.name); + for (const toolName of KUBERNETES_TOOL_NAMES) { + expect(toolNames).not.toContain(toolName); + } + }); + }); + + it('returns validation error when get-kubernetes-resources-for-entity name is missing', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.callTool( + { + name: 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', + arguments: {}, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBe(true); + expect(parseCallToolError(result)).toContain('name'); + }); + }); + + it('returns validation error when get-kubernetes-resources-for-entity name has invalid type', async () => { + await withMcpClient(backend.server, async client => { + const result = await client.callTool( + { + name: 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', + arguments: { name: 12345 }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBe(true); + expect(parseCallToolError(result)).toContain('name'); + }); + }); +}); 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 index 3a4419722ae..bee22bcd0f3 100644 --- a/workspaces/mcp-integrations/packages/backend/src/mcp-tools.integration.test.ts +++ b/workspaces/mcp-integrations/packages/backend/src/mcp-tools.integration.test.ts @@ -39,10 +39,13 @@ 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'; +import mcpKubernetesExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-kubernetes-mcp-extras'; const MCP_TOKEN = 'ci-test-mcp-token-12345'; const EXPECTED_EXTRA_TOOLS = [ + 'kubernetes-mcp-extras.get-kubernetes-clusters', + 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', 'scaffolder-mcp-extras.execute-template', 'scaffolder-mcp-extras.fetch-template-metadata', 'scaffolder-mcp-extras.get-scaffolder-task-logs', @@ -56,6 +59,8 @@ const EXPECTED_EXTRA_TOOLS = [ ]; const READ_ONLY_TOOLS = [ + 'kubernetes-mcp-extras.get-kubernetes-clusters', + 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', 'techdocs-mcp-extras.fetch-techdocs', 'techdocs-mcp-extras.retrieve-techdocs-content', 'scaffolder-mcp-extras.list-scaffolder-tasks', @@ -76,6 +81,7 @@ type CallToolResult = z.infer; type McpTestBackend = Awaited>; const ALL_PLUGIN_SOURCES = [ + 'kubernetes-mcp-extras', 'software-catalog-mcp-extras', 'techdocs-mcp-extras', 'scaffolder-mcp-extras', @@ -180,6 +186,7 @@ async function startMcpBackend(options: McpBackendOptions) { softwareCatalogMcpExtrasPlugin, mcpTechdocsExtrasPlugin, mcpScaffolderExtrasPlugin, + mcpKubernetesExtrasPlugin, metricsServiceMock.mock().factory, mockServices.rootConfig.factory({ data: createBackendConfig(options), diff --git a/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts b/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts new file mode 100644 index 00000000000..9abccb5378b --- /dev/null +++ b/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { mcpKubernetesExtrasPlugin } from './plugin'; + +const EXPECTED_ACTIONS = [ + 'get-kubernetes-clusters', + 'get-kubernetes-resources-for-entity', +]; + +describe('mcpKubernetesExtrasPlugin integration', () => { + let registeredActionNames: string[]; + + beforeAll(async () => { + registeredActionNames = []; + + await startTestBackend({ + features: [ + mcpKubernetesExtrasPlugin, + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { baseUrl: 'http://localhost:7007' }, + }, + }), + mockServices.auth.factory(), + mockServices.discovery.factory(), + 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); + }); +}); From 0ec8c3594c76a0489d796c02cb5fd0aefaa448ff Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 2 Sep 2026 23:22:22 +0530 Subject: [PATCH 2/4] test(mcp-integrations): remove unused mockCredentials import Fixes tsc:full CI failure in kubernetes-mcp-extras plugin integration test. Signed-off-by: HusneShabbir Co-authored-by: Cursor --- .../kubernetes-mcp-extras/src/plugin.integration.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts b/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts index 9abccb5378b..977a8168bb6 100644 --- a/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts +++ b/workspaces/mcp-integrations/plugins/kubernetes-mcp-extras/src/plugin.integration.test.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - mockCredentials, - mockServices, - startTestBackend, -} from '@backstage/backend-test-utils'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; import { createServiceFactory } from '@backstage/backend-plugin-api'; import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { mcpKubernetesExtrasPlugin } from './plugin'; From d7056f59324d8fcbbab9961f1bfff976c26e187f Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Thu, 3 Sep 2026 13:50:36 +0530 Subject: [PATCH 3/4] test(mcp-integrations): slim kubernetes MCP integration backend harness Load only kubernetes-mcp-extras and mcpPlugin in the integration test backend, matching the Orchestrator pattern instead of all MCP extras plugins. Co-authored-by: Cursor --- .../kubernetes-mcp-tools.integration.test.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts index c399e915470..004c9fc315a 100644 --- a/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts +++ b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts @@ -19,12 +19,7 @@ import { mockServices, startTestBackend, } from '@backstage/backend-test-utils'; -import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; import mcpPlugin from '@backstage/plugin-mcp-actions-backend'; -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'; import mcpKubernetesExtrasPlugin from '@red-hat-developer-hub/backstage-plugin-kubernetes-mcp-extras'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -44,12 +39,6 @@ type StartKubernetesMcpBackendOptions = { pluginSources?: string[]; }; -const TECHDOCS_CONFIG = { - builder: 'local', - generator: { runIn: 'local' }, - publisher: { type: 'local' }, -}; - const MCP_TRANSPORT_RECONNECTION_OPTIONS = { initialReconnectionDelay: 0, maxReconnectionDelay: 0, @@ -80,7 +69,6 @@ function createBackendConfig(pluginSources: string[]) { pluginSources, }, }, - techdocs: TECHDOCS_CONFIG, }; } @@ -89,17 +77,12 @@ async function startKubernetesMcpBackend({ }: StartKubernetesMcpBackendOptions = {}) { return startTestBackend({ features: [ - mcpPlugin, - softwareCatalogMcpExtrasPlugin, - mcpTechdocsExtrasPlugin, - mcpScaffolderExtrasPlugin, mcpKubernetesExtrasPlugin, - metricsServiceMock.mock().factory, + mcpPlugin, mockServices.rootConfig.factory({ data: createBackendConfig(pluginSources), }), mockServices.auth.factory(), - catalogServiceMock.factory({ entities: [] }), mockServices.httpAuth.factory({ defaultCredentials: mockCredentials.user('user:default/test'), }), From d5dff13ce75e0e81fb7a150802e0955becfb9609 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Thu, 3 Sep 2026 13:53:42 +0530 Subject: [PATCH 4/4] test(mcp-integrations): address PR review on kubernetes MCP tests Drop redundant tools/list test and READ_ONLY_TOOL_NAMES alias; the read-only metadata test already asserts both tools are exposed. Co-authored-by: Cursor --- .../kubernetes-mcp-tools.integration.test.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts index 004c9fc315a..5d5b5bb1a2f 100644 --- a/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts +++ b/workspaces/mcp-integrations/packages/backend/src/kubernetes-mcp-tools.integration.test.ts @@ -51,8 +51,6 @@ const KUBERNETES_TOOL_NAMES = [ 'kubernetes-mcp-extras.get-kubernetes-resources-for-entity', ] as const; -const READ_ONLY_TOOL_NAMES = [...KUBERNETES_TOOL_NAMES]; - function getServerPort(server: Server): number { const address = server.address(); if (typeof address !== 'object' || !address || !('port' in address)) { @@ -155,20 +153,6 @@ describe('Kubernetes MCP tools integration', () => { backend = await startKubernetesMcpBackend(); }); - it('exposes both kubernetes tools through MCP tools/list', async () => { - await withMcpClient(backend.server, async client => { - const result = await client.request( - { method: 'tools/list' }, - ListToolsResultSchema, - ); - - const toolNames = result.tools.map(tool => tool.name); - expect(toolNames).toEqual( - expect.arrayContaining([...KUBERNETES_TOOL_NAMES]), - ); - }); - }); - it('marks kubernetes tools as read-only in MCP metadata', async () => { await withMcpClient(backend.server, async client => { const result = await client.request( @@ -180,7 +164,7 @@ describe('Kubernetes MCP tools integration', () => { result.tools.map(tool => [tool.name, tool]), ); - for (const toolName of READ_ONLY_TOOL_NAMES) { + for (const toolName of KUBERNETES_TOOL_NAMES) { expect(toolsByName[toolName]?.annotations?.readOnlyHint).toBe(true); expect(toolsByName[toolName]?.annotations?.destructiveHint).toBe(false); expect(toolsByName[toolName]?.inputSchema).toMatchObject({