Skip to content

Commit 70f0bea

Browse files
Restructures server code, and adds Image search
The code has been restructured into components for maintainability. Each tool now has its own file, containing the description of the tool, related interfaces, and methods. This PR also introduces support for a new tool: brave_image_search. Claude may not always immediately share image URLs in its results, but will do so if prompted again. An effort has been made to provide a prompt that will strongly encourage Claude to show clickable-links in its results when querying Brave Search's API for images.
1 parent c5968be commit 70f0bea

File tree

9 files changed

+535
-313
lines changed

9 files changed

+535
-313
lines changed

src/brave-search/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ An MCP server implementation that integrates the Brave Search API, providing bot
44

55
## Features
66

7+
- **Image Search**: Query Brave Search for various images.
78
- **Web Search**: General queries, news, articles, with pagination and freshness controls
89
- **Local Search**: Find businesses, restaurants, and services with detailed information
910
- **Flexible Filtering**: Control result types, safety levels, and content freshness
1011
- **Smart Fallbacks**: Local search automatically falls back to web when no results are found
1112

1213
## Tools
1314

15+
- **brave_image_search**
16+
- Execute web searches with pagination and filtering
17+
- Inputs:
18+
- `query` (string): Search terms
19+
- `count` (number, optional): Results per page (max 100)
20+
- `safesearch` (string, optional): Filters results for sensitive content.
21+
1422
- **brave_web_search**
1523
- Execute web searches with pagination and filtering
1624
- Inputs:
@@ -30,7 +38,7 @@ An MCP server implementation that integrates the Brave Search API, providing bot
3038

