Skip to content

feat(lightspeed): [RHIDP-11659] Implementation of MCP Service Selection#2581

Merged
maysunfaisal merged 4 commits intoredhat-developer:mainfrom
maysunfaisal:RHIDP-11799-2
Mar 26, 2026
Merged

feat(lightspeed): [RHIDP-11659] Implementation of MCP Service Selection#2581
maysunfaisal merged 4 commits intoredhat-developer:mainfrom
maysunfaisal:RHIDP-11799-2

Conversation

@maysunfaisal
Copy link
Copy Markdown
Contributor

@maysunfaisal maysunfaisal commented Mar 20, 2026

Hey, I just made a Pull Request!

Fixes Issue

https://redhat.atlassian.net/browse/RHIDP-11659

Description

This PR implements the MCP Service Selection for Lightspeed Backend as the Figma Diagram outlined in https://redhat.atlassian.net/browse/DTUX-2411 and introduces backend API endpoints for MCP server management in the Lightspeed plugin. MCP servers are configured by admins in app-config.yaml; users can view, toggle, and provide personal access tokens for them.

Endpoints

GET /api/lightspeed/mcp-servers Returns the list of admin-configured MCP servers merged with the calling user's preferences (enabled/disabled state, validation status, tool count). Server URLs are resolved from LCS at startup via GET /v1/mcp-servers.

PATCH /api/lightspeed/mcp-servers/:name Allows users to toggle a server on/off (enabled) and/or provide a personal access token override (token). When a token is provided, the backend immediately validates it against the MCP server and returns the result.

POST /api/lightspeed/mcp-servers/:name/validate On-demand validation of a named MCP server. Performs an MCP protocol handshake (initialize + tools/list) using the effective token (user override > admin default) and persists the updated status and tool count.

POST /api/lightspeed/mcp-servers/validate Stateless credential test — accepts a url and token in the request body, validates connectivity without persisting anything. Intended for "test connection" flows before saving.

Key Design Decisions

  • Per-user preferences stored in lightspeed_mcp_user_settings table via Backstage's DatabaseService (SQLite locally, PostgreSQL on OpenShift)
  • Token precedence: user override (DB) > admin default (app-config.yaml) > skip server
  • URL resolution: fetched from LCS (GET /v1/mcp-servers) at startup and cached — not duplicated in app-config.yaml
  • Permissions: read endpoints require lightspeed.mcp.read, mutation endpoints require lightspeed.mcp.manage
  • Only enabled servers with available tokens are included in MCP-HEADERS sent to LCS streaming_query

✔️ Checklist

  • A changeset describing the change and affected packages. (more info)
  • Added or Updated documentation
  • Tests for new functionality and regression tests for bug fixes
  • Screenshots attached (for UI changes)

@maysunfaisal maysunfaisal changed the title feat(lightspeed): Implementation of MCP Service Selection feat(lightspeed): [RHIDP-11659] Implementation of MCP Service Selection Mar 20, 2026
@maysunfaisal maysunfaisal marked this pull request as ready for review March 23, 2026 20:51
@rhdh-gh-app
Copy link
Copy Markdown

rhdh-gh-app bot commented Mar 23, 2026

Important

This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/backstage-plugin-lightspeed-backend workspaces/lightspeed/plugins/lightspeed-backend minor v1.4.0
@red-hat-developer-hub/backstage-plugin-lightspeed-common workspaces/lightspeed/plugins/lightspeed-common minor v1.4.0

@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

Implement MCP Service Selection with per-user preferences and validation

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implement MCP Service Selection with per-user preferences and validation
• Add database-backed store for user MCP server settings and token management
• Create MCP server validator supporting JSON-RPC and SSE protocols
• Add REST endpoints for listing, validating, and managing MCP servers
• Introduce new permissions for MCP read and manage operations
Diagram
flowchart LR
  A["Admin Config<br/>mcpServers"] -->|name, token| B["Router<br/>MCP Endpoints"]
  C["LCS<br/>GET /v1/mcp-servers"] -->|server URLs| B
  B -->|GET /mcp-servers| D["User Preferences<br/>Database Store"]
  B -->|PATCH /mcp-servers/:name| D
  B -->|POST /mcp-servers/:name/validate| E["MCP Validator<br/>JSON-RPC + SSE"]
  E -->|initialize, tools/list| F["MCP Server<br/>HTTP Endpoint"]
  D -->|user tokens, enabled state| B
  B -->|MCP-HEADERS| G["LCS Query<br/>Streaming"]
Loading

Grey Divider

File Changes

1. workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts 🧪 Tests +27/-0

Add mock LCS MCP server list endpoint

workspaces/lightspeed/plugins/lightspeed-backend/fixtures/lcsHandlers.ts


2. workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts 🧪 Tests +73/-0

Create mock MCP server handlers for testing

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


3. workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts ⚙️ Configuration changes +6/-3

Update MCP server config schema with optional token

workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts


View more (13)
4. workspaces/lightspeed/plugins/lightspeed-backend/src/database/migration.ts ✨ Enhancement +35/-0

Add database migration runner for lightspeed plugin

workspaces/lightspeed/plugins/lightspeed-backend/src/database/migration.ts


5. workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts ✨ Enhancement +18/-5

Integrate database service and run migrations on init

workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts


6. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts ✨ Enhancement +118/-0

Implement database store for user MCP server preferences

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


7. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-types.ts ✨ Enhancement +52/-0

Define MCP server types and database row interfaces

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


8. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts ✨ Enhancement +212/-0

Implement MCP protocol validator with JSON-RPC and SSE support

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


9. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts 🧪 Tests +420/-0

Add comprehensive tests for MCP server management endpoints

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


10. workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts ✨ Enhancement +296/-9

Add MCP server management REST endpoints and LCS URL caching

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


11. workspaces/lightspeed/plugins/lightspeed-backend/src/service/types.ts ✨ Enhancement +2/-0

Add DatabaseService to RouterOptions type definition

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


12. workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts ✨ Enhancement +23/-0

Add MCP read and manage permissions to permission system

workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts


13. workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js ✨ Enhancement +41/-0

Create database migration for MCP user settings table

workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js


14. workspaces/lightspeed/app-config.yaml ⚙️ Configuration changes +12/-0

Add database configuration examples for local and production

workspaces/lightspeed/app-config.yaml


15. workspaces/lightspeed/plugins/lightspeed-backend/package.json Dependencies +4/-2

Add knex dependency and include migrations in package files

workspaces/lightspeed/plugins/lightspeed-backend/package.json


16. workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts Additional files +6/-0

...

workspaces/lightspeed/plugins/lightspeed-backend/src/index.ts


Grey Divider

Qodo Logo

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Mar 23, 2026

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (1) 📐 Spec deviations (0)

Grey Divider


Action required

1. MCP API missing endpoint input 📎 Requirement gap ✓ Correctness
Description
The new MCP server management flow does not accept an MCP server endpoint (and uses url resolved
from LCS instead), and it does not accept a token key as an input for MCP service selection. This
violates the requirement that the Lightspeed backend API supports MCP service selection with `server
name, endpoint, and token key` inputs.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R52-118]

+interface StaticMcpServer {
+  name: string;
+  token?: string;
+}
+
+/**
+ * Build MCP-HEADERS for LCS.  Format matches the LCS "client" auth model:
+ *   { "server-name": { "Authorization": "Bearer <token>" } }
+ *
+ * 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<string> {
+  const headers: Record<string, { Authorization: string }> = {};
+  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
 */
export async function createRouter(
  options: RouterOptions,
): Promise<express.Router> {
-  const { logger, config, httpAuth, userInfo, permissions } = options;
+  const { logger, config, database, httpAuth, userInfo, permissions } = options;

  const router = Router();
  router.use(express.json());

  const port = config.getOptionalNumber('lightspeed.servicePort') ?? 8080;
  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<string, { Authorization: string }> = {};
+  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'),
+      });
Evidence
Compliance ID 2 requires the backend API to accept MCP server name, endpoint, and token key.
In the new code, admin-configured servers only include name and optional token (no
endpoint/token-key), and endpoints are instead fetched from LCS (GET /v1/mcp-servers) rather than
accepted as an API input.

Lightspeed backend API supports MCP service selection with server name, endpoint, and token key
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[52-89]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[106-118]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[131-154]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The MCP service selection backend API does not accept the required inputs (`endpoint` and `token key`) and instead resolves `url` from LCS.

## Issue Context
Compliance requires the Lightspeed backend API to accept MCP server `name`, `endpoint`, and `token key` as inputs to drive MCP server selection for chat query requests.

## Fix Focus Areas
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[52-118]
- workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts[39-50]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. SSRF validate endpoint 🐞 Bug ⛨ Security
Description
POST /mcp-servers/validate accepts a user-provided URL and makes server-side fetch calls to it,
enabling SSRF to internal network resources. The backend also forwards the user-provided token as an
Authorization header to that URL.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R206-218]

