Compare commits

..

3 Commits

Author SHA1 Message Date
makeavish
b4572f3c0b 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.
2026-04-21 09:35:04 +05:30
makeavish
bdd155e59a 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.
2026-04-21 09:28:25 +05:30
makeavish
498bf54204 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.
2026-04-21 09:21:20 +05:30
20 changed files with 1107 additions and 498 deletions

View File

@@ -0,0 +1,52 @@
{
"page_title": "SigNoz MCP Server",
"page_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.",
"fallback_title": "MCP Server is available on SigNoz Cloud",
"fallback_body": "This in-product setup guide is for SigNoz Cloud. Self-hosted users can follow the docs to run the MCP server against their instance.",
"fallback_docs_link": "View MCP Server docs",
"region_warning_prefix": "We couldn't detect your Cloud region from this URL. Find it at ",
"region_warning_link": "Settings → Ingestion",
"region_warning_suffix": " (the label between your workspace name and signoz.cloud in the ingestion URL), then enter it here.",
"region_input_label": "SigNoz Cloud region",
"region_input_placeholder": "Your SigNoz Cloud region",
"step1_title": "Configure your client",
"step1_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.",
"step1_manual_fallback": "Or copy the config below for manual setup.",
"step1_client_docs_suffix": " setup docs",
"step1_add_to_client_prefix": "Add to ",
"client_cursor_install_label": "Add to Cursor",
"client_vscode_install_label": "Add to VS Code",
"client_claude_desktop_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.",
"client_other_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.",
"step2_title": "Authenticate from your client",
"step2_description": "On first connect, your client opens a SigNoz authorization page asking for two values:",
"step2_instance_url_label": "SigNoz Instance URL",
"step2_api_key_label": "API Key",
"step2_admin_cta": "Create service account",
"step2_admin_helper": "Create a service account, then add a new key inside it — paste that key into the API Key field.",
"step2_viewer_helper": "Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.",
"use_cases_title": "What you can do with it",
"use_cases_item_1": "Ask your AI assistant to investigate a spiking error rate.",
"use_cases_item_2": "Debug a slow service by walking through recent traces.",
"use_cases_item_3": "Summarize an alert and suggest likely root causes.",
"use_cases_item_4": "Generate dashboards or queries from a natural-language description.",
"use_cases_docs_link": "See more use cases",
"copy_tooltip_enabled": "Copy to clipboard",
"copy_tooltip_disabled": "Enter your Cloud region first",
"copy_aria_endpoint": "Copy MCP endpoint",
"copy_aria_instance_url": "Copy SigNoz instance URL",
"copy_aria_snippet_prefix": "Copy ",
"copy_aria_snippet_suffix": " config",
"toast_endpoint_copied": "Endpoint copied to clipboard",
"toast_snippet_copied": "Snippet copied to clipboard",
"toast_instance_url_copied": "Instance URL copied to clipboard",
"toast_region_required": "Enter your Cloud region before copying"
}

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

@@ -0,0 +1,52 @@
{
"page_title": "SigNoz MCP Server",
"page_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.",
"fallback_title": "MCP Server is available on SigNoz Cloud",
"fallback_body": "This in-product setup guide is for SigNoz Cloud. Self-hosted users can follow the docs to run the MCP server against their instance.",
"fallback_docs_link": "View MCP Server docs",
"region_warning_prefix": "We couldn't detect your Cloud region from this URL. Find it at ",
"region_warning_link": "Settings → Ingestion",
"region_warning_suffix": " (the label between your workspace name and signoz.cloud in the ingestion URL), then enter it here.",
"region_input_label": "SigNoz Cloud region",
"region_input_placeholder": "Your SigNoz Cloud region",
"step1_title": "Configure your client",
"step1_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.",
"step1_manual_fallback": "Or copy the config below for manual setup.",
"step1_client_docs_suffix": " setup docs",
"step1_add_to_client_prefix": "Add to ",
"client_cursor_install_label": "Add to Cursor",
"client_vscode_install_label": "Add to VS Code",
"client_claude_desktop_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.",
"client_other_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.",
"step2_title": "Authenticate from your client",
"step2_description": "On first connect, your client opens a SigNoz authorization page asking for two values:",
"step2_instance_url_label": "SigNoz Instance URL",
"step2_api_key_label": "API Key",
"step2_admin_cta": "Create service account",
"step2_admin_helper": "Create a service account, then add a new key inside it — paste that key into the API Key field.",
"step2_viewer_helper": "Only admins can create API keys. Ask your workspace admin for a key with read access, then paste it into the API Key field.",
"use_cases_title": "What you can do with it",
"use_cases_item_1": "Ask your AI assistant to investigate a spiking error rate.",
"use_cases_item_2": "Debug a slow service by walking through recent traces.",
"use_cases_item_3": "Summarize an alert and suggest likely root causes.",
"use_cases_item_4": "Generate dashboards or queries from a natural-language description.",
"use_cases_docs_link": "See more use cases",
"copy_tooltip_enabled": "Copy to clipboard",
"copy_tooltip_disabled": "Enter your Cloud region first",
"copy_aria_endpoint": "Copy MCP endpoint",
"copy_aria_instance_url": "Copy SigNoz instance URL",
"copy_aria_snippet_prefix": "Copy ",
"copy_aria_snippet_suffix": " config",
"toast_endpoint_copied": "Endpoint copied to clipboard",
"toast_snippet_copied": "Snippet copied to clipboard",
"toast_instance_url_copied": "Instance URL copied to clipboard",
"toast_region_required": "Enter your Cloud region before copying"
}

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

