feat(mcp-actions): add server operation and session duration metrics (#32978)

Signed-off-by: Kurt King <kurtaking@gmail.com>
Co-authored-by: Kurt King <kurtaking@gmail.com>
This commit is contained in:
Ben Lambert
2026-02-24 17:18:02 +01:00
committed by GitHub
parent 1ee5b28e41
commit dc81af158f
8 changed files with 395 additions and 49 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-mcp-actions-backend': patch
---
Adds two new metrics to track MCP server operations and sessions.
- `mcp.server.operation.duration`: The duration taken to process an individual MCP operation
- `mcp.server.session.duration`: The duration of the MCP session from the perspective of the server
+8 -1
View File
@@ -75,7 +75,7 @@ export const myPlugin = createBackendPlugin({
When errors are thrown from MCP actions, the backend will handle and surface error message for any error from `@backstage/errors`. Unknown errors will be handled by `@modelcontextprotocol/sdk`'s default error handling, which may result in a generic `500 Server Error` being returned. As a result, we recommend using errors from `@backstage/errors` when applicable.
See https://backstage.io/api/stable/modules/_backstage_errors.html for a full list of supported errors.
See [Backstage Errors](https://backstage.io/docs/reference/errors/) for a full list of supported errors.
When writing MCP tools, use the appropriate error from `@backstage/errors` when applicable:
@@ -178,6 +178,13 @@ There's a few different ways to configure MCP tools, but here's a snippet of the
}
```
## Metrics
The MCP Actions Backend emits metrics for the following operations:
- `mcp.server.operation.duration`: The duration taken to process an individual MCP operation
- `mcp.server.session.duration`: The duration of the MCP session from the perspective of the server
## Development
This plugin backend can be started in a standalone mode from directly in this package with `yarn start`. It is a limited setup that is most convenient when developing the plugin backend itself.
@@ -0,0 +1,73 @@
/*
* 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 { MetricAttributes } from '@backstage/backend-plugin-api/alpha';
/**
* Attributes for mcp.server.operation.duration
* Following OTel requirement levels from the spec
*
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#metric-mcpserveroperationduration
*/
export interface McpServerOperationAttributes extends MetricAttributes {
// Required
'mcp.method.name': string;
// Conditionally Required
'error.type'?: string;
'gen_ai.tool.name'?: string;
'gen_ai.prompt.name'?: string;
'mcp.resource.uri'?: string;
'rpc.response.status_code'?: string;
// Recommended
'gen_ai.operation.name'?: 'execute_tool';
'mcp.protocol.version'?: string;
'mcp.session.id'?: string;
'network.transport'?: 'tcp' | 'quic' | 'pipe' | 'unix';
'network.protocol.name'?: string;
'network.protocol.version'?: string;
}
/**
* Attributes for mcp.server.session.duration
* Following OTel requirement levels from the spec
*
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#metric-mcpserversessionduration
*/
export interface McpServerSessionAttributes extends MetricAttributes {
// Conditionally Required
'error.type'?: string;
// Recommended
'mcp.protocol.version'?: string;
'network.transport'?: 'tcp' | 'quic' | 'pipe' | 'unix';
'network.protocol.name'?: string;
'network.protocol.version'?: string;
}
/**
* OTel recommended bucket boundaries for MCP metrics
*
* @remarks
*
* Based on the MCP metrics defined in the OTel semantic conventions v1.39.0
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/
*
*/
export const bucketBoundaries = [
0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300,
];
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { metricsServiceMock } 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';
@@ -52,6 +53,7 @@ describe('Mcp Backend', () => {
features: [
mcpPlugin,
mockPluginWithActions,
metricsServiceMock.mock().factory,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -25,6 +25,7 @@ import { createSseRouter } from './routers/createSseRouter';
import {
actionsRegistryServiceRef,
actionsServiceRef,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
/**
@@ -46,6 +47,7 @@ export const mcpPlugin = createBackendPlugin({
rootRouter: coreServices.rootHttpRouter,
discovery: coreServices.discovery,
config: coreServices.rootConfig,
metrics: metricsServiceRef,
},
async init({
actions,
@@ -55,9 +57,11 @@ export const mcpPlugin = createBackendPlugin({
rootRouter,
discovery,
config,
metrics,
}) {
const mcpService = await McpService.create({
actions,
metrics,
});
const sseRouter = createSseRouter({
@@ -69,6 +73,7 @@ export const mcpPlugin = createBackendPlugin({
mcpService,
httpAuth,
logger,
metrics,
});
const router = Router();
@@ -15,23 +15,47 @@
*/
import PromiseRouter from 'express-promise-router';
import { Router } from 'express';
import { performance } from 'node:perf_hooks';
import { McpService } from '../services/McpService';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
import { isError } from '@backstage/errors';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { bucketBoundaries, McpServerSessionAttributes } from '../metrics';
export const createStreamableRouter = ({
mcpService,
httpAuth,
logger,
metrics,
}: {
mcpService: McpService;
logger: LoggerService;
httpAuth: HttpAuthService;
metrics: MetricsService;
}): Router => {
const router = PromiseRouter();
const sessionDuration = metrics.createHistogram<McpServerSessionAttributes>(
'mcp.server.session.duration',
{
description:
'The duration of the MCP session as observed on the MCP server',
unit: 's',
advice: { explicitBucketBoundaries: bucketBoundaries },
},
);
router.post('/', async (req, res) => {
const sessionStart = performance.now();
const baseAttributes: McpServerSessionAttributes = {
'mcp.protocol.version': LATEST_PROTOCOL_VERSION,
'network.transport': 'tcp',
'network.protocol.name': 'http',
};
try {
const server = mcpService.getServer({
credentials: await httpAuth.credentials(req),
@@ -49,8 +73,14 @@ export const createStreamableRouter = ({
res.on('close', () => {
transport.close();
server.close();
const durationSeconds = (performance.now() - sessionStart) / 1000;
sessionDuration.record(durationSeconds, baseAttributes);
});
} catch (error) {
const errorType = isError(error) ? error.name : 'Error';
if (isError(error)) {
logger.error(error.message);
}
@@ -65,6 +95,13 @@ export const createStreamableRouter = ({
id: null,
});
}
const durationSeconds = (performance.now() - sessionStart) / 1000;
sessionDuration.record(durationSeconds, {
...baseAttributes,
'error.type': errorType,
});
}
});
@@ -16,7 +16,10 @@
import { mockCredentials } from '@backstage/backend-test-utils';
import { McpService } from './McpService';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import {
actionsRegistryServiceMock,
metricsServiceMock,
} from '@backstage/backend-test-utils/alpha';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
@@ -38,8 +41,10 @@ describe('McpService', () => {
action: async () => ({ output: { output: 'test' } }),
});
const mockMetrics = metricsServiceMock.mock();
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
});
const server = mcpService.getServer({
@@ -90,6 +95,60 @@ describe('McpService', () => {
name: 'mock-action',
},
]);
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
expect(histogram.record).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
'mcp.method.name': 'tools/list',
}),
);
expect(histogram.record.mock.calls[0][1]).not.toHaveProperty('error.type');
});
it('should record metrics with error.type when tools/list fails', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockActionsRegistry.list = jest
.fn()
.mockRejectedValue(new Error('List failed'));
const mockMetrics = metricsServiceMock.mock();
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
});
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),
]);
await expect(
client.request({ method: 'tools/list' }, ListToolsResultSchema),
).rejects.toThrow();
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
expect(histogram.record).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
'mcp.method.name': 'tools/list',
'error.type': 'Error',
}),
);
});
it('should call the action when the tool is invoked', async () => {
@@ -107,8 +166,10 @@ describe('McpService', () => {
action: mockAction,
});
const mockMetrics = metricsServiceMock.mock();
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
});
const server = mcpService.getServer({
@@ -154,11 +215,25 @@ describe('McpService', () => {
].join('\n'),
},
]);
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
expect(histogram.record).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': 'mock-action',
'gen_ai.operation.name': 'execute_tool',
}),
);
expect(histogram.record.mock.calls[0][1]).not.toHaveProperty('error.type');
});
it('should return an error when the action is not found', async () => {
const mockMetrics = metricsServiceMock.mock();
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
metrics: mockMetrics,
});
const server = mcpService.getServer({
@@ -194,5 +269,78 @@ describe('McpService', () => {
],
isError: true,
});
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
expect(histogram.record).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': 'mock-action',
'gen_ai.operation.name': 'execute_tool',
'error.type': 'tool_error',
}),
);
});
it('should record metrics with error.type when tool invocation throws', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const customError = new Error('Action failed');
customError.name = 'CustomError';
mockActionsRegistry.register({
name: 'failing-action',
title: 'Failing',
description: 'Fails',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: jest.fn().mockRejectedValue(customError),
});
const mockMetrics = metricsServiceMock.mock();
const mcpService = await McpService.create({
actions: mockActionsRegistry,
metrics: mockMetrics,
});
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),
]);
await expect(
client.request(
{
method: 'tools/call',
params: { name: 'failing-action', arguments: {} },
},
CallToolResultSchema,
),
).rejects.toThrow('Action failed');
const histogram = mockMetrics.createHistogram.mock.results[0]?.value;
expect(histogram.record).toHaveBeenCalledTimes(1);
expect(histogram.record).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': 'failing-action',
'gen_ai.operation.name': 'execute_tool',
'error.type': 'CustomError',
}),
);
});
});
@@ -20,21 +20,43 @@ import {
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { JsonObject } from '@backstage/types';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import {
ActionsService,
MetricsServiceHistogram,
MetricsService,
} from '@backstage/backend-plugin-api/alpha';
import { version } from '@backstage/plugin-mcp-actions-backend/package.json';
import { NotFoundError } from '@backstage/errors';
import { performance } from 'node:perf_hooks';
import { handleErrors } from './handleErrors';
import { bucketBoundaries, McpServerOperationAttributes } from '../metrics';
export class McpService {
private readonly actions: ActionsService;
private readonly operationDuration: MetricsServiceHistogram<McpServerOperationAttributes>;
constructor(actions: ActionsService) {
constructor(actions: ActionsService, metrics: MetricsService) {
this.actions = actions;
this.operationDuration =
metrics.createHistogram<McpServerOperationAttributes>(
'mcp.server.operation.duration',
{
description: 'MCP request duration as observed on the receiver',
unit: 's',
advice: { explicitBucketBoundaries: bucketBoundaries },
},
);
}
static async create({ actions }: { actions: ActionsService }) {
return new McpService(actions);
static async create({
actions,
metrics,
}: {
actions: ActionsService;
metrics: MetricsService;
}) {
return new McpService(actions, metrics);
}
getServer({ credentials }: { credentials: BackstageCredentials }) {
@@ -48,57 +70,101 @@ export class McpService {
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
// TODO: switch this to be configuration based later
const { actions } = await this.actions.list({ credentials });
const startTime = performance.now();
let errorType: string | undefined;
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: action.name,
description: action.description,
annotations: {
title: action.title,
destructiveHint: action.attributes.destructive,
idempotentHint: action.attributes.idempotent,
readOnlyHint: action.attributes.readOnly,
openWorldHint: false,
},
})),
};
try {
// TODO: switch this to be configuration based later
const { actions } = await this.actions.list({ credentials });
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: action.name,
description: action.description,
annotations: {
title: action.title,
destructiveHint: action.attributes.destructive,
idempotentHint: action.attributes.idempotent,
readOnlyHint: action.attributes.readOnly,
openWorldHint: false,
},
})),
};
} catch (err) {
errorType = err instanceof Error ? err.name : 'Error';
throw err;
} finally {
const durationSeconds = (performance.now() - startTime) / 1000;
this.operationDuration.record(durationSeconds, {
'mcp.method.name': 'tools/list',
...(errorType && { 'error.type': errorType }),
});
}
});
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
return handleErrors(async () => {
const { actions } = await this.actions.list({ credentials });
const action = actions.find(a => a.name === params.name);
const startTime = performance.now();
let errorType: string | undefined;
let isError = false;
if (!action) {
throw new NotFoundError(`Action "${params.name}" not found`);
}
try {
const result = await handleErrors(async () => {
const { actions } = await this.actions.list({ credentials });
const action = actions.find(a => a.name === params.name);
const { output } = await this.actions.invoke({
id: action.id,
input: params.arguments as JsonObject,
credentials,
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,
});
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',
),
},
],
};
});
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',
),
},
],
};
});
isError = !!(result as { isError?: boolean })?.isError;
return result;
} catch (err) {
errorType = err instanceof Error ? err.name : 'Error';
throw err;
} finally {
const durationSeconds = (performance.now() - startTime) / 1000;
// Determine error.type per OTel MCP spec:
// - Thrown exceptions use the error name
// - CallToolResult with isError=true uses 'tool_error'
let errorAttribute: string | undefined = errorType;
if (!errorAttribute && isError) {
errorAttribute = 'tool_error';
}
this.operationDuration.record(durationSeconds, {
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': params.name,
'gen_ai.operation.name': 'execute_tool',
...(errorAttribute && { 'error.type': errorAttribute }),
});
}
});
return server;