├── test-directory └── test.txt ├── .gitattributes ├── src ├── types │ ├── index.ts │ └── config.ts ├── main.ts ├── lib │ ├── ChatInterface.ts │ ├── ToolManager.ts │ └── ChatManager.ts ├── utils │ ├── types │ │ ├── ollamaTypes.ts │ │ └── toolTypes.ts │ ├── environment.ts │ ├── toolFormatters.ts │ ├── mcpClient.ts │ └── commandResolver.ts ├── config │ └── index.ts ├── client │ └── client.ts └── demo.ts ├── mcp-config.json ├── tsconfig.json ├── package.json ├── README.md └── .gitignore /test-directory/test.txt: -------------------------------------------------------------------------------- 1 | rosebud -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from "child_process"; 2 | 3 | declare module "@modelcontextprotocol/sdk/client/stdio.js" { 4 | interface StdioClientTransport { 5 | childProcess: ChildProcess; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /mcp-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "filesystem": { 4 | "command": "npx", 5 | "args": ["@modelcontextprotocol/server-filesystem", "./"] 6 | }, 7 | "webresearch": { 8 | "command": "npx", 9 | "args": ["-y", "@mzxrai/mcp-webresearch"] 10 | } 11 | }, 12 | "ollama": { 13 | "host": "http://localhost:11434", 14 | "model": "qwen2.5:latest" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ChatManager } from "./lib/ChatManager"; 2 | import { ollamaConfig } from "./config"; 3 | 4 | async function main() { 5 | const chatManager = new ChatManager(ollamaConfig); 6 | 7 | try { 8 | await chatManager.initialize(); 9 | await chatManager.start(); 10 | } catch (error) { 11 | console.error("Failed to start chat:", error); 12 | } 13 | } 14 | 15 | main().catch(console.error); 16 | -------------------------------------------------------------------------------- /src/lib/ChatInterface.ts: -------------------------------------------------------------------------------- 1 | import readline from "readline"; 2 | 3 | export class ChatInterface { 4 | private rl: readline.Interface; 5 | 6 | constructor() { 7 | this.rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout, 10 | }); 11 | } 12 | 13 | async getUserInput(): Promise { 14 | return new Promise((resolve) => { 15 | this.rl.question("You: ", (input) => { 16 | resolve(input); 17 | }); 18 | }); 19 | } 20 | 21 | close() { 22 | this.rl.close(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "allowJs": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "baseUrl": "./", 15 | "paths": { 16 | "*": ["src/*"] 17 | } 18 | }, 19 | "include": ["*.ts", "src/**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/types/ollamaTypes.ts: -------------------------------------------------------------------------------- 1 | // ollamaTypes.ts 2 | // Types for Ollama API integration 3 | 4 | import { ToolCall } from "./toolTypes"; 5 | 6 | /** 7 | * Represents a tool call in Ollama's format 8 | */ 9 | export interface OllamaToolCall extends Omit { 10 | type: "function"; 11 | } 12 | 13 | /** 14 | * Represents a message in the Ollama chat format 15 | */ 16 | export interface OllamaMessage { 17 | role: "system" | "user" | "assistant" | "tool"; 18 | content: string; 19 | tool_calls?: OllamaToolCall[]; 20 | tool_call_id?: string; 21 | name?: string; 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-example", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/main.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "tsx src/main.ts", 9 | "build": "tsc" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "description": "", 15 | "dependencies": { 16 | "@modelcontextprotocol/sdk": "^1.17.3", 17 | "ollama": "^0.5.17" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^24.3.0", 21 | "tsx": "^4.20.4", 22 | "typescript": "^5.9.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | // src/config/index.ts 2 | 3 | import * as fs from "fs"; 4 | 5 | import { Config } from "../types/config"; // Import the main Config type 6 | 7 | // Load config once at startup 8 | const configPath = "./mcp-config.json"; 9 | let config: Config; 10 | 11 | try { 12 | config = JSON.parse(fs.readFileSync(configPath, "utf8")); 13 | } catch (error) { 14 | if (error instanceof Error) { 15 | throw new Error(`Failed to load config: ${error.message}`); 16 | } 17 | throw error; 18 | } 19 | 20 | // Export the full config and specific sections 21 | export const getConfig = () => config; 22 | export const ollamaConfig = config.ollama; 23 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 3 | 4 | export function createClient(name: string, version: string): Client { 5 | return new Client( 6 | { 7 | name, 8 | version, 9 | }, 10 | { 11 | capabilities: { 12 | tools: { 13 | call: true, 14 | list: true, 15 | }, 16 | }, 17 | } 18 | ); 19 | } 20 | 21 | export async function connectClient( 22 | client: Client, 23 | transport: StdioClientTransport 24 | ): Promise { 25 | console.log("Connecting to filesystem server..."); 26 | await client.connect(transport); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/environment.ts: -------------------------------------------------------------------------------- 1 | // src/utils/environment.ts 2 | 3 | // Default environment variables to inherit based on platform 4 | const DEFAULT_INHERITED_ENV_VARS = 5 | process.platform === "win32" 6 | ? [ 7 | "APPDATA", 8 | "HOMEDRIVE", 9 | "HOMEPATH", 10 | "LOCALAPPDATA", 11 | "PATH", 12 | "PROCESSOR_ARCHITECTURE", 13 | "SYSTEMDRIVE", 14 | "SYSTEMROOT", 15 | "TEMP", 16 | "USERNAME", 17 | "USERPROFILE", 18 | ] 19 | : ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; 20 | 21 | export function getDefaultEnvironment(): Record { 22 | // Filter process.env to only include default variables 23 | return Object.fromEntries( 24 | Object.entries(process.env).filter( 25 | ([key, value]) => 26 | DEFAULT_INHERITED_ENV_VARS.includes(key) && 27 | value !== undefined && 28 | !value.startsWith("()") 29 | ) 30 | ) as Record; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/toolFormatters.ts: -------------------------------------------------------------------------------- 1 | // utils/toolFormatters.ts 2 | 3 | export function formatToolResponse(responseContent: any): string { 4 | if (Array.isArray(responseContent)) { 5 | return responseContent 6 | .filter((item: any) => item && item.type === "text") 7 | .map((item: any) => item.text || "No content") 8 | .join("\n"); 9 | } 10 | return String(responseContent); 11 | } 12 | 13 | export function convertToOpenaiTools(tools: any[]): any[] { 14 | return tools 15 | .map((tool) => { 16 | if (!tool.name) { 17 | console.warn("Tool missing name:", tool); 18 | return null; 19 | } 20 | 21 | return { 22 | type: "function", 23 | function: { 24 | name: tool.name, 25 | description: tool.description || "", 26 | parameters: { 27 | type: "object", 28 | properties: tool.inputSchema?.properties || {}, 29 | required: tool.inputSchema?.required || [], 30 | }, 31 | } 32 | }; 33 | }) 34 | .filter(Boolean); // Remove any null entries 35 | } -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for an MCP server process 3 | */ 4 | export interface ServerConfig { 5 | /** Command to start the server */ 6 | command: string; 7 | /** Optional command line arguments */ 8 | args?: string[]; 9 | /** Optional environment variables */ 10 | env?: Record; 11 | /** Optional working directory for the server process */ 12 | cwd?: string; 13 | } 14 | 15 | /** 16 | * Configuration for Ollama integration 17 | */ 18 | export interface OllamaConfig { 19 | /** Ollama API host URL (e.g. http://localhost:11434) */ 20 | host: string; 21 | /** Model to use for chat completions (e.g. llama2, mistral) */ 22 | model: string; 23 | /** Optional API parameters */ 24 | parameters?: { 25 | temperature?: number; 26 | top_p?: number; 27 | top_k?: number; 28 | num_predict?: number; 29 | stop?: string[]; 30 | }; 31 | } 32 | 33 | /** 34 | * Root configuration object 35 | */ 36 | export interface Config { 37 | /** Map of MCP server configurations by name */ 38 | mcpServers: Record; 39 | /** Ollama configuration */ 40 | ollama: OllamaConfig; 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/types/toolTypes.ts: -------------------------------------------------------------------------------- 1 | // utils/toolTypes.ts 2 | // Types for handling tool definitions and executions in the MCP ecosystem 3 | 4 | import { Client } from "@modelcontextprotocol/sdk/client/index"; 5 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 6 | 7 | /** 8 | * Represents a tool call request from the LLM 9 | */ 10 | export interface ToolCall { 11 | id?: string; 12 | function: { 13 | name: string; 14 | arguments: Record; 15 | }; 16 | } 17 | 18 | /** 19 | * Represents an MCP client connection with its transport 20 | */ 21 | export interface McpClientEntry { 22 | client: Client; 23 | transport: StdioClientTransport; 24 | } 25 | 26 | /** 27 | * Describes a parameter in a tool's definition 28 | */ 29 | export interface ToolParameterInfo { 30 | type: string; 31 | description?: string; 32 | items?: { 33 | type: string; 34 | properties?: Record; 35 | }; 36 | properties?: Record; 37 | required?: string[]; 38 | minimum?: number; 39 | maximum?: number; 40 | pattern?: string; 41 | } 42 | 43 | /** 44 | * Complete tool definition following JSON Schema structure 45 | */ 46 | export interface ToolDefinition { 47 | name: string; 48 | description: string; 49 | parameters: { 50 | type: "object"; 51 | properties: Record; 52 | required: string[]; 53 | additionalProperties: boolean; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/mcpClient.ts: -------------------------------------------------------------------------------- 1 | // src/utils/mcpClient.ts 2 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index"; 4 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio"; 5 | import { getConfig } from "../config/index"; 6 | import { getDefaultEnvironment } from "./environment"; 7 | import { resolveCommand } from "./commandResolver"; 8 | 9 | export interface McpServerConfiguration { 10 | command: string; 11 | args?: string[]; 12 | env?: Record; 13 | capabilities?: any; 14 | } 15 | 16 | // src/utils/mcpClient.ts 17 | export async function createMcpClients() { 18 | const config = getConfig(); // Get the full config 19 | const clients = new Map< 20 | string, 21 | { client: Client; transport: StdioClientTransport } 22 | >(); 23 | 24 | for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) { 25 | const resolvedCommand = await resolveCommand(serverConfig.command); 26 | 27 | const transport = new StdioClientTransport({ 28 | command: resolvedCommand, 29 | args: serverConfig.args || [], 30 | env: 31 | (serverConfig.env as Record | undefined) || 32 | getDefaultEnvironment(), 33 | }); 34 | 35 | const client = new Client( 36 | { name: `ollama-client-${serverName}`, version: "1.0.0" }, 37 | { 38 | capabilities: { 39 | tools: { call: true, list: true }, 40 | }, 41 | } 42 | ); 43 | 44 | await client.connect(transport); 45 | clients.set(serverName, { client, transport }); 46 | } 47 | 48 | return clients; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/commandResolver.ts: -------------------------------------------------------------------------------- 1 | // src/utils/commandResolver.ts 2 | 3 | import { exec } from "child_process"; 4 | import { promisify } from "util"; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | export async function validateCommand(command: string): Promise { 9 | try { 10 | // On Windows, use 'where', on Unix use 'which' 11 | const checkCommand = process.platform === "win32" ? "where" : "which"; 12 | await execAsync(`${checkCommand} ${command}`); 13 | return true; 14 | } catch { 15 | return false; 16 | } 17 | } 18 | 19 | export async function resolveCommand(command: string): Promise { 20 | // Add .cmd extension for npx on Windows 21 | const commandToCheck = 22 | process.platform === "win32" && command === "npx" 23 | ? `${command}.cmd` 24 | : command; 25 | 26 | // Common package managers and their executables 27 | const packageManagers = { 28 | node: ["node", "npx", "npm"], 29 | python: ["python", "uvx", "pip"], 30 | }; 31 | 32 | // First try the specified command 33 | if (await validateCommand(commandToCheck)) { 34 | return commandToCheck; 35 | } 36 | 37 | // Try alternatives based on command type 38 | const alternatives = Object.values(packageManagers).flat(); 39 | for (const alt of alternatives) { 40 | if (await validateCommand(alt)) { 41 | console.log( 42 | `⚠️ Original command '${command}' not found, using '${alt}' instead` 43 | ); 44 | return alt; 45 | } 46 | } 47 | 48 | throw new Error( 49 | `Could not resolve command '${command}'. Please ensure it's installed and in your PATH.` 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/demo.ts: -------------------------------------------------------------------------------- 1 | // src/demo.ts 2 | 3 | import { ToolManager } from "./lib/ToolManager"; 4 | import { formatToolResponse } from "./utils/toolFormatters"; 5 | 6 | async function demonstrateMcpFunctionality() { 7 | let toolManager: ToolManager | undefined; 8 | 9 | try { 10 | // Create and initialize ToolManager 11 | console.log("\n🚀 Creating MCP clients and initializing ToolManager..."); 12 | toolManager = new ToolManager(); 13 | await toolManager.initialize(); 14 | 15 | // Get the clients from ToolManager's initialization 16 | const clients = toolManager.getClients(); 17 | if (!clients || clients.size === 0) { 18 | console.log("❌ No MCP clients loaded."); 19 | return; 20 | } 21 | 22 | // Display all tools in OpenAI format 23 | console.log("\n✨ All combined tools in OpenAI format:"); 24 | console.log(JSON.stringify(toolManager.tools, null, 2)); 25 | 26 | // Example interactions using ToolManager 27 | console.log("\n📂 Listing allowed directories (if available)..."); 28 | const allowedDirsResponse = await toolManager.callTool( 29 | "list_allowed_directories", 30 | {} 31 | ); 32 | if (allowedDirsResponse) { 33 | console.log( 34 | "Allowed directories:", 35 | formatToolResponse(allowedDirsResponse.content) 36 | ); 37 | } 38 | 39 | console.log( 40 | "\n📂 Listing contents of test-directory directory (if available)..." 41 | ); 42 | const dirContents = await toolManager.callTool("list_directory", { 43 | path: "test-directory", 44 | }); 45 | if (dirContents) { 46 | console.log( 47 | "Directory contents:", 48 | formatToolResponse(dirContents.content) 49 | ); 50 | } 51 | 52 | console.log("\n📄 Reading test.txt (if available)..."); 53 | const fileContent = await toolManager.callTool("read_file", { 54 | path: "test-directory/test.txt", 55 | }); 56 | if (fileContent) { 57 | console.log("File content:", formatToolResponse(fileContent.content)); 58 | } 59 | 60 | // Visit a webpage and get the content 61 | console.log("\n🌐 Visiting example.com and getting content..."); 62 | const webpageContent = await toolManager.callTool("visit_page", { 63 | url: "https://ollama.com/blog/tool-support", 64 | takeScreenshot: false, 65 | }); 66 | if (webpageContent) { 67 | console.log( 68 | "Webpage content:", 69 | formatToolResponse(webpageContent.content) 70 | ); 71 | } 72 | } catch (error: unknown) { 73 | console.error( 74 | "\n❌ An error occurred during demonstration:", 75 | error instanceof Error ? error.message : String(error) 76 | ); 77 | } finally { 78 | // Clean up using ToolManager's cleanup 79 | if (toolManager) { 80 | await toolManager.cleanup(); 81 | } 82 | process.exit(0); 83 | } 84 | } 85 | 86 | demonstrateMcpFunctionality().catch((error) => 87 | console.error("Fatal error during demonstration setup:", error) 88 | ); 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript MCP Agent with Ollama Integration 2 | 3 | This project demonstrates integration between [Model Context Protocol (MCP)](https://modelcontextprotocol.org/) servers and [Ollama](https://ollama.com/), allowing AI models to interact with various tools through a unified interface. 4 | 5 | ## ✨ Features 6 | 7 | - Supports multiple MCP servers (both uvx and npx tested) 8 | - Built-in support for file system operations and web research 9 | - Easy configuration through `mcp-config.json` similar to `claude_desktop_config.json` 10 | - Interactive chat interface with Ollama integration that should support any tools 11 | - Standalone demo mode for testing web and filesystem tools without an LLM 12 | 13 | ## 🚀 Getting Started 14 | 15 | 1. **Prerequisites:** 16 | 17 | - Node.js (version 18 or higher) 18 | - Ollama installed and running 19 | - Install the MCP tools globally that you want to use: 20 | 21 | ```bash 22 | # For filesystem operations 23 | npm install -g @modelcontextprotocol/server-filesystem 24 | 25 | # For web research 26 | npm install -g @mzxrai/mcp-webresearch 27 | ``` 28 | 29 | 2. **Clone and install:** 30 | 31 | ```bash 32 | git clone https://github.com/ausboss/mcp-ollama-agent.git 33 | cd mcp-ollama-agent 34 | npm install 35 | 36 | ``` 37 | 38 | 3. **Configure your tools and tool supported Ollama model in `mcp-config.json`:** 39 | 40 | ```json 41 | { 42 | "mcpServers": { 43 | "filesystem": { 44 | "command": "npx", 45 | "args": ["@modelcontextprotocol/server-filesystem", "./"] 46 | }, 47 | "webresearch": { 48 | "command": "npx", 49 | "args": ["-y", "@mzxrai/mcp-webresearch"] 50 | } 51 | }, 52 | "ollama": { 53 | "host": "http://localhost:11434", 54 | "model": "qwen2.5:latest" 55 | } 56 | } 57 | ``` 58 | 59 | 4. **Run the demo to test filesystem and webresearch tools without an LLM:** 60 | 61 | ```bash 62 | npx tsx ./src/demo.ts 63 | ``` 64 | 65 | 5. **Or start the chat interface with Ollama:** 66 | ```bash 67 | npm start 68 | ``` 69 | 70 | ## ⚙️ Configuration 71 | 72 | - **MCP Servers:** Add any MCP-compatible server to the `mcpServers` section 73 | - **Ollama:** Configure host and model (must support function calling) 74 | - Supports both Python (uvx) and Node.js (npx) MCP servers 75 | 76 | ## 💡 Example Usage 77 | 78 | This example used this model [qwen2.5:latest](https://ollama.com/library/qwen2.5) 79 | 80 | ``` 81 | Chat started. Type "exit" to end the conversation. 82 | You: can you use your list directory tool to see whats in test-directory then use your read file tool to read it to me? 83 | Model is using tools to help answer... 84 | Using tool: list_directory 85 | With arguments: { path: 'test-directory' } 86 | Tool result: [ { type: 'text', text: '[FILE] test.txt' } ] 87 | Assistant: 88 | Model is using tools to help answer... 89 | Using tool: read_file 90 | With arguments: { path: 'test-directory/test.txt' } 91 | Tool result: [ { type: 'text', text: 'rosebud' } ] 92 | Assistant: The content of the file `test.txt` in the `test-directory` is: 93 | rosebud 94 | You: thanks 95 | Assistant: You're welcome! If you have any other requests or need further assistance, feel free to ask. 96 | ``` 97 | 98 | ## System Prompts 99 | 100 | Some local models may need help with tool selection. Customize the system prompt in `ChatManager.ts` to improve tool usage. 101 | 102 | ## 🤝 Contributing 103 | 104 | Contributions welcome! Feel free to submit issues or pull requests. 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | # Logs 132 | logs 133 | *.log 134 | npm-debug.log* 135 | yarn-debug.log* 136 | yarn-error.log* 137 | lerna-debug.log* 138 | .pnpm-debug.log* 139 | 140 | # Diagnostic reports (https://nodejs.org/api/report.html) 141 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 142 | 143 | # Runtime data 144 | pids 145 | *.pid 146 | *.seed 147 | *.pid.lock 148 | 149 | # Directory for instrumented libs generated by jscoverage/JSCover 150 | lib-cov 151 | 152 | # Coverage directory used by tools like istanbul 153 | coverage 154 | *.lcov 155 | 156 | # nyc test coverage 157 | .nyc_output 158 | 159 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 160 | .grunt 161 | 162 | # Bower dependency directory (https://bower.io/) 163 | bower_components 164 | 165 | # node-waf configuration 166 | .lock-wscript 167 | 168 | # Compiled binary addons (https://nodejs.org/api/addons.html) 169 | build/Release 170 | 171 | # Dependency directories 172 | node_modules/ 173 | jspm_packages/ 174 | 175 | # Snowpack dependency directory (https://snowpack.dev/) 176 | web_modules/ 177 | 178 | # TypeScript cache 179 | *.tsbuildinfo 180 | 181 | # Optional npm cache directory 182 | .npm 183 | 184 | # Optional eslint cache 185 | .eslintcache 186 | 187 | # Optional stylelint cache 188 | .stylelintcache 189 | 190 | # Microbundle cache 191 | .rpt2_cache/ 192 | .rts2_cache_cjs/ 193 | .rts2_cache_es/ 194 | .rts2_cache_umd/ 195 | 196 | # Optional REPL history 197 | .node_repl_history 198 | 199 | # Output of 'npm pack' 200 | *.tgz 201 | 202 | # Yarn Integrity file 203 | .yarn-integrity 204 | 205 | # dotenv environment variable files 206 | .env 207 | .env.development.local 208 | .env.test.local 209 | .env.production.local 210 | .env.local 211 | 212 | # parcel-bundler cache (https://parceljs.org/) 213 | .cache 214 | .parcel-cache 215 | 216 | # Next.js build output 217 | .next 218 | out 219 | 220 | # Nuxt.js build / generate output 221 | .nuxt 222 | dist 223 | 224 | # Gatsby files 225 | .cache/ 226 | # Comment in the public line in if your project uses Gatsby and not Next.js 227 | # https://nextjs.org/blog/next-9-1#public-directory-support 228 | # public 229 | 230 | # vuepress build output 231 | .vuepress/dist 232 | 233 | # vuepress v2.x temp and cache directory 234 | .temp 235 | .cache 236 | 237 | # Docusaurus cache and generated files 238 | .docusaurus 239 | 240 | # Serverless directories 241 | .serverless/ 242 | 243 | # FuseBox cache 244 | .fusebox/ 245 | 246 | # DynamoDB Local files 247 | .dynamodb/ 248 | 249 | # TernJS port file 250 | .tern-port 251 | 252 | # Stores VSCode versions used for testing VSCode extensions 253 | .vscode-test 254 | .vscode 255 | 256 | # yarn v2 257 | .yarn/cache 258 | .yarn/unplugged 259 | .yarn/build-state.yml 260 | .yarn/install-state.gz 261 | .pnp.* 262 | node_modules/iconv-lite/encodings/tables/shiftjis.json 263 | -------------------------------------------------------------------------------- /src/lib/ToolManager.ts: -------------------------------------------------------------------------------- 1 | // lib/ToolManager.ts 2 | 3 | import { 4 | CallToolResult, 5 | CallToolResultSchema, 6 | ListToolsResultSchema, 7 | } from "@modelcontextprotocol/sdk/types"; 8 | import { 9 | McpClientEntry, 10 | ToolCall, 11 | ToolDefinition, 12 | } from "../utils/types/toolTypes"; 13 | 14 | import { Client } from "@modelcontextprotocol/sdk/client/index"; 15 | import { convertToOpenaiTools } from "../utils/toolFormatters"; 16 | import { createMcpClients } from "../utils/mcpClient"; 17 | import { formatToolResponse } from "../utils/toolFormatters"; 18 | 19 | export class ToolManager { 20 | private toolMap: Map = new Map(); 21 | protected clients: Map = new Map(); 22 | public tools: any[] = []; 23 | 24 | getClients(): Map { 25 | return this.clients; 26 | } 27 | 28 | async initialize() { 29 | const newClients = await createMcpClients(); 30 | if (!newClients || newClients.size === 0) { 31 | throw new Error("No MCP clients loaded."); 32 | } 33 | 34 | this.clients = newClients; 35 | let allMcpTools: any[] = []; 36 | 37 | // Fetch tools from all clients 38 | for (const [serverName, { client }] of this.clients.entries()) { 39 | const mcpTools = await this.fetchTools(client); 40 | if (mcpTools) { 41 | allMcpTools = allMcpTools.concat(mcpTools); 42 | mcpTools.forEach((tool) => { 43 | this.toolMap.set(tool.name, client); 44 | }); 45 | } 46 | } 47 | 48 | // Convert to OpenAI format for Ollama 49 | this.tools = convertToOpenaiTools(allMcpTools); 50 | return this.tools; 51 | } 52 | 53 | private async fetchTools(client: Client): Promise { 54 | try { 55 | const toolsResponse = await client.listTools(); 56 | const tools = toolsResponse?.tools || []; 57 | 58 | if ( 59 | !Array.isArray(tools) || 60 | !tools.every((tool) => typeof tool === "object") 61 | ) { 62 | console.debug("Invalid tools format received."); 63 | return null; 64 | } 65 | 66 | return tools; 67 | } catch (error) { 68 | console.error("Error fetching tools:", error); 69 | return null; 70 | } 71 | } 72 | 73 | private async callToolWithTimeout( 74 | client: Client, 75 | name: string, 76 | args: any, 77 | timeoutMs = 30000 78 | ): Promise { 79 | // Parse arguments if they're a string 80 | let parsedArgs = args; 81 | if (typeof args === "string") { 82 | try { 83 | parsedArgs = JSON.parse(args); 84 | } catch (e) { 85 | // If parsing fails, wrap the string in an object 86 | parsedArgs = { value: args }; 87 | } 88 | } 89 | 90 | // Ensure args is an object 91 | if (typeof parsedArgs !== "object" || parsedArgs === null) { 92 | parsedArgs = {}; 93 | } 94 | 95 | const toolCallPromise = client.callTool({ 96 | name, 97 | arguments: parsedArgs, 98 | }); 99 | 100 | const timeoutPromise = new Promise((_, reject) => { 101 | setTimeout( 102 | () => reject(new Error(`Tool call timed out after ${timeoutMs}ms`)), 103 | timeoutMs 104 | ); 105 | }); 106 | 107 | try { 108 | const result = await Promise.race([toolCallPromise, timeoutPromise]); 109 | return result; 110 | } catch (error) { 111 | throw new Error( 112 | `Tool call failed: ${ 113 | error instanceof Error ? error.message : String(error) 114 | }` 115 | ); 116 | } 117 | } 118 | 119 | async executeToolCall(toolCall: ToolCall): Promise { 120 | const { name, args } = this.parseToolCall(toolCall); 121 | const client = this.toolMap.get(name); 122 | 123 | if (!client) { 124 | throw new Error(`Tool '${name}' not found`); 125 | } 126 | 127 | const result = await this.callToolWithTimeout(client, name, args); 128 | return formatToolResponse((result as any)?.content || []); 129 | } 130 | 131 | private parseToolCall(toolCall: any): { name: string; args: any } { 132 | let toolName = "unknown_tool"; 133 | let rawArguments: any = {}; 134 | 135 | if ( 136 | (typeof toolCall === "object" && 137 | toolCall !== null && 138 | "function" in toolCall) || 139 | (toolCall?.function?.name && toolCall?.function?.arguments) 140 | ) { 141 | if (toolCall.function?.name) { 142 | toolName = toolCall.function.name; 143 | rawArguments = toolCall.function.arguments; 144 | } else { 145 | toolName = toolCall["function"]["name"]; 146 | rawArguments = toolCall["function"]["arguments"]; 147 | } 148 | } else { 149 | throw new Error("Invalid tool call format provided."); 150 | } 151 | 152 | let toolArgs: any = rawArguments; 153 | if (typeof rawArguments === "string") { 154 | try { 155 | toolArgs = JSON.parse(rawArguments); 156 | } catch (error: any) { 157 | console.debug( 158 | `Error parsing arguments string: ${error.message}`, 159 | rawArguments 160 | ); 161 | throw error; 162 | } 163 | } 164 | 165 | return { name: toolName, args: toolArgs }; 166 | } 167 | 168 | async callTool( 169 | toolName: string, 170 | args: Record 171 | ): Promise { 172 | const clientForTool = this.toolMap.get(toolName); 173 | if (!clientForTool) { 174 | console.warn(`Tool '${toolName}' not found among available tools.`); 175 | return undefined; 176 | } 177 | 178 | try { 179 | const toolCall = { 180 | name: toolName, 181 | arguments: args, 182 | }; 183 | 184 | return (await clientForTool.callTool(toolCall)) as CallToolResult; 185 | } catch (error) { 186 | console.error(`Error calling tool '${toolName}':`, error); 187 | return undefined; 188 | } 189 | } 190 | 191 | getToolParameterInfo(toolName: string): ToolDefinition | undefined { 192 | return this.tools.find((t) => t.name === toolName); 193 | } 194 | 195 | suggestParameterMapping( 196 | toolName: string, 197 | providedArgs: Record 198 | ): Record { 199 | const tool = this.getToolParameterInfo(toolName); 200 | if (!tool) return {}; 201 | 202 | const mapping: Record = {}; 203 | const expectedParams = Object.keys(tool.parameters.properties); 204 | 205 | for (const providedParam of Object.keys(providedArgs)) { 206 | if (expectedParams.includes(providedParam)) { 207 | continue; // Parameter is already correct 208 | } 209 | 210 | const mostSimilar = this.findMostSimilarParameter( 211 | providedParam, 212 | expectedParams 213 | ); 214 | if (mostSimilar) { 215 | mapping[providedParam] = mostSimilar; 216 | } 217 | } 218 | 219 | return mapping; 220 | } 221 | 222 | private findMostSimilarParameter( 223 | provided: string, 224 | expected: string[] 225 | ): string | null { 226 | const normalized = provided.toLowerCase().replace(/[_-]/g, ""); 227 | for (const param of expected) { 228 | const normalizedExpected = param.toLowerCase().replace(/[_-]/g, ""); 229 | if ( 230 | normalizedExpected.includes(normalized) || 231 | normalized.includes(normalizedExpected) 232 | ) { 233 | return param; 234 | } 235 | } 236 | return null; 237 | } 238 | 239 | async cleanup() { 240 | if (this.clients) { 241 | for (const { client, transport } of this.clients.values()) { 242 | await client.close(); 243 | await transport.close(); 244 | } 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/lib/ChatManager.ts: -------------------------------------------------------------------------------- 1 | import { ChatInterface } from "./ChatInterface"; 2 | import { Ollama } from "ollama"; 3 | import { OllamaMessage } from "../utils/types/ollamaTypes"; 4 | import { ToolManager } from "./ToolManager"; 5 | import { formatToolResponse } from "../utils/toolFormatters"; 6 | 7 | interface ErrorWithCause extends Error { 8 | cause?: { 9 | code?: string; 10 | }; 11 | } 12 | 13 | export class ChatManager { 14 | private ollama: Ollama; 15 | private messages: OllamaMessage[] = []; 16 | private toolManager: ToolManager; 17 | private chatInterface: ChatInterface; 18 | private model: string; 19 | 20 | constructor(ollamaConfig: { host?: string; model?: string } = {}) { 21 | this.ollama = new Ollama(ollamaConfig); 22 | this.model = ollamaConfig.model || "qwen2.5:latest"; // Default fallback if not provided 23 | this.toolManager = new ToolManager(); 24 | this.chatInterface = new ChatInterface(); 25 | 26 | this.messages = [ 27 | { 28 | role: "system", 29 | content: 30 | "You are a helpful AI assistant. Please provide clear, accurate, and relevant responses to user queries. If you need to use tools to help answer a question, explain what you're doing.", 31 | }, 32 | ]; 33 | } 34 | 35 | async initialize() { 36 | await this.toolManager.initialize(); 37 | // Test Ollama connection 38 | try { 39 | await this.testOllamaConnection(); 40 | } catch (error) { 41 | const errorMsg = error instanceof Error ? error.message : String(error); 42 | throw new Error( 43 | `Failed to connect to Ollama. Is Ollama running? Error: ${errorMsg}` 44 | ); 45 | } 46 | } 47 | 48 | private async testOllamaConnection() { 49 | try { 50 | // Try a simple chat call to test connection 51 | await this.ollama.chat({ 52 | model: this.model, 53 | messages: [{ role: "user", content: "test" }], 54 | tools: [], 55 | }); 56 | } catch (error) { 57 | const err = error as ErrorWithCause; 58 | if (err.cause?.code === "ECONNREFUSED") { 59 | throw new Error("Could not connect to Ollama server"); 60 | } 61 | throw error; 62 | } 63 | } 64 | 65 | async start() { 66 | try { 67 | console.log('Chat started. Type "exit" to end the conversation.'); 68 | 69 | while (true) { 70 | const userInput = await this.chatInterface.getUserInput(); 71 | if (userInput.toLowerCase() === "exit") break; 72 | 73 | try { 74 | await this.processUserInput(userInput); 75 | } catch (error) { 76 | const err = error as ErrorWithCause; 77 | if (err.cause?.code === "ECONNREFUSED") { 78 | console.error( 79 | "\nError: Lost connection to Ollama server. Please ensure Ollama is running." 80 | ); 81 | console.log("You can:"); 82 | console.log("1. Start Ollama and type your message again"); 83 | console.log('2. Type "exit" to quit\n'); 84 | } else { 85 | console.error( 86 | "Error processing input:", 87 | err instanceof Error ? err.message : String(err) 88 | ); 89 | } 90 | } 91 | } 92 | } catch (error) { 93 | console.error( 94 | "Error:", 95 | error instanceof Error ? error.message : String(error) 96 | ); 97 | } finally { 98 | this.cleanup(); 99 | } 100 | } 101 | 102 | private async processUserInput(userInput: string) { 103 | this.messages.push({ role: "user", content: userInput }); 104 | 105 | try { 106 | // Get initial response 107 | const response = await this.ollama.chat({ 108 | model: this.model, 109 | messages: this.messages as any[], 110 | tools: this.toolManager.tools, 111 | }); 112 | 113 | this.messages.push(response.message as OllamaMessage); 114 | 115 | // If no tool calls, just show the response and we're done 116 | const toolCalls = response.message.tool_calls ?? []; 117 | if (toolCalls.length === 0) { 118 | console.log("Assistant:", response.message.content); 119 | return; 120 | } 121 | 122 | // Handle tool calls and potential follow-ups 123 | await this.handleToolCalls(toolCalls); 124 | } catch (error) { 125 | // Remove the failed message from history 126 | this.messages.pop(); 127 | throw error; // Propagate the error to be handled by start() 128 | } 129 | } 130 | 131 | private async handleToolCalls(toolCalls: any[]) { 132 | console.log("Model is using tools to help answer..."); 133 | 134 | for (const toolCall of toolCalls) { 135 | const args = this.parseToolArguments(toolCall.function.arguments); 136 | console.log(`Using tool: ${toolCall.function.name}`); 137 | console.log(`With arguments:`, args); 138 | 139 | // Get parameter mapping suggestions before making the call 140 | const parameterMappings = this.toolManager.suggestParameterMapping( 141 | toolCall.function.name, 142 | args 143 | ); 144 | 145 | // Fix parameters using the suggested mappings 146 | const fixedArgs = this.fixToolArguments(args, parameterMappings); 147 | const result = await this.toolManager.callTool( 148 | toolCall.function.name, 149 | fixedArgs 150 | ); 151 | 152 | if (result) { 153 | console.log(`Tool result:`, result.content); 154 | 155 | // Check if the result contains an error 156 | const resultContent = result.content; 157 | if ( 158 | Array.isArray(resultContent) && 159 | resultContent[0]?.type === "text" && 160 | resultContent[0]?.text?.includes("Error") 161 | ) { 162 | // Get tool parameter information 163 | const toolInfo = this.toolManager.getToolParameterInfo( 164 | toolCall.function.name 165 | ); 166 | 167 | // Create detailed error message with parameter information 168 | const errorMessage = this.createDetailedErrorMessage( 169 | toolCall.function.name, 170 | resultContent[0].text, 171 | toolInfo, 172 | args, 173 | parameterMappings 174 | ); 175 | 176 | // Add error message to conversation 177 | this.messages.push({ 178 | role: "tool", 179 | content: errorMessage, 180 | tool_call_id: toolCall.function.name, 181 | }); 182 | 183 | try { 184 | // Let the model know about the error and try again 185 | const errorResponse = await this.ollama.chat({ 186 | model: this.model, 187 | messages: this.messages as any[], 188 | tools: this.toolManager.tools, 189 | }); 190 | 191 | this.messages.push(errorResponse.message as OllamaMessage); 192 | 193 | const newToolCalls = errorResponse.message.tool_calls ?? []; 194 | if (newToolCalls.length > 0) { 195 | // Don't recurse if we're retrying the same tool with the same args 196 | const currentToolName = toolCall.function.name; 197 | const hasNewToolCalls = newToolCalls.some( 198 | (call) => 199 | call.function.name !== currentToolName || 200 | JSON.stringify(call.function.arguments) !== 201 | JSON.stringify(toolCall.function.arguments) 202 | ); 203 | 204 | if (hasNewToolCalls) { 205 | await this.handleToolCalls(newToolCalls); 206 | } else { 207 | console.log( 208 | "There was an issue with the tool call. Trying again." 209 | ); 210 | return; 211 | } 212 | } 213 | } catch (error) { 214 | const err = error as ErrorWithCause; 215 | if (err.cause?.code === "ECONNREFUSED") { 216 | throw error; // Propagate connection errors 217 | } 218 | console.error( 219 | "Error handling tool response:", 220 | err instanceof Error ? err.message : String(err) 221 | ); 222 | } 223 | return; 224 | } 225 | 226 | // No error, proceed normally 227 | this.messages.push({ 228 | role: "tool", 229 | content: formatToolResponse(result.content), 230 | tool_call_id: toolCall.function.name, 231 | }); 232 | } 233 | } 234 | 235 | try { 236 | // Get final response after all tools in this batch are done 237 | const finalResponse = await this.ollama.chat({ 238 | model: this.model, 239 | messages: this.messages as any[], 240 | tools: this.toolManager.tools, 241 | }); 242 | 243 | // Add the model's response to messages 244 | this.messages.push(finalResponse.message as OllamaMessage); 245 | 246 | // Print the response regardless of whether there are tool calls 247 | console.log("Assistant:", finalResponse.message.content); 248 | 249 | // Check for new tool calls 250 | const newToolCalls = finalResponse.message.tool_calls ?? []; 251 | if (newToolCalls.length > 0) { 252 | // Check if the new tool calls are different from the previous ones 253 | const previousToolNames = new Set( 254 | toolCalls.map((t) => t.function.name) 255 | ); 256 | const hasNewTools = newToolCalls.some( 257 | (call) => !previousToolNames.has(call.function.name) 258 | ); 259 | 260 | if (hasNewTools) { 261 | await this.handleToolCalls(newToolCalls); 262 | } 263 | } 264 | } catch (error) { 265 | const err = error as ErrorWithCause; 266 | if (err.cause?.code === "ECONNREFUSED") { 267 | throw error; // Propagate connection errors 268 | } 269 | console.error( 270 | "Error getting final response:", 271 | err instanceof Error ? err.message : String(err) 272 | ); 273 | } 274 | } 275 | 276 | private createDetailedErrorMessage( 277 | toolName: string, 278 | errorText: string, 279 | toolInfo: any | undefined, 280 | providedArgs: Record, 281 | suggestedMappings: Record 282 | ): string { 283 | let message = `Error using tool ${toolName}:\n${errorText}\n\n`; 284 | 285 | if (toolInfo) { 286 | message += `Expected parameters:\n`; 287 | message += `Required: ${toolInfo.parameters.required.join(", ")}\n`; 288 | message += `Available: ${Object.keys(toolInfo.parameters.properties).join( 289 | ", " 290 | )}\n\n`; 291 | 292 | if (Object.keys(suggestedMappings).length > 0) { 293 | message += `Suggested parameter mappings:\n`; 294 | for (const [provided, suggested] of Object.entries(suggestedMappings)) { 295 | message += `- ${provided} → ${suggested}\n`; 296 | } 297 | } 298 | } 299 | 300 | return message; 301 | } 302 | 303 | private fixToolArguments( 304 | args: Record, 305 | mappings: Record 306 | ): Record { 307 | const fixedArgs: Record = {}; 308 | 309 | for (const [key, value] of Object.entries(args)) { 310 | const mappedKey = mappings[key] || key; 311 | fixedArgs[mappedKey] = value; 312 | } 313 | 314 | return fixedArgs; 315 | } 316 | 317 | private parseToolArguments( 318 | args: string | Record 319 | ): Record { 320 | if (typeof args === "string") { 321 | try { 322 | return JSON.parse(args); 323 | } catch (e) { 324 | console.error("Failed to parse tool arguments:", e); 325 | return { value: args }; 326 | } 327 | } 328 | return args; 329 | } 330 | 331 | private cleanup() { 332 | this.chatInterface.close(); 333 | this.toolManager.cleanup(); 334 | } 335 | } 336 | --------------------------------------------------------------------------------