Initial commit
This commit is contained in:
commit
12b8950ea5
|
|
@ -0,0 +1,16 @@
|
|||
# Binaries
|
||||
ollama-mcp-client
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# Ollama MCP Client - Go Architecture
|
||||
|
||||
## Overview
|
||||
Simple REPL-based CLI that connects Ollama LLMs to MCP servers. Minimal implementation focused on core functionality.
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
.
|
||||
├── main.go # Entry point, REPL loop
|
||||
├── config/
|
||||
│ └── config.go # JSON config parser (mcpServers format)
|
||||
├── mcp/
|
||||
│ ├── client.go # MCP protocol client
|
||||
│ └── transport.go # STDIO/SSE/HTTP transports
|
||||
├── ollama/
|
||||
│ └── client.go # Ollama API client
|
||||
└── tools/
|
||||
└── executor.go # Tool execution handler
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Main (main.go)
|
||||
- Simple REPL with `>` prompt
|
||||
- Multi-line input support (submit on ENTER)
|
||||
- Read user input → Send to Ollama → Handle tool calls → Display response
|
||||
|
||||
### 2. Config (config/config.go)
|
||||
```go
|
||||
type Config struct {
|
||||
MCPServers map[string]ServerConfig `json:"mcpServers"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Command string `json:"command,omitempty"` // STDIO
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Type string `json:"type,omitempty"` // "sse" or "streamable_http"
|
||||
URL string `json:"url,omitempty"` // SSE/HTTP
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. MCP Client (mcp/client.go)
|
||||
- Connect to MCP servers (STDIO/SSE/HTTP)
|
||||
- List available tools
|
||||
- Execute tool calls
|
||||
- Handle JSON-RPC protocol
|
||||
|
||||
### 4. Ollama Client (ollama/client.go)
|
||||
- Send chat requests with tool definitions
|
||||
- Stream responses
|
||||
- Parse tool call requests from model
|
||||
|
||||
### 5. Tool Executor (tools/executor.go)
|
||||
- Route tool calls to appropriate MCP server
|
||||
- Execute and return results
|
||||
- Handle errors
|
||||
|
||||
## Data Flow
|
||||
```
|
||||
User Input → Ollama (with tools) → Tool Call? → MCP Server → Tool Result → Ollama → Response
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Standard library (encoding/json, bufio, os/exec)
|
||||
- Ollama Go SDK (github.com/ollama/ollama/api)
|
||||
- MCP Go SDK (if available) or custom JSON-RPC implementation
|
||||
|
||||
## Implementation Notes
|
||||
- Keep it minimal - no TUI, just basic REPL
|
||||
- Focus on STDIO transport first (simplest)
|
||||
- Add SSE/HTTP support after STDIO works
|
||||
- Error handling: log and continue
|
||||
- No conversation history persistence initially
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# Ollama MCP Client
|
||||
|
||||
A minimal Go CLI application that connects Ollama LLMs to MCP (Model Context Protocol) servers, enabling tool use.
|
||||
|
||||
## Features
|
||||
|
||||
- Simple REPL interface with `>` prompt
|
||||
- Multi-line input support (submit on ENTER)
|
||||
- JSON configuration for MCP servers
|
||||
- STDIO transport support for MCP servers
|
||||
- Tool discovery and execution
|
||||
- Conversation history
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.21+
|
||||
- Ollama running locally (default: http://localhost:11434)
|
||||
- MCP servers configured in JSON file
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go build -o ollama-mcp-client
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a JSON configuration file with your MCP servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"anvilmcp": {
|
||||
"command": "anvilmcp",
|
||||
"args": []
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "mcp-server-filesystem",
|
||||
"args": ["/path/to/allowed/directory"],
|
||||
"env": {
|
||||
"DEBUG": "true"
|
||||
}
|
||||
},
|
||||
"disabled-server": {
|
||||
"command": "some-server",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `command`: Executable command for STDIO MCP server
|
||||
- `args`: Command-line arguments (optional)
|
||||
- `env`: Environment variables (optional)
|
||||
- `disabled`: Skip this server (optional)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./ollama-mcp-client config.json [model]
|
||||
```
|
||||
|
||||
Default model: `qwen3:8b`
|
||||
|
||||
Example with custom model:
|
||||
```bash
|
||||
./ollama-mcp-client config.json llama3.2
|
||||
```
|
||||
|
||||
### Example Session
|
||||
|
||||
```
|
||||
> What files are in the current directory?
|
||||
[Tool execution: list_directory]
|
||||
The current directory contains: main.go, config/, mcp/, ollama/, tools/
|
||||
|
||||
> Create a new file called test.txt
|
||||
[Tool execution: write_file]
|
||||
Created test.txt successfully.
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `main.go`: REPL loop and orchestration
|
||||
- `config/`: JSON configuration parser
|
||||
- `mcp/`: MCP protocol client and STDIO transport
|
||||
- `ollama/`: Ollama API client
|
||||
- `tools/`: Tool executor and manager
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No servers connected
|
||||
- Check that MCP server commands are in your PATH
|
||||
- Verify command names and arguments in config
|
||||
- Check logs for connection errors
|
||||
|
||||
### Tools not working
|
||||
- Ensure MCP servers support the `tools/list` and `tools/call` methods
|
||||
- Check tool execution logs for errors
|
||||
- Verify tool arguments match expected schema
|
||||
|
||||
### Ollama errors
|
||||
- Ensure Ollama is running: `ollama serve`
|
||||
- Check model is available: `ollama list`
|
||||
- Verify Ollama URL (default: http://localhost:11434)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MCPServers map[string]ServerConfig `json:"mcpServers"`
|
||||
Hooks map[string]string `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
content := `{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"command": "test-cmd",
|
||||
"args": ["arg1"],
|
||||
"env": {"KEY": "value"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "config*.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
if _, err := tmpfile.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpfile.Close()
|
||||
|
||||
cfg, err := Load(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.MCPServers) != 1 {
|
||||
t.Errorf("Expected 1 server, got %d", len(cfg.MCPServers))
|
||||
}
|
||||
|
||||
server := cfg.MCPServers["test"]
|
||||
if server.Command != "test-cmd" {
|
||||
t.Errorf("Expected command 'test-cmd', got '%s'", server.Command)
|
||||
}
|
||||
if len(server.Args) != 1 || server.Args[0] != "arg1" {
|
||||
t.Errorf("Expected args ['arg1'], got %v", server.Args)
|
||||
}
|
||||
if server.Env["KEY"] != "value" {
|
||||
t.Errorf("Expected env KEY=value, got %v", server.Env)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
module ollie
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require github.com/chzyer/readline v1.5.1
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/peterh/liner v1.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
|
||||
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"ollie/config"
|
||||
"ollie/mcp"
|
||||
"ollie/ollama"
|
||||
"ollie/tools"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
textarea textarea.Model
|
||||
viewport viewport.Model
|
||||
messages []ollama.Message
|
||||
client *ollama.Client
|
||||
tools []tools.ToolInfo
|
||||
executor *tools.Executor
|
||||
model string
|
||||
history []string
|
||||
ready bool
|
||||
hooks map[string]string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("Usage: ollama-mcp-client <config.json> [model]")
|
||||
}
|
||||
|
||||
modelName := "qwen3:8b"
|
||||
if len(os.Args) > 2 {
|
||||
modelName = os.Args[2]
|
||||
}
|
||||
|
||||
cfg, err := config.Load(os.Args[1])
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hooks := make(map[string]string)
|
||||
if cfg.Hooks != nil {
|
||||
hooks = cfg.Hooks
|
||||
}
|
||||
|
||||
executor := tools.NewExecutor()
|
||||
connectedServers := 0
|
||||
for name, serverCfg := range cfg.MCPServers {
|
||||
if serverCfg.Disabled {
|
||||
log.Printf("Skipping disabled server: %s", name)
|
||||
continue
|
||||
}
|
||||
if serverCfg.Command != "" {
|
||||
transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env)
|
||||
client, err := transport.Connect()
|
||||
if err != nil {
|
||||
log.Printf("Failed to connect to %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
executor.AddServer(name, client)
|
||||
connectedServers++
|
||||
log.Printf("Connected to server: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
if connectedServers == 0 {
|
||||
log.Fatal("No MCP servers connected")
|
||||
}
|
||||
|
||||
toolsList, err := executor.ListTools()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list tools: %v", err)
|
||||
}
|
||||
log.Printf("Loaded %d tools", len(toolsList))
|
||||
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Type your message..."
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = 0
|
||||
ta.SetHeight(5)
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j"))
|
||||
ta.Focus()
|
||||
|
||||
p := tea.NewProgram(model{
|
||||
textarea: ta,
|
||||
client: ollama.NewClient("http://localhost:11434"),
|
||||
tools: toolsList,
|
||||
executor: executor,
|
||||
model: modelName,
|
||||
hooks: hooks,
|
||||
})
|
||||
|
||||
if hook := hooks["agentSpawn"]; hook != "" {
|
||||
exec.Command("sh", "-c", hook).Run()
|
||||
}
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
type responseMsg struct {
|
||||
history []string
|
||||
messages []ollama.Message
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-5)
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - 5
|
||||
}
|
||||
m.textarea.SetWidth(msg.Width)
|
||||
case responseMsg:
|
||||
m.history = msg.history
|
||||
m.messages = msg.messages
|
||||
m.viewport.SetContent(strings.Join(m.history, "\n"))
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" || msg.String() == "esc" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if msg.String() == "enter" {
|
||||
input := strings.TrimSpace(m.textarea.Value())
|
||||
if input == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.history = append(m.history, "You: "+input)
|
||||
m.viewport.SetContent(strings.Join(m.history, "\n"))
|
||||
m.viewport.GotoBottom()
|
||||
m.textarea.Reset()
|
||||
|
||||
if hook := m.hooks["userPromptSubmit"]; hook != "" {
|
||||
exec.Command("sh", "-c", hook).Run()
|
||||
}
|
||||
|
||||
return m, m.handleInputCmd(input)
|
||||
}
|
||||
}
|
||||
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) handleInputCmd(input string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
history := append([]string{}, m.history...)
|
||||
messages := append([]ollama.Message{}, m.messages...)
|
||||
|
||||
messages = append(messages, ollama.Message{Role: "user", Content: input})
|
||||
|
||||
ollamaTools := make([]ollama.Tool, len(m.tools))
|
||||
for i, t := range m.tools {
|
||||
ollamaTools[i] = ollama.Tool{
|
||||
Type: "function",
|
||||
Function: ollama.Function{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.InputSchema,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := m.client.Chat(ollama.ChatRequest{
|
||||
Model: m.model,
|
||||
Messages: messages,
|
||||
Tools: ollamaTools,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
history = append(history, fmt.Sprintf("Error: %v", err))
|
||||
return responseMsg{history: history, messages: messages}
|
||||
}
|
||||
|
||||
messages = append(messages, resp.Message)
|
||||
|
||||
seenCalls := make(map[string]bool)
|
||||
for len(resp.Message.ToolCalls) > 0 {
|
||||
for _, tc := range resp.Message.ToolCalls {
|
||||
callKey := fmt.Sprintf("%s:%v", tc.Function.Name, tc.Function.Arguments)
|
||||
if seenCalls[callKey] {
|
||||
history = append(history, fmt.Sprintf("Skipping duplicate tool call: %s", tc.Function.Name))
|
||||
continue
|
||||
}
|
||||
seenCalls[callKey] = true
|
||||
|
||||
history = append(history, fmt.Sprintf("Running tool: %s", tc.Function.Name))
|
||||
var toolInfo *tools.ToolInfo
|
||||
for _, t := range m.tools {
|
||||
if t.Name == tc.Function.Name {
|
||||
toolInfo = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if toolInfo == nil {
|
||||
history = append(history, fmt.Sprintf("→ %s: error - tool not found", tc.Function.Name))
|
||||
continue
|
||||
}
|
||||
result, err := m.executor.Execute(toolInfo.Server, tc.Function.Name, tc.Function.Arguments)
|
||||
if err != nil {
|
||||
history = append(history, fmt.Sprintf("→ %s: error - %v", tc.Function.Name, err))
|
||||
continue
|
||||
}
|
||||
resultStr := string(result)
|
||||
history = append(history, fmt.Sprintf("→ %s: success - %s", tc.Function.Name, resultStr))
|
||||
messages = append(messages, ollama.Message{
|
||||
Role: "tool",
|
||||
Content: resultStr,
|
||||
})
|
||||
}
|
||||
|
||||
resp, err = m.client.Chat(ollama.ChatRequest{
|
||||
Model: m.model,
|
||||
Messages: messages,
|
||||
Tools: ollamaTools,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
history = append(history, fmt.Sprintf("Error: %v", err))
|
||||
return responseMsg{history: history, messages: messages}
|
||||
}
|
||||
messages = append(messages, resp.Message)
|
||||
}
|
||||
|
||||
if resp.Message.Content != "" {
|
||||
history = append(history, "Bot: "+resp.Message.Content)
|
||||
}
|
||||
|
||||
if hook := m.hooks["stop"]; hook != "" {
|
||||
exec.Command("sh", "-c", hook).Run()
|
||||
}
|
||||
|
||||
return responseMsg{history: history, messages: messages}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
return m.viewport.View() + "\n" + m.textarea.View()
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *RPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
reader *bufio.Reader
|
||||
writer io.Writer
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewClient(r io.Reader, w io.Writer) *Client {
|
||||
return &Client{
|
||||
reader: bufio.NewReader(r),
|
||||
writer: w,
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Call(method string, params interface{}) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
c.mu.Unlock()
|
||||
|
||||
req := Message{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: mustMarshal(params),
|
||||
}
|
||||
|
||||
if err := c.send(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.receive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("RPC error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
func (c *Client) send(msg Message) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err = c.writer.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) receive() (*Message, error) {
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var msg Message
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) json.RawMessage {
|
||||
data, _ := json.Marshal(v)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCall(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
client := NewClient(&buf, &buf)
|
||||
|
||||
go func() {
|
||||
resp := Message{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Result: json.RawMessage(`{"status":"ok"}`),
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
buf.Write(append(data, '\n'))
|
||||
}()
|
||||
|
||||
result, err := client.Call("test_method", map[string]string{"key": "value"})
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
var resultMap map[string]string
|
||||
if err := json.Unmarshal(result, &resultMap); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if resultMap["status"] != "ok" {
|
||||
t.Errorf("Expected status 'ok', got '%s'", resultMap["status"])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type Transport interface {
|
||||
Connect() (*Client, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type STDIOTransport struct {
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func NewSTDIOTransport(command string, args []string, env map[string]string) *STDIOTransport {
|
||||
cmd := exec.Command(command, args...)
|
||||
if env != nil {
|
||||
for k, v := range env {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
}
|
||||
return &STDIOTransport{cmd: cmd}
|
||||
}
|
||||
|
||||
func (t *STDIOTransport) Connect() (*Client, error) {
|
||||
stdin, err := t.cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := t.cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewClient(stdout, stdin), nil
|
||||
}
|
||||
|
||||
func (t *STDIOTransport) Close() error {
|
||||
if t.cmd.Process != nil {
|
||||
return t.cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"globalShortcut": "Ctrl+Space",
|
||||
"mcpServers": {
|
||||
"anvilmcp": {
|
||||
"command": "anvilmcp",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"agentSpawn": "anvillm-hook agentSpawn",
|
||||
"userPromptSubmit": "anvillm-hook userPromptSubmit",
|
||||
"stop": "anvillm-hook stop"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
INSTALL_PATH=$HOME/bin
|
||||
|
||||
all:V: install
|
||||
|
||||
build:V:
|
||||
go build -o $INSTALL_PATH/ollama-mcp-client
|
||||
|
||||
install:V: build
|
||||
|
||||
clean:V:
|
||||
rm -f $INSTALL_PATH/ollama-mcp-client
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package ollama
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function Function `json:"function"`
|
||||
}
|
||||
|
||||
type Function struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
Message Message `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Chat(req ChatRequest) (*ChatResponse, error) {
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Post(c.baseURL+"/api/chat", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &chatResp, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ollie/mcp"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
servers map[string]*mcp.Client
|
||||
}
|
||||
|
||||
func NewExecutor() *Executor {
|
||||
return &Executor{
|
||||
servers: make(map[string]*mcp.Client),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) AddServer(name string, client *mcp.Client) {
|
||||
e.servers[name] = client
|
||||
}
|
||||
|
||||
func (e *Executor) ListTools() ([]ToolInfo, error) {
|
||||
var allTools []ToolInfo
|
||||
for serverName, client := range e.servers {
|
||||
result, err := client.Call("tools/list", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server %s: %w", serverName, err)
|
||||
}
|
||||
var resp struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range resp.Tools {
|
||||
allTools = append(allTools, ToolInfo{
|
||||
Server: serverName,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
})
|
||||
}
|
||||
}
|
||||
return allTools, nil
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(serverName, toolName string, args json.RawMessage) (json.RawMessage, error) {
|
||||
client, ok := e.servers[serverName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("server not found: %s", serverName)
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": args,
|
||||
}
|
||||
return client.Call("tools/call", params)
|
||||
}
|
||||
|
||||
type ToolInfo struct {
|
||||
Server string
|
||||
Name string
|
||||
Description string
|
||||
InputSchema json.RawMessage
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutorAddServer(t *testing.T) {
|
||||
executor := NewExecutor()
|
||||
|
||||
if len(executor.servers) != 0 {
|
||||
t.Errorf("Expected 0 servers, got %d", len(executor.servers))
|
||||
}
|
||||
|
||||
executor.AddServer("test", nil)
|
||||
|
||||
if len(executor.servers) != 1 {
|
||||
t.Errorf("Expected 1 server, got %d", len(executor.servers))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue