diff --git a/package.json b/package.json index 047179f8..b0ef6fa8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@modelcontextprotocol/server-gdrive": "*", "@modelcontextprotocol/server-postgres": "*", "@modelcontextprotocol/server-puppeteer": "*", - "@modelcontextprotocol/server-slack": "*" + "@modelcontextprotocol/server-slack": "*", + "@modelcontextprotocol/server-brave-search": "*" } } diff --git a/src/brave-search/README.md b/src/brave-search/README.md new file mode 100644 index 00000000..2c1661d9 --- /dev/null +++ b/src/brave-search/README.md @@ -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. diff --git a/src/brave-search/index.ts b/src/brave-search/index.ts new file mode 100644 index 00000000..1f814dbe --- /dev/null +++ b/src/brave-search/index.ts @@ -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 { + 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 { + 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); +}); diff --git a/src/brave-search/package.json b/src/brave-search/package.json new file mode 100644 index 00000000..3769c583 --- /dev/null +++ b/src/brave-search/package.json @@ -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" + } +} diff --git a/src/brave-search/tsconfig.json b/src/brave-search/tsconfig.json new file mode 100644 index 00000000..087f641d --- /dev/null +++ b/src/brave-search/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "./**/*.ts" + ] + }