#
tokens: 47463/50000 51/51 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .github
│   ├── FUNDING.yml
│   └── workflows
│       ├── claude-code-review.yml
│       └── claude.yml
├── .gitignore
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── autoloader.ts
│   ├── index.ts
│   ├── schemas.ts
│   ├── server.ts
│   ├── tools
│   │   ├── chat.ts
│   │   ├── copy.ts
│   │   ├── create.ts
│   │   ├── delete.ts
│   │   ├── embed.ts
│   │   ├── generate.ts
│   │   ├── list.ts
│   │   ├── ps.ts
│   │   ├── pull.ts
│   │   ├── push.ts
│   │   ├── show.ts
│   │   ├── web-fetch.ts
│   │   └── web-search.ts
│   ├── types.ts
│   └── utils
│       ├── http-error.ts
│       ├── response-formatter.ts
│       ├── retry-config.ts
│       └── retry.ts
├── tests
│   ├── autoloader.test.ts
│   ├── index.test.ts
│   ├── integration
│   │   └── server.test.ts
│   ├── schemas
│   │   └── chat-input.test.ts
│   ├── tools
│   │   ├── chat.test.ts
│   │   ├── copy.test.ts
│   │   ├── create.test.ts
│   │   ├── delete.test.ts
│   │   ├── embed.test.ts
│   │   ├── generate.test.ts
│   │   ├── list.test.ts
│   │   ├── ps.test.ts
│   │   ├── pull.test.ts
│   │   ├── push.test.ts
│   │   ├── show.test.ts
│   │   ├── web-fetch.test.ts
│   │   └── web-search.test.ts
│   └── utils
│       ├── http-error.test.ts
│       ├── response-formatter.test.ts
│       ├── retry-config.test.ts
│       └── retry.test.ts
├── tsconfig.json
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Dependencies
 2 | node_modules/
 3 | 
 4 | # Compiled output
 5 | dist/
 6 | 
 7 | # Test coverage
 8 | coverage/
 9 | 
