Compare commits

..

2 Commits

Author SHA1 Message Date
SagarRajput-7
d3d738f2ed fix(mcp-page): configure access and role control 2026-04-30 02:50:16 +05:30
SagarRajput-7
c75c15fbc1 fix(mcp-page): added acitve host url instead of current url on mcp page 2026-04-30 00:17:31 +05:30
7 changed files with 352 additions and 61 deletions

View File

@@ -198,7 +198,6 @@
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",
"@types/uuid": "^8.3.1",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"autoprefixer": "10.4.19",
"babel-plugin-styled-components": "^1.12.0",
"eslint-plugin-sonarjs": "4.0.2",
@@ -241,7 +240,7 @@
"*.(js|jsx|ts|tsx)": [
"oxlint --fix",
"oxfmt --write",
"tsgo --noEmit"
"sh scripts/typecheck-staged.sh"
],
"*.(scss|css)": [
"stylelint"
@@ -267,4 +266,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -0,0 +1,25 @@
files="";
# lint-staged will pass all files in $1 $2 $3 etc. iterate and concat.
for var in "$@"
do
files="$files \"$var\","
done
# create temporary tsconfig which includes only passed files
str="{
\"extends\": \"./tsconfig.json\",
\"include\": [ \"src/typings/**/*.ts\",\"src/**/*.d.ts\", \"./babel.config.js\", \"./jest.config.ts\", \"./.eslintrc.js\",\"./__mocks__\",\"./public\",\"./tests\",\"./commitlint.config.ts\",\"./webpack.config.js\",\"./webpack.config.prod.js\",\"./jest.setup.ts\",\"./**/*.d.ts\",$files]
}"
echo $str > tsconfig.tmp
# run typecheck using temp config
tsc -p ./tsconfig.tmp
# capture exit code of tsc
code=$?
# delete temp config
rm ./tsconfig.tmp
exit $code

View File

@@ -7,6 +7,7 @@ const mockOnCreateServiceAccount = jest.fn();
const defaultProps = {
instanceUrl: 'http://localhost',
isCloudUser: false,
onCopyInstanceUrl: mockOnCopyInstanceUrl,
onCreateServiceAccount: mockOnCreateServiceAccount,
};
@@ -67,4 +68,75 @@ describe('AuthCard', () => {
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
});
describe('cloud non-admin: instance URL unavailable', () => {
const cloudNonAdminProps = { ...defaultProps, isCloudUser: true };
it('shows an info banner instead of the URL', () => {
render(<AuthCard {...cloudNonAdminProps} isAdmin={false} />);
expect(
screen.getByTestId('mcp-instance-url-unavailable'),
).toBeInTheDocument();
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
});
it('does not render the copy button', () => {
render(<AuthCard {...cloudNonAdminProps} isAdmin={false} />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
});
it('shows URL normally for cloud admin', () => {
render(<AuthCard {...cloudNonAdminProps} isAdmin />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
expect(
screen.queryByTestId('mcp-instance-url-unavailable'),
).not.toBeInTheDocument();
});
it('shows URL normally for self-hosted non-admin (browser URL is correct)', () => {
render(<AuthCard {...defaultProps} isAdmin={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
expect(
screen.queryByTestId('mcp-instance-url-unavailable'),
).not.toBeInTheDocument();
});
});
describe('isLoadingInstanceUrl', () => {
it('shows a skeleton and hides the URL while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not render the copy button while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
});
it('shows the URL and copy button once loading is done', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
expect(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
).toBeInTheDocument();
});
});
});

View File

