commit 12b8950ea596d228a5f0ea93b67c89c48b73b8d9 Author: Levi Neely Date: Sat Feb 21 20:47:38 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a632e0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c053f2a --- /dev/null +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..df71e41 --- /dev/null +++ b/README.md @@ -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 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..5b6f854 --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..23dba24 --- /dev/null +++ b/config/config_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3084851 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5752876 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..010cc70 --- /dev/null +++ b/main.go @@ -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 [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() +} + + diff --git a/mcp/client.go b/mcp/client.go new file mode 100644 index 0000000..cd7cf51 --- /dev/null +++ b/mcp/client.go @@ -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 +} diff --git a/mcp/client_test.go b/mcp/client_test.go new file mode 100644 index 0000000..6a62092 --- /dev/null +++ b/mcp/client_test.go @@ -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"]) + } +} diff --git a/mcp/transport.go b/mcp/transport.go new file mode 100644 index 0000000..54ab8eb --- /dev/null +++ b/mcp/transport.go @@ -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 +} diff --git a/mcpconfig.json b/mcpconfig.json new file mode 100644 index 0000000..55b7d34 --- /dev/null +++ b/mcpconfig.json @@ -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" + } +} diff --git a/mkfile b/mkfile new file mode 100644 index 0000000..525d38a --- /dev/null +++ b/mkfile @@ -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 diff --git a/ollama/client.go b/ollama/client.go new file mode 100644 index 0000000..ad1c782 --- /dev/null +++ b/ollama/client.go @@ -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 +} diff --git a/tools/executor.go b/tools/executor.go new file mode 100644 index 0000000..b227d25 --- /dev/null +++ b/tools/executor.go @@ -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 +} diff --git a/tools/executor_test.go b/tools/executor_test.go new file mode 100644 index 0000000..f928aa9 --- /dev/null +++ b/tools/executor_test.go @@ -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)) + } +} +