Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4cf7bed
🧭 feat: Purpose-Built AI Providers Editor (Wave 2)
danny-avila Oct 20, 2026
3fb0ac0
🛠️ feat: MCP Servers Editor (Wave 2)
danny-avila Oct 20, 2026
b35ef73
🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2)
danny-avila Oct 20, 2026
6c84a38
🧩 feat: Model Specs Editor (Wave 2)
danny-avila Oct 20, 2026
995c1b3
🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only
danny-avila Oct 22, 2026
a897480
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
0585188
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
f2a3c5b
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
4ebfc09
🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits
danny-avila Oct 22, 2026
b9d1d02
🔒 fix: Mask Secret Values in Collapsed Provider Summaries
danny-avila Oct 22, 2026
9aea2b8
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
ecbfe5d
🐛 fix: Restore Undone Endpoint Deletions in Their Original Order
danny-avila Oct 22, 2026
e8e08ac
🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Val…
danny-avila Oct 22, 2026
1030105
🐛 fix: Store Complete Built-In Parameter Definitions
danny-avila Oct 22, 2026
de1e539
🐛 fix: Insert Header Placeholders Into Header Values Only
danny-avila Oct 22, 2026
0f404a7
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
3d5460b
🐛 fix: Keep Spec Order When One Save Adds and Removes Specs
danny-avila Oct 22, 2026
16abc8a
🐛 fix: Keep a New Spec's Required Fields Under Set Values Only
danny-avila Oct 22, 2026
c09f17f
🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names
danny-avila Oct 22, 2026
3e04456
🐛 fix: Index Only the Prompt Fields a Spec Editor Shows
danny-avila Oct 22, 2026
ebdab4c
🐛 fix: Compare a Spec Rename With the Name It Was Loaded With
danny-avila Oct 22, 2026
5a1c35d
🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List
danny-avila Oct 22, 2026
e7d8998
🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint
danny-avila Oct 22, 2026
821bf2a
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
e03a2e5
🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Rec…
danny-avila Oct 22, 2026
5b5be53
🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Valu…
danny-avila Oct 22, 2026
a3fbfd8
🐛 fix: Pick MCP Copy Names Free in Any Letter Case
danny-avila Oct 22, 2026
5955f12
🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace
danny-avila Oct 22, 2026
666288f
🎨 style: Format MCP Secret Preview and Rename Validation Code
danny-avila Oct 22, 2026
1aca69b
🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only
danny-avila Oct 22, 2026
eb3ff9a
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
28238c1
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
5888feb
🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch
danny-avila Oct 22, 2026
1c9f458
🐛 fix: Keep Card State With a Spec When the List Is Reordered
danny-avila Oct 22, 2026
626b7ae
🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With…
danny-avila Oct 22, 2026
8ec1a05
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
4914645
🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication S…
danny-avila Oct 22, 2026
193ab79
🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends
danny-avila Oct 22, 2026
56b3109
🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Rev…
danny-avila Oct 22, 2026
dede842
🎨 style: Format the Spec Editor's Parameter Imports
danny-avila Oct 22, 2026
6e47384
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
bf90e66
🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It
danny-avila Oct 22, 2026
fb18e8b
Merge main (previous level's squash d16d38b) into feat/config-w2-4-mcp
danny-avila Oct 23, 2026
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
162 changes: 162 additions & 0 deletions e2e/mcp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';

/**
* MCP server journeys (WS12) from the MCP audit. They need a real LibreChat
* backend whose librechat.yaml defines the remote server E2E_MCP_REMOTE
* (with an Authorization header) and the stdio server E2E_MCP_STDIO: run
* with E2E_LIVE=1 and E2E_BASE_URL.
*/
const REMOTE = process.env.E2E_MCP_REMOTE ?? 'github';
const PLAIN = process.env.E2E_MCP_PLAIN ?? 'cloudflare';
const STDIO = process.env.E2E_MCP_STDIO ?? 'everything';

const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' });

async function openMcp(page: Page) {
await page.goto('/configuration?tab=mcp');
await page.waitForLoadState('networkidle');
}

/** The expanded card of the server `key`. */
async function card(page: Page, key: string): Promise<Locator> {
const entry = page.locator(`[data-mcp-server="${key}"]`);
await expect(entry).toHaveCount(1);
const header = entry.locator('button[aria-expanded]').first();
if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click();
return entry;
}

async function openGroup(entry: Locator, title: string) {
const trigger = entry.locator('.settings-group-header button', { hasText: title }).first();
if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click();
}

async function openMenu(page: Page, key: string) {
await page
.locator(`[data-mcp-server="${key}"]`)
.getByRole('button', { name: `Actions for ${key}` })
.click();
}

async function menu(page: Page, key: string, item: string | RegExp) {
await openMenu(page, key);
await page.getByRole('menuitem', { name: item }).click();
}

async function review(page: Page) {
await unsavedBar(page)
.getByRole('button', { name: /^Review & save$/ })
.click();
return page.getByRole('dialog');
}

async function saveAndWait(page: Page) {
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Save$/ }).click();
await expect(dialog).toHaveCount(0);
}

