#
tokens: 14895/50000 20/20 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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:
--------------------------------------------------------------------------------

```
1 | node_modules
2 | build
3 | dist
4 | .env
5 | notes.txt
6 | .vscode
```

--------------------------------------------------------------------------------
/.dxtignore:
--------------------------------------------------------------------------------

```
1 | notes.txt
2 | Dockerfile
3 | eslint.config.js
4 | smithery.yaml
5 | tsconfig.json
6 | DEBUGGING.md
7 | ./src
8 | ./.vscode/
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Brave Search MCP Server
  2 | 
  3 | 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
  4 | 
  5 | <a href="https://glama.ai/mcp/servers/@mikechao/brave-search-mcp">
  6 |   <img width="380" height="200" src="https://glama.ai/mcp/servers/@mikechao/brave-search-mcp/badge" alt="Brave Search MCP server" />
  7 | </a>
  8 | 
  9 | ## Features
 10 | 
 11 | - **Web Search**: Perform a regular search on the web
 12 | - **Image Search**: Search the web for images. Image search results will be available as a Resource
 13 | - **News Search**: Search the web for news
 14 | - **Video Search**: Search the web for videos
 15 | - **Local Points of Interest Search**: Search for local physical locations, businesses, restaurants, services, etc
 16 | 
 17 | ## Tools
 18 | 
 19 | - **brave_web_search**
 20 | 
 21 |   - Execute web searches using Brave's API
 22 |   - Inputs:
 23 |     - `query` (string): The term to search the internet for
 24 |     - `count` (number, optional): The number of results to return (max 20, default 10)
 25 |     - `offset` (number, optional, default 0): The offset for pagination
 26 |     - `freshness` (enum, optional): Filters search results by when they were discovered
 27 |       - The following values are supported
 28 |         - pd: Discovered within the last 24 hours.
 29 |         - pw: Discovered within the last 7 Days.
 30 |         - pm: Discovered within the last 31 Days.
 31 |         - py: Discovered within the last 365 Days
 32 |         - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)
 33 | 
 34 | - **brave_image_search**
 35 | 
 36 |   - Get images from the web relevant to the query
 37 |   - Inputs:
 38 |     - `query` (string): The term to search the internet for images of
 39 |     - `count` (number, optional): The number of images to return (max 3, default 1)
 40 | 
 41 | - **brave_news_search**
 42 | 
 43 |   - Searches the web for news
 44 |   - Inputs:
 45 |     - `query` (string): The term to search the internet for news articles, trending topics, or recent events
 46 |     - `count` (number, optional): The number of results to return (max 20, default 10)
 47 |     - `freshness` (enum, optional): Filters search results by when they were discovered
 48 |       - The following values are supported
 49 |         - pd: Discovered within the last 24 hours.
 50 |         - pw: Discovered within the last 7 Days.
 51 |         - pm: Discovered within the last 31 Days.
 52 |         - py: Discovered within the last 365 Days
 53 |         - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)
 54 | 
 55 | - **brave_local_search**
 56 | 
 57 |   - Search for local businesses, services and points of interest
 58 |   - **REQUIRES** subscription to the Pro api plan for location results
 59 |   - Falls back to brave_web_search if no location results are found
 60 |   - Inputs:
 61 |     - `query` (string): Local search term
 62 |     - `count` (number, optional): The number of results to return (max 20, default 5)
 63 | 
 64 | - **brave_video_search**
 65 | 
 66 |   - Search the web for videos
 67 |   - Inputs:
 68 |     - `query`: (string): The term to search for videos
 69 |     - `count`: (number, optional): The number of videos to return (max 20, default 10)
 70 |     - `freshness` (enum, optional): Filters search results by when they were discovered
 71 |       - The following values are supported
 72 |         - pd: Discovered within the last 24 hours.
 73 |         - pw: Discovered within the last 7 Days.
 74 |         - pm: Discovered within the last 31 Days.
 75 |         - py: Discovered within the last 365 Days
 76 |         - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)
 77 | 
 78 | ## Configuration
 79 | 
 80 | ### Getting an API Key
 81 | 
 82 | 1. Sign up for a [Brave Search API account](https://brave.com/search/api/)
 83 | 2. Choose a plan (Free tier available with 2,000 queries/month)
 84 | 3. Generate your API key [from the developer dashboard](https://api.search.brave.com/app/keys)
 85 | 
 86 | ### Usage with Claude Code
 87 | 
 88 | For [Claude Code](https://claude.ai/code) users, run this command:
 89 | 
 90 | **Windows:**
 91 | 
 92 | ```bash
 93 | claude mcp add-json brave-search '{"command":"cmd","args":["/c","npx","-y","brave-search-mcp"],"env":{"BRAVE_API_KEY":"YOUR_API_KEY_HERE"}}'
 94 | ```
 95 | 
 96 | **Linux/macOS:**
 97 | 
 98 | ```bash
 99 | claude mcp add-json brave-search '{"command":"npx","args":["-y","brave-search-mcp"],"env":{"BRAVE_API_KEY":"YOUR_API_KEY_HERE"}}'
