Remove MCP layer; execute_code is the sole built-in tool

Drop ollie/mcp, ollie/tools, ollie/ollama packages. Config now only
carries hooks. main.go wires a single execute_code tool directly to
the agent loop — no MCP discovery or dispatch needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-03-31 23:13:05 +02:00
parent 25cc7a8c33
commit 06ccc80da6
9 changed files with 68 additions and 486 deletions

View File

@ -6,18 +6,7 @@ import (
)
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"`
Hooks map[string]string `json:"hooks,omitempty"`
}
func Load(path string) (*Config, error) {

View File

@ -6,44 +6,48 @@ import (
)
func TestLoad(t *testing.T) {
content := `{
"mcpServers": {
"test": {
"command": "test-cmd",
"args": ["arg1"],
"env": {"KEY": "value"}
}
}
}`
content := `{"hooks": {"stop": "notify-send done"}}`
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)
if cfg.Hooks["stop"] != "notify-send done" {
t.Errorf("Expected hook 'notify-send done', got %q", cfg.Hooks["stop"])
}
}
func TestLoadEmpty(t *testing.T) {
content := `{}`
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.Hooks) != 0 {
t.Errorf("Expected no hooks, got %v", cfg.Hooks)
}
}

133
main.go
View File

@ -18,10 +18,28 @@ import (
"ollie/backend"
"ollie/config"
execpkg "ollie/exec"
"ollie/mcp"
"ollie/tools"
)
// executeCodeTool is the single built-in tool exposed to the model.
var executeCodeTool = backend.Tool{
Name: "execute_code",
Description: "Execute shell code or a named tool script in a sandboxed environment. " +
"Use 'code' for inline bash, 'tool'+'args' for a named script, " +
"or 'pipe' for a sequence of {tool, args} steps.",
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"code": {"type": "string", "description": "Inline shell code to run (bash)."},
"language": {"type": "string", "description": "Language interpreter (default: bash)."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."},
"tool": {"type": "string", "description": "Named tool script to run instead of inline code."},
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
"pipe": {"type": "array", "description": "Pipeline: array of {tool, args} objects run in sequence."}
}
}`),
}
type model struct {
textarea textarea.Model
viewport viewport.Model
@ -33,45 +51,25 @@ type model struct {
}
func main() {
if len(os.Args) < 2 {
log.Fatal("Usage: ollie <config.json> [model]")
}
modelName := os.Getenv("OLLIE_MODEL")
if modelName == "" && len(os.Args) > 2 {
modelName = os.Args[2]
if modelName == "" && len(os.Args) > 1 {
modelName = os.Args[1]
}
if modelName == "" {
modelName = "qwen3:8b"
}
cfg, err := config.Load(os.Args[1])
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Connect MCP servers.
mcpExecutor := tools.NewExecutor()
for name, serverCfg := range cfg.MCPServers {
if serverCfg.Disabled || serverCfg.Command == "" {
continue
}
transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env)
client, err := transport.Connect()
hooks := make(map[string]string)
if len(os.Args) > 2 {
cfg, err := config.Load(os.Args[2])
if err != nil {
log.Printf("Failed to connect to %s: %v", name, err)
continue
log.Fatalf("Failed to load config: %v", err)
}
if cfg.Hooks != nil {
hooks = cfg.Hooks
}
mcpExecutor.AddServer(name, client)
log.Printf("Connected to MCP server: %s", name)
}
mcpTools, err := mcpExecutor.ListTools()
if err != nil {
log.Fatalf("Failed to list tools: %v", err)
}
log.Printf("Loaded %d tools", len(mcpTools))
be, err := backend.New()
if err != nil {
log.Fatalf("Failed to create backend: %v", err)
@ -86,16 +84,16 @@ func main() {
loopcfg := agent.Config{
Backend: be,
Model: modelName,
Tools: mcpToolsToBackend(mcpTools),
Exec: buildDispatch(builtinExec, mcpExecutor, mcpTools),
Tools: []backend.Tool{executeCodeTool},
Exec: func(name string, args json.RawMessage) (string, error) {
if name == "execute_code" {
return dispatchBuiltinExec(builtinExec, args)
}
return "", fmt.Errorf("unknown tool: %s", name)
},
MaxSteps: 20,
}
hooks := cfg.Hooks
if hooks == nil {
hooks = make(map[string]string)
}
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.Prompt = ""
@ -185,8 +183,6 @@ func (m model) runLoop(input string) tea.Cmd {
display := append([]string{}, m.display...)
return func() tea.Msg {
// First message: create a new session with the input as goal.
// Subsequent messages: append to existing session.
if session == nil {
session = agent.NewSession(input)
} else {
@ -228,62 +224,7 @@ func (m model) View() string {
// -- helpers --
func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool {
out := make([]backend.Tool, len(mcpTools))
for i, t := range mcpTools {
out[i] = backend.Tool{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
}
}
return out
}
// buildDispatch routes tool calls to the MCP server that owns them, falling
// back to the built-in execute_code sandbox if not found via MCP.
func buildDispatch(builtin *execpkg.Executor, mcpExec *tools.Executor, mcpTools []tools.ToolInfo) agent.ToolExecutor {
serverOf := make(map[string]string, len(mcpTools))
for _, t := range mcpTools {
serverOf[t.Name] = t.Server
}
return func(name string, args json.RawMessage) (string, error) {
if server, ok := serverOf[name]; ok {
raw, err := mcpExec.Execute(server, name, args)
if err != nil {
return "", err
}
return extractMCPText(raw), nil
}
if name == "execute_code" {
return dispatchBuiltinExec(builtin, args)
}
return "", fmt.Errorf("unknown tool: %s", name)
}
}
// extractMCPText unwraps {"content":[{"type":"text","text":"..."}]}.
func extractMCPText(raw json.RawMessage) string {
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return string(raw)
}
var parts []string
for _, c := range result.Content {
if c.Type == "text" {
parts = append(parts, c.Text)
}
}
return strings.Join(parts, "\n")
}
// dispatchBuiltinExec handles execute_code natively without MCP.
// dispatchBuiltinExec handles execute_code natively.
func dispatchBuiltinExec(e *execpkg.Executor, args json.RawMessage) (string, error) {
var a struct {
Code string `json:"code"`

View File

@ -1,95 +0,0 @@
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
}

View File

@ -1,36 +0,0 @@
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"])
}
}

View File

@ -1,46 +0,0 @@
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
}

View File

@ -1,86 +0,0 @@
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
}

View File

@ -1,69 +0,0 @@
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
}

View File

@ -1,20 +0,0 @@
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))
}
}