Skip to content

Commit 50cbb36

Browse files
committed
resovled qoto comments
Signed-off-by: Yi Cai <yicai@redhat.com>
1 parent 561d00a commit 50cbb36

3 files changed

Lines changed: 191 additions & 24 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { McpServerValidator } from './mcp-server-validator';
18+
19+
describe('McpServerValidator auth header behavior', () => {
20+
const url = 'https://mcp.example.com';
21+
const childMock = jest.fn();
22+
const logger: ConstructorParameters<typeof McpServerValidator>[0] = {
23+
debug: jest.fn(),
24+
info: jest.fn(),
25+
warn: jest.fn(),
26+
error: jest.fn(),
27+
child: childMock,
28+
};
29+
childMock.mockImplementation(() => logger);
30+
31+
const originalFetch = global.fetch;
32+
33+
afterEach(() => {
34+
global.fetch = originalFetch;
35+
jest.clearAllMocks();
36+
});
37+
38+
it('tries raw token first, then falls back to Bearer on 401/403', async () => {
39+
const fetchMock = jest
40+
.fn()
41+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
42+
.mockResolvedValueOnce(
43+
new Response(
44+
JSON.stringify({
45+
jsonrpc: '2.0',
46+
result: { capabilities: { tools: {} } },
47+
id: 1,
48+
}),
49+
{
50+
status: 200,
51+
headers: { 'content-type': 'application/json' },
52+
},
53+
),
54+
)
55+
.mockResolvedValueOnce(new Response(null, { status: 204 }))
56+
.mockResolvedValueOnce(
57+
new Response(
58+
JSON.stringify({
59+
jsonrpc: '2.0',
60+
result: { tools: [{ name: 'tool-1' }] },
61+
id: 2,
62+
}),
63+
{
64+
status: 200,
65+
headers: { 'content-type': 'application/json' },
66+
},
67+
),
68+
);
69+
global.fetch = fetchMock;
70+
71+
const validator = new McpServerValidator(logger);
72+
const result = await validator.validate(url, 'raw-token');
73+
74+
expect(result.valid).toBe(true);
75+
expect(fetchMock).toHaveBeenCalledTimes(4);
76+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
77+
Authorization: 'raw-token',
78+
});
79+
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
80+
Authorization: 'Bearer raw-token',
81+
});
82+
});
83+
84+
it('does not rewrite tokens that already include an auth scheme', async () => {
85+
const fetchMock = jest
86+
.fn()
87+
.mockResolvedValue(new Response(null, { status: 401 }));
88+
global.fetch = fetchMock;
89+
90+
const validator = new McpServerValidator(logger);
91+
const result = await validator.validate(url, 'Basic abc123');
92+
93+
expect(result.valid).toBe(false);
94+
expect(fetchMock).toHaveBeenCalledTimes(1);
95+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
96+
Authorization: 'Basic abc123',
97+
});
98+
});
99+
});

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type { LoggerService } from '@backstage/backend-plugin-api';
1919
import { McpValidationResult } from './mcp-server-types';
2020

2121
const REQUEST_TIMEOUT_MS = 10_000;
22+
const INVALID_CREDENTIALS_ERROR =
23+
'Invalid credentials — server returned 401/403';
2224

