diff --git a/surfaces/gui/src/components/connectors/CustomMcp.test.tsx b/surfaces/gui/src/components/connectors/CustomMcp.test.tsx new file mode 100644 index 000000000..735a5b949 --- /dev/null +++ b/surfaces/gui/src/components/connectors/CustomMcp.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import zh from "../../locales/zh.json"; +import { CustomMcpGroup } from "./CustomMcp"; + +type Call = { url: string; method: string; body: unknown }; + +function stubMcpApi() { + const calls: Call[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || "GET").toUpperCase(); + calls.push({ + url, + method, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + const json = method === "GET" && url.endsWith("/v1/mcp") ? { servers: [] } : { ok: true }; + return { ok: true, json: async () => json } as Response; + }), + ); + return calls; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("CustomMcpGroup presets", () => { + it("adds Parallel Search through the existing no-auth MCP connection path", async () => { + const calls = stubMcpApi(); + const onChanged = vi.fn(); + render(); + + expect(await screen.findByText("Granola")).toBeTruthy(); + const preset = screen.getByTestId("mcp-preset-parallel-search"); + expect(within(preset).getByText("Parallel Search")).toBeTruthy(); + expect(within(preset).getByText(/no account or API key required/i)).toBeTruthy(); + + fireEvent.click(within(preset).getByRole("button", { name: "Connect" })); + + await waitFor(() => { + const addIndex = calls.findIndex( + (call) => call.method === "POST" && call.url.endsWith("/v1/mcp"), + ); + const connectIndex = calls.findIndex( + (call) => + call.method === "POST" && call.url.endsWith("/v1/mcp/parallel-search/connect"), + ); + expect(addIndex).toBeGreaterThan(-1); + expect(connectIndex).toBeGreaterThan(addIndex); + expect(calls[addIndex].body).toEqual({ + name: "parallel-search", + config: { type: "http", url: "https://search.parallel.ai/mcp" }, + }); + expect(onChanged).toHaveBeenCalledOnce(); + }); + }); + + it("renders the Parallel Search description in Chinese", async () => { + const i18n = createInstance(); + await i18n.init({ + resources: { zh: { translation: zh } }, + lng: "zh", + fallbackLng: false, + }); + render( + + + , + ); + + const preset = screen.getByTestId("mcp-preset-parallel-search"); + expect(within(preset).getByText("实时网页搜索与 URL 内容获取。无需账户或 API 密钥。")).toBeTruthy(); + }); +}); diff --git a/surfaces/gui/src/components/connectors/CustomMcp.tsx b/surfaces/gui/src/components/connectors/CustomMcp.tsx index 532ab5eb2..9f43c38e6 100644 --- a/surfaces/gui/src/components/connectors/CustomMcp.tsx +++ b/surfaces/gui/src/components/connectors/CustomMcp.tsx @@ -31,8 +31,8 @@ import { // for a stdio entry: Live = a connection is open right now; Ready = the one-time // Test passed (subtitle carries "tested ⟨when⟩", persisted server-side). -// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + -// DCR) — no keys to paste, tokens stay in the local secret store. +// Curated remote MCP quick-adds. OAuth presets keep tokens in the local secret +// store; no-auth presets use the same explicit connection path without browser sign-in. // `blurb` is an i18n key, resolved at render time. export const MCP_PRESETS: { name: string; @@ -46,6 +46,12 @@ export const MCP_PRESETS: { blurb: "mcp.preset_granola_blurb", config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" }, }, + { + name: "parallel-search", + label: "Parallel Search", + blurb: "mcp.preset_parallel_search_blurb", + config: { type: "http", url: "https://search.parallel.ai/mcp" }, + }, ]; export function mcpChip(s: McpServer) { @@ -148,7 +154,7 @@ export function CustomMcpGroup({ role="button" onClick={async () => { await addMcpServer(p.name, p.config); - await connectMcp(p.name); // opens the browser sign-in right away + await connectMcp(p.name); // OAuth presets may open browser sign-in onChanged(); }} > diff --git a/surfaces/gui/src/locales/en.json b/surfaces/gui/src/locales/en.json index 25cbc2602..c2931227a 100644 --- a/surfaces/gui/src/locales/en.json +++ b/surfaces/gui/src/locales/en.json @@ -1605,6 +1605,7 @@ "choose_a_folder": "Choose a folder…" }, "mcp": { + "preset_parallel_search_blurb": "Live web search & URL fetching. No account or API key required.", "preset_granola_blurb": "Meeting notes & transcripts — sign in with your Granola account.", "status_off": "Off", "status_signing_in": "Signing in…", diff --git a/surfaces/gui/src/locales/zh.json b/surfaces/gui/src/locales/zh.json index 65c3389d9..30a903f75 100644 --- a/surfaces/gui/src/locales/zh.json +++ b/surfaces/gui/src/locales/zh.json @@ -1574,6 +1574,7 @@ "choose_a_folder": "选择一个文件夹…" }, "mcp": { + "preset_parallel_search_blurb": "实时网页搜索与 URL 内容获取。无需账户或 API 密钥。", "preset_granola_blurb": "会议笔记与转录——用你的 Granola 账户登录。", "status_off": "已关闭", "status_signing_in": "登录中…",