@@ -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

@@ -0,0 +1,246 @@
.mcp-settings {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
max-width: 55rem;
&__header {
display: flex;
flex-direction: column;
gap: 0.5rem;
&-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--l0-foreground);
}
&-subtitle {
font-size: 0.8125rem;
font-weight: 400;
color: var(--l2-foreground);
line-height: 1.25rem;
}
}
&__card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
border: 0.0625rem solid var(--l3-background);
border-radius: 0.5rem;
background: var(--l1-background);
}
&__card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--l0-foreground);
}
&__step-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
background: var(--bg-robin-400);
color: var(--l0-background);
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
&__card-description {
font-size: 0.8125rem;
color: var(--l2-foreground);
line-height: 1.25rem;
}
&__endpoint-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__endpoint-field {
flex: 1;
min-width: 20rem;
}
&__endpoint-value {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 0.0625rem solid var(--l3-background);
border-radius: 0.375rem;
background: var(--l2-background);
font-family: var(--font-family-monospace, monospace);
font-size: 0.8125rem;
color: var(--l0-foreground);
width: 100%;
justify-content: space-between;
}
&__copy-btn {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--bg-robin-400);
}
}
&__region-warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 0.0625rem solid var(--bg-amber-500);
border-radius: 0.375rem;
background: var(--bg-amber-500-a10, rgba(255, 193, 7, 0.08));
font-size: 0.75rem;
color: var(--l2-foreground);
line-height: 1.125rem;
svg {
flex-shrink: 0;
color: var(--bg-amber-500);
margin-top: 0.125rem;
}
}
&__region-card,
&__cta-card {
padding: 1rem 1.25rem;
gap: 0.75rem;
}
&__auth-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
&__auth-field-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--l2-foreground);
letter-spacing: 0.01em;
}
&__info-banner-inline {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-left: 0.1875rem solid var(--bg-robin-400);
background: var(--l2-background);
border-radius: 0.25rem;
svg {
flex-shrink: 0;
color: var(--bg-robin-400);
margin-top: 0.125rem;
}
}
&__endpoint-input {
margin-top: 0.5rem;
font-family: var(--font-family-monospace, monospace);
}
&__cta-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__helper-text {
font-size: 0.75rem;
color: var(--l2-foreground);
line-height: 1.125rem;
}
&__tabs-container {
.ant-tabs-nav {
margin-bottom: 0.75rem;
}
}
&__snippet-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__install-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
&__snippet-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
&__use-cases {
display: flex;
flex-direction: column;
gap: 0.75rem;
&-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
li {
font-size: 0.8125rem;
color: var(--l1-foreground);
line-height: 1.25rem;
}
}
}
&__fallback {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
border: 0.0625rem dashed var(--l3-background);
border-radius: 0.5rem;
background: var(--l1-background);
max-width: 40rem;
&-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--l0-foreground);
}
&-body {
font-size: 0.8125rem;
color: var(--l2-foreground);
line-height: 1.25rem;
}
}
}

View File