100 | ```
101 | 
102 | Replace `YOUR_API_KEY_HERE` with your actual Brave Search API key.
103 | 
104 | ### Usage with Claude Desktop
105 | 
106 | ## Desktop Extension (DXT)
107 | 
108 | 1. Download the `dxt` file from the [Releases](https://github.com/mikechao/brave-search-mcp/releases)
109 | 2. Open it with Claude Desktop
110 |    or
111 |    Go to File -> Settings -> Extensions and drag the .DXT file to the window to install it
112 | 
113 | ## Docker
114 | 
115 | 1. Clone the repo
116 | 2. Docker build
117 | 
118 | ```bash
119 | docker build -t brave-search-mcp:latest -f ./Dockerfile .
120 | ```
121 | 
122 | 3. Add this to your `claude_desktop_config.json`:
123 | 
124 | ```json
125 | {
126 |   "mcp-servers": {
127 |     "brave-search": {
128 |       "command": "docker",
129 |       "args": [
130 |         "run",
131 |         "-i",
132 |         "--rm",
133 |         "-e",
134 |         "BRAVE_API_KEY",
135 |         "brave-search-mcp"
136 |       ],
137 |       "env": {
138 |         "BRAVE_API_KEY": "YOUR API KEY HERE"
139 |       }
140 |     }
141 |   }
142 | }
143 | ```
144 | 
145 | ### NPX
146 | 
147 | Add this to your `claude_desktop_config.json`:
148 | 
149 | ```json
150 | {
151 |   "mcp-servers": {
152 |     "brave-search": {
153 |       "command": "npx",
154 |       "args": [
155 |         "-y",
156 |         "brave-search-mcp"
157 |       ],
158 |       "env": {
159 |         "BRAVE_API_KEY": "YOUR API KEY HERE"
160 |       }
161 |     }
162 |   }
163 | }
164 | ```
165 | 
166 | ### Usage with LibreChat
167 | 
168 | Add this to librechat.yaml
169 | 
170 | ```yaml
171 | brave-search:
172 |   command: sh
173 |   args:
174 |     - -c
175 |     - BRAVE_API_KEY=API KEY npx -y brave-search-mcp
176 | ```
177 | 
178 | ## Contributing
179 | 
180 | Contributions are welcome! Please feel free to submit a Pull Request.
181 | 
182 | ## Desktop Extensions (DXT)
183 | 
184 | Anthropic recently released [Desktop Extensions](https://github.com/anthropics/dxt) allowing installation of local MCP Servers with one click.
185 | 
186 | Install the CLI tool to help generate both `manifest.json` and final `.dxt` file.
187 | 
188 | ```sh
189 | npm install -g @anthropic-ai/dxt
190 | ```
191 | 
192 | ### Creating the manifest.json file
193 | 
194 | 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`.
195 | 
196 | ### Creating the `dxt` file
197 | 
198 | 1. First install dev dependencies and build
199 | 
200 | ```sh
201 | npm install
202 | npm run build
203 | ```
204 | 
205 | 2. Then install only the production dependencies, generate a smaller nodule_modules directory
206 | 
207 | ```sh
208 | npm install --omit=dev
209 | ```
210 | 
211 | 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.
212 | 
213 | ## Disclaimer
214 | 
215 | This library is not officially associated with Brave Software. It is a third-party implementation of the Brave Search API with a MCP Server.
216 | 
217 | ## License
218 | 
219 | This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
220 | 
```

--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------

```javascript
 1 | import antfu from '@antfu/eslint-config';
 2 | 
 3 | export default antfu({
 4 |   formatters: true,
 5 |   typescript: {
 6 |     overrides: {
 7 |       'no-console': 'off',
 8 |     },
 9 |   },
10 |   stylistic: {
11 |     semi: true,
12 |     indent: 2,
13 |     quotes: 'single',
14 |   },
15 | });
16 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "rootDir": "./src",
 5 |     "module": "Node16",
 6 |     "moduleResolution": "Node16",
 7 |     "strict": true,
 8 |     "outDir": "./dist",
 9 |     "esModuleInterop": true,
10 |     "forceConsistentCasingInFileNames": true,
11 |     "skipLibCheck": true
12 |   },
13 |   "include": ["src/**/*"],
14 |   "exclude": ["node_modules"]
15 | }
16 | 
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
 1 | startCommand:
 2 |   type: stdio
 3 |   configSchema:
 4 |     type: object
 5 |     require:
 6 |       - braveapikey
 7 |     properties:
 8 |       braveapikey:
 9 |         type: string
10 |         description: The API key for Brave Search.
11 |   commandFunction:
12 |     |-
13 |       (config) => ({ command: 'node', args: ['dist/index.js'], env: { BRAVE_API_KEY: config.braveapikey } })
14 |   exampleConfig:
15 |     braveapikey: YOUR_API_KEY_HERE
16 | 
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | #!/usr/bin/env node
 2 | 
 3 | import process from 'node:process';
 4 | import { BraveMcpServer } from './server.js';
 5 | 
 6 | // Check for API key
 7 | const BRAVE_API_KEY = process.env.BRAVE_API_KEY;
 8 | if (!BRAVE_API_KEY) {
 9 |   console.error('Error: BRAVE_API_KEY environment variable is required');
10 |   process.exit(1);
11 | }
12 | 
13 | const braveMcpServer = new BraveMcpServer(BRAVE_API_KEY);
14 | braveMcpServer.start().catch((error) => {
15 |   console.error('Error starting server:', error);
16 |   process.exit(1);
17 | });
18 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | FROM node:23.11-alpine AS builder
 2 | 
 3 | # Must be entire project because `prepare` script is run during `npm install` and requires all files.
 4 | COPY . /app
 5 | 
 6 | WORKDIR /app
 7 | 
 8 | RUN --mount=type=cache,target=/root/.npm npm install
 9 | 
10 | FROM node:23.11-alpine AS release
11 | 
12 | WORKDIR /app
13 | 
14 | COPY --from=builder /app/dist /app/dist
15 | COPY --from=builder /app/package.json /app/package.json
16 | COPY --from=builder /app/package-lock.json /app/package-lock.json
17 | 
18 | ENV NODE_ENV=production
19 | 
20 | RUN npm ci --ignore-scripts --omit-dev
21 | 
22 | ENTRYPOINT ["node", "dist/index.js"]
```

--------------------------------------------------------------------------------
/src/tools/BaseTool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { z } from 'zod';
 2 | 
 3 | export abstract class BaseTool<T extends z.ZodType, R> {
 4 |   public abstract readonly name: string;
 5 |   public abstract readonly description: string;
 6 |   public abstract readonly inputSchema: T;
 7 | 
 8 |   protected constructor() {}
 9 | 
10 |   public abstract executeCore(input: z.infer<T>): Promise<R>;
11 | 
12 |   public async execute(input: z.infer<T>) {
13 |     try {
14 |       return await this.executeCore(input);
15 |     }
16 |     catch (error) {
17 |       console.error(`Error executing ${this.name}:`, error);
18 |       return {
19 |         content: [{
20 |           type: 'text' as const,
21 |           text: `Error in ${this.name}: ${error}`,
22 |         }],
23 |         isError: true,
24 |       };
25 |     }
26 |   }
27 | }
28 | 
```

--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Publish to NPM
 2 | on:
 3 |   release:
 4 |     types: [created]
 5 | jobs:
 6 |   publish-to-npm:
 7 |     runs-on: ubuntu-latest
 8 |     steps:
 9 |       - uses: actions/checkout@v4
10 |       - uses: actions/setup-node@v4
11 |         with:
12 |           node-version: 20.19.0
13 |           registry-url: 'https://registry.npmjs.org/'
14 |           cache: npm
15 |       - name: Install dependencies and build 🔧
16 |         run: |
17 |           npm ci
18 |           npm run build || (echo "Build failed" && exit 1)
19 |       - name: Publish to NPM 📦
20 |         run: npm publish
21 |         env:
22 |           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
23 |     permissions:
24 |       contents: read
25 |       id-token: write
26 | 
27 |   create-dxt-release:
28 |     needs: publish-to-npm
29 |     runs-on: ubuntu-latest
30 |     steps:
31 |       - uses: actions/checkout@v4
32 |       - uses: actions/setup-node@v4
33 |         with:
34 |           node-version: 20.19.0
35 |           cache: npm
36 |       - name: Install dependencies and build 🔧
37 |         run: |
38 |           npm ci
39 |           npm run build || (echo "Build failed" && exit 1)
40 |       - name: Install Anthropic DXT CLI
41 |         run: npm install -g @anthropic-ai/dxt
42 |       - name: Create DXT file
43 |         run: |
44 |           npm install --omit=dev
45 |           dxt pack
46 |       - name: Debug DXT file
47 |         run: |
48 |           ls -la *.dxt || echo "No .dxt files found"
49 |           find . -name "*.dxt" -type f || echo "No .dxt files found in subdirectories"
50 |       - name: Upload DXT to GitHub Release
51 |         uses: softprops/action-gh-release@v2
52 |         with:
53 |           files: ./brave-search-mcp.dxt
54 |         env:
55 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 |     permissions:
57 |       contents: write
58 |       id-token: write
59 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "brave-search-mcp",
 3 |   "type": "module",
 4 |   "version": "0.8.0",
 5 |   "description": "MCP Server that uses Brave Search API to search for images, general web search, video, news and points of interest.",
 6 |   "author": "[email protected]",
 7 |   "license": "GPL-3.0-or-later",
 8 |   "homepage": "https://github.com/mikechao/brave-search-mcp",
 9 |   "repository": {
10 |     "type": "git",
11 |     "url": "https://github.com/mikechao/brave-search-mcp.git"
12 |   },
13 |   "bugs": {
14 |     "url": "https://github.com/mikechao/brave-search-mcp/issues"
15 |   },
16 |   "keywords": [
17 |     "brave",
18 |     "mcp",
19 |     "web-search",
20 |     "image-search",
21 |     "news-search",
22 |     "brave-search-api",
23 |     "brave-search",
24 |     "brave-search-mcp"
25 |   ],
26 |   "main": "dist/index.js",
27 |   "bin": {
28 |     "brave-search-mcp": "dist/index.js"
29 |   },
30 |   "files": [
31 |     "LICENSE",
32 |     "README.md",
33 |     "dist"
34 |   ],
35 |   "scripts": {
36 |     "clean": "shx rm -rf dist && shx mkdir dist",
37 |     "build": "npm run clean && tsc && shx chmod +x dist/*.js",
38 |     "build:watch": "tsc --sourceMap -p tsconfig.json -w",
39 |     "lint": "eslint . --ext .ts,.js,.mjs,.cjs --fix",
40 |     "lint:check": "eslint . --ext .ts,.js,.mjs,.cjs",
41 |     "typecheck": "tsc --noEmit",
42 |     "check": "npm run lint:check && npm run typecheck"
43 |   },
44 |   "dependencies": {
45 |     "@modelcontextprotocol/sdk": "^1.8.0",
46 |     "brave-search": "^0.9.0",
47 |     "image-to-base64": "^2.2.0",
48 |     "zod": "^3.24.3"
49 |   },
50 |   "devDependencies": {
51 |     "@antfu/eslint-config": "^4.11.0",
52 |     "@modelcontextprotocol/inspector": "^0.14.1",
53 |     "@types/express": "^5.0.1",
54 |     "@types/image-to-base64": "^2.1.2",
55 |     "@types/node": "^22.13.14",
56 |     "eslint": "^9.23.0",
57 |     "eslint-plugin-format": "^1.0.1",
58 |     "shx": "^0.4.0",
59 |     "typescript": "^5.8.2"
60 |   },
61 |   "overrides": {
62 |     "form-data": "^4.0.4"
63 |   }
64 | }
65 | 
```

--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "dxt_version": "0.1",
 3 |   "name": "brave-search-mcp",
 4 |   "version": "0.8.0",
 5 |   "description": "MCP Server that uses Brave Search API to search for images, general web search, video, news and points of interest.",
 6 |   "author": {
 7 |     "name": "Mike Chao",
 8 |     "email": "[email protected]",
 9 |     "url": "https://github.com/mikechao"
10 |   },
11 |   "homepage": "https://github.com/mikechao/brave-search-mcp",
12 |   "documentation": "https://github.com/mikechao/brave-search-mcp/blob/main/README.md",
13 |   "server": {
14 |     "type": "node",
15 |     "entry_point": "dist/index.js",
16 |     "mcp_config": {
17 |       "command": "node",
18 |       "args": [
19 |         "${__dirname}/dist/index.js"
20 |       ],
21 |       "env": {
22 |         "BRAVE_API_KEY": "${user_config.brave_api_key}"
23 |       }
24 |     }
25 |   },
26 |   "tools": [
27 |     {
28 |       "name": "brave_web_search",
29 |       "description": "Execute web searches using Brave's API"
30 |     },
31 |     {
32 |       "name": "brave_image_search",
33 |       "description": "Get images from the web relevant to the query"
34 |     },
35 |     {
36 |       "name": "brave_news_search",
37 |       "description": "Searches the web for news"
38 |     },
39 |     {
40 |       "name": "brave_local_search",
41 |       "description": "Search for local businesses, services and points of interest"
42 |     },
43 |     {
44 |       "name": "brave_video_search",
45 |       "description": "Search the web for videos"
46 |     }
47 |   ],
48 |   "keywords": [
49 |     "brave search",
50 |     "web search",
51 |     "image search",
52 |     "video search"
53 |   ],
54 |   "license": "GPL-3.0-or-later",
55 |   "user_config": {
56 |     "brave_api_key": {
57 |       "type": "string",
58 |       "title": "Brave Search API Key",
59 |       "description": "Your Brave Search API key. You can get one from https://search.brave.com/settings/api-keys",
60 |       "sensitive": true,
61 |       "required": true
62 |     }
63 |   },
64 |   "repository": {
65 |     "type": "git",
66 |     "url": "https://github.com/mikechao/brave-search-mcp.git"
67 |   }
68 | }
69 | 
```

