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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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. 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
{
"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:
Expand Down
86 changes: 86 additions & 0 deletions app/components/@settings/tabs/mcp/McpTab.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<McpTab />);
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<HTMLTextAreaElement>('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(<McpTab />);
await waitFor(() => expect(useMCPStore.getState().isInitialized).toBe(true));
vi.mocked(fetch).mockClear();

fireEvent.click(screen.getByRole('button', { name: 'Load Example' }));

const editor = screen.getByLabelText<HTMLTextAreaElement>('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(<McpTab />);
await waitFor(() => expect(useMCPStore.getState().isInitialized).toBe(true));
fireEvent.change(screen.getByLabelText('Configuration JSON'), { target: { value: '{' } });
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Configuration' }).disabled).toBe(true);
expect(fetch).not.toHaveBeenCalled();
});
});
22 changes: 21 additions & 1 deletion app/components/@settings/tabs/mcp/McpTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -193,7 +197,23 @@ export default function McpTab() {
/>
</div>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
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.
</div>
<div className="text-sm text-bolt-elements-textSecondary">
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.{' '}
<a
href="https://docs.parallel.ai/integrations/mcp/search-mcp"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-link hover:underline"
>
Parallel Search setup and limits
</a>
</div>
<div className="text-sm text-bolt-elements-textSecondary">
<a
href="https://modelcontextprotocol.io/examples"
target="_blank"
Expand Down
126 changes: 126 additions & 0 deletions app/lib/services/mcpService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { createServer, type Server } from 'node:http';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { DataStreamWriter, Message } from 'ai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { TOOL_EXECUTION_APPROVAL, TOOL_EXECUTION_DENIED, TOOL_EXECUTION_ERROR } from '~/utils/constants';
import { MCPService } from './mcpService';

const payload = { results: [{ url: 'https://example.com/docs', title: 'Docs', excerpts: ['A useful excerpt.'] }] };
let httpServer: Server;
let server: McpServer;
let service: MCPService;
let failure: 'quota' | 'tool' | 'http' | undefined;
let calls: ReturnType<typeof vi.fn>;

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<void>((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<void>((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' }],
});
});
});
Loading