Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions docs/documentation/using-lightspeed/tools-and-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 117 additions & 3 deletions platform/web/src/pages/McpServersPage.dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@/api")>(), 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");
Expand Down Expand Up @@ -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: [] };
Expand Down Expand Up @@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('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<unknown[]>((resolve) => { release = resolve; });
mocks.api.mockImplementationOnce(() => refresh);
await act(async () => {
void client.invalidateQueries({ queryKey: ["mcp-servers", "universe"] });
});
await settle();
await click(dialog().querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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"));
Expand Down
71 changes: 59 additions & 12 deletions platform/web/src/pages/McpServersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -105,6 +115,7 @@ function ServerList({ universeId }: { universeId: string }) {
queryClient.invalidateQueries({ queryKey: ["mcp-servers", universeId] });

const [createOpen, setCreateOpen] = useState(false);
const [createPreset, setCreatePreset] = useState<ServerPreset | undefined>();
const [editing, setEditing] = useState<McpServer | null>(null);
const [oauthServer, setOAuthServer] = useState<McpServer | null>(null);

Expand All @@ -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 && (
<Button onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Add server
</Button>
<>
{servers.data && !rows.some((server) => server.serverId === PARALLEL_SEARCH_PRESET.serverId) && (
<Button variant="outline" disabled={servers.isFetching} onClick={() => {
setCreatePreset(PARALLEL_SEARCH_PRESET);
setCreateOpen(true);
}}>
Add Parallel Search
</Button>
)}
<Button onClick={() => {
setCreatePreset(undefined);
setCreateOpen(true);
}}>
<Plus data-icon="inline-start" />
Add server
</Button>
</>
)}
/>
{servers.isLoading && <LoadingNote />}
Expand Down Expand Up @@ -271,10 +295,13 @@ function ServerList({ universeId }: { universeId: string }) {
</p>
{writable && (
<ServerDialog
key={createOpen ? "create-open" : "create-closed"}
key={createOpen ? `create-open-${createPreset?.serverId ?? "custom"}` : "create-closed"}
universeId={universeId}
open={createOpen}
server={null}
preset={createPreset}
registeredServerIds={rows.map((server) => server.serverId)}
registryReady={Boolean(servers.data) && !servers.isFetching && !servers.error}
authGrants={authGrants.data ?? []}
authGrantsLoading={authGrants.isLoading}
onOpenChange={setCreateOpen}
Expand Down Expand Up @@ -360,6 +387,9 @@ function ServerDialog({
universeId,
open,
server,
preset,
registeredServerIds = [],
registryReady = true,
authGrants,
authGrantsLoading,
onOpenChange,
Expand All @@ -368,19 +398,22 @@ function ServerDialog({
universeId: string;
open: boolean;
server: McpServer | null;
preset?: ServerPreset;
registeredServerIds?: string[];
registryReady?: boolean;
authGrants: AuthGrantOption[];
authGrantsLoading: boolean;
onOpenChange: (open: boolean) => void;
onDone: (server: McpServer, connectOAuth: boolean) => void;
}) {
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<McpServer["execution"]>(
server?.execution ?? "provider",
server?.execution ?? preset?.execution ?? "provider",
);
const [exposure, setExposure] = useState<McpServer["exposure"]>(
server?.exposure ?? "inject",
Expand All @@ -393,9 +426,9 @@ function ServerDialog({
);
const [allowedTools, setAllowedTools] = useState<string[] | undefined>(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<string>(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"),
);
Expand Down Expand Up @@ -490,7 +523,10 @@ function ServerDialog({
authorizationServer: oauthAuthorizationServer,
});
if (!editing) {
return api<McpServer>("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<McpServer>(preset ? "PUT" : "POST",
`/api/v1/universes/${universeId}/mcp-servers${preset ? `/${encodeURIComponent(serverId)}` : ""}`, {
serverId,
serverUrl,
defaultServerLabel: serverId,
Expand All @@ -504,6 +540,7 @@ function ServerDialog({
displayName: displayName.trim(),
...(description.trim() ? { description: description.trim() } : {}),
allowedTools: null,
...(preset ? { revision: 0 } : {}),
});
}
return api<McpServer>(
Expand Down Expand Up @@ -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);
Expand Down