+  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;
+      }
+
+      const result = await mcpValidator.validate(url, token);
+      res.json(result);
Evidence
The router exposes a generic validation endpoint that takes {url, token} directly from the request
body and passes them to the validator. The validator then performs multiple fetch(url, ...) requests
(initialize, initialized notification, tools/list) using the provided Authorization header, with no
allowlist or restriction to LCS-known MCP URLs.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[206-218]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts[34-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`POST /mcp-servers/validate` performs server-side `fetch` calls to an arbitrary, user-supplied `url`, enabling SSRF.

### Issue Context
The endpoint is guarded by `lightspeedMcpReadPermission`, but that still permits any authorized user to make the backend reach out to internal services.

### Fix Focus Areas
- Add strict URL validation/allowlisting (e.g., only URLs returned from LCS, or only https + allowed hostnames), and reject IP-literals/private ranges.
- Consider removing this endpoint entirely or changing it to validate by `serverName` only.
- Ensure error responses don’t leak internal network details.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[206-227]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts[34-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Read permission writes DB 🐞 Bug ⛨ Security
Description
POST /mcp-servers/:name/validate is authorized with lightspeedMcpReadPermission but writes
validation status/tool_count into lightspeed_mcp_user_settings. This allows users with read-only
permission to mutate persisted per-user state.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R229-273]

+  router.post('/mcp-servers/:name/validate', async (req, res) => {
+    try {
+      const credentials = await httpAuth.credentials(req);
+      await authorizer.authorizeUser(lightspeedMcpReadPermission, 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}' not found in configuration`,
+        });
+        return;
+      }
+      // Resolve URL: config override → 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,
+      );
Evidence
The endpoint authorizes with the read permission and then persists status and tool count through
settingsStore.updateStatus, which performs INSERT/UPDATE. This is a permission/side-effect mismatch
and contradicts the permission naming/docs in lightspeed-common (manage permission is described as
used for validate).

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[229-274]
workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts[59-77]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`POST /mcp-servers/:name/validate` uses `lightspeedMcpReadPermission` but persists cached validation results to the DB.

### Issue Context
Even though the data written is only status/tool_count, it is still a write side-effect and should be protected by the manage/update permission.

### Fix Focus Areas
- Change authorization for `POST /mcp-servers/:name/validate` to `lightspeedMcpManagePermission` (or stop persisting from this endpoint).
- Update tests accordingly.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[229-289]
- workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts[59-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Non-atomic upsert race 🐞 Bug ⛯ Reliability
Description
McpUserSettingsStore uses read-then-insert logic for upsert/updateStatus, which can violate the
unique(server_name,user_entity_ref) constraint under concurrent requests. This can cause
intermittent 500s on PATCH/validate endpoints.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[R58-87]

+    const existing = await this.get(serverName, userEntityRef);
+    const now = new Date().toISOString();
+
+    if (existing) {
+      const fields: Partial<McpUserSettingsRow> = { updated_at: now };
+      if (updates.enabled !== undefined) fields.enabled = updates.enabled;
+      if (updates.token !== undefined) {
+        fields.token = updates.token;
+        if (updates.token) fields.status = 'unknown';
+      }
+
+      await this.db(TABLE)
+        .where({ server_name: serverName, user_entity_ref: userEntityRef })
+        .update(fields);
+      return (await this.get(serverName, userEntityRef))!;
+    }
+
+    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,
+    };
+    await this.db(TABLE).insert(row);
+    return row;
Evidence
Both upsert and updateStatus first call get() then decide to insert if missing, but the table
enforces a uniqueness constraint on (server_name, user_entity_ref). Two concurrent requests can
observe no existing row and then both attempt insert, causing a constraint violation.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[53-88]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[91-116]
workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js[20-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The store uses read-then-insert, which is not safe under concurrency due to a unique constraint.

### Issue Context
The table has `unique(['server_name','user_entity_ref'])`, so inserts must be atomic.

### Fix Focus Areas
- Implement atomic upsert using Knex `.insert(...).onConflict(['server_name','user_entity_ref']).merge(...)` (Postgres/SQLite supported) for both `upsert` and `updateStatus`.
- Ensure status/tool_count updates are part of the same atomic statement.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[53-88]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[91-116]
- workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js[20-33]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. No timeout on LCS 🐞 Bug ⛯ Reliability
Description
refreshLcsUrlCache fetches /v1/mcp-servers without an explicit timeout, and
/mcp-servers/:name/validate may await it on a cache miss. This can stall requests when LCS is
slow/unreachable.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R131-149]

+  async function refreshLcsUrlCache(): Promise<void> {
+    try {
+      const response = await fetch(`http://0.0.0.0:${port}/v1/mcp-servers`);
+      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) {
+      logger.warn(`Failed to fetch MCP server URLs from LCS: ${error}`);
+    }
Evidence
The LCS URL refresh uses a plain fetch(...) without AbortSignal.timeout, and validate-on-demand
awaits it when the URL isn't cached. That makes the HTTP request lifecycle depend on an unbounded
network call.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[131-149]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[243-248]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
LCS URL refresh uses `fetch` without an explicit timeout and can block `/mcp-servers/:name/validate`.

### Issue Context
Only triggered when URL cache misses, but that includes startup races and new servers.

### Fix Focus Areas
- Add `signal: AbortSignal.timeout(<ms>)` to the LCS fetch.
- Optionally cache failures/backoff to avoid repeated slow calls.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[131-149]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[243-248]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. PATCH validation cache race 🐞 Bug ⛯ Reliability
Description
PATCH /mcp-servers/:name validates only if the LCS URL is already cached, but the cache refresh on
startup is non-blocking. This can cause token updates to skip validation intermittently, leaving
status/toolCount unchanged.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R322-337]