2325
const getEndpointLabel = (targetUrl: string): string => {
2426
try {
@@ -107,9 +109,47 @@ export class McpServerValidator {
107109

108110
async validate(url: string, token: string): Promise<McpValidationResult> {
109111
const trimmedToken = token.trim();
110-
const authorizationHeader = /^Bearer\s+/i.test(trimmedToken)
111-
? trimmedToken
112-
: `Bearer ${trimmedToken}`;
112+
const hasAuthScheme = /^[A-Za-z][A-Za-z0-9_-]*\s+/.test(trimmedToken);
113+
const authorizationHeaders = hasAuthScheme
114+
? [trimmedToken]
115+
: [trimmedToken, `Bearer ${trimmedToken}`];
116+
117+
let lastResult: McpValidationResult = {
118+
valid: false,
119+
toolCount: 0,
120+
tools: [],
121+
error: INVALID_CREDENTIALS_ERROR,
122+
};
123+
124+
for (const [index, authorizationHeader] of authorizationHeaders.entries()) {
125+
const result = await this.validateWithAuthorizationHeader(
126+
url,
127+
authorizationHeader,
128+
);
129+
lastResult = result;
130+
131+
const isLastAttempt = index === authorizationHeaders.length - 1;
132+
const shouldRetryWithAlternativeAuth =
133+
!isLastAttempt &&
134+
!result.valid &&
135+
result.error === INVALID_CREDENTIALS_ERROR;
136+
137+
if (!shouldRetryWithAlternativeAuth) {
138+
return result;
139+
}
140+
141+
this.logger.debug(
142+
`MCP validation got 401/403 for ${url}; retrying with an alternate Authorization header format`,
143+
);
144+
}
145+
146+
return lastResult;
147+
}
148+
149+
private async validateWithAuthorizationHeader(
150+
url: string,
151+
authorizationHeader: string,
152+
): Promise<McpValidationResult> {
113153
const headers: Record<string, string> = {
114154
'Content-Type': 'application/json',
115155
Authorization: authorizationHeader,
@@ -139,7 +179,7 @@ export class McpServerValidator {
139179
valid: false,
140180
toolCount: 0,
141181
tools: [],
142-
error: 'Invalid credentials — server returned 401/403',
182+
error: INVALID_CREDENTIALS_ERROR,
143183
};
144184
}
145185

workspaces/lightspeed/plugins/lightspeed/src/components/McpServersSettings.tsx

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
1818

1919
import { configApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api';
20+
import { usePermission } from '@backstage/plugin-permission-react';
2021

2122
import { makeStyles } from '@material-ui/core';
2223
import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined';
@@ -33,6 +34,8 @@ import {
3334
} from '@patternfly/react-icons';
3435
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
3536

37+
import { lightspeedMcpManagePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common';
38+
3639
type ServerStatus = 'tokenRequired' | 'disabled' | 'ok' | 'failed' | 'unknown';
3740

3841
type McpServer = {
@@ -209,8 +212,8 @@ const getStatusIcon = (status: ServerStatus, className: string) => {
209212
};
210213

211214
const getDisplayStatus = (server: McpServer): ServerStatus => {
212-
if (!server.enabled) return 'disabled';
213215
if (!server.hasToken) return 'tokenRequired';
216+
if (!server.enabled) return 'disabled';
214217
if (server.status === 'error') return 'failed';
215218
if (server.status === 'connected') return 'ok';
216219
return 'unknown';
@@ -248,6 +251,10 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
248251
const classes = useStyles();
249252
const configApi = useApi(configApiRef);
250253
const fetchApi = useApi(fetchApiRef);
254+
const mcpManagePermission = usePermission({
255+
permission: lightspeedMcpManagePermission,
256+
});
257+
const canManageMcp = mcpManagePermission.allowed;
251258
const [servers, setServers] = useState<McpServer[]>([]);
252259
const [sortAsc, setSortAsc] = useState(true);
253260
const [isLoading, setIsLoading] = useState(true);
@@ -328,30 +335,32 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
328335
const uiServers = (data.servers ?? []).map(server => toUiServer(server));
329336
setServers(uiServers);
330337

331-
const serversToValidate = uiServers.filter(server => server.hasToken);
332-
void Promise.allSettled(
333-
serversToValidate.map(async server => {
334-
try {
335-
await validateServer(server.name);
336-
} catch (validationError) {
337-
setError(
338-
prev =>
339-
prev ??
340-
(validationError instanceof Error
341-
? validationError.message
342-
: `Failed to validate ${server.name}`),
343-
);
344-
}
345-
}),
346-
);
338+
if (canManageMcp) {
339+
const serversToValidate = uiServers.filter(server => server.hasToken);
340+
void Promise.allSettled(
341+
serversToValidate.map(async server => {
342+
try {
343+
await validateServer(server.name);
344+
} catch (validationError) {
345+
setError(
346+
prev =>
347+
prev ??
348+
(validationError instanceof Error
349+
? validationError.message
350+
: `Failed to validate ${server.name}`),
351+
);
352+
}
353+
}),
354+
);
355+
}
347356
} catch (e) {
348357
setError(
349358
e instanceof Error ? e.message : 'Failed to load MCP server settings',
350359
);
351360
} finally {
352361
setIsLoading(false);
353362
}
354-
}, [fetchJson, getBaseUrl, validateServer]);
363+
}, [canManageMcp, fetchJson, getBaseUrl, validateServer]);
355364

356365
useEffect(() => {
357366
loadServers();
@@ -362,6 +371,9 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
362371
serverName: string,
363372
body: { enabled?: boolean; token?: string | null },
364373
) => {
374+
if (!canManageMcp) {
375+
return;
376+
}
365377
setError(null);
366378
setIsSaving(prev => ({ ...prev, [serverName]: true }));
367379
try {
@@ -395,7 +407,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
395407
setIsSaving(prev => ({ ...prev, [serverName]: false }));
396408
}
397409
},
398-
[fetchJson, getBaseUrl, loadServers],
410+
[canManageMcp, fetchJson, getBaseUrl, loadServers],
399411
);
400412

401413
const selectedCount = useMemo(
@@ -449,6 +461,14 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
449461
className={classes.alert}
450462
/>
451463
)}
464+
{!mcpManagePermission.loading && !canManageMcp && (
465+
<Alert
466+
variant="info"
467+
isInline
468+
title="You have read-only access to MCP servers."
469+
className={classes.alert}
470+
/>
471+
)}
452472

453473
<Table
454474
variant="compact"
@@ -481,6 +501,11 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
481501
<Td colSpan={4}>Loading MCP servers...</Td>
482502
</Tr>
483503
)}
504+
{!isLoading && sortedServers.length === 0 && (
505+
<Tr>
506+
<Td colSpan={4}>No MCP servers available.</Td>
507+
</Tr>
508+
)}
484509
{sortedServers.map(server => {
485510
const displayStatus = getDisplayStatus(server);
486511
const displayDetail = getDisplayDetail(server, displayStatus);
@@ -508,7 +533,9 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
508533
id={`mcp-switch-${server.id}`}
509534
aria-label={`Toggle ${server.name}`}
510535
isChecked={isChecked}
511-
isDisabled={isUnavailable || isRowSaving}
536+
isDisabled={
537+
isUnavailable || isRowSaving || !canManageMcp
538+
}
512539
onChange={(_event, checked) => {
513540
patchServer(server.name, { enabled: checked });
514541
}}
@@ -557,6 +584,7 @@ export const McpServersSettings = ({ onClose }: McpServersSettingsProps) => {
557584
icon={<ModeEditOutlineOutlinedIcon fontSize="small" />}
558585
variant="plain"
559586
className={classes.actionButton}
587+
isDisabled={!canManageMcp}
560588
onClick={onEditClick}
561589
/>
562590
</Td>

0 commit comments

Comments
 (0)