diff --git a/workspaces/lightspeed/.changeset/lemon-walls-fly.md b/workspaces/lightspeed/.changeset/lemon-walls-fly.md new file mode 100644 index 0000000000..1e3d92a376 --- /dev/null +++ b/workspaces/lightspeed/.changeset/lemon-walls-fly.md @@ -0,0 +1,6 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor +'@red-hat-developer-hub/backstage-plugin-lightspeed-common': minor +--- + +Added MCP Server management backend APIs with per-user preferences, on-demand validation, and new permissions (lightspeed.mcp.read, lightspeed.mcp.manage) diff --git a/workspaces/lightspeed/.gitignore b/workspaces/lightspeed/.gitignore index 77ad56d128..7e61dcf7f4 100644 --- a/workspaces/lightspeed/.gitignore +++ b/workspaces/lightspeed/.gitignore @@ -52,3 +52,7 @@ site # E2E test reports e2e-test-report/ + +# Local SQLite database files +sqlite-data/ +*.sqlite diff --git a/workspaces/lightspeed/app-config.yaml b/workspaces/lightspeed/app-config.yaml index 99e2684f3f..3964465183 100644 --- a/workspaces/lightspeed/app-config.yaml +++ b/workspaces/lightspeed/app-config.yaml @@ -35,6 +35,18 @@ backend: database: client: better-sqlite3 connection: ':memory:' + # To persist the database to a file for local development, replace the above with: + # connection: + # directory: './sqlite-data' + # OpenShift / RHDH production — uses the PostgreSQL instance managed by the + # RHDH Helm chart or Operator. These env vars are injected from the + # PostgreSQL Secret into the Backstage container by the deployment config. + # client: pg + # connection: + # host: ${POSTGRES_HOST} + # port: ${POSTGRES_PORT} + # user: ${POSTGRES_USER} + # password: ${POSTGRES_PASSWORD} # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir integrations: diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts index e785a52d31..e07f70273b 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts @@ -237,6 +237,33 @@ export const lcsHandlers: HttpHandler[] = [ return HttpResponse.json(mockModelRes); }), + // LCS MCP server list — returns registered servers so the backend can + // resolve URLs for validation without requiring url in app-config. + http.get(`${LOCAL_LCS_ADDR}/v1/mcp-servers`, () => { + return HttpResponse.json({ + servers: [ + { + name: 'static-mcp', + url: 'https://mock-mcp-server:9999', + provider_id: 'model-context-protocol', + source: 'config', + }, + { + name: 'no-token-server', + url: 'https://mock-mcp-server:9999', + provider_id: 'model-context-protocol', + source: 'config', + }, + { + name: 'lcs-only-server', + url: 'https://mock-mcp-server:9999', + provider_id: 'model-context-protocol', + source: 'api', + }, + ], + }); + }), + // Catch-all handler for unknown paths http.all(`${LOCAL_LCS_ADDR}/*`, ({ request }) => { console.log(`Caught request to unknown path: ${request.url}`); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts new file mode 100644 index 0000000000..5e6810451c --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts @@ -0,0 +1,73 @@ +/* + * 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 { http, HttpResponse, type HttpHandler } from 'msw'; + +export const MOCK_MCP_ADDR = 'https://mock-mcp-server:9999'; +export const MOCK_MCP_VALID_TOKEN = 'valid-mcp-token'; + +const MOCK_TOOLS = [ + { name: 'create_issue', description: 'Create a GitHub issue' }, + { name: 'list_repos', description: 'List repositories' }, + { name: 'get_user', description: 'Get user profile' }, +]; + +export const mcpHandlers: HttpHandler[] = [ + http.post(MOCK_MCP_ADDR, async ({ request }) => { + const auth = request.headers.get('Authorization'); + if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await request.json()) as { method: string; id?: number }; + + if (body.method === 'initialize') { + return HttpResponse.json( + { + jsonrpc: '2.0', + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'mock-mcp-server', version: '1.0.0' }, + }, + id: body.id, + }, + { headers: { 'Mcp-Session-Id': 'mock-session-123' } }, + ); + } + + if (body.method === 'notifications/initialized') { + return new HttpResponse(null, { status: 204 }); + } + + if (body.method === 'tools/list') { + return HttpResponse.json({ + jsonrpc: '2.0', + result: { tools: MOCK_TOOLS }, + id: body.id, + }); + } + + return HttpResponse.json( + { + jsonrpc: '2.0', + error: { code: -32601, message: 'Method not found' }, + id: body.id, + }, + { status: 200 }, + ); + }), +]; diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts b/workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts index e3b77252e7..6e67804f33 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts @@ -36,15 +36,18 @@ export interface Config { */ mcpServers?: Array<{ /** - * The name of the mcp server. + * The name of the MCP server. Must match the name registered in LCS config. + * The URL is fetched from LCS (GET /v1/mcp-servers) at startup. * @visibility backend */ name: string; /** - * The access token for authenticating MCP server. + * The default access token for authenticating with this MCP server. + * Optional — if omitted, users must provide their own token via the UI. + * Users can also override this with a personal token via PATCH /mcp-servers/:name. * @visibility secret */ - token: string; + token?: string; }>; /** * Configuration for AI Notebooks (Developer Preview) diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js b/workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js new file mode 100644 index 0000000000..b0dc8f9909 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js @@ -0,0 +1,41 @@ +/* + * 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. + */ + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('lightspeed_mcp_user_settings', table => { + table.string('id').primary().notNullable(); + table.string('server_name').notNullable(); + table.string('user_entity_ref').notNullable(); + table.boolean('enabled').notNullable().defaultTo(true); + table.text('token'); // nullable — user override for admin default + table.string('status').notNullable().defaultTo('unknown'); + table.integer('tool_count').notNullable().defaultTo(0); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table.unique(['server_name', 'user_entity_ref']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTableIfExists('lightspeed_mcp_user_settings'); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/package.json b/workspaces/lightspeed/plugins/lightspeed-backend/package.json index 77db21c1f3..dd492920f8 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/package.json +++ b/workspaces/lightspeed/plugins/lightspeed-backend/package.json @@ -55,6 +55,7 @@ "htmlparser2": "^9.1.0", "http-proxy-middleware": "^3.0.2", "js-yaml": "^4.1.1", + "knex": "^3.1.0", "llama-stack-client": "^0.5.0", "multer": "^1.4.5-lts.1", "pdfjs-dist": "^4.10.38" @@ -75,7 +76,8 @@ "files": [ "dist", "config.d.ts", - "app-config.yaml" + "app-config.yaml", + "migrations" ], "configSchema": "config.d.ts", "repository": { diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/report.api.md b/workspaces/lightspeed/plugins/lightspeed-backend/report.api.md index ab01d149fa..c880589815 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/report.api.md +++ b/workspaces/lightspeed/plugins/lightspeed-backend/report.api.md @@ -6,6 +6,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; +import type { DatabaseService } from '@backstage/backend-plugin-api'; import express from 'express'; import type { HttpAuthService } from '@backstage/backend-plugin-api'; import type { LoggerService } from '@backstage/backend-plugin-api'; @@ -19,10 +20,52 @@ export function createRouter(options: RouterOptions): Promise; const lightspeedPlugin: BackendFeature; export default lightspeedPlugin; +// @public +export interface McpServerResponse { + // (undocumented) + enabled: boolean; + // (undocumented) + hasToken: boolean; + // (undocumented) + hasUserToken: boolean; + // (undocumented) + name: string; + // (undocumented) + status: McpServerStatus; + // (undocumented) + toolCount: number; + // (undocumented) + url?: string; +} + +// @public (undocumented) +export type McpServerStatus = 'connected' | 'error' | 'unknown'; + +// @public (undocumented) +export interface McpToolInfo { + // (undocumented) + description: string; + // (undocumented) + name: string; +} + +// @public (undocumented) +export interface McpValidationResult { + // (undocumented) + error?: string; + // (undocumented) + toolCount: number; + // (undocumented) + tools: McpToolInfo[]; + // (undocumented) + valid: boolean; +} + // @public export type RouterOptions = { logger: LoggerService; config: Config; + database: DatabaseService; httpAuth: HttpAuthService; userInfo: UserInfoService; permissions: PermissionsService; diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/database/migration.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/database/migration.ts new file mode 100644 index 0000000000..8c50b8f916 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/database/migration.ts @@ -0,0 +1,35 @@ +/* + * 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 { + DatabaseService, + resolvePackagePath, +} from '@backstage/backend-plugin-api'; + +const migrationsDir = resolvePackagePath( + '@red-hat-developer-hub/backstage-plugin-lightspeed-backend', + 'migrations', +); + +export async function migrate(databaseManager: DatabaseService) { + const knex = await databaseManager.getClient(); + + if (!databaseManager.migrations?.skip) { + await knex.migrate.latest({ + directory: migrationsDir, + }); + } +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts index 60117dd076..cc6802fec0 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts @@ -18,3 +18,9 @@ export { lightspeedPlugin as default } from './plugin'; export * from './service/router'; export type { RouterOptions } from './service/types'; +export type { + McpServerResponse, + McpServerStatus, + McpToolInfo, + McpValidationResult, +} from './service/mcp-server-types'; diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts index 3ea794aa2f..925601f871 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts @@ -19,6 +19,7 @@ import { createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { migrate } from './database/migration'; import { createNotebooksRouter } from './service/notebooks'; import { createRouter } from './service/router'; @@ -37,15 +38,26 @@ export const lightspeedPlugin = createBackendPlugin({ httpAuth: coreServices.httpAuth, userInfo: coreServices.userInfo, permissions: coreServices.permissions, + database: coreServices.database, }, - async init({ logger, config, http, httpAuth, userInfo, permissions }) { - // Main lightspeed router + async init({ + logger, + config, + http, + httpAuth, + userInfo, + permissions, + database, + }) { + await migrate(database); + http.use( await createRouter({ - config: config, - logger: logger, - httpAuth: httpAuth, - userInfo: userInfo, + config, + logger, + database, + httpAuth, + userInfo, permissions, }), ); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts new file mode 100644 index 0000000000..86774587b2 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts @@ -0,0 +1,119 @@ +/* + * 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 { Knex } from 'knex'; + +import { randomUUID } from 'node:crypto'; + +import { McpServerStatus, McpUserSettingsRow } from './mcp-server-types'; + +const TABLE = 'lightspeed_mcp_user_settings'; + +/** + * Stores per-user preferences for admin-configured MCP servers. + * + * Each row represents one user's settings for one static MCP server: + * enabled/disabled toggle, optional personal token override, and + * cached validation status. + */ +export class McpUserSettingsStore { + constructor(private readonly db: Knex) {} + + /** List all settings for a specific user. */ + async listByUser(userEntityRef: string): Promise { + return this.db(TABLE) + .where({ user_entity_ref: userEntityRef }) + .select('*'); + } + + /** Get settings for a specific server + user combination. */ + async get( + serverName: string, + userEntityRef: string, + ): Promise { + return this.db(TABLE) + .where({ server_name: serverName, user_entity_ref: userEntityRef }) + .first(); + } + + /** Create or update user settings for a server (atomic). */ + async upsert( + serverName: string, + userEntityRef: string, + updates: { enabled?: boolean; token?: string | null }, + ): Promise { + const now = new Date().toISOString(); + + const row: McpUserSettingsRow = { + id: randomUUID(), + server_name: serverName, + user_entity_ref: userEntityRef, + enabled: updates.enabled ?? true, + token: updates.token ?? null, + status: 'unknown', + tool_count: 0, + created_at: now, + updated_at: now, + }; + + const mergeFields: Partial = { updated_at: now }; + if (updates.enabled !== undefined) mergeFields.enabled = updates.enabled; + if (updates.token !== undefined) { + mergeFields.token = updates.token; + // Reset cached validation when token changes (new or cleared) + mergeFields.status = 'unknown'; + mergeFields.tool_count = 0; + } + + await this.db(TABLE) + .insert(row) + .onConflict(['server_name', 'user_entity_ref']) + .merge(mergeFields); + + const result = await this.get(serverName, userEntityRef); + if (!result) { + throw new Error( + `Failed to upsert settings for ${serverName}/${userEntityRef}`, + ); + } + return result; + } + + /** Update cached validation status for a user's server setting (atomic). */ + async updateStatus( + serverName: string, + userEntityRef: string, + status: McpServerStatus, + toolCount: number, + ): Promise { + const now = new Date().toISOString(); + + await this.db(TABLE) + .insert({ + id: randomUUID(), + server_name: serverName, + user_entity_ref: userEntityRef, + enabled: true, + token: null, + status, + tool_count: toolCount, + created_at: now, + updated_at: now, + }) + .onConflict(['server_name', 'user_entity_ref']) + .merge({ status, tool_count: toolCount, updated_at: now }); + } +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-types.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-types.ts new file mode 100644 index 0000000000..2097de494d --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-types.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +/** Database row for the lightspeed_mcp_user_settings table. */ +export interface McpUserSettingsRow { + id: string; + server_name: string; + user_entity_ref: string; + enabled: boolean; + token: string | null; + status: McpServerStatus; + tool_count: number; + created_at: string; + updated_at: string; +} + +/** + * @public + */ +export type McpServerStatus = 'connected' | 'error' | 'unknown'; + +/** + * Public-facing response for an MCP server with user settings merged. + * @public + */ +export interface McpServerResponse { + name: string; + url?: string; + enabled: boolean; + status: McpServerStatus; + toolCount: number; + hasToken: boolean; + hasUserToken: boolean; +} + +/** + * @public + */ +export interface McpToolInfo { + name: string; + description: string; +} + +/** + * @public + */ +export interface McpValidationResult { + valid: boolean; + toolCount: number; + tools: McpToolInfo[]; + error?: string; +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts new file mode 100644 index 0000000000..ea8a7e994c --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts @@ -0,0 +1,215 @@ +/* + * 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 type { LoggerService } from '@backstage/backend-plugin-api'; + +import { McpValidationResult } from './mcp-server-types'; + +const REQUEST_TIMEOUT_MS = 10_000; + +/** + * Validates MCP server credentials using the Streamable HTTP transport. + * + * The flow follows the MCP protocol: + * 1. POST initialize → server returns capabilities + session id + * 2. POST notifications/initialized + * 3. POST tools/list → server returns available tools + */ +export class McpServerValidator { + constructor(private readonly logger: LoggerService) {} + + async validate(url: string, token: string): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + Accept: 'application/json, text/event-stream', + }; + + try { + // Step 1: Initialize + const initResponse = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'lightspeed-backend', version: '1.0.0' }, + }, + id: 1, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (initResponse.status === 401 || initResponse.status === 403) { + return { + valid: false, + toolCount: 0, + tools: [], + error: 'Invalid credentials — server returned 401/403', + }; + } + + if (!initResponse.ok) { + return { + valid: false, + toolCount: 0, + tools: [], + error: `Server returned HTTP ${initResponse.status}`, + }; + } + + const sessionId = initResponse.headers.get('mcp-session-id'); + if (sessionId) { + headers['Mcp-Session-Id'] = sessionId; + } + + // Step 2: Send initialized notification + await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch((err: unknown) => { + this.logger.debug( + `MCP initialized notification failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + // Step 3: List tools + const toolsResponse = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + id: 2, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (toolsResponse.ok) { + const contentType = toolsResponse.headers.get('content-type') || ''; + + let rpcResult: + | { + tools?: Array<{ name: string; description?: string }>; + } + | undefined; + + if (contentType.includes('application/json')) { + const data = (await toolsResponse.json()) as { + result?: typeof rpcResult; + }; + rpcResult = data.result; + } else if (contentType.includes('text/event-stream')) { + rpcResult = await this.parseToolsFromSse(toolsResponse); + } + + if (rpcResult) { + const tools = rpcResult.tools ?? []; + return { + valid: true, + toolCount: tools.length, + tools: tools.map(t => ({ + name: t.name, + description: t.description ?? '', + })), + }; + } + + // Server responded with an unexpected content-type — still valid + return { valid: true, toolCount: 0, tools: [] }; + } + + // Initialize succeeded but tools/list failed — still consider connected + this.logger.warn( + `MCP server at ${url} accepted initialize but tools/list returned ${toolsResponse.status}`, + ); + 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', + }; + } + + this.logger.error(`MCP validation failed for ${url}: ${message}`); + return { + valid: false, + toolCount: 0, + tools: [], + error: message, + }; + } + } + + /** + * Parse a tools/list JSON-RPC result from an SSE (text/event-stream) response. + * MCP Streamable HTTP servers may return SSE instead of plain JSON. + * Each SSE event has the form: + * event: message + * data: {"jsonrpc":"2.0","result":{...},"id":2} + */ + private async parseToolsFromSse( + response: Response, + ): Promise< + { tools?: Array<{ name: string; description?: string }> } | undefined + > { + try { + const body = await response.text(); + + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + + const jsonStr = trimmed.slice('data:'.length).trim(); + if (!jsonStr) continue; + + try { + const parsed = JSON.parse(jsonStr) as { + result?: { + tools?: Array<{ name: string; description?: string }>; + }; + }; + if (parsed.result) { + return parsed.result; + } + } catch { + // not valid JSON — skip this data line + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to parse SSE tools/list response: ${msg}`); + } + return undefined; + } +} diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts new file mode 100644 index 0000000000..045d8de149 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts @@ -0,0 +1,474 @@ +/* + * 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 { type BackendFeature } from '@backstage/backend-plugin-api'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { setupServer } from 'msw/node'; +import request from 'supertest'; + +import { handlers } from '../../__fixtures__/handlers'; +import { lcsHandlers } from '../../__fixtures__/lcsHandlers'; +import { + mcpHandlers, + MOCK_MCP_ADDR, + MOCK_MCP_VALID_TOKEN, +} from '../../__fixtures__/mcpHandlers'; +import { lightspeedPlugin } from '../plugin'; + +const mockUserId = 'user:default/user1'; + +const BASE_CONFIG = { + lightspeed: { + servers: [ + { + id: 'test-server', + url: 'http://localhost:443/v1', + token: 'dummy-token', + }, + ], + }, +}; + +// URLs are not in app-config — they come from LCS (GET /v1/mcp-servers). +// The LCS mock in lcsHandlers returns URLs for 'static-mcp' and 'no-token-server'. +const MCP_CONFIG = { + lightspeed: { + ...BASE_CONFIG.lightspeed, + mcpServers: [ + { + name: 'static-mcp', + token: MOCK_MCP_VALID_TOKEN, + }, + ], + }, +}; + +const MCP_CONFIG_MULTI = { + lightspeed: { + ...BASE_CONFIG.lightspeed, + mcpServers: [ + { + name: 'static-mcp', + token: MOCK_MCP_VALID_TOKEN, + }, + { + name: 'no-token-server', + }, + ], + }, +}; + +jest.mock('@backstage/backend-plugin-api', () => ({ + ...jest.requireActual('@backstage/backend-plugin-api'), + UserInfoService: jest.fn().mockImplementation(() => ({ + getUserInfo: jest.fn().mockResolvedValue({ + BackstageUserInfo: { + userEntityRef: mockUserId, + }, + }), + })), +})); + +async function startBackendServer( + config?: Record, + authorizeResult?: AuthorizeResult.DENY | AuthorizeResult.ALLOW, +) { + const features: (BackendFeature | Promise<{ default: BackendFeature }>)[] = [ + lightspeedPlugin, + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { ...BASE_CONFIG, ...config }, + }), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(mockUserId), + }), + mockServices.permissions.mock({ + authorize: async () => [ + { result: authorizeResult ?? AuthorizeResult.ALLOW }, + ], + }).factory, + mockServices.userInfo.factory(), + ]; + return (await startTestBackend({ features })).server; +} + +describe('MCP server management endpoints', () => { + const server = setupServer(...handlers, ...lcsHandlers, ...mcpHandlers); + + beforeAll(() => { + server.listen({ + onUnhandledRequest: (req, print) => { + if (req.url.includes('/api/lightspeed')) { + return; + } + print.warning(); + }, + }); + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + server.resetHandlers(); + }); + + // ─── GET /mcp-servers ───────────────────────────────────────────── + + describe('GET /mcp-servers', () => { + it('returns empty list when no MCP servers configured', async () => { + const backendServer = await startBackendServer(); + const response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(response.status).toBe(200); + expect(response.body.servers).toEqual([]); + }); + + it('returns static servers from config with defaults', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(response.status).toBe(200); + expect(response.body.servers).toHaveLength(1); + expect(response.body.servers[0]).toMatchObject({ + name: 'static-mcp', + url: MOCK_MCP_ADDR, + enabled: true, + status: 'unknown', + toolCount: 0, + hasToken: true, + hasUserToken: false, + }); + }); + + it('shows hasToken false for servers without a token', async () => { + const backendServer = await startBackendServer(MCP_CONFIG_MULTI); + const response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(response.status).toBe(200); + expect(response.body.servers).toHaveLength(2); + + const noTokenServer = response.body.servers.find( + (s: any) => s.name === 'no-token-server', + ); + expect(noTokenServer.hasToken).toBe(false); + expect(noTokenServer.hasUserToken).toBe(false); + + const withTokenServer = response.body.servers.find( + (s: any) => s.name === 'static-mcp', + ); + expect(withTokenServer.hasToken).toBe(true); + expect(withTokenServer.hasUserToken).toBe(false); + }); + + it('distinguishes admin token from user token via hasUserToken', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + + // Before user sets a token: admin token exists, no user token + let response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + expect(response.body.servers[0].hasToken).toBe(true); + expect(response.body.servers[0].hasUserToken).toBe(false); + + // After user sets a personal token: both should be true + await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ token: 'my-personal-token' }); + + response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + expect(response.body.servers[0].hasToken).toBe(true); + expect(response.body.servers[0].hasUserToken).toBe(true); + }); + + it('reflects user settings after PATCH', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + + await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ enabled: false }); + + const response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(response.status).toBe(200); + expect(response.body.servers[0].enabled).toBe(false); + }); + + it('returns 403 when permission denied', async () => { + const backendServer = await startBackendServer( + MCP_CONFIG, + AuthorizeResult.DENY, + ); + const response = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(response.status).toBe(403); + }); + }); + + // ─── PATCH /mcp-servers/:name ─────────────────────────────────────── + + describe('PATCH /mcp-servers/:name', () => { + it('toggles enabled to false', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ enabled: false }); + + expect(patchRes.status).toBe(200); + expect(patchRes.body.server.enabled).toBe(false); + expect(patchRes.body.validation).toBeUndefined(); + }); + + it('toggles enabled back to true', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + + await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ enabled: false }); + + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ enabled: true }); + + expect(patchRes.status).toBe(200); + expect(patchRes.body.server.enabled).toBe(true); + }); + + it('updates token and validates successfully', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ token: MOCK_MCP_VALID_TOKEN }); + + expect(patchRes.status).toBe(200); + expect(patchRes.body.server.status).toBe('connected'); + expect(patchRes.body.server.hasToken).toBe(true); + expect(patchRes.body.server.hasUserToken).toBe(true); + expect(patchRes.body.validation).toBeDefined(); + expect(patchRes.body.validation.valid).toBe(true); + expect(patchRes.body.validation.toolCount).toBe(3); + }); + + it('reports validation failure for bad token', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ token: 'invalid-token' }); + + expect(patchRes.status).toBe(200); + expect(patchRes.body.server.status).toBe('error'); + expect(patchRes.body.validation.valid).toBe(false); + }); + + it('returns 404 for server not in config', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/nonexistent') + .send({ enabled: false }); + + expect(patchRes.status).toBe(404); + }); + + it('resets status when token is cleared', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + + // First set a valid token (triggers validation → connected) + await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ token: MOCK_MCP_VALID_TOKEN }); + + // Clear the token + const clearRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ token: null }); + + expect(clearRes.status).toBe(200); + expect(clearRes.body.server.status).toBe('unknown'); + expect(clearRes.body.server.toolCount).toBe(0); + expect(clearRes.body.server.hasUserToken).toBe(false); + }); + + it('returns 400 when no fields provided', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({}); + + expect(patchRes.status).toBe(400); + expect(patchRes.body.error).toContain('At least one of'); + }); + + it('returns 403 when permission denied', async () => { + const backendServer = await startBackendServer( + MCP_CONFIG, + AuthorizeResult.DENY, + ); + const patchRes = await request(backendServer) + .patch('/api/lightspeed/mcp-servers/static-mcp') + .send({ enabled: false }); + + expect(patchRes.status).toBe(403); + }); + }); + + // ─── POST /mcp-servers/validate (generic) ───────────────────────── + + describe('POST /mcp-servers/validate', () => { + it('validates valid credentials with LCS-known URL', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer) + .post('/api/lightspeed/mcp-servers/validate') + .send({ url: MOCK_MCP_ADDR, token: MOCK_MCP_VALID_TOKEN }); + + expect(response.status).toBe(200); + expect(response.body.valid).toBe(true); + expect(response.body.toolCount).toBe(3); + expect(response.body.tools).toHaveLength(3); + }); + + it('validates invalid credentials with LCS-known URL', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer) + .post('/api/lightspeed/mcp-servers/validate') + .send({ url: MOCK_MCP_ADDR, token: 'bad-token' }); + + expect(response.status).toBe(200); + expect(response.body.valid).toBe(false); + }); + + it('returns 400 when url or token missing', async () => { + const backendServer = await startBackendServer(); + const response = await request(backendServer) + .post('/api/lightspeed/mcp-servers/validate') + .send({ url: MOCK_MCP_ADDR }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('url and token are required'); + }); + + it('rejects unknown URL (SSRF protection)', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer) + .post('/api/lightspeed/mcp-servers/validate') + .send({ url: 'https://internal-service:1234', token: 'some-token' }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('URL not recognized'); + }); + }); + + // ─── POST /mcp-servers/:name/validate (on-demand) ────────────────── + + describe('POST /mcp-servers/:name/validate', () => { + it('validates using config token', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer).post( + '/api/lightspeed/mcp-servers/static-mcp/validate', + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + name: 'static-mcp', + status: 'connected', + toolCount: 3, + }); + expect(response.body.validation.valid).toBe(true); + }); + + it('validates using user override token', async () => { + const backendServer = await startBackendServer(MCP_CONFIG_MULTI); + + // Set a user token for the no-token-server + await request(backendServer) + .patch('/api/lightspeed/mcp-servers/no-token-server') + .send({ token: MOCK_MCP_VALID_TOKEN }); + + const response = await request(backendServer).post( + '/api/lightspeed/mcp-servers/no-token-server/validate', + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('connected'); + expect(response.body.toolCount).toBe(3); + }); + + it('persists status after validation', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + + await request(backendServer).post( + '/api/lightspeed/mcp-servers/static-mcp/validate', + ); + + const listRes = await request(backendServer).get( + '/api/lightspeed/mcp-servers', + ); + + expect(listRes.body.servers[0].status).toBe('connected'); + expect(listRes.body.servers[0].toolCount).toBe(3); + }); + + it('returns 404 for server not in config', async () => { + const backendServer = await startBackendServer(MCP_CONFIG); + const response = await request(backendServer).post( + '/api/lightspeed/mcp-servers/nonexistent/validate', + ); + + expect(response.status).toBe(404); + }); + + it('returns 400 when no token available', async () => { + const backendServer = await startBackendServer(MCP_CONFIG_MULTI); + const response = await request(backendServer).post( + '/api/lightspeed/mcp-servers/no-token-server/validate', + ); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('No token available'); + }); + + it('returns 403 when permission denied', async () => { + const backendServer = await startBackendServer( + MCP_CONFIG, + AuthorizeResult.DENY, + ); + const response = await request(backendServer).post( + '/api/lightspeed/mcp-servers/static-mcp/validate', + ); + + expect(response.status).toBe(403); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts index db138c0743..2af0b7a80f 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts @@ -25,12 +25,21 @@ import { lightspeedChatCreatePermission, lightspeedChatDeletePermission, lightspeedChatReadPermission, + lightspeedMcpManagePermission, + lightspeedMcpReadPermission, lightspeedPermissions, } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; import { Readable } from 'node:stream'; import { DEFAULT_LIGHTSPEED_SERVICE_PORT } from './constant'; +import { McpUserSettingsStore } from './mcp-server-store'; +import { + McpServerResponse, + McpServerStatus, + McpValidationResult, +} from './mcp-server-types'; +import { McpServerValidator } from './mcp-server-validator'; import { userPermissionAuthorization } from './permission'; import { DEFAULT_HISTORY_LENGTH, @@ -41,6 +50,45 @@ import { validateCompletionsRequest } from './validation'; const SKIP_USER_ID_ENDPOINTS = new Set(['/v1/models', '/v1/shields']); +interface StaticMcpServer { + name: string; + token?: string; +} + +/** + * Build MCP-HEADERS for LCS. Format matches the LCS "client" auth model: + * { "server-name": { "Authorization": "Bearer " } } + * + * For each admin-configured server, includes the user's override token if + * present in the DB, falling back to the admin default from app-config. + * Servers the user has disabled are excluded. + */ +async function buildMcpHeaders( + servers: StaticMcpServer[], + store: McpUserSettingsStore, + userEntityRef: string, +): Promise { + const headers: Record = {}; + const userSettings = await store.listByUser(userEntityRef); + const settingsMap = new Map(userSettings.map(s => [s.server_name, s])); + + for (const server of servers) { + const setting = settingsMap.get(server.name); + const enabled = setting ? Boolean(setting.enabled) : true; + if (!enabled) continue; + + // User's personal token (DB) takes precedence over admin default (app-config). + // If the user hasn't set one, falls back to the config token. + // If neither exists, the server is excluded from MCP-HEADERS. + const token = setting?.token || server.token; + if (token) { + headers[server.name] = { Authorization: `Bearer ${token}` }; + } + } + + return Object.keys(headers).length > 0 ? JSON.stringify(headers) : ''; +} + /** * @public * The lightspeed backend router @@ -48,7 +96,7 @@ const SKIP_USER_ID_ENDPOINTS = new Set(['/v1/models', '/v1/shields']); export async function createRouter( options: RouterOptions, ): Promise { - const { logger, config, httpAuth, userInfo, permissions } = options; + const { logger, config, database, httpAuth, userInfo, permissions } = options; const router = Router(); router.use(express.json()); @@ -58,18 +106,62 @@ export async function createRouter( DEFAULT_LIGHTSPEED_SERVICE_PORT; const system_prompt = config.getOptionalString('lightspeed.systemPrompt'); + // Parse admin-configured MCP servers from app-config. + // Only name is required; token is optional (users can provide their own via the UI). + // URLs come from LCS (GET /v1/mcp-servers), not from app-config. const mcpServersConfig = config.getOptionalConfigArray( 'lightspeed.mcpServers', ); - const mcpHeaders: Record = {}; + const staticServers: StaticMcpServer[] = []; if (mcpServersConfig) { for (const mcpServer of mcpServersConfig) { - const name = mcpServer.getString('name'); - const token = mcpServer.getString('token'); - mcpHeaders[name] = { Authorization: `Bearer ${token}` }; + staticServers.push({ + name: mcpServer.getString('name'), + token: mcpServer.getOptionalString('token'), + }); + } + } + + // Initialize database-backed store for per-user preferences and validator + const dbClient = await database.getClient(); + const settingsStore = new McpUserSettingsStore(dbClient); + const mcpValidator = new McpServerValidator(logger); + + // URL cache populated from LCS GET /v1/mcp-servers. + // The canonical URL for each MCP server lives in LCS config, not app-config. + const lcsUrlCache = new Map(); + + async function refreshLcsUrlCache(): Promise { + try { + const response = await fetch(`http://0.0.0.0:${port}/v1/mcp-servers`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + logger.warn( + `Failed to fetch MCP server URLs from LCS: HTTP ${response.status}`, + ); + return; + } + const data = (await response.json()) as { + servers: Array<{ name: string; url: string }>; + }; + for (const s of data.servers) { + lcsUrlCache.set(s.name, s.url); + } + logger.info(`Cached ${lcsUrlCache.size} MCP server URL(s) from LCS`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to fetch MCP server URLs from LCS: ${msg}`); } } + function resolveServerUrl(serverName: string): string | undefined { + return lcsUrlCache.get(serverName); + } + + // Best-effort URL cache on startup (non-blocking) + refreshLcsUrlCache().catch(() => {}); + router.get('/health', (_, response) => { response.json({ status: 'ok' }); }); @@ -81,7 +173,222 @@ export async function createRouter( const authorizer = userPermissionAuthorization(permissions); - // Middleware proxy to exclude rcs POST endpoints + // ─── MCP Server Management Endpoints ──────────────────────────────── + // All MCP servers are admin-configured (static). Users can view the + // list, toggle servers on/off, and provide personal access tokens. + + router.get('/mcp-servers', async (req, res) => { + try { + const credentials = await httpAuth.credentials(req); + await authorizer.authorizeUser(lightspeedMcpReadPermission, credentials); + const user = await userInfo.getUserInfo(credentials); + + const userSettings = await settingsStore.listByUser(user.userEntityRef); + const settingsMap = new Map(userSettings.map(s => [s.server_name, s])); + + const servers: McpServerResponse[] = staticServers.map(server => { + const setting = settingsMap.get(server.name); + return { + name: server.name, + url: resolveServerUrl(server.name), + enabled: setting ? Boolean(setting.enabled) : true, + status: setting?.status ?? 'unknown', + toolCount: setting?.tool_count ?? 0, + hasToken: !!(setting?.token || server.token), + hasUserToken: !!setting?.token, + }; + }); + + res.json({ servers }); + } catch (error) { + if (error instanceof NotAllowedError) { + res.status(403).json({ error: error.message }); + } else { + logger.error(`Error listing MCP servers: ${error}`); + res.status(500).json({ error: 'Failed to list MCP servers' }); + } + } + }); + + router.post('/mcp-servers/validate', async (req, res) => { + try { + const credentials = await httpAuth.credentials(req); + await authorizer.authorizeUser(lightspeedMcpReadPermission, credentials); + + const { url, token } = req.body; + if (!url || !token) { + res.status(400).json({ error: 'url and token are required' }); + return; + } + + // SSRF protection: only allow URLs registered in LCS + let knownUrls = new Set(lcsUrlCache.values()); + if (!knownUrls.has(url)) { + await refreshLcsUrlCache(); + knownUrls = new Set(lcsUrlCache.values()); + } + if (!knownUrls.has(url)) { + res.status(400).json({ + error: + 'URL not recognized — only MCP server URLs registered in LCS are allowed', + }); + return; + } + + const result = await mcpValidator.validate(url, token); + res.json(result); + } catch (error) { + if (error instanceof NotAllowedError) { + res.status(403).json({ error: error.message }); + } else { + logger.error(`Error validating MCP credentials: ${error}`); + res.status(500).json({ error: 'Validation failed' }); + } + } + }); + + router.post('/mcp-servers/:name/validate', async (req, res) => { + try { + const credentials = await httpAuth.credentials(req); + await authorizer.authorizeUser( + lightspeedMcpManagePermission, + credentials, + ); + const user = await userInfo.getUserInfo(credentials); + + const { name } = req.params; + const server = staticServers.find(s => s.name === name); + if (!server) { + res.status(404).json({ + error: `MCP server '${name}' is not configured — it must be defined in the Lightspeed Stack config and listed under lightspeed.mcpServers in app-config`, + }); + return; + } + // Resolve URL: LCS cache → fresh LCS fetch + let serverUrl = resolveServerUrl(server.name); + if (!serverUrl) { + await refreshLcsUrlCache(); + serverUrl = resolveServerUrl(server.name); + } + if (!serverUrl) { + res + .status(400) + .json({ error: 'Server has no URL — not found in LCS or config' }); + return; + } + + const setting = await settingsStore.get(name, user.userEntityRef); + const effectiveToken = setting?.token || server.token; + if (!effectiveToken) { + res + .status(400) + .json({ error: 'No token available — provide one first' }); + return; + } + + const validation = await mcpValidator.validate(serverUrl, effectiveToken); + const status: McpServerStatus = validation.valid ? 'connected' : 'error'; + + await settingsStore.updateStatus( + name, + user.userEntityRef, + status, + validation.toolCount, + ); + + res.json({ + name, + status, + toolCount: validation.toolCount, + validation, + }); + } catch (error) { + if (error instanceof NotAllowedError) { + res.status(403).json({ error: error.message }); + } else { + logger.error(`Error validating MCP server: ${error}`); + res.status(500).json({ error: 'Validation failed' }); + } + } + }); + + router.patch('/mcp-servers/:name', async (req, res) => { + try { + const credentials = await httpAuth.credentials(req); + await authorizer.authorizeUser( + lightspeedMcpManagePermission, + credentials, + ); + const user = await userInfo.getUserInfo(credentials); + + const { name } = req.params; + const server = staticServers.find(s => s.name === name); + if (!server) { + res.status(404).json({ + error: `MCP server '${name}' is not configured — it must be defined in the Lightspeed Stack config and listed under lightspeed.mcpServers in app-config`, + }); + return; + } + + const { enabled, token } = req.body; + if (enabled === undefined && token === undefined) { + res.status(400).json({ + error: 'At least one of enabled or token must be provided', + }); + return; + } + + const setting = await settingsStore.upsert(name, user.userEntityRef, { + enabled, + token, + }); + + let validation: McpValidationResult | undefined; + let serverUrl = resolveServerUrl(server.name); + if (token && !serverUrl) { + await refreshLcsUrlCache(); + serverUrl = resolveServerUrl(server.name); + } + if (token && serverUrl) { + validation = await mcpValidator.validate(serverUrl, token); + const newStatus: McpServerStatus = validation.valid + ? 'connected' + : 'error'; + await settingsStore.updateStatus( + name, + user.userEntityRef, + newStatus, + validation.toolCount, + ); + setting.status = newStatus; + setting.tool_count = validation.toolCount; + } + + const result: Record = { + server: { + name: server.name, + url: resolveServerUrl(server.name), + enabled: Boolean(setting.enabled), + status: setting.status, + toolCount: setting.tool_count, + hasToken: !!(setting.token || server.token), + hasUserToken: !!setting.token, + }, + }; + if (validation) result.validation = validation; + res.json(result); + } catch (error) { + if (error instanceof NotAllowedError) { + res.status(403).json({ error: error.message }); + } else { + logger.error(`Error updating MCP server settings: ${error}`); + res.status(500).json({ error: 'Failed to update MCP server settings' }); + } + } + }); + + // ─── Proxy Middleware (existing) ──────────────────────────────────── + router.use('/', async (req, res, next) => { const passthroughPaths = ['/v1/query', '/v1/feedback']; // Skip middleware for ai-notebooks routes and specific paths @@ -230,8 +537,14 @@ export async function createRouter( } const requestBody = JSON.stringify(request.body); - const mcpHeadersValue = - Object.keys(mcpHeaders).length > 0 ? JSON.stringify(mcpHeaders) : ''; + + // Build MCP headers from config servers + this user's preferences + const mcpHeadersValue = await buildMcpHeaders( + staticServers, + settingsStore, + user_id, + ); + const fetchResponse = await fetch( `http://0.0.0.0:${port}/v1/streaming_query?${userQueryParam}`, { diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts index db6ac334b0..f6f573ce5d 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts @@ -15,6 +15,7 @@ */ import type { + DatabaseService, HttpAuthService, LoggerService, PermissionsService, @@ -29,6 +30,7 @@ import type { Config } from '@backstage/config'; export type RouterOptions = { logger: LoggerService; config: Config; + database: DatabaseService; httpAuth: HttpAuthService; userInfo: UserInfoService; permissions: PermissionsService; diff --git a/workspaces/lightspeed/plugins/lightspeed-common/report.api.md b/workspaces/lightspeed/plugins/lightspeed-common/report.api.md index d2cb378c17..94e603a558 100644 --- a/workspaces/lightspeed/plugins/lightspeed-common/report.api.md +++ b/workspaces/lightspeed/plugins/lightspeed-common/report.api.md @@ -18,6 +18,12 @@ export const lightspeedChatReadPermission: BasicPermission; // @public export const lightspeedChatUpdatePermission: BasicPermission; +// @public +export const lightspeedMcpManagePermission: BasicPermission; + +// @public +export const lightspeedMcpReadPermission: BasicPermission; + // @public export const lightspeedNotebooksUsePermission: BasicPermission; diff --git a/workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts b/workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts index 5f2e922261..7820abc1c1 100644 --- a/workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts +++ b/workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts @@ -56,6 +56,26 @@ export const lightspeedChatUpdatePermission = createPermission({ }, }); +/** This permission is used to list configured MCP servers + * @public + */ +export const lightspeedMcpReadPermission = createPermission({ + name: 'lightspeed.mcp.read', + attributes: { + action: 'read', + }, +}); + +/** This permission is used to add, update, delete, and validate MCP servers + * @public + */ +export const lightspeedMcpManagePermission = createPermission({ + name: 'lightspeed.mcp.manage', + attributes: { + action: 'update', + }, +}); + /** This permission is used to access AI Notebooks features * @public */ @@ -76,5 +96,7 @@ export const lightspeedPermissions = [ lightspeedChatCreatePermission, lightspeedChatDeletePermission, lightspeedChatUpdatePermission, + lightspeedMcpReadPermission, + lightspeedMcpManagePermission, lightspeedNotebooksUsePermission, ]; diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index 4525ade753..9ca71bd44a 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -7103,98 +7103,98 @@ __metadata: languageName: node linkType: hard -"@napi-rs/canvas-android-arm64@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-android-arm64@npm:0.1.96" +"@napi-rs/canvas-android-arm64@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-android-arm64@npm:0.1.97" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@napi-rs/canvas-darwin-arm64@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-darwin-arm64@npm:0.1.96" +"@napi-rs/canvas-darwin-arm64@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-darwin-arm64@npm:0.1.97" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@napi-rs/canvas-darwin-x64@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-darwin-x64@npm:0.1.96" +"@napi-rs/canvas-darwin-x64@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-darwin-x64@npm:0.1.97" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.96" +"@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.97" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@napi-rs/canvas-linux-arm64-gnu@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:0.1.96" +"@napi-rs/canvas-linux-arm64-gnu@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:0.1.97" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-arm64-musl@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-arm64-musl@npm:0.1.96" +"@napi-rs/canvas-linux-arm64-musl@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-arm64-musl@npm:0.1.97" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.96" +"@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.97" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-x64-gnu@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-x64-gnu@npm:0.1.96" +"@napi-rs/canvas-linux-x64-gnu@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-x64-gnu@npm:0.1.97" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-x64-musl@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-linux-x64-musl@npm:0.1.96" +"@napi-rs/canvas-linux-x64-musl@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-linux-x64-musl@npm:0.1.97" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@napi-rs/canvas-win32-arm64-msvc@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-win32-arm64-msvc@npm:0.1.96" +"@napi-rs/canvas-win32-arm64-msvc@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-win32-arm64-msvc@npm:0.1.97" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@napi-rs/canvas-win32-x64-msvc@npm:0.1.96": - version: 0.1.96 - resolution: "@napi-rs/canvas-win32-x64-msvc@npm:0.1.96" +"@napi-rs/canvas-win32-x64-msvc@npm:0.1.97": + version: 0.1.97 + resolution: "@napi-rs/canvas-win32-x64-msvc@npm:0.1.97" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@napi-rs/canvas@npm:^0.1.65": - version: 0.1.96 - resolution: "@napi-rs/canvas@npm:0.1.96" - dependencies: - "@napi-rs/canvas-android-arm64": "npm:0.1.96" - "@napi-rs/canvas-darwin-arm64": "npm:0.1.96" - "@napi-rs/canvas-darwin-x64": "npm:0.1.96" - "@napi-rs/canvas-linux-arm-gnueabihf": "npm:0.1.96" - "@napi-rs/canvas-linux-arm64-gnu": "npm:0.1.96" - "@napi-rs/canvas-linux-arm64-musl": "npm:0.1.96" - "@napi-rs/canvas-linux-riscv64-gnu": "npm:0.1.96" - "@napi-rs/canvas-linux-x64-gnu": "npm:0.1.96" - "@napi-rs/canvas-linux-x64-musl": "npm:0.1.96" - "@napi-rs/canvas-win32-arm64-msvc": "npm:0.1.96" - "@napi-rs/canvas-win32-x64-msvc": "npm:0.1.96" + version: 0.1.97 + resolution: "@napi-rs/canvas@npm:0.1.97" + dependencies: + "@napi-rs/canvas-android-arm64": "npm:0.1.97" + "@napi-rs/canvas-darwin-arm64": "npm:0.1.97" + "@napi-rs/canvas-darwin-x64": "npm:0.1.97" + "@napi-rs/canvas-linux-arm-gnueabihf": "npm:0.1.97" + "@napi-rs/canvas-linux-arm64-gnu": "npm:0.1.97" + "@napi-rs/canvas-linux-arm64-musl": "npm:0.1.97" + "@napi-rs/canvas-linux-riscv64-gnu": "npm:0.1.97" + "@napi-rs/canvas-linux-x64-gnu": "npm:0.1.97" + "@napi-rs/canvas-linux-x64-musl": "npm:0.1.97" + "@napi-rs/canvas-win32-arm64-msvc": "npm:0.1.97" + "@napi-rs/canvas-win32-x64-msvc": "npm:0.1.97" dependenciesMeta: "@napi-rs/canvas-android-arm64": optional: true @@ -7218,7 +7218,7 @@ __metadata: optional: true "@napi-rs/canvas-win32-x64-msvc": optional: true - checksum: 10c0/edb4bd46be484d4d59ad060f338b06ac14c271aaac00a31538f45e2efddf7ffb41700b1fbc58d0a07851378821022d2d4fcb79c177a0948b88289b219724e646 + checksum: 10c0/e201c7c547a1d882becd41943c0e0a09f7cd9a1cd1bce0d2b1442d2a1032b44585b1687a2d44068974a35dbcae9a62f1ae1057d460fe8e9485c3b1b1ea19e891 languageName: node linkType: hard @@ -10529,6 +10529,7 @@ __metadata: htmlparser2: "npm:^9.1.0" http-proxy-middleware: "npm:^3.0.2" js-yaml: "npm:^4.1.1" + knex: "npm:^3.1.0" llama-stack-client: "npm:^0.5.0" msw: "npm:2.12.10" multer: "npm:^1.4.5-lts.1" @@ -23734,7 +23735,7 @@ __metadata: languageName: node linkType: hard -"knex@npm:3, knex@npm:3.1.0, knex@npm:^3.0.0": +"knex@npm:3, knex@npm:3.1.0, knex@npm:^3.0.0, knex@npm:^3.1.0": version: 3.1.0 resolution: "knex@npm:3.1.0" dependencies: