Skip to content

Commit

Permalink
Restructures server code, and adds Image search
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jonathansampson committed Feb 4, 2025
1 parent c5968be commit 70f0bea
Show file tree
Hide file tree
Showing 9 changed files with 535 additions and 313 deletions.
10 changes: 9 additions & 1 deletion src/brave-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ An MCP server implementation that integrates the Brave Search API, providing bot

## Features

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

## Tools

- **brave_image_search**
- Execute web searches with pagination and filtering
- Inputs:
- `query` (string): Search terms
- `count` (number, optional): Results per page (max 100)
- `safesearch` (string, optional): Filters results for sensitive content.

- **brave_web_search**
- Execute web searches with pagination and filtering
- Inputs:
Expand All @@ -30,7 +38,7 @@ An MCP server implementation that integrates the Brave Search API, providing bot

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

### Usage with Claude Desktop
Expand Down
8 changes: 8 additions & 0 deletions src/brave-search/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const BRAVE_API_KEY = process.env.BRAVE_API_KEY!;

export function checkEnvVariables() {
if (!BRAVE_API_KEY) {
console.error("Error: BRAVE_API_KEY environment variable is required");
process.exit(1);
}
}
325 changes: 13 additions & 312 deletions src/brave-search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,65 +5,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";

const WEB_SEARCH_TOOL: Tool = {
name: "brave_web_search",
description:
"Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. " +
"Use this for broad information gathering, recent events, or when you need diverse web sources. " +
"Supports pagination, content filtering, and freshness controls. " +
"Maximum 20 results per request, with offset for pagination. ",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (max 400 chars, 50 words)"
},
count: {
type: "number",
description: "Number of results (1-20, default 10)",
default: 10
},
offset: {
type: "number",
description: "Pagination offset (max 9, default 0)",
default: 0
},
},
required: ["query"],
},
};

const LOCAL_SEARCH_TOOL: Tool = {
name: "brave_local_search",
description:
"Searches for local businesses and places using Brave's Local Search API. " +
"Best for queries related to physical locations, businesses, restaurants, services, etc. " +
"Returns detailed information including:\n" +
"- Business names and addresses\n" +
"- Ratings and review counts\n" +
"- Phone numbers and opening hours\n" +
"Use this when the query implies 'near me' or mentions specific locations. " +
"Automatically falls back to web search if no local results are found.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Local search query (e.g. 'pizza near Central Park')"
},
count: {
type: "number",
description: "Number of results (1-20, default 5)",
default: 5
},
},
required: ["query"]
}
};
import tools from "./tools.js";
import { handleRequest as handleImageSearchRequest } from "./tools/imageSearch.js";
import { handleRequest as handleWebSearchRequest } from "./tools/webSearch.js";
import { handleRequest as handleLocalSearchRequest } from "./tools/localSearch.js";
import { checkEnvVariables } from "./env.js";

// Server implementation
const server = new Server(
Expand All @@ -79,238 +26,10 @@ const server = new Server(
);

// Check for API key
const BRAVE_API_KEY = process.env.BRAVE_API_KEY!;
if (!BRAVE_API_KEY) {
console.error("Error: BRAVE_API_KEY environment variable is required");
process.exit(1);
}

const RATE_LIMIT = {
perSecond: 1,
perMonth: 15000
};

let requestCount = {
second: 0,
month: 0,
lastReset: Date.now()
};

function checkRateLimit() {
const now = Date.now();
if (now - requestCount.lastReset > 1000) {
requestCount.second = 0;
requestCount.lastReset = now;
}
if (requestCount.second >= RATE_LIMIT.perSecond ||
requestCount.month >= RATE_LIMIT.perMonth) {
throw new Error('Rate limit exceeded');
}
requestCount.second++;
requestCount.month++;
}

interface BraveWeb {
web?: {
results?: Array<{
title: string;
description: string;
url: string;
language?: string;
published?: string;
rank?: number;
}>;
};
locations?: {
results?: Array<{
id: string; // Required by API
title?: string;
}>;
};
}

interface BraveLocation {
id: string;
name: string;
address: {
streetAddress?: string;
addressLocality?: string;
addressRegion?: string;
postalCode?: string;
};
coordinates?: {
latitude: number;
longitude: number;
};
phone?: string;
rating?: {
ratingValue?: number;
ratingCount?: number;
};
openingHours?: string[];
priceRange?: string;
}

interface BravePoiResponse {
results: BraveLocation[];
}

interface BraveDescription {
descriptions: {[id: string]: string};
}

function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === "object" &&
args !== null &&
"query" in args &&
typeof (args as { query: string }).query === "string"
);
}