3139
### Getting an API Key
3240
1. Sign up for a [Brave Search API account](https://brave.com/search/api/)
33-
2. Choose a plan (Free tier available with 2,000 queries/month)
41+
2. Choose a _Data for AI_ plan (Free tier available with 1 query/second and 2,000 queries/month)
3442
3. Generate your API key [from the developer dashboard](https://api.search.brave.com/app/keys)
3543

3644
### Usage with Claude Desktop

src/brave-search/env.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const BRAVE_API_KEY = process.env.BRAVE_API_KEY!;
2+
3+
export function checkEnvVariables() {
4+
if (!BRAVE_API_KEY) {
5+
console.error("Error: BRAVE_API_KEY environment variable is required");
6+
process.exit(1);
7+
}
8+
}

src/brave-search/index.ts

Lines changed: 13 additions & 312 deletions
Original file line numberDiff line numberDiff line change
@@ -5,65 +5,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
55
import {
66
CallToolRequestSchema,
77
ListToolsRequestSchema,
8-
Tool,
98
} from "@modelcontextprotocol/sdk/types.js";
10-
11-
const WEB_SEARCH_TOOL: Tool = {
12-
name: "brave_web_search",
13-
description:
14-
"Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. " +
15-
"Use this for broad information gathering, recent events, or when you need diverse web sources. " +
16-
"Supports pagination, content filtering, and freshness controls. " +
17-
"Maximum 20 results per request, with offset for pagination. ",
18-
inputSchema: {
19-
type: "object",
20-
properties: {
21-
query: {
22-
type: "string",
23-
description: "Search query (max 400 chars, 50 words)"
24-
},
25-
count: {
26-
type: "number",
27-
description: "Number of results (1-20, default 10)",
28-
default: 10
29-
},
30-
offset: {
31-
type: "number",
32-
description: "Pagination offset (max 9, default 0)",
33-
default: 0
34-
},
35-
},
36-
required: ["query"],
37-
},
38-
};
39-
40-
const LOCAL_SEARCH_TOOL: Tool = {
41-
name: "brave_local_search",
42-
description:
43-
"Searches for local businesses and places using Brave's Local Search API. " +
44-
"Best for queries related to physical locations, businesses, restaurants, services, etc. " +
45-
"Returns detailed information including:\n" +
46-
"- Business names and addresses\n" +
47-
"- Ratings and review counts\n" +
48-
"- Phone numbers and opening hours\n" +
49-
"Use this when the query implies 'near me' or mentions specific locations. " +
50-
"Automatically falls back to web search if no local results are found.",
51-
inputSchema: {
52-
type: "object",
53-
properties: {
54-
query: {
55-
type: "string",
56-
description: "Local search query (e.g. 'pizza near Central Park')"
57-
},
58-
count: {
59-
type: "number",
60-
description: "Number of results (1-20, default 5)",
61-
default: 5
62-
},
63-
},
64-
required: ["query"]
65-
}
66-
};
9+
import tools from "./tools.js";
10+
import { handleRequest as handleImageSearchRequest } from "./tools/imageSearch.js";
11+
import { handleRequest as handleWebSearchRequest } from "./tools/webSearch.js";
12+
import { handleRequest as handleLocalSearchRequest } from "./tools/localSearch.js";
13+
import { checkEnvVariables } from "./env.js";
6714

6815
// Server implementation
6916
const server = new Server(
@@ -79,238 +26,10 @@ const server = new Server(
7926
);
8027

8128
// Check for API key
82-
const BRAVE_API_KEY = process.env.BRAVE_API_KEY!;
83-
if (!BRAVE_API_KEY) {
84-
console.error("Error: BRAVE_API_KEY environment variable is required");
85-
process.exit(1);
86-
}
87-
88-
const RATE_LIMIT = {
89-
perSecond: 1,
90-
perMonth: 15000
91-
};
92-
93-
let requestCount = {
94-
second: 0,
95-
month: 0,
96-
lastReset: Date.now()
97-
};
98-
99-
function checkRateLimit() {
100-
const now = Date.now();
101-
if (now - requestCount.lastReset > 1000) {
102-
requestCount.second = 0;
103-
requestCount.lastReset = now;
104-
}
105-
if (requestCount.second >= RATE_LIMIT.perSecond ||
106-
requestCount.month >= RATE_LIMIT.perMonth) {
107-
throw new Error('Rate limit exceeded');
108-
}
109-
requestCount.second++;
110-
requestCount.month++;
111-
}
112-
113-
interface BraveWeb {
114-
web?: {
115-
results?: Array<{
116-
title: string;
117-
description: string;
118-
url: string;
119-
language?: string;
120-
published?: string;
121-
rank?: number;
122-
}>;
123-
};
124-
locations?: {
125-
results?: Array<{
126-
id: string; // Required by API
127-
title?: string;
128-
}>;
129-
};
130-
}
131-
132-
interface BraveLocation {
133-
id: string;
134-
name: string;
135-
address: {
136-
streetAddress?: string;
137-
addressLocality?: string;
138-
addressRegion?: string;
139-
postalCode?: string;
140-
};
141-
coordinates?: {
142-
latitude: number;
143-
longitude: number;
144-
};
145-
phone?: string;
146-
rating?: {
147-
ratingValue?: number;
148-
ratingCount?: number;
149-
};
150-
openingHours?: string[];
151-
priceRange?: string;
152-
}
153-
154-
interface BravePoiResponse {
155-
results: BraveLocation[];
156-
}
157-
158-
interface BraveDescription {
159-
descriptions: {[id: string]: string};
160-
}
161-
162-
function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
163-
return (
164-
typeof args === "object" &&
165-
args !== null &&
166-
"query" in args &&
167-
typeof (args as { query: string }).query === "string"
168-
);
169-
}
170-
171-
function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
172-
return (
173-
typeof args === "object" &&
174-
args !== null &&
175-
"query" in args &&
176-
typeof (args as { query: string }).query === "string"
177-
);
178-
}
179-
180-
async function performWebSearch(query: string, count: number = 10, offset: number = 0) {
181-
checkRateLimit();
182-
const url = new URL('https://api.search.brave.com/res/v1/web/search');
183-
url.searchParams.set('q', query);
184-
url.searchParams.set('count', Math.min(count, 20).toString()); // API limit
185-
url.searchParams.set('offset', offset.toString());
186-
187-
const response = await fetch(url, {
188-
headers: {
189-
'Accept': 'application/json',
190-
'Accept-Encoding': 'gzip',
191-
'X-Subscription-Token': BRAVE_API_KEY
192-
}
193-
});
194-
195-
if (!response.ok) {
196-
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
197-
}
198-
199-
const data = await response.json() as BraveWeb;
200-
201-
// Extract just web results
202-
const results = (data.web?.results || []).map(result => ({
203-
title: result.title || '',
204-
description: result.description || '',
205-
url: result.url || ''
206-
}));
207-
208-
return results.map(r =>
209-
`Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`
210-
).join('\n\n');
211-
}
212-
213-
async function performLocalSearch(query: string, count: number = 5) {
214-
checkRateLimit();
215-
// Initial search to get location IDs
216-
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search');
217-
webUrl.searchParams.set('q', query);
218-
webUrl.searchParams.set('search_lang', 'en');
219-
webUrl.searchParams.set('result_filter', 'locations');
220-
webUrl.searchParams.set('count', Math.min(count, 20).toString());
221-
222-
const webResponse = await fetch(webUrl, {
223-
headers: {
224-
'Accept': 'application/json',
225-
'Accept-Encoding': 'gzip',
226-
'X-Subscription-Token': BRAVE_API_KEY
227-
}
228-
});
229-
230-
if (!webResponse.ok) {
231-
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`);
232-
}
233-
234-
const webData = await webResponse.json() as BraveWeb;
235-
const locationIds = webData.locations?.results?.filter((r): r is {id: string; title?: string} => r.id != null).map(r => r.id) || [];
236-
237-
if (locationIds.length === 0) {
238-
return performWebSearch(query, count); // Fallback to web search
239-
}
240-
241-
// Get POI details and descriptions in parallel
242-
const [poisData, descriptionsData] = await Promise.all([
243-
getPoisData(locationIds),
244-
getDescriptionsData(locationIds)
245-
]);
246-
247-
return formatLocalResults(poisData, descriptionsData);
248-
}
249-
250-
async function getPoisData(ids: string[]): Promise<BravePoiResponse> {
251-
checkRateLimit();
252-
const url = new URL('https://api.search.brave.com/res/v1/local/pois');
253-
ids.filter(Boolean).forEach(id => url.searchParams.append('ids', id));
254-
const response = await fetch(url, {
255-
headers: {
256-
'Accept': 'application/json',
257-
'Accept-Encoding': 'gzip',
258-
'X-Subscription-Token': BRAVE_API_KEY
259-
}
260-
});
261-
262-
if (!response.ok) {
263-
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
264-
}
265-
266-
const poisResponse = await response.json() as BravePoiResponse;
267-
return poisResponse;
268-
}
269-
270-
async function getDescriptionsData(ids: string[]): Promise<BraveDescription> {
271-
checkRateLimit();
272-
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions');
273-
ids.filter(Boolean).forEach(id => url.searchParams.append('ids', id));
274-
const response = await fetch(url, {
275-
headers: {
276-
'Accept': 'application/json',
277-
'Accept-Encoding': 'gzip',
278-
'X-Subscription-Token': BRAVE_API_KEY
279-
}
280-
});
281-
282-
if (!response.ok) {
283-
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
284-
}
285-
286-
const descriptionsData = await response.json() as BraveDescription;
287-
return descriptionsData;
288-
}
289-
290-
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
291-
return (poisData.results || []).map(poi => {
292-
const address = [
293-
poi.address?.streetAddress ?? '',
294-
poi.address?.addressLocality ?? '',
295-
poi.address?.addressRegion ?? '',
296-
poi.address?.postalCode ?? ''
297-
].filter(part => part !== '').join(', ') || 'N/A';
298-
299-
return `Name: ${poi.name}
300-
Address: ${address}
301-
Phone: ${poi.phone || 'N/A'}
302-
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
303-
Price Range: ${poi.priceRange || 'N/A'}
304-
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
305-
Description: ${descData.descriptions[poi.id] || 'No description available'}
306-
`;
307-
}).join('\n---\n') || 'No local results found';
308-
}
29+
checkEnvVariables();
30930

31031
// Tool handlers
311-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
312-
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL],
313-
}));
32+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
31433

31534
server.setRequestHandler(CallToolRequestSchema, async (request) => {
31635
try {
@@ -321,30 +40,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
32140
}
32241

32342
switch (name) {
324-
case "brave_web_search": {
325-
if (!isBraveWebSearchArgs(args)) {
326-
throw new Error("Invalid arguments for brave_web_search");
327-
}
328-
const { query, count = 10 } = args;
329-
const results = await performWebSearch(query, count);
330-
return {
331-
content: [{ type: "text", text: results }],
332-
isError: false,
333-
};
334-
}
335-
336-
case "brave_local_search": {
337-
if (!isBraveLocalSearchArgs(args)) {
338-
throw new Error("Invalid arguments for brave_local_search");
339-
}
340-
const { query, count = 5 } = args;
341-
const results = await performLocalSearch(query, count);
342-
return {
343-
content: [{ type: "text", text: results }],
344-
isError: false,
345-
};
346-
}
347-
43+
case "brave_web_search":
44+
return handleWebSearchRequest(args);
45+
case "brave_local_search":
46+
return handleLocalSearchRequest(args);
47+
case "brave_image_search":
48+
return handleImageSearchRequest(args);
34849
default:
34950
return {
35051
content: [{ type: "text", text: `Unknown tool: ${name}` }],

0 commit comments

Comments
 (0)