diff --git a/package-lock.json b/package-lock.json index 6cb7c9c4..55939430 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "src/*" ], "dependencies": { + "@modelcontextprotocol/server-brave-search": "*", "@modelcontextprotocol/server-everything": "*", "@modelcontextprotocol/server-gdrive": "*", "@modelcontextprotocol/server-memory": "*", @@ -65,6 +66,10 @@ "zod": "^3.23.8" } }, + "node_modules/@modelcontextprotocol/server-brave-search": { + "resolved": "src/brave-search", + "link": true + }, "node_modules/@modelcontextprotocol/server-everything": { "resolved": "src/everything", "link": true @@ -1001,6 +1006,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", @@ -1032,6 +1060,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1727,6 +1767,25 @@ "node": ">= 0.4.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -2774,6 +2833,15 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -2895,6 +2963,80 @@ "zod": "^3.23.3" } }, + "src/brave-search": { + "name": "@modelcontextprotocol/server-brave-search", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "0.5.0", + "node-fetch": "^3.3.2" + }, + "bin": { + "mcp-server-brave-search": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "shx": "^0.3.4", + "typescript": "^5.6.2" + } + }, + "src/brave-search/node_modules/@types/node": { + "version": "20.17.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", + "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "src/brave-search/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "src/brave-search/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "src/duckduckgo": { + "name": "@modelcontextprotocol/server-duckduckgo", + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "0.5.0", + "jsdom": "^24.1.3", + "node-fetch": "^3.3.2" + }, + "bin": { + "mcp-server-duckduckgo": "dist/index.js" + }, + "devDependencies": { + "@types/jsdom": "^21.1.6", + "@types/node": "^20.10.0", + "shx": "^0.3.4", + "typescript": "^5.6.2" + } + }, "src/everything": { "name": "@modelcontextprotocol/server-everything", "version": "0.1.0", @@ -2914,6 +3056,24 @@ "typescript": "^5.6.2" } }, + "src/filesystem": { + "name": "@modelcontextprotocol/server-filesystem", + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "0.5.0", + "glob": "^10.3.10" + }, + "bin": { + "mcp-server-filesystem": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "shx": "^0.3.4", + "typescript": "^5.3.3" + } + }, "src/gdrive": { "name": "@modelcontextprotocol/server-gdrive", "version": "0.1.0", diff --git a/package.json b/package.json index 39e83766..57e35a1d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@modelcontextprotocol/server-postgres": "*", "@modelcontextprotocol/server-puppeteer": "*", "@modelcontextprotocol/server-slack": "*", + "@modelcontextprotocol/server-brave-search": "*", "@modelcontextprotocol/server-memory": "*" } } diff --git a/src/brave-search/README.md b/src/brave-search/README.md new file mode 100644 index 00000000..0c118e29 --- /dev/null +++ b/src/brave-search/README.md @@ -0,0 +1,46 @@ +# Brave Search MCP Server + +An MCP server implementation that integrates the Brave Search API, providing both web and local search capabilities. + +## 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 + +## Tools + +- **brave_web_search** + - Execute web searches with pagination and filtering + - Inputs: + - `query` (string): Search terms + - `count` (number, optional): Results per page (max 20) + - `offset` (number, optional): Pagination offset (max 9) + +- **brave_local_search** + - Search for local businesses and services + - Inputs: + - `query` (string): Local search terms + - `count` (number, optional): Number of results (max 20) + - Automatically falls back to web search if no local results found + + +## Configuration + +### 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) +3. Generate your API key [from the developer dashboard](https://api.search.brave.com/app/keys) + +### Usage with Claude Desktop +Add this to your `claude_desktop_config.json`: + +```json +"mcp-server-brave-search": { + "command": "mcp-server-brave-search", + "env": { + "BRAVE_API_KEY": "YOUR_API_KEY_HERE" + } +} +``` diff --git a/src/brave-search/index.ts b/src/brave-search/index.ts new file mode 100644 index 00000000..596dc9f7 --- /dev/null +++ b/src/brave-search/index.ts @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import fetch from "node-fetch"; + +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"] + } +}; + +// Server implementation +const server = new Server( + { + name: "example-servers/brave-search", + version: "0.1.0", + }, + { + capabilities: { + tools: {}, + }, + }, +); + +// 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 { + 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'; +} + +// 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..1b39f592 --- /dev/null +++ b/src/brave-search/package.json @@ -0,0 +1,30 @@ +{ + "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", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "@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" + ] + }