Initial commit

This commit is contained in:
Levi Neely 2026-02-21 20:47:38 +01:00
commit 12b8950ea5
16 changed files with 1020 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -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

76
ARCHITECTURE.md Normal file
View File

@ -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

111
README.md Normal file
View File

@ -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

33
config/config.go Normal file
View File

@ -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
}

49
config/config_test.go Normal file
View File

@ -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)
}
}

33
go.mod Normal file
View File

@ -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
)

62
go.sum Normal file
View File

@ -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=

263
main.go Normal file
View File

@ -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()
}

95
mcp/client.go Normal file
View File

@ -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
}

36
mcp/client_test.go Normal file
View File

@ -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"])
}
}

46
mcp/transport.go Normal file
View File

@ -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
}

14
mcpconfig.json Normal file
View File

@ -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"
}
}

11
mkfile Normal file
View File

@ -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

86
ollama/client.go Normal file
View File

@ -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
}

69
tools/executor.go Normal file
View File

@ -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
}

20
tools/executor_test.go Normal file
View File

@ -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))
}
}