+      let validation: McpValidationResult | undefined;
+      const 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;
+      }
Evidence
The router triggers refreshLcsUrlCache() at startup without awaiting it, and PATCH only validates
when resolveServerUrl returns a value; it does not attempt a refresh on cache miss (unlike the
on-demand validate endpoint). This creates a race where immediate PATCH after startup may not
validate because the URL isn’t cached yet.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[156-158]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[322-337]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[243-248]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
PATCH token validation is skipped when the MCP server URL isn't already cached.

### Issue Context
Startup cache refresh is best-effort and non-blocking; PATCH may happen before cache is populated.

### Fix Focus Areas
- In PATCH, if `token` is provided and `serverUrl` is missing, call `await refreshLcsUrlCache()` and retry `resolveServerUrl` (same pattern as `/:name/validate`).
- Consider returning a clear response that validation was skipped due to missing URL.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[156-158]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[322-337]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Clearing token keeps status 🐞 Bug ✓ Correctness
Description
When a user clears their token (token: null/empty), McpUserSettingsStore.upsert does not reset
status/tool_count to unknown. GET /mcp-servers can continue to report a stale connected/error state
without credentials.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[R64-67]

+      if (updates.token !== undefined) {
+        fields.token = updates.token;
+        if (updates.token) fields.status = 'unknown';
+      }
Evidence
The store resets status only when updates.token is truthy; setting it to null/empty updates the
token but leaves prior status/tool_count intact. The listing endpoint surfaces setting.status and
setting.tool_count, so clients can observe stale connection state after token removal.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[61-67]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[183-193]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Clearing a token does not reset cached validation status/tool_count.

### Issue Context
Status/tool_count is shown to users via GET `/mcp-servers`.

### Fix Focus Areas
- In `upsert`, when `updates.token !== undefined` and the new token is null/empty, explicitly set `status = 'unknown'` and `tool_count = 0` (or similar).

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[53-88]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[174-204]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@maysunfaisal
Copy link
Copy Markdown
Contributor Author

maysunfaisal commented Mar 23, 2026

Lightspeed Stack Config (lightspeed-stack.yaml):

mcp_servers:
  - name: mcp-integration-tools
    provider_id: "model-context-protocol"
    url: "http://localhost:7008/api/mcp-actions/v1"
    authorization_headers:
      Authorization: "client"
  - name: test-mcp-server
    provider_id: "model-context-protocol"
    url: "http://localhost:8888/mcp"
    authorization_headers:
      Authorization: "client"

Backstage App Config (app-config.yaml, two corresponding MCP Servers, one without token and to be patched via API as an example)

backend:
  baseUrl: http://localhost:7007
  listen:
    port: 7007
  csp:
    connect-src: ["'self'", 'http:', 'https:']
  cors:
    origin: http://localhost:3000
    methods: [GET, HEAD, PATCH, POST, PUT, DELETE]
    credentials: true
  database:
    client: better-sqlite3
    connection:
      directory: './sqlite-data'

...

lightspeed:
  servicePort: 8080
  mcpServers:
    - name: test-mcp-server
      # token: ${MCP_TOKEN_1}
    - name: mcp-integration-tools
      token: ${MCP_TOKEN_2}

Lightspeed Backend APIs

List MCP Servers

Returns the MCP Servers configured with Llama Stack, shows the cached status to avoid latency or making network calls. At this stage, the MCP Server status is unknown and tool count is 0.

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "unknown",
      "toolCount": 0,
      "hasToken": false
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "unknown",
      "toolCount": 0,
      "hasToken": true
    }
  ]
}

Validate MCP Server on demand (for MCP Server with no token)

mfaisal-mac:lightspeed maysun$ curl -s -X POST http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server/validate \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "error": "No token available — provide one first"
}

Provide token to MCP Server via PATCH

MCP Server token gets updated along with status and tool count

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"token": "test-secret-token"}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true
  },
  "validation": {
    "valid": true,
    "toolCount": 4,
    "tools": [
      {
        "name": "echo",
        "description": "Echo back a message - useful for testing connectivity."
      },
      {
        "name": "get_current_time",
        "description": "Get the current server time."
      },
      {
        "name": "add_numbers",
        "description": "Add two numbers together."
      },
      {
        "name": "get_server_info",
        "description": "Get information about this MCP server."
      }
    ]
  }
}

List MCP Servers again

The MCP Server that was validated, has its status and tool count updated

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "connected",
      "toolCount": 4,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "unknown",
      "toolCount": 0,
      "hasToken": true
    }
  ]
}

Verify DB configured via Backstage

mfaisal-mac:lightspeed maysun$ sqlite3 packages/backend/sqlite-data/lightspeed.sqlite \
>   "SELECT server_name, enabled, token IS NOT NULL as has_token, status, tool_count FROM lightspeed_mcp_user_settings;"
test-mcp-server|1|1|connected|4

Validate the 2nd MCP Server