10 | # Logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | 
15 | # Environment variables
16 | .env
17 | 
18 | # IDE
19 | .idea/
20 | .vscode/
21 | *.swp
22 | *.swo
23 | 
24 | # OS
25 | .DS_Store
26 | Thumbs.db
```

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

```markdown
  1 | <div align="center">
  2 | 
  3 | # 🦙 Ollama MCP Server
  4 | 
  5 | **Supercharge your AI assistant with local LLM access**
  6 | 
  7 | [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
  8 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue)](https://www.typescriptlang.org/)
  9 | [![MCP](https://img.shields.io/badge/MCP-1.0-green)](https://github.com/anthropics/model-context-protocol)
 10 | [![Coverage](https://img.shields.io/badge/Coverage-96%25-brightgreen)](https://github.com/rawveg/ollama-mcp)
 11 | 
 12 | An MCP (Model Context Protocol) server that exposes the complete Ollama SDK as MCP tools, enabling seamless integration between your local LLM models and MCP-compatible applications like Claude Desktop and Cline.
 13 | 
 14 | [Features](#-features) • [Installation](#-installation) • [Available Tools](#-available-tools) • [Configuration](#-configuration) • [Retry Behavior](#-retry-behavior) • [Development](#-development)
 15 | 
 16 | </div>
 17 | 
 18 | ---
 19 | 
 20 | ## ✨ Features
 21 | 
 22 | - ☁️ **Ollama Cloud Support** - Full integration with Ollama's cloud platform
 23 | - 🔧 **14 Comprehensive Tools** - Full access to Ollama's SDK functionality
 24 | - 🔄 **Hot-Swap Architecture** - Automatic tool discovery with zero-config
 25 | - 🎯 **Type-Safe** - Built with TypeScript and Zod validation
 26 | - 📊 **High Test Coverage** - 96%+ coverage with comprehensive test suite
 27 | - 🚀 **Zero Dependencies** - Minimal footprint, maximum performance
 28 | - 🔌 **Drop-in Integration** - Works with Claude Desktop, Cline, and other MCP clients
 29 | - 🌐 **Web Search & Fetch** - Real-time web search and content extraction via Ollama Cloud
 30 | - 🔀 **Hybrid Mode** - Use local and cloud models seamlessly in one server
 31 | 
 32 | ## 💡 Level Up Your Ollama Experience with Claude Code and Desktop
 33 | 
 34 | ### The Complete Package: Tools + Knowledge
 35 | 
 36 | This MCP server gives Claude the **tools** to interact with Ollama - but you'll get even more value by also installing the **Ollama Skill** from the [Skillsforge Marketplace](https://github.com/rawveg/skillsforge-marketplace):
 37 | 
 38 | - 🚗 **This MCP = The Car** - All the tools and capabilities
 39 | - 🎓 **Ollama Skill = Driving Lessons** - Expert knowledge on how to use them effectively
 40 | 
 41 | The Ollama Skill teaches Claude:
 42 | - Best practices for model selection and configuration
 43 | - Optimal prompting strategies for different Ollama models
 44 | - When to use chat vs generate, embeddings, and other tools
 45 | - Performance optimization and troubleshooting
 46 | - Advanced features like tool calling and function support
 47 | 
 48 | **Install both for the complete experience:**
 49 | 1. ✅ This MCP server (tools)
 50 | 2. ✅ [Ollama Skill](https://github.com/rawveg/skillsforge-marketplace) (expertise)
 51 | 
 52 | Result: Claude doesn't just have the car - it knows how to drive! 🏎️
 53 | 
 54 | ## 📦 Installation
 55 | 
 56 | ### Quick Start with Claude Desktop
 57 | 
 58 | Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
 59 | 
 60 | ```json
 61 | {
 62 |   "mcpServers": {
 63 |     "ollama": {
 64 |       "command": "npx",
 65 |       "args": ["-y", "ollama-mcp"]
 66 |     }
 67 |   }
 68 | }
 69 | ```
 70 | 
 71 | ### Global Installation
 72 | 
 73 | ```bash
 74 | npm install -g ollama-mcp
 75 | ```
 76 | 
 77 | ### For Cline (VS Code)
 78 | 
 79 | Add to your Cline MCP settings (`cline_mcp_settings.json`):
 80 | 
 81 | ```json
 82 | {
 83 |   "mcpServers": {
 84 |     "ollama": {
 85 |       "command": "npx",
 86 |       "args": ["-y", "ollama-mcp"]
 87 |     }
 88 |   }
 89 | }
 90 | ```
 91 | 
 92 | ## 🛠️ Available Tools
 93 | 
 94 | ### Model Management
 95 | | Tool | Description |
 96 | |------|-------------|
 97 | | `ollama_list` | List all available local models |
 98 | | `ollama_show` | Get detailed information about a specific model |
 99 | | `ollama_pull` | Download models from Ollama library |
100 | | `ollama_push` | Push models to Ollama library |
101 | | `ollama_copy` | Create a copy of an existing model |
102 | | `ollama_delete` | Remove models from local storage |
103 | | `ollama_create` | Create custom models from Modelfile |
104 | 
105 | ### Model Operations
106 | | Tool | Description |
107 | |------|-------------|
108 | | `ollama_ps` | List currently running models |
109 | | `ollama_generate` | Generate text completions |
110 | | `ollama_chat` | Interactive chat with models (supports tools/functions) |
111 | | `ollama_embed` | Generate embeddings for text |
112 | 
113 | ### Web Tools (Ollama Cloud)
114 | | Tool | Description |
115 | |------|-------------|
116 | | `ollama_web_search` | Search the web with customizable result limits (requires `OLLAMA_API_KEY`) |
117 | | `ollama_web_fetch` | Fetch and parse web page content (requires `OLLAMA_API_KEY`) |
118 | 
119 | > **Note:** Web tools require an Ollama Cloud API key. They connect to `https://ollama.com/api` for web search and fetch operations.
120 | 
121 | ## ⚙️ Configuration
122 | 
123 | ### Environment Variables
124 | 
125 | | Variable | Default | Description |
126 | |----------|---------|-------------|
127 | | `OLLAMA_HOST` | `http://127.0.0.1:11434` | Ollama server endpoint (use `https://ollama.com` for cloud) |
128 | | `OLLAMA_API_KEY` | - | API key for Ollama Cloud (required for web tools and cloud models) |
129 | 
130 | ### Custom Ollama Host
131 | 
132 | ```json
133 | {
134 |   "mcpServers": {
135 |     "ollama": {
136 |       "command": "npx",
137 |       "args": ["-y", "ollama-mcp"],
138 |       "env": {
139 |         "OLLAMA_HOST": "http://localhost:11434"
140 |       }
141 |     }
142 |   }
143 | }
144 | ```
145 | 
146 | ### Ollama Cloud Configuration
147 | 
148 | To use Ollama's cloud platform with web search and fetch capabilities:
149 | 
150 | ```json
151 | {
152 |   "mcpServers": {
153 |     "ollama": {
154 |       "command": "npx",
155 |       "args": ["-y", "ollama-mcp"],
156 |       "env": {
157 |         "OLLAMA_HOST": "https://ollama.com",
158 |         "OLLAMA_API_KEY": "your-ollama-cloud-api-key"
159 |       }
160 |     }
161 |   }
162 | }
163 | ```
164 | 
165 | **Cloud Features:**
166 | - ☁️ Access cloud-hosted models
167 | - 🔍 Web search with `ollama_web_search` (requires API key)
168 | - 📄 Web fetch with `ollama_web_fetch` (requires API key)
169 | - 🚀 Faster inference on cloud infrastructure
170 | 
171 | **Get your API key:** Visit [ollama.com](https://ollama.com) to sign up and obtain your API key.
172 | 
173 | ### Hybrid Mode (Local + Cloud)
174 | 
175 | You can use both local and cloud models by pointing to your local Ollama instance while providing an API key:
176 | 
177 | ```json
178 | {
179 |   "mcpServers": {
180 |     "ollama": {
181 |       "command": "npx",
182 |       "args": ["-y", "ollama-mcp"],
183 |       "env": {
184 |         "OLLAMA_HOST": "http://127.0.0.1:11434",
185 |         "OLLAMA_API_KEY": "your-ollama-cloud-api-key"
186 |       }
187 |     }
188 |   }
189 | }
190 | ```
191 | 
192 | This configuration:
193 | - ✅ Runs local models from your Ollama instance
194 | - ✅ Enables cloud-only web search and fetch tools
195 | - ✅ Best of both worlds: privacy + web connectivity
196 | 
197 | ## 🔄 Retry Behavior
198 | 
199 | The MCP server includes intelligent retry logic for handling transient failures when communicating with Ollama APIs:
200 | 
201 | ### Automatic Retry Strategy
202 | 
203 | **Web Tools (`ollama_web_search` and `ollama_web_fetch`):**
204 | - Automatically retry on rate limit errors (HTTP 429)
205 | - Maximum of **3 retry attempts** (4 total requests including initial)
206 | - **Request timeout:** 30 seconds per request (prevents hung connections)
207 | - Respects the `Retry-After` header when provided by the API
208 | - Falls back to exponential backoff with jitter when `Retry-After` is not present
209 | 
210 | ### Retry-After Header Support
211 | 
212 | The server intelligently handles the standard HTTP `Retry-After` header in two formats:
213 | 
214 | **1. Delay-Seconds Format:**
215 | ```
216 | Retry-After: 60
217 | ```
218 | Waits exactly 60 seconds before retrying.
219 | 
220 | **2. HTTP-Date Format:**
221 | ```
222 | Retry-After: Wed, 21 Oct 2025 07:28:00 GMT
223 | ```
224 | Calculates delay until the specified timestamp.
225 | 
226 | ### Exponential Backoff
227 | 
228 | When `Retry-After` is not provided or invalid:
229 | - **Initial delay:** 1 second (default)
230 | - **Maximum delay:** 10 seconds (default, configurable)
231 | - **Strategy:** Exponential backoff with full jitter
232 | - **Formula:** `random(0, min(initialDelay × 2^attempt, maxDelay))`
233 | 
234 | **Example retry delays:**
235 | - 1st retry: 0-1 seconds
236 | - 2nd retry: 0-2 seconds
237 | - 3rd retry: 0-4 seconds (capped at 0-10s max)
238 | 
239 | ### Error Handling
240 | 
241 | **Retried Errors (transient failures):**
242 | - HTTP 429 (Too Many Requests) - rate limiting
243 | - HTTP 500 (Internal Server Error) - transient server issues
244 | - HTTP 502 (Bad Gateway) - gateway/proxy received invalid response
245 | - HTTP 503 (Service Unavailable) - server temporarily unable to handle request
246 | - HTTP 504 (Gateway Timeout) - gateway/proxy did not receive timely response
247 | 
248 | **Non-Retried Errors (permanent failures):**
249 | - Request timeouts (30 second limit exceeded)
250 | - Network timeouts (no status code)
251 | - Abort/cancel errors
252 | - HTTP 4xx errors (except 429) - client errors requiring changes
253 | - Other HTTP 5xx errors (501, 505, 506, 508, etc.) - configuration/implementation issues
254 | 
255 | The retry mechanism ensures robust handling of temporary API issues while respecting server-provided retry guidance and preventing excessive request rates. Transient 5xx errors (500, 502, 503, 504) are safe to retry for the idempotent POST operations used by `ollama_web_search` and `ollama_web_fetch`. Individual requests timeout after 30 seconds to prevent indefinitely hung connections.
256 | 
257 | ## 🎯 Usage Examples
258 | 
259 | ### Chat with a Model
260 | 
261 | ```typescript
262 | // MCP clients can invoke:
263 | {
264 |   "tool": "ollama_chat",
265 |   "arguments": {
266 |     "model": "llama3.2:latest",
267 |     "messages": [
268 |       { "role": "user", "content": "Explain quantum computing" }
269 |     ]
270 |   }
271 | }
272 | ```
273 | 
274 | ### Generate Embeddings
275 | 
276 | ```typescript
277 | {
278 |   "tool": "ollama_embed",
279 |   "arguments": {
280 |     "model": "nomic-embed-text",
281 |     "input": ["Hello world", "Embeddings are great"]
282 |   }
283 | }
284 | ```
285 | 
286 | ### Web Search
287 | 
288 | ```typescript
289 | {
290 |   "tool": "ollama_web_search",
291 |   "arguments": {
292 |     "query": "latest AI developments",
293 |     "max_results": 5
294 |   }
295 | }
296 | ```
297 | 
298 | ## 🏗️ Architecture
299 | 
300 | This server uses a **hot-swap autoloader** pattern:
301 | 
302 | ```
303 | src/
304 | ├── index.ts          # Entry point (27 lines)
305 | ├── server.ts         # MCP server creation
306 | ├── autoloader.ts     # Dynamic tool discovery
307 | └── tools/            # Tool implementations
308 |     ├── chat.ts       # Each exports toolDefinition
309 |     ├── generate.ts
310 |     └── ...
311 | ```
312 | 
313 | **Key Benefits:**
314 | - Add new tools by dropping files in `src/tools/`
315 | - Zero server code changes required
316 | - Each tool is independently testable
317 | - 100% function coverage on all tools
318 | 
319 | ## 🧪 Development
320 | 
321 | ### Prerequisites
322 | 
323 | - Node.js v16+
324 | - npm or pnpm
325 | - Ollama running locally
326 | 
327 | ### Setup
328 | 
329 | ```bash
330 | # Clone repository
331 | git clone https://github.com/rawveg/ollama-mcp.git
332 | cd ollama-mcp
333 | 
334 | # Install dependencies
335 | npm install
336 | 
337 | # Build project
338 | npm run build
339 | 
340 | # Run tests
341 | npm test
342 | 
343 | # Run tests with coverage
344 | npm run test:coverage
345 | ```
346 | 
347 | ### Test Coverage
348 | 
349 | ```
350 | Statements   : 96.37%
351 | Branches     : 84.82%
352 | Functions    : 100%
353 | Lines        : 96.37%
354 | ```
355 | 
356 | ### Adding a New Tool
357 | 
358 | 1. Create `src/tools/your-tool.ts`:
359 | 
360 | ```typescript
361 | import { ToolDefinition } from '../autoloader.js';
362 | import { Ollama } from 'ollama';
363 | import { ResponseFormat } from '../types.js';
364 | 
365 | export const toolDefinition: ToolDefinition = {
366 |   name: 'ollama_your_tool',
367 |   description: 'Your tool description',
368 |   inputSchema: {
369 |     type: 'object',
370 |     properties: {
371 |       param: { type: 'string' }
372 |     },
373 |     required: ['param']
374 |   },
375 |   handler: async (ollama, args, format) => {
376 |     // Implementation
377 |     return 'result';
378 |   }
379 | };
380 | ```
381 | 
382 | 2. Create tests in `tests/tools/your-tool.test.ts`
383 | 3. Done! The autoloader discovers it automatically.
384 | 
385 | ## 🤝 Contributing
386 | 
387 | Contributions are welcome! Please follow these guidelines:
388 | 
389 | 1. **Fork** the repository
390 | 2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
391 | 3. **Write tests** - We maintain 96%+ coverage
392 | 4. **Commit** with clear messages (`git commit -m 'Add amazing feature'`)
393 | 5. **Push** to your branch (`git push origin feature/amazing-feature`)
394 | 6. **Open** a Pull Request
395 | 
396 | ### Code Quality Standards
397 | 
398 | - All new tools must export `toolDefinition`
399 | - Maintain ≥80% test coverage
400 | - Follow existing TypeScript patterns
401 | - Use Zod schemas for input validation
402 | 
403 | ## 📄 License
404 | 
405 | This project is licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0).
406 | 
407 | See [LICENSE](LICENSE) for details.
408 | 
409 | ## 🔗 Related Projects
410 | 
411 | - [Skillsforge Marketplace](https://github.com/rawveg/skillsforge-marketplace) - Claude Code skills including the Ollama Skill
412 | - [Ollama](https://ollama.ai) - Get up and running with large language models locally
413 | - [Model Context Protocol](https://github.com/anthropics/model-context-protocol) - Open standard for AI assistant integration
414 | - [Claude Desktop](https://claude.ai/desktop) - Anthropic's desktop application
415 | - [Cline](https://github.com/cline/cline) - VS Code AI assistant
416 | 
417 | ## 🙏 Acknowledgments
418 | 
419 | Built with:
420 | - [Ollama SDK](https://github.com/ollama/ollama-js) - Official Ollama JavaScript library
421 | - [MCP SDK](https://github.com/anthropics/model-context-protocol) - Model Context Protocol SDK
422 | - [Zod](https://zod.dev) - TypeScript-first schema validation
423 | 
424 | ---
425 | 
426 | <div align="center">
427 | 
428 | **[⬆ back to top](#-ollama-mcp-server)**
429 | 
430 | Made with ❤️ by [Tim Green](https://github.com/rawveg)
431 | 
432 | </div>
433 | 
```

--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { defineConfig } from 'vitest/config';
 2 | 
 3 | export default defineConfig({
 4 |   test: {
 5 |     globals: true,
 6 |     environment: 'node',
 7 |     coverage: {
 8 |       provider: 'v8',
 9 |       reporter: ['text', 'html', 'lcov'],
10 |       include: ['src/**/*.ts'],
11 |       exclude: ['src/**/*.test.ts', 'src/**/*.d.ts', 'src/types.ts'],
12 |       lines: 100,
13 |       functions: 100,
14 |       branches: 100,
15 |       statements: 100,
16 |     },
17 |   },
18 | });
19 | 
```

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

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "module": "ES2022",
 5 |     "lib": ["ES2022"],
 6 |     "moduleResolution": "node",
 7 |     "outDir": "./dist",
 8 |     "rootDir": "./src",
 9 |     "strict": true,
10 |     "esModuleInterop": true,
11 |     "skipLibCheck": true,
12 |     "forceConsistentCasingInFileNames": true,
13 |     "resolveJsonModule": true,
14 |     "declaration": true,
15 |     "declarationMap": true,
16 |     "sourceMap": true,
17 |     "types": ["node", "vitest/globals"]
18 |   },
19 |   "include": ["src/**/*"],
20 |   "exclude": ["node_modules", "dist"]
21 | }
22 | 
```

--------------------------------------------------------------------------------
/src/utils/http-error.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /**
 2 |  * Custom error class for HTTP errors with status codes
 3 |  */
 4 | export class HttpError extends Error {
 5 |   /**
 6 |    * Create an HTTP error
 7 |    * @param message - Error message
 8 |    * @param status - HTTP status code
 9 |    * @param retryAfter - Optional Retry-After header value (seconds or date string)
10 |    */
11 |   constructor(
12 |     message: string,
13 |     public status: number,
14 |     public retryAfter?: string
15 |   ) {
16 |     super(message);
17 |     this.name = 'HttpError';
18 | 
19 |     // Maintains proper stack trace for where our error was thrown (only available on V8)
20 |     if (Error.captureStackTrace) {
21 |       Error.captureStackTrace(this, HttpError);
22 |     }
23 |   }
24 | }
25 | 
```

--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------

```yaml
 1 | # These are supported funding model platforms
 2 | 
 3 | github: rawveg
 4 | patreon: # Replace with a single Patreon username
 5 | open_collective: # Replace with a single Open Collective username
 6 | ko_fi: # Replace with a single Ko-fi username
 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
 9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: rawveg
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 | 
```

--------------------------------------------------------------------------------
/src/utils/retry-config.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /**
 2 |  * Retry configuration constants for web API calls
 3 |  *
 4 |  * These values are used by web-fetch and web-search tools when calling
 5 |  * the Ollama Cloud API endpoints. They align with the default values
 6 |  * in RetryOptions but are extracted here for consistency and maintainability.
 7 |  */
 8 | 
 9 | import { RetryOptions } from './retry.js';
10 | 
11 | /**
12 |  * Standard retry configuration for web API calls
13 |  *
14 |  * - maxRetries: 3 retry attempts after the initial call
15 |  * - initialDelay: 1000ms (1 second) before first retry
16 |  * - maxDelay: Uses default from RetryOptions (10000ms)
17 |  */
18 | export const WEB_API_RETRY_CONFIG: RetryOptions = {
19 |   maxRetries: 3,
20 |   initialDelay: 1000,
21 | } as const;
22 | 
23 | /**
24 |  * Request timeout for web API calls
25 |  *
26 |  * Individual requests timeout after 30 seconds to prevent
27 |  * indefinitely hung connections.
28 |  */
29 | export const WEB_API_TIMEOUT = 30000 as const;
30 | 
```

--------------------------------------------------------------------------------
/tests/utils/retry-config.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect } from 'vitest';
 2 | import { WEB_API_RETRY_CONFIG, WEB_API_TIMEOUT } from '../../src/utils/retry-config.js';
 3 | 
 4 | describe('Retry Configuration Constants', () => {
 5 |   it('should define WEB_API_RETRY_CONFIG with correct default values', () => {
 6 |     // Assert - Verify configuration matches documented defaults
 7 |     expect(WEB_API_RETRY_CONFIG).toEqual({
 8 |       maxRetries: 3,
 9 |       initialDelay: 1000,
10 |     });
11 |   });
12 | 
13 |   it('should define WEB_API_TIMEOUT with correct value', () => {
14 |     // Assert - Verify timeout matches documented 30 second limit
15 |     expect(WEB_API_TIMEOUT).toBe(30000);
16 |   });
17 | 
18 |   it('should use values consistent with retry.ts defaults', () => {
19 |     // Assert - Ensure retry config doesn't override maxDelay
20 |     // (maxDelay should use the default 10000ms from RetryOptions)
21 |     expect(WEB_API_RETRY_CONFIG).not.toHaveProperty('maxDelay');
22 |   });
23 | });
24 | 
```

--------------------------------------------------------------------------------
/tests/autoloader.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect } from 'vitest';
 2 | import { discoverTools } from '../src/autoloader.js';
 3 | 
 4 | describe('Tool Autoloader', () => {
 5 |   it('should discover all .ts files in tools directory', async () => {
 6 |     const tools = await discoverTools();
 7 | 
 8 |     expect(tools).toBeDefined();
 9 |     expect(Array.isArray(tools)).toBe(true);
10 |     expect(tools.length).toBeGreaterThan(0);
11 |   });
12 | 
13 |   it('should discover tool metadata from each file', async () => {
14 |     const tools = await discoverTools();
15 | 
16 |     // Check that each tool has required metadata
17 |     tools.forEach((tool) => {
18 |       expect(tool).toHaveProperty('name');
19 |       expect(tool).toHaveProperty('description');
20 |       expect(tool).toHaveProperty('inputSchema');
21 |       expect(tool).toHaveProperty('handler');
22 | 
23 |       expect(typeof tool.name).toBe('string');
24 |       expect(typeof tool.description).toBe('string');
25 |       expect(typeof tool.inputSchema).toBe('object');
26 |       expect(typeof tool.handler).toBe('function');
27 |     });
28 |   });
29 | });
30 | 
```

--------------------------------------------------------------------------------
/src/tools/ps.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { PsInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * List running models
 9 |  */
10 | export async function listRunningModels(
11 |   ollama: Ollama,
12 |   format: ResponseFormat
13 | ): Promise<string> {
14 |   const response = await ollama.ps();
15 | 
16 |   return formatResponse(JSON.stringify(response), format);
17 | }
18 | 
19 | export const toolDefinition: ToolDefinition = {
20 |   name: 'ollama_ps',
21 |   description:
22 |     'List running models. Shows which models are currently loaded in memory.',
23 |   inputSchema: {
24 |     type: 'object',
25 |     properties: {
26 |       format: {
27 |         type: 'string',
28 |         enum: ['json', 'markdown'],
29 |         default: 'json',
30 |       },
31 |     },
32 |   },
33 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
34 |     PsInputSchema.parse(args);
35 |     return listRunningModels(ollama, format);
36 |   },
37 | };
38 | 
```

--------------------------------------------------------------------------------
/src/tools/list.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | 
 6 | /**
 7 |  * List all available models
 8 |  */
 9 | export async function listModels(
10 |   ollama: Ollama,
11 |   format: ResponseFormat
12 | ): Promise<string> {
13 |   const response = await ollama.list();
14 | 
15 |   return formatResponse(JSON.stringify(response), format);
16 | }
17 | 
18 | /**
19 |  * Tool metadata definition
20 |  */
21 | export const toolDefinition: ToolDefinition = {
22 |   name: 'ollama_list',
23 |   description:
24 |     'List all available Ollama models installed locally. Returns model names, sizes, and modification dates.',
25 |   inputSchema: {
26 |     type: 'object',
27 |     properties: {
28 |       format: {
29 |         type: 'string',
30 |         enum: ['json', 'markdown'],
31 |         description: 'Output format (default: json)',
32 |         default: 'json',
33 |       },
34 |     },
35 |   },
36 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
37 |     return listModels(ollama, format);
38 |   },
39 | };
40 | 
```

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

```typescript
 1 | #!/usr/bin/env node
 2 | 
 3 | /**
 4 |  * Ollama MCP Server - Main entry point
 5 |  * Exposes Ollama SDK functionality through MCP tools
 6 |  */
 7 | 
 8 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
 9 | import { createServer } from './server.js';
10 | 
11 | /**
12 |  * Main function to start the MCP server
13 |  * Exported for testing purposes
14 |  */
15 | export async function main() {
16 |   const server = createServer();
17 |   const transport = new StdioServerTransport();
18 |   await server.connect(transport);
19 | 
20 |   // Handle shutdown gracefully
21 |   process.on('SIGINT', async () => {
22 |     await server.close();
23 |     process.exit(0);
24 |   });
25 | 
26 |   return { server, transport };
27 | }
28 | 
29 | // Only run if this is the main module (not being imported for testing)
30 | // Check both direct execution and npx execution
31 | const isMain = import.meta.url.startsWith('file://') &&
32 |                (import.meta.url === `file://${process.argv[1]}` ||
33 |                 process.argv[1]?.includes('ollama-mcp'));
34 | 
35 | if (isMain) {
36 |   main().catch((error) => {
37 |     console.error('Fatal error:', error);
38 |     process.exit(1);
39 |   });
40 | }
41 | 
```

--------------------------------------------------------------------------------
/tests/tools/delete.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { deleteModel, toolDefinition } from '../../src/tools/delete.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('deleteModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockDelete: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockDelete = vi.fn();
12 |     ollama = {
13 |       delete: mockDelete,
14 |     } as any;
15 |   });
16 | 
17 |   it('should delete a model', async () => {
18 |     mockDelete.mockResolvedValue({
19 |       status: 'success',
20 |     });
21 | 
22 |     const result = await deleteModel(
23 |       ollama,
24 |       'my-model:latest',
25 |       ResponseFormat.JSON
26 |     );
27 | 
28 |     expect(typeof result).toBe('string');
29 |     expect(mockDelete).toHaveBeenCalledWith({
30 |       model: 'my-model:latest',
31 |     });
32 |   });
33 | 
34 |   it('should work through toolDefinition handler', async () => {
35 |     const result = await toolDefinition.handler(
36 |       ollama,
37 |       { model: 'model-to-delete:latest', format: 'json' },
38 |       ResponseFormat.JSON
39 |     );
40 | 
41 |     expect(typeof result).toBe('string');
42 |   });
43 | 
44 | });
```

--------------------------------------------------------------------------------
/tests/tools/push.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { pushModel, toolDefinition } from '../../src/tools/push.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('pushModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockPush: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockPush = vi.fn();
12 |     ollama = {
13 |       push: mockPush,
14 |     } as any;
15 |   });
16 | 
17 |   it('should push a model to registry', async () => {
18 |     mockPush.mockResolvedValue({
19 |       status: 'success',
20 |     });
21 | 
22 |     const result = await pushModel(
23 |       ollama,
24 |       'my-model:latest',
25 |       false,
26 |       ResponseFormat.JSON
27 |     );
28 | 
29 |     expect(typeof result).toBe('string');
30 |     expect(mockPush).toHaveBeenCalledWith({
31 |       model: 'my-model:latest',
32 |       insecure: false,
33 |       stream: false,
34 |     });
35 |   });
36 | 
37 |   it('should work through toolDefinition handler', async () => {
38 |     const result = await toolDefinition.handler(
39 |       ollama,
40 |       { model: 'my-model:latest', format: 'json' },
41 |       ResponseFormat.JSON
42 |     );
43 | 
44 |     expect(typeof result).toBe('string');
45 |   });
46 | 
47 | });
```

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

```json
 1 | {
 2 |   "name": "ollama-mcp",
 3 |   "version": "2.1.0",
 4 |   "description": "MCP server for Ollama - exposes all Ollama SDK functionality through MCP tools",
 5 |   "type": "module",
 6 |   "main": "dist/index.js",
 7 |   "types": "dist/index.d.ts",
 8 |   "bin": {
 9 |     "ollama-mcp": "dist/index.js"
10 |   },
11 |   "files": [
12 |     "dist",
13 |     "README.md",
14 |     "LICENSE"
15 |   ],
16 |   "repository": {
17 |     "type": "git",
18 |     "url": "git+https://github.com/rawveg/ollama-mcp.git"
19 |   },
20 |   "bugs": {
21 |     "url": "https://github.com/rawveg/ollama-mcp/issues"
22 |   },
23 |   "homepage": "https://github.com/rawveg/ollama-mcp#readme",
24 |   "scripts": {
25 |     "build": "tsc",
26 |     "test": "vitest run",
27 |     "test:coverage": "vitest run --coverage",
28 |     "dev": "node dist/index.js",
29 |     "prepare": "npm run build"
30 |   },
31 |   "keywords": [
32 |     "mcp",
33 |     "ollama",
34 |     "ai",
35 |     "llm"
36 |   ],
37 |   "author": "Tim Green <[email protected]>",
38 |   "license": "AGPL-3.0",
39 |   "dependencies": {
40 |     "@modelcontextprotocol/sdk": "^1.0.4",
41 |     "json2md": "^2.0.3",
42 |     "markdown-table": "^3.0.4",
43 |     "ollama": "^0.5.11",
44 |     "zod": "^3.24.1"
45 |   },
46 |   "devDependencies": {
47 |     "@types/node": "^22.10.5",
48 |     "@vitest/coverage-v8": "^2.1.9",
49 |     "typescript": "^5.7.3",
50 |     "vitest": "^2.1.9"
51 |   }
52 | }
53 | 
```

--------------------------------------------------------------------------------
/src/tools/delete.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { DeleteModelInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Delete a model
 9 |  */
10 | export async function deleteModel(
11 |   ollama: Ollama,
12 |   model: string,
13 |   format: ResponseFormat
14 | ): Promise<string> {
15 |   const response = await ollama.delete({
16 |     model,
17 |   });
18 | 
19 |   return formatResponse(JSON.stringify(response), format);
20 | }
21 | 
22 | export const toolDefinition: ToolDefinition = {
23 |   name: 'ollama_delete',
24 |   description:
25 |     'Delete a model from local storage. Removes the model and frees up disk space.',
26 |   inputSchema: {
27 |     type: 'object',
28 |     properties: {
29 |       model: {
30 |         type: 'string',
31 |         description: 'Name of the model to delete',
32 |       },
33 |       format: {
34 |         type: 'string',
35 |         enum: ['json', 'markdown'],
36 |         default: 'json',
37 |       },
38 |     },
39 |     required: ['model'],
40 |   },
41 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
42 |     const validated = DeleteModelInputSchema.parse(args);
43 |     return deleteModel(ollama, validated.model, format);
44 |   },
45 | };
46 | 
```

--------------------------------------------------------------------------------
/tests/tools/pull.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { pullModel, toolDefinition } from '../../src/tools/pull.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('pullModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockPull: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockPull = vi.fn();
12 |     ollama = {
13 |       pull: mockPull,
14 |     } as any;
15 |   });
16 | 
17 |   it('should pull a model from registry', async () => {
18 |     mockPull.mockResolvedValue({
19 |       status: 'success',
20 |     });
21 | 
22 |     const result = await pullModel(
23 |       ollama,
24 |       'llama3.2:latest',
25 |       false,
26 |       ResponseFormat.JSON
27 |     );
28 | 
29 |     expect(typeof result).toBe('string');
30 |     expect(mockPull).toHaveBeenCalledTimes(1);
31 |     expect(mockPull).toHaveBeenCalledWith({
32 |       model: 'llama3.2:latest',
33 |       insecure: false,
34 |       stream: false,
35 |     });
36 | 
37 |     const parsed = JSON.parse(result);
38 |     expect(parsed).toHaveProperty('status');
39 |   });
40 | 
41 |   it('should work through toolDefinition handler', async () => {
42 |     const result = await toolDefinition.handler(
43 |       ollama,
44 |       { model: 'llama3.2:latest', format: 'json' },
45 |       ResponseFormat.JSON
46 |     );
47 | 
48 |     expect(typeof result).toBe('string');
49 |   });
50 | 
51 | });
```

--------------------------------------------------------------------------------
/tests/tools/copy.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { copyModel, toolDefinition } from '../../src/tools/copy.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('copyModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockCopy: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockCopy = vi.fn();
12 |     ollama = {
13 |       copy: mockCopy,
14 |     } as any;
15 |   });
16 | 
17 |   it('should copy a model', async () => {
18 |     mockCopy.mockResolvedValue({
19 |       status: 'success',
20 |     });
21 | 
22 |     const result = await copyModel(
23 |       ollama,
24 |       'model-a:latest',
25 |       'model-b:latest',
26 |       ResponseFormat.JSON
27 |     );
28 | 
29 |     expect(typeof result).toBe('string');
30 |     expect(mockCopy).toHaveBeenCalledWith({
31 |       source: 'model-a:latest',
32 |       destination: 'model-b:latest',
33 |     });
34 |   });
35 | 
36 |   it('should work through toolDefinition handler', async () => {
37 |     mockCopy.mockResolvedValue({
38 |       status: 'success',
39 |     });
40 | 
41 |     const result = await toolDefinition.handler(
42 |       ollama,
43 |       {
44 |         source: 'model-a:latest',
45 |         destination: 'model-b:latest',
46 |         format: 'json',
47 |       },
48 |       ResponseFormat.JSON
49 |     );
50 | 
51 |     expect(typeof result).toBe('string');
52 |     expect(mockCopy).toHaveBeenCalledTimes(1);
53 |   });
54 | });
55 | 
```

--------------------------------------------------------------------------------
/src/tools/show.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { ShowModelInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Show information about a specific model
 9 |  */
10 | export async function showModel(
11 |   ollama: Ollama,
12 |   model: string,
13 |   format: ResponseFormat
14 | ): Promise<string> {
15 |   const response = await ollama.show({ model });
16 | 
17 |   return formatResponse(JSON.stringify(response), format);
18 | }
19 | 
20 | export const toolDefinition: ToolDefinition = {
21 |   name: 'ollama_show',
22 |   description:
23 |     'Show detailed information about a specific model including modelfile, parameters, and architecture details.',
24 |   inputSchema: {
25 |     type: 'object',
26 |     properties: {
27 |       model: {
28 |         type: 'string',
29 |         description: 'Name of the model to show',
30 |       },
31 |       format: {
32 |         type: 'string',
33 |         enum: ['json', 'markdown'],
34 |         description: 'Output format (default: json)',
35 |         default: 'json',
36 |       },
37 |     },
38 |     required: ['model'],
39 |   },
40 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
41 |     const validated = ShowModelInputSchema.parse(args);
42 |     return showModel(ollama, validated.model, format);
43 |   },
44 | };
45 | 
```

--------------------------------------------------------------------------------
/tests/tools/ps.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { listRunningModels, toolDefinition } from '../../src/tools/ps.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('listRunningModels', () => {
 7 |   let ollama: Ollama;
 8 |   let mockPs: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockPs = vi.fn();
12 |     ollama = {
13 |       ps: mockPs,
14 |     } as any;
15 |   });
16 | 
17 |   it('should list running models', async () => {
18 |     mockPs.mockResolvedValue({
19 |       models: [
20 |         {
21 |           name: 'llama3.2:latest',
22 |           size: 5000000000,
23 |         },
24 |       ],
25 |     });
26 | 
27 |     const result = await listRunningModels(ollama, ResponseFormat.JSON);
28 | 
29 |     expect(typeof result).toBe('string');
30 |     expect(mockPs).toHaveBeenCalledTimes(1);
31 | 
32 |     const parsed = JSON.parse(result);
33 |     expect(parsed).toHaveProperty('models');
34 |   });
35 | 
36 |   it('should work through toolDefinition handler', async () => {
37 |     mockPs.mockResolvedValue({
38 |       models: [
39 |         {
40 |           name: 'llama3.2:latest',
41 |           size: 5000000000,
42 |         },
43 |       ],
44 |     });
45 | 
46 |     const result = await toolDefinition.handler(
47 |       ollama,
48 |       { format: 'json' },
49 |       ResponseFormat.JSON
50 |     );
51 | 
52 |     expect(typeof result).toBe('string');
53 |     expect(mockPs).toHaveBeenCalledTimes(1);
54 |   });
55 | });
56 | 
```

--------------------------------------------------------------------------------
/src/tools/copy.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { CopyModelInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Copy a model
 9 |  */
10 | export async function copyModel(
11 |   ollama: Ollama,
12 |   source: string,
13 |   destination: string,
14 |   format: ResponseFormat
15 | ): Promise<string> {
16 |   const response = await ollama.copy({
17 |     source,
18 |     destination,
19 |   });
20 | 
21 |   return formatResponse(JSON.stringify(response), format);
22 | }
23 | 
24 | export const toolDefinition: ToolDefinition = {
25 |   name: 'ollama_copy',
26 |   description:
27 |     'Copy a model. Creates a duplicate of an existing model with a new name.',
28 |   inputSchema: {
29 |     type: 'object',
30 |     properties: {
31 |       source: {
32 |         type: 'string',
33 |         description: 'Name of the source model',
34 |       },
35 |       destination: {
36 |         type: 'string',
37 |         description: 'Name for the copied model',
38 |       },
39 |       format: {
40 |         type: 'string',
41 |         enum: ['json', 'markdown'],
42 |         default: 'json',
43 |       },
44 |     },
45 |     required: ['source', 'destination'],
46 |   },
47 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
48 |     const validated = CopyModelInputSchema.parse(args);
49 |     return copyModel(ollama, validated.source, validated.destination, format);
50 |   },
51 | };
52 | 
```

--------------------------------------------------------------------------------
/tests/tools/create.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { createModel, toolDefinition } from '../../src/tools/create.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('createModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockCreate: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockCreate = vi.fn();
12 |     ollama = {
13 |       create: mockCreate,
14 |     } as any;
15 |   });
16 | 
17 |   it('should create a model with structured parameters', async () => {
18 |     mockCreate.mockResolvedValue({
19 |       status: 'success',
20 |     });
21 | 
22 |     const result = await createModel(
23 |       ollama,
24 |       {
25 |         model: 'my-model:latest',
26 |         from: 'llama3.2',
27 |         system: 'You are a helpful assistant',
28 |       },
29 |       ResponseFormat.JSON
30 |     );
31 | 
32 |     expect(typeof result).toBe('string');
33 |     expect(mockCreate).toHaveBeenCalledWith({
34 |       model: 'my-model:latest',
35 |       from: 'llama3.2',
36 |       system: 'You are a helpful assistant',
37 |       template: undefined,
38 |       license: undefined,
39 |       stream: false,
40 |     });
41 |   });
42 | 
43 |   it('should work through toolDefinition handler', async () => {
44 |     mockCreate.mockResolvedValue({
45 |       status: 'success',
46 |     });
47 | 
48 |     const result = await toolDefinition.handler(
49 |       ollama,
50 |       { model: 'my-custom-model:latest', from: 'llama3.2', format: 'json' },
51 |       ResponseFormat.JSON
52 |     );
53 | 
54 |     expect(typeof result).toBe('string');
55 |   });
56 | 
57 | });
```

--------------------------------------------------------------------------------
/src/tools/push.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { PushModelInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Push a model to the Ollama registry
 9 |  */
10 | export async function pushModel(
11 |   ollama: Ollama,
12 |   model: string,
13 |   insecure: boolean,
14 |   format: ResponseFormat
15 | ): Promise<string> {
16 |   const response = await ollama.push({
17 |     model,
18 |     insecure,
19 |     stream: false,
20 |   });
21 | 
22 |   return formatResponse(JSON.stringify(response), format);
23 | }
24 | 
25 | export const toolDefinition: ToolDefinition = {
26 |   name: 'ollama_push',
27 |   description:
28 |     'Push a model to the Ollama registry. Uploads a local model to make it available remotely.',
29 |   inputSchema: {
30 |     type: 'object',
31 |     properties: {
32 |       model: {
33 |         type: 'string',
34 |         description: 'Name of the model to push',
35 |       },
36 |       insecure: {
37 |         type: 'boolean',
38 |         description: 'Allow insecure connections',
39 |         default: false,
40 |       },
41 |       format: {
42 |         type: 'string',
43 |         enum: ['json', 'markdown'],
44 |         default: 'json',
45 |       },
46 |     },
47 |     required: ['model'],
48 |   },
49 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
50 |     const validated = PushModelInputSchema.parse(args);
51 |     return pushModel(ollama, validated.model, validated.insecure, format);
52 |   },
53 | };
54 | 
```

--------------------------------------------------------------------------------
/src/tools/pull.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { PullModelInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Pull a model from the Ollama registry
 9 |  */
10 | export async function pullModel(
11 |   ollama: Ollama,
12 |   model: string,
13 |   insecure: boolean,
14 |   format: ResponseFormat
15 | ): Promise<string> {
16 |   const response = await ollama.pull({
17 |     model,
18 |     insecure,
19 |     stream: false,
20 |   });
21 | 
22 |   return formatResponse(JSON.stringify(response), format);
23 | }
24 | 
25 | export const toolDefinition: ToolDefinition = {
26 |   name: 'ollama_pull',
27 |   description:
28 |     'Pull a model from the Ollama registry. Downloads the model to make it available locally.',
29 |   inputSchema: {
30 |     type: 'object',
31 |     properties: {
32 |       model: {
33 |         type: 'string',
34 |         description: 'Name of the model to pull',
35 |       },
36 |       insecure: {
37 |         type: 'boolean',
38 |         description: 'Allow insecure connections',
39 |         default: false,
40 |       },
41 |       format: {
42 |         type: 'string',
43 |         enum: ['json', 'markdown'],
44 |         default: 'json',
45 |       },
46 |     },
47 |     required: ['model'],
48 |   },
49 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
50 |     const validated = PullModelInputSchema.parse(args);
51 |     return pullModel(ollama, validated.model, validated.insecure, format);
52 |   },
53 | };
54 | 
```

--------------------------------------------------------------------------------
/tests/tools/embed.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { embedWithModel, toolDefinition } from '../../src/tools/embed.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('embedWithModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockEmbed: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockEmbed = vi.fn();
12 |     ollama = {
13 |       embed: mockEmbed,
14 |     } as any;
15 |   });
16 | 
17 |   it('should generate embeddings for single input', async () => {
18 |     mockEmbed.mockResolvedValue({
19 |       embeddings: [[0.1, 0.2, 0.3, 0.4, 0.5]],
20 |     });
21 | 
22 |     const result = await embedWithModel(
23 |       ollama,
24 |       'llama3.2:latest',
25 |       'Hello world',
26 |       ResponseFormat.JSON
27 |     );
28 | 
29 |     expect(typeof result).toBe('string');
30 |     expect(mockEmbed).toHaveBeenCalledTimes(1);
31 |     expect(mockEmbed).toHaveBeenCalledWith({
32 |       model: 'llama3.2:latest',
33 |       input: 'Hello world',
34 |     });
35 | 
36 |     const parsed = JSON.parse(result);
37 |     expect(parsed).toHaveProperty('embeddings');
38 |     expect(Array.isArray(parsed.embeddings)).toBe(true);
39 |   });
40 | 
41 |   it('should work through toolDefinition handler', async () => {
42 |     mockEmbed.mockResolvedValue({
43 |       embeddings: [[0.1, 0.2, 0.3]],
44 |     });
45 | 
46 |     const result = await toolDefinition.handler(
47 |       ollama,
48 |       { model: 'llama3.2:latest', input: 'Test input', format: 'json' },
49 |       ResponseFormat.JSON
50 |     );
51 | 
52 |     expect(typeof result).toBe('string');
53 |   });
54 | 
55 | });
```

--------------------------------------------------------------------------------
/src/autoloader.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { readdir } from 'fs/promises';
 2 | import { join, dirname } from 'path';
 3 | import { fileURLToPath } from 'url';
 4 | import type { Ollama } from 'ollama';
 5 | import { ResponseFormat } from './types.js';
 6 | 
 7 | const __filename = fileURLToPath(import.meta.url);
 8 | const __dirname = dirname(__filename);
 9 | 
10 | /**
11 |  * Represents a tool's metadata and handler function
12 |  */
13 | export interface ToolDefinition {
14 |   name: string;
15 |   description: string;
16 |   inputSchema: {
17 |     type: 'object';
18 |     properties: Record<string, unknown>;
19 |     required?: string[];
20 |   };
21 |   handler: (
22 |     ollama: Ollama,
23 |     args: Record<string, unknown>,
24 |     format: ResponseFormat
25 |   ) => Promise<string>;
26 | }
27 | 
28 | /**
29 |  * Discover and load all tools from the tools directory
30 |  */
31 | export async function discoverTools(): Promise<ToolDefinition[]> {
32 |   const toolsDir = join(__dirname, 'tools');
33 |   const files = await readdir(toolsDir);
34 | 
35 |   // Filter for .js files (production) or .ts files (development)
36 |   // Exclude test files and declaration files
37 |   const toolFiles = files.filter(
38 |     (file) =>
39 |       (file.endsWith('.js') || file.endsWith('.ts')) &&
40 |       !file.includes('.test.') &&
41 |       !file.endsWith('.d.ts')
42 |   );
43 | 
44 |   const tools: ToolDefinition[] = [];
45 | 
46 |   for (const file of toolFiles) {
47 |     const toolPath = join(toolsDir, file);
48 |     const module = await import(toolPath);
49 | 
50 |     // Check if module exports tool metadata
51 |     if (module.toolDefinition) {
52 |       tools.push(module.toolDefinition);
53 |     }
54 |   }
55 | 
56 |   return tools;
57 | }
58 | 
```

--------------------------------------------------------------------------------
/src/tools/embed.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { EmbedInputSchema } from '../schemas.js';
 6 | 
 7 | /**
 8 |  * Generate embeddings for text input
 9 |  */
10 | export async function embedWithModel(
11 |   ollama: Ollama,
12 |   model: string,
13 |   input: string | string[],
14 |   format: ResponseFormat
15 | ): Promise<string> {
16 |   const response = await ollama.embed({
17 |     model,
18 |     input,
19 |   });
20 | 
21 |   return formatResponse(JSON.stringify(response), format);
22 | }
23 | 
24 | export const toolDefinition: ToolDefinition = {
25 |   name: 'ollama_embed',
26 |   description:
27 |     'Generate embeddings for text input. Returns numerical vector representations.',
28 |   inputSchema: {
29 |     type: 'object',
30 |     properties: {
31 |       model: {
32 |         type: 'string',
33 |         description: 'Name of the model to use',
34 |       },
35 |       input: {
36 |         type: 'string',
37 |         description:
38 |           'Text input. For batch processing, provide a JSON-encoded array of strings, e.g., ["text1", "text2"]',
39 |       },
40 |       format: {
41 |         type: 'string',
42 |         enum: ['json', 'markdown'],
43 |         default: 'json',
44 |       },
45 |     },
46 |     required: ['model', 'input'],
47 |   },
48 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
49 |     const validated = EmbedInputSchema.parse(args);
50 |     return embedWithModel(ollama, validated.model, validated.input, format);
51 |   },
52 | };
53 | 
```

--------------------------------------------------------------------------------
/tests/tools/list.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { listModels } from '../../src/tools/list.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('listModels', () => {
 7 |   let ollama: Ollama;
 8 |   let mockList: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockList = vi.fn();
12 |     ollama = {
13 |       list: mockList,
14 |     } as any;
15 |   });
16 | 
17 |   it('should return formatted model list in JSON format', async () => {
18 |     mockList.mockResolvedValue({
19 |       models: [
20 |         {
21 |           name: 'llama3.2:latest',
22 |           modified_at: '2024-01-01T00:00:00Z',
23 |           size: 5000000000,
24 |           digest: 'abc123',
25 |         },
26 |       ],
27 |     });
28 | 
29 |     const result = await listModels(ollama, ResponseFormat.JSON);
30 | 
31 |     expect(typeof result).toBe('string');
32 |     expect(mockList).toHaveBeenCalledTimes(1);
33 | 
34 |     const parsed = JSON.parse(result);
35 |     expect(parsed).toHaveProperty('models');
36 |     expect(Array.isArray(parsed.models)).toBe(true);
37 |   });
38 | 
39 |   it('should return markdown format when specified', async () => {
40 |     mockList.mockResolvedValue({
41 |       models: [
42 |         {
43 |           name: 'llama3.2:latest',
44 |           modified_at: '2024-01-01T00:00:00Z',
45 |           size: 5000000000,
46 |           digest: 'abc123',
47 |         },
48 |       ],
49 |     });
50 | 
51 |     const result = await listModels(ollama, ResponseFormat.MARKDOWN);
52 | 
53 |     expect(typeof result).toBe('string');
54 |     expect(mockList).toHaveBeenCalledTimes(1);
55 |     // Markdown format should contain markdown table with headers
56 |     expect(result).toContain('| name');
57 |     expect(result).toContain('llama3.2:latest');
58 |   });
59 | });
60 | 
```

--------------------------------------------------------------------------------
/tests/tools/show.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { showModel, toolDefinition } from '../../src/tools/show.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('showModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockShow: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockShow = vi.fn();
12 |     ollama = {
13 |       show: mockShow,
14 |     } as any;
15 |   });
16 | 
17 |   it('should return model information in JSON format', async () => {
18 |     mockShow.mockResolvedValue({
19 |       modelfile: 'FROM llama3.2\nPARAMETER temperature 0.7',
20 |       parameters: 'temperature 0.7',
21 |       template: 'template content',
22 |       details: {
23 |         parent_model: '',
24 |         format: 'gguf',
25 |         family: 'llama',
26 |         families: ['llama'],
27 |         parameter_size: '3B',
28 |         quantization_level: 'Q4_0',
29 |       },
30 |     });
31 | 
32 |     const result = await showModel(ollama, 'llama3.2:latest', ResponseFormat.JSON);
33 | 
34 |     expect(typeof result).toBe('string');
35 |     expect(mockShow).toHaveBeenCalledWith({ model: 'llama3.2:latest' });
36 |     expect(mockShow).toHaveBeenCalledTimes(1);
37 | 
38 |     const parsed = JSON.parse(result);
39 |     expect(parsed).toHaveProperty('modelfile');
40 |     expect(parsed).toHaveProperty('details');
41 |   });
42 | 
43 |   it('should work through toolDefinition handler', async () => {
44 |     mockShow.mockResolvedValue({
45 |       modelfile: 'FROM llama3.2',
46 |       details: { family: 'llama' },
47 |     });
48 | 
49 |     const result = await toolDefinition.handler(
50 |       ollama,
51 |       { model: 'llama3.2:latest', format: 'json' },
52 |       ResponseFormat.JSON
53 |     );
54 | 
55 |     expect(typeof result).toBe('string');
56 |   });
57 | 
58 | });
```

--------------------------------------------------------------------------------
/src/tools/generate.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import type { GenerationOptions } from '../types.js';
 3 | import { ResponseFormat } from '../types.js';
 4 | import { formatResponse } from '../utils/response-formatter.js';
 5 | import type { ToolDefinition } from '../autoloader.js';
 6 | import { GenerateInputSchema } from '../schemas.js';
 7 | 
 8 | /**
 9 |  * Generate completion from a prompt
10 |  */
11 | export async function generateWithModel(
12 |   ollama: Ollama,
13 |   model: string,
14 |   prompt: string,
15 |   options: GenerationOptions,
16 |   format: ResponseFormat
17 | ): Promise<string> {
18 |   const response = await ollama.generate({
19 |     model,
20 |     prompt,
21 |     options,
22 |     format: format === ResponseFormat.JSON ? 'json' : undefined,
23 |     stream: false,
24 |   });
25 | 
26 |   return formatResponse(response.response, format);
27 | }
28 | 
29 | export const toolDefinition: ToolDefinition = {
30 |   name: 'ollama_generate',
31 |   description:
32 |     'Generate completion from a prompt. Simpler than chat, useful for single-turn completions.',
33 |   inputSchema: {
34 |     type: 'object',
35 |     properties: {
36 |       model: {
37 |         type: 'string',
38 |         description: 'Name of the model to use',
39 |       },
40 |       prompt: {
41 |         type: 'string',
42 |         description: 'The prompt to generate from',
43 |       },
44 |       options: {
45 |         type: 'string',
46 |         description: 'Generation options (optional). Provide as JSON object with settings like temperature, top_p, etc.',
47 |       },
48 |       format: {
49 |         type: 'string',
50 |         enum: ['json', 'markdown'],
51 |         default: 'json',
52 |       },
53 |     },
54 |     required: ['model', 'prompt'],
55 |   },
56 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
57 |     const validated = GenerateInputSchema.parse(args);
58 |     return generateWithModel(
59 |       ollama,
60 |       validated.model,
61 |       validated.prompt,
62 |       validated.options || {},
63 |       format
64 |     );
65 |   },
66 | };
67 | 
```

--------------------------------------------------------------------------------
/.github/workflows/claude.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Claude Code
 2 | 
 3 | on:
 4 |   issue_comment:
 5 |     types: [created]
 6 |   pull_request_review_comment:
 7 |     types: [created]
 8 |   issues:
 9 |     types: [opened, assigned]
