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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-server-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': patch
---

feat(mcp): advertise a title, description, website URL, and SVG icon in the MCP server's initialize response
57 changes: 57 additions & 0 deletions packages/api/src/mcp/__tests__/serverMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Implementation } from '@modelcontextprotocol/sdk/types.js';

import { McpContext } from '@/mcp/tools/types';

const context: McpContext = { teamId: 'team-id', userId: 'user-id' };

async function getServerInfo(
frontendUrl: string,
): Promise<Implementation | undefined> {
let createTestClient!: typeof import('./mcpTestUtils').createTestClient;
jest.isolateModules(() => {
jest.doMock('@/config', () => ({
...jest.requireActual('@/config'),
FRONTEND_URL: frontendUrl,
}));
({ createTestClient } =
jest.requireActual<typeof import('./mcpTestUtils')>('./mcpTestUtils'));
});
jest.dontMock('@/config');
const client = await createTestClient(context);
return client.getServerVersion();
}

describe('MCP server metadata', () => {
it('advertises a title, description, website, and app-hosted icon', async () => {
const info = await getServerInfo('https://app.example.com/');

expect(info).toMatchObject({
name: 'clickstack',
title: 'ClickStack',
websiteUrl: expect.stringMatching(/^https:\/\//),
description: expect.any(String),
});
expect(info?.icons).toEqual([
{
src: 'https://app.example.com/favicons/hyperdx/favicon.svg',
mimeType: 'image/svg+xml',
sizes: ['any'],
},
]);
});

it('keeps the FRONTEND_URL sub-path in the icon URL', async () => {
const info = await getServerInfo('https://host.example.com/hyperdx');

expect(info?.icons?.[0]?.src).toBe(
'https://host.example.com/hyperdx/favicons/hyperdx/favicon.svg',
);
});

it('omits the icon when FRONTEND_URL is unset', async () => {
const info = await getServerInfo('');

expect(info?.title).toBe('ClickStack');
expect(info?.icons).toBeUndefined();
});
});
38 changes: 28 additions & 10 deletions packages/api/src/mcp/mcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Implementation } from '@modelcontextprotocol/sdk/types.js';

import { CODE_VERSION } from '@/config';
import { CODE_VERSION, FRONTEND_URL } from '@/config';

import dashboardPrompts from './prompts/dashboards/index';
import alertsTools from './tools/alerts/index';
Expand Down Expand Up @@ -35,16 +36,33 @@ const SERVER_INSTRUCTIONS = [
'dashboard authoring.',
].join('\n');

// Served by the app; omitted when FRONTEND_URL is unset because MCP icon
// sources must be absolute URLs. String concatenation (not `new URL`) keeps
// any sub-path the app is mounted under. The HyperDX logo is intentional.
const SERVER_ICON_URL = FRONTEND_URL
Comment thread
brandon-pereira marked this conversation as resolved.
? `${FRONTEND_URL.replace(/\/+$/, '')}/favicons/hyperdx/favicon.svg`
: undefined;

const SERVER_INFO: Implementation = {
name: 'clickstack',
title: 'ClickStack',
version: `${CODE_VERSION}-beta`,
description:
'Query and investigate logs, metrics, traces, and sessions stored in ' +
'ClickHouse, and manage ClickStack dashboards, alerts, data sources, ' +
'and saved searches.',
websiteUrl: 'https://clickhouse.com/docs/use-cases/observability/clickstack',
...(SERVER_ICON_URL && {
icons: [
{ src: SERVER_ICON_URL, mimeType: 'image/svg+xml', sizes: ['any'] },
],
}),
};

export function createServer(context: McpContext) {
const server = new McpServer(
{
name: 'clickstack',
version: `${CODE_VERSION}-beta`,
},
{
instructions: SERVER_INSTRUCTIONS,
},
);
const server = new McpServer(SERVER_INFO, {
instructions: SERVER_INSTRUCTIONS,
});

const registerTool = createRegisterTool(server, context);
const registrar = { server, context, registerTool };
Expand Down
Loading