Merge branch 'backstage:master' into feature/catalog-export
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user