--------------------------------------------------------------------------------
/src/tools/BraveImageSearchTool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { BraveSearch } from 'brave-search';
 2 | import type { BraveMcpServer } from '../server.js';
 3 | import { SafeSearchLevel } from 'brave-search/dist/types.js';
 4 | import imageToBase64 from 'image-to-base64';
 5 | import { z } from 'zod';
 6 | import { BaseTool } from './BaseTool.js';
 7 | 
 8 | const imageSearchInputSchema = z.object({
 9 |   searchTerm: z.string().describe('The term to search the internet for images of'),
10 |   count: z.number().min(1).max(3).optional().default(1).describe('The number of images to search for, minimum 1, maximum 3'),
11 | });
12 | 
13 | export class BraveImageSearchTool extends BaseTool<typeof imageSearchInputSchema, any> {
14 |   public readonly name = 'brave_image_search';
15 |   public readonly description = 'A tool for searching the web for images using the Brave Search API.';
16 |   public readonly inputSchema = imageSearchInputSchema;
17 |   public readonly imageByTitle = new Map<string, string>();
18 | 
19 |   constructor(private server: BraveMcpServer, private braveSearch: BraveSearch) {
20 |     super();
21 |   }
22 | 
23 |   public async executeCore(input: z.infer<typeof imageSearchInputSchema>) {
24 |     const { searchTerm, count } = input;
25 |     this.server.log(`Searching for images of "${searchTerm}" with count ${count}`, 'debug');
26 | 
27 |     const imageResults = await this.braveSearch.imageSearch(searchTerm, {
28 |       count,
29 |       safesearch: SafeSearchLevel.Strict,
30 |     });
31 |     this.server.log(`Found ${imageResults.results.length} images for "${searchTerm}"`, 'debug');
32 |     const base64Strings = [];
33 |     const titles = [];
34 |     for (const result of imageResults.results) {
35 |       const base64 = await imageToBase64(result.properties.url);
36 |       this.server.log(`Image base64 length: ${base64.length}`, 'debug');
37 |       titles.push(result.title);
38 |       base64Strings.push(base64);
39 |       this.imageByTitle.set(result.title, base64);
40 |     }
41 |     const results = [];
42 |     for (const [index, title] of titles.entries()) {
43 |       results.push({
44 |         type: 'text',
45 |         text: `${title}`,
46 |       });
47 |       results.push({
48 |         type: 'image',
49 |         data: base64Strings[index],
50 |         mimeType: 'image/png',
51 |       });
52 |     }
53 |     this.server.resourceChangedNotification();
54 |     return { content: results };
55 |   }
56 | }
57 | 
```

--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { LocalDescriptionsSearchApiResponse, LocalPoiSearchApiResponse, OpeningHours } from 'brave-search/dist/types.js';
 2 | import type { BraveVideoResult } from './tools/BraveVideoSearchTool.js';
 3 | 
 4 | export function formatPoiResults(poiData: LocalPoiSearchApiResponse, poiDesc: LocalDescriptionsSearchApiResponse) {
 5 |   return (poiData.results || []).map((poi) => {
 6 |     const description = poiDesc.results.find(locationDescription => locationDescription.id === poi.id);
 7 |     return `Name: ${poi.title}\n`
 8 |       + `${poi.serves_cuisine ? `Cuisine: ${poi.serves_cuisine.join(', ')}\n` : ''}`
 9 |       + `Address: ${poi.postal_address.displayAddress}\n`
10 |       + `Phone: ${poi.contact?.telephone || 'No phone number found'}\n`
11 |       + `Email: ${poi.contact?.email || 'No email found'}\n`
12 |       + `Price Range: ${poi.price_range || 'No price range found'}\n`
13 |       + `Ratings: ${poi.rating?.ratingValue || 'N/A'} (${poi.rating?.reviewCount}) reviews\n`
14 |       + `Hours:\n ${(poi.opening_hours) ? formatOpeningHours(poi.opening_hours) : 'No opening hours found'}\n`
15 |       + `Description: ${(description) ? description.description : 'No description found'}\n`;
16 |   }).join('\n---\n');
17 | }
18 | 
19 | export function formatVideoResults(results: BraveVideoResult[]) {
20 |   return (results || []).map((video) => {
21 |     return `Title: ${video.title}\n`
22 |       + `URL: ${video.url}\n`
23 |       + `Description: ${video.description}\n`
24 |       + `Age: ${video.age}\n`
25 |       + `Duration: ${video.video.duration}\n`
26 |       + `Views: ${video.video.views}\n`
27 |       + `Creator: ${video.video.creator}\n`
28 |       + `${('requires_subscription' in video.video)
29 |         ? (video.video.requires_subscription ? 'Requires subscription\n' : 'No subscription\n')
30 |         : ''} `
31 |         + `${('tags' in video.video && video.video.tags)
32 |           ? (`Tags: ${video.video.tags.join(', ')}`)
33 |           : ''} `
34 |     ;
35 |   }).join('\n---\n');
36 | }
37 | 
38 | function formatOpeningHours(data: OpeningHours): string {
39 |   const today = data.current_day.map((day) => {
40 |     return `${day.full_name} ${day.opens} - ${day.closes}\n`;
41 |   });
42 |   const weekly = data.days.map((daySlot) => {
43 |     return daySlot.map((day) => {
44 |       return `${day.full_name} ${day.opens} - ${day.closes}`;
45 |     });
46 |   });
47 |   return `Today: ${today}\nWeekly:\n${weekly.join('\n')}`;
48 | }
49 | 
```

