# 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 | [](https://www.gnu.org/licenses/agpl-3.0)
8 | [](https://www.typescriptlang.org/)
9 | [](https://github.com/anthropics/model-context-protocol)
10 | [](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 |
```