Skip to content

Commit 4d8c6c8

Browse files
authored
feat(lightspeed): add MCP servers settings panel (#2582)
* feat(lightspeed): add MCP servers settings panel * improved UI Signed-off-by: Yi Cai <yicai@redhat.com> * backend integration Signed-off-by: Yi Cai <yicai@redhat.com> * code improvement Signed-off-by: Yi Cai <yicai@redhat.com> * error messages improvements Signed-off-by: Yi Cai <yicai@redhat.com> * fixed failed test Signed-off-by: Yi Cai <yicai@redhat.com> * resovled qoto comments Signed-off-by: Yi Cai <yicai@redhat.com> * resolved failed e2e tests Signed-off-by: Yi Cai <yicai@redhat.com> * fixed failed ci check Signed-off-by: Yi Cai <yicai@redhat.com> * addressed review points Signed-off-by: Yi Cai <yicai@redhat.com> * prettier fix Signed-off-by: Yi Cai <yicai@redhat.com> * fixed failed tests Signed-off-by: Yi Cai <yicai@redhat.com> --------- Signed-off-by: Yi Cai <yicai@redhat.com>
1 parent 93e17f5 commit 4d8c6c8

File tree

10 files changed

+1058
-109
lines changed

10 files changed

+1058
-109
lines changed

workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ const MOCK_TOOLS = [
2828
export const mcpHandlers: HttpHandler[] = [
2929
http.post(MOCK_MCP_ADDR, async ({ request }) => {
3030
const auth = request.headers.get('Authorization');
31-
if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) {
31+
if (
32+
auth !== MOCK_MCP_VALID_TOKEN &&
33+
auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`
34+
) {
3235
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
3336
}
3437

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 Bearer on 401/403', async () => {
39+
const fetchMock = jest
40+
.fn()
41+
.mockResolvedValue(new Response(null, { status: 401 }));
42+
global.fetch = fetchMock;
43+
44+
const validator = new McpServerValidator(logger);
45+
const result = await validator.validate(url, 'raw-token');
46+
47+
expect(result).toMatchObject({
48+
valid: false,
49+
toolCount: 0,
50+
tools: [],
51+
error: 'Invalid credentials — server returned 401/403',
52+
});
53+
expect(fetchMock).toHaveBeenCalledTimes(2);
54+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
55+
Authorization: 'raw-token',
56+
});
57+
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
58+
Authorization: 'Bearer raw-token',
59+
});
60+
});
61+
62+
it('uses token as-is when it already has an auth scheme', async () => {
63+
const fetchMock = jest
64+
.fn()
65+
.mockResolvedValue(new Response(null, { status: 401 }));
66+
global.fetch = fetchMock;
67+
68+
const validator = new McpServerValidator(logger);
69+
const result = await validator.validate(url, 'Basic abc123');
70+
71+
expect(result.valid).toBe(false);
72+
expect(fetchMock).toHaveBeenCalledTimes(1);
73+
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
74+
Authorization: 'Basic abc123',
75+
});
76+
});
77+
});

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

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,82 @@ 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';
24+
25+
const getEndpointLabel = (targetUrl: string): string => {
26+
try {
27+
const parsed = new URL(targetUrl);
28+
return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
29+
} catch {
30+
return targetUrl;
31+
}
32+
};
33+
34+
const getNestedError = (error: unknown): Error | undefined => {
35+
if (
36+
error &&
37+
typeof error === 'object' &&
38+
'cause' in error &&
39+
(error as { cause?: unknown }).cause instanceof Error
40+
) {
41+
return (error as { cause: Error }).cause;
42+
}
43+
return undefined;
44+
};
45+
46+
const getNetworkErrorMessage = (url: string, error: unknown): string => {
47+
const endpoint = getEndpointLabel(url);
48+
const nestedError = getNestedError(error);
49+
const fullMessage = [
50+
error instanceof Error ? error.message : '',
51+
nestedError?.name ?? '',
52+
nestedError?.message ?? '',
53+
]
54+
.filter(Boolean)
55+
.join(' ')
56+
.toLowerCase();
57+
58+
if (
59+
fullMessage.includes('timeout') ||
60+
fullMessage.includes('aborterror') ||
61+
fullMessage.includes('aborted')
62+
) {
63+
return `Connection timed out while contacting ${endpoint}`;
64+
}
65+
if (
66+
fullMessage.includes('econnrefused') ||
67+
fullMessage.includes('connection refused')
68+
) {
69+
return `Connection refused by ${endpoint}`;
70+
}
71+
if (
72+
fullMessage.includes('enotfound') ||
73+
fullMessage.includes('getaddrinfo')
74+
) {
75+
return `Host not found for ${endpoint}`;
76+
}
77+
if (
78+
fullMessage.includes('econnreset') ||
79+
fullMessage.includes('socket hang up')
80+
) {
81+
return `Connection reset by ${endpoint}`;
82+
}
83+
if (
84+
fullMessage.includes('ehostunreach') ||
85+
fullMessage.includes('enetunreach')
86+
) {
87+
return `Host unreachable: ${endpoint}`;
88+
}
89+
if (fullMessage.includes('fetch failed')) {
90+
return `Unable to connect to ${endpoint}`;
91+
}
92+
93+
return (
94+
nestedError?.message ||
95+
(error instanceof Error ? error.message : String(error))
96+
);
97+
};
2298

2399
/**
24100
* Validates MCP server credentials using the Streamable HTTP transport.
@@ -32,13 +108,51 @@ export class McpServerValidator {
32108
constructor(private readonly logger: LoggerService) {}
33109

34110
async validate(url: string, token: string): Promise<McpValidationResult> {
35-
// Bearer prefix is required here because the validator hits the MCP server
36-
// directly (not through LCS). LCS handles its own auth scheme via
37-
// MCP-HEADERS (see buildMcpHeaders in router.ts), but direct MCP
38-
// Streamable HTTP endpoints expect standard Bearer authentication.
111+
const trimmedToken = token.trim();
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> {
39153
const headers: Record<string, string> = {
40154
'Content-Type': 'application/json',
41-
Authorization: `Bearer ${token}`,
155+
Authorization: authorizationHeader,
42156
Accept: 'application/json, text/event-stream',
43157
};
44158

@@ -65,7 +179,7 @@ export class McpServerValidator {
65179
valid: false,
66180
toolCount: 0,
67181
tools: [],
68-
error: 'Invalid credentials — server returned 401/403',
182+
error: INVALID_CREDENTIALS_ERROR,
69183
};
70184
}
71185

@@ -150,20 +264,7 @@ export class McpServerValidator {
150264
);
151265
return { valid: true, toolCount: 0, tools: [] };
152266
} catch (error: unknown) {
153-
const message = error instanceof Error ? error.message : String(error);
154-
155-
if (
156-
message.includes('TimeoutError') ||
157-
message.includes('AbortError') ||
158-
message.includes('abort')
159-
) {
160-
return {
161-
valid: false,
162-
toolCount: 0,
163-
tools: [],
164-
error: 'Connection timed out',
165-
};
166-
}
267+
const message = getNetworkErrorMessage(url, error);
167268

168269
this.logger.error(`MCP validation failed for ${url}: ${message}`);
169270
return {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ describe('MCP server management endpoints', () => {
400400
expect(response.body.error).toContain('url and token are required');
401401
});
402402

403-
it('sends Bearer prefix when validating directly against MCP server', async () => {
403+
it('sends raw token first when validating directly against MCP server', async () => {
404404
let capturedAuth = '';
405405
server.use(
406406
http.post(MOCK_MCP_ADDR, ({ request: req }) => {
@@ -422,7 +422,7 @@ describe('MCP server management endpoints', () => {
422422
.post('/api/lightspeed/mcp-servers/validate')
423423
.send({ url: MOCK_MCP_ADDR, token: 'my-raw-token' });
424424

425-
expect(capturedAuth).toBe('Bearer my-raw-token');
425+
expect(capturedAuth).toBe('my-raw-token');
426426
});
427427

428428
it('rejects unknown URL (SSRF protection)', async () => {

workspaces/lightspeed/plugins/lightspeed/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"@patternfly/chatbot": "6.5.0",
6969
"@patternfly/react-core": "6.4.1",
7070
"@patternfly/react-icons": "^6.3.1",
71+
"@patternfly/react-table": "^6.4.1",
7172
"@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^",
7273
"@red-hat-developer-hub/backstage-plugin-theme": "^0.12.0",
7374
"@tanstack/react-query": "^5.59.15",

0 commit comments

Comments
 (0)