From 81f3bb30dc8c7d99a0655f4ec1b73ba7291795b0 Mon Sep 17 00:00:00 2001
From: George Pickett
Date: Fri, 2 Oct 2026 18:35:19 +0000
Subject: [PATCH 1/3] Add opt-in Parallel Search MCP registration preset
Signed-off-by: George Pickett
---
.../using-lightspeed/tools-and-mcp.md | 16 +++++
.../src/pages/McpServersPage.dialog.test.tsx | 59 ++++++++++++++++++-
platform/web/src/pages/McpServersPage.tsx | 47 +++++++++++----
3 files changed, 109 insertions(+), 13 deletions(-)
diff --git a/docs/documentation/using-lightspeed/tools-and-mcp.md b/docs/documentation/using-lightspeed/tools-and-mcp.md
index 5a6543df2..436e9e9f6 100644
--- a/docs/documentation/using-lightspeed/tools-and-mcp.md
+++ b/docs/documentation/using-lightspeed/tools-and-mcp.md
@@ -106,6 +106,22 @@ 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.
+
+[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..dfbd96f93 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" }));
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,6 +46,7 @@ let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
+ mocks.role = "operator";
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("PointerEvent", MouseEvent);
@@ -109,6 +110,60 @@ 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("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 === "POST" && (path as string).endsWith("/mcp-servers"))?.[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,
+ });
+});
+
+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..c1b7b5a91 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,21 @@ function ServerList({ universeId }: { universeId: string }) {
title="MCP servers"
description="Connect remote tools once, then make them available to profiles and sessions."
actions={writable && (
-
+ <>
+
+
+ >
)}
/>
{servers.isLoading && }
@@ -271,10 +293,11 @@ function ServerList({ universeId }: { universeId: string }) {
{writable && (
void;
@@ -375,12 +400,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 +418,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"),
);
From 90ec5e648f4d9eddbd2baa5493696838b11012fb Mon Sep 17 00:00:00 2001
From: George Pickett
Date: Fri, 2 Oct 2026 18:39:17 +0000
Subject: [PATCH 2/3] Preserve saved Parallel server settings when offering the
preset
Signed-off-by: George Pickett
---
.../using-lightspeed/tools-and-mcp.md | 3 +-
.../src/pages/McpServersPage.dialog.test.tsx | 28 +++++++++++++++++--
platform/web/src/pages/McpServersPage.tsx | 14 ++++++----
3 files changed, 36 insertions(+), 9 deletions(-)
diff --git a/docs/documentation/using-lightspeed/tools-and-mcp.md b/docs/documentation/using-lightspeed/tools-and-mcp.md
index 436e9e9f6..179afb688 100644
--- a/docs/documentation/using-lightspeed/tools-and-mcp.md
+++ b/docs/documentation/using-lightspeed/tools-and-mcp.md
@@ -112,7 +112,8 @@ 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.
+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
diff --git a/platform/web/src/pages/McpServersPage.dialog.test.tsx b/platform/web/src/pages/McpServersPage.dialog.test.tsx
index dfbd96f93..3527deb05 100644
--- a/platform/web/src/pages/McpServersPage.dialog.test.tsx
+++ b/platform/web/src/pages/McpServersPage.dialog.test.tsx
@@ -7,7 +7,7 @@ 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(), role: "operator" }));
+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: 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.
@@ -47,11 +47,12 @@ 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: [] };
@@ -117,6 +118,29 @@ it("offers neither registration action to a viewer", async () => {
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("registers Parallel through the ordinary server flow with native keyless settings", async () => {
await show();
await click(button("Add Parallel Search"));
diff --git a/platform/web/src/pages/McpServersPage.tsx b/platform/web/src/pages/McpServersPage.tsx
index c1b7b5a91..3a364164e 100644
--- a/platform/web/src/pages/McpServersPage.tsx
+++ b/platform/web/src/pages/McpServersPage.tsx
@@ -143,12 +143,14 @@ function ServerList({ universeId }: { universeId: string }) {
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) && (
+
+ )}