mfaisal-mac:lightspeed maysun$ curl -s -X POST http://localhost:7007/api/lightspeed/mcp-servers/mcp-integration-tools/validate \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "name": "mcp-integration-tools",
  "status": "connected",
  "toolCount": 7,
  "validation": {
    "valid": true,
    "toolCount": 7,
    "tools": [
      {
        "name": "fetch-template-metadata",
        "description": "Search and retrieve Software Template metadata from the Backstage catalog.\n\nThis tool retrieves Backstage Software Templates with their configuration details including parameters and steps. \nIt can be called without filters to list all templates, or filtered by name, title, or uid to find specific templates.\nFor additional information related to the fetched Software Template metadata, you can call an available Tech Docs retrieval tool to receive the documentation\nfor the Templates as well.\n\nReturns template-specific fields including:\n- Basic metadata (name, tags, labels, description, owner)\n- Template parameters (as JSON string)\n- Template steps/workflow (as JSON string)\n\nWhen to use this tool:\n- List all available Software Templates\n- Search for templates by name, title, or unique identifier\n- Get template configuration details (parameters, steps)\n"
      },
      {
        "name": "fetch-catalog-entities",
        "description": "Search and retrieve catalog entities from the Backstage server.\n\nList all Backstage entities such as Components, Systems, Resources, APIs, Locations, Users, and Groups. \nBy default, results are returned in JSON array format, where each entry in the JSON array is an entity with the following fields: 'name', 'description','type', 'owner', 'tags', 'dependsOn' and 'kind'.\nSetting 'verbose' to true will return the full Backstage entity objects, but should only be used if the reduced output is not sufficient, as this will significantly impact context usage (especially on smaller models).\nNote: 'type' can only be filtered on if a specified entity 'kind' is also specified.\n\nExample invocations and the output from those invocations:\n  # Find all Resources of type storage\n  fetch-catalog-entities kind:Resource type:storage\n  Output: {\n  \"entities\": [\n    {\n      \"name\": \"ibm-granite-s3-bucket\",\n      \"kind\": \"Resource\",\n      \"type\": \"storage\",\n      \"tags\": [\n        \"genai\",\n        \"ibm\",\n        \"llm\",\n        \"granite\",\n        \"conversational\",\n        \"task-text-generation\"\n      ]\n    }\n  ]\n\n\n"
      },
      {
        "name": "register-catalog-entities",
        "description": "Register entities defined remotely (create new Locations).\n\nThis tool is analogous to the \"register existing components\" function you see in the Backstage dashboard,\nwhere you supply a URL link that allows for the downloading of a 'catalog-info.yaml' file that \ndefines the various Entities (Components, Systems, Resources, APIs, Users, and Groups) you want to \ncreate or import into the Backstage catalog.\n\nThe action within the catalog results in the creation of a Location entity that is the parent of owner\nof all the additional entities defined in the 'catalog-info.yaml' file.\n\nThe dynamically generated, random UUID created to go along with the newly created Location entity is returned\nto the caller.  That UUID can be later used if you want to unregister or delete the Location entity and all\nof its children entities.\n\nExample invocation and the output from the invocation:\n  # Register a Location from a GitHub URL\n  register-catalog-entities locationURL: https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml\n  Output: {\n    \"locationID\": \"aaaa-bbbb-cccc-dddd\"\n  }      \n"
      },
      {
        "name": "unregister-catalog-entities",
        "description": "Unregister a Location and the entities it owns.\n\nThis tool is analogous to the \"unregister location\" function you see in the Backstage dashboard,\nwhere you supply the UUID for a Location entity link that allows for the removal of the Location and\nthe various Entities (Components, Systems, Resources, APIs, Users, and Groups) that were also created\nwhen the Location was imported. Alternatively, you can provide the URL used to register the location.\n\nExample invocation and the output from the invocation:\n  # Unregister a Location by ID\n  unregister-catalog-entities type: { locationId: \"aaa-bbb-ccc-ddd\" }\n  Output: {}\n\n  # Unregister a Location by URL\n  unregister-catalog-entities type: { locationUrl: \"https://github.com/redhat-ai-dev/model-catalog-example/blob/main/developer-model-service/catalog-info.yaml\" }\n  Output: {}\n"
      },
      {
        "name": "fetch-techdocs",
        "description": "Search and retrieve all TechDoc entities from the Backstage Server\n\n      List all Backstage entities with techdocs. Results are returned in JSON array format, where each\n      entry includes entity details and TechDocs metadata, like last update timestamp and build information.\n\n      Example invocations and the output from those invocations:\n        Output: {\n          \"entities\": [\n            {\n              \"name\": \"developer-model-service\",\n              \"title\": \"Developer Model Service\",\n              \"tags\": [\n                \"genai\",\n                \"ibm-granite\"\n              ],\n              \"description\": \"A description\",\n              \"owner\": \"user:default/exampleuser\",\n              \"lifecycle\": \"experimental\",\n              \"namespace\": \"default\",\n              \"kind\": \"Component\",\n              \"techDocsUrl\": \"https://backstage.example.com/docs/default/component/developer-model-service\",\n              \"metadataUrl\": \"https://backstage.example.com/api/techdocs/default/component/developer-model-service\",\n              \"metadata\": {\n                \"lastUpdated\": \"2024-01-15T10:30:00Z\",\n                \"buildTimestamp\": 1705313400,\n                \"siteName\": \"Developer Model Service Docs\",\n                \"siteDescription\": \"Documentation for the developer model service\"\n              }\n            }\n          ]\n        }\n      }\n"
      },
      {
        "name": "analyze-techdocs-coverage",
        "description": "Analyze documentation coverage across Backstage entities to understand what percentage of entities have TechDocs available.\n\n      It calculates the percentage of entities that have TechDocs configured, helping identify documentation gaps and improve overall documentation coverage.\n\n      Example output:\n      {\n        \"totalEntities\": 150,\n        \"entitiesWithDocs\": 95,\n        \"coveragePercentage\": 63.3\n      }\n\n      Supports filtering by entity type, namespace, owner, lifecycle, and tags to analyze coverage for specific subsets of entities."
      },
      {
        "name": "retrieve-techdocs-content",
        "description": "Retrieve the actual TechDocs content for a specific entity and optional page.\n\n      This tool allows AI clients to access documentation content for specific catalog entities.\n      You can retrieve the main documentation page or specific pages within the entity's documentation.\n\n      Example invocations and expected responses:\n        Input: {\n          \"entityRef\": \"component:default/developer-model-service\",\n          \"pagePath\": \"index.html\"\n        }\n\n        Output: {\n          \"entityRef\": \"component:default/developer-model-service\",\n          \"name\": \"developer-model-service\",\n          \"title\": \"Developer Model Service\",\n          \"kind\": \"component\",\n          \"namespace\": \"default\",\n          \"content\": \"Developer Model Service Documentation\n\nWelcome to the service...\",\n          \"pageTitle\": \"Developer Model Service Documentation\",\n          \"path\": \"index.html\",\n          \"contentType\": \"text\",\n          \"lastModified\": \"2024-01-15T10:30:00Z\",\n          \"metadata\": {\n            \"lastUpdated\": \"2024-01-15T10:30:00Z\",\n            \"buildTimestamp\": 1705313400,\n            \"siteName\": \"Developer Model Service Docs\"\n          }\n        }\n\n      Note: HTML files are automatically converted to plain text for better readability and AI processing.\n      Supports retrieving specific pages by providing pagePath parameter (e.g., \"api/endpoints.html\", \"guides/setup.md\")."
      }
    ]
  }
}

