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) && ( + + )}