async function setText(field: Locator, value: string) {
await field.fill(value);
await field.press('Tab');
}

test.describe('Configuration: MCP servers', () => {
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });

test('turning Show in chat menu on and back to Default leaves nothing to save (MCP-6)', async ({
page,
}) => {
await openMcp(page);
const entry = await card(page, PLAIN);
await openGroup(entry, 'Display');
const choice = entry.locator(`#mcpServers-${PLAIN}-chatMenu`);
await choice.getByRole('button', { name: 'On', exact: true }).click();
await expect(unsavedBar(page)).toBeVisible();
await choice.getByRole('button', { name: /^Default/ }).click();
await expect(unsavedBar(page)).toHaveCount(0);
});

test('a librechat.yaml header and the transport cannot be removed or changed (MCP-5)', async ({
page,
}) => {
await openMcp(page);
const entry = await card(page, REMOTE);
await expect(entry.locator(`#mcpServers-${REMOTE}-headers`)).toBeVisible();
await expect(
entry.locator(`#mcpServers-${REMOTE}-headers`).getByRole('button', { name: /^Delete / }),
).toHaveCount(0);
await expect(entry.locator(`select#mcpServers-${REMOTE}-type`)).toHaveCount(0);
await openMenu(page, REMOTE);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toHaveAttribute(
'aria-disabled',
'true',
);
});

test('a stdio server is read-only (D10)', async ({ page }) => {
await openMcp(page);
const entry = await card(page, STDIO);
await expect(entry.getByRole('note').first()).toContainText('local process');
await expect(entry.locator(`input#mcpServers-${STDIO}-command`)).toHaveCount(0);
await openGroup(entry, 'Display');
await expect(entry.locator(`input#mcpServers-${STDIO}-title`)).toHaveCount(0);
});

