mcp,tools,main: shut down MCP servers on agent switch

Client gains a Close() method that closes stdin (giving the subprocess
a clean EOF) then kills and reaps the process. Executor.Close() calls
Close() on all registered clients. agentEnv now carries the mcpExec so
the /agent command can close the outgoing executor before building the
new one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-06 20:36:20 +02:00
parent fd5de0dec3
commit ea07f6d3a0
4 changed files with 39 additions and 1 deletions

View File

@ -204,6 +204,7 @@ type model struct {
backendName string // e.g. "ollama", "openrouter", "openai"
agentName string // active agent config name, e.g. "default"
agentsDir string // path to ~/.config/ollie/agents/
mcpExec *tools.Executor // current agent's MCP executor; closed on agent switch
builtinExec *execpkg.Executor
confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run
ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas)
@ -243,6 +244,7 @@ type timeoutMsg struct{}
// agentEnv holds the runtime state derived from an agent config file.
type agentEnv struct {
mcpExec *tools.Executor // kept so it can be closed on agent switch
tools []backend.Tool
exec agent.ToolExecutor
confirm *agent.ConfirmFn // pointer filled in by startAgent per-run
@ -339,6 +341,7 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
}
return agentEnv{
mcpExec: mcpExec,
tools: allTools,
exec: execFn,
confirm: confirmPtr,
@ -445,6 +448,7 @@ func main() {
backendName: backendName,
agentName: agentName,
agentsDir: agentsDir,
mcpExec: env.mcpExec,
builtinExec: builtinExec,
confirmPtr: env.confirm,
ctxOverhead: env.ctxOverhead,
@ -856,7 +860,11 @@ func (m *model) handleCommand(input string) bool {
m.display = append(m.display, fmt.Sprintf("Error: agent %q: %v", name, err))
return true
}
if m.mcpExec != nil {
m.mcpExec.Close()
}
env := buildAgentEnv(cfg, m.builtinExec)
m.mcpExec = env.mcpExec
m.hooks = env.hooks
m.loopcfg.SystemPrompt = env.systemPrompt
m.loopcfg.Tools = env.tools

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
)
@ -32,9 +33,11 @@ func (e *RPCError) Error() string {
// of a subprocess).
type Client struct {
mu sync.Mutex
wc io.WriteCloser
w io.Writer
sc *bufio.Scanner
nextID int
cmd *exec.Cmd
}
// NewClient creates a Client from a reader and writer.
@ -42,6 +45,26 @@ func NewClient(r io.Reader, w io.Writer) *Client {
return &Client{w: w, sc: bufio.NewScanner(r)}
}
// newClientWithProcess creates a Client that owns the given subprocess.
// Close() will shut it down.
func newClientWithProcess(r io.Reader, wc io.WriteCloser, cmd *exec.Cmd) *Client {
return &Client{wc: wc, w: wc, sc: bufio.NewScanner(r), cmd: cmd}
}
// Close shuts down the underlying subprocess: closes its stdin so it receives
// EOF, then kills the process if it is still running.
func (c *Client) Close() error {
var err error
if c.wc != nil {
err = c.wc.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
c.cmd.Wait() // reap the process; ignore errors
}
return err
}
// Call sends a JSON-RPC request and returns the result.
func (c *Client) Call(method string, params interface{}) (json.RawMessage, error) {
c.mu.Lock()

View File

@ -37,7 +37,7 @@ func (t *STDIOTransport) Connect() (*Client, error) {
return nil, err
}
client := NewClient(stdout, stdin)
client := newClientWithProcess(stdout, stdin, cmd)
// MCP initialization handshake.
if _, err := client.Call("initialize", map[string]interface{}{

View File

@ -29,6 +29,13 @@ func (e *Executor) AddServer(name string, client *mcp.Client) {
e.servers[name] = client
}
// Close shuts down all connected MCP servers.
func (e *Executor) Close() {
for _, client := range e.servers {
client.Close()
}
}
// ListTools returns all tools advertised by all connected servers.
func (e *Executor) ListTools() ([]ToolInfo, error) {
var all []ToolInfo