List now has all connected

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "connected",
      "toolCount": 4,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "connected",
      "toolCount": 7,
      "hasToken": true
    }
  ]
}

Show all DB columns

After Validation, DB has all the entries.

mfaisal-mac:lightspeed maysun$ sqlite3 -header packages/backend/sqlite-data/lightspeed.sqlite \
>   "SELECT * FROM lightspeed_mcp_user_settings;"
id|server_name|user_entity_ref|enabled|token|status|tool_count|created_at|updated_at
fd3e2c18-a3b3-4759-a707-5bbf6b7df930|test-mcp-server|user:default/maysunfaisal|1|test-secret-token|connected|4|2026-03-20T20:37:46.174Z|2026-03-20T20:37:46.184Z
cab74cd6-4d9a-43b0-9caf-2809b1318d21|mcp-integration-tools|user:default/maysunfaisal|1||connected|7|2026-03-20T20:42:52.866Z|2026-03-20T20:42:52.866Z

Toggle MCP Servers Off and On

Lets users enable/disable MCP Servers to be used

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"enabled": false}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": false,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true
  }
}
mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": false,
      "status": "connected",
      "toolCount": 4,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "connected",
      "toolCount": 7,
      "hasToken": true
    }
  ]
}
mfaisal-mac:lightspeed maysun$ 
mfaisal-mac:lightspeed maysun$ 
mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"enabled": true}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true
  }
}
mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "connected",
      "toolCount": 4,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "connected",
      "toolCount": 7,
      "hasToken": true
    }
  ]
}

Comment on lines +206 to +218
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;
}

const result = await mcpValidator.validate(url, token);
res.json(result);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Ssrf validate endpoint 🐞 Bug ⛨ Security

POST /mcp-servers/validate accepts a user-provided URL and makes server-side fetch calls to it,
enabling SSRF to internal network resources. The backend also forwards the user-provided token as an
Authorization header to that URL.
Agent Prompt
### Issue description
`POST /mcp-servers/validate` performs server-side `fetch` calls to an arbitrary, user-supplied `url`, enabling SSRF.

### Issue Context
The endpoint is guarded by `lightspeedMcpReadPermission`, but that still permits any authorized user to make the backend reach out to internal services.