test('create, rename with an edit, and delete a server (MCP-4, MCP-9, MCP-12)', async ({
page,
}) => {
const name = `e2e-${Date.now()}`;
const renamed = `${name}-2`;
await openMcp(page);

await page.getByRole('button', { name: 'Create MCP server' }).click();
const create = page.getByRole('dialog');
await expect(create.locator('#mcpDraft-transport-streamable-http')).toBeChecked();
await expect(create.locator('#mcpDraft-transport-stdio')).toBeDisabled();
await create.getByLabel('Server name').fill(name);
await expect(create).toContainText('Still needed: Server URL');
await setText(create.locator('#mcpDraft-url'), 'https://mcp.e2e.example/mcp');
await create.getByRole('button', { name: 'API key', exact: true }).click();
const key = create.locator('#mcpDraft-apiKey-key');
await expect(key).toHaveAttribute('type', 'password');
await setText(key, 'sk-e2e-secret');
await create.getByRole('button', { name: 'Create', exact: true }).click();
await expect(create).toHaveCount(0);

let dialog = await review(page);
await expect(dialog).toContainText(`Added MCP server ${name}`);
await expect(dialog).not.toContainText('sk-e2e-secret');
await saveAndWait(page);

await page.reload();
await page.waitForLoadState('networkidle');
await menu(page, name, 'Rename');
const input = page.getByRole('textbox', { name: `Rename ${name}` });
await input.fill(renamed);
await input.press('Enter');
const entry = await card(page, renamed);
await openGroup(entry, 'Display');
await setText(entry.locator(`#mcpServers-${renamed}-title`), 'Renamed in e2e');
dialog = await review(page);
await expect(dialog).toContainText(`(${name} → ${renamed})`);
await expect(dialog).toContainText('Renamed in e2e');
await saveAndWait(page);

await menu(page, renamed, 'Delete');
const ghost = page.locator(`[data-mcp-deleted="${renamed}"]`);
await expect(ghost).toContainText('Will be deleted');
await ghost.getByRole('button', { name: `Undo deleting ${renamed}` }).click();
await expect(page.locator(`[data-mcp-server="${renamed}"]`)).toHaveCount(1);
await menu(page, renamed, 'Delete');
dialog = await review(page);
await expect(dialog).toContainText(`Removed MCP server ${renamed}`);
await saveAndWait(page);
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator(`[data-mcp-server="${renamed}"]`)).toHaveCount(0);
});
});
5 changes: 5 additions & 0 deletions src/components/configuration/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,11 @@ export function ConfigPage({
filterPaths={filterPaths}
valueLayers={layers.valueLayers}
visibilityEdits={edits.visibilityEdits}
onHideField={permissions.canAssign ? edits.handleHideField : undefined}
onUnhideField={
permissions.canAssign ? edits.handleUnhideField : undefined
}
onRenameEntry={edits.handleRenameEntry}
/>
</CollectionEditsContext.Provider>
</RevealTargetContext.Provider>
Expand Down
6 changes: 6 additions & 0 deletions src/components/configuration/ConfigTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export function ConfigTabContent({
unknownSettings,
valueLayers,
visibilityEdits,
onHideField,
onUnhideField,
onRenameEntry,
visibleSectionIds,
filterPaths,
filterText,
Expand Down Expand Up @@ -270,6 +273,9 @@ export function ConfigTabContent({
yamlBaseKeys: baseRecordKeys?.[dataKey],
onValidationError,
editSessionId,
onHideField,
onUnhideField,
onRenameEntry,
};
const content = (
<>
Expand Down
2 changes: 1 addition & 1 deletion src/components/configuration/configMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const SECTION_META: Record<string, t.SectionMeta> = {
},
mcpSettings: {
titleKey: 'com_config_section_mcp_settings',
descriptionKey: 'com_config_section_mcp_settings_desc',
descriptionKey: 'com_config_mcp_settings_intro',
tab: 'mcp',
order: 20,
},
Expand Down
13 changes: 12 additions & 1 deletion src/components/configuration/edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from './edits';
import { getKeyedCollection, getValueAtPath, planConfigChanges } from '@/utils';
import { extractSchemaTree, getSchemaPathSet } from '@/server/config';
import { issueDisplayPath, formatIssue } from './issues';
import { issueDisplayPath, formatIssue, describeIssues } from './issues';
import { summarizeChanges } from './summary';
import { findSchemaField } from './controls';

Expand Down Expand Up @@ -428,6 +428,17 @@ describe('mergeIndexedArrayEdits', () => {
});

describe('issue display and summary', () => {
it("shows a plain list item's first problem on the list row", () => {
const issues: t.ConfigIssue[] = [
{ path: 'mcpSettings.allowedAddresses.0', code: 'custom', message: 'must include a port' },
{ path: 'mcpSettings.allowedAddresses.0', code: 'custom', message: 'is not private' },
];
const { fieldErrors } = describeIssues(issues, [], (key, params) =>
key === 'com_config_issue_list_item' ? `Item ${params?.n}: ${params?.message}` : key,
);
expect(fieldErrors['mcpSettings.allowedAddresses']).toBe('Item 1: must include a port');
});

const edit0 = captureCollectionEdit(custom, root);
const collections: t.PendingCollection[] = [
{ collection: custom, items: [endpoints[1], endpoints[0], endpoints[2]], edit: edit0 },
Expand Down
24 changes: 24 additions & 0 deletions src/components/configuration/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ export function formatIssue(
return { inline, summary };
}

/**
* A plain list's item an issue is about (`mcpSettings.allowedAddresses.0`):
* the list row shows the item's first problem ("Item 1: must include a
* port"), since the list control has no row per item. Entries of keyed
* collections show their issues in their own cards.
*/
function listItemOf(
issue: t.ConfigIssue,
path: string,
): { listPath: string; index: number } | undefined {
if (issueCollection(issue)) return undefined;
const dot = path.lastIndexOf('.');
const segment = path.slice(dot + 1);
if (dot < 0 || !/^\d+$/.test(segment)) return undefined;
return { listPath: path.slice(0, dot), index: Number(segment) };
}

/** Per-path inline messages and a summary line per issue, for the errors map and the review. */
export function describeIssues(
issues: t.ConfigIssue[],
Expand All @@ -96,6 +113,13 @@ export function describeIssues(
const { inline, summary } = formatIssue(issue, path, localize);
fieldErrors[path] = fieldErrors[path] ? `${fieldErrors[path]}; ${inline}` : inline;
if (!summaries.includes(summary)) summaries.push(summary);
const item = listItemOf(issue, path);
if (item && !fieldErrors[item.listPath]) {
fieldErrors[item.listPath] = localize('com_config_issue_list_item', {
n: item.index + 1,
message: issuePredicate(issue, localize),
});
}
}
return { fieldErrors, summaries };
}
2 changes: 2 additions & 0 deletions src/components/configuration/meta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { SECRET_CONFIG_KEYS } from '@/constants';
import { ENDPOINT_FIELD_META } from './endpoints';
import { GENERIC_FIELD_META } from './generic';
import { SPEC_FIELD_META } from './specs';
import { MCP_FIELD_META } from './mcp';

/** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */
const META_SOURCES: t.FieldMetaRegistry[] = [
GENERIC_FIELD_META,
PROVIDER_FIELD_META,
ENDPOINT_FIELD_META,
SPEC_FIELD_META,
MCP_FIELD_META,
];

const REGISTRY = buildFieldMetaRegistry(META_SOURCES);
Expand Down
Loading
Loading