10 |   pull_request_review:
11 |     types: [submitted]
12 | 
13 | jobs:
14 |   claude:
15 |     if: |
16 |       (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17 |       (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18 |       (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19 |       (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20 |     runs-on: ubuntu-latest
21 |     permissions:
22 |       contents: read
23 |       pull-requests: read
24 |       issues: read
25 |       id-token: write
26 |       actions: read # Required for Claude to read CI results on PRs
27 |     steps:
28 |       - name: Checkout repository
29 |         uses: actions/checkout@v4
30 |         with:
31 |           fetch-depth: 1
32 | 
33 |       - name: Run Claude Code
34 |         id: claude
35 |         uses: anthropics/claude-code-action@v1
36 |         with:
37 |           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38 | 
39 |           # This is an optional setting that allows Claude to read CI results on PRs
40 |           additional_permissions: |
41 |             actions: read
42 | 
43 |           # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
44 |           # prompt: 'Update the pull request description to include a summary of changes.'
45 | 
46 |           # Optional: Add claude_args to customize behavior and configuration
47 |           # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
48 |           # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
49 |           # claude_args: '--allowed-tools Bash(gh pr:*)'
50 | 
51 | 
```

--------------------------------------------------------------------------------
/.github/workflows/claude-code-review.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Claude Code Review
 2 | 
 3 | on:
 4 |   pull_request:
 5 |     types: [opened, synchronize]
 6 |     # Optional: Only run on specific file changes
 7 |     # paths:
 8 |     #   - "src/**/*.ts"
 9 |     #   - "src/**/*.tsx"
10 |     #   - "src/**/*.js"
11 |     #   - "src/**/*.jsx"
12 | 
13 | jobs:
14 |   claude-review:
15 |     # Optional: Filter by PR author
16 |     # if: |
17 |     #   github.event.pull_request.user.login == 'external-contributor' ||
18 |     #   github.event.pull_request.user.login == 'new-developer' ||
19 |     #   github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20 | 
21 |     runs-on: ubuntu-latest
22 |     permissions:
23 |       contents: read
24 |       pull-requests: read
25 |       issues: read
26 |       id-token: write
27 | 
28 |     steps:
29 |       - name: Checkout repository
30 |         uses: actions/checkout@v4
31 |         with:
32 |           fetch-depth: 1
33 | 
34 |       - name: Run Claude Code Review
35 |         id: claude-review
36 |         uses: anthropics/claude-code-action@v1
37 |         with:
38 |           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39 |           prompt: |
40 |             REPO: ${{ github.repository }}
41 |             PR NUMBER: ${{ github.event.pull_request.number }}
42 | 
43 |             Please review this pull request and provide feedback on:
44 |             - Code quality and best practices
45 |             - Potential bugs or issues
46 |             - Performance considerations
47 |             - Security concerns
48 |             - Test coverage
49 | 
50 |             Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
51 | 
52 |             Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
53 | 
54 |           # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
55 |           # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
56 |           claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
57 | 
58 | 
```

--------------------------------------------------------------------------------
/tests/tools/generate.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
 2 | import { Ollama } from 'ollama';
 3 | import { generateWithModel, toolDefinition } from '../../src/tools/generate.js';
 4 | import { ResponseFormat } from '../../src/types.js';
 5 | 
 6 | describe('generateWithModel', () => {
 7 |   let ollama: Ollama;
 8 |   let mockGenerate: ReturnType<typeof vi.fn>;
 9 | 
10 |   beforeEach(() => {
11 |     mockGenerate = vi.fn();
12 |     ollama = {
13 |       generate: mockGenerate,
14 |     } as any;
15 |   });
16 | 
17 |   it('should generate completion from prompt', async () => {
18 |     mockGenerate.mockResolvedValue({
19 |       response: 'The sky appears blue because...',
20 |       done: true,
21 |     });
22 | 
23 |     const result = await generateWithModel(
24 |       ollama,
25 |       'llama3.2:latest',
26 |       'Why is the sky blue?',
27 |       {},
28 |       ResponseFormat.MARKDOWN
29 |     );
30 | 
31 |     expect(typeof result).toBe('string');
32 |     expect(mockGenerate).toHaveBeenCalledTimes(1);
33 |     expect(mockGenerate).toHaveBeenCalledWith({
34 |       model: 'llama3.2:latest',
35 |       prompt: 'Why is the sky blue?',
36 |       options: {},
37 |       stream: false,
38 |     });
39 |     expect(result).toContain('The sky appears blue because');
40 |   });
41 | 
42 |   it('should use JSON format when ResponseFormat.JSON is specified', async () => {
43 |     mockGenerate.mockResolvedValue({
44 |       response: '{"answer": "test"}',
45 |       done: true,
46 |     });
47 | 
48 |     const result = await generateWithModel(
49 |       ollama,
50 |       'llama3.2:latest',
51 |       'Test prompt',
52 |       {},
53 |       ResponseFormat.JSON
54 |     );
55 | 
56 |     expect(mockGenerate).toHaveBeenCalledWith({
57 |       model: 'llama3.2:latest',
58 |       prompt: 'Test prompt',
59 |       options: {},
60 |       format: 'json',
61 |       stream: false,
62 |     });
63 |   });
64 | 
65 |   it('should work through toolDefinition handler', async () => {
66 |     mockGenerate.mockResolvedValue({ response: "test", done: true });
67 |     const result = await toolDefinition.handler(
68 |       ollama,
69 |       { model: 'llama3.2:latest', prompt: 'Test prompt', format: 'json' },
70 |       ResponseFormat.JSON
71 |     );
72 | 
73 |     expect(typeof result).toBe('string');
74 |   });
75 | 
76 | });
```

--------------------------------------------------------------------------------
/tests/index.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 2 | import { main } from '../src/index.js';
 3 | 
 4 | describe('index (main entry point)', () => {
 5 |   let processOnSpy: ReturnType<typeof vi.fn>;
 6 |   let processExitSpy: ReturnType<typeof vi.fn>;
 7 |   let originalProcessOn: typeof process.on;
 8 |   let originalProcessExit: typeof process.exit;
 9 | 
10 |   beforeEach(() => {
11 |     // Store original process functions
12 |     originalProcessOn = process.on;
13 |     originalProcessExit = process.exit;
14 | 
15 |     // Mock process.on to capture SIGINT handler
16 |     processOnSpy = vi.fn();
17 |     process.on = processOnSpy as any;
18 | 
19 |     // Mock process.exit to prevent actual exits during testing
20 |     processExitSpy = vi.fn();
21 |     process.exit = processExitSpy as any;
22 |   });
23 | 
24 |   afterEach(() => {
25 |     // Restore original process functions
26 |     process.on = originalProcessOn;
27 |     process.exit = originalProcessExit;
28 |     vi.restoreAllMocks();
29 |   });
30 | 
31 |   it('should create and connect server', async () => {
32 |     const result = await main();
33 | 
34 |     // Verify server and transport are returned
35 |     expect(result).toHaveProperty('server');
36 |     expect(result).toHaveProperty('transport');
37 |     expect(result.server).toBeDefined();
38 |     expect(result.transport).toBeDefined();
39 |   });
40 | 
41 |   it('should register SIGINT handler for graceful shutdown', async () => {
42 |     await main();
43 | 
44 |     // Verify SIGINT handler was registered
45 |     expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
46 |   });
47 | 
48 |   it('should close server and exit on SIGINT', async () => {
49 |     const result = await main();
50 | 
51 |     // Get the SIGINT handler that was registered
52 |     const sigintHandler = processOnSpy.mock.calls.find(
53 |       (call) => call[0] === 'SIGINT'
54 |     )?.[1] as () => Promise<void>;
55 | 
56 |     expect(sigintHandler).toBeDefined();
57 | 
58 |     // Mock server.close
59 |     const closeSpy = vi.spyOn(result.server, 'close').mockResolvedValue();
60 | 
61 |     // Call the SIGINT handler
62 |     await sigintHandler();
63 | 
64 |     // Verify server.close was called and process.exit(0) was called
65 |     expect(closeSpy).toHaveBeenCalled();
66 |     expect(processExitSpy).toHaveBeenCalledWith(0);
67 |   });
68 | });
69 | 
```

--------------------------------------------------------------------------------
/src/tools/create.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { CreateModelInputSchema } from '../schemas.js';
 6 | 
 7 | export interface CreateModelOptions {
 8 |   model: string;
 9 |   from: string;
10 |   system?: string;
11 |   template?: string;
12 |   license?: string;
13 | }
14 | 
15 | /**
16 |  * Create a model with structured parameters
17 |  */
18 | export async function createModel(
19 |   ollama: Ollama,
20 |   options: CreateModelOptions,
21 |   format: ResponseFormat
22 | ): Promise<string> {
23 |   const response = await ollama.create({
24 |     model: options.model,
25 |     from: options.from,
26 |     system: options.system,
27 |     template: options.template,
28 |     license: options.license,
29 |     stream: false,
30 |   });
31 | 
32 |   return formatResponse(JSON.stringify(response), format);
33 | }
34 | 
35 | export const toolDefinition: ToolDefinition = {
36 |   name: 'ollama_create',
37 |   description:
38 |     'Create a new model with structured parameters. Allows customization of model behavior, system prompts, and templates.',
39 |   inputSchema: {
40 |     type: 'object',
41 |     properties: {
42 |       model: {
43 |         type: 'string',
44 |         description: 'Name for the new model',
45 |       },
46 |       from: {
47 |         type: 'string',
48 |         description: 'Base model to derive from (e.g., llama2, llama3)',
49 |       },
50 |       system: {
51 |         type: 'string',
52 |         description: 'System prompt for the model',
53 |       },
54 |       template: {
55 |         type: 'string',
56 |         description: 'Prompt template to use',
57 |       },
58 |       license: {
59 |         type: 'string',
60 |         description: 'License for the model',
61 |       },
62 |       format: {
63 |         type: 'string',
64 |         enum: ['json', 'markdown'],
65 |         default: 'json',
66 |       },
67 |     },
68 |     required: ['model', 'from'],
69 |   },
70 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
71 |     const validated = CreateModelInputSchema.parse(args);
72 |     return createModel(
73 |       ollama,
74 |       {
75 |         model: validated.model,
76 |         from: validated.from,
77 |         system: validated.system,
78 |         template: validated.template,
79 |         license: validated.license,
80 |       },
81 |       format
82 |     );
83 |   },
84 | };
85 | 
```

--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Core types for Ollama MCP Server
  3 |  */
  4 | 
  5 | import type { Ollama } from 'ollama';
  6 | 
  7 | /**
  8 |  * Response format for tool outputs
  9 |  */
 10 | export enum ResponseFormat {
 11 |   MARKDOWN = 'markdown',
 12 |   JSON = 'json',
 13 | }
 14 | 
 15 | /**
 16 |  * Generation options that can be passed to Ollama models
 17 |  */
 18 | export interface GenerationOptions {
 19 |   temperature?: number;
 20 |   top_p?: number;
 21 |   top_k?: number;
 22 |   num_predict?: number;
 23 |   repeat_penalty?: number;
 24 |   seed?: number;
 25 |   stop?: string[];
 26 | }
 27 | 
 28 | /**
 29 |  * Message role for chat
 30 |  */
 31 | export type MessageRole = 'system' | 'user' | 'assistant';
 32 | 
 33 | /**
 34 |  * Chat message structure
 35 |  */
 36 | export interface ChatMessage {
 37 |   role: MessageRole;
 38 |   content: string;
 39 |   images?: string[];
 40 |   tool_calls?: ToolCall[];
 41 | }
 42 | 
 43 | /**
 44 |  * Tool definition for function calling
 45 |  */
 46 | export interface Tool {
 47 |   type: string;
 48 |   function: {
 49 |     name?: string;
 50 |     description?: string;
 51 |     parameters?: {
 52 |       type?: string;
 53 |       required?: string[];
 54 |       properties?: {
 55 |         [key: string]: {
 56 |           type?: string | string[];
 57 |           description?: string;
 58 |           enum?: any[];
 59 |         };
 60 |       };
 61 |     };
 62 |   };
 63 | }
 64 | 
 65 | /**
 66 |  * Tool call made by the model
 67 |  */
 68 | export interface ToolCall {
 69 |   function: {
 70 |     name: string;
 71 |     arguments: {
 72 |       [key: string]: any;
 73 |     };
 74 |   };
 75 | }
 76 | 
 77 | /**
 78 |  * Base tool context passed to all tool implementations
 79 |  */
 80 | export interface ToolContext {
 81 |   ollama: Ollama;
 82 | }
 83 | 
 84 | /**
 85 |  * Tool result with content and format
 86 |  */
 87 | export interface ToolResult {
 88 |   content: string;
 89 |   format: ResponseFormat;
 90 | }
 91 | 
 92 | /**
 93 |  * Error types specific to Ollama operations
 94 |  */
 95 | export class OllamaError extends Error {
 96 |   constructor(
 97 |     message: string,
 98 |     public readonly cause?: unknown
 99 |   ) {
100 |     super(message);
101 |     this.name = 'OllamaError';
102 |   }
103 | }
104 | 
105 | export class ModelNotFoundError extends OllamaError {
106 |   constructor(modelName: string) {
107 |     super(
108 |       `Model not found: ${modelName}. Use ollama_list to see available models.`
109 |     );
110 |     this.name = 'ModelNotFoundError';
111 |   }
112 | }
113 | 
114 | export class NetworkError extends OllamaError {
115 |   constructor(message: string, cause?: unknown) {
116 |     super(message, cause);
117 |     this.name = 'NetworkError';
118 |   }
119 | }
120 | 
121 | /**
122 |  * Web search result
123 |  */
124 | export interface WebSearchResult {
125 |   title: string;
126 |   url: string;
127 |   content: string;
128 | }
129 | 
130 | /**
131 |  * Web fetch result
132 |  */
133 | export interface WebFetchResult {
134 |   title: string;
135 |   content: string;
136 |   links: string[];
137 | }
138 | 
```

--------------------------------------------------------------------------------
/src/tools/web-fetch.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { WebFetchInputSchema } from '../schemas.js';
 6 | import { retryWithBackoff, fetchWithTimeout } from '../utils/retry.js';
 7 | import { HttpError } from '../utils/http-error.js';
 8 | import { WEB_API_RETRY_CONFIG, WEB_API_TIMEOUT } from '../utils/retry-config.js';
 9 | 
10 | /**
11 |  * Fetch a web page using Ollama's web fetch API
12 |  */
13 | export async function webFetch(
14 |   ollama: Ollama,
15 |   url: string,
16 |   format: ResponseFormat
17 | ): Promise<string> {
18 |   // Web fetch requires direct API call as it's not in the SDK
19 |   const apiKey = process.env.OLLAMA_API_KEY;
20 |   if (!apiKey) {
21 |     throw new Error(
22 |       'OLLAMA_API_KEY environment variable is required for web fetch'
23 |     );
24 |   }
25 | 
26 |   return retryWithBackoff(
27 |     async () => {
28 |       const response = await fetchWithTimeout(
29 |         'https://ollama.com/api/web_fetch',
30 |         {
31 |           method: 'POST',
32 |           headers: {
33 |             'Content-Type': 'application/json',
34 |             Authorization: `Bearer ${apiKey}`,
35 |           },
36 |           body: JSON.stringify({
37 |             url,
38 |           }),
39 |         },
40 |         WEB_API_TIMEOUT
41 |       );
42 | 
43 |       if (!response.ok) {
44 |         const retryAfter = response.headers.get('retry-after') ?? undefined;
45 |         throw new HttpError(
46 |           `Web fetch failed: ${response.status} ${response.statusText}`,
47 |           response.status,
48 |           retryAfter
49 |         );
50 |       }
51 | 
52 |       const data = await response.json();
53 |       return formatResponse(JSON.stringify(data), format);
54 |     },
55 |     WEB_API_RETRY_CONFIG
56 |   );
57 | }
58 | 
59 | export const toolDefinition: ToolDefinition = {
60 |   name: 'ollama_web_fetch',
61 |   description:
62 |     'Fetch a web page by URL using Ollama\'s web fetch API. Returns the page title, content, and links. Requires OLLAMA_API_KEY environment variable.',
63 |   inputSchema: {
64 |     type: 'object',
65 |     properties: {
66 |       url: {
67 |         type: 'string',
68 |         description: 'The URL to fetch',
69 |       },
70 |       format: {
71 |         type: 'string',
72 |         enum: ['json', 'markdown'],
73 |         default: 'json',
74 |       },
75 |     },
76 |     required: ['url'],
77 |   },
78 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
79 |     const validated = WebFetchInputSchema.parse(args);
80 |     return webFetch(ollama, validated.url, format);
81 |   },
82 | };
83 | 
```

--------------------------------------------------------------------------------
/tests/schemas/chat-input.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect } from 'vitest';
 2 | import { ChatInputSchema } from '../../src/schemas.js';
 3 | 
 4 | describe('ChatInputSchema', () => {
 5 |   it('should validate valid chat input with messages', () => {
 6 |     const input = {
 7 |       model: 'llama3.2:latest',
 8 |       messages: [
 9 |         { role: 'user', content: 'Hello' },
10 |       ],
11 |       format: 'json',
12 |     };
13 | 
14 |     const result = ChatInputSchema.safeParse(input);
15 |     expect(result.success).toBe(true);
16 |   });
17 | 
18 |   it('should parse tools from JSON string', () => {
19 |     const input = {
20 |       model: 'llama3.2:latest',
21 |       messages: [{ role: 'user', content: 'Test' }],
22 |       tools: JSON.stringify([
23 |         {
24 |           type: 'function',
25 |           function: { name: 'get_weather', description: 'Get weather' },
26 |         },
27 |       ]),
28 |     };
29 | 
30 |     const result = ChatInputSchema.safeParse(input);
31 |     expect(result.success).toBe(true);
32 |     if (result.success) {
33 |       expect(Array.isArray(result.data.tools)).toBe(true);
34 |       expect(result.data.tools[0].function.name).toBe('get_weather');
35 |     }
36 |   });
37 | 
38 |   it('should default tools to empty array when not provided', () => {
39 |     const input = {
40 |       model: 'llama3.2:latest',
41 |       messages: [{ role: 'user', content: 'Test' }],
42 |     };
43 | 
44 |     const result = ChatInputSchema.safeParse(input);
45 |     expect(result.success).toBe(true);
46 |     if (result.success) {
47 |       expect(result.data.tools).toEqual([]);
48 |     }
49 |   });
50 | 
51 |   it('should parse options from JSON string', () => {
52 |     const input = {
53 |       model: 'llama3.2:latest',
54 |       messages: [{ role: 'user', content: 'Test' }],
55 |       options: JSON.stringify({ temperature: 0.7, top_p: 0.9 }),
56 |     };
57 | 
58 |     const result = ChatInputSchema.safeParse(input);
59 |     expect(result.success).toBe(true);
60 |     if (result.success) {
61 |       expect(result.data.options).toEqual({ temperature: 0.7, top_p: 0.9 });
62 |     }
63 |   });
64 | 
65 |   it('should reject invalid JSON in tools field', () => {
66 |     const input = {
67 |       model: 'llama3.2:latest',
68 |       messages: [{ role: 'user', content: 'Test' }],
69 |       tools: 'not valid json{',
70 |     };
71 | 
72 |     const result = ChatInputSchema.safeParse(input);
73 |     expect(result.success).toBe(false);
74 |   });
75 | 
76 |   it('should reject missing model field', () => {
77 |     const input = {
78 |       messages: [{ role: 'user', content: 'Test' }],
79 |     };
80 | 
81 |     const result = ChatInputSchema.safeParse(input);
82 |     expect(result.success).toBe(false);
83 |   });
84 | 
85 |   it('should reject empty messages array', () => {
86 |     const input = {
87 |       model: 'llama3.2:latest',
88 |       messages: [],
89 |     };
90 | 
91 |     const result = ChatInputSchema.safeParse(input);
92 |     expect(result.success).toBe(false);
93 |   });
94 | });
95 | 
```

--------------------------------------------------------------------------------
/src/tools/web-search.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { Ollama } from 'ollama';
 2 | import { ResponseFormat } from '../types.js';
 3 | import { formatResponse } from '../utils/response-formatter.js';
 4 | import type { ToolDefinition } from '../autoloader.js';
 5 | import { WebSearchInputSchema } from '../schemas.js';
 6 | import { retryWithBackoff, fetchWithTimeout } from '../utils/retry.js';
 7 | import { HttpError } from '../utils/http-error.js';
 8 | import { WEB_API_RETRY_CONFIG, WEB_API_TIMEOUT } from '../utils/retry-config.js';
 9 | 
10 | /**
11 |  * Perform a web search using Ollama's web search API
12 |  */
13 | export async function webSearch(
14 |   ollama: Ollama,
15 |   query: string,
16 |   maxResults: number,
17 |   format: ResponseFormat
18 | ): Promise<string> {
19 |   // Web search requires direct API call as it's not in the SDK
20 |   const apiKey = process.env.OLLAMA_API_KEY;
21 |   if (!apiKey) {
22 |     throw new Error(
23 |       'OLLAMA_API_KEY environment variable is required for web search'
24 |     );
25 |   }
26 | 
27 |   return retryWithBackoff(
28 |     async () => {
29 |       const response = await fetchWithTimeout(
30 |         'https://ollama.com/api/web_search',
31 |         {
32 |           method: 'POST',
33 |           headers: {
34 |             'Content-Type': 'application/json',
35 |             Authorization: `Bearer ${apiKey}`,
36 |           },
37 |           body: JSON.stringify({
38 |             query,
39 |             max_results: maxResults,
40 |           }),
41 |         },
42 |         WEB_API_TIMEOUT
43 |       );
44 | 
45 |       if (!response.ok) {
46 |         const retryAfter = response.headers.get('retry-after') ?? undefined;
47 |         throw new HttpError(
48 |           `Web search failed: ${response.status} ${response.statusText}`,
49 |           response.status,
50 |           retryAfter
51 |         );
52 |       }
53 | 
54 |       const data = await response.json();
55 |       return formatResponse(JSON.stringify(data), format);
56 |     },
57 |     WEB_API_RETRY_CONFIG
58 |   );
59 | }
60 | 
61 | export const toolDefinition: ToolDefinition = {
62 |   name: 'ollama_web_search',
63 |   description:
64 |     'Perform a web search using Ollama\'s web search API. Augments models with latest information to reduce hallucinations. Requires OLLAMA_API_KEY environment variable.',
65 |   inputSchema: {
66 |     type: 'object',
67 |     properties: {
68 |       query: {
69 |         type: 'string',
70 |         description: 'The search query string',
71 |       },
72 |       max_results: {
73 |         type: 'number',
74 |         description: 'Maximum number of results to return (1-10, default 5)',
75 |         default: 5,
76 |       },
77 |       format: {
78 |         type: 'string',
79 |         enum: ['json', 'markdown'],
80 |         default: 'json',
81 |       },
82 |     },
83 |     required: ['query'],
84 |   },
85 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
86 |     const validated = WebSearchInputSchema.parse(args);
87 |     return webSearch(ollama, validated.query, validated.max_results, format);
88 |   },
89 | };
90 | 
```

--------------------------------------------------------------------------------
/tests/utils/http-error.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect } from 'vitest';
 2 | import { HttpError } from '../../src/utils/http-error.js';
 3 | 
 4 | describe('HttpError', () => {
 5 |   it('should create an HttpError with message and status', () => {
 6 |     // Arrange & Act
 7 |     const error = new HttpError('Not found', 404);
 8 | 
 9 |     // Assert
10 |     expect(error).toBeInstanceOf(Error);
11 |     expect(error).toBeInstanceOf(HttpError);
12 |     expect(error.message).toBe('Not found');
13 |     expect(error.status).toBe(404);
14 |     expect(error.name).toBe('HttpError');
15 |   });
16 | 
17 |   it('should create a rate limit error (429)', () => {
18 |     // Arrange & Act
19 |     const error = new HttpError('Rate limit exceeded', 429);
20 | 
21 |     // Assert
22 |     expect(error.status).toBe(429);
23 |     expect(error.message).toBe('Rate limit exceeded');
24 |   });
25 | 
26 |   it('should create a server error (500)', () => {
27 |     // Arrange & Act
28 |     const error = new HttpError('Internal server error', 500);
29 | 
30 |     // Assert
31 |     expect(error.status).toBe(500);
32 |     expect(error.message).toBe('Internal server error');
33 |   });
34 | 
35 |   it('should have correct error name', () => {
36 |     // Arrange & Act
37 |     const error = new HttpError('Bad request', 400);
38 | 
39 |     // Assert
40 |     expect(error.name).toBe('HttpError');
41 |   });
42 | 
43 |   it('should be throwable and catchable', () => {
44 |     // Arrange
45 |     const throwError = () => {
46 |       throw new HttpError('Unauthorized', 401);
47 |     };
48 | 
49 |     // Act & Assert
50 |     expect(throwError).toThrow(HttpError);
51 |     expect(throwError).toThrow('Unauthorized');
52 | 
53 |     try {
54 |       throwError();
55 |     } catch (error) {
56 |       expect(error).toBeInstanceOf(HttpError);
57 |       expect((error as HttpError).status).toBe(401);
58 |     }
59 |   });
60 | 
61 |   it('should preserve stack trace', () => {
62 |     // Arrange & Act
63 |     const error = new HttpError('Test error', 500);
64 | 
65 |     // Assert
66 |     expect(error.stack).toBeDefined();
67 |     expect(error.stack).toContain('HttpError');
68 |   });
69 | 
70 |   it('should create an HttpError with Retry-After header', () => {
71 |     // Arrange & Act
72 |     const error = new HttpError('Rate limit exceeded', 429, '60');
73 | 
74 |     // Assert
75 |     expect(error).toBeInstanceOf(HttpError);
76 |     expect(error.status).toBe(429);
77 |     expect(error.retryAfter).toBe('60');
78 |   });
79 | 
80 |   it('should create an HttpError without Retry-After header', () => {
81 |     // Arrange & Act
82 |     const error = new HttpError('Rate limit exceeded', 429);
83 | 
84 |     // Assert
85 |     expect(error).toBeInstanceOf(HttpError);
86 |     expect(error.status).toBe(429);
87 |     expect(error.retryAfter).toBeUndefined();
88 |   });
89 | 
90 |   it('should handle Retry-After as HTTP-date string', () => {
91 |     // Arrange & Act
92 |     const dateString = 'Wed, 21 Oct 2025 07:28:00 GMT';
93 |     const error = new HttpError('Rate limit exceeded', 429, dateString);
94 | 
95 |     // Assert
96 |     expect(error.retryAfter).toBe(dateString);
97 |   });
98 | });
99 | 
```

--------------------------------------------------------------------------------
/tests/utils/response-formatter.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect } from 'vitest';
 2 | import { formatResponse } from '../../src/utils/response-formatter.js';
 3 | import { ResponseFormat } from '../../src/types.js';
 4 | 
 5 | describe('formatResponse', () => {
 6 |   it('should return plain text as-is for markdown format', () => {
 7 |     const content = 'Hello, world!';
 8 |     const result = formatResponse(content, ResponseFormat.MARKDOWN);
 9 | 
10 |     expect(result).toBe(content);
11 |   });
12 | 
13 |   it('should convert JSON object to markdown format', () => {
14 |     const jsonObject = { message: 'Hello', count: 42 };
15 |     const content = JSON.stringify(jsonObject);
16 |     const result = formatResponse(content, ResponseFormat.MARKDOWN);
17 | 
18 |     expect(result).toContain('**message:** Hello');
19 |     expect(result).toContain('**count:** 42');
20 |   });
21 | 
22 |   it('should convert JSON array to markdown table', () => {
23 |     const content = JSON.stringify({
24 |       models: [
25 |         { name: 'model1', size: 100 },
26 |         { name: 'model2', size: 200 },
27 |       ],
28 |     });
29 |     const result = formatResponse(content, ResponseFormat.MARKDOWN);
30 | 
31 |     // Check for markdown table elements (markdown-table adds proper spacing)
32 |     expect(result).toContain('| name');
33 |     expect(result).toContain('| size');
34 |     expect(result).toContain('model1');
35 |     expect(result).toContain('model2');
36 |     expect(result).toContain('100');
37 |     expect(result).toContain('200');
38 |   });
39 | 
40 |   it('should parse and stringify JSON content', () => {
41 |     const jsonObject = { message: 'Hello', count: 42 };
42 |     const content = JSON.stringify(jsonObject);
43 |     const result = formatResponse(content, ResponseFormat.JSON);
44 | 
45 |     const parsed = JSON.parse(result);
46 |     expect(parsed).toEqual(jsonObject);
47 |   });
48 | 
49 |   it('should wrap non-JSON content in error object for JSON format', () => {
50 |     const content = 'This is not JSON';
51 |     const result = formatResponse(content, ResponseFormat.JSON);
52 | 
53 |     const parsed = JSON.parse(result);
54 |     expect(parsed).toHaveProperty('error');
55 |     expect(parsed.error).toContain('Invalid JSON');
56 |     expect(parsed).toHaveProperty('raw_content');
57 |   });
58 | 
59 |   it('should format object with array value', () => {
60 |     const content = JSON.stringify({
61 |       name: 'test',
62 |       items: ['a', 'b', 'c'],
63 |     });
64 |     const result = formatResponse(content, ResponseFormat.MARKDOWN);
65 | 
66 |     expect(result).toContain('**name:** test');
67 |     expect(result).toContain('**items:**');
68 |     expect(result).toContain('- a');
69 |   });
70 | 
71 |   it('should format object with nested object value', () => {
72 |     const content = JSON.stringify({
73 |       user: 'alice',
74 |       details: { age: 30, city: 'NYC' },
75 |     });
76 |     const result = formatResponse(content, ResponseFormat.MARKDOWN);
77 | 
78 |     expect(result).toContain('**user:** alice');
79 |     expect(result).toContain('**details:**');
80 |     expect(result).toContain('**age:** 30');
81 |   });
82 | });
83 | 
```

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

```typescript
  1 | /**
  2 |  * MCP Server creation and configuration
  3 |  */
  4 | 
  5 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
  6 | import {
  7 |   CallToolRequestSchema,
  8 |   ListToolsRequestSchema,
  9 | } from '@modelcontextprotocol/sdk/types.js';
 10 | import { Ollama } from 'ollama';
 11 | import { discoverTools } from './autoloader.js';
 12 | import { ResponseFormat } from './types.js';
 13 | 
 14 | /**
 15 |  * Create and configure the MCP server with tool handlers
 16 |  */
 17 | export function createServer(ollamaInstance?: Ollama): Server {
 18 |   // Initialize Ollama client
 19 |   const ollamaConfig: {
 20 |     host: string;
 21 |     headers?: Record<string, string>;
 22 |   } = {
 23 |     host: process.env.OLLAMA_HOST || 'http://127.0.0.1:11434',
 24 |   };
 25 | 
 26 |   // Add API key header if OLLAMA_API_KEY is set
 27 |   if (process.env.OLLAMA_API_KEY) {
 28 |     ollamaConfig.headers = {
 29 |       Authorization: `Bearer ${process.env.OLLAMA_API_KEY}`,
 30 |     };
 31 |   }
 32 | 
 33 |   const ollama = ollamaInstance || new Ollama(ollamaConfig);
 34 | 
 35 |   // Create MCP server
 36 |   const server = new Server(
 37 |     {
 38 |       name: 'ollama-mcp',
 39 |       version: '2.0.2',
 40 |     },
 41 |     {
 42 |       capabilities: {
 43 |         tools: {},
 44 |       },
 45 |     }
 46 |   );
 47 | 
 48 |   // Register tool list handler
 49 |   server.setRequestHandler(ListToolsRequestSchema, async () => {
 50 |     const tools = await discoverTools();
 51 | 
 52 |     return {
 53 |       tools: tools.map((tool) => ({
 54 |         name: tool.name,
 55 |         description: tool.description,
 56 |         inputSchema: tool.inputSchema,
 57 |       })),
 58 |     };
 59 |   });
 60 | 
 61 |   // Register tool call handler
 62 |   server.setRequestHandler(CallToolRequestSchema, async (request) => {
 63 |     try {
 64 |       const { name, arguments: args } = request.params;
 65 | 
 66 |       // Discover all tools
 67 |       const tools = await discoverTools();
 68 | 
 69 |       // Find the matching tool
 70 |       const tool = tools.find((t) => t.name === name);
 71 | 
 72 |       if (!tool) {
 73 |         throw new Error(`Unknown tool: ${name}`);
 74 |       }
 75 | 
 76 |       // Determine format from args
 77 |       const formatArg = (args as Record<string, unknown>).format;
 78 |       const format =
 79 |         formatArg === 'markdown' ? ResponseFormat.MARKDOWN : ResponseFormat.JSON;
 80 | 
 81 |       // Call the tool handler
 82 |       const result = await tool.handler(
 83 |         ollama,
 84 |         args as Record<string, unknown>,
 85 |         format
 86 |       );
 87 | 
 88 |       // Parse the result to extract structured data
 89 |       let structuredData: unknown = undefined;
 90 |       try {
 91 |         // Attempt to parse the result as JSON
 92 |         structuredData = JSON.parse(result);
 93 |       } catch {
 94 |         // If parsing fails, leave structuredData as undefined
 95 |         // This handles cases where the result is markdown or plain text
 96 |       }
 97 | 
 98 |       return {
 99 |         structuredContent: structuredData,
100 |         content: [
101 |           {
102 |             type: 'text',
103 |             text: result,
104 |           },
105 |         ],
106 |       };
107 |     } catch (error) {
108 |       const errorMessage =
109 |         error instanceof Error ? error.message : String(error);
110 |       return {
111 |         content: [
112 |           {
113 |             type: 'text',
114 |             text: `Error: ${errorMessage}`,
115 |           },
116 |         ],
117 |         isError: true,
118 |       };
119 |     }
120 |   });
121 | 
122 |   return server;
123 | }
124 | 
```

--------------------------------------------------------------------------------
/src/tools/chat.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { Ollama } from 'ollama';
  2 | import type { ChatMessage, GenerationOptions, Tool } from '../types.js';
  3 | import { ResponseFormat } from '../types.js';
  4 | import { formatResponse } from '../utils/response-formatter.js';
  5 | import type { ToolDefinition } from '../autoloader.js';
  6 | import { ChatInputSchema } from '../schemas.js';
  7 | 
  8 | /**
  9 |  * Chat with a model using conversation messages
 10 |  */
 11 | export async function chatWithModel(
 12 |   ollama: Ollama,
 13 |   model: string,
 14 |   messages: ChatMessage[],
 15 |   options: GenerationOptions,
 16 |   format: ResponseFormat,
 17 |   tools?: Tool[]
 18 | ): Promise<string> {
 19 |   // Determine format parameter for Ollama API
 20 |   let ollamaFormat: 'json' | undefined = undefined;
 21 |   if (format === ResponseFormat.JSON) {
 22 |     ollamaFormat = 'json';
 23 |   }
 24 | 
 25 |   const response = await ollama.chat({
 26 |     model,
 27 |     messages,
 28 |     tools,
 29 |     options,
 30 |     format: ollamaFormat,
 31 |     stream: false,
 32 |   });
 33 | 
 34 |   // Extract content with fallback
 35 |   let content = response.message.content;
 36 |   if (!content) {
 37 |     content = '';
 38 |   }
 39 | 
 40 |   const tool_calls = response.message.tool_calls;
 41 | 
 42 |   // If the response includes tool calls, include them in the output
 43 |   let hasToolCalls = false;
 44 |   if (tool_calls) {
 45 |     if (tool_calls.length > 0) {
 46 |       hasToolCalls = true;
 47 |     }
 48 |   }
 49 | 
 50 |   if (hasToolCalls) {
 51 |     const fullResponse = {
 52 |       content,
 53 |       tool_calls,
 54 |     };
 55 |     return formatResponse(JSON.stringify(fullResponse), format);
 56 |   }
 57 | 
 58 |   return formatResponse(content, format);
 59 | }
 60 | 
 61 | export const toolDefinition: ToolDefinition = {
 62 |   name: 'ollama_chat',
 63 |   description:
 64 |     'Chat with a model using conversation messages. Supports system messages, multi-turn conversations, tool calling, and generation options.',
 65 |   inputSchema: {
 66 |     type: 'object',
 67 |     properties: {
 68 |       model: {
 69 |         type: 'string',
 70 |         description: 'Name of the model to use',
 71 |       },
 72 |       messages: {
 73 |         type: 'array',
 74 |         description: 'Array of chat messages',
 75 |         items: {
 76 |           type: 'object',
 77 |           properties: {
 78 |             role: {
 79 |               type: 'string',
 80 |               enum: ['system', 'user', 'assistant'],
 81 |             },
 82 |             content: {
 83 |               type: 'string',
 84 |             },
 85 |             images: {
 86 |               type: 'array',
 87 |               items: { type: 'string' },
 88 |             },
 89 |           },
 90 |           required: ['role', 'content'],
 91 |         },
 92 |       },
 93 |       tools: {
 94 |         type: 'string',
 95 |         description: 'Tools that the model can call (optional). Provide as JSON array of tool objects.',
 96 |       },
 97 |       options: {
 98 |         type: 'string',
 99 |         description: 'Generation options (optional). Provide as JSON object with settings like temperature, top_p, etc.',
100 |       },
101 |       format: {
102 |         type: 'string',
103 |         enum: ['json', 'markdown'],
104 |         default: 'json',
105 |       },
106 |     },
107 |     required: ['model', 'messages'],
108 |   },
109 |   handler: async (ollama: Ollama, args: Record<string, unknown>, format: ResponseFormat) => {
110 |     const validated = ChatInputSchema.parse(args);
111 |     return chatWithModel(
112 |       ollama,
113 |       validated.model,
114 |       validated.messages,
115 |       validated.options || {},
116 |       format,
117 |       validated.tools.length > 0 ? validated.tools : undefined
118 |     );
119 |   },
120 | };
121 | 
```

