Compare commits

..

1 Commits

Author SHA1 Message Date
Vishal Sharma
bb10f51cc5 feat(settings): add SigNoz MCP Server setup page (#11025)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(settings): add SigNoz MCP Server setup page

Add a new Settings page at /settings/mcp-server that guides Cloud users
through connecting AI assistants (Cursor, VS Code, Claude Desktop, Claude
Code, Codex) to SigNoz via the Model Context Protocol, and provides a
deep-link into Service Accounts for creating the API key the OAuth flow
needs.

* feat(settings): point MCP Server onboarding tile to in-app settings page

The onboarding-config-with-links entry for SigNoz MCP Server previously
linked out to the docs page. Now that we ship an in-product setup page
at /settings/mcp-server, route Cloud users there directly via the
existing internalRedirect pattern. Self-hosted users still see the
in-page fallback card with a docs link.

* fix(settings): fire MCP Server page-viewed event reliably on mount

Previously gated the PAGE_VIEWED analytics event on
isGlobalConfigFetched to avoid a double-fire when the async
ingestion_url resolved. Side effect: if /global/config was slow or
errored out, the event never fired. Fire once on mount instead with
hostname-derived region metadata, which is synchronous and reliable.

* feat(mcp-page): ui refactor and redesign

* feat(mcp-page): global config and page access changes

* feat(mcp-page): refactor and added fallback page

* feat(mcp-page): added test cases

* feat(mcp-page): formatting lint

* feat(mcp-page): code refactor

* feat(mcp-page): cleanup and migrated global config api to open api spec

* feat(mcp-page): removed translation json and add endpoint url to copy

* feat(mcp-page): added mcp server to menu always for cloud and enterprise

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
2026-04-24 21:25:00 +00:00
42 changed files with 1289 additions and 88 deletions

View File

@@ -16,5 +16,6 @@
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"
}

View File

@@ -53,5 +53,6 @@
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
}

View File

@@ -16,5 +16,6 @@
"roles": "Roles",
"role_details": "Role Details",
"members": "Members",
"service_accounts": "Service Accounts"
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"
}

View File

