Skip to content
Closed
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- [Running with Docker](#running-with-docker)
- [Desktop App (Electron)](#desktop-app-electron)
- [Configuring API Keys and Providers](#configuring-api-keys-and-providers)
- [Web search with MCP](#web-search-with-mcp)
- [Features](#features)
- [Recent Highlights](#recent-highlights)
- [Available Scripts](#available-scripts)
Expand Down Expand Up @@ -110,6 +111,36 @@ LMSTUDIO_BASE_URL=http://127.0.0.1:1234

---

## 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.

---

## Features

- **In-Browser Web Development**: Full-stack Node.js development environment powered by WebContainers.
Expand Down
184 changes: 184 additions & 0 deletions app/components/@settings/tabs/mcp/McpServerList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import McpServerList from './McpServerList';
import McpToolErrorBoundary from './McpToolErrorBoundary';
import type { MCPServer } from '~/lib/services/mcpService';

// Remix's development transform expects the browser preamble, which Vitest does not load.
vi.hoisted(() => {
Object.assign(window, { __vite_plugin_react_preamble_installed__: true });
});

/*
* Tools reach the settings tab as JSON over /api/mcp-update-config, so the AI SDK Schema
* wrapper is already reduced to a bare { jsonSchema } object by the time it is rendered.
*/
const serializedParallelTools = {
web_search: {
description: 'Search the web.',
type: 'dynamic',
inputSchema: {
jsonSchema: {
type: 'object',
title: 'v1_search_toolArguments',
required: ['objective', 'search_queries'],
properties: {
objective: { type: 'string', title: 'Objective', description: 'What the search is trying to find.' },
search_queries: {
type: 'array',
items: { type: 'string' },
title: 'Search Queries',
description: 'Concise keyword search queries.',
},
session_id: { type: 'string', description: 'Stable conversation identifier.', maxLength: 100 },
},
},
},
},
web_fetch: {
description: 'Fetch page content.',
type: 'dynamic',
inputSchema: {
jsonSchema: {
type: 'object',
required: ['urls'],
properties: {
urls: { type: 'array', items: { type: 'string' }, description: 'List of URLs to extract content from.' },
objective: { anyOf: [{ type: 'string' }, { type: 'null' }], default: null, title: 'Objective' },
full_content: { type: 'boolean', default: false, description: 'Return the entire page as markdown.' },
},
},
},
},
};

const hostileTools = {
'union-type': {
description: 'Accepts several types.',
inputSchema: { jsonSchema: { type: 'object', properties: { value: { type: ['string', 'null'] } } } },
},
'nested-object': {
inputSchema: {
jsonSchema: {
type: 'object',
required: 'not-an-array',
properties: {
filters: {
type: 'object',
description: { text: 'descriptions are meant to be strings' },
properties: { since: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } },
},
},
},
},
},
'lazy-schema': { inputSchema: { jsonSchema: () => ({ type: 'object', properties: { q: { type: 'string' } } }) } },
'throwing-schema': {
inputSchema: {
get jsonSchema() {
throw new Error('schema resolution failed');
},
},
},
'async-schema': { inputSchema: { jsonSchema: Promise.resolve({ properties: { q: { type: 'string' } } }) } },
'raw-schema': { inputSchema: { type: 'object', properties: { q: { type: 'string' } } } },
'function-description': { description: () => 'callable descriptions cannot be rendered', inputSchema: undefined },
'string-schema': { inputSchema: 'not a schema at all' },
'no-schema': {},
'null-tool': null,
};

function availableServer(tools: unknown): MCPServer {
return {
status: 'available',
tools,
client: null,
config: { type: 'streamable-http', url: 'https://search.parallel.ai/mcp' },
} as unknown as MCPServer;
}

function renderExpanded(serverName: string, tools: unknown) {
return render(
<McpServerList
serverEntries={[[serverName, availableServer(tools)]]}
expandedServer={serverName}
checkingServers={false}
toggleServerExpanded={vi.fn()}
/>,
);
}

afterEach(cleanup);

describe('McpServerList expanded tools', () => {
it('renders Parallel Search tools as they arrive from the server', () => {
renderExpanded('parallel-search', serializedParallelTools);

expect(screen.getByText('web_search')).toBeTruthy();
expect(screen.getByText('Search the web.')).toBeTruthy();
expect(screen.getByText('What the search is trying to find.')).toBeTruthy();
expect(screen.getAllByText('array').length).toBe(2);

// anyOf-only properties still need a readable type.
expect(screen.getByText('string | null')).toBeTruthy();
});

it('marks required parameters and leaves optional ones unmarked', () => {
renderExpanded('parallel-search', serializedParallelTools);

// `objective` is required on web_search and optional on web_fetch.
expect(screen.getAllByText('objective').map((element) => element.textContent)).toEqual(['objective*', 'objective']);
expect(screen.getByText('session_id').textContent).toBe('session_id');
});

it('does not throw on malformed, lazy, async or missing tool schemas', () => {
expect(() => renderExpanded('hostile', hostileTools)).not.toThrow();

expect(screen.getByText('no-schema')).toBeTruthy();
expect(screen.getByText('throwing-schema')).toBeTruthy();
expect(screen.getAllByText('No description available').length).toBeGreaterThan(0);
});

it('renders union types and nested object parameters as display strings', () => {
renderExpanded('hostile', hostileTools);

expect(screen.getByText('string | null')).toBeTruthy();
expect(screen.getByText('filters')).toBeTruthy();

// A non-string description must be serialized rather than handed to React as an object.
expect(screen.getByText('{"text":"descriptions are meant to be strings"}')).toBeTruthy();
});

it('keeps the list rendered when a server reports no tools object', () => {
expect(() => renderExpanded('broken', undefined)).not.toThrow();
expect(screen.getByText('No tools available')).toBeTruthy();
});
});

describe('McpToolErrorBoundary', () => {
it('contains a tool that fails to render', () => {
// React reports every error an error boundary catches; the test expects this one.
vi.spyOn(console, 'error').mockImplementation(vi.fn());

function Exploding(): never {
throw new Error('boom');
}

render(
<>
<McpToolErrorBoundary toolName="web_search">
<Exploding />
</McpToolErrorBoundary>
<McpToolErrorBoundary toolName="web_fetch">
<span>web_fetch renders fine</span>
</McpToolErrorBoundary>
</>,
);

expect(screen.getByText("This tool's schema could not be displayed.")).toBeTruthy();
expect(screen.getByText('web_fetch renders fine')).toBeTruthy();

vi.mocked(console.error).mockRestore();
});
});
12 changes: 6 additions & 6 deletions app/components/@settings/tabs/mcp/McpServerList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import McpServerListItem from '~/components/@settings/tabs/mcp/McpServerListItem';
import McpStatusBadge from '~/components/@settings/tabs/mcp/McpStatusBadge';
import McpToolErrorBoundary from '~/components/@settings/tabs/mcp/McpToolErrorBoundary';
import type { MCPServer } from '~/lib/services/mcpService';

type McpServerListProps = {
Expand Down Expand Up @@ -30,7 +31,8 @@ export default function McpServerList({
{filteredEntries.map(([serverName, mcpServer]) => {
const isAvailable = mcpServer.status === 'available';
const isExpanded = expandedServer === serverName;
const serverTools = isAvailable ? Object.entries(mcpServer.tools) : [];
const tools = isAvailable ? mcpServer.tools : undefined;
const serverTools = tools && typeof tools === 'object' ? Object.entries(tools) : [];

return (
<div key={serverName} className="flex flex-col p-2 rounded-md bg-bolt-elements-background-depth-1">
Expand Down Expand Up @@ -81,11 +83,9 @@ export default function McpServerList({
) : (
<div className="mt-1 space-y-2">
{serverTools.map(([toolName, toolSchema]) => (
<McpServerListItem
key={`${serverName}-${toolName}`}
toolName={toolName}
toolSchema={toolSchema}
/>
<McpToolErrorBoundary key={`${serverName}-${toolName}`} toolName={toolName}>
<McpServerListItem toolName={toolName} toolSchema={toolSchema} />
</McpToolErrorBoundary>
))}
</div>
)}
Expand Down
50 changes: 17 additions & 33 deletions app/components/@settings/tabs/mcp/McpServerListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,21 @@
import { asSchema, type Tool } from 'ai';

type ParameterProperty = {
type?: string;
description?: string;
};
import { describeMcpTool } from '~/components/@settings/tabs/mcp/mcpToolSchema';

type McpToolProps = {
toolName: string;
toolSchema: Tool;

/*
* Not typed as `Tool`: these arrive as JSON from /api/mcp-update-config, so the runtime
* value is a plain object missing everything the AI SDK types promise.
*/
toolSchema: unknown;
};

export default function McpServerListItem({ toolName, toolSchema }: McpToolProps) {
if (!toolSchema) {
return null;
}

/*
* v5 renamed `parameters` to `inputSchema` and dropped the local ToolParameters
* shim in favour of asSchema, which normalises FlexibleSchema. The schema may
* be async, hence the local widening at the use site below.
*/
const { jsonSchema } = asSchema((toolSchema as { inputSchema: never }).inputSchema);
const resolved = jsonSchema as { properties?: Record<string, ParameterProperty>; required?: string[] } | undefined;
const parameters = resolved?.properties || {};
const requiredParams = resolved?.required || [];

/*
* Tool is a four-way union in v5 and `description` may be a function of the
* call context, which cannot be rendered directly.
*/
const description = typeof toolSchema.description === 'string' ? toolSchema.description : undefined;
const { description, parameters } = describeMcpTool(toolSchema);

return (
<div className="mt-2 ml-4 p-3 rounded-md bg-bolt-elements-background-depth-2 text-xs">
Expand All @@ -40,28 +26,26 @@ export default function McpServerListItem({ toolName, toolSchema }: McpToolProps

<p className="text-bolt-elements-textSecondary">{description || 'No description available'}</p>

{Object.keys(parameters).length > 0 && (
{parameters.length > 0 && (
<div className="mt-2.5">
<h4 className="text-bolt-elements-textSecondary font-semibold mb-1.5">Parameters:</h4>
<ul className="ml-1 space-y-2">
{Object.entries(parameters).map(([paramName, paramDetails]) => (
<li key={paramName} className="break-words">
{parameters.map((parameter) => (
<li key={parameter.name} className="break-words">
<div className="flex items-start">
<span className="font-medium text-bolt-elements-textPrimary">
{paramName}
{requiredParams.includes(paramName) && (
<span className="text-red-600 dark:text-red-400 ml-1">*</span>
)}
{parameter.name}
{parameter.required && <span className="text-red-600 dark:text-red-400 ml-1">*</span>}
</span>

<span className="mx-2 text-bolt-elements-textSecondary">•</span>

<div className="flex-1">
{paramDetails.type && (
<span className="text-bolt-elements-textSecondary italic">{paramDetails.type}</span>
{parameter.type && (
<span className="text-bolt-elements-textSecondary italic">{parameter.type}</span>
)}
{paramDetails.description && (
<div className="mt-0.5 text-bolt-elements-textSecondary">{paramDetails.description}</div>
{parameter.description && (
<div className="mt-0.5 text-bolt-elements-textSecondary">{parameter.description}</div>
)}
</div>
</div>
Expand Down
Loading