--------------------------------------------------------------------------------
/tests/integration/server.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
  2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
  3 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
  4 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
  5 | import { Ollama } from 'ollama';
  6 | 
  7 | // Mock the Ollama SDK
  8 | vi.mock('ollama', () => {
  9 |   return {
 10 |     Ollama: vi.fn().mockImplementation(() => ({
 11 |       list: vi.fn().mockResolvedValue({
 12 |         models: [
 13 |           {
 14 |             name: 'llama2:latest',
 15 |             size: 3825819519,
 16 |             digest: 'abc123',
 17 |             modified_at: '2024-01-01T00:00:00Z',
 18 |           },
 19 |         ],
 20 |       }),
 21 |       ps: vi.fn().mockResolvedValue({
 22 |         models: [
 23 |           {
 24 |             name: 'llama2:latest',
 25 |             size: 3825819519,
 26 |             size_vram: 3825819519,
 27 |           },
 28 |         ],
 29 |       }),
 30 |     })),
 31 |   };
 32 | });
 33 | 
 34 | describe('MCP Server Integration', () => {
 35 |   let server: Server;
 36 |   let client: Client;
 37 |   let serverTransport: InMemoryTransport;
 38 |   let clientTransport: InMemoryTransport;
 39 | 
 40 |   beforeAll(async () => {
 41 |     // Create transport pair
 42 |     [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
 43 | 
 44 |     // Import and create server
 45 |     const { createServer } = await import('../../src/server.js');
 46 | 
 47 |     // Create a mock Ollama instance
 48 |     const mockOllama = new Ollama({ host: 'http://localhost:11434' });
 49 |     server = createServer(mockOllama);
 50 | 
 51 |     // Create client
 52 |     client = new Client(
 53 |       {
 54 |         name: 'test-client',
 55 |         version: '1.0.0',
 56 |       },
 57 |       {
 58 |         capabilities: {},
 59 |       }
 60 |     );
 61 | 
 62 |     // Connect both
 63 |     await Promise.all([
 64 |       server.connect(serverTransport),
 65 |       client.connect(clientTransport),
 66 |     ]);
 67 |   });
 68 | 
 69 |   afterAll(async () => {
 70 |     await server.close();
 71 |     await client.close();
 72 |   });
 73 | 
 74 |   it('should list available tools', async () => {
 75 |     const response = await client.listTools();
 76 | 
 77 |     expect(response.tools).toBeDefined();
 78 |     expect(Array.isArray(response.tools)).toBe(true);
 79 |     expect(response.tools.length).toBeGreaterThan(0);
 80 | 
 81 |     // Check that tools have expected properties
 82 |     const tool = response.tools[0];
 83 |     expect(tool).toHaveProperty('name');
 84 |     expect(tool).toHaveProperty('description');
 85 |     expect(tool).toHaveProperty('inputSchema');
 86 |   });
 87 | 
 88 |   it('should call ollama_list tool', async () => {
 89 |     const response = await client.callTool({
 90 |       name: 'ollama_list',
 91 |       arguments: {
 92 |         format: 'json',
 93 |       },
 94 |     });
 95 | 
 96 |     expect(response.content).toBeDefined();
 97 |     expect(Array.isArray(response.content)).toBe(true);
 98 |     expect(response.content.length).toBeGreaterThan(0);
 99 |     expect(response.content[0].type).toBe('text');
100 |   });
101 | 
102 |   it('should call ollama_ps tool', async () => {
103 |     const response = await client.callTool({
104 |       name: 'ollama_ps',
105 |       arguments: {
106 |         format: 'json',
107 |       },
108 |     });
109 | 
110 |     expect(response.content).toBeDefined();
111 |     expect(Array.isArray(response.content)).toBe(true);
112 |     expect(response.content.length).toBeGreaterThan(0);
113 |     expect(response.content[0].type).toBe('text');
114 |   });
115 | 
116 |   it('should return error for unknown tool', async () => {
117 |     const response = await client.callTool({
118 |       name: 'ollama_unknown',
119 |       arguments: {},
120 |     });
121 | 
122 |     expect(response.isError).toBe(true);
123 |     expect(response.content[0].text).toContain('Unknown tool');
124 |   });
125 | });
126 | 
```

--------------------------------------------------------------------------------
/src/utils/response-formatter.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { markdownTable } from 'markdown-table';
  2 | import { ResponseFormat } from '../types.js';
  3 | 
  4 | /**
  5 |  * Format response content based on the specified format
  6 |  */
  7 | export function formatResponse(
  8 |   content: string,
  9 |   format: ResponseFormat
 10 | ): string {
 11 |   if (format === ResponseFormat.JSON) {
 12 |     // For JSON format, validate and potentially wrap errors
 13 |     try {
 14 |       // Try to parse to validate it's valid JSON
 15 |       JSON.parse(content);
 16 |       return content;
 17 |     } catch {
 18 |       // If not valid JSON, wrap in error object
 19 |       return JSON.stringify({
 20 |         error: 'Invalid JSON content',
 21 |         raw_content: content,
 22 |       });
 23 |     }
 24 |   }
 25 | 
 26 |   // Format as markdown
 27 |   try {
 28 |     const data = JSON.parse(content);
 29 |     return jsonToMarkdown(data);
 30 |   } catch {
 31 |     // If not valid JSON, return as-is
 32 |     return content;
 33 |   }
 34 | }
 35 | 
 36 | /**
 37 |  * Convert JSON data to markdown format
 38 |  */
 39 | function jsonToMarkdown(data: any, indent: string = ''): string {
 40 |   // Handle null/undefined
 41 |   if (data === null || data === undefined) {
 42 |     return `${indent}_null_`;
 43 |   }
 44 | 
 45 |   // Handle primitives
 46 |   if (typeof data !== 'object') {
 47 |     return `${indent}${String(data)}`;
 48 |   }
 49 | 
 50 |   // Handle arrays
 51 |   if (Array.isArray(data)) {
 52 |     if (data.length === 0) {
 53 |       return `${indent}_empty array_`;
 54 |     }
 55 | 
 56 |     // Check if array of objects with consistent keys (table format)
 57 |     if (
 58 |       data.length > 0 &&
 59 |       typeof data[0] === 'object' &&
 60 |       !Array.isArray(data[0]) &&
 61 |       data[0] !== null
 62 |     ) {
 63 |       return arrayToMarkdownTable(data, indent);
 64 |     }
 65 | 
 66 |     // Array of primitives or mixed types
 67 |     return data
 68 |       .map((item) => `${indent}- ${jsonToMarkdown(item, '')}`)
 69 |       .join('\n');
 70 |   }
 71 | 
 72 |   // Handle objects
 73 |   const entries = Object.entries(data);
 74 |   if (entries.length === 0) {
 75 |     return `${indent}_empty object_`;
 76 |   }
 77 | 
 78 |   return entries
 79 |     .map(([key, value]) => {
 80 |       const formattedKey = key.replace(/_/g, ' ');
 81 |       if (typeof value === 'object' && value !== null) {
 82 |         if (Array.isArray(value)) {
 83 |           return `${indent}**${formattedKey}:**\n${jsonToMarkdown(value, indent + '  ')}`;
 84 |         }
 85 |         return `${indent}**${formattedKey}:**\n${jsonToMarkdown(value, indent + '  ')}`;
 86 |       }
 87 |       return `${indent}**${formattedKey}:** ${value}`;
 88 |     })
 89 |     .join('\n');
 90 | }
 91 | 
 92 | /**
 93 |  * Convert array of objects to markdown table using markdown-table library
 94 |  */
 95 | function arrayToMarkdownTable(data: any[], indent: string = ''): string {
 96 |   if (data.length === 0) return `${indent}_empty_`;
 97 | 
 98 |   // Get all unique keys from all objects
 99 |   const allKeys = new Set<string>();
100 |   data.forEach((item) => {
101 |     if (item && typeof item === 'object') {
102 |       Object.keys(item).forEach((key) => allKeys.add(key));
103 |     }
104 |   });
105 |   const keys = Array.from(allKeys);
106 | 
107 |   // Format headers (replace underscores with spaces)
108 |   const headers = keys.map((k) => k.replace(/_/g, ' '));
109 | 
110 |   // Build table data
111 |   const tableData = data.map((item) => {
112 |     return keys.map((key) => {
113 |       const value = item[key];
114 |       if (value === null || value === undefined) return '';
115 |       if (typeof value === 'object') return JSON.stringify(value);
116 |       return String(value);
117 |     });
118 |   });
119 | 
120 |   // Generate markdown table
121 |   const table = markdownTable([headers, ...tableData]);
122 | 
123 |   // Add indent to each line if needed
124 |   if (indent) {
125 |     return table
126 |       .split('\n')
127 |       .map((line) => indent + line)
128 |       .join('\n');
129 |   }
130 | 
131 |   return table;
132 | }
133 | 
```

--------------------------------------------------------------------------------
/tests/tools/chat.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
  2 | import { Ollama } from 'ollama';
  3 | import { chatWithModel, toolDefinition } from '../../src/tools/chat.js';
  4 | import { ResponseFormat } from '../../src/types.js';
  5 | 
  6 | describe('chatWithModel', () => {
  7 |   let ollama: Ollama;
  8 |   let mockChat: ReturnType<typeof vi.fn>;
  9 | 
 10 |   beforeEach(() => {
 11 |     mockChat = vi.fn();
 12 |     ollama = {
 13 |       chat: mockChat,
 14 |     } as any;
 15 |   });
 16 | 
 17 |   it('should handle basic chat with single user message', async () => {
 18 |     mockChat.mockResolvedValue({
 19 |       message: {
 20 |         role: 'assistant',
 21 |         content: 'Hello! How can I help you today?',
 22 |       },
 23 |       done: true,
 24 |     });
 25 | 
 26 |     const messages = [{ role: 'user' as const, content: 'Hello' }];
 27 |     const result = await chatWithModel(
 28 |       ollama,
 29 |       'llama3.2:latest',
 30 |       messages,
 31 |       {},
 32 |       ResponseFormat.MARKDOWN
 33 |     );
 34 | 
 35 |     expect(typeof result).toBe('string');
 36 |     expect(mockChat).toHaveBeenCalledTimes(1);
 37 |     expect(mockChat).toHaveBeenCalledWith({
 38 |       model: 'llama3.2:latest',
 39 |       messages,
 40 |       options: {},
 41 |       stream: false,
 42 |     });
 43 |     expect(result).toContain('Hello! How can I help you today?');
 44 |   });
 45 | 
 46 |   it('should handle chat with system message and options', async () => {
 47 |     mockChat.mockResolvedValue({
 48 |       message: {
 49 |         role: 'assistant',
 50 |         content: 'I will be helpful and concise.',
 51 |       },
 52 |       done: true,
 53 |     });
 54 | 
 55 |     const messages = [
 56 |       { role: 'system' as const, content: 'Be helpful' },
 57 |       { role: 'user' as const, content: 'Hello' },
 58 |     ];
 59 |     const options = { temperature: 0.7, top_p: 0.9 };
 60 | 
 61 |     const result = await chatWithModel(
 62 |       ollama,
 63 |       'llama3.2:latest',
 64 |       messages,
 65 |       options,
 66 |       ResponseFormat.MARKDOWN
 67 |     );
 68 | 
 69 |     expect(typeof result).toBe('string');
 70 |     expect(mockChat).toHaveBeenCalledWith({
 71 |       model: 'llama3.2:latest',
 72 |       messages,
 73 |       options,
 74 |       stream: false,
 75 |     });
 76 |   });
 77 | 
 78 |   it('should use JSON format when ResponseFormat.JSON is specified', async () => {
 79 |     mockChat.mockResolvedValue({
 80 |       message: {
 81 |         role: 'assistant',
 82 |         content: '{"response": "test"}',
 83 |       },
 84 |       done: true,
 85 |     });
 86 | 
 87 |     const messages = [{ role: 'user' as const, content: 'Hello' }];
 88 |     const result = await chatWithModel(
 89 |       ollama,
 90 |       'llama3.2:latest',
 91 |       messages,
 92 |       {},
 93 |       ResponseFormat.JSON
 94 |     );
 95 | 
 96 |     expect(mockChat).toHaveBeenCalledWith({
 97 |       model: 'llama3.2:latest',
 98 |       messages,
 99 |       options: {},
100 |       format: 'json',
101 |       stream: false,
102 |     });
103 |   });
104 | 
105 |   it('should handle empty content with fallback', async () => {
106 |     mockChat.mockResolvedValue({
107 |       message: {
108 |         role: 'assistant',
109 |         content: '',
110 |       },
111 |       done: true,
112 |     });
113 | 
114 |     const messages = [{ role: 'user' as const, content: 'Hello' }];
115 |     const result = await chatWithModel(
116 |       ollama,
117 |       'llama3.2:latest',
118 |       messages,
119 |       {},
120 |       ResponseFormat.MARKDOWN
121 |     );
122 | 
123 |     expect(typeof result).toBe('string');
124 |   });
125 | 
126 |   it('should handle tool_calls when present', async () => {
127 |     mockChat.mockResolvedValue({
128 |       message: {
129 |         role: 'assistant',
130 |         content: 'Checking weather',
131 |         tool_calls: [{ function: { name: 'get_weather' } }],
132 |       },
133 |       done: true,
134 |     });
135 | 
136 |     const messages = [{ role: 'user' as const, content: 'Weather?' }];
137 |     const result = await chatWithModel(
138 |       ollama,
139 |       'llama3.2:latest',
140 |       messages,
141 |       {},
142 |       ResponseFormat.JSON
143 |     );
144 | 
145 |     expect(result).toContain('tool_calls');
146 |   });
147 | 
148 |   it('should work through toolDefinition handler', async () => {
149 |     mockChat.mockResolvedValue({ message: { content: "test" }, done: true });
150 |     const result = await toolDefinition.handler(
151 |       ollama,
152 |       { model: 'llama3.2:latest', messages: [{ role: 'user', content: 'test' }], format: 'json' },
153 |       ResponseFormat.JSON
154 |     );
155 | 
156 |     expect(typeof result).toBe('string');
157 |   });
158 | 
159 | });
```

--------------------------------------------------------------------------------
/src/schemas.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Zod schemas for MCP tool input validation
  3 |  */
  4 | 
  5 | import { z } from 'zod';
  6 | 
  7 | /**
  8 |  * Response format enum schema
  9 |  */
 10 | export const ResponseFormatSchema = z.enum(['markdown', 'json']);
 11 | 
 12 | /**
 13 |  * Generation options schema
 14 |  */
 15 | export const GenerationOptionsSchema = z
 16 |   .object({
 17 |     temperature: z.number().min(0).max(2).optional(),
 18 |     top_p: z.number().min(0).max(1).optional(),
 19 |     top_k: z.number().min(0).optional(),
 20 |     num_predict: z.number().int().positive().optional(),
 21 |     repeat_penalty: z.number().min(0).optional(),
 22 |     seed: z.number().int().optional(),
 23 |     stop: z.array(z.string()).optional(),
 24 |   })
 25 |   .optional();
 26 | 
 27 | /**
 28 |  * Tool schema for function calling
 29 |  */
 30 | export const ToolSchema = z.object({
 31 |   type: z.string(),
 32 |   function: z.object({
 33 |     name: z.string().optional(),
 34 |     description: z.string().optional(),
 35 |     parameters: z
 36 |       .object({
 37 |         type: z.string().optional(),
 38 |         required: z.array(z.string()).optional(),
 39 |         properties: z.record(z.any()).optional(),
 40 |       })
 41 |       .optional(),
 42 |   }),
 43 | });
 44 | 
 45 | /**
 46 |  * Chat message schema
 47 |  */
 48 | export const ChatMessageSchema = z.object({
 49 |   role: z.enum(['system', 'user', 'assistant']),
 50 |   content: z.string(),
 51 |   images: z.array(z.string()).optional(),
 52 | });
 53 | 
 54 | /**
 55 |  * Schema for ollama_list tool
 56 |  */
 57 | export const ListModelsInputSchema = z.object({
 58 |   format: ResponseFormatSchema.default('json'),
 59 | });
 60 | 
 61 | /**
 62 |  * Schema for ollama_show tool
 63 |  */
 64 | export const ShowModelInputSchema = z.object({
 65 |   model: z.string().min(1),
 66 |   format: ResponseFormatSchema.default('json'),
 67 | });
 68 | 
 69 | /**
 70 |  * Helper to parse JSON string or return default value
 71 |  */
 72 | const parseJsonOrDefault = <T>(defaultValue: T) =>
 73 |   z.string().optional().transform((val, ctx) => {
 74 |     if (!val || val.trim() === '') {
 75 |       return defaultValue;
 76 |     }
 77 |     try {
 78 |       return JSON.parse(val) as T;
 79 |     } catch (e) {
 80 |       ctx.addIssue({
 81 |         code: z.ZodIssueCode.custom,
 82 |         message: 'Invalid JSON format',
 83 |       });
 84 |       return z.NEVER;
 85 |     }
 86 |   });
 87 | 
 88 | /**
 89 |  * Schema for ollama_chat tool
 90 |  */
 91 | export const ChatInputSchema = z.object({
 92 |   model: z.string().min(1),
 93 |   messages: z.array(ChatMessageSchema).min(1),
 94 |   tools: parseJsonOrDefault([]).pipe(z.array(ToolSchema)),
 95 |   options: parseJsonOrDefault({}).pipe(GenerationOptionsSchema),
 96 |   format: ResponseFormatSchema.default('json'),
 97 |   stream: z.boolean().default(false),
 98 | });
 99 | 
100 | /**
101 |  * Schema for ollama_generate tool
102 |  */
103 | export const GenerateInputSchema = z.object({
104 |   model: z.string().min(1),
105 |   prompt: z.string(),
106 |   options: parseJsonOrDefault({}).pipe(GenerationOptionsSchema),
107 |   format: ResponseFormatSchema.default('json'),
108 |   stream: z.boolean().default(false),
109 | });
110 | 
111 | /**
112 |  * Schema for ollama_embed tool
113 |  */
114 | export const EmbedInputSchema = z.object({
115 |   model: z.string().min(1),
116 |   input: z.string().transform((val, ctx) => {
117 |     const trimmed = val.trim();
118 |     // If it looks like a JSON array, try to parse it
119 |     if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
120 |       try {
121 |         const parsed = JSON.parse(trimmed);
122 |         if (Array.isArray(parsed)) {
123 |           // Validate all elements are strings
124 |           const allStrings = parsed.every((item) => typeof item === 'string');
125 |           if (allStrings) {
126 |             return parsed as string[];
127 |           } else {
128 |             ctx.addIssue({
129 |               code: z.ZodIssueCode.custom,
130 |               message:
131 |                 'Input is a JSON array but contains non-string elements',
132 |             });
133 |             return z.NEVER;
134 |           }
135 |         }
136 |       } catch (e) {
137 |         // Failed to parse as JSON, treat as plain string
138 |       }
139 |     }
140 |     // Return as plain string
141 |     return trimmed;
142 |   }),
143 |   format: ResponseFormatSchema.default('json'),
144 | });
145 | 
146 | /**
147 |  * Schema for ollama_pull tool
148 |  */
149 | export const PullModelInputSchema = z.object({
150 |   model: z.string().min(1),
151 |   insecure: z.boolean().default(false),
152 |   format: ResponseFormatSchema.default('json'),
153 | });
154 | 
155 | /**
156 |  * Schema for ollama_push tool
157 |  */
158 | export const PushModelInputSchema = z.object({
159 |   model: z.string().min(1),
160 |   insecure: z.boolean().default(false),
161 |   format: ResponseFormatSchema.default('json'),
162 | });
163 | 
164 | /**
165 |  * Schema for ollama_create tool
166 |  */
167 | export const CreateModelInputSchema = z.object({
168 |   model: z.string().min(1),
169 |   from: z.string().min(1),
170 |   system: z.string().optional(),
171 |   template: z.string().optional(),
172 |   license: z.string().optional(),
173 |   format: ResponseFormatSchema.default('json'),
174 | });
175 | 
176 | /**
177 |  * Schema for ollama_delete tool
178 |  */
179 | export const DeleteModelInputSchema = z.object({
180 |   model: z.string().min(1),
181 |   format: ResponseFormatSchema.default('json'),
182 | });
183 | 
184 | /**
185 |  * Schema for ollama_copy tool
186 |  */
187 | export const CopyModelInputSchema = z.object({
188 |   source: z.string().min(1),
189 |   destination: z.string().min(1),
190 |   format: ResponseFormatSchema.default('json'),
191 | });
192 | 
193 | /**
194 |  * Schema for ollama_ps tool (list running models)
195 |  */
196 | export const PsInputSchema = z.object({
197 |   format: ResponseFormatSchema.default('json'),
198 | });
199 | 
200 | /**
201 |  * Schema for ollama_abort tool
202 |  */
203 | export const AbortRequestInputSchema = z.object({
204 |   model: z.string().min(1),
205 | });
206 | 
207 | /**
208 |  * Schema for ollama_web_search tool
209 |  */
210 | export const WebSearchInputSchema = z.object({
211 |   query: z.string().min(1),
212 |   max_results: z.number().int().min(1).max(10).default(5),
213 |   format: ResponseFormatSchema.default('json'),
214 | });
215 | 
216 | /**
217 |  * Schema for ollama_web_fetch tool
218 |  */
219 | export const WebFetchInputSchema = z.object({
220 |   url: z.string().url().min(1),
221 |   format: ResponseFormatSchema.default('json'),
222 | });
223 | 
```

--------------------------------------------------------------------------------
/tests/tools/web-fetch.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  2 | import { webFetch } from '../../src/tools/web-fetch.js';
  3 | import { ResponseFormat } from '../../src/types.js';
  4 | import type { Ollama } from 'ollama';
  5 | import { HttpError } from '../../src/utils/http-error.js';
  6 | 
  7 | // Mock fetch globally
  8 | global.fetch = vi.fn();
  9 | 
 10 | describe('webFetch', () => {
 11 |   const mockOllama = {} as Ollama;
 12 |   const testUrl = 'https://example.com';
 13 | 
 14 |   beforeEach(() => {
 15 |     vi.clearAllMocks();
 16 |     process.env.OLLAMA_API_KEY = 'test-api-key';
 17 |   });
 18 | 
 19 |   afterEach(() => {
 20 |     delete process.env.OLLAMA_API_KEY;
 21 |   });
 22 | 
 23 |   it('should throw error if OLLAMA_API_KEY is not set', async () => {
 24 |     // Arrange
 25 |     delete process.env.OLLAMA_API_KEY;
 26 | 
 27 |     // Act & Assert
 28 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
 29 |       .rejects.toThrow('OLLAMA_API_KEY environment variable is required');
 30 |   });
 31 | 
 32 |   it('should successfully fetch web page', async () => {
 33 |     // Arrange
 34 |     const mockResponse = {
 35 |       title: 'Test Page',
 36 |       content: 'Test content',
 37 |       links: [],
 38 |     };
 39 | 
 40 |     (global.fetch as any).mockResolvedValueOnce({
 41 |       ok: true,
 42 |       json: async () => mockResponse,
 43 |     });
 44 | 
 45 |     // Act
 46 |     const result = await webFetch(mockOllama, testUrl, ResponseFormat.JSON);
 47 | 
 48 |     // Assert
 49 |     expect(global.fetch).toHaveBeenCalledWith(
 50 |       'https://ollama.com/api/web_fetch',
 51 |       {
 52 |         method: 'POST',
 53 |         headers: {
 54 |           'Content-Type': 'application/json',
 55 |           Authorization: 'Bearer test-api-key',
 56 |         },
 57 |         body: JSON.stringify({ url: testUrl }),
 58 |         signal: expect.any(AbortSignal),
 59 |       }
 60 |     );
 61 |     expect(result).toContain('Test Page');
 62 |   });
 63 | 
 64 |   it('should successfully complete on first attempt (no retry needed)', async () => {
 65 |     // Arrange
 66 |     const mockResponse = {
 67 |       title: 'Test Page',
 68 |       content: 'Test content',
 69 |     };
 70 | 
 71 |     (global.fetch as any).mockResolvedValueOnce({
 72 |       ok: true,
 73 |       json: async () => mockResponse,
 74 |     });
 75 | 
 76 |     // Act
 77 |     const result = await webFetch(mockOllama, testUrl, ResponseFormat.JSON);
 78 | 
 79 |     // Assert
 80 |     expect(result).toContain('Test Page');
 81 |     expect(global.fetch).toHaveBeenCalledTimes(1);
 82 |   });
 83 | 
 84 |   it('should retry on 429 rate limit error and eventually succeed', async () => {
 85 |     // Arrange
 86 |     const mockResponse = {
 87 |       title: 'Success after retry',
 88 |       content: 'content',
 89 |     };
 90 | 
 91 |     // Mock setTimeout to execute immediately (avoid real delays in tests)
 92 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
 93 |       Promise.resolve().then(() => callback());
 94 |       return 0 as any;
 95 |     }) as any);
 96 | 
 97 |     // First call returns 429, second call succeeds
 98 |     (global.fetch as any)
 99 |       .mockResolvedValueOnce({
100 |         ok: false,
101 |         status: 429,
102 |         statusText: 'Too Many Requests',
103 |         headers: {
104 |           get: vi.fn().mockReturnValue(null),
105 |         },
106 |       })
107 |       .mockResolvedValueOnce({
108 |         ok: true,
109 |         json: async () => mockResponse,
110 |       });
111 | 
112 |     // Act
113 |     const result = await webFetch(mockOllama, testUrl, ResponseFormat.JSON);
114 | 
115 |     // Assert
116 |     expect(result).toContain('Success after retry');
117 |     expect(global.fetch).toHaveBeenCalledTimes(2);
118 |   });
119 | 
120 |   it('should throw error on non-retryable HTTP errors', async () => {
121 |     // Arrange - 501 Not Implemented is not retried
122 |     (global.fetch as any).mockResolvedValueOnce({
123 |       ok: false,
124 |       status: 501,
125 |       statusText: 'Not Implemented',
126 |       headers: {
127 |         get: vi.fn().mockReturnValue(null),
128 |       },
129 |     });
130 | 
131 |     // Act & Assert
132 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
133 |       .rejects.toThrow('Web fetch failed: 501 Not Implemented');
134 |   });
135 | 
136 |   it('should throw error on network timeout (no status code)', async () => {
137 |     // Arrange
138 |     const networkError = new Error('Network timeout - no response from server');
139 |     (global.fetch as any).mockRejectedValueOnce(networkError);
140 | 
141 |     // Act & Assert
142 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
143 |       .rejects.toThrow('Network timeout - no response from server');
144 | 
145 |     // Should not retry network errors
146 |     expect(global.fetch).toHaveBeenCalledTimes(1);
147 |   });
148 | 
149 |   it('should throw error when response.json() fails (malformed JSON)', async () => {
150 |     // Arrange
151 |     (global.fetch as any).mockResolvedValueOnce({
152 |       ok: true,
153 |       json: async () => {
154 |         throw new Error('Unexpected token < in JSON at position 0');
155 |       },
156 |     });
157 | 
158 |     // Act & Assert
159 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
160 |       .rejects.toThrow('Unexpected token < in JSON at position 0');
161 |   });
162 | 
163 |   it('should handle fetch abort/cancel errors', async () => {
164 |     // Arrange
165 |     const abortError = new Error('The operation was aborted');
166 |     abortError.name = 'AbortError';
167 |     (global.fetch as any).mockRejectedValueOnce(abortError);
168 | 
169 |     // Act & Assert
170 |     // Note: fetchWithTimeout transforms AbortError to timeout message
171 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
172 |       .rejects.toThrow('Request to https://ollama.com/api/web_fetch timed out after 30000ms');
173 | 
174 |     // Should not retry abort errors
175 |     expect(global.fetch).toHaveBeenCalledTimes(1);
176 |   });
177 | 
178 |   it('should eventually fail after multiple 429 retries', async () => {
179 |     // Arrange
180 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
181 |       Promise.resolve().then(() => callback());
182 |       return 0 as any;
183 |     }) as any);
184 | 
185 |     // Always return 429 (will exhaust retries)
186 |     (global.fetch as any).mockResolvedValue({
187 |       ok: false,
188 |       status: 429,
189 |       statusText: 'Too Many Requests',
190 |       headers: {
191 |         get: vi.fn().mockReturnValue(null),
192 |       },
193 |     });
194 | 
195 |     // Act & Assert
196 |     await expect(webFetch(mockOllama, testUrl, ResponseFormat.JSON))
197 |       .rejects.toThrow('Web fetch failed: 429 Too Many Requests');
198 | 
199 |     // Should attempt initial + 3 retries = 4 total
200 |     expect(global.fetch).toHaveBeenCalledTimes(4);
201 |   });
202 | });
203 | 
```

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

```typescript
  1 | import { HttpError } from './http-error.js';
  2 | 
  3 | /**
  4 |  * Options for retry behavior
  5 |  */
  6 | export interface RetryOptions {
  7 |   /** Number of retry attempts after the initial call (default: 3) */
  8 |   maxRetries?: number;
  9 |   /** Initial delay in milliseconds before first retry (default: 1000ms) */
 10 |   initialDelay?: number;
 11 |   /** Maximum delay in milliseconds to cap exponential backoff (default: 10000ms) */
 12 |   maxDelay?: number;
 13 |   /** Request timeout in milliseconds (default: 30000ms) */
 14 |   timeout?: number;
 15 | }
 16 | 
 17 | /**
 18 |  * Sleep for a specified duration
 19 |  */
 20 | function sleep(ms: number): Promise<void> {
 21 |   return new Promise((resolve) => setTimeout(resolve, ms));
 22 | }
 23 | 
 24 | /**
 25 |  * Check if an error is retryable based on HTTP status code
 26 |  *
 27 |  * Retryable errors include:
 28 |  * - 429 (Too Many Requests) - Rate limiting
 29 |  * - 500 (Internal Server Error) - Transient server issues
 30 |  * - 502 (Bad Gateway) - Gateway/proxy received invalid response
 31 |  * - 503 (Service Unavailable) - Server temporarily unable to handle request
 32 |  * - 504 (Gateway Timeout) - Gateway/proxy did not receive timely response
 33 |  *
 34 |  * These errors are typically transient and safe to retry for idempotent operations.
 35 |  * Other 5xx errors (501, 505, 506, 508, etc.) indicate permanent configuration
 36 |  * or implementation issues and should not be retried.
 37 |  */
 38 | function isRetryableError(error: unknown): boolean {
 39 |   if (!(error instanceof HttpError)) {
 40 |     return false;
 41 |   }
 42 | 
 43 |   const retryableStatuses = [429, 500, 502, 503, 504];
 44 |   return retryableStatuses.includes(error.status);
 45 | }
 46 | 
 47 | /**
 48 |  * Fetch with timeout support using AbortController
 49 |  *
 50 |  * Note: Creates an internal AbortController for timeout management.
 51 |  * External cancellation via options.signal is not supported - any signal
 52 |  * passed in options will be overridden by the internal timeout signal.
 53 |  *
 54 |  * @param url - URL to fetch
 55 |  * @param options - Fetch options (signal will be overridden)
 56 |  * @param timeout - Timeout in milliseconds (default: 30000ms)
 57 |  * @returns Fetch response
 58 |  * @throws Error if request times out
 59 |  */
 60 | export async function fetchWithTimeout(
 61 |   url: string,
 62 |   options?: RequestInit,
 63 |   timeout: number = 30000
 64 | ): Promise<Response> {
 65 |   const controller = new AbortController();
 66 |   const timeoutId = setTimeout(() => controller.abort(), timeout);
 67 | 
 68 |   try {
 69 |     const response = await fetch(url, {
 70 |       ...options,
 71 |       signal: controller.signal,
 72 |     });
 73 |     return response;
 74 |   } catch (error: unknown) {
 75 |     if (error instanceof Error && error.name === 'AbortError') {
 76 |       throw new Error(`Request to ${url} timed out after ${timeout}ms`);
 77 |     }
 78 |     throw error;
 79 |   } finally {
 80 |     // Always clear timeout to prevent memory leaks and race conditions.
 81 |     // If fetch completes exactly at timeout boundary, clearTimeout ensures
 82 |     // the timeout callback doesn't execute after we've already returned.
 83 |     clearTimeout(timeoutId);
 84 |   }
 85 | }
 86 | 
 87 | /**
 88 |  * Parse Retry-After header value to milliseconds
 89 |  * Supports both delay-seconds and HTTP-date formats
 90 |  * @param retryAfter - Retry-After header value
 91 |  * @returns Delay in milliseconds, or null if invalid
 92 |  */
 93 | function parseRetryAfter(retryAfter: string | undefined): number | null {
 94 |   if (!retryAfter) {
 95 |     return null;
 96 |   }
 97 | 
 98 |   // Try parsing as seconds (integer)
 99 |   const seconds = parseInt(retryAfter, 10);
100 |   if (!isNaN(seconds) && seconds >= 0) {
101 |     return seconds * 1000;
102 |   }
103 | 
104 |   // Try parsing as HTTP-date
105 |   const date = new Date(retryAfter);
106 |   if (!isNaN(date.getTime())) {
107 |     const delay = date.getTime() - Date.now();
108 |     // Only use if it's a future date
109 |     return delay > 0 ? delay : 0;
110 |   }
111 | 
112 |   return null;
113 | }
114 | 
115 | /**
116 |  * Retry a function with exponential backoff on rate limit errors
117 |  *
118 |  * Uses exponential backoff with full jitter to prevent thundering herd:
119 |  * - Attempt 0: 0-1 seconds (random in range [0, 1s])
120 |  * - Attempt 1: 0-2 seconds (random in range [0, 2s])
121 |  * - Attempt 2: 0-4 seconds (random in range [0, 4s])
122 |  * - And so on...
123 |  *
124 |  * Retry attempts are logged to console.debug for debugging and telemetry purposes,
125 |  * including attempt number, delay, and error message.
126 |  *
127 |  * @param fn - The function to retry
128 |  * @param options - Retry options (maxRetries: number of retry attempts after initial call)
129 |  * @returns The result of the function
130 |  * @throws The last error if max retries exceeded or non-retryable error
131 |  */
132 | export async function retryWithBackoff<T>(
133 |   fn: () => Promise<T>,
134 |   options: RetryOptions = {}
135 | ): Promise<T> {
136 |   const { maxRetries = 3, initialDelay = 1000, maxDelay = 10000 } = options;
137 | 
138 |   for (let attempt = 0; attempt <= maxRetries; attempt++) {
139 |     try {
140 |       return await fn();
141 |     } catch (error) {
142 |       // Only retry on transient errors (429, 500, 502, 503, 504)
143 |       // Throw immediately for any other error type
144 |       if (!isRetryableError(error)) {
145 |         throw error;
146 |       }
147 | 
148 |       // Throw if we've exhausted all retry attempts
149 |       if (attempt === maxRetries) {
150 |         throw error;
151 |       }
152 | 
153 |       // Check if error has Retry-After header
154 |       let delay: number;
155 |       const retryAfterDelay = error instanceof HttpError ? parseRetryAfter(error.retryAfter) : null;
156 | 
157 |       if (retryAfterDelay !== null) {
158 |         // Use Retry-After header value, capped at maxDelay
159 |         delay = Math.min(retryAfterDelay, maxDelay);
160 |       } else {
161 |         // Calculate delay with exponential backoff and full jitter, capped at maxDelay
162 |         const exponentialDelay = Math.min(
163 |           initialDelay * Math.pow(2, attempt),
164 |           maxDelay
165 |         );
166 |         // Full jitter: random value between 0 and exponentialDelay
167 |         delay = Math.random() * exponentialDelay;
168 |       }
169 | 
170 |       // Log retry attempt for debugging/telemetry
171 |       console.debug(
172 |         `Retry attempt ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms delay`,
173 |         {
174 |           delay: Math.round(delay),
175 |           error: error instanceof Error ? error.message : String(error)
176 |         }
177 |       );
178 | 
179 |       await sleep(delay);
180 |     }
181 |   }
182 | 
183 |   // This line is unreachable due to the throw statements above,
184 |   // but TypeScript requires it for exhaustiveness checking
185 |   throw new Error('Unexpected: retry loop completed without return or throw');
186 | }
187 | 
```

--------------------------------------------------------------------------------
/tests/tools/web-search.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  2 | import { webSearch } from '../../src/tools/web-search.js';
  3 | import { ResponseFormat } from '../../src/types.js';
  4 | import type { Ollama } from 'ollama';
  5 | import { HttpError } from '../../src/utils/http-error.js';
  6 | 
  7 | // Mock fetch globally
  8 | global.fetch = vi.fn();
  9 | 
 10 | describe('webSearch', () => {
 11 |   const mockOllama = {} as Ollama;
 12 |   const testQuery = 'test search query';
 13 |   const maxResults = 5;
 14 | 
 15 |   beforeEach(() => {
 16 |     vi.clearAllMocks();
 17 |     process.env.OLLAMA_API_KEY = 'test-api-key';
 18 |   });
 19 | 
 20 |   afterEach(() => {
 21 |     delete process.env.OLLAMA_API_KEY;
 22 |   });
 23 | 
 24 |   it('should throw error if OLLAMA_API_KEY is not set', async () => {
 25 |     // Arrange
 26 |     delete process.env.OLLAMA_API_KEY;
 27 | 
 28 |     // Act & Assert
 29 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
 30 |       .rejects.toThrow('OLLAMA_API_KEY environment variable is required');
 31 |   });
 32 | 
 33 |   it('should successfully perform web search', async () => {
 34 |     // Arrange
 35 |     const mockResponse = {
 36 |       results: [
 37 |         { title: 'Result 1', url: 'https://example.com/1', snippet: 'Test snippet 1' },
 38 |         { title: 'Result 2', url: 'https://example.com/2', snippet: 'Test snippet 2' },
 39 |       ],
 40 |     };
 41 | 
 42 |     (global.fetch as any).mockResolvedValueOnce({
 43 |       ok: true,
 44 |       json: async () => mockResponse,
 45 |     });
 46 | 
 47 |     // Act
 48 |     const result = await webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON);
 49 | 
 50 |     // Assert
 51 |     expect(global.fetch).toHaveBeenCalledWith(
 52 |       'https://ollama.com/api/web_search',
 53 |       {
 54 |         method: 'POST',
 55 |         headers: {
 56 |           'Content-Type': 'application/json',
 57 |           Authorization: 'Bearer test-api-key',
 58 |         },
 59 |         body: JSON.stringify({ query: testQuery, max_results: maxResults }),
 60 |         signal: expect.any(AbortSignal),
 61 |       }
 62 |     );
 63 |     expect(result).toContain('Result 1');
 64 |     expect(result).toContain('Result 2');
 65 |   });
 66 | 
 67 |   it('should successfully complete on first attempt (no retry needed)', async () => {
 68 |     // Arrange
 69 |     const mockResponse = {
 70 |       results: [
 71 |         { title: 'Result 1', url: 'https://example.com/1', snippet: 'Test' },
 72 |       ],
 73 |     };
 74 | 
 75 |     (global.fetch as any).mockResolvedValueOnce({
 76 |       ok: true,
 77 |       json: async () => mockResponse,
 78 |     });
 79 | 
 80 |     // Act
 81 |     const result = await webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON);
 82 | 
 83 |     // Assert
 84 |     expect(result).toContain('Result 1');
 85 |     expect(global.fetch).toHaveBeenCalledTimes(1);
 86 |   });
 87 | 
 88 |   it('should retry on 429 rate limit error and eventually succeed', async () => {
 89 |     // Arrange
 90 |     const mockResponse = {
 91 |       results: [
 92 |         { title: 'Success after retry', url: 'https://example.com', snippet: 'Test' },
 93 |       ],
 94 |     };
 95 | 
 96 |     // Mock setTimeout to execute immediately (avoid real delays in tests)
 97 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
 98 |       Promise.resolve().then(() => callback());
 99 |       return 0 as any;
100 |     }) as any);
101 | 
102 |     // First call returns 429, second call succeeds
103 |     (global.fetch as any)
104 |       .mockResolvedValueOnce({
105 |         ok: false,
106 |         status: 429,
107 |         statusText: 'Too Many Requests',
108 |         headers: {
109 |           get: vi.fn().mockReturnValue(null),
110 |         },
111 |       })
112 |       .mockResolvedValueOnce({
113 |         ok: true,
114 |         json: async () => mockResponse,
115 |       });
116 | 
117 |     // Act
118 |     const result = await webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON);
119 | 
120 |     // Assert
121 |     expect(result).toContain('Success after retry');
122 |     expect(global.fetch).toHaveBeenCalledTimes(2);
123 |   });
124 | 
125 |   it('should throw error on non-retryable HTTP errors', async () => {
126 |     // Arrange - 501 Not Implemented is not retried
127 |     (global.fetch as any).mockResolvedValueOnce({
128 |       ok: false,
129 |       status: 501,
130 |       statusText: 'Not Implemented',
131 |       headers: {
132 |         get: vi.fn().mockReturnValue(null),
133 |       },
134 |     });
135 | 
136 |     // Act & Assert
137 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
138 |       .rejects.toThrow('Web search failed: 501 Not Implemented');
139 |   });
140 | 
141 |   it('should throw error on network timeout (no status code)', async () => {
142 |     // Arrange
143 |     const networkError = new Error('Network timeout - no response from server');
144 |     (global.fetch as any).mockRejectedValueOnce(networkError);
145 | 
146 |     // Act & Assert
147 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
148 |       .rejects.toThrow('Network timeout - no response from server');
149 | 
150 |     // Should not retry network errors
151 |     expect(global.fetch).toHaveBeenCalledTimes(1);
152 |   });
153 | 
154 |   it('should throw error when response.json() fails (malformed JSON)', async () => {
155 |     // Arrange
156 |     (global.fetch as any).mockResolvedValueOnce({
157 |       ok: true,
158 |       json: async () => {
159 |         throw new Error('Unexpected token < in JSON at position 0');
160 |       },
161 |     });
162 | 
163 |     // Act & Assert
164 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
165 |       .rejects.toThrow('Unexpected token < in JSON at position 0');
166 |   });
167 | 
168 |   it('should handle fetch abort/cancel errors', async () => {
169 |     // Arrange
170 |     const abortError = new Error('The operation was aborted');
171 |     abortError.name = 'AbortError';
172 |     (global.fetch as any).mockRejectedValueOnce(abortError);
173 | 
174 |     // Act & Assert
175 |     // Note: fetchWithTimeout transforms AbortError to timeout message
176 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
177 |       .rejects.toThrow('Request to https://ollama.com/api/web_search timed out after 30000ms');
178 | 
179 |     // Should not retry abort errors
180 |     expect(global.fetch).toHaveBeenCalledTimes(1);
181 |   });
182 | 
183 |   it('should eventually fail after multiple 429 retries', async () => {
184 |     // Arrange
185 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
186 |       Promise.resolve().then(() => callback());
187 |       return 0 as any;
188 |     }) as any);
189 | 
190 |     // Always return 429 (will exhaust retries)
191 |     (global.fetch as any).mockResolvedValue({
192 |       ok: false,
193 |       status: 429,
194 |       statusText: 'Too Many Requests',
195 |       headers: {
196 |         get: vi.fn().mockReturnValue(null),
197 |       },
198 |     });
199 | 
200 |     // Act & Assert
201 |     await expect(webSearch(mockOllama, testQuery, maxResults, ResponseFormat.JSON))
202 |       .rejects.toThrow('Web search failed: 429 Too Many Requests');
203 | 
204 |     // Should attempt initial + 3 retries = 4 total
205 |     expect(global.fetch).toHaveBeenCalledTimes(4);
206 |   });
207 | });
208 | 
```

