Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ const MOCK_TOOLS = [
export const mcpHandlers: HttpHandler[] = [
http.post(MOCK_MCP_ADDR, async ({ request }) => {
const auth = request.headers.get('Authorization');
if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) {
if (
auth !== MOCK_MCP_VALID_TOKEN &&
auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`
) {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { McpServerValidator } from './mcp-server-validator';

describe('McpServerValidator auth header behavior', () => {
const url = 'https://mcp.example.com';
const childMock = jest.fn();
const logger: ConstructorParameters<typeof McpServerValidator>[0] = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: childMock,
};
childMock.mockImplementation(() => logger);

const originalFetch = global.fetch;

Check warning on line 31 in workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ1FYok3y7ka4KSVaZKU&open=AZ1FYok3y7ka4KSVaZKU&pullRequest=2582

afterEach(() => {
global.fetch = originalFetch;

Check warning on line 34 in workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ1FYok3y7ka4KSVaZKV&open=AZ1FYok3y7ka4KSVaZKV&pullRequest=2582
jest.clearAllMocks();
});

it('tries raw token first, then Bearer on 401/403', async () => {
const fetchMock = jest
.fn()
.mockResolvedValue(new Response(null, { status: 401 }));
global.fetch = fetchMock;

Check warning on line 42 in workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ1FYok3y7ka4KSVaZKW&open=AZ1FYok3y7ka4KSVaZKW&pullRequest=2582

const validator = new McpServerValidator(logger);
const result = await validator.validate(url, 'raw-token');

expect(result).toMatchObject({
valid: false,
toolCount: 0,
tools: [],
error: 'Invalid credentials — server returned 401/403',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'raw-token',
});
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer raw-token',
});
});

it('uses token as-is when it already has an auth scheme', async () => {
const fetchMock = jest
.fn()
.mockResolvedValue(new Response(null, { status: 401 }));
global.fetch = fetchMock;

Check warning on line 66 in workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ1FYok3y7ka4KSVaZKX&open=AZ1FYok3y7ka4KSVaZKX&pullRequest=2582

const validator = new McpServerValidator(logger);
const result = await validator.validate(url, 'Basic abc123');

expect(result.valid).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Basic abc123',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,82 @@
import { McpValidationResult } from './mcp-server-types';

const REQUEST_TIMEOUT_MS = 10_000;
const INVALID_CREDENTIALS_ERROR =
'Invalid credentials — server returned 401/403';

const getEndpointLabel = (targetUrl: string): string => {
try {
const parsed = new URL(targetUrl);
return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
} catch {
return targetUrl;
}
};

const getNestedError = (error: unknown): Error | undefined => {
if (
error &&
typeof error === 'object' &&
'cause' in error &&
(error as { cause?: unknown }).cause instanceof Error
) {
return (error as { cause: Error }).cause;
}
return undefined;
};

const getNetworkErrorMessage = (url: string, error: unknown): string => {
const endpoint = getEndpointLabel(url);
const nestedError = getNestedError(error);
const fullMessage = [
error instanceof Error ? error.message : '',
nestedError?.name ?? '',
nestedError?.message ?? '',
]
.filter(Boolean)
.join(' ')
.toLowerCase();

if (
fullMessage.includes('timeout') ||
fullMessage.includes('aborterror') ||
fullMessage.includes('aborted')
) {
return `Connection timed out while contacting ${endpoint}`;
}
if (
fullMessage.includes('econnrefused') ||
fullMessage.includes('connection refused')
) {
return `Connection refused by ${endpoint}`;
}
if (
fullMessage.includes('enotfound') ||
fullMessage.includes('getaddrinfo')
) {
return `Host not found for ${endpoint}`;
}
if (
fullMessage.includes('econnreset') ||
fullMessage.includes('socket hang up')
) {
return `Connection reset by ${endpoint}`;
}
if (
fullMessage.includes('ehostunreach') ||
fullMessage.includes('enetunreach')
) {
return `Host unreachable: ${endpoint}`;
}
if (fullMessage.includes('fetch failed')) {
return `Unable to connect to ${endpoint}`;
}

return (
nestedError?.message ||
(error instanceof Error ? error.message : String(error))

Check warning on line 95 in workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'error' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ1FM4s0gLsdgtcoVbOA&open=AZ1FM4s0gLsdgtcoVbOA&pullRequest=2582
);
};

/**
* Validates MCP server credentials using the Streamable HTTP transport.
Expand All @@ -32,13 +108,51 @@
constructor(private readonly logger: LoggerService) {}

async validate(url: string, token: string): Promise<McpValidationResult> {
// Bearer prefix is required here because the validator hits the MCP server
// directly (not through LCS). LCS handles its own auth scheme via
// MCP-HEADERS (see buildMcpHeaders in router.ts), but direct MCP
// Streamable HTTP endpoints expect standard Bearer authentication.
const trimmedToken = token.trim();
const hasAuthScheme = /^[A-Za-z][A-Za-z0-9_-]*\s+/.test(trimmedToken);
const authorizationHeaders = hasAuthScheme
? [trimmedToken]
: [trimmedToken, `Bearer ${trimmedToken}`];

let lastResult: McpValidationResult = {
valid: false,
toolCount: 0,
tools: [],
error: INVALID_CREDENTIALS_ERROR,
};

for (const [index, authorizationHeader] of authorizationHeaders.entries()) {
const result = await this.validateWithAuthorizationHeader(
url,
authorizationHeader,
);
lastResult = result;

const isLastAttempt = index === authorizationHeaders.length - 1;
const shouldRetryWithAlternativeAuth =
!isLastAttempt &&
!result.valid &&
result.error === INVALID_CREDENTIALS_ERROR;

if (!shouldRetryWithAlternativeAuth) {
return result;
}

this.logger.debug(
`MCP validation got 401/403 for ${url}; retrying with an alternate Authorization header format`,
);
}

return lastResult;
}

private async validateWithAuthorizationHeader(
url: string,
authorizationHeader: string,
): Promise<McpValidationResult> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Authorization: authorizationHeader,
Accept: 'application/json, text/event-stream',
Comment on lines +111 to 156

This comment was marked as resolved.

};

Expand All @@ -65,7 +179,7 @@
valid: false,
toolCount: 0,
tools: [],
error: 'Invalid credentials — server returned 401/403',
error: INVALID_CREDENTIALS_ERROR,
};
}

Expand Down Expand Up @@ -150,20 +264,7 @@
);
return { valid: true, toolCount: 0, tools: [] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);

if (
message.includes('TimeoutError') ||
message.includes('AbortError') ||
message.includes('abort')
) {
return {
valid: false,
toolCount: 0,
tools: [],
error: 'Connection timed out',
};
}
const message = getNetworkErrorMessage(url, error);

this.logger.error(`MCP validation failed for ${url}: ${message}`);
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ describe('MCP server management endpoints', () => {
expect(response.body.error).toContain('url and token are required');
});

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

expect(capturedAuth).toBe('Bearer my-raw-token');
expect(capturedAuth).toBe('my-raw-token');
});

it('rejects unknown URL (SSRF protection)', async () => {
Expand Down
1 change: 1 addition & 0 deletions workspaces/lightspeed/plugins/lightspeed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@patternfly/chatbot": "6.5.0",
"@patternfly/react-core": "6.4.1",
"@patternfly/react-icons": "^6.3.1",
"@patternfly/react-table": "^6.4.1",
"@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-theme": "^0.12.0",
"@tanstack/react-query": "^5.59.15",
Expand Down
Loading
Loading