@@ -76,5 +76,6 @@
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
}

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
GlobalConfigData,
GlobalConfigDataProps,
} from 'types/api/globalConfig/types';
const getGlobalConfig = async (): Promise<
SuccessResponseV2<GlobalConfigData>
> => {
try {
const response = await axios.get<GlobalConfigDataProps>(`/global/config`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getGlobalConfig;

View File

@@ -32,6 +32,7 @@ function CreateServiceAccountModal(): JSX.Element {
SA_QUERY_PARAMS.CREATE_SA,
parseAsBoolean.withDefault(false),
);
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const { showErrorModal, isErrorModalVisible } = useErrorModal();
@@ -50,11 +51,12 @@ function CreateServiceAccountModal(): JSX.Element {
const { mutate: createServiceAccount, isLoading: isSubmitting } =
useCreateServiceAccount({
mutation: {
onSuccess: async () => {
onSuccess: async (response) => {
toast.success('Service account created successfully');
reset();
await setIsOpen(null);
await invalidateListServiceAccounts(queryClient);
await setSelectedAccountId(response.data.id);
},
onError: (err) => {
const errMessage = convertToApiError(
@@ -67,7 +69,7 @@ function CreateServiceAccountModal(): JSX.Element {
function handleClose(): void {
reset();
setIsOpen(null);
void setIsOpen(null);
}
function handleCreate(values: FormValues): void {

View File

@@ -12,6 +12,12 @@
flex-shrink: 0;
}
&__layout {
display: flex;
flex-direction: column;
height: 100%;
}
&__tab-group {
[data-slot='toggle-group'] {
border-radius: 2px;

View File

@@ -87,6 +87,7 @@ const ROUTES = {
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
MCP_SERVER: '/settings/mcp-server',
} as const;
export default ROUTES;

View File

@@ -228,7 +228,6 @@ function StatusCodeBarCharts({
<BarChart
config={config}
data={chartData}
canPinTooltip
width={dimensions.width}
height={dimensions.height}
timezone={timezone}

View File

@@ -48,7 +48,8 @@ import { initialQueryMeterWithType } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
import dayjs from 'dayjs';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
@@ -358,6 +359,8 @@ function MultiIngestionSettings(): JSX.Element {
error: globalConfigError,
} = useGetGlobalConfig();
const globalConfigApiError = convertToApiError(globalConfigError);
const { mutate: createIngestionKey, isLoading: isLoadingCreateAPIKey } =
useCreateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
@@ -966,7 +969,7 @@ function MultiIngestionSettings(): JSX.Element {
<div className="ingestion-key-value">
<Typography.Text>
{APIKey?.value?.substring(0, 2)}********
{APIKey?.value?.slice(0, 2)}********
{APIKey?.value
?.substring(APIKey?.value?.length ? APIKey.value.length - 2 : 0)
?.trim()}
@@ -1604,11 +1607,11 @@ function MultiIngestionSettings(): JSX.Element {
title={
<div className="ingestion-url-error-content">
<Typography.Text className="ingestion-url-error-code">
{globalConfigError?.getErrorCode()}
{globalConfigApiError?.getErrorCode()}
</Typography.Text>
<Typography.Text className="ingestion-url-error-message">
{globalConfigError?.getErrorMessage()}
{globalConfigApiError?.getErrorMessage()}
</Typography.Text>
</div>
}

View File

@@ -0,0 +1,83 @@
.mcp-auth-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-5);
border: 1px solid var(--l2-border);
border-radius: 8px;
background: var(--l2-background);
&__title {
display: flex;
align-items: center;
gap: var(--spacing-5);
font-size: var(--label-medium-500-font-size);
font-weight: var(--label-medium-500-font-weight);
color: var(--l1-foreground);
margin: 0;
}
&__description {
font-size: var(--font-size-xs);
color: var(--foreground);
line-height: var(--line-height-18);
margin: 0;
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
&__field-label {
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--foreground);
letter-spacing: 0.01em;
}
&__endpoint-value {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--padding-2) var(--padding-3);
border: 1px solid var(--l3-border);
border-radius: 6px;
background: var(--l3-background);
font-family: var(--font-family-sf-mono, monospace);
font-size: var(--paragraph-base-400-font-size);
color: var(--l1-foreground);
width: 100%;
justify-content: space-between;
}
&__cta-row {
display: flex;
align-items: center;
gap: var(--spacing-10);
flex-wrap: wrap;
}
&__helper-text {
font-size: var(--paragraph-small-400-font-size);
color: var(--foreground);
line-height: var(--paragraph-small-400-line-height);
}
&__info-banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-2);
padding: var(--padding-2) var(--padding-3);
border-left: 3px solid var(--bg-robin-400);
background: var(--l3-background);
border-radius: 4px;
svg {
flex-shrink: 0;
color: var(--bg-robin-400);
margin-top: 2px;
}
}
}

View File

@@ -0,0 +1,70 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AuthCard from './AuthCard';
const mockOnCopyInstanceUrl = jest.fn();
const mockOnCreateServiceAccount = jest.fn();
const defaultProps = {
instanceUrl: 'http://localhost',
onCopyInstanceUrl: mockOnCopyInstanceUrl,
onCreateServiceAccount: mockOnCreateServiceAccount,
};
describe('AuthCard', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the instance URL', () => {
render(<AuthCard {...defaultProps} isAdmin />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
});
it('shows Create Service Account button for admin', () => {
render(<AuthCard {...defaultProps} isAdmin />);
expect(screen.getByText('Create service account')).toBeInTheDocument();
expect(
screen.queryByText(
'Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.',
),
).not.toBeInTheDocument();
});
it('shows info banner for non-admin', () => {
render(<AuthCard {...defaultProps} isAdmin={false} />);
expect(
screen.getByText(
'Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.',
),
).toBeInTheDocument();
expect(screen.queryByText('Create service account')).not.toBeInTheDocument();
});
it('calls onCopyInstanceUrl when copy button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<AuthCard {...defaultProps} isAdmin />);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockOnCopyInstanceUrl).toHaveBeenCalledTimes(1);
});
it('calls onCreateServiceAccount when admin clicks the CTA', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<AuthCard {...defaultProps} isAdmin />);
await user.click(screen.getByText('Create service account'));
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,75 @@
import { Badge, Button } from '@signozhq/ui';
import { Info, KeyRound } from '@signozhq/icons';
import CopyIconButton from '../CopyIconButton';
import './AuthCard.styles.scss';
interface AuthCardProps {
isAdmin: boolean;
instanceUrl: string;
onCopyInstanceUrl: () => void;
onCreateServiceAccount: () => void;
}
function AuthCard({
isAdmin,
instanceUrl,
onCopyInstanceUrl,
onCreateServiceAccount,
}: AuthCardProps): JSX.Element {
return (
<section className="mcp-auth-card">
<h3 className="mcp-auth-card__title">
<Badge color="secondary" variant="default">
2
</Badge>
Authenticate from your client
</h3>
<p className="mcp-auth-card__description">
On first connect, your client opens a SigNoz authorization page asking for
two values:
</p>
<div className="mcp-auth-card__field">
<span className="mcp-auth-card__field-label">SigNoz Instance URL</span>
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
/>
</div>
</div>
<div className="mcp-auth-card__field">
<span className="mcp-auth-card__field-label">API Key</span>
{isAdmin ? (
<div className="mcp-auth-card__cta-row">
<Button
variant="solid"
color="primary"
prefix={<KeyRound size={14} />}
onClick={onCreateServiceAccount}
>
Create service account
</Button>
<span className="mcp-auth-card__helper-text">
Create a service account, then add a new key inside it - paste that key
into the API Key field.
</span>
</div>
) : (
<div className="mcp-auth-card__info-banner">
<Info size={14} />
<span className="mcp-auth-card__helper-text">
Only admins can create API keys. Ask your workspace admin for a key with
read access, then paste it into the API Key field.
</span>
</div>
)}
</div>
</section>
);
}
export default AuthCard;

View File

@@ -0,0 +1,66 @@
.mcp-client-tabs-root {
button[data-variant='primary'] {
border: none;
background: transparent;
box-shadow: none;
}
// Remove default tab content padding/margin — the card provides spacing.
--tab-content-padding: 0;
--tab-content-margin: var(--spacing-4) 0 0;
}
.mcp-client-tabs {
&__snippet-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
margin-top: var(--spacing-2);
.learn-more {
width: fit-content;
font-size: var(--font-size-xs);
}
}
&__install-row {
display: flex;
align-items: center;
gap: var(--spacing-10);
flex-wrap: wrap;
}
&__helper-text {
font-size: var(--paragraph-small-400-font-size);
color: var(--foreground);
line-height: var(--paragraph-small-400-line-height);
}
&__endpoint-value {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--padding-2) var(--padding-3);
border: 1px solid var(--l3-border);
border-radius: 6px;
background: var(--l3-background);
font-family: var(--font-family-sf-mono, monospace);
font-size: var(--paragraph-base-400-font-size);
color: var(--l1-foreground);
width: 100%;
justify-content: space-between;
}
&__snippet-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
&__instructions {
font-size: var(--font-size-xs);
color: var(--foreground);
line-height: var(--line-height-20);
margin: 0;
}
}

View File

@@ -0,0 +1,108 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ClientTabs from './ClientTabs';
import { MCP_CLIENTS } from '../clients';
jest.mock('utils/navigation', () => ({
openInNewTab: jest.fn(),
}));
const mockOnTabChange = jest.fn();
const mockOnCopySnippet = jest.fn();
const mockOnInstallClick = jest.fn();
const mockOnDocsLinkClick = jest.fn();
const MCP_ENDPOINT = 'https://mcp.us.signoz.cloud/mcp';
const defaultProps = {
endpoint: MCP_ENDPOINT,
activeTab: MCP_CLIENTS[0].key,
onTabChange: mockOnTabChange,
onCopySnippet: mockOnCopySnippet,
onInstallClick: mockOnInstallClick,
onDocsLinkClick: mockOnDocsLinkClick,
};
describe('ClientTabs', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders a tab for each MCP client', () => {
render(<ClientTabs {...defaultProps} />);
MCP_CLIENTS.forEach((client) => {
expect(screen.getByText(client.label)).toBeInTheDocument();
});
});
it('renders the snippet for clients that provide one (Cursor)', () => {
render(<ClientTabs {...defaultProps} activeTab="cursor" />);
// The snippet is rendered inside a <pre> element; check its content
const snippetPre = document.querySelector('.mcp-client-tabs__snippet-pre');
expect(snippetPre).toBeInTheDocument();
expect(snippetPre?.textContent).toContain(MCP_ENDPOINT);
expect(snippetPre?.textContent).toContain('mcpServers');
});
it('renders endpoint block and instructions text for clients without a snippet (Claude Desktop)', () => {
render(<ClientTabs {...defaultProps} activeTab="claude-desktop" />);
const snippetPre = document.querySelector('.mcp-client-tabs__snippet-pre');
expect(snippetPre?.textContent).toBe(MCP_ENDPOINT);
expect(
screen.getByText(
'Open Claude Desktop, go to Settings → Connectors → Add custom connector, and paste the endpoint URL above. Claude Desktop does not read remote MCP servers from claude_desktop_config.json - the connector UI is the only supported path.',
),
).toBeInTheDocument();
});
it('shows enabled install button when endpoint is set (Cursor)', () => {
render(<ClientTabs {...defaultProps} activeTab="cursor" />);
const installBtn = screen.getByRole('button', {
name: 'Add to Cursor',
});
expect(installBtn).toBeEnabled();
});
it('shows disabled install button when endpoint is missing (Cursor)', () => {
render(<ClientTabs {...defaultProps} endpoint="" activeTab="cursor" />);
const installBtn = screen.getByRole('button', {
name: 'Add to Cursor',
});
expect(installBtn).toBeDisabled();
});
it('calls onCopySnippet with client key and snippet on copy', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const cursorClient = MCP_CLIENTS.find((c) => c.key === 'cursor')!;
render(<ClientTabs {...defaultProps} activeTab="cursor" />);
await user.click(
screen.getByRole('button', {
name: `Copy ${cursorClient.label} config`,
}),
);
expect(mockOnCopySnippet).toHaveBeenCalledWith(
'cursor',
cursorClient.snippet!(MCP_ENDPOINT),
);
});
it('copy button is disabled when no endpoint', () => {
render(<ClientTabs {...defaultProps} endpoint="" activeTab="cursor" />);
const cursorClient = MCP_CLIENTS.find((c) => c.key === 'cursor')!;
const copyBtn = screen.getByRole('button', {
name: `Copy ${cursorClient.label} config`,
});
expect(copyBtn).toBeDisabled();
});
});

View File

@@ -0,0 +1,126 @@
import { useMemo } from 'react';
import { Button, Tabs } from '@signozhq/ui';
import LearnMore from 'components/LearnMore/LearnMore';
import { Download } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import CopyIconButton from '../CopyIconButton';
import { docsUrl, MCP_CLIENTS, McpClient } from '../clients';
import './ClientTabs.styles.scss';
const ENDPOINT_PLACEHOLDER = 'https://mcp.<region>.signoz.cloud/mcp';
interface ClientTabsProps {
endpoint: string;
activeTab: string;
onTabChange: (key: string) => void;
onCopySnippet: (clientKey: string, snippet: string) => void;
onInstallClick: (clientKey: string) => void;
onDocsLinkClick: (target: string) => void;
}
function ClientTabs({
endpoint,
activeTab,
onTabChange,
onCopySnippet,
onInstallClick,
onDocsLinkClick,
}: ClientTabsProps): JSX.Element {
const items = useMemo(
() =>
MCP_CLIENTS.map((client: McpClient) => {
const snippet = client.snippet
? client.snippet(endpoint || ENDPOINT_PLACEHOLDER)
: null;
const installHref =
client.installUrl && endpoint ? client.installUrl(endpoint) : null;
const installLabel = client.installLabel ?? `Add to ${client.label}`;
return {
key: client.key,
label: client.label,
children: (
<div className="mcp-client-tabs__snippet-wrapper">
{snippet !== null ? (
<div className="mcp-client-tabs__endpoint-value mcp-client-tabs__snippet">
<pre className="mcp-client-tabs__snippet-pre">{snippet}</pre>
<CopyIconButton
ariaLabel={`Copy ${client.label} config`}
disabled={!endpoint}
onCopy={(): void => onCopySnippet(client.key, snippet)}
/>
</div>
) : (
<>
<div className="mcp-client-tabs__endpoint-value mcp-client-tabs__snippet">
<pre className="mcp-client-tabs__snippet-pre">
{endpoint || ENDPOINT_PLACEHOLDER}
</pre>
<CopyIconButton
ariaLabel="Copy MCP endpoint"
disabled={!endpoint}
onCopy={(): void => onCopySnippet(client.key, endpoint)}
/>
</div>
<p className="mcp-client-tabs__instructions">
{client.instructions ?? ''}
</p>
</>
)}
{client.installUrl && (
<div className="mcp-client-tabs__install-row">
{installHref ? (
<Button
variant="solid"
color="primary"
prefix={<Download size={14} />}
onClick={(): void => {
onInstallClick(client.key);
openInNewTab(installHref);
}}
>
{installLabel}
</Button>
) : (
<Button
variant="solid"
color="primary"
disabled
prefix={<Download size={14} />}
>
{installLabel}
</Button>
)}
<span className="mcp-client-tabs__helper-text">
Or copy the config below for manual setup.
</span>
</div>
)}
<LearnMore
text={`${client.label} setup docs`}
url={docsUrl(client.docsPath)}
onClick={(): void => onDocsLinkClick(`client-${client.key}`)}
/>
</div>
),
};
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[endpoint, onCopySnippet, onInstallClick, onDocsLinkClick],
);
return (
<Tabs
className="mcp-client-tabs-root"
value={activeTab}
onChange={onTabChange}
items={items}
/>
);
}
export default ClientTabs;

View File

@@ -0,0 +1,5 @@
.mcp-copy-btn {
&:hover {
background-color: var(--l3-background-hover) !important;
}
}

View File

@@ -0,0 +1,40 @@
import { Button, Tooltip, TooltipProvider } from '@signozhq/ui';
import { Copy } from '@signozhq/icons';
import './CopyIconButton.styles.scss';
interface CopyIconButtonProps {
ariaLabel: string;
onCopy: () => void;
disabled?: boolean;
}
function CopyIconButton({
ariaLabel,
onCopy,
disabled = false,
}: CopyIconButtonProps): JSX.Element {
const tooltipTitle = disabled
? 'Enter your Cloud region first'
: 'Copy to clipboard';
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</Tooltip>
</TooltipProvider>
);
}
export default CopyIconButton;

View File

@@ -0,0 +1,60 @@
.mcp-settings {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding: var(--padding-8) var(--padding-2) var(--padding-6) var(--padding-4);
max-width: 880px;
margin: 0 auto;
width: 100%;
&__header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__header-title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--l1-foreground);
letter-spacing: -0.09px;
line-height: var(--line-height-normal);
margin: 0;
}
&__header-subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
}
.mcp-settings__card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-5);
border: 1px solid var(--l2-border);
border-radius: 8px;
background: var(--l2-background);
&-title {
display: flex;
align-items: center;
gap: var(--spacing-5);
font-size: var(--label-medium-500-font-size);
font-weight: var(--label-medium-500-font-weight);
color: var(--l1-foreground);
margin: 0;
}
&-description {
font-size: var(--font-size-xs);
color: var(--foreground);
line-height: var(--line-height-18);
margin: 0;
}
}

View File

@@ -0,0 +1,161 @@
import { render, screen, userEvent } from 'tests/test-utils';
import MCPServerSettings from './MCPServerSettings';
const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
jest.mock('api/generated/services/global', () => ({
useGetGlobalConfig: (...args: unknown[]): unknown =>
mockUseGetGlobalConfig(...args),
}));
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
}));
jest.mock('@signozhq/ui', () => ({
...jest.requireActual('@signozhq/ui'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
warning: (...args: unknown[]): unknown => mockToastWarning(...args),
},
}));
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: (...args: unknown[]): unknown => mockHistoryPush(...args),
location: { pathname: '/', search: '', hash: '', state: null },
},
}));
jest.mock('utils/basePath', () => ({
getBaseUrl: (): string => 'http://localhost',
getBasePath: (): string => '/',
withBasePath: (p: string): string => p,
}));
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
mockUseGetGlobalConfig.mockReturnValue({
data: { data: { mcp_url: mcpUrl, ingestion_url: '' }, status: 'success' },
isLoading: false,
});
}
describe('MCPServerSettings', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('shows loading spinner while config is loading', () => {
mockUseGetGlobalConfig.mockReturnValue({
data: undefined,
isLoading: true,
});
render(<MCPServerSettings />);
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
expect(screen.queryByTestId('mcp-settings')).not.toBeInTheDocument();
});
it('shows fallback page when mcp_url is not configured', () => {
setupGlobalConfig({ mcpUrl: null });
render(<MCPServerSettings />);
expect(
screen.getByText('MCP Server is available on SigNoz'),
).toBeInTheDocument();
expect(screen.queryByTestId('mcp-settings')).not.toBeInTheDocument();
});
it('renders main settings page when mcp_url is present', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-settings')).toBeInTheDocument();
expect(screen.getByText('SigNoz MCP Server')).toBeInTheDocument();
expect(screen.getByText('Configure your client')).toBeInTheDocument();
expect(screen.getByText('Authenticate from your client')).toBeInTheDocument();
});
it('fires PAGE_VIEWED analytics event on mount', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
role: 'ADMIN',
});
});
it('admin sees the Create Service Account CTA', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(screen.getByText('Create service account')).toBeInTheDocument();
expect(
screen.queryByText(
'Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.',
),
).not.toBeInTheDocument();
});
it('non-admin sees an info banner instead of the CTA', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
expect(
screen.getByText(
'Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.',
),
).toBeInTheDocument();
expect(screen.queryByText('Create service account')).not.toBeInTheDocument();
});
it('navigates to service accounts when admin clicks Create CTA', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
await user.click(screen.getByText('Create service account'));
expect(mockHistoryPush).toHaveBeenCalledWith(
'/settings/service-accounts?create-sa=true',
);
});
it('copies instance URL and shows success toast', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
expect(mockToastSuccess).toHaveBeenCalledWith(
'Instance URL copied to clipboard',
);
});
});

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { useGetGlobalConfig } from 'api/generated/services/global';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
import { Badge, toast } from '@signozhq/ui';
import Spinner from 'components/Spinner';
import AuthCard from './AuthCard/AuthCard';
import ClientTabs from './ClientTabs/ClientTabs';
import NotCloudFallback from './NotCloudFallback/NotCloudFallback';
import UseCasesCard from './UseCasesCard/UseCasesCard';
import { MCP_CLIENTS } from './clients';
import './MCPServerSettings.styles.scss';
const ANALYTICS = {
PAGE_VIEWED: 'MCP Settings: Page viewed',
CREATE_SA_CLICKED: 'MCP Settings: Create service account clicked',
CLIENT_TAB_SELECTED: 'MCP Settings: Client tab selected',
SNIPPET_COPIED: 'MCP Settings: Client snippet copied',
ONE_CLICK_INSTALL_CLICKED: 'MCP Settings: One-click install clicked',
INSTANCE_URL_COPIED: 'MCP Settings: Instance URL copied',
DOCS_LINK_CLICKED: 'MCP Settings: Docs link clicked',
} as const;
function MCPServerSettings(): JSX.Element {
const { user } = useAppContext();
const [, copyToClipboard] = useCopyToClipboard();
const isAdmin = user.role === USER_ROLES.ADMIN;
const instanceUrl = getBaseUrl();
const { data: globalConfig, isLoading: isConfigLoading } =
useGetGlobalConfig();
const endpoint = globalConfig?.data?.mcp_url ?? '';
const [activeTab, setActiveTab] = useState<string>(MCP_CLIENTS[0]?.key ?? '');
useEffect(() => {
void logEvent(ANALYTICS.PAGE_VIEWED, {
role: user.role,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCopySnippet = useCallback(
(clientKey: string, snippet: string) => {
if (!endpoint) {
toast.warning('Enter your Cloud region before copying');
return;
}
copyToClipboard(snippet);
toast.success('Snippet copied to clipboard');
void logEvent(ANALYTICS.SNIPPET_COPIED, { client: clientKey });
},
[endpoint, copyToClipboard],
);
const handleCreateServiceAccount = useCallback(() => {
void logEvent(ANALYTICS.CREATE_SA_CLICKED, {});
history.push(
`${ROUTES.SERVICE_ACCOUNTS_SETTINGS}?${SA_QUERY_PARAMS.CREATE_SA}=true`,
);
}, []);
const handleCopyInstanceUrl = useCallback(() => {
copyToClipboard(instanceUrl);
toast.success('Instance URL copied to clipboard');
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
}, [copyToClipboard, instanceUrl]);
const handleDocsLinkClick = useCallback((target: string) => {
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
}, []);
const handleInstallClick = useCallback((clientKey: string) => {
void logEvent(ANALYTICS.ONE_CLICK_INSTALL_CLICKED, { client: clientKey });
}, []);
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
void logEvent(ANALYTICS.CLIENT_TAB_SELECTED, { client: key });
}, []);
if (isConfigLoading) {
return <Spinner tip="Loading..." height="70vh" />;
}
if (!endpoint) {
return <NotCloudFallback />;
}
return (
<div className="mcp-settings" data-testid="mcp-settings">
<header className="mcp-settings__header">
<h1 className="mcp-settings__header-title">SigNoz MCP Server</h1>
<p className="mcp-settings__header-subtitle">
Connect AI assistants like Claude, Cursor, VS Code, and Codex to your
SigNoz data via the Model Context Protocol. Authenticate from your MCP
client with a service-account API key.
</p>
</header>
<section className="mcp-settings__card">
<h3 className="mcp-settings__card-title">
<Badge color="secondary" variant="default">
1
</Badge>
Configure your client
</h3>
<p className="mcp-settings__card-description">
Add SigNoz to your MCP client. Use a one-click install where available, or
copy the config for manual setup. On first connect, the client will open a
SigNoz authorization page - use the instance URL and API key from step 2.
</p>
<ClientTabs
endpoint={endpoint}
activeTab={activeTab}
onTabChange={handleTabChange}
onCopySnippet={handleCopySnippet}
onInstallClick={handleInstallClick}
onDocsLinkClick={handleDocsLinkClick}
/>
</section>
<AuthCard
isAdmin={isAdmin}
instanceUrl={instanceUrl}
onCopyInstanceUrl={handleCopyInstanceUrl}
onCreateServiceAccount={handleCreateServiceAccount}
/>
<UseCasesCard onDocsLinkClick={handleDocsLinkClick} />
</div>
);
}
export default MCPServerSettings;

View File

@@ -0,0 +1,41 @@
.not-cloud-fallback {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 60vh;
&__content {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--padding-6);
border: 1px dashed var(--l2-border);
border-radius: 8px;
background: var(--l2-background);
max-width: 50%;
.learn-more {
width: fit-content;
font-size: var(--font-size-xs);
}
}
&__title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--l1-foreground);
margin: 0;
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import LearnMore from 'components/LearnMore/LearnMore';
import './NotCloudFallback.styles.scss';
import { MCP_DOCS_URL } from '../clients';
const DOCS_LINK_EVENT = 'MCP Settings: Docs link clicked';
function NotCloudFallback(): JSX.Element {
const handleDocsClick = useCallback(() => {
void logEvent(DOCS_LINK_EVENT, { target: 'fallback' });
}, []);
return (
<div className="not-cloud-fallback">
<div className="not-cloud-fallback__content">
<h2 className="not-cloud-fallback__title">
MCP Server is available on SigNoz
</h2>
<p className="not-cloud-fallback__body">
Users can follow the docs to setup the MCP server against their SigNoz
instance.
</p>
<LearnMore
text="View MCP Server docs"
url={MCP_DOCS_URL}
onClick={handleDocsClick}
/>
</div>
</div>
);
}
export default NotCloudFallback;

View File

@@ -0,0 +1,35 @@
.mcp-use-cases-card {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--padding-4) var(--padding-5);
border: 1px solid var(--l2-border);
border-radius: 8px;
background: var(--l2-background);
&__title {
font-size: var(--label-medium-500-font-size);
font-weight: var(--label-medium-500-font-weight);
color: var(--l1-foreground);
margin: 0;
}
.learn-more {
width: fit-content;
font-size: var(--font-size-xs);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding-left: var(--padding-4);
margin: 0;
li {
font-size: var(--font-size-xs);
color: var(--l1-foreground);
line-height: var(--line-height-20);
}
}
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import LearnMore from 'components/LearnMore/LearnMore';
import { MCP_USE_CASES_URL } from '../clients';
import './UseCasesCard.styles.scss';
interface UseCasesCardProps {
onDocsLinkClick: (target: string) => void;
}
function UseCasesCard({ onDocsLinkClick }: UseCasesCardProps): JSX.Element {
const handleClick = useCallback(
() => onDocsLinkClick('use-cases'),
[onDocsLinkClick],
);
return (
<section className="mcp-use-cases-card">
<h3 className="mcp-use-cases-card__title">What you can do with it</h3>
<ul className="mcp-use-cases-card__list">
<li>Ask your AI assistant to investigate a spiking error rate.</li>
<li>Debug a slow service by walking through recent traces.</li>
<li>Summarize an alert and suggest likely root causes.</li>
<li>Generate dashboards or queries from a natural-language description.</li>
</ul>
<LearnMore
text="See more use cases"
url={MCP_USE_CASES_URL}
onClick={handleClick}
/>
</section>
);
}
export default UseCasesCard;

View File

@@ -0,0 +1,108 @@
import { DOCS_BASE_URL } from 'constants/app';
export interface McpClient {
key: string;
label: string;
docsPath: string;
snippet: ((endpoint: string) => string) | null;
instructions?: string;
installUrl?: (endpoint: string) => string;
installLabel?: string;
}
function b64url(input: string): string {
if (typeof btoa === 'function') {
return btoa(input);
}
// fallback for non-browser TS contexts (never hit at runtime)
return Buffer.from(input, 'utf8').toString('base64');
}
export const MCP_CLIENTS: McpClient[] = [
{
key: 'cursor',
label: 'Cursor',
docsPath: '/docs/ai/signoz-mcp-server/#cursor',
snippet: (endpoint): string =>
JSON.stringify(
{
mcpServers: {
signoz: {
url: endpoint,
},
},
},
null,
2,
),
installUrl: (endpoint): string => {
const config = b64url(JSON.stringify({ url: endpoint }));
return `cursor://anysphere.cursor-deeplink/mcp/install?name=SigNoz&config=${config}`;
},
installLabel: 'Add to Cursor',
},
{
key: 'claude-code',
label: 'Claude Code',
docsPath: '/docs/ai/signoz-mcp-server/#claude-code',
snippet: (endpoint): string =>
`claude mcp add --scope user --transport http signoz ${endpoint}`,
},
{
key: 'vscode',
label: 'VS Code',
docsPath: '/docs/ai/signoz-mcp-server/#vs-code',
snippet: (endpoint): string =>
JSON.stringify(
{
servers: {
signoz: {
type: 'http',
url: endpoint,
},
},
},
null,
2,
),
installUrl: (endpoint): string => {
const payload = encodeURIComponent(
JSON.stringify({
name: 'signoz',
config: { type: 'http', url: endpoint },
}),
);
return `vscode:mcp/install?${payload}`;
},
installLabel: 'Add to VS Code',
},
{
key: 'claude-desktop',
label: 'Claude Desktop',
docsPath: '/docs/ai/signoz-mcp-server/#claude-desktop',
snippet: null,
instructions:
'Open Claude Desktop, go to Settings → Connectors → Add custom connector, and paste the endpoint URL above. Claude Desktop does not read remote MCP servers from claude_desktop_config.json - the connector UI is the only supported path.',
},
{
key: 'codex',
label: 'Codex',
docsPath: '/docs/ai/signoz-mcp-server/#codex',
snippet: (endpoint): string => `codex mcp add signoz --url ${endpoint}`,
},
{
key: 'other',
label: 'Other',
docsPath: '/docs/ai/signoz-mcp-server/',
snippet: null,
instructions:
'Most MCP clients that support remote HTTP servers will accept the endpoint URL above. Add it as a new MCP server in your client and paste your SigNoz API key when the client prompts for authentication. See the docs for client-specific instructions.',
},
];
export function docsUrl(path: string): string {
return `${DOCS_BASE_URL}${path}`;
}
export const MCP_DOCS_URL = `${DOCS_BASE_URL}/docs/ai/signoz-mcp-server/`;
export const MCP_USE_CASES_URL = `${DOCS_BASE_URL}/docs/ai/use-cases/`;

View File

@@ -15,7 +15,7 @@ import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { DOCS_BASE_URL } from 'constants/app';
import ROUTES from 'constants/routes';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';

View File

@@ -5,7 +5,8 @@ import logEvent from 'api/common/logEvent';
import { useGetIngestionKeys } from 'api/generated/services/gateway';
import { GatewaytypesIngestionKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { DOCS_BASE_URL } from 'constants/app';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowUpRight, Copy, Info, Key, TriangleAlert } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
@@ -59,6 +60,8 @@ export default function OnboardingIngestionDetails(): JSX.Element {
error: globalConfigError,
} = useGetGlobalConfig();
const globalConfigApiError = convertToApiError(globalConfigError);
const handleCopyKey = (text: string): void => {
handleCopyToClipboard(text);
notifications.success({
@@ -155,11 +158,11 @@ export default function OnboardingIngestionDetails(): JSX.Element {
title={
<div className="ingestion-url-error-content">
<Typography.Text className="ingestion-url-error-code">
{globalConfigError?.getErrorCode()}
{globalConfigApiError?.getErrorCode()}
</Typography.Text>
<Typography.Text className="ingestion-url-error-message">
{globalConfigError?.getErrorMessage()}
{globalConfigApiError?.getErrorMessage()}
</Typography.Text>
</div>
}

View File

@@ -192,7 +192,8 @@ const onboardingConfigWithLinks = [
'setup',
],
imgUrl: signozBrandLogoUrl,
link: '/docs/ai/signoz-mcp-server/',
link: '/settings/mcp-server',
internalRedirect: true,
},
{
dataSource: 'migrate-from-datadog',

View File

@@ -32,6 +32,7 @@ import {
Settings,
Shield,
Slack,
Sparkles,
Unplug,
User,
UserPlus,
@@ -337,6 +338,13 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'integrations',
},
{
key: ROUTES.MCP_SERVER,
label: 'MCP Server',
icon: <Sparkles size={16} />,
isEnabled: false,
itemKey: 'mcp-server',
},
],
},

View File

@@ -157,6 +157,7 @@ export const routesToSkip = [
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
ROUTES.MCP_SERVER,
ROUTES.INGESTION_SETTINGS,
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,

View File

@@ -1,14 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getGlobalConfig from 'api/globalConfig/getGlobalConfig';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { GlobalConfigData } from 'types/api/globalConfig/types';
export const useGetGlobalConfig = (): UseQueryResult<
SuccessResponseV2<GlobalConfigData>,
APIError
> =>
useQuery<SuccessResponseV2<GlobalConfigData>, APIError>({
queryKey: ['getGlobalConfig'],
queryFn: () => getGlobalConfig(),
});

View File

@@ -8,15 +8,15 @@
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
.divider {
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
}

View File

@@ -31,9 +31,7 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, {
[Styles.pinned]: isPinned,
})}
className={cx(Styles.container, isPinned && Styles.pinned)}
data-testid="uplot-tooltip-container"
>
{showHeader && (

View File

@@ -11,10 +11,11 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.pinnedItem {
padding: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
}
.status {

View File

@@ -3,7 +3,7 @@
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
padding: 0px var(--spacing-2) 0 var(--spacing-4);
[data-test-id='virtuoso-item-list'] > * + * {
margin-top: var(--spacing-2);

View File

@@ -83,7 +83,8 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
item.key === ROUTES.SHORTCUTS ||
item.key === ROUTES.MCP_SERVER
? true
: item.isEnabled,
}));
@@ -95,7 +96,8 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.SHORTCUTS
item.key === ROUTES.SHORTCUTS ||
item.key === ROUTES.MCP_SERVER
? true
: item.isEnabled,
}));
@@ -114,7 +116,8 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.MCP_SERVER
? true
: item.isEnabled,
}));
@@ -125,7 +128,8 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.MCP_SERVER
? true
: item.isEnabled,
}));

