diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index fb1a805ebf..96965f0325 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -295,10 +295,22 @@ See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn h ## Tracing -The MCP Actions Backend emits a trace span for each `tools/call` invocation via the [Tracing Service](../backend-system/core-services/tracing.md), following the [OpenTelemetry server-side MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#server). Each span uses the name `tools/call `, server kind, and includes the standard MCP attributes (`mcp.method.name`, `gen_ai.tool.name`, `gen_ai.operation.name`). When a tool result returns `isError: true`, the span is marked with `error.type=tool_error`; thrown handler errors are recorded and the span status is set to `ERROR`. +The MCP Actions Backend emits a trace span for each `tools/call` invocation via the [Tracing Service](../backend-system/core-services/tracing.md), following the [OpenTelemetry server-side MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#server). Each span uses the name `tools/call `, server kind, and includes the standard MCP attributes (`mcp.method.name`, `gen_ai.tool.name`, `gen_ai.operation.name`). Known Backstage errors (such as `InputError` or `NotFoundError`) are caught and returned as `isError: true` tool responses — the span is marked with `error.type=tool_error` in that case. Unhandled exceptions are recorded automatically by the Tracing Service and the span status is set to `ERROR`. In addition to those attributes, the Tracing Service automatically attaches the authenticated principal's type as `backstage.principal.type` (one of `user`, `service`, or `none`). Each `tools/call` span is also attributed to the plugin that owns the invoked action via `backstage.plugin.id` (e.g. `catalog`, `scaffolder`) — overriding the default `mcp-actions` value so tracing backends can filter activity by the source plugin rather than by the MCP transport. +### Baggage propagation + +The MCP Actions routers propagate OpenTelemetry context from the incoming HTTP request headers so that trace parent and baggage survive through the MCP transport layer. The following low-cardinality identifier entries from the OpenTelemetry [`gen_ai.*` attribute registry](https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/), when set by the MCP client in baggage, are automatically forwarded as attributes on the `tools/call` span: + +- `gen_ai.agent.id` +- `gen_ai.agent.name` +- `gen_ai.conversation.id` +- `gen_ai.provider.name` +- `gen_ai.request.model` + +This enables tracing backends to correlate MCP tool invocations back to the originating agent, conversation, or model without additional configuration. Other `gen_ai.*` baggage entries are intentionally not forwarded — baggage may be set by arbitrary upstream callers, and a broad prefix filter would let clients smuggle high-cardinality or payload-shaped keys (e.g. `gen_ai.tool.call.result`, `gen_ai.prompt`) onto the span and bypass the [tool payload capture flag](#capturing-tool-arguments-and-results). + ### Capturing the authenticated end user The Tracing Service can additionally include the authenticated principal's identity as `enduser.id` (the user entity ref for a user principal, the service subject for a service principal). This is gated behind a backend-wide configuration flag and is **disabled by default**: diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts new file mode 100644 index 0000000000..be80625932 --- /dev/null +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 The Backstage Authors + * + * 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 { tracingServiceMock } from './TracingServiceMock'; + +describe('tracingServiceMock', () => { + it('parses the baggage header from withPropagatedContext and exposes it via getActiveBaggage', async () => { + const tracing = tracingServiceMock.mock(); + + const seen = await tracing.withPropagatedContext( + { + baggage: + 'gen_ai.conversation.id=conv-123, gen_ai.agent.id=agent-456;property=ignored', + }, + () => { + const baggage = tracing.getActiveBaggage(); + return { + conv: baggage?.getEntry('gen_ai.conversation.id')?.value, + agent: baggage?.getEntry('gen_ai.agent.id')?.value, + missing: baggage?.getEntry('gen_ai.missing'), + all: baggage?.getAllEntries().map(([k, v]) => [k, v.value]), + }; + }, + ); + + expect(seen).toEqual({ + conv: 'conv-123', + agent: 'agent-456', + missing: undefined, + all: [ + ['gen_ai.conversation.id', 'conv-123'], + ['gen_ai.agent.id', 'agent-456'], + ], + }); + + // Baggage is scoped to the propagated callback. + expect(tracing.getActiveBaggage()).toBeUndefined(); + }); + + it('honours mockReturnValue overrides for getActiveBaggage', async () => { + const tracing = tracingServiceMock.mock(); + const override = { + getEntry: () => ({ value: 'override' }), + getAllEntries: () => + [['gen_ai.conversation.id', { value: 'override' }]] as Array< + [string, { value: string }] + >, + }; + tracing.getActiveBaggage.mockReturnValue(override); + + expect(tracing.getActiveBaggage()).toBe(override); + await tracing.withPropagatedContext( + { baggage: 'gen_ai.conversation.id=conv-from-header' }, + () => { + // mockReturnValue takes precedence over the default header parsing. + expect(tracing.getActiveBaggage()).toBe(override); + }, + ); + }); + + it('returns undefined baggage when no baggage header is supplied', async () => { + const tracing = tracingServiceMock.mock(); + await tracing.withPropagatedContext({ traceparent: 'whatever' }, () => { + expect(tracing.getActiveBaggage()).toBeUndefined(); + }); + }); +}); diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 5e000b7603..9f43c7149f 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -21,12 +21,47 @@ import { import { TracingService, TracingServiceAttributeValue, + TracingServiceBaggage, TracingServiceSpan, TracingServiceSpanStatus, tracingServiceRef, } from '@backstage/backend-plugin-api/alpha'; import { tracingServiceFactory } from '@backstage/backend-defaults/alpha'; +// Parses the `baggage` header per the W3C Baggage member syntax, +// dropping value properties (`;property=value`). This mirrors what +// `propagation.extract` does in the real tracing service, just enough +// for tests to assert end-to-end behaviour between propagated headers +// and `getActiveBaggage()`. +function parseBaggageHeader( + headers: Record, +): TracingServiceBaggage | undefined { + let raw: string | undefined; + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() !== 'baggage') continue; + raw = Array.isArray(value) ? value[0] : value; + break; + } + if (!raw) return undefined; + + const entries = new Map(); + for (const segment of raw.split(',')) { + const [pair] = segment.split(';'); + const eqIdx = pair.indexOf('='); + if (eqIdx === -1) continue; + const key = decodeURIComponent(pair.slice(0, eqIdx).trim()); + const value = decodeURIComponent(pair.slice(eqIdx + 1).trim()); + if (!key) continue; + entries.set(key, { value }); + } + if (entries.size === 0) return undefined; + + return { + getEntry: key => entries.get(key), + getAllEntries: () => Array.from(entries.entries()), + }; +} + /** * A jest-mocked span captured by {@link TracingServiceMock}. * @@ -42,6 +77,14 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan { * `startActiveSpan` so tests can assert on the options passed in and the * methods called on the span inside the callback. * + * By default, `withPropagatedContext` parses the `baggage` header (W3C + * Baggage syntax) out of the supplied headers and makes those entries + * available via `getActiveBaggage` for the duration of the wrapped + * callback. Other propagation headers (e.g. `traceparent`) are ignored. + * Tests that need fully custom baggage can still override + * `getActiveBaggage` via `mockReturnValue` / `mockImplementation`, which + * takes precedence over the default behaviour. + * * @alpha */ export interface TracingServiceMock extends TracingService { @@ -79,11 +122,17 @@ export namespace tracingServiceMock { return await fn(span); }) as TracingServiceMock['startActiveSpan']; - const withPropagatedContext = jest.fn(async (_headers, fn) => - fn(), - ) as TracingServiceMock['withPropagatedContext']; + const baggageStack: Array = []; + const withPropagatedContext = jest.fn(async (headers, fn) => { + baggageStack.push(parseBaggageHeader(headers)); + try { + return await fn(); + } finally { + baggageStack.pop(); + } + }) as TracingServiceMock['withPropagatedContext']; const getActiveBaggage = jest.fn( - () => undefined, + () => baggageStack[baggageStack.length - 1], ) as TracingServiceMock['getActiveBaggage']; const service: TracingService = { diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index e0ce76dc4d..526474e5b4 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -89,6 +89,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, logger, metrics, + tracing, serverConfig, }); @@ -105,6 +106,7 @@ export const mcpPlugin = createBackendPlugin({ const sseRouter = createSseRouter({ mcpService, httpAuth, + tracing, serverConfig, }); @@ -113,6 +115,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, logger, metrics, + tracing, serverConfig, }); diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index 954d28af4e..d0f6411046 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -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,9 @@ export const createSseRouter = ({ const transport = transportsToSessionId.get(sessionId); if (transport) { - await transport.handlePostMessage(req, res, req.body); + await tracing.withPropagatedContext(req.headers, () => + transport.handlePostMessage(req, res, req.body), + ); } else { res .status(400) diff --git a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts index 517aa18824..1c71703ddf 100644 --- a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts @@ -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,9 @@ export const createStreamableRouter = ({ }); await server.connect(transport); - await transport.handleRequest(req, res, req.body); + await tracing.withPropagatedContext(req.headers, () => + transport.handleRequest(req, res, req.body), + ); res.on('close', () => { transport.close(); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index d56bacad2e..ae4a1770a4 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -1010,7 +1010,68 @@ describe('McpService', () => { ); }); - it('includes tool arguments in the span options and sets the result via setAttribute when captureToolPayloads is true', async () => { + it('includes gen_ai baggage entries as span attributes when present', async () => { + const tracing = tracingServiceMock.mock(); + tracing.getActiveBaggage.mockReturnValue({ + getEntry: (key: string) => { + const entries: Record = { + 'gen_ai.conversation.id': { value: 'conv-123' }, + 'gen_ai.agent.id': { value: 'agent-456' }, + }; + return entries[key]; + }, + 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.getActiveBaggage.mockReturnValue({ + getEntry: (key: string) => { + const entries: Record = { + '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' }, + }; + return entries[key]; + }, + 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('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 }); @@ -1024,9 +1085,9 @@ describe('McpService', () => { ([key]) => key === 'gen_ai.tool.call.result', ); expect(resultCall).toBeDefined(); - const parsed = JSON.parse(resultCall![1] as string); - expect(parsed.content[0].type).toBe('text'); - expect(parsed.content[0].text).toContain('"output": "test"'); + // 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 () => { @@ -1049,6 +1110,7 @@ describe('McpService', () => { actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), tracingService: tracing, + captureToolPayloads: true, }); const server = mcpService.getServer({ @@ -1083,6 +1145,12 @@ describe('McpService', () => { 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(); }); }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 038151c5b6..693f0b90a4 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -43,6 +43,32 @@ function safeStringify(value: unknown): string { } } +// 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: readonly string[] = [ + 'gen_ai.agent.id', + 'gen_ai.agent.name', + 'gen_ai.conversation.id', + 'gen_ai.provider.name', + 'gen_ai.request.model', +]; + +function baggageAttributes( + tracingService: TracingService, +): Record { + const baggage = tracingService.getActiveBaggage(); + if (!baggage) return {}; + const attrs: Record = {}; + for (const key of PROPAGATED_BAGGAGE_ATTRIBUTES) { + const entry = baggage.getEntry(key); + if (entry) { + attrs[key] = entry.value; + } + } + return attrs; +} + export class McpService { private readonly actions: ActionsService; private readonly namespacedToolNames: boolean; @@ -189,6 +215,16 @@ export class McpService { credentials, }); + // Record the structured action output directly rather than the + // CallToolResult envelope below, which wraps an already- + // stringified markdown-fenced JSON block. + if (this.captureToolPayloads) { + span.setAttribute( + 'gen_ai.tool.call.result', + safeStringify(output), + ); + } + return { // todo(blam): unfortunately structuredContent is not supported by most clients yet. // so the validation for the output happens in the default actions registry @@ -207,12 +243,6 @@ export class McpService { }); isError = !!(result as { isError?: boolean })?.isError; - if (this.captureToolPayloads) { - span.setAttribute( - 'gen_ai.tool.call.result', - safeStringify(result), - ); - } if (isError) { span.setAttribute('error.type', 'tool_error'); span.setStatus({ code: 'error', message: 'tool_error' }); @@ -223,6 +253,7 @@ export class McpService { kind: 'server', credentials, attributes: { + ...baggageAttributes(this.tracingService), 'mcp.method.name': 'tools/call', 'gen_ai.tool.name': params.name, 'gen_ai.operation.name': 'execute_tool',