Restore MCP server support alongside built-in execute_code

Bring back ollie/mcp and ollie/tools packages. Config MCPServers
field restored. MCP tools are discovered at startup and added to
the agent tool list alongside execute_code. Dispatch routes by
server name for MCP tools, falls back to built-in execute_code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-01 00:34:09 +02:00
parent f9ba6c8bce
commit 01b128fdf5
7 changed files with 298 additions and 11 deletions

View File

@ -6,7 +6,15 @@ import (
)
type Config struct {
Hooks map[string]string `json:"hooks,omitempty"`
MCPServers map[string]ServerConfig `json:"mcpServers,omitempty"`
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"`
Disabled bool `json:"disabled,omitempty"`
}
func Load(path string) (*Config, error) {

83
main.go
View File

@ -18,6 +18,8 @@ import (
"ollie/backend"
"ollie/config"
execpkg "ollie/exec"
"ollie/mcp"
"ollie/tools"
)
const systemPrompt = `You are an autonomous agent. You have one tool: execute_code.
@ -123,6 +125,7 @@ func main() {
}
hooks := make(map[string]string)
var cfg *config.Config
cfgPath := ""
if len(os.Args) > 2 {
cfgPath = os.Args[2]
@ -130,15 +133,41 @@ func main() {
home, _ := os.UserHomeDir()
cfgPath = home + "/.config/ollie/config.json"
}
if cfg, err := config.Load(cfgPath); err == nil {
if c, err := config.Load(cfgPath); err == nil {
cfg = c
if cfg.Hooks != nil {
hooks = cfg.Hooks
}
} else if len(os.Args) > 2 {
// Only fatal if the path was explicitly provided.
log.Fatalf("Failed to load config: %v", err)
}
// Connect MCP servers.
mcpExec := tools.NewExecutor()
if cfg != nil {
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()
if err != nil {
log.Printf("Failed to connect to MCP server %s: %v", name, err)
continue
}
mcpExec.AddServer(name, client)
log.Printf("Connected to MCP server: %s", name)
}
}
mcpTools, err := mcpExec.ListTools()
if err != nil {
log.Fatalf("Failed to list MCP tools: %v", err)
}
if len(mcpTools) > 0 {
log.Printf("Loaded %d MCP tools", len(mcpTools))
}
be, err := backend.New()
if err != nil {
log.Fatalf("Failed to create backend: %v", err)
@ -150,12 +179,25 @@ func main() {
home+"/.cache/ollie/exec",
)
allTools := append(mcpToolsToBackend(mcpTools), executeCodeTool)
serverOf := make(map[string]string, len(mcpTools))
for _, t := range mcpTools {
serverOf[t.Name] = t.Server
}
loopcfg := agent.Config{
Backend: be,
Model: modelName,
SystemPrompt: systemPrompt,
Tools: []backend.Tool{executeCodeTool},
Exec: func(name string, args json.RawMessage) (string, error) {
Tools: allTools,
Exec: 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(builtinExec, args)
}
@ -356,6 +398,39 @@ func wordWrap(s string, width int) string {
// -- tool helpers --
// mcpToolsToBackend converts MCP tool descriptors to backend.Tool entries.
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
}
// 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.
func dispatchBuiltinExec(e *execpkg.Executor, args json.RawMessage) (string, error) {
var a struct {

82
mcp/client.go Normal file
View File

@ -0,0 +1,82 @@
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"sync"
)
// Message is a JSON-RPC 2.0 message.
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params interface{} `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError is a JSON-RPC error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *RPCError) Error() string {
return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message)
}
// Client is a JSON-RPC client over a pair of streams (typically stdin/stdout
// of a subprocess).
type Client struct {
mu sync.Mutex
w io.Writer
sc *bufio.Scanner
nextID int
}
// NewClient creates a Client from a reader and writer.
func NewClient(r io.Reader, w io.Writer) *Client {
return &Client{w: w, sc: bufio.NewScanner(r)}
}
// Call sends a JSON-RPC request and returns the result.
func (c *Client) Call(method string, params interface{}) (json.RawMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.nextID++
id := c.nextID
msg := Message{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: params,
}
data, err := json.Marshal(msg)
if err != nil {
return nil, err
}
if _, err := fmt.Fprintf(c.w, "%s\n", data); err != nil {
return nil, err
}
if !c.sc.Scan() {
if err := c.sc.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
var resp Message
if err := json.Unmarshal(c.sc.Bytes(), &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, resp.Error
}
return resp.Result, nil
}

56
mcp/transport.go Normal file
View File

@ -0,0 +1,56 @@
package mcp
import (
"fmt"
"os"
"os/exec"
)
// STDIOTransport launches a subprocess and connects a Client to its stdin/stdout.
type STDIOTransport struct {
command string
args []string
env map[string]string
}
// NewSTDIOTransport creates a transport that will launch the given command.
func NewSTDIOTransport(command string, args []string, env map[string]string) *STDIOTransport {
return &STDIOTransport{command: command, args: args, env: env}
}
// Connect launches the subprocess and returns a connected Client.
func (t *STDIOTransport) Connect() (*Client, error) {
cmd := exec.Command(t.command, t.args...)
for k, v := range t.env {
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", k, v))
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
client := NewClient(stdout, stdin)
// MCP initialization handshake.
if _, err := client.Call("initialize", map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{},
"clientInfo": map[string]interface{}{"name": "ollie", "version": "0.1"},
}); err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
if _, err := client.Call("notifications/initialized", nil); err != nil {
// Some servers don't respond to this notification; ignore errors.
_ = err
}
return client, nil
}

View File

@ -1,11 +1,5 @@
{
"globalShortcut": "Ctrl+Space",
"mcpServers": {
"anvilmcp": {
"command": "anvilmcp",
"args": []
}
},
"hooks": {
"agentSpawn": "anvillm-hook agentSpawn",
"userPromptSubmit": "anvillm-hook userPromptSubmit",

BIN
ollie

Binary file not shown.

72
tools/executor.go Normal file
View File

@ -0,0 +1,72 @@
package tools
import (
"encoding/json"
"fmt"
"ollie/mcp"
)
// ToolInfo describes a tool provided by an MCP server.
type ToolInfo struct {
Server string
Name string
Description string
InputSchema json.RawMessage
}
// Executor routes tool calls to the MCP server that owns them.
type Executor struct {
servers map[string]*mcp.Client
}
// NewExecutor creates an empty Executor.
func NewExecutor() *Executor {
return &Executor{servers: make(map[string]*mcp.Client)}
}
// AddServer registers an MCP client under the given name.
func (e *Executor) AddServer(name string, client *mcp.Client) {
e.servers[name] = client
}
// ListTools returns all tools advertised by all connected servers.
func (e *Executor) ListTools() ([]ToolInfo, error) {
var all []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 {
all = append(all, ToolInfo{
Server: serverName,
Name: t.Name,
Description: t.Description,
InputSchema: t.InputSchema,
})
}
}
return all, nil
}
// Execute calls a named tool on the named server.
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)
}
return client.Call("tools/call", map[string]any{
"name": toolName,
"arguments": args,
})
}