--------------------------------------------------------------------------------
/tests/utils/retry.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, afterEach } from 'vitest';
  2 | import { retryWithBackoff, fetchWithTimeout } from '../../src/utils/retry.js';
  3 | import { HttpError } from '../../src/utils/http-error.js';
  4 | 
  5 | // Type-safe mock for setTimeout that executes callbacks immediately
  6 | type TimeoutCallback = (...args: any[]) => void;
  7 | type MockSetTimeout = (callback: TimeoutCallback, ms?: number) => NodeJS.Timeout;
  8 | 
  9 | const createMockSetTimeout = (delayTracker?: number[]): MockSetTimeout => {
 10 |   return ((callback: TimeoutCallback, delay?: number) => {
 11 |     if (delayTracker && delay !== undefined) {
 12 |       delayTracker.push(delay);
 13 |     }
 14 |     Promise.resolve().then(() => callback());
 15 |     return 0 as unknown as NodeJS.Timeout;
 16 |   }) as MockSetTimeout;
 17 | };
 18 | 
 19 | describe('retryWithBackoff', () => {
 20 |   afterEach(() => {
 21 |     vi.restoreAllMocks();
 22 |   });
 23 | 
 24 |   it('should successfully execute function on first attempt', async () => {
 25 |     // Arrange
 26 |     const mockFn = vi.fn().mockResolvedValue('success');
 27 | 
 28 |     // Act
 29 |     const result = await retryWithBackoff(mockFn);
 30 | 
 31 |     // Assert
 32 |     expect(result).toBe('success');
 33 |     expect(mockFn).toHaveBeenCalledTimes(1);
 34 |   });
 35 | 
 36 |   it('should retry on 429 rate limit error with exponential backoff', async () => {
 37 |     // Arrange
 38 |     const error429 = new HttpError('Rate limit exceeded', 429);
 39 | 
 40 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout());
 41 | 
 42 |     const mockFn = vi
 43 |       .fn()
 44 |       .mockRejectedValueOnce(error429)
 45 |       .mockRejectedValueOnce(error429)
 46 |       .mockResolvedValueOnce('success');
 47 | 
 48 |     // Act
 49 |     const result = await retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 1000 });
 50 | 
 51 |     // Assert
 52 |     expect(result).toBe('success');
 53 |     expect(mockFn).toHaveBeenCalledTimes(3);
 54 |   });
 55 | 
 56 |   it('should throw error after max retries exceeded', async () => {
 57 |     // Arrange
 58 |     const error429 = new HttpError('Rate limit exceeded', 429);
 59 | 
 60 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout());
 61 | 
 62 |     const mockFn = vi.fn().mockRejectedValue(error429);
 63 | 
 64 |     // Act & Assert
 65 |     await expect(retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 100 }))
 66 |       .rejects.toThrow('Rate limit exceeded');
 67 |     expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
 68 |   });
 69 | 
 70 |   it('should not retry on non-retryable errors (e.g., 400, 404)', async () => {
 71 |     // Arrange
 72 |     const error404 = new HttpError('Not Found', 404);
 73 | 
 74 |     const mockFn = vi.fn().mockRejectedValue(error404);
 75 | 
 76 |     // Act & Assert
 77 |     await expect(retryWithBackoff(mockFn)).rejects.toThrow('Not Found');
 78 |     expect(mockFn).toHaveBeenCalledTimes(1);
 79 |   });
 80 | 
 81 |   it('should use exponential backoff with jitter: 1-2s, 2-4s, 4-8s', async () => {
 82 |     // Arrange
 83 |     const error429 = new HttpError('Rate limit exceeded', 429);
 84 | 
 85 |     const delays: number[] = [];
 86 | 
 87 |     // Immediately execute callback to avoid timing issues
 88 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
 89 | 
 90 |     const mockFn = vi
 91 |       .fn()
 92 |       .mockRejectedValueOnce(error429)
 93 |       .mockRejectedValueOnce(error429)
 94 |       .mockRejectedValueOnce(error429)
 95 |       .mockResolvedValueOnce('success');
 96 | 
 97 |     // Act
 98 |     await retryWithBackoff(mockFn, { maxRetries: 4, initialDelay: 1000 });
 99 | 
100 |     // Assert
101 |     // Check that delays follow exponential pattern with full jitter
102 |     // Formula: Full jitter = random() * exponentialDelay
103 |     // Attempt 0: 0 to 1000ms
104 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
105 |     expect(delays[0]).toBeLessThan(1000);
106 | 
107 |     // Attempt 1: 0 to 2000ms
108 |     expect(delays[1]).toBeGreaterThanOrEqual(0);
109 |     expect(delays[1]).toBeLessThan(2000);
110 | 
111 |     // Attempt 2: 0 to 4000ms
112 |     expect(delays[2]).toBeGreaterThanOrEqual(0);
113 |     expect(delays[2]).toBeLessThan(4000);
114 |   });
115 | 
116 |   it('should add jitter to prevent thundering herd', async () => {
117 |     // Arrange
118 |     const error429 = new HttpError('Rate limit exceeded', 429);
119 | 
120 |     const delays: number[] = [];
121 | 
122 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
123 | 
124 |     vi.spyOn(Math, 'random').mockReturnValue(0.5); // Fixed jitter for testing
125 | 
126 |     const mockFn = vi
127 |       .fn()
128 |       .mockRejectedValueOnce(error429)
129 |       .mockResolvedValueOnce('success');
130 | 
131 |     // Act
132 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
133 | 
134 |     // Assert
135 |     // Full jitter: Delay should be 1000 * 0.5 = 500ms
136 |     expect(delays[0]).toBe(500);
137 |   });
138 | 
139 |   it('should respect maxRetries exactly (3 retries = 4 total attempts)', async () => {
140 |     // Arrange
141 |     const error429 = new HttpError('Rate limit exceeded', 429);
142 | 
143 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout());
144 | 
145 |     const mockFn = vi
146 |       .fn()
147 |       .mockRejectedValueOnce(error429)
148 |       .mockRejectedValueOnce(error429)
149 |       .mockRejectedValueOnce(error429)
150 |       .mockResolvedValueOnce('success');
151 | 
152 |     // Act
153 |     const result = await retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 100 });
154 | 
155 |     // Assert
156 |     expect(result).toBe('success');
157 |     expect(mockFn).toHaveBeenCalledTimes(4); // Initial + 3 retries
158 |   });
159 | 
160 |   it('should throw after exactly maxRetries attempts (not maxRetries + 1)', async () => {
161 |     // Arrange
162 |     const error429 = new HttpError('Rate limit exceeded', 429);
163 | 
164 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout());
165 | 
166 |     const mockFn = vi.fn().mockRejectedValue(error429);
167 | 
168 |     // Act & Assert
169 |     await expect(retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 100 }))
170 |       .rejects.toThrow('Rate limit exceeded');
171 | 
172 |     // Should be called 4 times: initial attempt + 3 retries
173 |     expect(mockFn).toHaveBeenCalledTimes(4);
174 |   });
175 | 
176 |   it('should not retry on network timeout errors (no status code)', async () => {
177 |     // Arrange
178 |     const networkError = new Error('Network timeout');
179 |     // Intentionally don't add status property - simulates timeout/network errors
180 | 
181 |     const mockFn = vi.fn().mockRejectedValue(networkError);
182 | 
183 |     // Act & Assert
184 |     await expect(retryWithBackoff(mockFn, { maxRetries: 3 }))
185 |       .rejects.toThrow('Network timeout');
186 | 
187 |     // Should only be called once (no retries for non-HTTP errors)
188 |     expect(mockFn).toHaveBeenCalledTimes(1);
189 |   });
190 | 
191 |   it('should not retry on non-HttpError errors', async () => {
192 |     // Arrange
193 |     const genericError = new Error('Something went wrong');
194 | 
195 |     const mockFn = vi.fn().mockRejectedValue(genericError);
196 | 
197 |     // Act & Assert
198 |     await expect(retryWithBackoff(mockFn, { maxRetries: 3 }))
199 |       .rejects.toThrow('Something went wrong');
200 | 
201 |     // Should only be called once
202 |     expect(mockFn).toHaveBeenCalledTimes(1);
203 |   });
204 | 
205 |   it('should handle maximum retries with high initial delay', async () => {
206 |     // Arrange
207 |     const error429 = new HttpError('Rate limit exceeded', 429);
208 |     const delays: number[] = [];
209 | 
210 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
211 | 
212 |     const mockFn = vi.fn().mockRejectedValue(error429);
213 | 
214 |     // Act & Assert
215 |     await expect(retryWithBackoff(mockFn, { maxRetries: 5, initialDelay: 10000 }))
216 |       .rejects.toThrow('Rate limit exceeded');
217 | 
218 |     // Verify delays grow exponentially even with high initial delay (full jitter)
219 |     // Attempt 0: 0 to 10000ms
220 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
221 |     expect(delays[0]).toBeLessThan(10000);
222 | 
223 |     // Attempt 1: 0 to 20000ms
224 |     expect(delays[1]).toBeGreaterThanOrEqual(0);
225 |     expect(delays[1]).toBeLessThan(20000);
226 | 
227 |     // Attempt 2: 0 to 40000ms
228 |     expect(delays[2]).toBeGreaterThanOrEqual(0);
229 |     expect(delays[2]).toBeLessThan(40000);
230 | 
231 |     // Should attempt maxRetries + 1 times (initial + 5 retries)
232 |     expect(mockFn).toHaveBeenCalledTimes(6);
233 |   });
234 | 
235 |   it('should cap delays at maxDelay when specified', async () => {
236 |     // Arrange
237 |     const error429 = new HttpError('Rate limit exceeded', 429);
238 |     const delays: number[] = [];
239 | 
240 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
241 | 
242 |     const mockFn = vi.fn().mockRejectedValue(error429);
243 | 
244 |     // Act & Assert
245 |     await expect(retryWithBackoff(mockFn, {
246 |       maxRetries: 5,
247 |       initialDelay: 1000,
248 |       maxDelay: 5000
249 |     })).rejects.toThrow('Rate limit exceeded');
250 | 
251 |     // All delays should be capped at maxDelay (5000ms) with full jitter
252 |     // Without cap: 1000, 2000, 4000, 8000, 16000 (exponentialDelay)
253 |     // With cap: 1000, 2000, 4000, 5000, 5000 (exponentialDelay capped)
254 |     // With full jitter: delays are random(0, exponentialDelay)
255 | 
256 |     // First three delays should follow exponential pattern (full jitter)
257 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
258 |     expect(delays[0]).toBeLessThan(1000);
259 | 
260 |     expect(delays[1]).toBeGreaterThanOrEqual(0);
261 |     expect(delays[1]).toBeLessThan(2000);
262 | 
263 |     expect(delays[2]).toBeGreaterThanOrEqual(0);
264 |     expect(delays[2]).toBeLessThan(4000);
265 | 
266 |     // Fourth and fifth delays should be capped at maxDelay (full jitter: 0 to 5000)
267 |     expect(delays[3]).toBeGreaterThanOrEqual(0);
268 |     expect(delays[3]).toBeLessThan(5000);
269 | 
270 |     expect(delays[4]).toBeGreaterThanOrEqual(0);
271 |     expect(delays[4]).toBeLessThan(5000);
272 |   });
273 | 
274 |   it('should default maxDelay to 10000ms when not specified', async () => {
275 |     // Arrange
276 |     const error429 = new HttpError('Rate limit exceeded', 429);
277 |     const delays: number[] = [];
278 | 
279 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
280 | 
281 |     // Mock Math.random to always return maximum value (0.999...) to test upper bound
282 |     const originalRandom = Math.random;
283 |     Math.random = vi.fn().mockReturnValue(0.9999);
284 | 
285 |     const mockFn = vi.fn().mockRejectedValue(error429);
286 | 
287 |     // Act & Assert
288 |     await expect(retryWithBackoff(mockFn, {
289 |       maxRetries: 5,
290 |       initialDelay: 1000
291 |       // maxDelay not specified - should default to 10000ms
292 |     })).rejects.toThrow('Rate limit exceeded');
293 | 
294 |     // Restore Math.random
295 |     Math.random = originalRandom;
296 | 
297 |     // Verify delays are capped at default 10000ms
298 |     // With Math.random = 0.9999 (near maximum):
299 |     // Attempt 0: ~999.9ms (0.9999 * 1000)
300 |     // Attempt 1: ~1999.8ms (0.9999 * 2000)
301 |     // Attempt 2: ~3999.6ms (0.9999 * 4000)
302 |     // Attempt 3: ~7999.2ms (0.9999 * 8000)
303 |     // Attempt 4: ~9999ms (0.9999 * min(16000, 10000)) - should be capped at 10000
304 | 
305 |     // With current Infinity default, attempt 4 would be ~15999ms (0.9999 * 16000)
306 |     // This assertion should FAIL with Infinity default
307 |     expect(delays[4]).toBeLessThan(10000); // Should fail: will be ~16000 with Infinity
308 |   });
309 | 
310 |   it('should allow unbounded growth when maxDelay is explicitly set to Infinity', async () => {
311 |     // Arrange
312 |     const error429 = new HttpError('Rate limit exceeded', 429);
313 |     const delays: number[] = [];
314 | 
315 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
316 | 
317 |     const mockFn = vi
318 |       .fn()
319 |       .mockRejectedValueOnce(error429)
320 |       .mockRejectedValueOnce(error429)
321 |       .mockResolvedValueOnce('success');
322 | 
323 |     // Act - explicitly set maxDelay to Infinity to allow unbounded growth
324 |     await retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 1000, maxDelay: Infinity });
325 | 
326 |     // Assert - delays should grow without bounds (full jitter)
327 |     // Attempt 0: 0 to 1000ms
328 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
329 |     expect(delays[0]).toBeLessThan(1000);
330 | 
331 |     // Attempt 1: 0 to 2000ms
332 |     expect(delays[1]).toBeGreaterThanOrEqual(0);
333 |     expect(delays[1]).toBeLessThan(2000);
334 |   });
335 | 
336 |   it('should handle maxDelay smaller than initialDelay', async () => {
337 |     // Arrange
338 |     const error429 = new HttpError('Rate limit exceeded', 429);
339 |     const delays: number[] = [];
340 | 
341 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
342 | 
343 |     const mockFn = vi
344 |       .fn()
345 |       .mockRejectedValueOnce(error429)
346 |       .mockResolvedValueOnce('success');
347 | 
348 |     // Act - maxDelay (500) is less than initialDelay (1000)
349 |     await retryWithBackoff(mockFn, {
350 |       maxRetries: 2,
351 |       initialDelay: 1000,
352 |       maxDelay: 500
353 |     });
354 | 
355 |     // Assert - delay should be capped at maxDelay from the start (full jitter)
356 |     // exponentialDelay would be min(1000, 500) = 500
357 |     // With full jitter: 0 to 500ms
358 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
359 |     expect(delays[0]).toBeLessThan(500);
360 |   });
361 | 
362 |   it('should use Retry-After header value in seconds when provided', async () => {
363 |     // Arrange
364 |     const error429 = new HttpError('Rate limit exceeded', 429, '5'); // 5 seconds
365 |     const delays: number[] = [];
366 | 
367 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
368 | 
369 |     const mockFn = vi
370 |       .fn()
371 |       .mockRejectedValueOnce(error429)
372 |       .mockResolvedValueOnce('success');
373 | 
374 |     // Act
375 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
376 | 
377 |     // Assert - should use 5000ms from Retry-After instead of exponential backoff
378 |     expect(delays[0]).toBe(5000);
379 |   });
380 | 
381 |   it('should use Retry-After header value as HTTP-date when provided', async () => {
382 |     // Arrange
383 |     const futureDate = new Date(Date.now() + 3000); // 3 seconds from now
384 |     const error429 = new HttpError('Rate limit exceeded', 429, futureDate.toUTCString());
385 |     const delays: number[] = [];
386 | 
387 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
388 | 
389 |     const mockFn = vi
390 |       .fn()
391 |       .mockRejectedValueOnce(error429)
392 |       .mockResolvedValueOnce('success');
393 | 
394 |     // Act
395 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
396 | 
397 |     // Assert - should use calculated delay from date (behavior: delay is non-zero and reasonable)
398 |     // Test behavior: Retry-After date was parsed and used instead of exponential backoff
399 |     expect(delays[0]).toBeGreaterThan(0);
400 |     expect(mockFn).toHaveBeenCalledTimes(2); // Initial call + 1 retry
401 |   });
402 | 
403 |   it('should fallback to exponential backoff if Retry-After is invalid', async () => {
404 |     // Arrange
405 |     const error429 = new HttpError('Rate limit exceeded', 429, 'invalid-value');
406 |     const delays: number[] = [];
407 | 
408 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
409 | 
410 |     const mockFn = vi
411 |       .fn()
412 |       .mockRejectedValueOnce(error429)
413 |       .mockResolvedValueOnce('success');
414 | 
415 |     // Act
416 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
417 | 
418 |     // Assert - should fallback to exponential backoff with full jitter (0 to 1000ms)
419 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
420 |     expect(delays[0]).toBeLessThan(1000);
421 |   });
422 | 
423 |   it('should respect maxDelay even with Retry-After header', async () => {
424 |     // Arrange
425 |     const error429 = new HttpError('Rate limit exceeded', 429, '20'); // 20 seconds
426 |     const delays: number[] = [];
427 | 
428 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
429 | 
430 |     const mockFn = vi
431 |       .fn()
432 |       .mockRejectedValueOnce(error429)
433 |       .mockResolvedValueOnce('success');
434 | 
435 |     // Act - maxDelay is 5000ms, but Retry-After says 20000ms
436 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000, maxDelay: 5000 });
437 | 
438 |     // Assert - should be capped at maxDelay
439 |     expect(delays[0]).toBe(5000);
440 |   });
441 | 
442 |   it('should handle negative or past Retry-After dates gracefully', async () => {
443 |     // Arrange
444 |     const pastDate = new Date(Date.now() - 5000); // 5 seconds ago
445 |     const error429 = new HttpError('Rate limit exceeded', 429, pastDate.toUTCString());
446 |     const delays: number[] = [];
447 | 
448 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
449 | 
450 |     const mockFn = vi
451 |       .fn()
452 |       .mockRejectedValueOnce(error429)
453 |       .mockResolvedValueOnce('success');
454 | 
455 |     // Act
456 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
457 | 
458 |     // Assert - should handle past date gracefully (behavior: retry still occurs)
459 |     // Past dates return 0ms delay, but retry mechanism should still work
460 |     expect(delays[0]).toBeGreaterThanOrEqual(0);
461 |     expect(mockFn).toHaveBeenCalledTimes(2); // Initial call + 1 retry
462 |   });
463 | 
464 |   it('should cap very large Retry-After values (3600+ seconds) at maxDelay', async () => {
465 |     // Arrange - Server requests 1 hour (3600 seconds) delay
466 |     const error429 = new HttpError('Rate limit exceeded', 429, '3600');
467 |     const delays: number[] = [];
468 | 
469 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
470 | 
471 |     const mockFn = vi
472 |       .fn()
473 |       .mockRejectedValueOnce(error429)
474 |       .mockResolvedValueOnce('success');
475 | 
476 |     // Act - maxDelay defaults to 10000ms, but Retry-After says 3600000ms (1 hour)
477 |     await retryWithBackoff(mockFn, { maxRetries: 2, initialDelay: 1000 });
478 | 
479 |     // Assert - should be capped at default maxDelay (10000ms), not 3600000ms
480 |     expect(delays[0]).toBe(10000);
481 |   });
482 | 
483 |   it('should use standard full jitter (0 to exponentialDelay, not additive)', async () => {
484 |     // Arrange - Test that jitter is in range [0, exponentialDelay] not [exponentialDelay, 2*exponentialDelay]
485 |     const error429 = new HttpError('Rate limit exceeded', 429);
486 |     const delays: number[] = [];
487 | 
488 |     vi.spyOn(global, 'setTimeout').mockImplementation(createMockSetTimeout(delays));
489 | 
490 |     // Mock Math.random to return 0.5 (50% of range)
491 |     const originalRandom = Math.random;
492 |     Math.random = vi.fn().mockReturnValue(0.5);
493 | 
494 |     const mockFn = vi
495 |       .fn()
496 |       .mockRejectedValueOnce(error429) // Attempt 0: exponentialDelay = 1000
497 |       .mockRejectedValueOnce(error429) // Attempt 1: exponentialDelay = 2000
498 |       .mockResolvedValueOnce('success');
499 | 
500 |     // Act
501 |     await retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 1000 });
502 | 
503 |     // Restore Math.random
504 |     Math.random = originalRandom;
505 | 
506 |     // Assert - Standard full jitter should produce delays at 50% of exponentialDelay
507 |     // Attempt 0: exponentialDelay=1000, jitter should be ~500ms (0.5 * 1000)
508 |     // Attempt 1: exponentialDelay=2000, jitter should be ~1000ms (0.5 * 2000)
509 |     expect(delays[0]).toBeCloseTo(500, 0); // Should be ~500ms, not ~1500ms (additive)
510 |     expect(delays[1]).toBeCloseTo(1000, 0); // Should be ~1000ms, not ~3000ms (additive)
511 |   });
512 | 
513 |   it('should retry on 500 Internal Server Error', async () => {
514 |     // Arrange
515 |     const error500 = new HttpError('Internal Server Error', 500);
516 |     const mockResponse = { ok: true };
517 | 
518 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
519 |       Promise.resolve().then(() => callback());
520 |       return 0 as any;
521 |     }) as any);
522 | 
523 |     const mockFn = vi
524 |       .fn()
525 |       .mockRejectedValueOnce(error500)
526 |       .mockResolvedValueOnce(mockResponse);
527 | 
528 |     // Act
529 |     const result = await retryWithBackoff(mockFn, { maxRetries: 2 });
530 | 
531 |     // Assert
532 |     expect(result).toBe(mockResponse);
533 |     expect(mockFn).toHaveBeenCalledTimes(2);
534 |   });
535 | 
536 |   it('should retry on 502 Bad Gateway', async () => {
537 |     // Arrange
538 |     const error502 = new HttpError('Bad Gateway', 502);
539 |     const mockResponse = { ok: true };
540 | 
541 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
542 |       Promise.resolve().then(() => callback());
543 |       return 0 as any;
544 |     }) as any);
545 | 
546 |     const mockFn = vi
547 |       .fn()
548 |       .mockRejectedValueOnce(error502)
549 |       .mockResolvedValueOnce(mockResponse);
550 | 
551 |     // Act
552 |     const result = await retryWithBackoff(mockFn, { maxRetries: 2 });
553 | 
554 |     // Assert
555 |     expect(result).toBe(mockResponse);
556 |     expect(mockFn).toHaveBeenCalledTimes(2);
557 |   });
558 | 
559 |   it('should retry on 503 Service Unavailable', async () => {
560 |     // Arrange
561 |     const error503 = new HttpError('Service Unavailable', 503);
562 |     const mockResponse = { ok: true };
563 | 
564 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
565 |       Promise.resolve().then(() => callback());
566 |       return 0 as any;
567 |     }) as any);
568 | 
569 |     const mockFn = vi
570 |       .fn()
571 |       .mockRejectedValueOnce(error503)
572 |       .mockResolvedValueOnce(mockResponse);
573 | 
574 |     // Act
575 |     const result = await retryWithBackoff(mockFn, { maxRetries: 2 });
576 | 
577 |     // Assert
578 |     expect(result).toBe(mockResponse);
579 |     expect(mockFn).toHaveBeenCalledTimes(2);
580 |   });
581 | 
582 |   it('should retry on 504 Gateway Timeout', async () => {
583 |     // Arrange
584 |     const error504 = new HttpError('Gateway Timeout', 504);
585 |     const mockResponse = { ok: true };
586 | 
587 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
588 |       Promise.resolve().then(() => callback());
589 |       return 0 as any;
590 |     }) as any);
591 | 
592 |     const mockFn = vi
593 |       .fn()
594 |       .mockRejectedValueOnce(error504)
595 |       .mockResolvedValueOnce(mockResponse);
596 | 
597 |     // Act
598 |     const result = await retryWithBackoff(mockFn, { maxRetries: 2 });
599 | 
600 |     // Assert
601 |     expect(result).toBe(mockResponse);
602 |     expect(mockFn).toHaveBeenCalledTimes(2);
603 |   });
604 | 
605 |   it('should not retry on 501 Not Implemented (non-retryable 5xx)', async () => {
606 |     // Arrange
607 |     const error501 = new HttpError('Not Implemented', 501);
608 | 
609 |     const mockFn = vi.fn().mockRejectedValue(error501);
610 | 
611 |     // Act & Assert
612 |     await expect(retryWithBackoff(mockFn, { maxRetries: 2 }))
613 |       .rejects.toThrow('Not Implemented');
614 | 
615 |     // Should not retry - only 1 attempt
616 |     expect(mockFn).toHaveBeenCalledTimes(1);
617 |   });
618 | 
619 |   it('should handle real timing with exponential backoff (integration test)', async () => {
620 |     // Arrange - Integration test with real timing (no mocked setTimeout)
621 |     const error429 = new HttpError('Rate limit', 429);
622 |     const mockResponse = { ok: true };
623 | 
624 |     // Don't mock setTimeout - use real timing
625 |     const mockFn = vi
626 |       .fn()
627 |       .mockRejectedValueOnce(error429)
628 |       .mockRejectedValueOnce(error429)
629 |       .mockResolvedValueOnce(mockResponse);
630 | 
631 |     // Act - Use small delays for fast test execution
632 |     const result = await retryWithBackoff(mockFn, {
633 |       maxRetries: 3,
634 |       initialDelay: 10,
635 |       maxDelay: 50,
636 |     });
637 | 
638 |     // Assert - Test behavior, not implementation details (timing)
639 |     expect(result).toBe(mockResponse);
640 |     expect(mockFn).toHaveBeenCalledTimes(3); // Initial call + 2 retries
641 |   });
642 | 
643 |   it('should log retry attempts for debugging/telemetry', async () => {
644 |     // Arrange
645 |     const consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
646 |     const error429 = new HttpError('Rate limit', 429);
647 |     const mockResponse = { ok: true };
648 | 
649 |     // Mock setTimeout to execute immediately
650 |     vi.spyOn(global, 'setTimeout').mockImplementation(((callback: any) => {
651 |       Promise.resolve().then(() => callback());
652 |       return 0 as any;
653 |     }) as any);
654 | 
655 |     const mockFn = vi
656 |       .fn()
657 |       .mockRejectedValueOnce(error429)
658 |       .mockRejectedValueOnce(error429)
659 |       .mockResolvedValueOnce(mockResponse);
660 | 
661 |     // Act
662 |     await retryWithBackoff(mockFn, { maxRetries: 3, initialDelay: 1000 });
663 | 
664 |     // Assert - Verify debug logs were called
665 |     expect(consoleDebugSpy).toHaveBeenCalledTimes(2); // Two retries
666 | 
667 |     // First retry log
668 |     expect(consoleDebugSpy).toHaveBeenNthCalledWith(
669 |       1,
670 |       expect.stringContaining('Retry attempt 1/3'),
671 |       expect.objectContaining({
672 |         delay: expect.any(Number),
673 |         error: 'Rate limit'
674 |       })
675 |     );
676 | 
677 |     // Second retry log
678 |     expect(consoleDebugSpy).toHaveBeenNthCalledWith(
679 |       2,
680 |       expect.stringContaining('Retry attempt 2/3'),
681 |       expect.objectContaining({
682 |         delay: expect.any(Number),
683 |         error: 'Rate limit'
684 |       })
685 |     );
686 | 
687 |     consoleDebugSpy.mockRestore();
688 |   });
689 | });
690 | 
691 | describe('fetchWithTimeout', () => {
692 |   afterEach(() => {
693 |     vi.restoreAllMocks();
694 |   });
695 | 
696 |   it('should successfully fetch when request completes before timeout', async () => {
697 |     // Arrange
698 |     const mockResponse = { ok: true, status: 200 } as Response;
699 |     global.fetch = vi.fn().mockResolvedValue(mockResponse);
700 | 
701 |     // Act
702 |     const result = await fetchWithTimeout('https://example.com', undefined, 5000);
703 | 
704 |     // Assert
705 |     expect(result).toBe(mockResponse);
706 |     expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
707 |       signal: expect.any(AbortSignal),
708 |     });
709 |   });
710 | 
711 |   it('should timeout when request takes too long', async () => {
712 |     // Arrange - Create a fetch that respects abort signal
713 |     const slowFetch = vi.fn((url: string, options?: RequestInit): Promise<Response> =>
714 |       new Promise((resolve, reject) => {
715 |         const timeoutId = setTimeout(() => resolve({ ok: true } as Response), 200);
716 | 
717 |         // Listen for abort signal
718 |         if (options?.signal) {
719 |           options.signal.addEventListener('abort', () => {
720 |             clearTimeout(timeoutId);
721 |             const error = new Error('The operation was aborted');
722 |             error.name = 'AbortError';
723 |             reject(error);
724 |           });
725 |         }
726 |       })
727 |     );
728 |     global.fetch = slowFetch as typeof fetch;
729 | 
730 |     // Act & Assert
731 |     await expect(fetchWithTimeout('https://example.com', undefined, 50))
732 |       .rejects.toThrow('Request to https://example.com timed out after 50ms');
733 |   });
734 | 
735 |   it('should pass through non-abort errors', async () => {
736 |     // Arrange - Create a fetch that throws a network error
737 |     const networkError = new Error('Network failure');
738 |     global.fetch = vi.fn().mockRejectedValue(networkError);
739 | 
740 |     // Act & Assert
741 |     await expect(fetchWithTimeout('https://example.com'))
742 |       .rejects.toThrow('Network failure');
743 |   });
744 | });
745 | 
```