function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === "object" &&
args !== null &&
"query" in args &&
typeof (args as { query: string }).query === "string"
);
}

async function performWebSearch(query: string, count: number = 10, offset: number = 0) {
checkRateLimit();
const url = new URL('https://api.search.brave.com/res/v1/web/search');
url.searchParams.set('q', query);
url.searchParams.set('count', Math.min(count, 20).toString()); // API limit
url.searchParams.set('offset', offset.toString());

const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': BRAVE_API_KEY
}
});

if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
}

const data = await response.json() as BraveWeb;

// Extract just web results
const results = (data.web?.results || []).map(result => ({
title: result.title || '',
description: result.description || '',
url: result.url || ''
}));

return results.map(r =>
`Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`
).join('\n\n');
}

async function performLocalSearch(query: string, count: number = 5) {
checkRateLimit();
// Initial search to get location IDs
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search');
webUrl.searchParams.set('q', query);
webUrl.searchParams.set('search_lang', 'en');
webUrl.searchParams.set('result_filter', 'locations');
webUrl.searchParams.set('count', Math.min(count, 20).toString());

const webResponse = await fetch(webUrl, {
headers: {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': BRAVE_API_KEY
}
});

if (!webResponse.ok) {
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`);
}

const webData = await webResponse.json() as BraveWeb;
const locationIds = webData.locations?.results?.filter((r): r is {id: string; title?: string} => r.id != null).map(r => r.id) || [];

if (locationIds.length === 0) {
return performWebSearch(query, count); // Fallback to web search
}

// Get POI details and descriptions in parallel
const [poisData, descriptionsData] = await Promise.all([
getPoisData(locationIds),
getDescriptionsData(locationIds)
]);

return formatLocalResults(poisData, descriptionsData);
}

async function getPoisData(ids: string[]): Promise<BravePoiResponse> {
checkRateLimit();
const url = new URL('https://api.search.brave.com/res/v1/local/pois');
ids.filter(Boolean).forEach(id => url.searchParams.append('ids', id));
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': BRAVE_API_KEY
}
});

if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
}

const poisResponse = await response.json() as BravePoiResponse;
return poisResponse;
}

async function getDescriptionsData(ids: string[]): Promise<BraveDescription> {
checkRateLimit();
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions');
ids.filter(Boolean).forEach(id => url.searchParams.append('ids', id));
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': BRAVE_API_KEY
}
});

if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`);
}

const descriptionsData = await response.json() as BraveDescription;
return descriptionsData;
}

function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
return (poisData.results || []).map(poi => {
const address = [
poi.address?.streetAddress ?? '',
poi.address?.addressLocality ?? '',
poi.address?.addressRegion ?? '',
poi.address?.postalCode ?? ''
].filter(part => part !== '').join(', ') || 'N/A';

return `Name: ${poi.name}
Address: ${address}
Phone: ${poi.phone || 'N/A'}
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
Price Range: ${poi.priceRange || 'N/A'}
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
Description: ${descData.descriptions[poi.id] || 'No description available'}
`;
}).join('\n---\n') || 'No local results found';
}
checkEnvVariables();

// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL],
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
Expand All @@ -321,30 +40,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}

switch (name) {
case "brave_web_search": {
if (!isBraveWebSearchArgs(args)) {
throw new Error("Invalid arguments for brave_web_search");
}
const { query, count = 10 } = args;
const results = await performWebSearch(query, count);
return {
content: [{ type: "text", text: results }],
isError: false,
};
}

case "brave_local_search": {
if (!isBraveLocalSearchArgs(args)) {
throw new Error("Invalid arguments for brave_local_search");
}
const { query, count = 5 } = args;
const results = await performLocalSearch(query, count);
return {
content: [{ type: "text", text: results }],
isError: false,
};
}

case "brave_web_search":
return handleWebSearchRequest(args);
case "brave_local_search":
return handleLocalSearchRequest(args);
case "brave_image_search":
return handleImageSearchRequest(args);
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
Expand Down
Loading

0 comments on commit 70f0bea

Please sign in to comment.