--------------------------------------------------------------------------------
/src/tools/BraveNewsSearchTool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { BraveSearch } from 'brave-search';
 2 | import type { BraveMcpServer } from '../server.js';
 3 | import { z } from 'zod';
 4 | import { BaseTool } from './BaseTool.js';
 5 | 
 6 | const newsSearchInputSchema = z.object({
 7 |   query: z.string().describe('The term to search the internet for news articles, trending topics, or recent events'),
 8 |   count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'),
 9 |   freshness: z.union([
10 |     z.enum(['pd', 'pw', 'pm', 'py']),
11 |     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')
12 |   ])
13 |     .optional()
14 |     .describe(
15 |       `Filters search results by when they were discovered.
16 | The following values are supported:
17 | - pd: Discovered within the last 24 hours.
18 | - pw: Discovered within the last 7 Days.
19 | - pm: Discovered within the last 31 Days.
20 | - py: Discovered within the last 365 Days.
21 | - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`,
22 |     ),
23 | });
24 | 
25 | export class BraveNewsSearchTool extends BaseTool<typeof newsSearchInputSchema, any> {
26 |   public readonly name = 'brave_news_search';
27 |   public readonly description = 'Searches for news articles using the Brave Search API. '
28 |     + 'Use this for recent events, trending topics, or specific news stories. '
29 |     + 'Returns a list of articles with titles, URLs, and descriptions. '
30 |     + 'Maximum 20 results per request.';
31 | 
32 |   public readonly inputSchema = newsSearchInputSchema;
33 | 
34 |   constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch) {
35 |     super();
36 |   }
37 | 
38 |   public async executeCore(input: z.infer<typeof newsSearchInputSchema>) {
39 |     const { query, count, freshness } = input;
40 |     const newsResult = await this.braveSearch.newsSearch(query, {
41 |       count,
42 |       ...(freshness ? { freshness } : {}),
43 |     });
44 |     if (!newsResult.results || newsResult.results.length === 0) {
45 |       this.braveMcpServer.log(`No news results found for "${query}"`);
46 |       const text = `No news results found for "${query}"`;
47 |       return { content: [{ type: 'text' as const, text }] };
48 |     }
49 | 
50 |     const text = newsResult.results
51 |       .map(result =>
52 |         `Title: ${result.title}\n`
53 |         + `URL: ${result.url}\n`
54 |         + `Age: ${result.age}\n`
55 |         + `Description: ${result.description}\n`,
56 |       )
57 |       .join('\n\n');
58 |     return { content: [{ type: 'text' as const, text }] };
59 |   }
60 | }
61 | 
```

--------------------------------------------------------------------------------
/src/tools/BraveWebSearchTool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { BraveSearch } from 'brave-search';
 2 | import type { BraveMcpServer } from '../server.js';
 3 | import { SafeSearchLevel } from 'brave-search/dist/types.js';
 4 | import { z } from 'zod';
 5 | import { BaseTool } from './BaseTool.js';
 6 | 
 7 | const webSearchInputSchema = z.object({
 8 |   query: z.string().describe('The term to search the internet for'),
 9 |   count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'),
10 |   offset: z.number().min(0).default(0).optional().describe('The offset for pagination, minimum 0'),
11 |   freshness: z.union([
12 |     z.enum(['pd', 'pw', 'pm', 'py']),
13 |     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')
14 |   ])
15 |     .optional()
16 |     .describe(
17 |       `Filters search results by when they were discovered.
18 | The following values are supported:
19 | - pd: Discovered within the last 24 hours.
20 | - pw: Discovered within the last 7 Days.
21 | - pm: Discovered within the last 31 Days.
22 | - py: Discovered within the last 365 Days.
23 | - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`,
24 |     ),
25 | });
26 | 
27 | export class BraveWebSearchTool extends BaseTool<typeof webSearchInputSchema, any> {
28 |   public readonly name = 'brave_web_search';
29 |   public readonly description = 'Performs a web search using the Brave Search API, ideal for general queries, and online content. '
30 |     + 'Use this for broad information gathering, recent events, or when you need diverse web sources. '
31 |     + 'Maximum 20 results per request ';
32 | 
33 |   public readonly inputSchema = webSearchInputSchema;
34 | 
35 |   constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch) {
36 |     super();
37 |   }
38 | 
39 |   public async executeCore(input: z.infer<typeof webSearchInputSchema>) {
40 |     const { query, count, offset, freshness } = input;
41 |     const results = await this.braveSearch.webSearch(query, {
42 |       count,
43 |       offset,
44 |       safesearch: SafeSearchLevel.Strict,
45 |       ...(freshness ? { freshness } : {}),
46 |     });
47 |     if (!results.web || results.web?.results.length === 0) {
48 |       this.braveMcpServer.log(`No results found for "${query}"`);
49 |       const text = `No results found for "${query}"`;
50 |       return { content: [{ type: 'text' as const, text }] };
51 |     }
52 |     const text = results.web.results.map(result => `Title: ${result.title}\nURL: ${result.url}\nDescription: ${result.description}`).join('\n\n');
53 |     return { content: [{ type: 'text' as const, text }] };
54 |   }
55 | }
56 | 
```

--------------------------------------------------------------------------------
/DEBUGGING.md:
--------------------------------------------------------------------------------

```markdown
  1 | ## Debugging
  2 | 
  3 | 1. Clone the repo
  4 | 
  5 | 2. Install Dependencies and build it
  6 | 
  7 | ```bash
  8 | npm install
  9 | ```
 10 | 
 11 | 3. Build the app
 12 | 
 13 | ```bash
 14 | npm run build
 15 | ```
 16 | 
 17 | ### Use the VS Code Run and Debug Function
 18 | 
 19 | ⚠ Does not seem to work on Windows 10/11, but works in WSL2
 20 | 
 21 | Use the VS Code
 22 | [Run and Debug launcher](https://code.visualstudio.com/docs/debugtest/debugging#_start-a-debugging-session) with fully
 23 | functional breakpoints in the code:
 24 | 
 25 | 1. Locate and select the run debug.
 26 | 2. Select the configuration labeled "`MCP Server Launcher`" in the dropdown.
 27 | 3. Select the run/debug button.
 28 |    We can debug the various tools using [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and VS Code.
 29 | 
 30 | ### VS Code Debug setup
 31 | 
 32 | To set up local debugging with breakpoints:
 33 | 
 34 | 1. Store Brave API Key in the VS Code
 35 | 
 36 |    - Open the Command Palette (Cmd/Ctrl + Shift + P).
 37 |    - Type `Preferences: Open User Settings (JSON)`.
 38 |    - Add the following snippet:
 39 | 
 40 |    ```json
 41 |    {
 42 |      "brave.search.api.key": "your-api-key-here"
 43 |    }
 44 |    ```
 45 | 
 46 | 2. Create or update `.vscode/launch.json`:
 47 | 
 48 | ```json
 49 | {
 50 |   "version": "0.2.0",
 51 |   "configurations": [
 52 |     {
 53 |       "type": "node",
 54 |       "request": "launch",
 55 |       "name": "MCP Server Launcher",
 56 |       "skipFiles": ["<node_internals>/**"],
 57 |       "program": "${workspaceFolder}/node_modules/@modelcontextprotocol/inspector/cli/build/cli.js",
 58 |       "outFiles": ["${workspaceFolder}/dist/**/*.js"],
 59 |       "env": {
 60 |         "BRAVE_API_KEY": "${config:brave.search.api.key}",
 61 |         "DEBUG": "true"
 62 |       },
 63 |       "args": ["dist/index.js"],
 64 |       "sourceMaps": true,
 65 |       "console": "integratedTerminal",
 66 |       "internalConsoleOptions": "neverOpen",
 67 |       "preLaunchTask": "npm: build:watch"
 68 |     },
 69 |     {
 70 |       "type": "node",
 71 |       "request": "attach",
 72 |       "name": "Attach to Debug Hook Process",
 73 |       "port": 9332,
 74 |       "skipFiles": ["<node_internals>/**"],
 75 |       "sourceMaps": true,
 76 |       "outFiles": ["${workspaceFolder}/dist/**/*.js"]
 77 |     },
 78 |     {
 79 |       "type": "node",
 80 |       "request": "attach",
 81 |       "name": "Attach to REPL Process",
 82 |       "port": 9333,
 83 |       "skipFiles": ["<node_internals>/**"],
 84 |       "sourceMaps": true,
 85 |       "outFiles": ["${workspaceFolder}/dist/**/*.js"]
 86 |     }
 87 |   ],
 88 |   "compounds": [
 89 |     {
 90 |       "name": "Attach to MCP Server",
 91 |       "configurations": ["Attach to Debug Hook Process", "Attach to REPL Process"]
 92 |     }
 93 |   ]
 94 | }
 95 | ```
 96 | 
 97 | 3. Create `.vscode/tasks.json`:
 98 | 
 99 | ```json
100 | {
101 |   "version": "2.0.0",
102 |   "tasks": [
103 |     {
104 |       "type": "npm",
105 |       "script": "build:watch",
106 |       "group": {
107 |         "kind": "build",
108 |         "isDefault": true
109 |       },
110 |       "problemMatcher": ["$tsc"]
111 |     }
112 |   ]
113 | }
114 | ```
```

--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
  2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
  3 | import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
  4 | import { BraveSearch } from 'brave-search';
  5 | import { BraveImageSearchTool } from './tools/BraveImageSearchTool.js';
  6 | import { BraveLocalSearchTool } from './tools/BraveLocalSearchTool.js';
  7 | import { BraveNewsSearchTool } from './tools/BraveNewsSearchTool.js';
  8 | import { BraveVideoSearchTool } from './tools/BraveVideoSearchTool.js';
  9 | import { BraveWebSearchTool } from './tools/BraveWebSearchTool.js';
 10 | 
 11 | export class BraveMcpServer {
 12 |   private server: McpServer;
 13 |   private braveSearch: BraveSearch;
 14 |   private imageSearchTool: BraveImageSearchTool;
 15 |   private webSearchTool: BraveWebSearchTool;
 16 |   private localSearchTool: BraveLocalSearchTool;
 17 |   private newsSearchTool: BraveNewsSearchTool;
 18 |   private videoSearchTool: BraveVideoSearchTool;
 19 | 
 20 |   constructor(private braveSearchApiKey: string) {
 21 |     this.server = new McpServer(
 22 |       {
 23 |         name: 'Brave Search MCP Server',
 24 |         description: 'A server that provides tools for searching the web, images, videos, and local businesses using the Brave Search API.',
 25 |         version: '0.6.0',
 26 |       },
 27 |       {
 28 |         capabilities: {
 29 |           resources: {},
 30 |           tools: {},
 31 |           logging: {},
 32 |         },
 33 |       },
 34 |     );
 35 |     this.braveSearch = new BraveSearch(braveSearchApiKey);
 36 |     this.imageSearchTool = new BraveImageSearchTool(this, this.braveSearch);
 37 |     this.webSearchTool = new BraveWebSearchTool(this, this.braveSearch);
 38 |     this.localSearchTool = new BraveLocalSearchTool(this, this.braveSearch, this.webSearchTool, braveSearchApiKey);
 39 |     this.newsSearchTool = new BraveNewsSearchTool(this, this.braveSearch);
 40 |     this.videoSearchTool = new BraveVideoSearchTool(this, this.braveSearch, braveSearchApiKey);
 41 |     this.setupTools();
 42 |     this.setupResourceListener();
 43 |   }
 44 | 
 45 |   private setupTools(): void {
 46 |     this.server.tool(
 47 |       this.imageSearchTool.name,
 48 |       this.imageSearchTool.description,
 49 |       this.imageSearchTool.inputSchema.shape,
 50 |       this.imageSearchTool.execute.bind(this.imageSearchTool),
 51 |     );
 52 |     this.server.tool(
 53 |       this.webSearchTool.name,
 54 |       this.webSearchTool.description,
 55 |       this.webSearchTool.inputSchema.shape,
 56 |       this.webSearchTool.execute.bind(this.webSearchTool),
 57 |     );
 58 |     this.server.tool(
 59 |       this.localSearchTool.name,
 60 |       this.localSearchTool.description,
 61 |       this.localSearchTool.inputSchema.shape,
 62 |       this.localSearchTool.execute.bind(this.localSearchTool),
 63 |     );
 64 |     this.server.tool(
 65 |       this.newsSearchTool.name,
 66 |       this.newsSearchTool.description,
 67 |       this.newsSearchTool.inputSchema.shape,
 68 |       this.newsSearchTool.execute.bind(this.newsSearchTool),
 69 |     );
 70 |     this.server.tool(
 71 |       this.videoSearchTool.name,
 72 |       this.videoSearchTool.description,
 73 |       this.videoSearchTool.inputSchema.shape,
 74 |       this.videoSearchTool.execute.bind(this.videoSearchTool),
 75 |     );
 76 |   }
 77 | 
 78 |   private setupResourceListener(): void {
 79 |     this.server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
 80 |       resources: [
 81 |         ...Array.from(this.imageSearchTool.imageByTitle.keys()).map(title => ({
 82 |           uri: `brave-image://${title}`,
 83 |           mimeType: 'image/png',
 84 |           name: `${title}`,
 85 |         })),
 86 |       ],
 87 |     }));
 88 | 
 89 |     this.server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
 90 |       const uri = request.params.uri.toString();
 91 |       if (uri.startsWith('brave-image://')) {
 92 |         const title = uri.split('://')[1];
 93 |         const image = this.imageSearchTool.imageByTitle.get(title);
 94 |         if (image) {
 95 |           return {
 96 |             contents: [{
 97 |               uri,
 98 |               mimeType: 'image/png',
 99 |               blob: image,
100 |             }],
101 |           };
102 |         }
103 |       }
104 |       return {
105 |         content: [{ type: 'text', text: `Resource not found: ${uri}` }],
106 |         isError: true,
107 |       };
108 |     });
109 |   }
110 | 
111 |   public async start() {
112 |     const transport = new StdioServerTransport();
113 |     await this.server.connect(transport);
114 |     this.log('Server is running with Stdio transport');
115 |   }
116 | 
117 |   public resourceChangedNotification() {
118 |     this.server.server.notification({
119 |       method: 'notifications/resources/list_changed',
120 |     });
121 |   }
122 | 
123 |   public log(
124 |     message: string,
125 |     level: 'error' | 'debug' | 'info' | 'notice' | 'warning' | 'critical' | 'alert' | 'emergency' = 'info',
126 |   ): void {
127 |     this.server.server.sendLoggingMessage({
128 |       level,
129 |       message,
130 |     });
131 |   }
132 | }
133 | 
```

--------------------------------------------------------------------------------
/src/tools/BraveVideoSearchTool.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { BraveSearch } from 'brave-search';
  2 | import type { BraveSearchOptions, Profile, Query, VideoData, VideoResult } from 'brave-search/dist/types.js';
  3 | import type { BraveMcpServer } from '../server.js';
  4 | import axios from 'axios';
  5 | import { SafeSearchLevel } from 'brave-search/dist/types.js';
  6 | import { z } from 'zod';
  7 | import { formatVideoResults } from '../utils.js';
  8 | import { BaseTool } from './BaseTool.js';
  9 | 
 10 | // workaround for https://github.com/erik-balfe/brave-search/pull/4
 11 | // not being merged yet into brave-search
 12 | export interface BraveVideoData extends VideoData {
 13 |   /**
 14 |    * Whether the video requires a subscription.
 15 |    * @type {boolean}
 16 |    */
 17 |   requires_subscription?: boolean;
 18 |   /**
 19 |    * A list of tags relevant to the video.
 20 |    * @type {string[]}
 21 |    */
 22 |   tags?: string[];
 23 |   /**
 24 |    * A profile associated with the video.
 25 |    * @type {Profile}
 26 |    */
 27 |   author?: Profile;
 28 | }
 29 | 
 30 | export interface BraveVideoResult extends Omit<VideoResult, 'video'> {
 31 |   video: BraveVideoData;
 32 | }
 33 | 
 34 | export interface VideoSearchApiResponse {
 35 |   /**
 36 |    * The type of search API result. The value is always video.
 37 |    * @type {string}
 38 |    */
 39 |   type: 'video';
 40 |   /**
 41 |    * Video search query string.
 42 |    * @type {Query}
 43 |    */
 44 |   query: Query;
 45 |   /**
 46 |    * The list of video results for the given query.
 47 |    * @type {BraveVideoResult[]}
 48 |    */
 49 |   results: BraveVideoResult[];
 50 | }
 51 | 
 52 | export interface VideoSearchOptions extends Pick<BraveSearchOptions, 'country' | 'search_lang' | 'ui_lang' | 'count' | 'offset' | 'spellcheck' | 'safesearch' | 'freshness'> {
 53 | }
 54 | // end workaround
 55 | 
 56 | const videoSearchInputSchema = z.object({
 57 |   query: z.string().describe('The term to search the internet for videos of'),
 58 |   count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'),
 59 |   freshness: z.union([
 60 |     z.enum(['pd', 'pw', 'pm', 'py']),
 61 |     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')
 62 |   ])
 63 |     .optional()
 64 |     .describe(
 65 |       `Filters search results by when they were discovered.
 66 | The following values are supported:
 67 | - pd: Discovered within the last 24 hours.
 68 | - pw: Discovered within the last 7 Days.
 69 | - pm: Discovered within the last 31 Days.
 70 | - py: Discovered within the last 365 Days.
 71 | - YYYY-MM-DDtoYYYY-MM-DD: Custom date range (e.g., 2022-04-01to2022-07-30)`,
 72 |     ),
 73 | });
 74 | 
 75 | export class BraveVideoSearchTool extends BaseTool<typeof videoSearchInputSchema, any> {
 76 |   public readonly name = 'brave_video_search';
 77 |   public readonly description = 'Searches for videos using the Brave Search API. '
 78 |     + 'Use this for video content, tutorials, or any media-related queries. '
 79 |     + 'Returns a list of videos with titles, URLs, and descriptions. '
 80 |     + 'Maximum 20 results per request.';
 81 | 
 82 |   public readonly inputSchema = videoSearchInputSchema;
 83 | 
 84 |   private baseUrl = 'https://api.search.brave.com/res/v1';
 85 | 
 86 |   constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch, private apiKey: string) {
 87 |     super();
 88 |   }
 89 | 
 90 |   public async executeCore(input: z.infer<typeof videoSearchInputSchema>) {
 91 |     const { query, count, freshness } = input;
 92 |     const videoSearchResults = await this.videoSearch(query, {
 93 |       count,
 94 |       safesearch: SafeSearchLevel.Strict,
 95 |       ...(freshness ? { freshness } : {}),
 96 |     });
 97 |     if (!videoSearchResults.results || videoSearchResults.results.length === 0) {
 98 |       this.braveMcpServer.log(`No video results found for "${query}"`);
 99 |       const text = `No video results found for "${query}"`;
100 |       return { content: [{ type: 'text' as const, text }] };
101 |     }
102 | 
103 |     const text = formatVideoResults(videoSearchResults.results);
104 |     return { content: [{ type: 'text' as const, text }] };
105 |   }
106 | 
107 |   // workaround for https://github.com/erik-balfe/brave-search/pull/4
108 |   // not being merged yet into brave-search
109 |   private async videoSearch(
110 |     query: string,
111 |     options: VideoSearchOptions = {},
112 |   ): Promise<VideoSearchApiResponse> {
113 |     const response = await axios.get<VideoSearchApiResponse>(
114 |       `${this.baseUrl}/videos/search?`,
115 |       {
116 |         params: {
117 |           q: query,
118 |           ...this.formatOptions(options),
119 |         },
120 |         headers: this.getHeaders(),
121 |       },
122 |     );
123 |     return response.data;
124 |   }
125 | 
126 |   private formatOptions(options: Record<string, any>): Record<string, string> {
127 |     return Object.entries(options).reduce(
128 |       (acc, [key, value]) => {
129 |         if (value !== undefined) {
130 |           acc[key] = value.toString();
131 |         }
132 |         return acc;
133 |       },
134 |       {} as Record<string, string>,
135 |     );
136 |   }
137 | 
138 |   private getHeaders() {
139 |     return {
140 |       'Accept': 'application/json',
141 |       'Accept-Encoding': 'gzip',
142 |       'X-Subscription-Token': this.apiKey,
143 |     };
144 |   }
145 |   // end workaround
146 | }
147 | 
```

