Skip to content
Merged
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
6 changes: 6 additions & 0 deletions workspaces/lightspeed/.changeset/lemon-walls-fly.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions workspaces/lightspeed/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ site

# E2E test reports
e2e-test-report/

# Local SQLite database files
sqlite-data/
*.sqlite
12 changes: 12 additions & 0 deletions workspaces/lightspeed/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },
);
}),
];
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
};
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -75,7 +76,8 @@
"files": [
"dist",
"config.d.ts",
"app-config.yaml"
"app-config.yaml",
"migrations"
],
"configSchema": "config.d.ts",
"repository": {
Expand Down
43 changes: 43 additions & 0 deletions workspaces/lightspeed/plugins/lightspeed-backend/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,10 +20,52 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 18 additions & 6 deletions workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
}),
);
Expand Down
Loading
Loading