diff --git a/docs/documentation/using-lightspeed/tools-and-mcp.md b/docs/documentation/using-lightspeed/tools-and-mcp.md
index 5a6543df2..179afb688 100644
--- a/docs/documentation/using-lightspeed/tools-and-mcp.md
+++ b/docs/documentation/using-lightspeed/tools-and-mcp.md
@@ -106,6 +106,23 @@ For **OAuth sign-in**, choose **Add and connect**, then **Open the sign-in**.
Complete the external service's consent flow and return until the connection
shows **Connected**. The next step selects which profiles can use it.
+### Add Parallel Search
+
+Choose **MCP servers → Add Parallel Search** to prefill a connection to
+`https://search.parallel.ai/mcp`. The preset uses **Lightspeed connects**,
+**Show tools to the model up front**, and **No authentication**. Review the
+name and URL, choose **Continue**, then **Add server**. Registration happens
+only after you save. If the `parallel-search` server is already registered,
+the shortcut is hidden; use its existing row to edit the connection.
+
+[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
+provides free web search and page extraction without an API key, subject to
+anonymous rate limits. Attach the registered server under a profile or
+session's **MCP Servers** feature as described below. Its tools use that
+attachment's grants and the server's tool allowance and approval settings.
+Built-in **Web** search and its domain filters are configured separately.
+You can edit, disable, or delete the server like any other MCP connection.
+
## Select tools and grant the server
A newly registered server initially allows all of its advertised tools.
diff --git a/platform/web/src/pages/McpServersPage.dialog.test.tsx b/platform/web/src/pages/McpServersPage.dialog.test.tsx
index 36d20d70f..615dcaeb0 100644
--- a/platform/web/src/pages/McpServersPage.dialog.test.tsx
+++ b/platform/web/src/pages/McpServersPage.dialog.test.tsx
@@ -7,9 +7,9 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { PermissionIdentityProvider } from "@/lib/permissions";
import { McpServersPage, mcpConnectionSummary } from "./McpServersPage";
-const mocks = vi.hoisted(() => ({ api: vi.fn() }));
+const mocks = vi.hoisted(() => ({ api: vi.fn(), role: "operator", servers: [] as unknown[] }));
vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api }));
-vi.mock("@/lib/universes", () => ({ useActiveUniverse: () => ({ universe: { id: "universe", role: "operator", slug: "test", name: "Test" }, slug: "test", isLoading: false }) }));
+vi.mock("@/lib/universes", () => ({ useActiveUniverse: () => ({ universe: { id: "universe", role: mocks.role, slug: "test", name: "Test" }, slug: "test", isLoading: false }) }));
// Every select becomes a native control named by its trigger; jsdom cannot lay out the popup.
vi.mock("@/components/ui/select", async () => {
const React = await import("react");
@@ -46,11 +46,13 @@ let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
+ mocks.role = "operator";
+ mocks.servers = [saved];
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("PointerEvent", MouseEvent);
mocks.api.mockReset().mockImplementation(async (method: string, path: string) => {
- if (method === "GET" && path.endsWith("/mcp-servers")) return [saved];
+ if (method === "GET" && path.endsWith("/mcp-servers")) return mocks.servers;
if (path.endsWith("/auth-grants")) return [];
if (path.endsWith("/discover-auth")) return { oauth: null };
if (path.endsWith("/tools/discover")) return { status: "success", tools: [] };
@@ -109,6 +111,118 @@ async function choose(label: string, value: string) {
const summary = () =>
[...dialog().querySelectorAll('[data-slot="settings-disclosure"]')].at(-1)!.querySelector("p")!.textContent;
+it("offers neither registration action to a viewer", async () => {
+ mocks.role = "viewer";
+ await show();
+ expect(button("Add Parallel Search")).toBeUndefined();
+ expect(button("Add server")).toBeUndefined();
+});
+
+it("keeps an existing Parallel server's saved settings instead of offering to replace it", async () => {
+ mocks.servers = [{
+ ...saved,
+ serverId: "parallel-search",
+ displayName: "Team research",
+ serverUrl: "https://search.parallel.ai/mcp?mode=fast",
+ authPolicy: { type: "requiredBearer" },
+ credential: { type: "authGrant", grantId: "team-credential" },
+ approval: "always",
+ allowedTools: ["web_search"],
+ }];
+ await show();
+ expect(button("Add Parallel Search")).toBeUndefined();
+ expect(button("Add server")).toBeDefined();
+ await click(container.querySelector('[aria-label="Edit parallel-search"]'));
+ expect((field("#mcp-name") as HTMLInputElement).value).toBe("Team research");
+ expect((field("#mcp-url") as HTMLInputElement).value).toBe("https://search.parallel.ai/mcp?mode=fast");
+ expect(dialog().textContent).toContain("Disabled. It stays configured");
+ expect(summary()).toContain("approval: always ask");
+ expect(mocks.api.mock.calls.some(([method, path]) => method === "PUT"
+ || (method === "POST" && (path as string).endsWith("/mcp-servers")))).toBe(false);
+});
+
+it("refuses to replace a server discovered while the preset dialog is open", async () => {
+ await show();
+ await click(button("Add Parallel Search"));
+ await click(button("Continue"));
+ mocks.servers = [saved, { ...saved, serverId: "parallel-search" }];
+ await act(async () => {
+ await client.invalidateQueries({ queryKey: ["mcp-servers", "universe"] });
+ });
+ await settle();
+ await click(dialog().querySelector('button[type="submit"]'));
+ expect(dialog().textContent).toContain("A server with this ID is already registered.");
+ expect(mocks.api.mock.calls.some(([method, path]) => method === "PUT"
+ || (method === "POST" && (path as string).endsWith("/mcp-servers")))).toBe(false);
+});
+
+it("waits for an in-flight registry refresh before submitting a preset", async () => {
+ await show();
+ await click(button("Add Parallel Search"));
+ await click(button("Continue"));
+ let release!: (servers: unknown[]) => void;
+ const refresh = new Promise((resolve) => { release = resolve; });
+ mocks.api.mockImplementationOnce(() => refresh);
+ await act(async () => {
+ void client.invalidateQueries({ queryKey: ["mcp-servers", "universe"] });
+ });
+ await settle();
+ await click(dialog().querySelector('button[type="submit"]'));
+ expect(dialog().textContent).toContain("Wait for the server list to finish refreshing");
+ expect(mocks.api.mock.calls.some(([method]) => method === "PUT")).toBe(false);
+ await act(async () => { release(mocks.servers); });
+ await settle();
+});
+
+it("registers Parallel through the ordinary server flow with native keyless settings", async () => {
+ await show();
+ await click(button("Add Parallel Search"));
+ expect((field("#mcp-name") as HTMLInputElement).value).toBe("Parallel Search");
+ expect((field("#mcp-url") as HTMLInputElement).value).toBe("https://search.parallel.ai/mcp");
+ expect(mocks.api.mock.calls.filter(([method]) => method === "POST")).toHaveLength(0);
+ await click(button("Continue"));
+ expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/universes/universe/mcp-servers/discover-auth", {
+ serverUrl: "https://search.parallel.ai/mcp",
+ });
+ expect((field('select[aria-label="Authentication"]') as HTMLSelectElement).value).toBe("none");
+ expect(summary()).toBe("Lightspeed connects · tools shown up front · no approval·Customize");
+ await click(dialog().querySelector('button[type="submit"]'));
+ const create = mocks.api.mock.calls.find(([method, path]) => method === "PUT"
+ && path === "/api/v1/universes/universe/mcp-servers/parallel-search")?.[2];
+ expect(create).toEqual({
+ serverId: "parallel-search",
+ serverUrl: "https://search.parallel.ai/mcp",
+ defaultServerLabel: "parallel-search",
+ execution: "native",
+ exposure: "inject",
+ approval: "never",
+ allowPrivateNetwork: false,
+ authPolicy: { type: "none" },
+ credential: null,
+ status: "active",
+ displayName: "Parallel Search",
+ description: "Free web search and page extraction, with no API key required.",
+ allowedTools: null,
+ revision: 0,
+ });
+});
+
+it("keeps the preset editable and resets to custom defaults after closing it", async () => {
+ await show();
+ await click(button("Add Parallel Search"));
+ await type("#mcp-name", "Team research");
+ await type("#mcp-url", "https://example.test/mcp");
+ expect(dialog().textContent).toContain("team-research");
+ await click(button("Cancel"));
+ await click(button("Add server"));
+ expect((field("#mcp-name") as HTMLInputElement).value).toBe("");
+ expect((field("#mcp-url") as HTMLInputElement).value).toBe("");
+ await type("#mcp-name", "Custom");
+ await type("#mcp-url", "https://example.test/mcp");
+ await click(button("Continue"));
+ expect(summary()).toBe("Model provider connects · no approval·Customize");
+});
+
it("names the server first, then confirms the connection with everything else behind one summary", async () => {
await show();
await click(button("Add server"));
diff --git a/platform/web/src/pages/McpServersPage.tsx b/platform/web/src/pages/McpServersPage.tsx
index 03afd3fc3..2aab3420f 100644
--- a/platform/web/src/pages/McpServersPage.tsx
+++ b/platform/web/src/pages/McpServersPage.tsx
@@ -87,6 +87,16 @@ export function McpServersPage({ admin: _admin }: { admin: boolean }) {
const APPROVALS = ["always", "never"] as const;
+const PARALLEL_SEARCH_PRESET = {
+ displayName: "Parallel Search",
+ serverId: "parallel-search",
+ serverUrl: "https://search.parallel.ai/mcp",
+ description: "Free web search and page extraction, with no API key required.",
+ execution: "native" as const,
+};
+
+type ServerPreset = typeof PARALLEL_SEARCH_PRESET;
+
function ServerList({ universeId }: { universeId: string }) {
// Adding servers and changing credentials are universe configuration;
// everything else on a row is decided for that server.
@@ -105,6 +115,7 @@ function ServerList({ universeId }: { universeId: string }) {
queryClient.invalidateQueries({ queryKey: ["mcp-servers", universeId] });
const [createOpen, setCreateOpen] = useState(false);
+ const [createPreset, setCreatePreset] = useState();
const [editing, setEditing] = useState(null);
const [oauthServer, setOAuthServer] = useState(null);
@@ -131,10 +142,23 @@ function ServerList({ universeId }: { universeId: string }) {
title="MCP servers"
description="Connect remote tools once, then make them available to profiles and sessions."
actions={writable && (
-
+ <>
+ {servers.data && !rows.some((server) => server.serverId === PARALLEL_SEARCH_PRESET.serverId) && (
+
+ )}
+
+ >
)}
/>
{servers.isLoading && }
@@ -271,10 +295,13 @@ function ServerList({ universeId }: { universeId: string }) {
{writable && (
server.serverId)}
+ registryReady={Boolean(servers.data) && !servers.isFetching && !servers.error}
authGrants={authGrants.data ?? []}
authGrantsLoading={authGrants.isLoading}
onOpenChange={setCreateOpen}
@@ -360,6 +387,9 @@ function ServerDialog({
universeId,
open,
server,
+ preset,
+ registeredServerIds = [],
+ registryReady = true,
authGrants,
authGrantsLoading,
onOpenChange,
@@ -368,6 +398,9 @@ function ServerDialog({
universeId: string;
open: boolean;
server: McpServer | null;
+ preset?: ServerPreset;
+ registeredServerIds?: string[];
+ registryReady?: boolean;
authGrants: AuthGrantOption[];
authGrantsLoading: boolean;
onOpenChange: (open: boolean) => void;
@@ -375,12 +408,12 @@ function ServerDialog({
}) {
const editing = server !== null;
const [step, setStep] = useState<1 | 2>(editing ? 2 : 1);
- const [displayName, setDisplayName] = useState(server?.displayName ?? "");
- const [serverId, setServerId] = useState(server?.serverId ?? "");
+ const [displayName, setDisplayName] = useState(server?.displayName ?? preset?.displayName ?? "");
+ const [serverId, setServerId] = useState(server?.serverId ?? preset?.serverId ?? "");
const [idTouched, setIdTouched] = useState(false);
- const [serverUrl, setServerUrl] = useState(server?.serverUrl ?? "");
+ const [serverUrl, setServerUrl] = useState(server?.serverUrl ?? preset?.serverUrl ?? "");
const [execution, setExecution] = useState(
- server?.execution ?? "provider",
+ server?.execution ?? preset?.execution ?? "provider",
);
const [exposure, setExposure] = useState(
server?.exposure ?? "inject",
@@ -393,9 +426,9 @@ function ServerDialog({
);
const [allowedTools, setAllowedTools] = useState(server?.allowedTools ?? undefined);
const toolDiscoverySource = useMcpToolDiscoverySource(universeId);
- const [description, setDescription] = useState(server?.description ?? "");
+ const [description, setDescription] = useState(server?.description ?? preset?.description ?? "");
const [authPolicy, setAuthPolicy] = useState(server?.authPolicy.type ?? "none");
- const [authTouched, setAuthTouched] = useState(Boolean(server));
+ const [authTouched, setAuthTouched] = useState(Boolean(server || preset));
const [oauthResource, setOAuthResource] = useState(
oauthPolicyString(server?.authPolicy, "resource"),
);
@@ -490,7 +523,10 @@ function ServerDialog({
authorizationServer: oauthAuthorizationServer,
});
if (!editing) {
- return api("POST", `/api/v1/universes/${universeId}/mcp-servers`, {
+ // Revision zero creates a new record but cannot replace a saved one,
+ // including a concurrent registration not yet visible in the UI.
+ return api(preset ? "PUT" : "POST",
+ `/api/v1/universes/${universeId}/mcp-servers${preset ? `/${encodeURIComponent(serverId)}` : ""}`, {
serverId,
serverUrl,
defaultServerLabel: serverId,
@@ -504,6 +540,7 @@ function ServerDialog({
displayName: displayName.trim(),
...(description.trim() ? { description: description.trim() } : {}),
allowedTools: null,
+ ...(preset ? { revision: 0 } : {}),
});
}
return api(
@@ -561,6 +598,16 @@ function ServerDialog({
void continueToConnection();
return;
}
+ if (!editing && preset) {
+ if (!registryReady) {
+ setError("Wait for the server list to finish refreshing, then try again.");
+ return;
+ }
+ if (registeredServerIds.includes(serverId.trim())) {
+ setError("A server with this ID is already registered. Edit its existing row, or choose a different ID.");
+ return;
+ }
+ }
const credentialError = mcpServerCredentialError(authPolicy, credentialGrantId);
if (credentialError) {
setError(credentialError);