@@ -0,0 +1,536 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TFunction, useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import { Button, Input, Tabs, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import LearnMore from 'components/LearnMore/LearnMore';
import ROUTES from 'constants/routes';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
Copy,
Download,
Info,
KeyRound,
Sparkles,
TriangleAlert,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import {
docsUrl,
MCP_CLIENTS,
MCP_DOCS_URL,
MCP_USE_CASES_URL,
McpClient,
} from './clients';
import {
buildMcpEndpoint,
getCloudRegion,
normalizeRegion,
parseRegionFromUrl,
} from './getCloudRegion';
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;
const ENDPOINT_PLACEHOLDER = 'https://mcp.<region>.signoz.cloud/mcp';
function NotCloudFallback(): JSX.Element {
const { t } = useTranslation('mcpServer');
const onClick = useCallback(() => {
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target: 'fallback' });
}, []);
return (
<div className="mcp-settings">
<div className="mcp-settings__fallback">
<div className="mcp-settings__fallback-title">
<Sparkles size={18} /> {t('fallback_title')}
</div>
<Typography.Text className="mcp-settings__fallback-body">
{t('fallback_body')}
</Typography.Text>
<LearnMore
text={t('fallback_docs_link')}
url={MCP_DOCS_URL}
onClick={onClick}
/>
</div>
</div>
);
}
interface CopyIconButtonProps {
ariaLabel: string;
onCopy: () => void;
disabled?: boolean;
}
function CopyIconButton({
ariaLabel,
onCopy,
disabled,
}: CopyIconButtonProps): JSX.Element {
const { t } = useTranslation('mcpServer');
const tooltipTitle = disabled
? t('copy_tooltip_disabled')
: t('copy_tooltip_enabled');
const button = (
<Button
type="text"
size="small"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-settings__copy-btn"
icon={<Copy size={14} />}
onClick={onCopy}
/>
);
// Ant Design Tooltip doesn't reliably surface for a disabled Button —
// wrap in a span so hover/focus still reaches the Tooltip.
return (
<Tooltip title={tooltipTitle}>
{disabled ? <span>{button}</span> : button}
</Tooltip>
);
}
CopyIconButton.defaultProps = {
disabled: false,
};
interface RegionFallbackCardProps {
manualRegion: string;
onRegionChange: (value: string) => void;
onIngestionLinkClick: () => void;
t: TFunction<'mcpServer'>;
}
function RegionFallbackCard({
manualRegion,
onRegionChange,
onIngestionLinkClick,
t,
}: RegionFallbackCardProps): JSX.Element {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => onRegionChange(e.target.value),
[onRegionChange],
);
return (
<div className="mcp-settings__card mcp-settings__region-card">
<div className="mcp-settings__region-warning">
<TriangleAlert size={14} />
<span>
{t('region_warning_prefix')}
<Button
type="link"
size="small"
className="mcp-settings__inline-link"
onClick={onIngestionLinkClick}
>
{t('region_warning_link')}
</Button>
{t('region_warning_suffix')}
</span>
</div>
<label
className="mcp-settings__auth-field-label"
htmlFor="mcp-settings-manual-region"
>
{t('region_input_label')}
</label>
<Input
id="mcp-settings-manual-region"
className="mcp-settings__endpoint-input"
size="small"
value={manualRegion}
placeholder={t('region_input_placeholder')}
aria-label={t('region_input_label')}
onChange={handleChange}
/>
</div>
);
}
interface ClientTabsProps {
endpoint: string;
activeTab: string;
onTabChange: (key: string) => void;
onCopySnippet: (clientKey: string, snippet: string) => void;
onInstallClick: (clientKey: string) => void;
onDocsLinkClick: (target: string) => void;
t: TFunction<'mcpServer'>;
}
interface ClientTabChildrenProps {
client: McpClient;
endpoint: string;
onCopySnippet: (clientKey: string, snippet: string) => void;
onInstallClick: (clientKey: string) => void;
onDocsLinkClick: (target: string) => void;
t: TFunction<'mcpServer'>;
}
function ClientTabChildren({
client,
endpoint,
onCopySnippet,
onInstallClick,
onDocsLinkClick,
t,
}: ClientTabChildrenProps): JSX.Element {
const snippet = client.snippet
? client.snippet(endpoint || ENDPOINT_PLACEHOLDER)
: null;
const installHref =
client.installUrl && endpoint ? client.installUrl(endpoint) : null;
const handleInstallClick = useCallback(() => onInstallClick(client.key), [
onInstallClick,
client.key,
]);
const handleDocsClick = useCallback(
() => onDocsLinkClick(`client-${client.key}`),
[onDocsLinkClick, client.key],
);
const handleSnippetCopy = useCallback(() => {
if (snippet) {
onCopySnippet(client.key, snippet);
}
}, [onCopySnippet, client.key, snippet]);
const installLabel = client.installLabelKey
? t(client.installLabelKey)
: `${t('step1_add_to_client_prefix')}${client.label}`;
const instructions = client.instructionsKey ? t(client.instructionsKey) : '';
return (
<div className="mcp-settings__snippet-wrapper">
{client.installUrl && (
<div className="mcp-settings__install-row">
<Button
type="primary"
disabled={!installHref}
icon={<Download size={14} />}
href={installHref ?? undefined}
onClick={handleInstallClick}
>
{installLabel}
</Button>
<Typography.Text className="mcp-settings__helper-text">
{t('step1_manual_fallback')}
</Typography.Text>
</div>
)}
{snippet !== null ? (
<div className="mcp-settings__endpoint-value mcp-settings__snippet">
<pre className="mcp-settings__snippet-pre">{snippet}</pre>
<CopyIconButton
ariaLabel={`${t('copy_aria_snippet_prefix')}${client.label}${t(
'copy_aria_snippet_suffix',
)}`}
disabled={!endpoint}
onCopy={handleSnippetCopy}
/>
</div>
) : (
<Typography.Text className="mcp-settings__card-description">
{instructions}
</Typography.Text>
)}
<LearnMore
text={`${client.label}${t('step1_client_docs_suffix')}`}
url={docsUrl(client.docsPath)}
onClick={handleDocsClick}
/>
</div>
);
}
function ClientTabs({
endpoint,
activeTab,
onTabChange,
onCopySnippet,
onInstallClick,
onDocsLinkClick,
t,
}: ClientTabsProps): JSX.Element {
const items = useMemo(
() =>
MCP_CLIENTS.map((client: McpClient) => ({
key: client.key,
label: client.label,
children: (
<ClientTabChildren
client={client}
endpoint={endpoint}
onCopySnippet={onCopySnippet}
onInstallClick={onInstallClick}
onDocsLinkClick={onDocsLinkClick}
t={t}
/>
),
})),
[endpoint, onCopySnippet, onInstallClick, onDocsLinkClick, t],
);
return (
<Tabs
className="mcp-settings__tabs-container"
activeKey={activeTab}
onChange={onTabChange}
items={items}
/>
);
}
interface AuthCardProps {
isAdmin: boolean;
instanceUrl: string;
onCopyInstanceUrl: () => void;
onCreateServiceAccount: () => void;
t: TFunction<'mcpServer'>;
}
function AuthCard({
isAdmin,
instanceUrl,
onCopyInstanceUrl,
onCreateServiceAccount,
t,
}: AuthCardProps): JSX.Element {
return (
<section className="mcp-settings__card mcp-settings__cta-card">
<h3 className="mcp-settings__card-title">
<span className="mcp-settings__step-badge">2</span> {t('step2_title')}
</h3>
<Typography.Text className="mcp-settings__card-description">
{t('step2_description')}
</Typography.Text>
<div className="mcp-settings__auth-field">
<Typography.Text className="mcp-settings__auth-field-label">
{t('step2_instance_url_label')}
</Typography.Text>
<div className="mcp-settings__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel={t('copy_aria_instance_url')}
onCopy={onCopyInstanceUrl}
/>
</div>
</div>
<div className="mcp-settings__auth-field">
<Typography.Text className="mcp-settings__auth-field-label">
{t('step2_api_key_label')}
</Typography.Text>
{isAdmin ? (
<div className="mcp-settings__cta-row">
<Button
type="primary"
icon={<KeyRound size={14} />}
onClick={onCreateServiceAccount}
>
{t('step2_admin_cta')}
</Button>
<Typography.Text className="mcp-settings__helper-text">
{t('step2_admin_helper')}
</Typography.Text>
</div>
) : (
<div className="mcp-settings__info-banner-inline">
<Info size={14} />
<Typography.Text className="mcp-settings__helper-text">
{t('step2_viewer_helper')}
</Typography.Text>
</div>
)}
</div>
</section>
);
}
interface UseCasesCardProps {
onDocsLinkClick: (target: string) => void;
t: TFunction<'mcpServer'>;
}
function UseCasesCard({ onDocsLinkClick, t }: UseCasesCardProps): JSX.Element {
const handleClick = useCallback(() => onDocsLinkClick('use-cases'), [
onDocsLinkClick,
]);
return (
<section className="mcp-settings__card mcp-settings__use-cases">
<h3 className="mcp-settings__card-title">{t('use_cases_title')}</h3>
<ul className="mcp-settings__use-cases-list">
<li>{t('use_cases_item_1')}</li>
<li>{t('use_cases_item_2')}</li>
<li>{t('use_cases_item_3')}</li>
<li>{t('use_cases_item_4')}</li>
</ul>
<LearnMore
text={t('use_cases_docs_link')}
url={MCP_USE_CASES_URL}
onClick={handleClick}
/>
</section>
);
}
function MCPServerSettings(): JSX.Element {
const { t } = useTranslation('mcpServer');
const { user } = useAppContext();
const { isCloudUser } = useGetTenantLicense();
const { notifications } = useNotifications();
const [, copyToClipboard] = useCopyToClipboard();
const isAdmin = user.role === USER_ROLES.ADMIN;
const instanceUrl = window.location.origin;
const { data: globalConfig } = useGetGlobalConfig();
const regionFromHost = useMemo(() => getCloudRegion(), []);
const regionFromIngestion = useMemo(
() =>
globalConfig?.data?.ingestion_url
? parseRegionFromUrl(globalConfig.data.ingestion_url)
: null,
[globalConfig?.data?.ingestion_url],
);
const autoDetectedRegion = regionFromHost.region ?? regionFromIngestion;
const [manualRegion, setManualRegion] = useState<string>('');
const [activeTab, setActiveTab] = useState<string>(MCP_CLIENTS[0]?.key ?? '');
const resolvedRegion: string | null =
autoDetectedRegion ?? normalizeRegion(manualRegion);
const endpoint = resolvedRegion ? buildMcpEndpoint(resolvedRegion) : '';
// Fire once on mount so we reliably capture every visit, even if the
// globalConfig fetch is slow or fails. Region is best-effort from the
// hostname at mount time; if ingestion_url resolves later we skip logging
// again to avoid double-fires.
useEffect(() => {
logEvent(ANALYTICS.PAGE_VIEWED, {
isCloudUser,
role: user.role,
region: regionFromHost.region,
isAutoDetected: Boolean(regionFromHost.region),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCopySnippet = useCallback(
(clientKey: string, snippet: string) => {
if (!endpoint) {
notifications.warning({ message: t('toast_region_required') });
return;
}
copyToClipboard(snippet);
notifications.success({ message: t('toast_snippet_copied') });
logEvent(ANALYTICS.SNIPPET_COPIED, { client: clientKey });
},
[endpoint, copyToClipboard, notifications, t],
);
const handleCreateServiceAccount = useCallback(() => {
logEvent(ANALYTICS.CREATE_SA_CLICKED, {});
history.push(
`${ROUTES.SERVICE_ACCOUNTS_SETTINGS}?${SA_QUERY_PARAMS.CREATE_SA}=true`,
);
}, []);
const handleCopyInstanceUrl = useCallback(() => {
copyToClipboard(instanceUrl);
notifications.success({ message: t('toast_instance_url_copied') });
logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
}, [copyToClipboard, instanceUrl, notifications, t]);
const handleDocsLinkClick = useCallback((target: string) => {
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
}, []);
const handleIngestionLinkClick = useCallback(() => {
logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target: 'ingestion-settings' });
history.push(ROUTES.INGESTION_SETTINGS);
}, []);
const handleInstallClick = useCallback((clientKey: string) => {
logEvent(ANALYTICS.ONE_CLICK_INSTALL_CLICKED, { client: clientKey });
}, []);
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
logEvent(ANALYTICS.CLIENT_TAB_SELECTED, { client: key });
}, []);
if (!isCloudUser) {
return <NotCloudFallback />;
}
return (
<div className="mcp-settings" data-testid="mcp-settings">
<header className="mcp-settings__header">
<h2 className="mcp-settings__header-title">
<Sparkles size={20} /> {t('page_title')}
</h2>
<Typography.Text className="mcp-settings__header-subtitle">
{t('page_subtitle')}
</Typography.Text>
</header>
{!autoDetectedRegion && (
<RegionFallbackCard
manualRegion={manualRegion}
onRegionChange={setManualRegion}
onIngestionLinkClick={handleIngestionLinkClick}
t={t}
/>
)}
<section className="mcp-settings__card">
<h3 className="mcp-settings__card-title">
<span className="mcp-settings__step-badge">1</span> {t('step1_title')}
</h3>
<Typography.Text className="mcp-settings__card-description">
{t('step1_description')}
</Typography.Text>
<ClientTabs
endpoint={endpoint}
activeTab={activeTab}
onTabChange={handleTabChange}
onCopySnippet={handleCopySnippet}
onInstallClick={handleInstallClick}
onDocsLinkClick={handleDocsLinkClick}
t={t}
/>
</section>
<AuthCard
isAdmin={isAdmin}
instanceUrl={instanceUrl}
onCopyInstanceUrl={handleCopyInstanceUrl}
onCreateServiceAccount={handleCreateServiceAccount}
t={t}
/>
<UseCasesCard onDocsLinkClick={handleDocsLinkClick} t={t} />
</div>
);
}
export default MCPServerSettings;