View File

@@ -56,6 +56,7 @@ describe('SettingsPage nav sections', () => {
'sso',
'integrations',
'ingestion',
'mcp-server',
])('renders "%s" element', (id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
@@ -81,9 +82,12 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it.each(['billing', 'roles', 'mcp-server'])(
'does not render "%s" element',
(id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
},
);
});
describe('Self-hosted Admin', () => {
@@ -95,12 +99,16 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['roles', 'members', 'integrations', 'sso', 'ingestion'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
it.each([
'roles',
'members',
'integrations',
'sso',
'ingestion',
'mcp-server',
])('renders "%s" element', (id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
});
describe('section structure', () => {

View File

@@ -8,6 +8,7 @@ import GeneralSettings from 'container/GeneralSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSettings';
import MCPServerSettings from 'container/MCPServerSettings/MCPServerSettings';
import MySettings from 'container/MySettings';
import OrganizationSettings from 'container/OrganizationSettings';
import RolesSettings from 'container/RolesSettings';
@@ -24,6 +25,7 @@ import {
Pencil,
Plus,
Shield,
Sparkles,
User,
Users,
} from 'lucide-react';
@@ -205,6 +207,19 @@ export const serviceAccountsSettings = (
},
];
export const mcpServerSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: MCPServerSettings,
name: (
<div className="periscope-tab">
<Sparkles size={16} /> {t('routes:mcp_server').toString()}
</div>
),
route: ROUTES.MCP_SERVER,
key: ROUTES.MCP_SERVER,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (

View File

@@ -10,6 +10,7 @@ import {
generalSettings,
ingestionSettings,
keyboardShortcuts,
mcpServerSettings,
membersSettings,
multiIngestionSettings,
mySettings,
@@ -79,6 +80,7 @@ export const getRoutes = (
...createAlertChannels(t),
...editAlertChannels(t),
...keyboardShortcuts(t),
...mcpServerSettings(t),
);
return settings;

View File

@@ -1,9 +0,0 @@
export interface GlobalConfigData {
external_url: string;
ingestion_url: string;
}
export interface GlobalConfigDataProps {
status: string;
data: GlobalConfigData;
}

View File

@@ -132,4 +132,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
METER_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
PUBLIC_DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERT_TYPE_SELECTION: ['ADMIN', 'EDITOR'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
};