Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/orchestrator/.changeset/quiet-otters-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch
---

Bound the page size of the `list-workflows` and `list-instances` MCP actions (default 50, max 100, with `limit`/`offset` inputs) so they no longer fetch every visible workflow/instance in a single unbounded call. Also cache compiled Ajv validators in the `execute-workflow` action instead of recompiling the workflow's input schema on every invocation.
5 changes: 5 additions & 0 deletions workspaces/orchestrator/.changeset/tame-plums-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': minor
---

Add 5 Orchestrator MCP actions (`list-workflows`, `get-workflow-schema`, `execute-workflow`, `list-instances`, `get-instance`) so LLM/CLI clients can discover and run Orchestrator workflows through the Model Context Protocol. Each action enforces the existing Orchestrator RBAC permissions (`orchestrator.workflow`, `orchestrator.workflow.use`, `orchestrator.instanceAdminView`) with full fidelity, including conditional policies and instance ownership checks.
14 changes: 14 additions & 0 deletions workspaces/orchestrator/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ backend:
# echo mycurlpasswd | base64
token: bXljdXJscGFzc3dkCg== # NOSONAR
subject: my-external-feed
# Uncomment to let MCP clients (e.g. Cursor, Claude Code) authenticate
# against the Orchestrator MCP Actions below - see
# plugins/orchestrator-backend/README.md#mcp-actions
# - type: static
# options:
# token: ${MCP_TOKEN}
# subject: mcp-clients
# Uncomment this when Orchestrator MCP Actions are needed (RHIDP-14041) -
# without this, actions registered via the Actions Registry are not
# exposed through @backstage/plugin-mcp-actions-backend, even though the
# plugin is wired in packages/backend/src/index.ts.
# actions:
# pluginSources:
# - 'orchestrator'
listen:
port: 7007
# Uncomment the following host directive to bind to specific interfaces
Expand Down
1 change: 1 addition & 0 deletions workspaces/orchestrator/packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@backstage/plugin-catalog-backend-module-gitlab": "^0.8.4",
"@backstage/plugin-catalog-backend-module-logs": "^0.1.23",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.21",
"@backstage/plugin-mcp-actions-backend": "^0.1.14",
"@backstage/plugin-notifications-backend": "^0.6.6",
"@backstage/plugin-permission-backend": "^0.7.13",
"@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.20",
Expand Down
4 changes: 4 additions & 0 deletions workspaces/orchestrator/packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ backend.add(import('@backstage/plugin-search-backend-module-pg'));
backend.add(import('@backstage/plugin-search-backend-module-catalog'));
backend.add(import('@backstage/plugin-search-backend-module-techdocs'));

// MCP actions (required for the Orchestrator MCP actions to be reachable by
// an LLM/CLI client — see RHIDP-14041)
backend.add(import('@backstage/plugin-mcp-actions-backend'));

// orchestrator
backend.add(
import('@red-hat-developer-hub/backstage-plugin-orchestrator-backend'),
Expand Down
69 changes: 69 additions & 0 deletions workspaces/orchestrator/plugins/orchestrator-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,72 @@
Welcome to the backend package for the Orchestrator plugin!

For more information about the Orchestrator plugin, see the [Orchestrator Plugin documentation](https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/orchestrator/plugins/orchestrator) on GitHub.

## MCP Actions

The Orchestrator backend plugin registers MCP (Model Context Protocol) actions that allow AI agents and MCP clients (e.g. Cursor, Claude Code) to discover Orchestrator workflows and instances, and to run workflows, programmatically.

### Available actions

| Action | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `list-workflows` | Lists the workflow definitions visible to the caller, optionally filtered by name and/or last-run status. |
| `get-workflow-schema` | Returns a workflow's input JSON schema. Call this before `execute-workflow` to learn what inputs it expects. |
| `execute-workflow` | Starts a new run of a workflow with the given inputs, validated against the workflow's own schema first. |
| `list-instances` | Lists workflow runs (instances) visible to the caller, optionally filtered by status. |
| `get-instance` | Fetches a single workflow instance's status, timestamps, and output data. |

These actions enforce the same Backstage permissions as the Orchestrator REST API (`orchestrator.workflow`, `orchestrator.workflow.use`, `orchestrator.instanceAdminView`) — no separate permission model is introduced for MCP.

### Enabling MCP Actions

To enable MCP actions, install the `@backstage/plugin-mcp-actions-backend` package and configure authentication:

1. Install the MCP actions backend plugin:

```bash
# From your root directory
yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend
```

2. Add the plugin to your backend in `packages/backend/src/index.ts`:

```ts
backend.add(import('@backstage/plugin-mcp-actions-backend'));
```

3. Add the orchestrator plugin as an action source and configure a static token for MCP client authentication in your `app-config.yaml`:

```yaml
backend:
actions:
pluginSources:
- 'orchestrator'
auth:
externalAccess:
- type: static
options:
token: ${MCP_TOKEN}
subject: mcp-clients
```

4. Set the `MCP_TOKEN` environment variable (8 characters or longer) before starting the backend.

### Interacting with MCP Actions

See the [Backstage MCP Actions Backend documentation](https://github.com/backstage/backstage/tree/master/plugins/mcp-actions-backend#configuring-mcp-clients) for more information on configuring MCP clients.

Sample `mcp.json` for Cursor:

```json
{
"mcpServers": {
"backstage-actions": {
"url": "http://localhost:7007/api/mcp-actions/v1",
"headers": {
"Authorization": "Bearer ${MCP_TOKEN}"
}
}
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"lint:check": "backstage-cli package lint",
"lint:fix": "backstage-cli package lint --fix",
"test": "backstage-cli package test --passWithNoTests --coverage",
"test:integration": "backstage-cli package test src/mcp-tools.integration.test.ts --watch=false",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
Expand All @@ -79,6 +80,7 @@
"@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-orchestrator-node": "workspace:^",
"@urql/core": "^6.0.1",
"ajv": "^8.17.1",
"ajv-formats": "^2.1.1",
"cloudevents": "^10.0.0",
"express": "^4.21.2",
Expand All @@ -97,8 +99,10 @@
"@backstage-community/plugin-rbac-common": "^1.29.0",
"@backstage/backend-test-utils": "^1.11.4",
"@backstage/cli": "^0.36.3",
"@backstage/plugin-mcp-actions-backend": "^0.1.14",
"@janus-idp/backstage-plugin-audit-log-node": "^1.7.1",
"@janus-idp/cli": "3.7.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"@types/express": "4.17.25",
"@types/fs-extra": "11.0.4",
"@types/json-schema": "7.0.15",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* 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 mcpPlugin from '@backstage/plugin-mcp-actions-backend';
import {
AuthorizeResult,
type PolicyDecision,
} from '@backstage/plugin-permission-common';

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

import type { Server } from 'node:http';

// Must be imported after the `jest.mock` calls above so that `orchestratorPlugin`'s
// transitive imports resolve to the mocked service classes.
// eslint-disable-next-line import/first
import { orchestratorPlugin } from '../plugin';

// Orchestrator has no official test double for SonataFlowService /
// DataIndexService / WorkflowCacheService / OrchestratorService (unlike e.g.
// Scorecard's `catalogServiceMock`). The only established convention for
// stubbing them in this package is `service/router.test.ts`'s same-package,
// relative-path `jest.mock(...)` calls, reused here verbatim so the real
// `orchestratorPlugin` boots through `startTestBackend` without touching a
// real SonataFlow/DataIndex service.
jest.mock('../service/DataIndexService', () => ({
DataIndexService: jest.fn().mockImplementation(() => ({})),
}));

jest.mock('../service/SonataFlowService', () => ({
SonataFlowService: jest.fn().mockImplementation(() => ({})),
}));

jest.mock('../service/WorkflowCacheService', () => ({
WorkflowCacheService: jest.fn().mockImplementation(() => ({
schedule: jest.fn(),
})),
}));

export const mockOrchestratorService = {
fetchInstance: jest.fn(),
fetchWorkflowOverviews: jest.fn(),
fetchWorkflowInfo: jest.fn(),
fetchWorkflowDefinition: jest.fn(),
fetchWorkflowInfoOnService: jest.fn(),
executeWorkflow: jest.fn(),
fetchInstances: jest.fn(),
getWorkflowIds: jest.fn(),
};

jest.mock('../service/OrchestratorService', () => ({
OrchestratorService: jest
.fn()
.mockImplementation(() => mockOrchestratorService),
}));

export type BackendPermissionMode = 'allow-all' | 'deny-all';

export type StartMcpBackendOptions = {
permissionMode?: BackendPermissionMode;
};

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 createPermissionsFactory(mode: BackendPermissionMode) {
const decision: PolicyDecision =
mode === 'deny-all'
? { result: AuthorizeResult.DENY }
: { result: AuthorizeResult.ALLOW };

return mockServices.permissions.mock({
authorize: async () => [decision],
authorizeConditional: async () => [decision],
}).factory;
}

export async function startMcpBackend({
permissionMode = 'allow-all',
}: StartMcpBackendOptions = {}) {
return startTestBackend({
features: [
orchestratorPlugin,
mcpPlugin,
mockServices.rootConfig.factory({
data: {
backend: {
baseUrl: 'http://localhost:7007',
actions: { pluginSources: ['orchestrator'] },
},
orchestrator: {
dataIndexService: { url: 'http://localhost:8080' },
},
},
}),
mockServices.auth.factory(),
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.user('user:default/test'),
}),
createPermissionsFactory(permissionMode),
mockServices.database.factory(),
mockServices.cache.factory(),
],
});
}

const MCP_TRANSPORT_RECONNECTION_OPTIONS = {
initialReconnectionDelay: 0,
maxReconnectionDelay: 0,
reconnectionDelayGrowFactor: 1,
maxRetries: 0,
} as const;

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<void> {
try {
await transport.terminateSession();
} catch {
// MCP servers may return 405 when session termination is unsupported.
}

await client.close();
}

export async function withMcpClient<T>(
server: Server,
run: (client: Client) => Promise<T>,
): Promise<T> {
const client = new Client({
name: 'orchestrator-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);
}
}

export type CallToolResult = {
structuredContent?: unknown;
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
};

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();
}

export 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');
}

export 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');
}
Loading
Loading