View File

@@ -0,0 +1,111 @@
import { DOCS_BASE_URL } from 'constants/app';
export interface McpClient {
key: string;
// `label` is the client brand name (Cursor, VS Code, Claude Desktop …).
// Brand names are not translated.
label: string;
docsPath: string;
snippet: ((endpoint: string) => string) | null;
// i18n key under the `mcpServer` namespace. Resolved at render time via t().
instructionsKey?: string;
installUrl?: (endpoint: string) => string;
// i18n key for the install button label. Falls back to
// `step1_add_to_client_prefix` + `label` when not set.
installLabelKey?: 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}`;
},
installLabelKey: 'client_cursor_install_label',
},
{
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}`;
},
installLabelKey: 'client_vscode_install_label',
},
{
key: 'claude-desktop',
label: 'Claude Desktop',
docsPath: '/docs/ai/signoz-mcp-server/#claude-desktop',
snippet: null,
instructionsKey: 'client_claude_desktop_instructions',
},
{
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: '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,
instructionsKey: 'client_other_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

@@ -0,0 +1,51 @@
export interface CloudRegionResult {
region: string | null;
isKnown: boolean;
}
const VALID_REGION_LABEL = /^[a-z0-9][a-z0-9-]*$/;
export function parseRegionFromSignozCloudHost(host: string): string | null {
const parts = host.split('.');
const len = parts.length;
// SigNoz Cloud tenant hosts follow `<tenant>.<region>.signoz.cloud`
// (4 labels). 3-label hosts like `app.signoz.cloud` would wrongly
// resolve to region=`app`, so require at least 4 labels.
if (len < 4 || parts[len - 1] !== 'cloud' || parts[len - 2] !== 'signoz') {
return null;
}
const region = parts[len - 3]?.toLowerCase() ?? '';
if (!VALID_REGION_LABEL.test(region)) {
return null;
}
return region;
}
export function parseRegionFromUrl(url: string): string | null {
try {
return parseRegionFromSignozCloudHost(new URL(url).hostname);
} catch {
return null;
}
}
export function parseCloudRegion(hostname: string): CloudRegionResult {
const region = parseRegionFromSignozCloudHost(hostname);
return region ? { region, isKnown: true } : { region: null, isKnown: false };
}
export function normalizeRegion(input: string): string | null {
const value = input.trim().toLowerCase();
if (!VALID_REGION_LABEL.test(value)) {
return null;
}
return value;
}
export function buildMcpEndpoint(region: string): string {
return `https://mcp.${region}.signoz.cloud/mcp`;
}
export function getCloudRegion(): CloudRegionResult {
return parseCloudRegion(window.location.hostname);
}

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

@@ -112,15 +112,14 @@ export const getUPlotChartData = (
const processAnomalyDetectionData = (
anomalyDetectionData: any,
isDarkMode: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Record<string, { data: (number | null)[][]; color: string }> => {
): Record<string, { data: number[][]; color: string }> => {
if (!anomalyDetectionData) {
return {};
}
const processedData: Record<
string,
{ data: (number | null)[][]; color: string; legendLabel: string }
{ data: number[][]; color: string; legendLabel: string }
> = {};
for (
@@ -149,30 +148,24 @@ const processAnomalyDetectionData = (
anomalyDetectionData.length > 1 ? `${queryName}-${label}` : label;
// Single iteration instead of 5 separate map operations
const { values: seriesValues } = series?.[index] || { values: [] };
const { values: predictedValues } = predictedSeries?.[index] || {
values: [],
};
const { values: upperBoundValues } = upperBoundSeries?.[index] || {
values: [],
};
const { values: lowerBoundValues } = lowerBoundSeries?.[index] || {
values: [],
};
const { values: seriesValues } = series[index];
const { values: predictedValues } = predictedSeries[index];
const { values: upperBoundValues } = upperBoundSeries[index];
const { values: lowerBoundValues } = lowerBoundSeries[index];
const length = seriesValues.length;
const timestamps: number[] = new Array(length);
const values: number[] = new Array(length);
const predicted: (number | null)[] = new Array(length);
const upperBound: (number | null)[] = new Array(length);
const lowerBound: (number | null)[] = new Array(length);
const predicted: number[] = new Array(length);
const upperBound: number[] = new Array(length);
const lowerBound: number[] = new Array(length);
for (let i = 0; i < length; i++) {
timestamps[i] = seriesValues[i].timestamp / 1000;
values[i] = seriesValues[i].value;
predicted[i] = predictedValues[i]?.value ?? null;
upperBound[i] = upperBoundValues[i]?.value ?? null;
lowerBound[i] = lowerBoundValues[i]?.value ?? null;
predicted[i] = predictedValues[i].value;
upperBound[i] = upperBoundValues[i].value;
lowerBound[i] = lowerBoundValues[i].value;
}
processedData[objKey] = {
@@ -192,10 +185,7 @@ const processAnomalyDetectionData = (
export const getUplotChartDataForAnomalyDetection = (
apiResponse: MetricRangePayloadProps,
isDarkMode: boolean,
): Record<
string,
{ [x: string]: any; data: (number | null)[][]; color: string }
> => {
): Record<string, { [x: string]: any; data: number[][]; color: string }> => {
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
return processAnomalyDetectionData(anomalyDetectionData, isDarkMode);
};

View File

@@ -1,470 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
getUPlotChartData,
getUplotChartDataForAnomalyDetection,
} from '../getUplotChartData';
describe('getUPlotChartData', () => {
test('should return empty array with timestamps when no series data', () => {
const result = getUPlotChartData(undefined);
expect(result).toEqual([[]]);
});
test('should return timestamps and values for single series', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
[3000, '30'],
],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([1000, 2000, 3000]); // timestamps
expect(result[1]).toEqual([10, 20, 30]); // values
});
test('should handle multiple series with different timestamps', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
],
queryName: 'A',
legend: '',
},
{
metric: {},
values: [
[1000, '100'],
[3000, '300'],
],
queryName: 'B',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
// All unique timestamps sorted
expect(result[0]).toEqual([1000, 2000, 3000]);
// First series: has value at 1000, 2000, missing at 3000
expect(result[1]).toEqual([10, 20, null]);
// Second series: has value at 1000, missing at 2000, has at 3000
expect(result[2]).toEqual([100, null, 300]);
});
test('should handle stacked bar chart', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '20'],
],
queryName: 'A',
legend: '',
},
{
metric: {},
values: [
[1000, '5'],
[2000, '10'],
],
queryName: 'B',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse, false, true);
expect(result[0]).toEqual([1000, 2000]); // timestamps
// Stacked: first series = its value + second series value
expect(result[1]).toEqual([15, 30]); // 10+5, 20+10
expect(result[2]).toEqual([5, 10]); // second series unchanged
});
test('should handle invalid values like +Inf', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [
[1000, '10'],
[2000, '+Inf'],
[3000, 'NaN'],
],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([1000, 2000, 3000]);
expect(result[1]).toEqual([10, null, null]); // Invalid values become null
});
test('should handle series with empty values array', () => {
const apiResponse = ({
data: {
result: [
{
metric: {},
values: [],
queryName: 'A',
legend: '',
},
],
resultType: 'matrix',
newResult: { data: { result: [] } },
},
} as unknown) as MetricRangePayloadProps;
const result = getUPlotChartData(apiResponse);
expect(result[0]).toEqual([]); // No timestamps
expect(result[1]).toEqual([]); // Empty values
});
});
describe('getUplotChartDataForAnomalyDetection', () => {
const createSeriesItem = (
labels: Record<string, string>,
values: Array<{ timestamp: number; value: string }>,
) => ({
labels,
labelsArray: Object.entries(labels).map(([k, v]) => ({ [k]: v })),
values,
});
const createAnomalyResponse = (
overrides: Partial<{
series: ReturnType<typeof createSeriesItem>[];
predictedSeries: ReturnType<typeof createSeriesItem>[];
upperBoundSeries: ReturnType<typeof createSeriesItem>[];
lowerBoundSeries: ReturnType<typeof createSeriesItem>[];
queryName: string;
legend: string;
}> = {},
): MetricRangePayloadProps =>
(({
data: {
newResult: {
data: {
result: [
{
series: overrides.series ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '10' },
{ timestamp: 2000000, value: '20' },
]),
],
predictedSeries: overrides.predictedSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '12' },
{ timestamp: 2000000, value: '22' },
]),
],
upperBoundSeries: overrides.upperBoundSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '15' },
{ timestamp: 2000000, value: '25' },
]),
],
lowerBoundSeries: overrides.lowerBoundSeries ?? [
createSeriesItem({ service: 'test-service' }, [
{ timestamp: 1000000, value: '8' },
{ timestamp: 2000000, value: '18' },
]),
],
queryName: overrides.queryName ?? 'A',
legend: overrides.legend ?? '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps);
test('should return empty object when anomalyDetectionData is undefined', () => {
const result = getUplotChartDataForAnomalyDetection(
{ data: { resultType: 'matrix' } } as MetricRangePayloadProps,
true,
);
expect(result).toEqual({});
});
test('should process anomaly detection data correctly', () => {
const apiResponse = createAnomalyResponse({});
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
expect(Object.keys(result)).toHaveLength(1);
const seriesKey = Object.keys(result)[0];
const seriesData = result[seriesKey];
// Data should have 5 arrays: timestamps, values, predicted, upperBound, lowerBound
expect(seriesData.data).toHaveLength(5);
// Timestamps (converted from ms to seconds)
expect(seriesData.data[0]).toEqual([1000, 2000]);
// Values (stored as strings from API)
expect(seriesData.data[1]).toEqual(['10', '20']);
// Predicted
expect(seriesData.data[2]).toEqual(['12', '22']);
// Upper bound
expect(seriesData.data[3]).toEqual(['15', '25']);
// Lower bound
expect(seriesData.data[4]).toEqual(['8', '18']);
expect(seriesData.color).toBeDefined();
expect(seriesData.legendLabel).toBeDefined();
});
test('should handle multiple queries with unique keys', () => {
const apiResponse = ({
data: {
newResult: {
data: {
result: [
{
series: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '10' },
]),
],
predictedSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '12' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '15' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'service-a' }, [
{ timestamp: 1000000, value: '8' },
]),
],
queryName: 'QueryA',
legend: '',
},
{
series: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '100' },
]),
],
predictedSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '120' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '150' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'service-b' }, [
{ timestamp: 1000000, value: '80' },
]),
],
queryName: 'QueryB',
legend: '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps;
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
// Should have 2 series with queryName prefix since multiple queries
expect(Object.keys(result)).toHaveLength(2);
expect(Object.keys(result).some((key) => key.startsWith('QueryA-'))).toBe(
true,
);
expect(Object.keys(result).some((key) => key.startsWith('QueryB-'))).toBe(
true,
);
});
// Issue #11022: Anomaly alert preview bug with empty prediction series
describe('Issue #11022 - Empty prediction series handling', () => {
test('should not crash when predictedSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
predictedSeries: [],
});
// This should not throw
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Should still have valid structure with null predicted values
expect(result[seriesKey].data).toHaveLength(5);
// Predicted values should be null when predictedSeries is empty
expect(result[seriesKey].data[2]).toEqual([null, null]);
});
test('should not crash when upperBoundSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
upperBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Upper bound values should be null when upperBoundSeries is empty
expect(result[seriesKey].data[3]).toEqual([null, null]);
});
test('should not crash when lowerBoundSeries is empty array', () => {
const apiResponse = createAnomalyResponse({
lowerBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Lower bound values should be null when lowerBoundSeries is empty
expect(result[seriesKey].data[4]).toEqual([null, null]);
});
test('should not crash when all bound series are empty arrays', () => {
const apiResponse = createAnomalyResponse({
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
});
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
const seriesKey = Object.keys(result)[0];
// Should have structure with values from series but null predictions/bounds
expect(result[seriesKey].data).toHaveLength(5);
expect(result[seriesKey].data[0]).toEqual([1000, 2000]); // timestamps from series
expect(result[seriesKey].data[1]).toEqual(['10', '20']); // values from series (strings from API)
expect(result[seriesKey].data[2]).toEqual([null, null]); // predicted null
expect(result[seriesKey].data[3]).toEqual([null, null]); // upper bound null
expect(result[seriesKey].data[4]).toEqual([null, null]); // lower bound null
});
test('should not crash when series exists but prediction arrays have fewer elements', () => {
const apiResponse = ({
data: {
newResult: {
data: {
result: [
{
series: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '10' },
{ timestamp: 2000000, value: '20' },
]),
createSeriesItem({ service: 'test2' }, [
{ timestamp: 1000000, value: '30' },
]),
],
// Only one element in prediction arrays, but series has two
predictedSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '12' },
]),
],
upperBoundSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '15' },
]),
],
lowerBoundSeries: [
createSeriesItem({ service: 'test' }, [
{ timestamp: 1000000, value: '8' },
]),
],
queryName: 'A',
legend: '',
},
],
},
},
resultType: 'matrix',
},
} as unknown) as MetricRangePayloadProps;
// This tests the case where series[1] exists but predictedSeries[1] is undefined
expect(() =>
getUplotChartDataForAnomalyDetection(apiResponse, true),
).not.toThrow();
const result = getUplotChartDataForAnomalyDetection(apiResponse, true);
// Should have 2 series processed
expect(Object.keys(result)).toHaveLength(2);
// The second series should have null values for predictions/bounds
const secondKey = Object.keys(result).find((k) =>
k.includes('test2'),
) as string;
expect(result[secondKey].data[2]).toEqual([null]); // predicted
expect(result[secondKey].data[3]).toEqual([null]); // upper bound
expect(result[secondKey].data[4]).toEqual([null]); // lower bound
});
});
});

View File

@@ -75,6 +75,11 @@ function SettingsPage(): JSX.Element {
}
if (isCloudUser) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
}));
if (isAdmin) {
updatedItems = updatedItems.map((item) => ({
...item,

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,10 @@ export const getRoutes = (
...createAlertChannels(t),
...editAlertChannels(t),
...keyboardShortcuts(t),
// Route is registered for everyone so direct-URL visitors see the
// in-page fallback. Sidebar visibility is still Cloud-only, gated in
// Settings.tsx.
...mcpServerSettings(t),
);
return settings;

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'],
};