--------------------------------------------------------------------------------
/src/tools/BraveLocalSearchTool.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { BraveSearch, LocalDescriptionsSearchApiResponse, LocalPoiSearchApiResponse } from 'brave-search';
  2 | import type { BraveMcpServer } from '../server.js';
  3 | import type { BraveWebSearchTool } from './BraveWebSearchTool.js';
  4 | import { SafeSearchLevel } from 'brave-search/dist/types.js';
  5 | import { z } from 'zod';
  6 | import { formatPoiResults } from '../utils.js';
  7 | import { BaseTool } from './BaseTool.js';
  8 | 
  9 | const localSearchInputSchema = z.object({
 10 |   query: z.string().describe('Local search query (e.g. \'pizza near Central Park\')'),
 11 |   count: z.number().min(1).max(20).default(10).optional().describe('The number of results to return, minimum 1, maximum 20'),
 12 | });
 13 | 
 14 | export class BraveLocalSearchTool extends BaseTool<typeof localSearchInputSchema, any> {
 15 |   public readonly name = 'brave_local_search';
 16 |   public readonly description = 'Searches for local businesses and places using Brave\'s Local Search API. '
 17 |     + 'Best for queries related to physical locations, businesses, restaurants, services, etc. '
 18 |     + 'Returns detailed information including:\n'
 19 |     + '- Business names and addresses\n'
 20 |     + '- Ratings and review counts\n'
 21 |     + '- Phone numbers and opening hours\n'
 22 |     + 'Use this when the query implies \'near me\' or mentions specific locations. '
 23 |     + 'Automatically falls back to web search if no local results are found.';
 24 | 
 25 |   public readonly inputSchema = localSearchInputSchema;
 26 | 
 27 |   private baseUrl = 'https://api.search.brave.com/res/v1';
 28 | 
 29 |   constructor(private braveMcpServer: BraveMcpServer, private braveSearch: BraveSearch, private webSearchTool: BraveWebSearchTool, private apiKey: string) {
 30 |     super();
 31 |   }
 32 | 
 33 |   public async executeCore(input: z.infer<typeof localSearchInputSchema>) {
 34 |     const { query, count } = input;
 35 |     const results = await this.braveSearch.webSearch(query, {
 36 |       count,
 37 |       safesearch: SafeSearchLevel.Strict,
 38 |       result_filter: 'locations',
 39 |     });
 40 |     // it looks like the count parameter is only good for web search results
 41 |     if (!results.locations || results.locations?.results.length === 0) {
 42 |       this.braveMcpServer.log(`No location results found for "${query}" falling back to web search. Make sure your API Plan is at least "Pro"`);
 43 |       return this.webSearchTool.executeCore({ query, count, offset: 0 });
 44 |     }
 45 |     const allIds = results.locations.results.map(result => result.id);
 46 |     // count is restricted to 20 in the schema, and the location api supports up to 20 at a time
 47 |     // so we can just use the count parameter to limit the number of ids, take the first "count" ids
 48 |     const ids = allIds.slice(0, count);
 49 |     this.braveMcpServer.log(`Using ${ids.length} of ${allIds.length} location IDs for "${query}"`, 'debug');
 50 |     const formattedText = [];
 51 | 
 52 |     const localPoiSearchApiResponse = await this.localPoiSearch(ids);
 53 |     // the call to localPoiSearch does not return the id of the pois
 54 |     // add them here, they should be in the same order as the ids
 55 |     // and the same order of id in localDescriptionsSearchApiResponse
 56 |     localPoiSearchApiResponse.results.forEach((result, index) => {
 57 |       (result as any).id = ids[index];
 58 |     });
 59 |     const localDescriptionsSearchApiResponse = await this.localDescriptionsSearch(ids);
 60 |     const text = formatPoiResults(localPoiSearchApiResponse, localDescriptionsSearchApiResponse);
 61 |     formattedText.push(text);
 62 |     const finalText = formattedText.join('\n\n');
 63 |     return { content: [{ type: 'text' as const, text: finalText }] };
 64 |   }
 65 | 
 66 |   // workaround for https://github.com/erik-balfe/brave-search/pull/3
 67 |   // not being merged yet into brave-search
 68 |   private async localPoiSearch(ids: string[]) {
 69 |     try {
 70 |       const qs = ids.map(id => `ids=${encodeURIComponent(id)}`).join('&');
 71 |       const url = `${this.baseUrl}/local/pois?${qs}`;
 72 |       this.braveMcpServer.log(`Fetching local POI data from ${url}`, 'debug');
 73 |       const res = await fetch(url, {
 74 |         method: 'GET',
 75 |         headers: this.getHeaders(),
 76 |         redirect: 'follow',
 77 |       });
 78 |       if (!res.ok) {
 79 |         throw new Error(`Error fetching local POI data Status:${res.status} Status Text:${res.statusText} Headers:${JSON.stringify(res.headers)}`);
 80 |       }
 81 |       const data = (await res.json()) as LocalPoiSearchApiResponse;
 82 |       return data;
 83 |     }
 84 |     catch (error) {
 85 |       this.handleError(error);
 86 |       throw error;
 87 |     }
 88 |   }
 89 | 
 90 |   private async localDescriptionsSearch(ids: string[]) {
 91 |     try {
 92 |       const qs = ids.map(id => `ids=${encodeURIComponent(id)}`).join('&');
 93 |       const url = `${this.baseUrl}/local/descriptions?${qs}`;
 94 |       const res = await fetch(url, {
 95 |         method: 'GET',
 96 |         headers: this.getHeaders(),
 97 |         redirect: 'follow',
 98 |       });
 99 |       if (!res.ok) {
100 |         const responseText = await res.text();
101 |         this.braveMcpServer.log(`Error response body: ${responseText}`, 'error');
102 |         this.braveMcpServer.log(`Response headers: ${JSON.stringify(Object.fromEntries(res.headers.entries()))}`, 'error');
103 |         this.braveMcpServer.log(`Request URL: ${url}`, 'error');
104 |         this.braveMcpServer.log(`Request headers: ${JSON.stringify(this.getHeaders())}`, 'error');
105 |         if (res.status === 429) {
106 |           this.braveMcpServer.log('429 Rate limit exceeded, consider adding delay between requests', 'error');
107 |         }
108 |         else if (res.status === 403) {
109 |           this.braveMcpServer.log('403 Authentication error - check your API key', 'error');
110 |         }
111 |         else if (res.status === 500) {
112 |           this.braveMcpServer.log('500 Internal server error - might be an issue with request format or API temporary issues', 'error');
113 |         }
114 |         // return an empty response instead of error so we can at least return the pois results
115 |         return {
116 |           type: 'local_descriptions',
117 |           results: [],
118 |         } as LocalDescriptionsSearchApiResponse;
119 |       }
120 |       const data = (await res.json()) as LocalDescriptionsSearchApiResponse;
121 |       return data;
122 |     }
123 |     catch (error) {
124 |       this.handleError(error);
125 |       throw error;
126 |     }
127 |   }
128 | 
129 |   private handleError(error: any) {
130 |     this.braveMcpServer.log(`${error}`, 'error');
131 |   }
132 | 
133 |   private getHeaders() {
134 |     return {
135 |       'Accept': '*/*',
136 |       'Accept-Encoding': 'gzip, deflate, br',
137 |       'Connection': 'keep-alive',
138 |       'X-Subscription-Token': this.apiKey,
139 |       'User-Agent': 'BraveSearchMCP/1.0',
140 |       'Content-Type': 'application/json',
141 |       'Cache-Control': 'no-cache',
142 |     };
143 |   }
144 |   // end workaround
145 | }
146 | 
```