diff --git a/config/config.go b/config/config.go index 9d8cde6..7d98d86 100644 --- a/config/config.go +++ b/config/config.go @@ -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) { diff --git a/main.go b/main.go index 1032aea..e79dade 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/mcp/client.go b/mcp/client.go new file mode 100644 index 0000000..2664261 --- /dev/null +++ b/mcp/client.go @@ -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 +} diff --git a/mcp/transport.go b/mcp/transport.go new file mode 100644 index 0000000..1d6a831 --- /dev/null +++ b/mcp/transport.go @@ -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 +} diff --git a/mcpconfig.json b/mcpconfig.json index 55b7d34..298bcb8 100644 --- a/mcpconfig.json +++ b/mcpconfig.json @@ -1,11 +1,5 @@ { "globalShortcut": "Ctrl+Space", - "mcpServers": { - "anvilmcp": { - "command": "anvilmcp", - "args": [] - } - }, "hooks": { "agentSpawn": "anvillm-hook agentSpawn", "userPromptSubmit": "anvillm-hook userPromptSubmit", diff --git a/ollie b/ollie index 313f0b0..61629bf 100755 Binary files a/ollie and b/ollie differ diff --git a/tools/executor.go b/tools/executor.go new file mode 100644 index 0000000..03096a8 --- /dev/null +++ b/tools/executor.go @@ -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, + }) +}