Merge branch 'backstage:master' into feature/catalog-export

This commit is contained in:
1337
2026-05-20 20:32:27 +02:00
committed by GitHub
1172 changed files with 67648 additions and 10161 deletions
+13
View File
@@ -1,5 +1,18 @@
# @backstage/plugin-mcp-actions-backend
## 0.1.13
### Patch Changes
- ca8951a: Fixed an issue where actions returned Markdown-formatted JSON instead of plain JSON and a `structuredContent` field for model context protocol responses.
- 8916f83: Trace spans are now emitted for MCP `tools/call` invocations, following OpenTelemetry server-side MCP semantic conventions.
- Updated dependencies
- @backstage/errors@1.3.1
- @backstage/backend-plugin-api@1.9.1
- @backstage/plugin-catalog-node@2.2.1
- @backstage/catalog-client@1.15.1
- @backstage/config@1.3.8
## 0.1.13-next.0
### Patch Changes
+4
View File
@@ -135,6 +135,10 @@ When errors are thrown from MCP actions, the backend will handle and surface err
See [Backstage Errors](https://backstage.io/docs/reference/errors/) for a full list of supported errors.
### Response Format
Tool execution results are returned in a format compliant with the MCP specification, including both a plain-text representation in the `content` array and the raw JSON result in the `structuredContent` field. This ensures that AI clients can process the data either as text or as structured data for more precise tool use.
When writing MCP tools, use the appropriate error from `@backstage/errors` when applicable:
```ts
+15
View File
@@ -36,6 +36,21 @@ export interface Config {
*/
namespacedToolNames?: boolean;
tracing?: {
capture?: {
/**
* When true, the MCP tool call's input arguments and output result
* are included on the MCP `tools/call` server span as
* `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`.
* These attributes are marked Opt-In by the OpenTelemetry GenAI
* semantic conventions because they may contain sensitive
* information (entity payloads, scaffolder inputs, free-form
* text). Defaults to false.
*/
toolPayload?: boolean;
};
};
/**
* Named MCP servers, each exposed at /api/mcp-actions/v1/{key}.
* When not configured, the plugin serves a single server at /api/mcp-actions/v1.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-mcp-actions-backend",
"version": "0.1.13-next.0",
"version": "0.1.13",
"backstage": {
"role": "backend-plugin",
"pluginId": "mcp-actions",
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
import {
metricsServiceMock,
tracingServiceMock,
} from '@backstage/backend-test-utils/alpha';
import { mcpPlugin } from './plugin';
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
import { createBackendPlugin } from '@backstage/backend-plugin-api';
@@ -54,6 +57,7 @@ describe('Mcp Backend', () => {
mcpPlugin,
mockPluginWithActions,
metricsServiceMock.mock().factory,
tracingServiceMock.mock().factory,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -220,6 +224,7 @@ describe('Mcp Backend', () => {
mockCatalogPlugin,
mockScaffolderPlugin,
metricsServiceMock.mock().factory,
tracingServiceMock.mock().factory,
mockServices.rootConfig.factory({
data: {
backend: {
+11
View File
@@ -26,6 +26,7 @@ import {
actionsRegistryServiceRef,
actionsServiceRef,
metricsServiceRef,
tracingServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { parseServerConfigs } from './config';
@@ -49,6 +50,7 @@ export const mcpPlugin = createBackendPlugin({
discovery: coreServices.discovery,
config: coreServices.rootConfig,
metrics: metricsServiceRef,
tracing: tracingServiceRef,
},
async init({
actions,
@@ -59,16 +61,22 @@ export const mcpPlugin = createBackendPlugin({
discovery,
config,
metrics,
tracing,
}) {
const serverConfigs = parseServerConfigs(config);
const namespacedToolNames = config.getOptionalBoolean(
'mcpActions.namespacedToolNames',
);
const captureToolPayloads =
config.getOptionalBoolean('mcpActions.tracing.capture.toolPayload') ??
false;
const mcpService = await McpService.create({
actions,
metrics,
namespacedToolNames,
tracingService: tracing,
captureToolPayloads,
});
const router = Router();
@@ -81,6 +89,7 @@ export const mcpPlugin = createBackendPlugin({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
});
@@ -97,6 +106,7 @@ export const mcpPlugin = createBackendPlugin({
const sseRouter = createSseRouter({
mcpService,
httpAuth,
tracing,
serverConfig,
});
@@ -105,6 +115,7 @@ export const mcpPlugin = createBackendPlugin({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
});
@@ -18,6 +18,7 @@ import { Router } from 'express';
import { McpService } from '../services/McpService';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { TracingService } from '@backstage/backend-plugin-api/alpha';
import { McpServerConfig } from '../config';
/**
@@ -26,10 +27,12 @@ import { McpServerConfig } from '../config';
export const createSseRouter = ({
mcpService,
httpAuth,
tracing,
serverConfig,
}: {
mcpService: McpService;
httpAuth: HttpAuthService;
tracing: TracingService;
serverConfig?: McpServerConfig;
}): Router => {
const router = PromiseRouter();
@@ -65,7 +68,13 @@ export const createSseRouter = ({
const transport = transportsToSessionId.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res, req.body);
const ctx = tracing.propagation.extract(
tracing.context.active(),
req.headers,
);
await tracing.context.with(ctx, () =>
transport.handlePostMessage(req, res, req.body),
);
} else {
res
.status(400)
@@ -21,7 +21,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
import { toError } from '@backstage/errors';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import {
MetricsService,
TracingService,
} from '@backstage/backend-plugin-api/alpha';
import { bucketBoundaries, McpServerSessionAttributes } from '../metrics';
import { McpServerConfig } from '../config';
@@ -30,12 +33,14 @@ export const createStreamableRouter = ({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
}: {
mcpService: McpService;
logger: LoggerService;
httpAuth: HttpAuthService;
metrics: MetricsService;
tracing: TracingService;
serverConfig?: McpServerConfig;
}): Router => {
const router = PromiseRouter();
@@ -72,7 +77,13 @@ export const createStreamableRouter = ({
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
const ctx = tracing.propagation.extract(
tracing.context.active(),
req.headers,
);
await tracing.context.with(ctx, () =>
transport.handleRequest(req, res, req.body),
);
res.on('close', () => {
transport.close();
@@ -19,6 +19,7 @@ import { McpService } from './McpService';
import {
actionsRegistryServiceMock,
metricsServiceMock,
tracingServiceMock,
} from '@backstage/backend-test-utils/alpha';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -49,6 +50,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -121,6 +123,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -174,6 +177,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -212,13 +216,10 @@ describe('McpService', () => {
expect(result.content).toEqual([
{
type: 'text',
text: [
'```json',
JSON.stringify({ output: 'test' }, null, 2),
'```',
].join('\n'),
text: JSON.stringify({ output: 'test' }),
},
]);
expect(result).toHaveProperty('structuredContent', { output: 'test' });
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
@@ -238,6 +239,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
metrics: mockMetrics,
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -306,6 +308,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -366,6 +369,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -422,6 +426,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -509,6 +514,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: fakeActionsService,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const serverConfig: McpServerConfig = {
@@ -542,6 +548,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: fakeActionsService,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const serverConfig: McpServerConfig = {
@@ -583,6 +590,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: fakeActionsService,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const serverConfig: McpServerConfig = {
@@ -621,6 +629,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: fakeActionsService,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const serverConfig: McpServerConfig = {
@@ -659,6 +668,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: fakeActionsService,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const serverConfig: McpServerConfig = {
@@ -711,6 +721,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -734,6 +745,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -763,6 +775,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -805,6 +818,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -843,6 +857,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
namespacedToolNames: false,
});
@@ -882,6 +897,7 @@ describe('McpService', () => {
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracingServiceMock.mock(),
});
const server = mcpService.getServer({
@@ -907,4 +923,246 @@ describe('McpService', () => {
expect(result.isError).toBeUndefined();
});
});
describe('tracing', () => {
async function invokeMockAction(opts: {
tracing: ReturnType<typeof tracingServiceMock.mock>;
captureToolPayloads?: boolean;
credentials?:
| ReturnType<typeof mockCredentials.user>
| ReturnType<typeof mockCredentials.service>;
}) {
const mockActionsRegistry = actionsRegistryServiceMock();
mockActionsRegistry.register({
name: 'mock-action',
title: 'Test',
description: 'Test',
schema: {
input: z => z.object({ input: z.string() }),
output: z => z.object({ output: z.string() }),
},
action: async () => ({ output: { output: 'test' } }),
});
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: opts.tracing,
captureToolPayloads: opts.captureToolPayloads,
});
const server = mcpService.getServer({
credentials: opts.credentials ?? mockCredentials.user(),
});
const client = new Client({ name: 'test client', version: '1.0' });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
return client.request(
{
method: 'tools/call',
params: { name: 'test.mock-action', arguments: { input: 'val' } },
},
CallToolResultSchema,
);
}
it('starts a tools/call span with spec attributes, server kind, and the request credentials', async () => {
const tracing = tracingServiceMock.mock();
const credentials = mockCredentials.user();
await invokeMockAction({ tracing, credentials });
expect(tracing.startActiveSpan).toHaveBeenCalledTimes(1);
const [name, options] = tracing.startActiveSpan.mock.calls[0];
expect(name).toBe('tools/call test.mock-action');
expect(options?.kind).toBe('server');
expect(options?.attributes).toEqual(
expect.objectContaining({
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': 'test.mock-action',
'gen_ai.operation.name': 'execute_tool',
}),
);
expect(options?.attributes).not.toHaveProperty(
'gen_ai.tool.call.arguments',
);
expect(options?.credentials).toBe(credentials);
expect(tracing.spans[0].setStatus).not.toHaveBeenCalled();
});
it('overrides backstage.plugin.id on the span to match the action source plugin', async () => {
const tracing = tracingServiceMock.mock();
await invokeMockAction({ tracing });
// The mock action is registered via actionsRegistryServiceMock(),
// which assigns pluginId 'test'.
expect(tracing.spans[0].setAttribute).toHaveBeenCalledWith(
'backstage.plugin.id',
'test',
);
});
it('includes gen_ai baggage entries as span attributes when present', async () => {
const tracing = tracingServiceMock.mock();
tracing.propagation.getActiveBaggage.mockReturnValue({
getAllEntries: () => [
['gen_ai.conversation.id', { value: 'conv-123' }],
['gen_ai.agent.id', { value: 'agent-456' }],
],
});
await invokeMockAction({ tracing });
const [, options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.conversation.id']).toBe('conv-123');
expect(options?.attributes?.['gen_ai.agent.id']).toBe('agent-456');
});
it('only forwards allowlisted baggage keys onto the span', async () => {
const tracing = tracingServiceMock.mock();
tracing.propagation.getActiveBaggage.mockReturnValue({
getAllEntries: () => [
['gen_ai.conversation.id', { value: 'conv-123' }],
['gen_ai.tool.call.result', { value: 'injected-result' }],
['gen_ai.prompt', { value: 'injected-prompt' }],
['gen_ai.user.message', { value: 'injected-user-message' }],
],
});
await invokeMockAction({ tracing });
const [, options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.conversation.id']).toBe('conv-123');
expect(options?.attributes).not.toHaveProperty('gen_ai.tool.call.result');
expect(options?.attributes).not.toHaveProperty('gen_ai.prompt');
expect(options?.attributes).not.toHaveProperty('gen_ai.user.message');
});
it('omits gen_ai baggage attributes when no baggage is present', async () => {
const tracing = tracingServiceMock.mock();
await invokeMockAction({ tracing });
const [, options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes).not.toHaveProperty('gen_ai.conversation.id');
expect(options?.attributes).not.toHaveProperty('gen_ai.agent.id');
});
it('threads baggage end-to-end from a propagated baggage header through context.with into the tool span', async () => {
const tracing = tracingServiceMock.mock();
// Simulate what the routers do on incoming requests: extract context
// from headers and run the handler with that context active.
const ctx = tracing.propagation.extract(tracing.context.active(), {
baggage: 'gen_ai.conversation.id=conv-end-to-end',
});
await tracing.context.with(ctx, () => invokeMockAction({ tracing }));
const [, options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.conversation.id']).toBe(
'conv-end-to-end',
);
});
it('truncates overlong baggage values before stamping them on the span', async () => {
const tracing = tracingServiceMock.mock();
const longValue = 'a'.repeat(1024);
tracing.propagation.getActiveBaggage.mockReturnValue({
getAllEntries: () => [['gen_ai.conversation.id', { value: longValue }]],
});
await invokeMockAction({ tracing });
const [, options] = tracing.startActiveSpan.mock.calls[0];
const recorded = options?.attributes?.['gen_ai.conversation.id'];
expect(typeof recorded).toBe('string');
expect((recorded as string).length).toBe(256);
expect(recorded).toBe('a'.repeat(256));
});
it('includes tool arguments in the span options and sets the structured action output as the result attribute when captureToolPayloads is true', async () => {
const tracing = tracingServiceMock.mock();
await invokeMockAction({ tracing, captureToolPayloads: true });
const [, options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.tool.call.arguments']).toBe(
JSON.stringify({ input: 'val' }),
);
const span = tracing.spans[0];
const resultCall = span.setAttribute.mock.calls.find(
([key]) => key === 'gen_ai.tool.call.result',
);
expect(resultCall).toBeDefined();
// The recorded result should be the structured action output, not the
// CallToolResult envelope wrapping a fenced JSON block.
expect(JSON.parse(resultCall![1] as string)).toEqual({ output: 'test' });
});
it('sets error.type=tool_error and ERROR status on the span when the tool returns isError', async () => {
const tracing = tracingServiceMock.mock();
const mockActionsRegistry = actionsRegistryServiceMock();
mockActionsRegistry.register({
name: 'failing-action',
title: 'Failing',
description: 'Throws InputError',
schema: {
input: z => z.object({ value: z.string() }),
output: z => z.object({}),
},
action: async () => {
throw new InputError('the value was invalid');
},
});
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracing,
captureToolPayloads: true,
});
const server = mcpService.getServer({
credentials: mockCredentials.user(),
});
const client = new Client({ name: 'test client', version: '1.0' });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.request(
{
method: 'tools/call',
params: {
name: 'test.failing-action',
arguments: { value: 'test' },
},
},
CallToolResultSchema,
);
expect(result.isError).toBe(true);
const span = tracing.spans[0];
expect(span.setAttribute).toHaveBeenCalledWith(
'error.type',
'tool_error',
);
expect(span.setStatus).toHaveBeenCalledWith({
code: 'error',
message: 'tool_error',
});
// The error is signalled via error.type + status; the result attribute
// should be omitted even when captureToolPayloads is enabled.
const resultCall = span.setAttribute.mock.calls.find(
([key]) => key === 'gen_ai.tool.call.result',
);
expect(resultCall).toBeUndefined();
});
});
});
@@ -25,6 +25,7 @@ import {
ActionsServiceAction,
MetricsServiceHistogram,
MetricsService,
TracingService,
} from '@backstage/backend-plugin-api/alpha';
import { version } from '@backstage/plugin-mcp-actions-backend/package.json';
import { NotFoundError } from '@backstage/errors';
@@ -34,18 +35,63 @@ import { handleErrors } from './handleErrors';
import { bucketBoundaries, McpServerOperationAttributes } from '../metrics';
import { FilterRule, McpServerConfig } from '../config';
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
// Baggage is propagated from untrusted callers, so we forward only an
// explicit allowlist of low-cardinality identifier keys from the OTel
// `gen_ai.*` registry.
const PROPAGATED_BAGGAGE_ATTRIBUTES: ReadonlySet<string> = new Set([
'gen_ai.agent.id',
'gen_ai.agent.name',
'gen_ai.conversation.id',
'gen_ai.provider.name',
'gen_ai.request.model',
]);
// Cap each forwarded baggage value before it lands on a span attribute.
// Baggage values are caller-controlled strings of unbounded length;
// allowlisting keys protects against arbitrary attribute names but not
// against pathologically large values inflating exported span sizes.
const BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH = 256;
function baggageAttributes(
tracingService: TracingService,
): Record<string, string> {
const baggage = tracingService.propagation.getActiveBaggage();
if (!baggage) return {};
const attrs: Record<string, string> = {};
for (const [key, entry] of baggage.getAllEntries()) {
if (PROPAGATED_BAGGAGE_ATTRIBUTES.has(key)) {
attrs[key] = entry.value.slice(0, BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH);
}
}
return attrs;
}
export class McpService {
private readonly actions: ActionsService;
private readonly namespacedToolNames: boolean;
private readonly tracingService: TracingService;
private readonly captureToolPayloads: boolean;
private readonly operationDuration: MetricsServiceHistogram<McpServerOperationAttributes>;
constructor(
actions: ActionsService,
metrics: MetricsService,
tracingService: TracingService,
namespacedToolNames?: boolean,
captureToolPayloads?: boolean,
) {
this.actions = actions;
this.namespacedToolNames = namespacedToolNames ?? true;
this.tracingService = tracingService;
this.captureToolPayloads = captureToolPayloads ?? false;
this.operationDuration =
metrics.createHistogram<McpServerOperationAttributes>(
'mcp.server.operation.duration',
@@ -60,13 +106,23 @@ export class McpService {
static async create({
actions,
metrics,
tracingService,
namespacedToolNames,
captureToolPayloads,
}: {
actions: ActionsService;
metrics: MetricsService;
tracingService: TracingService;
namespacedToolNames?: boolean;
captureToolPayloads?: boolean;
}) {
return new McpService(actions, metrics, namespacedToolNames);
return new McpService(
actions,
metrics,
tracingService,
namespacedToolNames,
captureToolPayloads,
);
}
getServer({
@@ -79,7 +135,6 @@ export class McpService {
const server = new McpServer(
{
name: serverConfig?.name ?? 'backstage',
// TODO: this version will most likely change in the future.
version,
...(serverConfig?.description && {
description: serverConfig.description,
@@ -103,9 +158,6 @@ export class McpService {
return {
tools: actions.map(action => ({
inputSchema: action.schema.input,
// todo(blam): this is unfortunately not supported by most clients yet.
// When this is provided you need to provide structuredContent instead.
// outputSchema: action.schema.output,
name: this.getToolName(action),
description: action.description,
annotations: {
@@ -136,43 +188,78 @@ export class McpService {
let isError = false;
try {
const result = await handleErrors(async () => {
const { actions: allActions } = await this.actions.list({
return await this.tracingService.startActiveSpan(
`tools/call ${params.name}`,
{
kind: 'server',
credentials,
});
const actions = serverConfig
? this.filterActions(allActions, serverConfig)
: allActions;
attributes: {
...baggageAttributes(this.tracingService),
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': params.name,
'gen_ai.operation.name': 'execute_tool',
...(this.captureToolPayloads && {
'gen_ai.tool.call.arguments': safeStringify(params.arguments),
}),
},
},
async span => {
const result = await handleErrors(async () => {
const { actions: allActions } = await this.actions.list({
credentials,
});
const actions = serverConfig
? this.filterActions(allActions, serverConfig)
: allActions;
const action = actions.find(a => this.getToolName(a) === params.name);
const action = actions.find(
a => this.getToolName(a) === params.name,
);
if (!action) {
throw new NotFoundError(`Action "${params.name}" not found`);
}
if (!action) {
throw new NotFoundError(`Action "${params.name}" not found`);
}
const { output } = await this.actions.invoke({
id: action.id,
input: params.arguments as JsonObject,
credentials,
});
// Re-attribute the span to the plugin that owns the action.
// This runs after the span has started, so head-based samplers
// still see the default `mcp-actions` value when deciding
// whether to record the span. The pluginId is only known after
// resolving the action via `actions.list`, so the reattribution
// is unavoidable.
span.setAttribute('backstage.plugin.id', action.pluginId);
return {
// todo(blam): unfortunately structuredContent is not supported by most clients yet.
// so the validation for the output happens in the default actions registry
// and we return it as json text instead for now.
content: [
{
type: 'text',
text: ['```json', JSON.stringify(output, null, 2), '```'].join(
'\n',
),
},
],
};
});
const { output } = await this.actions.invoke({
id: action.id,
input: params.arguments as JsonObject,
credentials,
});
isError = !!(result as { isError?: boolean })?.isError;
return result;
if (this.captureToolPayloads) {
span.setAttribute(
'gen_ai.tool.call.result',
safeStringify(output),
);
}
return {
content: [
{
type: 'text',
text: safeStringify(output),
},
],
structuredContent: output,
};
});
isError = !!(result as { isError?: boolean })?.isError;
if (isError) {
span.setAttribute('error.type', 'tool_error');
span.setStatus({ code: 'error', message: 'tool_error' });
}
return result;
},
);
} catch (err) {
errorType = err instanceof Error ? err.name : 'Error';
throw err;