### Fix Focus Areas
- Add strict URL validation/allowlisting (e.g., only URLs returned from LCS, or only https + allowed hostnames), and reject IP-literals/private ranges.
- Consider removing this endpoint entirely or changing it to validate by `serverName` only.
- Ensure error responses don’t leak internal network details.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[206-227]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts[34-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +229 to +273
router.post('/mcp-servers/:name/validate', async (req, res) => {
try {
const credentials = await httpAuth.credentials(req);
await authorizer.authorizeUser(lightspeedMcpReadPermission, 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}' not found in configuration`,
});
return;
}
// Resolve URL: config override → 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,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Read permission writes db 🐞 Bug ⛨ Security

POST /mcp-servers/:name/validate is authorized with lightspeedMcpReadPermission but writes
validation status/tool_count into lightspeed_mcp_user_settings. This allows users with read-only
permission to mutate persisted per-user state.
Agent Prompt
### Issue description
`POST /mcp-servers/:name/validate` uses `lightspeedMcpReadPermission` but persists cached validation results to the DB.

### Issue Context
Even though the data written is only status/tool_count, it is still a write side-effect and should be protected by the manage/update permission.

### Fix Focus Areas
- Change authorization for `POST /mcp-servers/:name/validate` to `lightspeedMcpManagePermission` (or stop persisting from this endpoint).
- Update tests accordingly.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[229-289]
- workspaces/lightspeed/plugins/lightspeed-common/src/permissions.ts[59-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +58 to +87
const existing = await this.get(serverName, userEntityRef);
const now = new Date().toISOString();

if (existing) {
const fields: Partial<McpUserSettingsRow> = { updated_at: now };
if (updates.enabled !== undefined) fields.enabled = updates.enabled;
if (updates.token !== undefined) {
fields.token = updates.token;
if (updates.token) fields.status = 'unknown';
}

await this.db(TABLE)
.where({ server_name: serverName, user_entity_ref: userEntityRef })
.update(fields);
return (await this.get(serverName, userEntityRef))!;
}

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,
};
await this.db(TABLE).insert(row);
return row;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Non-atomic upsert race 🐞 Bug ⛯ Reliability

McpUserSettingsStore uses read-then-insert logic for upsert/updateStatus, which can violate the
unique(server_name,user_entity_ref) constraint under concurrent requests. This can cause
intermittent 500s on PATCH/validate endpoints.
Agent Prompt
### Issue description
The store uses read-then-insert, which is not safe under concurrency due to a unique constraint.

### Issue Context
The table has `unique(['server_name','user_entity_ref'])`, so inserts must be atomic.

### Fix Focus Areas
- Implement atomic upsert using Knex `.insert(...).onConflict(['server_name','user_entity_ref']).merge(...)` (Postgres/SQLite supported) for both `upsert` and `updateStatus`.
- Ensure status/tool_count updates are part of the same atomic statement.

- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[53-88]
- workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[91-116]
- workspaces/lightspeed/plugins/lightspeed-backend/migrations/20260302120000_add_mcp_servers.js[20-33]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member

@ciiay ciiay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @maysunfaisal , thanks for the detailed walkthrough and curl examples. The happy path looks solid from frontend integration perspective. I just have a concern about the status mapping. We currently need to derive UI states like “Token required”, “Failed”, “Disabled”, “Ready/Connected” from:

  • status (connected | error | unknown)
  • enabled
  • hasToken

I see the mapping table in your document, but I still have questions regarding priority/order when multiple conditions are meet:

  • If enabled: false and hasToken: false, should UI show Disabled or Token Required?
  • If status: error and enabled: false, which one wins?
  • Should unknown be shown only during active validation, or also for untouched/new rows?

Looping in @aprilma419 as well for UX alignment on edge-case state priority.

if (updates.enabled !== undefined) fields.enabled = updates.enabled;
if (updates.token !== undefined) {
fields.token = updates.token;
if (updates.token) fields.status = 'unknown';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status is only reset when updates.token is truthy. If token is cleared (null/empty), previous status and tool_count remain. In PATCH /mcp-servers/:name, validation only runs when token && serverUrl, so a cleared token can leave server state like status: connected while hasToken: false (inconsistent UI behavior).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority order: Disabled > Token Required > Error > Connected > Unknown

  • If enabled: false and hasToken: false -> UI shows Disabled (enabled takes precedence)
  • If status: error and enabled: false -> UI shows Disabled
  • unknown is the default state for untouched/new rows and also appears after a token is cleared.. Meaning no validation has been performed yet..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been addressed in the latest commit where the upsert() method now resets status='unknown' and tool_count=0 whenever the token field is updated, whether setting a new token or clearing it (null). I also updated the tests for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq '.servers[] | select(.name == "test-mcp-server")'
{
  "name": "test-mcp-server",
  "url": "http://localhost:8888/mcp",
  "enabled": true,
  "status": "unknown",
  "toolCount": 0,
  "hasToken": false
}

Patch Token for missing Token

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"token": "test-secret-token"}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true
  },
  "validation": {
    "valid": true,
    "toolCount": 4,
    "tools": [
      {
        "name": "echo",
        "description": "Echo back a message - useful for testing connectivity."
      },
      {
        "name": "get_current_time",
        "description": "Get the current server time."
      },
      {
        "name": "add_numbers",
        "description": "Add two numbers together."
      },
      {
        "name": "get_server_info",
        "description": "Get information about this MCP server."
      }
    ]
  }
}

List again

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq '.servers[] | select(.name == "test-mcp-server")'
{
  "name": "test-mcp-server",
  "url": "http://localhost:8888/mcp",
  "enabled": true,
  "status": "connected",
  "toolCount": 4,
  "hasToken": true
}

Clear token/ update to null

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"token": null}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "unknown",
    "toolCount": 0,
    "hasToken": false
  }
}

List again

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq '.servers[] | select(.name == "test-mcp-server")'
{
  "name": "test-mcp-server",
  "url": "http://localhost:8888/mcp",
  "enabled": true,
  "status": "unknown",
  "toolCount": 0,
  "hasToken": false
}

Patch Token again and verify DB

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -H "Content-Type: application/json" \
>   -d '{"token": "test-secret-token"}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true
  },
  "validation": {
    "valid": true,
    "toolCount": 4,
    "tools": [
      {
        "name": "echo",
        "description": "Echo back a message - useful for testing connectivity."
      },
      {
        "name": "get_current_time",
        "description": "Get the current server time."
      },
      {
        "name": "add_numbers",
        "description": "Add two numbers together."
      },
      {
        "name": "get_server_info",
        "description": "Get information about this MCP server."
      }
    ]
  }
}

mfaisal-mac:lightspeed maysun$ sqlite3 -header packages/backend/sqlite-data/lightspeed.sqlite \
>   "SELECT server_name, token IS NOT NULL as has_token, status, tool_count FROM lightspeed_mcp_user_settings;"
server_name|has_token|status|tool_count

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the case, where someone tries to patch with wrong token, GET updates the status:

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers  -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "connected",
      "toolCount": 4,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "connected",
      "toolCount": 7,
      "hasToken": true
    }
  ]
}

mfaisal-mac:lightspeed maysun$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server -H "Authorization: Bearer $BACKSTAGE_TOKEN" -H "Content-Type: application/json" -d '{"token": "test-secret-token-wrong"}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "error",
    "toolCount": 0,
    "hasToken": true
  },
  "validation": {
    "valid": false,
    "toolCount": 0,
    "tools": [],
    "error": "Invalid credentials — server returned 401/403"
  }
}

mfaisal-mac:lightspeed maysun$ curl -s http://localhost:7007/api/lightspeed/mcp-servers  -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "servers": [
    {
      "name": "test-mcp-server",
      "url": "http://localhost:8888/mcp",
      "enabled": true,
      "status": "error",
      "toolCount": 0,
      "hasToken": true
    },
    {
      "name": "mcp-integration-tools",
      "url": "http://localhost:7008/api/mcp-actions/v1",
      "enabled": true,
      "status": "connected",
      "toolCount": 7,
      "hasToken": true
    }
  ]
}

Copy link
Copy Markdown
Contributor

@gabemontero gabemontero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one admittedly esoteric consideration @maysunfaisal

but it is conceivable address it is simply making the error message a bit more detailed

}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}).catch(() => {
// notifications may not return a response — ignore errors
Copy link
Copy Markdown
Contributor

@yangcao77 yangcao77 Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to log it at debug/trace level?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated!

Copy link
Copy Markdown
Contributor

@yangcao77 yangcao77 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generally looks good! just some minor comments.

also please include a changeset within this PR

@maysunfaisal
Copy link
Copy Markdown
Contributor Author

generally looks good! just some minor comments.

also please include a changeset within this PR

@yangcao77 changeset included in latest commit

Copy link
Copy Markdown
Member

@ciiay ciiay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good from frontend side, and the latest fixes help a lot (especially token-clear reset and status priority).

Two quick clarifications before integration:

  • Could the API return tokenSource (or hasUserToken) in addition to hasToken?
    frontend needs to distinguish user-saved token vs admin default for correct UX/actions.
  • Can we confirm encrypted-at-rest handling for saved tokens?
    This is part of the feature requirements, and I don’t see it explicitly in this PR.

Once these are clear, the frontend wiring should be straightforward.
If these are planned for a follow-up PR, that works too 🤝

Copy link
Copy Markdown
Contributor

@yangcao77 yangcao77 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@maysunfaisal
Copy link
Copy Markdown
Contributor Author

I will need to update the API reports for the CI to pass. I can implement @ciiay first suggestion about hasUserToken with this PR, let me take a look about encrypting tokens stored in backend DB.

Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
Made-with: Cursor
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
5 Security Hotspots

See analysis details on SonarQube Cloud

@maysunfaisal
Copy link
Copy Markdown
Contributor Author

maysunfaisal commented Mar 26, 2026

Merging the PR, CI passes after I updated the API Reports.

@ciiay I have included hasUserToken with this PR, I will do the token encryption with a new PR so as to not block your frontend efforts.

@maysunfaisal maysunfaisal merged commit 024d5a8 into redhat-developer:main Mar 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants