brave search

This commit is contained in:
Skirano
2024-11-20 15:50:41 -05:00
parent 1902c3bebb
commit 016f885a9c
5 changed files with 610 additions and 1 deletions

View File

@@ -23,6 +23,7 @@
"@modelcontextprotocol/server-gdrive": "*",
"@modelcontextprotocol/server-postgres": "*",
"@modelcontextprotocol/server-puppeteer": "*",
"@modelcontextprotocol/server-slack": "*"
"@modelcontextprotocol/server-slack": "*",
"@modelcontextprotocol/server-brave-search": "*"
}
}

View File

@@ -0,0 +1,94 @@
# Brave Search MCP Server
An MCP server implementation that integrates the Brave Search API, providing both web and local search capabilities through the Model Context Protocol.
## Features
- **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
## Configuration
### Client Configuration
Add this to your MCP client config:
```json
"brave-search": {
"command": "mcp-server-brave-search",
"env": {
"BRAVE_API_KEY": "YOUR_API_KEY_HERE"
}
}
```
Alternatively, you can set the API key as an environment variable:
```bash
export BRAVE_API_KEY='your_actual_api_key_here'
```
### Getting an API Key
1. Sign up for a Brave Search API account
2. Choose a plan (Free tier available)
3. Generate your API key from the developer dashboard
## Tools
### brave_web_search
Performs general web searches:
```javascript
{
"name": "brave_web_search",
"arguments": {
"query": "latest AI developments",
"count": 10,
"freshness": "pw", // Past week
"safesearch": "moderate"
}
}
```
### brave_local_search
Finds local businesses and services:
```javascript
{
"name": "brave_local_search",
"arguments": {
"query": "pizza near Central Park",
"count": 5,
"units": "imperial"
}
}
```
## Key Implementation Details
- Rate limiting to respect API quotas (1 request/second, 15000/month)
- Parallel fetching of POI details and descriptions for local search
- Type-safe argument validation
- Comprehensive error handling and logging
## Development
```bash
# Install dependencies
npm install
# Build the server
npm run build
# Run the server
mcp-server-brave-search
```
## Contributing
Contributions welcome! Please check the issues tab or submit a PR.
## License
MIT - see [LICENSE](LICENSE) file for details.

472
src/brave-search/index.ts Normal file
View File

@@ -0,0 +1,472 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
// Define tool schemas
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. " +
"Additional features:\n" +
"- Safesearch: moderate (default), strict, or off\n" +
"- Freshness: filter by recency (past day/week/month/year)\n" +
"- Result types: web, news, videos, discussions\n" +
"- Spell check and query alteration support",
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
},
freshness: {
type: "string",
description: "Filter by recency: pd (past day), pw (past week), pm (past month), or custom date range",
enum: ["pd", "pw", "pm", "py"]
},
safesearch: {
type: "string",
description: "Content filtering level",
enum: ["off", "moderate", "strict"],
default: "moderate"
},
country: {
type: "string",
description: "2-letter country code for localized results",
default: "US"
},
search_lang: {
type: "string",
description: "Search language (2+ char code)",
default: "en"
},
ui_lang: {
type: "string",
description: "UI language preference",
default: "en-US"
},
result_filter: {
type: "string",
description: "Comma-separated result types: web, news, videos, discussions, locations",
default: null
}
},
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" +
"- AI-generated descriptions\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
},
units: {
type: "string",
description: "Measurement system for distances",
enum: ["metric", "imperial"]
},
country: {
type: "string",
description: "2-letter country code for localized results",
default: "US"
},
search_lang: {
type: "string",
description: "Search language (2+ char code)",
default: "en"
},
ui_lang: {
type: "string",
description: "UI language preference",
default: "en-US"
}
},
required: ["query"]
}
};
// Server implementation
const server = new Server(
{
name: "example-servers/brave-search",
version: "0.1.0",
},
{
capabilities: {
tools: {},
resources: {},
},
},
);
// 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};
}
// Type guard functions for arguments
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"
);
}
// API functions
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('search_lang', 'en');
url.searchParams.set('count', Math.min(count, 20).toString()); // API limit
url.searchParams.set('offset', offset.toString());
url.searchParams.set('result_filter', 'web');
url.searchParams.set('text_decorations', '0');
url.searchParams.set('spellcheck', '0');
url.searchParams.set('safesearch', 'moderate');
url.searchParams.set('freshness', 'pw'); // Past week results
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';
}
// Resource handlers
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "brave://search",
mimeType: "text/plain",
name: "Brave Search Interface",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri.toString() === "brave://search") {
return {
contents: [
{
uri: "brave://search",
mimeType: "text/plain",
text: "Brave Search API interface",
},
],
};
}
throw new Error("Resource not found");
});
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("No arguments provided");
}
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,
};
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Brave Search MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
{
"name": "@modelcontextprotocol/server-brave-search",
"version": "0.1.0",
"description": "MCP server for Brave Search API integration",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
"type": "module",
"bin": {
"mcp-server-brave-search": "dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && shx chmod +x dist/*.js",
"prepare": "npm run build",
"watch": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "0.5.0",
"jsdom": "^24.1.3",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"@types/node": "^20.10.0",
"shx": "^0.3.4",
"typescript": "^5.6.2"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": [
"./**/*.ts"
]
}