@@ -1,3 +1,4 @@
import { Skeleton } from 'antd';
import { Badge, Button } from '@signozhq/ui';
import { Info, KeyRound } from '@signozhq/icons';
import CopyIconButton from '../CopyIconButton';
@@ -6,17 +7,22 @@ import './AuthCard.styles.scss';
interface AuthCardProps {
isAdmin: boolean;
isCloudUser: boolean;
instanceUrl: string;
isLoadingInstanceUrl?: boolean;
onCopyInstanceUrl: () => void;
onCreateServiceAccount: () => void;
}
function AuthCard({
isAdmin,
isCloudUser,
instanceUrl,
isLoadingInstanceUrl = false,
onCopyInstanceUrl,
onCreateServiceAccount,
}: AuthCardProps): JSX.Element {
const showInstanceUrlUnavailable = isCloudUser && !isAdmin;
return (
<section className="mcp-auth-card">
<h3 className="mcp-auth-card__title">
@@ -32,13 +38,28 @@ function AuthCard({
<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>
{showInstanceUrlUnavailable ? (
<div className="mcp-auth-card__info-banner">
<Info size={14} />
<span
className="mcp-auth-card__helper-text"
data-testid="mcp-instance-url-unavailable"
>
Ask your workspace admin for the SigNoz instance URL.
</span>
</div>
) : isLoadingInstanceUrl ? (
<Skeleton.Input active size="small" />
) : (
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
disabled={isLoadingInstanceUrl}
/>
</div>
)}
</div>
<div className="mcp-auth-card__field">

View File

@@ -6,6 +6,8 @@ const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
const mockUseGetHosts = jest.fn();
const mockUseGetTenantLicense = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
@@ -19,6 +21,14 @@ jest.mock('api/generated/services/global', () => ({
mockUseGetGlobalConfig(...args),
}));
jest.mock('api/generated/services/zeus', () => ({
useGetHosts: (...args: unknown[]): unknown => mockUseGetHosts(...args),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): unknown => mockUseGetTenantLicense(),
}));
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
@@ -47,6 +57,23 @@ jest.mock('utils/basePath', () => ({
}));
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
const CUSTOM_HOST_URL = 'https://myteam.signoz.cloud';
const DEFAULT_HOST_URL = 'https://default.signoz.cloud';
function setupLicense({
isCloudUser = true,
isEnterpriseSelfHostedUser = false,
}: {
isCloudUser?: boolean;
isEnterpriseSelfHostedUser?: boolean;
} = {}): void {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser,
isCommunityUser: !isCloudUser && !isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser: false,
});
}
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
mockUseGetGlobalConfig.mockReturnValue({
@@ -55,7 +82,29 @@ function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
});
}
function setupHosts({
hosts = [],
isLoading = false,
isError = false,
}: {
hosts?: { name?: string; url?: string; is_default?: boolean }[];
isLoading?: boolean;
isError?: boolean;
} = {}): void {
mockUseGetHosts.mockReturnValue({
data: isLoading || isError ? undefined : { data: { hosts } },
isLoading,
isError,
});
}
describe('MCPServerSettings', () => {
beforeEach(() => {
// Default: cloud user, hosts loaded but empty → instanceUrl falls back to getBaseUrl()
setupLicense();
setupHosts();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -158,4 +207,154 @@ describe('MCPServerSettings', () => {
'Instance URL copied to clipboard',
);
});
describe('instance URL resolution', () => {
it('uses the active custom host URL when available', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [
{ name: 'default', url: DEFAULT_HOST_URL, is_default: true },
{ name: 'myteam', url: CUSTOM_HOST_URL, is_default: false },
],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
CUSTOM_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(CUSTOM_HOST_URL);
});
it('falls back to the default host URL when no custom host exists', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
DEFAULT_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(DEFAULT_HOST_URL);
});
it('falls back to browser URL when hosts request errors', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isError: true });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('shows URL skeleton while hosts are loading', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
render(<MCPServerSettings />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not copy while hosts are still loading', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('disables the hosts query for non-cloud deployments', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(false);
});
it('uses browser URL immediately for enterprise self-hosted (no skeleton)', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
setupHosts({ isLoading: false });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('disables the hosts query for cloud non-admin users', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(false);
});
it('shows "ask admin" banner for cloud non-admin instead of the URL', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
setupHosts({ isLoading: false });
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(
screen.getByTestId('mcp-instance-url-unavailable'),
).toBeInTheDocument();
});
it('enables the hosts query only for cloud admins', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(true);
});
});
});

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, 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 { useGetHosts } from 'api/generated/services/zeus';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
@@ -34,7 +36,23 @@ function MCPServerSettings(): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isAdmin = user.role === USER_ROLES.ADMIN;
const instanceUrl = getBaseUrl();
const { isCloudUser } = useGetTenantLicense();
const {
data: hostsData,
isLoading: isLoadingHosts,
isError: isHostsError,
} = useGetHosts({ query: { enabled: isCloudUser && isAdmin } });
const instanceUrl = useMemo(() => {
if (isLoadingHosts || isHostsError || !hostsData) {
return getBaseUrl();
}
const hosts = hostsData.data?.hosts ?? [];
const activeHost =
hosts.find((h) => !h.is_default) ?? hosts.find((h) => h.is_default);
return activeHost?.url ?? getBaseUrl();
}, [hostsData, isLoadingHosts, isHostsError]);
const { data: globalConfig, isLoading: isConfigLoading } =
useGetGlobalConfig();
@@ -70,10 +88,13 @@ function MCPServerSettings(): JSX.Element {
}, []);
const handleCopyInstanceUrl = useCallback(() => {
if (isLoadingHosts) {
return;
}
copyToClipboard(instanceUrl);
toast.success('Instance URL copied to clipboard');
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
}, [copyToClipboard, instanceUrl]);
}, [copyToClipboard, instanceUrl, isLoadingHosts]);
const handleDocsLinkClick = useCallback((target: string) => {
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
@@ -131,7 +152,9 @@ function MCPServerSettings(): JSX.Element {
<AuthCard
isAdmin={isAdmin}
isCloudUser={isCloudUser}
instanceUrl={instanceUrl}
isLoadingInstanceUrl={isLoadingHosts}
onCopyInstanceUrl={handleCopyInstanceUrl}
onCreateServiceAccount={handleCreateServiceAccount}
/>

View File

@@ -6697,54 +6697,6 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260421.2.tgz#8d2a1a9febf3759dcaddc6d5731874f4df01f003"
integrity sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==
"@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260421.2.tgz#1f878dedaf60505d7b67505dbe47b44503593e09"
integrity sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==
"@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260421.2.tgz#529465d6436de4ee0ed922d7de2e91fb605d7622"
integrity sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==
"@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260421.2.tgz#cb5abde3e2fed1ed1c1aff1f22338a4f4c1d4a7b"
integrity sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==
"@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260421.2.tgz#a8ca0d1ceb4659027878b915276ebcfa0ab93130"
integrity sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==
"@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260421.2.tgz#f1165ba01b22697729c16edd6ae4d1629318a7f4"
integrity sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==
"@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260421.2.tgz#01724cea15792e06e9543f1d698141b61fc229fc"
integrity sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==
"@typescript/native-preview@7.0.0-dev.20260421.2":
version "7.0.0-dev.20260421.2"
resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260421.2.tgz#5c22e3118533f394dc2cade1e290680f978ba7b7"
integrity sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==
optionalDependencies:
"@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260421.2"
"@typescript/native-preview-darwin-x64" "7.0.0-dev.20260421.2"
"@typescript/native-preview-linux-arm" "7.0.0-dev.20260421.2"
"@typescript/native-preview-linux-arm64" "7.0.0-dev.20260421.2"
"@typescript/native-preview-linux-x64" "7.0.0-dev.20260421.2"
"@typescript/native-preview-win32-arm64" "7.0.0-dev.20260421.2"
"@typescript/native-preview-win32-x64" "7.0.0-dev.20260421.2"
"@uiw/codemirror-extensions-basic-setup@4.23.10":
version "4.23.10"
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.10.tgz#e5d901e860a039ac61d955af26a12866e9dc356c"