From ce7901d662d991fe9c792625bc2a2ba0de563ccb Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:13:06 -0700 Subject: [PATCH 1/2] feat: add an opt-in Parallel Search MCP example --- README.md | 29 ++++ .../@settings/tabs/mcp/McpTab.spec.tsx | 86 ++++++++++++ app/components/@settings/tabs/mcp/McpTab.tsx | 22 ++- app/lib/services/mcpService.spec.ts | 126 ++++++++++++++++++ 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 app/components/@settings/tabs/mcp/McpTab.spec.tsx create mode 100644 app/lib/services/mcpService.spec.ts diff --git a/README.md b/README.md index 72da566defc..5f555dc4997 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed - [Quick Installation](#quick-installation) - [Manual Installation](#manual-installation) - [Configuring API Keys and Providers](#configuring-api-keys-and-providers) +- [Web search with MCP](#web-search-with-mcp) - [Setup Using Git (For Developers only)](#setup-using-git-for-developers-only) - [Available Scripts](#available-scripts) - [Contributing](#contributing) @@ -354,6 +355,34 @@ LMSTUDIO_BASE_URL=http://127.0.0.1:1234 > **💡 Pro Tip**: Start with OpenAI or Anthropic for the best results, then explore other providers based on your specific needs and budget considerations. +## Web search with MCP + +In community **bolt.diy**, open **Settings → MCP Servers** to connect external tools. This does not configure hosted Bolt.new. + +### Parallel Search + +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) provides `web_search` and `web_fetch` over Streamable HTTP. Anonymous access needs no Parallel account, API key, headers, or local command. Free search is rate limited; your chat model and hosting may still have their own costs. + +1. Add the `parallel-search` entry below inside your existing `mcpServers` object, keeping your other servers. For a new configuration, you can paste the whole example. +2. Click **Save Configuration**. Saving connects the configured servers. Expand `parallel-search` under **MCP Servers Configured** and confirm that `web_search` and `web_fetch` appear. A saved configuration does not guarantee a successful connection; use **Check availability** to retry discovery. +3. In chat, use a model that supports tool calling and ask, for example: "Use web_search to find the official Vite documentation on environment variables, then use web_fetch to read it. Include the source URLs." +4. Review the proposed arguments and click **Run tool**, or **Cancel** to decline. Queries, requested URLs, and any supplied objective/context go to Parallel when you approve the call. Tool results retain the source URLs and excerpts for the chat to use. + +```json +{ + "mcpServers": { + "parallel-search": { + "type": "streamable-http", + "url": "https://search.parallel.ai/mcp" + } + } +} +``` + +**Load Example** also includes this connection alongside other example servers. It replaces the editor draft, not saved settings. Keep only the entries you want before saving; do not save the whole sample over an existing configuration. Nothing is enabled by default, and adding Parallel does not change your selected model or automatically run its tools. + +Use `streamable-http`, not `sse`, and leave out authentication headers for anonymous access. If a call fails or reaches a rate limit, wait before retrying and check the [service guidance](https://docs.parallel.ai/integrations/mcp/search-mcp#troubleshooting). bolt.diy may show a generic tool execution error; that is not a successful empty search. To disconnect, remove only `parallel-search` from the configuration and save again. + ## Setup Using Git (For Developers only) This method is recommended for developers who want to: diff --git a/app/components/@settings/tabs/mcp/McpTab.spec.tsx b/app/components/@settings/tabs/mcp/McpTab.spec.tsx new file mode 100644 index 00000000000..cfc9b4ed816 --- /dev/null +++ b/app/components/@settings/tabs/mcp/McpTab.spec.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMCPStore } from '~/lib/stores/mcp'; +import McpTab from './McpTab'; + +// Remix's development transform expects the browser preamble, which Vitest does not load. +vi.hoisted(() => { + Object.assign(window, { __vite_plugin_react_preamble_installed__: true }); +}); + +const existingSettings = { + maxLLMSteps: 3, + mcpConfig: { mcpServers: { existing: { type: 'streamable-http' as const, url: 'https://example.com/mcp' } } }, +}; + +beforeEach(() => { + localStorage.clear(); + useMCPStore.setState({ ...useMCPStore.getInitialState() }); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => Response.json({})), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('MCP example configuration', () => { + it('keeps a fresh installation empty until the user saves', async () => { + render(); + await waitFor(() => expect(useMCPStore.getState().isInitialized).toBe(true)); + expect(useMCPStore.getState().settings.mcpConfig.mcpServers).toEqual({}); + expect(fetch).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Load Example' })); + + const editor = screen.getByLabelText('Configuration JSON'); + const example = JSON.parse(editor.value); + const parallel = example.mcpServers['parallel-search']; + + expect(parallel).toEqual({ type: 'streamable-http', url: 'https://search.parallel.ai/mcp' }); + expect(fetch).not.toHaveBeenCalled(); + expect(JSON.parse(localStorage.getItem('mcp_settings')!).mcpConfig.mcpServers).toEqual({}); + + fireEvent.change(editor, { target: { value: JSON.stringify({ mcpServers: { 'parallel-search': parallel } }) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + await waitFor(() => + expect(useMCPStore.getState().settings.mcpConfig.mcpServers).toEqual({ 'parallel-search': parallel }), + ); + expect(JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string)).toEqual({ + mcpServers: { 'parallel-search': parallel }, + }); + }); + + it('preserves saved servers while previewing the example, then saves only the edited configuration', async () => { + localStorage.setItem('mcp_settings', JSON.stringify(existingSettings)); + render(); + await waitFor(() => expect(useMCPStore.getState().isInitialized).toBe(true)); + vi.mocked(fetch).mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Load Example' })); + + const editor = screen.getByLabelText('Configuration JSON'); + const parallel = JSON.parse(editor.value).mcpServers['parallel-search']; + expect(useMCPStore.getState().settings).toEqual(existingSettings); + expect(JSON.parse(localStorage.getItem('mcp_settings')!)).toEqual(existingSettings); + expect(fetch).not.toHaveBeenCalled(); + + const merged = { mcpServers: { ...existingSettings.mcpConfig.mcpServers, 'parallel-search': parallel } }; + fireEvent.change(editor, { target: { value: JSON.stringify(merged) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + await waitFor(() => expect(useMCPStore.getState().settings.mcpConfig).toEqual(merged)); + expect(JSON.parse(localStorage.getItem('mcp_settings')!)).toEqual({ ...existingSettings, mcpConfig: merged }); + }); + + it('does not save invalid JSON', async () => { + render(); + await waitFor(() => expect(useMCPStore.getState().isInitialized).toBe(true)); + fireEvent.change(screen.getByLabelText('Configuration JSON'), { target: { value: '{' } }); + expect(screen.getByRole('button', { name: 'Save Configuration' }).disabled).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/@settings/tabs/mcp/McpTab.tsx b/app/components/@settings/tabs/mcp/McpTab.tsx index 9fb765be848..5d2a2ab1f24 100644 --- a/app/components/@settings/tabs/mcp/McpTab.tsx +++ b/app/components/@settings/tabs/mcp/McpTab.tsx @@ -16,6 +16,10 @@ const EXAMPLE_MCP_CONFIG: MCPConfig = { type: 'streamable-http', url: 'https://mcp.deepwiki.com/mcp', }, + 'parallel-search': { + type: 'streamable-http', + url: 'https://search.parallel.ai/mcp', + }, 'local-sse': { type: 'sse', url: 'http://localhost:8000/sse', @@ -193,7 +197,23 @@ export default function McpTab() { />
- The MCP configuration format is identical to the one used in Claude Desktop. + Load Example replaces the editor contents, not your saved settings. Keep only the servers you want before + saving. To preserve existing servers, add individual entries to your current mcpServers object instead. +
+
+ The Parallel Search example provides web_search and web_fetch without a Parallel account or API key. Free + access is rate limited. When you approve a tool call in chat, its queries, URLs and context are sent to + Parallel.{' '} + + Parallel Search setup and limits + +
+
; + +beforeEach(async () => { + failure = undefined; + calls = vi.fn(); + service = new MCPService(); + server = new McpServer({ name: 'search-fixture', version: '1.0.0' }); + server.tool('web_search', { objective: z.string(), search_queries: z.array(z.string()) }, async (args) => { + calls(args); + return failure === 'tool' + ? { isError: true, content: [{ type: 'text', text: 'Search failed' }] } + : { content: [{ type: 'text', text: JSON.stringify(payload) }] }; + }); + + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + httpServer = createServer(async (req, res) => { + const chunks = []; + + for await (const chunk of req) { + chunks.push(chunk); + } + + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : undefined; + + if (body?.method === 'tools/call' && (failure === 'quota' || failure === 'http')) { + res.writeHead(failure === 'http' ? 429 : 200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, error: { code: -32000, message: 'Rate limit reached' } })); + + return; + } + + await transport.handleRequest(req, res, body); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + + const address = httpServer.address(); + + if (!address || typeof address === 'string') { + throw new Error('Fixture failed to listen'); + } + + await service.updateConfig({ + mcpServers: { search: { type: 'streamable-http', url: `http://127.0.0.1:${address.port}/mcp` } }, + }); +}); + +afterEach(async () => { + await service.updateConfig({ mcpServers: {} }); + await server.close(); + httpServer.closeAllConnections(); + await new Promise((resolve) => httpServer.close(() => resolve())); +}); + +async function invoke(approval: string) { + const messages: Message[] = [ + { + id: 'response', + role: 'assistant', + content: '', + parts: [ + { + type: 'tool-invocation', + toolInvocation: { + state: 'result', + toolCallId: 'search-call', + toolName: 'web_search', + args: { objective: 'Find docs', search_queries: ['example docs'] }, + result: approval, + }, + }, + ], + }, + ]; + const write = vi.fn(); + const processed = await service.processToolInvocations(messages, { write } as unknown as DataStreamWriter); + const part = processed[0].parts![0]; + + if (part.type !== 'tool-invocation' || part.toolInvocation.state !== 'result') { + throw new Error('Expected a tool result'); + } + + expect(write).toHaveBeenCalledOnce(); + + return part.toolInvocation.result; +} + +describe('Streamable HTTP tools in chat', () => { + it('discovers tools, retains result content and URLs, and requires approval to execute', async () => { + expect(service.toolsWithoutExecute.web_search).toBeDefined(); + expect(service.toolsWithoutExecute.web_search.execute).toBeUndefined(); + expect(await invoke(TOOL_EXECUTION_APPROVAL.REJECT)).toBe(TOOL_EXECUTION_DENIED); + expect(calls).not.toHaveBeenCalled(); + + const result = await invoke(TOOL_EXECUTION_APPROVAL.APPROVE); + expect(JSON.parse(result.content[0].text)).toEqual(payload); + expect(calls).toHaveBeenCalledWith({ objective: 'Find docs', search_queries: ['example docs'] }); + }); + + it.each(['quota', 'http'] as const)('surfaces %s failures as execution errors, not empty results', async (mode) => { + failure = mode; + expect(await invoke(TOOL_EXECUTION_APPROVAL.APPROVE)).toBe(TOOL_EXECUTION_ERROR); + }); + + it('preserves MCP tool error content and its error flag', async () => { + failure = 'tool'; + expect(await invoke(TOOL_EXECUTION_APPROVAL.APPROVE)).toEqual({ + isError: true, + content: [{ type: 'text', text: 'Search failed' }], + }); + }); +}); From a6308e51191813649ada8aa400aa6fef99edef06 Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:28:06 -0700 Subject: [PATCH 2/2] docs: correct MCP approval guidance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f555dc4997..003c9f52d9b 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ In community **bolt.diy**, open **Settings → MCP Servers** to connect external 1. Add the `parallel-search` entry below inside your existing `mcpServers` object, keeping your other servers. For a new configuration, you can paste the whole example. 2. Click **Save Configuration**. Saving connects the configured servers. Expand `parallel-search` under **MCP Servers Configured** and confirm that `web_search` and `web_fetch` appear. A saved configuration does not guarantee a successful connection; use **Check availability** to retry discovery. 3. In chat, use a model that supports tool calling and ask, for example: "Use web_search to find the official Vite documentation on environment variables, then use web_fetch to read it. Include the source URLs." -4. Review the proposed arguments and click **Run tool**, or **Cancel** to decline. Queries, requested URLs, and any supplied objective/context go to Parallel when you approve the call. Tool results retain the source URLs and excerpts for the chat to use. +4. When chat proposes a tool, click **Run tool** to approve it or **Cancel** to decline. The approval card shows the tool name and description, not its arguments. Queries, requested URLs, and any supplied objective/context go to Parallel when you approve the call. After the call, expand **MCP Tool Invocations** to inspect its parameters and result. Tool results retain the source URLs and excerpts for the chat to use. ```json {