# Directory Structure ``` ├── .dxtignore ├── .github │ └── workflows │ └── publish.yml ├── .gitignore ├── DEBUGGING.md ├── Dockerfile ├── eslint.config.js ├── LICENSE ├── manifest.json ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ ├── index.ts │ ├── server.ts │ ├── tools │ │ ├── BaseTool.ts │ │ ├── BraveImageSearchTool.ts │ │ ├── BraveLocalSearchTool.ts │ │ ├── BraveNewsSearchTool.ts │ │ ├── BraveVideoSearchTool.ts │ │ └── BraveWebSearchTool.ts │ └── utils.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules build dist .env notes.txt .vscode ``` -------------------------------------------------------------------------------- /.dxtignore: -------------------------------------------------------------------------------- ``` notes.txt Dockerfile eslint.config.js smithery.yaml tsconfig.json DEBUGGING.md ./src ./.vscode/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Brave Search MCP Server An MCP Server implementation that integrates the [Brave Search API](https://brave.com/search/api/), providing, Web Search, Local Points of Interest Search, Video Search, Image Search and News Search capabilities <a href="https://glama.ai/mcp/servers/@mikechao/brave-search-mcp"> <img width="380" height="200" src="https://glama.ai/mcp/servers/@mikechao/brave-search-mcp/badge" alt="Brave Search MCP server" /> </a> ## Features - **Web Search**: Perform a regular search on the web - **Image Search**: Search the web for images. Image search results will be available as a Resource - **News Search**: Search the web for news - **Video Search**: Search the web for videos - **Local Points of Interest Search**: Search for local physical locations, businesses, restaurants, services, etc ## Tools - **brave_web_search** - Execute web searches using Brave's API - Inputs: - `query` (string): The term to search the internet for - `count` (number, optional): The number of results to return (max 20, default 10) - `offset` (number, optional, default 0): The offset for pagination - `freshness` (enum, optional): Filters search results by when they were discovered - The following values are supported - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30) - **brave_image_search** - Get images from the web relevant to the query - Inputs: - `query` (string): The term to search the internet for images of - `count` (number, optional): The number of images to return (max 3, default 1) - **brave_news_search** - Searches the web for news - Inputs: - `query` (string): The term to search the internet for news articles, trending topics, or recent events - `count` (number, optional): The number of results to return (max 20, default 10) - `freshness` (enum, optional): Filters search results by when they were discovered - The following values are supported - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30) - **brave_local_search** - Search for local businesses, services and points of interest - **REQUIRES** subscription to the Pro api plan for location results - Falls back to brave_web_search if no location results are found - Inputs: - `query` (string): Local search term - `count` (number, optional): The number of results to return (max 20, default 5) - **brave_video_search** - Search the web for videos - Inputs: - `query`: (string): The term to search for videos - `count`: (number, optional): The number of videos to return (max 20, default 10) - `freshness` (enum, optional): Filters search results by when they were discovered - The following values are supported - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30) ## 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 Code For [Claude Code](https://claude.ai/code) users, run this command: **Windows:** ```bash claude mcp add-json brave-search '{"command":"cmd","args":["/c","npx","-y","brave-search-mcp"],"env":{"BRAVE_API_KEY":"YOUR_API_KEY_HERE"}}' ``` **Linux/macOS:** ```bash claude mcp add-json brave-search '{"command":"npx","args":["-y","brave-search-mcp"],"env":{"BRAVE_API_KEY":"YOUR_API_KEY_HERE"}}' ``` Replace `YOUR_API_KEY_HERE` with your actual Brave Search API key. ### Usage with Claude Desktop ## Desktop Extension (DXT) 1. Download the `dxt` file from the [Releases](https://github.com/mikechao/brave-search-mcp/releases) 2. Open it with Claude Desktop or Go to File -> Settings -> Extensions and drag the .DXT file to the window to install it ## Docker 1. Clone the repo 2. Docker build ```bash docker build -t brave-search-mcp:latest -f ./Dockerfile . ``` 3. Add this to your `claude_desktop_config.json`: ```json { "mcp-servers": { "brave-search": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "BRAVE_API_KEY", "brave-search-mcp" ], "env": { "BRAVE_API_KEY": "YOUR API KEY HERE" } } } } ``` ### NPX Add this to your `claude_desktop_config.json`: ```json { "mcp-servers": { "brave-search": { "command": "npx", "args": [ "-y", "brave-search-mcp" ], "env": { "BRAVE_API_KEY": "YOUR API KEY HERE" } } } } ``` ### Usage with LibreChat Add this to librechat.yaml ```yaml brave-search: command: sh args: - -c - BRAVE_API_KEY=API KEY npx -y brave-search-mcp ``` ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Desktop Extensions (DXT) Anthropic recently released [Desktop Extensions](https://github.com/anthropics/dxt) allowing installation of local MCP Servers with one click. Install the CLI tool to help generate both `manifest.json` and final `.dxt` file. ```sh npm install -g @anthropic-ai/dxt ``` ### Creating the manifest.json file 1. In this folder/directory which contains the local MCP Server, run `dxt init`. The command will start an interactive CLI to help create the `manifest.json`. ### Creating the `dxt` file 1. First install dev dependencies and build ```sh npm install npm run build ``` 2. Then install only the production dependencies, generate a smaller nodule_modules directory ```sh npm install --omit=dev ``` 3. Run `dxt pack` to create a `dxt` file. This will also validate the manifest.json that was created. The `dxt` is essentially a zip file and will contain everything in this directory. ## Disclaimer This library is not officially associated with Brave Software. It is a third-party implementation of the Brave Search API with a MCP Server. ## License This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import antfu from '@antfu/eslint-config'; export default antfu({ formatters: true, typescript: { overrides: { 'no-console': 'off', }, }, stylistic: { semi: true, indent: 2, quotes: 'single', }, }); ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "rootDir": "./src", "module": "Node16", "moduleResolution": "Node16", "strict": true, "outDir": "./dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml startCommand: type: stdio configSchema: type: object require: - braveapikey properties: braveapikey: type: string description: The API key for Brave Search. commandFunction: |- (config) => ({ command: 'node', args: ['dist/index.js'], env: { BRAVE_API_KEY: config.braveapikey } }) exampleConfig: braveapikey: YOUR_API_KEY_HERE ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import process from 'node:process'; import { BraveMcpServer } from './server.js'; // 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 braveMcpServer = new BraveMcpServer(BRAVE_API_KEY); braveMcpServer.start().catch((error) => { console.error('Error starting server:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:23.11-alpine AS builder # Must be entire project because `prepare` script is run during `npm install` and requires all files. COPY . /app WORKDIR /app RUN --mount=type=cache,target=/root/.npm npm install FROM node:23.11-alpine AS release WORKDIR /app COPY --from=builder /app/dist /app/dist COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package-lock.json /app/package-lock.json ENV NODE_ENV=production RUN npm ci --ignore-scripts --omit-dev ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /src/tools/BaseTool.ts: -------------------------------------------------------------------------------- ```typescript import type { z } from 'zod'; export abstract class BaseTool<T extends z.ZodType, R> { public abstract readonly name: string; public abstract readonly description: string; public abstract readonly inputSchema: T; protected constructor() {} public abstract executeCore(input: z.infer<T>): Promise<R>; public async execute(input: z.infer<T>) { try { return await this.executeCore(input); } catch (error) { console.error(`Error executing ${this.name}:`, error); return { content: [{ type: 'text' as const, text: `Error in ${this.name}: ${error}`, }], isError: true, }; } } } ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish to NPM on: release: types: [created] jobs: publish-to-npm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20.19.0 registry-url: 'https://registry.npmjs.org/' cache: npm - name: Install dependencies and build 🔧 run: | npm ci npm run build || (echo "Build failed" && exit 1) - name: Publish to NPM 📦 run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} permissions: contents: read id-token: write create-dxt-release: needs: publish-to-npm runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20.19.0 cache: npm - name: Install dependencies and build 🔧 run: | npm ci npm run build || (echo "Build failed" && exit 1) - name: Install Anthropic DXT CLI run: npm install -g @anthropic-ai/dxt - name: Create DXT file run: | npm install --omit=dev dxt pack - name: Debug DXT file run: | ls -la *.dxt || echo "No .dxt files found" find . -name "*.dxt" -type f || echo "No .dxt files found in subdirectories" - name: Upload DXT to GitHub Release uses: softprops/action-gh-release@v2 with: files: ./brave-search-mcp.dxt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} permissions: contents: write id-token: write ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "brave-search-mcp", "type": "module", "version": "0.8.0", "description": "MCP Server that uses Brave Search API to search for images, general web search, video, news and points of interest.", "author": "[email protected]", "license": "GPL-3.0-or-later", "homepage": "https://github.com/mikechao/brave-search-mcp", "repository": { "type": "git", "url": "https://github.com/mikechao/brave-search-mcp.git" }, "bugs": { "url": "https://github.com/mikechao/brave-search-mcp/issues" }, "keywords": [ "brave", "mcp", "web-search", "image-search", "news-search", "brave-search-api", "brave-search", "brave-search-mcp" ], "main": "dist/index.js", "bin": { "brave-search-mcp": "dist/index.js" }, "files": [ "LICENSE", "README.md", "dist" ], "scripts": { "clean": "shx rm -rf dist && shx mkdir dist", "build": "npm run clean && tsc && shx chmod +x dist/*.js", "build:watch": "tsc --sourceMap -p tsconfig.json -w", "lint": "eslint . --ext .ts,.js,.mjs,.cjs --fix", "lint:check": "eslint . --ext .ts,.js,.mjs,.cjs", "typecheck": "tsc --noEmit", "check": "npm run lint:check && npm run typecheck" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "brave-search": "^0.9.0", "image-to-base64": "^2.2.0", "zod": "^3.24.3" }, "devDependencies": { "@antfu/eslint-config": "^4.11.0", "@modelcontextprotocol/inspector": "^0.14.1", "@types/express": "^5.0.1", "@types/image-to-base64": "^2.1.2", "@types/node": "^22.13.14", "eslint": "^9.23.0", "eslint-plugin-format": "^1.0.1", "shx": "^0.4.0", "typescript": "^5.8.2" }, "overrides": { "form-data": "^4.0.4" } } ``` -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- ```json { "dxt_version": "0.1", "name": "brave-search-mcp", "version": "0.8.0", "description": "MCP Server that uses Brave Search API to search for images, general web search, video, news and points of interest.", "author": { "name": "Mike Chao", "email": "[email protected]", "url": "https://github.com/mikechao" }, "homepage": "https://github.com/mikechao/brave-search-mcp", "documentation": "https://github.com/mikechao/brave-search-mcp/blob/main/README.md", "server": { "type": "node", "entry_point": "dist/index.js", "mcp_config": { "command": "node", "args": [ "${__dirname}/dist/index.js" ], "env": { "BRAVE_API_KEY": "${user_config.brave_api_key}" } } }, "tools": [ { "name": "brave_web_search", "description": "Execute web searches using Brave's API" }, { "name": "brave_image_search", "description": "Get images from the web relevant to the query" }, { "name": "brave_news_search", "description": "Searches the web for news" }, { "name": "brave_local_search", "description": "Search for local businesses, services and points of interest" }, { "name": "brave_video_search", "description": "Search the web for videos" } ], "keywords": [ "brave search", "web search", "image search", "video search" ], "license": "GPL-3.0-or-later", "user_config": { "brave_api_key": { "type": "string", "title": "Brave Search API Key", "description": "Your Brave Search API key. You can get one from https://search.brave.com/settings/api-keys", "sensitive": true, "required": true } }, "repository": { "type": "git", "url": "https://github.com/mikechao/brave-search-mcp.git" } } ``` -------------------------------------------------------------------------------- /src/tools/BraveImageSearchTool.ts: -------------------------------------------------------------------------------- ```typescript import type { BraveSearch } from 'brave-search'; import type { BraveMcpServer } from '../server.js'; import { SafeSearchLevel } from 'brave-search/dist/types.js'; import imageToBase64 from 'image-to-base64'; import { z } from 'zod'; import { BaseTool } from './BaseTool.js'; const imageSearchInputSchema = z.object({ searchTerm: z.string().describe('The term to search the internet for images of'), count: z.number().min(1).max(3).optional().default(1).describe('The number of images to search for, minimum 1, maximum 3'), }); export class BraveImageSearchTool extends BaseTool<typeof imageSearchInputSchema, any> { public readonly name = 'brave_image_search'; public readonly description = 'A tool for searching the web for images using the Brave Search API.'; public readonly inputSchema = imageSearchInputSchema; public readonly imageByTitle = new Map<string, string>(); constructor(private server: BraveMcpServer, private braveSearch: BraveSearch) { super(); } public async executeCore(input: z.infer<typeof imageSearchInputSchema>) { const { searchTerm, count } = input; this.server.log(`Searching for images of "${searchTerm}" with count ${count}`, 'debug'); const imageResults = await this.braveSearch.imageSearch(searchTerm, { count, safesearch: SafeSearchLevel.Strict, }); this.server.log(`Found ${imageResults.results.length} images for "${searchTerm}"`, 'debug'); const base64Strings = []; const titles = []; for (const result of imageResults.results) { const base64 = await imageToBase64(result.properties.url); this.server.log(`Image base64 length: ${base64.length}`, 'debug'); titles.push(result.title); base64Strings.push(base64); this.imageByTitle.set(result.title, base64); } const results = []; for (const [index, title] of titles.entries()) { results.push({ type: 'text', text: `${title}`, }); results.push({ type: 'image', data: base64Strings[index], mimeType: 'image/png', }); } this.server.resourceChangedNotification(); return { content: results }; } } ``` -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- ```typescript import type { LocalDescriptionsSearchApiResponse, LocalPoiSearchApiResponse, OpeningHours } from 'brave-search/dist/types.js'; import type { BraveVideoResult } from './tools/BraveVideoSearchTool.js'; export function formatPoiResults(poiData: LocalPoiSearchApiResponse, poiDesc: LocalDescriptionsSearchApiResponse) { return (poiData.results || []).map((poi) => { const description = poiDesc.results.find(locationDescription => locationDescription.id === poi.id); return `Name: ${poi.title}\n` + `${poi.serves_cuisine ? `Cuisine: ${poi.serves_cuisine.join(', ')}\n` : ''}` + `Address: ${poi.postal_address.displayAddress}\n` + `Phone: ${poi.contact?.telephone || 'No phone number found'}\n` + `Email: ${poi.contact?.email || 'No email found'}\n` + `Price Range: ${poi.price_range || 'No price range found'}\n` + `Ratings: ${poi.rating?.ratingValue || 'N/A'} (${poi.rating?.reviewCount}) reviews\n` + `Hours:\n ${(poi.opening_hours) ? formatOpeningHours(poi.opening_hours) : 'No opening hours found'}\n` + `Description: ${(description) ? description.description : 'No description found'}\n`; }).join('\n---\n'); } export function formatVideoResults(results: BraveVideoResult[]) { return (results || []).map((video) => { return `Title: ${video.title}\n` + `URL: ${video.url}\n` + `Description: ${video.description}\n` + `Age: ${video.age}\n` + `Duration: ${video.video.duration}\n` + `Views: ${video.video.views}\n` + `Creator: ${video.video.creator}\n` + `${('requires_subscription' in video.video) ? (video.video.requires_subscription ? 'Requires subscription\n' : 'No subscription\n') : ''} ` + `${('tags' in video.video && video.video.tags) ? (`Tags: ${video.video.tags.join(', ')}`) : ''} ` ; }).join('\n---\n'); } function formatOpeningHours(data: OpeningHours): string { const today = data.current_day.map((day) => { return `${day.full_name} ${day.opens} - ${day.closes}\n`; }); const weekly = data.days.map((daySlot) => { return daySlot.map((day) => { return `${day.full_name} ${day.opens} - ${day.closes}`; }); }); return `Today: ${today}\nWeekly:\n${weekly.join('\n')}`; } ``` -------------------------------------------------------------------------------- /src/tools/BraveNewsSearchTool.ts: -------------------------------------------------------------------------------- ```typescript import type { BraveSearch } from 'brave-search'; import type { BraveMcpServer } from '../server.js'; import { z } from 'zod'; import { BaseTool } from './BaseTool.js'; const newsSearchInputSchema = z.object({ query: z.string().describe('The term to search the internet for news articles, trending topics, or recent events'), count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'), freshness: z.union([ z.enum(['pd', 'pw', 'pm', 'py']), z.string().regex(/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/, 'Date range must be in format YYYY-MM-DDtoYYYY-MM-DD') ]) .optional() .describe( `Filters search results by when they were discovered. The following values are supported: - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days. - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`, ), }); export class BraveNewsSearchTool extends BaseTool<typeof newsSearchInputSchema, any> { public readonly name = 'brave_news_search'; public readonly description = 'Searches for news articles using the Brave Search API. ' + 'Use this for recent events, trending topics, or specific news stories. ' + 'Returns a list of articles with titles, URLs, and descriptions. ' + 'Maximum 20 results per request.'; public readonly inputSchema = newsSearchInputSchema; constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch) { super(); } public async executeCore(input: z.infer<typeof newsSearchInputSchema>) { const { query, count, freshness } = input; const newsResult = await this.braveSearch.newsSearch(query, { count, ...(freshness ? { freshness } : {}), }); if (!newsResult.results || newsResult.results.length === 0) { this.braveMcpServer.log(`No news results found for "${query}"`); const text = `No news results found for "${query}"`; return { content: [{ type: 'text' as const, text }] }; } const text = newsResult.results .map(result => `Title: ${result.title}\n` + `URL: ${result.url}\n` + `Age: ${result.age}\n` + `Description: ${result.description}\n`, ) .join('\n\n'); return { content: [{ type: 'text' as const, text }] }; } } ``` -------------------------------------------------------------------------------- /src/tools/BraveWebSearchTool.ts: -------------------------------------------------------------------------------- ```typescript import type { BraveSearch } from 'brave-search'; import type { BraveMcpServer } from '../server.js'; import { SafeSearchLevel } from 'brave-search/dist/types.js'; import { z } from 'zod'; import { BaseTool } from './BaseTool.js'; const webSearchInputSchema = z.object({ query: z.string().describe('The term to search the internet for'), count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'), offset: z.number().min(0).default(0).optional().describe('The offset for pagination, minimum 0'), freshness: z.union([ z.enum(['pd', 'pw', 'pm', 'py']), z.string().regex(/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/, 'Date range must be in format YYYY-MM-DDtoYYYY-MM-DD') ]) .optional() .describe( `Filters search results by when they were discovered. The following values are supported: - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days. - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`, ), }); export class BraveWebSearchTool extends BaseTool<typeof webSearchInputSchema, any> { public readonly name = 'brave_web_search'; public readonly description = 'Performs a web search using the Brave Search API, ideal for general queries, and online content. ' + 'Use this for broad information gathering, recent events, or when you need diverse web sources. ' + 'Maximum 20 results per request '; public readonly inputSchema = webSearchInputSchema; constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch) { super(); } public async executeCore(input: z.infer<typeof webSearchInputSchema>) { const { query, count, offset, freshness } = input; const results = await this.braveSearch.webSearch(query, { count, offset, safesearch: SafeSearchLevel.Strict, ...(freshness ? { freshness } : {}), }); if (!results.web || results.web?.results.length === 0) { this.braveMcpServer.log(`No results found for "${query}"`); const text = `No results found for "${query}"`; return { content: [{ type: 'text' as const, text }] }; } const text = results.web.results.map(result => `Title: ${result.title}\nURL: ${result.url}\nDescription: ${result.description}`).join('\n\n'); return { content: [{ type: 'text' as const, text }] }; } } ``` -------------------------------------------------------------------------------- /DEBUGGING.md: -------------------------------------------------------------------------------- ```markdown ## Debugging 1. Clone the repo 2. Install Dependencies and build it ```bash npm install ``` 3. Build the app ```bash npm run build ``` ### Use the VS Code Run and Debug Function ⚠ Does not seem to work on Windows 10/11, but works in WSL2 Use the VS Code [Run and Debug launcher](https://code.visualstudio.com/docs/debugtest/debugging#_start-a-debugging-session) with fully functional breakpoints in the code: 1. Locate and select the run debug. 2. Select the configuration labeled "`MCP Server Launcher`" in the dropdown. 3. Select the run/debug button. We can debug the various tools using [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and VS Code. ### VS Code Debug setup To set up local debugging with breakpoints: 1. Store Brave API Key in the VS Code - Open the Command Palette (Cmd/Ctrl + Shift + P). - Type `Preferences: Open User Settings (JSON)`. - Add the following snippet: ```json { "brave.search.api.key": "your-api-key-here" } ``` 2. Create or update `.vscode/launch.json`: ```json { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "MCP Server Launcher", "skipFiles": ["<node_internals>/**"], "program": "${workspaceFolder}/node_modules/@modelcontextprotocol/inspector/cli/build/cli.js", "outFiles": ["${workspaceFolder}/dist/**/*.js"], "env": { "BRAVE_API_KEY": "${config:brave.search.api.key}", "DEBUG": "true" }, "args": ["dist/index.js"], "sourceMaps": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "preLaunchTask": "npm: build:watch" }, { "type": "node", "request": "attach", "name": "Attach to Debug Hook Process", "port": 9332, "skipFiles": ["<node_internals>/**"], "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"] }, { "type": "node", "request": "attach", "name": "Attach to REPL Process", "port": 9333, "skipFiles": ["<node_internals>/**"], "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"] } ], "compounds": [ { "name": "Attach to MCP Server", "configurations": ["Attach to Debug Hook Process", "Attach to REPL Process"] } ] } ``` 3. Create `.vscode/tasks.json`: ```json { "version": "2.0.0", "tasks": [ { "type": "npm", "script": "build:watch", "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$tsc"] } ] } ``` ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { BraveSearch } from 'brave-search'; import { BraveImageSearchTool } from './tools/BraveImageSearchTool.js'; import { BraveLocalSearchTool } from './tools/BraveLocalSearchTool.js'; import { BraveNewsSearchTool } from './tools/BraveNewsSearchTool.js'; import { BraveVideoSearchTool } from './tools/BraveVideoSearchTool.js'; import { BraveWebSearchTool } from './tools/BraveWebSearchTool.js'; export class BraveMcpServer { private server: McpServer; private braveSearch: BraveSearch; private imageSearchTool: BraveImageSearchTool; private webSearchTool: BraveWebSearchTool; private localSearchTool: BraveLocalSearchTool; private newsSearchTool: BraveNewsSearchTool; private videoSearchTool: BraveVideoSearchTool; constructor(private braveSearchApiKey: string) { this.server = new McpServer( { name: 'Brave Search MCP Server', description: 'A server that provides tools for searching the web, images, videos, and local businesses using the Brave Search API.', version: '0.6.0', }, { capabilities: { resources: {}, tools: {}, logging: {}, }, }, ); this.braveSearch = new BraveSearch(braveSearchApiKey); this.imageSearchTool = new BraveImageSearchTool(this, this.braveSearch); this.webSearchTool = new BraveWebSearchTool(this, this.braveSearch); this.localSearchTool = new BraveLocalSearchTool(this, this.braveSearch, this.webSearchTool, braveSearchApiKey); this.newsSearchTool = new BraveNewsSearchTool(this, this.braveSearch); this.videoSearchTool = new BraveVideoSearchTool(this, this.braveSearch, braveSearchApiKey); this.setupTools(); this.setupResourceListener(); } private setupTools(): void { this.server.tool( this.imageSearchTool.name, this.imageSearchTool.description, this.imageSearchTool.inputSchema.shape, this.imageSearchTool.execute.bind(this.imageSearchTool), ); this.server.tool( this.webSearchTool.name, this.webSearchTool.description, this.webSearchTool.inputSchema.shape, this.webSearchTool.execute.bind(this.webSearchTool), ); this.server.tool( this.localSearchTool.name, this.localSearchTool.description, this.localSearchTool.inputSchema.shape, this.localSearchTool.execute.bind(this.localSearchTool), ); this.server.tool( this.newsSearchTool.name, this.newsSearchTool.description, this.newsSearchTool.inputSchema.shape, this.newsSearchTool.execute.bind(this.newsSearchTool), ); this.server.tool( this.videoSearchTool.name, this.videoSearchTool.description, this.videoSearchTool.inputSchema.shape, this.videoSearchTool.execute.bind(this.videoSearchTool), ); } private setupResourceListener(): void { this.server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ ...Array.from(this.imageSearchTool.imageByTitle.keys()).map(title => ({ uri: `brave-image://${title}`, mimeType: 'image/png', name: `${title}`, })), ], })); this.server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri.toString(); if (uri.startsWith('brave-image://')) { const title = uri.split('://')[1]; const image = this.imageSearchTool.imageByTitle.get(title); if (image) { return { contents: [{ uri, mimeType: 'image/png', blob: image, }], }; } } return { content: [{ type: 'text', text: `Resource not found: ${uri}` }], isError: true, }; }); } public async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); this.log('Server is running with Stdio transport'); } public resourceChangedNotification() { this.server.server.notification({ method: 'notifications/resources/list_changed', }); } public log( message: string, level: 'error' | 'debug' | 'info' | 'notice' | 'warning' | 'critical' | 'alert' | 'emergency' = 'info', ): void { this.server.server.sendLoggingMessage({ level, message, }); } } ``` -------------------------------------------------------------------------------- /src/tools/BraveVideoSearchTool.ts: -------------------------------------------------------------------------------- ```typescript import type { BraveSearch } from 'brave-search'; import type { BraveSearchOptions, Profile, Query, VideoData, VideoResult } from 'brave-search/dist/types.js'; import type { BraveMcpServer } from '../server.js'; import axios from 'axios'; import { SafeSearchLevel } from 'brave-search/dist/types.js'; import { z } from 'zod'; import { formatVideoResults } from '../utils.js'; import { BaseTool } from './BaseTool.js'; // workaround for https://github.com/erik-balfe/brave-search/pull/4 // not being merged yet into brave-search export interface BraveVideoData extends VideoData { /** * Whether the video requires a subscription. * @type {boolean} */ requires_subscription?: boolean; /** * A list of tags relevant to the video. * @type {string[]} */ tags?: string[]; /** * A profile associated with the video. * @type {Profile} */ author?: Profile; } export interface BraveVideoResult extends Omit<VideoResult, 'video'> { video: BraveVideoData; } export interface VideoSearchApiResponse { /** * The type of search API result. The value is always video. * @type {string} */ type: 'video'; /** * Video search query string. * @type {Query} */ query: Query; /** * The list of video results for the given query. * @type {BraveVideoResult[]} */ results: BraveVideoResult[]; } export interface VideoSearchOptions extends Pick<BraveSearchOptions, 'country' | 'search_lang' | 'ui_lang' | 'count' | 'offset' | 'spellcheck' | 'safesearch' | 'freshness'> { } // end workaround const videoSearchInputSchema = z.object({ query: z.string().describe('The term to search the internet for videos of'), count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'), freshness: z.union([ z.enum(['pd', 'pw', 'pm', 'py']), z.string().regex(/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/, 'Date range must be in format YYYY-MM-DDtoYYYY-MM-DD') ]) .optional() .describe( `Filters search results by when they were discovered. The following values are supported: - pd: Discovered within the last 24 hours. - pw: Discovered within the last 7 Days. - pm: Discovered within the last 31 Days. - py: Discovered within the last 365 Days. - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`, ), }); export class BraveVideoSearchTool extends BaseTool<typeof videoSearchInputSchema, any> { public readonly name = 'brave_video_search'; public readonly description = 'Searches for videos using the Brave Search API. ' + 'Use this for video content, tutorials, or any media-related queries. ' + 'Returns a list of videos with titles, URLs, and descriptions. ' + 'Maximum 20 results per request.'; public readonly inputSchema = videoSearchInputSchema; private baseUrl = 'https://api.search.brave.com/res/v1'; constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch, private apiKey: string) { super(); } public async executeCore(input: z.infer<typeof videoSearchInputSchema>) { const { query, count, freshness } = input; const videoSearchResults = await this.videoSearch(query, { count, safesearch: SafeSearchLevel.Strict, ...(freshness ? { freshness } : {}), }); if (!videoSearchResults.results || videoSearchResults.results.length === 0) { this.braveMcpServer.log(`No video results found for "${query}"`); const text = `No video results found for "${query}"`; return { content: [{ type: 'text' as const, text }] }; } const text = formatVideoResults(videoSearchResults.results); return { content: [{ type: 'text' as const, text }] }; } // workaround for https://github.com/erik-balfe/brave-search/pull/4 // not being merged yet into brave-search private async videoSearch( query: string, options: VideoSearchOptions = {}, ): Promise<VideoSearchApiResponse> { const response = await axios.get<VideoSearchApiResponse>( `${this.baseUrl}/videos/search?`, { params: { q: query, ...this.formatOptions(options), }, headers: this.getHeaders(), }, ); return response.data; } private formatOptions(options: Record<string, any>): Record<string, string> { return Object.entries(options).reduce( (acc, [key, value]) => { if (value !== undefined) { acc[key] = value.toString(); } return acc; }, {} as Record<string, string>, ); } private getHeaders() { return { 'Accept': 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': this.apiKey, }; } // end workaround } ``` -------------------------------------------------------------------------------- /src/tools/BraveLocalSearchTool.ts: -------------------------------------------------------------------------------- ```typescript import type { BraveSearch, LocalDescriptionsSearchApiResponse, LocalPoiSearchApiResponse } from 'brave-search'; import type { BraveMcpServer } from '../server.js'; import type { BraveWebSearchTool } from './BraveWebSearchTool.js'; import { SafeSearchLevel } from 'brave-search/dist/types.js'; import { z } from 'zod'; import { formatPoiResults } from '../utils.js'; import { BaseTool } from './BaseTool.js'; const localSearchInputSchema = z.object({ query: z.string().describe('Local search query (e.g. \'pizza near Central Park\')'), count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'), }); export class BraveLocalSearchTool extends BaseTool<typeof localSearchInputSchema, any> { public readonly name = 'brave_local_search'; public readonly 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.'; public readonly inputSchema = localSearchInputSchema; private baseUrl = 'https://api.search.brave.com/res/v1'; constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch, private webSearchTool: BraveWebSearchTool, private apiKey: string) { super(); } public async executeCore(input: z.infer<typeof localSearchInputSchema>) { const { query, count } = input; const results = await this.braveSearch.webSearch(query, { count, safesearch: SafeSearchLevel.Strict, result_filter: 'locations', }); // it looks like the count parameter is only good for web search results if (!results.locations || results.locations?.results.length === 0) { this.braveMcpServer.log(`No location results found for "${query}" falling back to web search. Make sure your API Plan is at least "Pro"`); return this.webSearchTool.executeCore({ query, count, offset: 0 }); } const allIds = results.locations.results.map(result => result.id); // count is restricted to 20 in the schema, and the location api supports up to 20 at a time // so we can just use the count parameter to limit the number of ids, take the first "count" ids const ids = allIds.slice(0, count); this.braveMcpServer.log(`Using ${ids.length} of ${allIds.length} location IDs for "${query}"`, 'debug'); const formattedText = []; const localPoiSearchApiResponse = await this.localPoiSearch(ids); // the call to localPoiSearch does not return the id of the pois // add them here, they should be in the same order as the ids // and the same order of id in localDescriptionsSearchApiResponse localPoiSearchApiResponse.results.forEach((result, index) => { (result as any).id = ids[index]; }); const localDescriptionsSearchApiResponse = await this.localDescriptionsSearch(ids); const text = formatPoiResults(localPoiSearchApiResponse, localDescriptionsSearchApiResponse); formattedText.push(text); const finalText = formattedText.join('\n\n'); return { content: [{ type: 'text' as const, text: finalText }] }; } // workaround for https://github.com/erik-balfe/brave-search/pull/3 // not being merged yet into brave-search private async localPoiSearch(ids: string[]) { try { const qs = ids.map(id => `ids=${encodeURIComponent(id)}`).join('&'); const url = `${this.baseUrl}/local/pois?${qs}`; this.braveMcpServer.log(`Fetching local POI data from ${url}`, 'debug'); const res = await fetch(url, { method: 'GET', headers: this.getHeaders(), redirect: 'follow', }); if (!res.ok) { throw new Error(`Error fetching local POI data Status:${res.status} Status Text:${res.statusText} Headers:${JSON.stringify(res.headers)}`); } const data = (await res.json()) as LocalPoiSearchApiResponse; return data; } catch (error) { this.handleError(error); throw error; } } private async localDescriptionsSearch(ids: string[]) { try { const qs = ids.map(id => `ids=${encodeURIComponent(id)}`).join('&'); const url = `${this.baseUrl}/local/descriptions?${qs}`; const res = await fetch(url, { method: 'GET', headers: this.getHeaders(), redirect: 'follow', }); if (!res.ok) { const responseText = await res.text(); this.braveMcpServer.log(`Error response body: ${responseText}`, 'error'); this.braveMcpServer.log(`Response headers: ${JSON.stringify(Object.fromEntries(res.headers.entries()))}`, 'error'); this.braveMcpServer.log(`Request URL: ${url}`, 'error'); this.braveMcpServer.log(`Request headers: ${JSON.stringify(this.getHeaders())}`, 'error'); if (res.status === 429) { this.braveMcpServer.log('429 Rate limit exceeded, consider adding delay between requests', 'error'); } else if (res.status === 403) { this.braveMcpServer.log('403 Authentication error - check your API key', 'error'); } else if (res.status === 500) { this.braveMcpServer.log('500 Internal server error - might be an issue with request format or API temporary issues', 'error'); } // return an empty response instead of error so we can at least return the pois results return { type: 'local_descriptions', results: [], } as LocalDescriptionsSearchApiResponse; } const data = (await res.json()) as LocalDescriptionsSearchApiResponse; return data; } catch (error) { this.handleError(error); throw error; } } private handleError(error: any) { this.braveMcpServer.log(`${error}`, 'error'); } private getHeaders() { return { 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'X-Subscription-Token': this.apiKey, 'User-Agent': 'BraveSearchMCP/1.0', 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', }; } // end workaround } ```