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);