mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-25 05:10:27 +01:00
Compare commits
1 Commits
chore/mino
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb10f51cc5 |
@@ -16,5 +16,6 @@
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
[data-slot='toggle-group'] {
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -228,7 +228,6 @@ function StatusCodeBarCharts({
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
canPinTooltip
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.mcp-copy-btn {
|
||||
&:hover {
|
||||
background-color: var(--l3-background-hover) !important;
|
||||
}
|
||||
}
|
||||
40
frontend/src/container/MCPServerSettings/CopyIconButton.tsx
Normal file
40
frontend/src/container/MCPServerSettings/CopyIconButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
144
frontend/src/container/MCPServerSettings/MCPServerSettings.tsx
Normal file
144
frontend/src/container/MCPServerSettings/MCPServerSettings.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
108
frontend/src/container/MCPServerSettings/clients.ts
Normal file
108
frontend/src/container/MCPServerSettings/clients.ts
Normal 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/`;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface GlobalConfigData {
|
||||
external_url: string;
|
||||
ingestion_url: string;
|
||||
}
|
||||
|
||||
export interface GlobalConfigDataProps {
|
||||
status: string;
|
||||
data: GlobalConfigData;
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user