Skip to content

Commit 5ad82d7

Browse files
authored
Update Lightspeed router for handling multiple MCP Servers (#2408)
Assisted-by: Claude Opus 4.5 Generated-by: Cursor Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
1 parent f36a43e commit 5ad82d7

4 files changed

Lines changed: 184 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
3+
---
4+
5+
Add support for multiple MCP servers with individual authentication headers

workspaces/lightspeed/app-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ auth:
7676
# signIn:
7777
# resolvers:
7878
# - resolver: usernameMatchingUserEntityName
79+
# # Since we do not have a User entity, for local development, uncomment the following line to allow sign-in without a user in the catalog
80+
# dangerouslyAllowSignInWithoutUserInCatalog: true
7981

8082
scaffolder: {}
8183
# see https://backstage.io/docs/features/software-templates/configuration for software template options

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,168 @@ describe('lightspeed router tests', () => {
459459
expect(receivedData).toEqual(expectedData);
460460
});
461461

462+
it('should send MCP headers for multiple MCP servers', async () => {
463+
let capturedMcpHeaders: string | null = null;
464+
465+
rcs.use(
466+
http.post(
467+
`${LOCAL_LCS_ADDR}/v1/streaming_query`,
468+
({ request: req }) => {
469+
capturedMcpHeaders = req.headers.get('MCP-HEADERS');
470+
const textEncoder = new TextEncoder();
471+
const mockData = [
472+
{
473+
choices: [{ delta: { content: 'Test' }, finish_reason: null }],
474+
},
475+
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
476+
];
477+
const stream = new ReadableStream({
478+
start(controller) {
479+
mockData.forEach((chunk: any) => {
480+
controller.enqueue(
481+
textEncoder.encode(`data: ${JSON.stringify(chunk)}\n\n`),
482+
);
483+
});
484+
controller.close();
485+
},
486+
});
487+
return new HttpResponse(stream, {
488+
headers: { 'Content-Type': 'text/plain' },
489+
});
490+
},
491+
),
492+
);
493+
494+
const backendServer = await startBackendServer({
495+
lightspeed: {
496+
...BASE_CONFIG.lightspeed,
497+
mcpServers: [
498+
{ name: 'mcp-server-1', token: 'token-1' },
499+
{ name: 'mcp-server-2', token: 'token-2' },
500+
],
501+
},
502+
});
503+
504+
const response = await request(backendServer)
505+
.post('/api/lightspeed/v1/query')
506+
.send({
507+
model: mockModel,
508+
query: 'Hello',
509+
provider: 'test-server',
510+
});
511+
512+
expect(response.statusCode).toEqual(200);
513+
expect(capturedMcpHeaders).not.toBeNull();
514+
515+
const parsedHeaders = JSON.parse(capturedMcpHeaders!);
516+
expect(parsedHeaders).toEqual({
517+
'mcp-server-1': { Authorization: 'Bearer token-1' },
518+
'mcp-server-2': { Authorization: 'Bearer token-2' },
519+
});
520+
});
521+
522+
it('should send empty MCP headers when no MCP servers configured', async () => {
523+
let capturedMcpHeaders: string | null = null;
524+
525+
rcs.use(
526+
http.post(
527+
`${LOCAL_LCS_ADDR}/v1/streaming_query`,
528+
({ request: req }) => {
529+
capturedMcpHeaders = req.headers.get('MCP-HEADERS');
530+
const textEncoder = new TextEncoder();
531+
const mockData = [
532+
{
533+
choices: [{ delta: { content: 'Test' }, finish_reason: null }],
534+
},
535+
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
536+
];
537+
const stream = new ReadableStream({
538+
start(controller) {
539+
mockData.forEach((chunk: any) => {
540+
controller.enqueue(
541+
textEncoder.encode(`data: ${JSON.stringify(chunk)}\n\n`),
542+
);
543+
});
544+
controller.close();
545+
},
546+
});
547+
return new HttpResponse(stream, {
548+
headers: { 'Content-Type': 'text/plain' },
549+
});
550+
},
551+
),
552+
);
553+
554+
const backendServer = await startBackendServer();
555+
556+
const response = await request(backendServer)
557+
.post('/api/lightspeed/v1/query')
558+
.send({
559+
model: mockModel,
560+
query: 'Hello',
561+
provider: 'test-server',
562+
});
563+
564+
expect(response.statusCode).toEqual(200);
565+
expect(capturedMcpHeaders).toBe('');
566+
});
567+
568+
it('should send MCP headers for single MCP server', async () => {
569+
let capturedMcpHeaders: string | null = null;
570+
571+
rcs.use(
572+
http.post(
573+
`${LOCAL_LCS_ADDR}/v1/streaming_query`,
574+
({ request: req }) => {
575+
capturedMcpHeaders = req.headers.get('MCP-HEADERS');
576+
const textEncoder = new TextEncoder();
577+
const mockData = [
578+
{
579+
choices: [{ delta: { content: 'Test' }, finish_reason: null }],
580+
},
581+
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
582+
];
583+
const stream = new ReadableStream({
584+
start(controller) {
585+
mockData.forEach((chunk: any) => {
586+
controller.enqueue(
587+
textEncoder.encode(`data: ${JSON.stringify(chunk)}\n\n`),
588+
);
589+
});
590+
controller.close();
591+
},
592+
});
593+
return new HttpResponse(stream, {
594+
headers: { 'Content-Type': 'text/plain' },
595+
});
596+
},
597+
),
598+
);
599+
600+
const backendServer = await startBackendServer({
601+
lightspeed: {
602+
...BASE_CONFIG.lightspeed,
603+
mcpServers: [{ name: 'single-mcp-server', token: 'single-token' }],
604+
},
605+
});
606+
607+
const response = await request(backendServer)
608+
.post('/api/lightspeed/v1/query')
609+
.send({
610+
model: mockModel,
611+
query: 'Hello',
612+
provider: 'test-server',
613+
});
614+
615+
expect(response.statusCode).toEqual(200);
616+
expect(capturedMcpHeaders).not.toBeNull();
617+
618+
const parsedHeaders = JSON.parse(capturedMcpHeaders!);
619+
expect(parsedHeaders).toEqual({
620+
'single-mcp-server': { Authorization: 'Bearer single-token' },
621+
});
622+
});
623+
462624
it('should fail with unauthorized error in chat completion API', async () => {
463625
const backendServer = await startBackendServer({}, AuthorizeResult.DENY);
464626
const chatCompletionResponse = await request(backendServer)

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,18 @@ export async function createRouter(
5454

5555
const port = config.getOptionalNumber('lightspeed.servicePort') ?? 8080;
5656
const system_prompt = config.getOptionalString('lightspeed.systemPrompt');
57-
// Only support one MCP server for now
58-
const mcpServerName = config
59-
.getOptionalConfigArray('lightspeed.mcpServers')?.[0]
60-
?.getString('name');
61-
const mcpToken = config
62-
.getOptionalConfigArray('lightspeed.mcpServers')?.[0]
63-
?.getString('token');
57+
58+
const mcpServersConfig = config.getOptionalConfigArray(
59+
'lightspeed.mcpServers',
60+
);
61+
const mcpHeaders: Record<string, { Authorization: string }> = {};
62+
if (mcpServersConfig) {
63+
for (const mcpServer of mcpServersConfig) {
64+
const name = mcpServer.getString('name');
65+
const token = mcpServer.getString('token');
66+
mcpHeaders[name] = { Authorization: `Bearer ${token}` };
67+
}
68+
}
6469

6570
router.get('/health', (_, response) => {
6671
response.json({ status: 'ok' });
@@ -217,16 +222,15 @@ export async function createRouter(
217222
}
218223

219224
const requestBody = JSON.stringify(request.body);
220-
const mcpHeaders = mcpToken
221-
? `{"${mcpServerName}": {"Authorization": "Bearer ${mcpToken}"}}`
222-
: '';
225+
const mcpHeadersValue =
226+
Object.keys(mcpHeaders).length > 0 ? JSON.stringify(mcpHeaders) : '';
223227
const fetchResponse = await fetch(
224228
`http://0.0.0.0:${port}/v1/streaming_query?${userQueryParam}`,
225229
{
226230
method: 'POST',
227231
headers: {
228232
'Content-Type': 'application/json',
229-
'MCP-HEADERS': mcpHeaders,
233+
'MCP-HEADERS': mcpHeadersValue,
230234
},
231235
body: requestBody,
232236
},

0 commit comments

Comments
 (0)