package main import ( "context" "fmt" "log" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/webfetch" ) // mcpRefreshInterval — how often the manager re-dials a server that is down. // The manager applies its own backoff on top, so this being short is cheap. const mcpRefreshInterval = time.Minute // mcpWiring — the MCP client, when the `mcp` block configures at least one // enabled server. nil ⇒ nothing was configured, nothing is connected, and an // allowlist row that happens to look like an MCP row refuses to run. // // It lives on the voice wiring because MCP tools ARE acts: they run through // tool.Executor, the enabled allowlist and the confirm turn, which only exist // on the voice/chat path. No voice surface ⇒ nothing that could call a tool. type mcpWiring struct { mgr *mcp.Manager st *store.Store } // wireMCP builds the manager, connects, and proposes what it found. It never // fails the daemon: a server that is unreachable at boot is logged and retried, // because Maven starting is not contingent on someone else's process. func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring { servers := cfg.MCPServers() if len(servers) == 0 { return nil } limits := webfetch.Config{} if cfg.MCP != nil { limits.AllowHosts = cfg.MCP.AllowHosts limits.DenyHosts = cfg.MCP.DenyHosts limits.MaxBytes = cfg.MCP.MaxBytes limits.Timeout = time.Duration(cfg.MCP.Timeout) } mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers) if err != nil { // Validation already ran in config.validate, so this is a programming // error rather than a config one. Still not fatal: MCP off is a working // Maven. log.Printf("mcp: not wired: %v", err) return nil } w := &mcpWiring{mgr: mgr, st: st} ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() mgr.Connect(ctx) w.propose(ctx) return w } // propose writes a 'proposed' allowlist row for every discovered tool. It does // NOT enable anything: a configured server is a place Maven may look, not a // capability she has. Kami enables what he wants on /tools, behind step-up, // which is the same gate a shell tool goes through. // // Re-running on every boot is idempotent — ProposeMCPTool never touches an // existing row, so a tool he disabled stays disabled and one he enabled keeps // the cmd he enabled it with. func (w *mcpWiring) propose(ctx context.Context) { if w == nil { return } now := time.Now() fresh := 0 for _, t := range w.mgr.Tools() { name := mcp.LocalName(t.Server, t.Name) // No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong // in this direction only costs a question. destructive := !t.ReadOnly provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name) if t.Description != "" { provenance += ": " + t.Description } ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server), mcp.Cmd(t.Server, t.Name), destructive, provenance, now) if err != nil { log.Printf("mcp: propose %s: %v", name, err) continue } if ok { fresh++ } } if fresh > 0 { log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh) } } // run re-dials downed servers and picks up tools that appeared, until ctx is // canceled. func (w *mcpWiring) run(ctx context.Context) { if w == nil { return } t := time.NewTicker(mcpRefreshInterval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: w.mgr.Refresh(ctx) w.propose(ctx) } } } // status maps the manager's view onto the wire type the web surface reads. func (w *mcpWiring) status() []ipc.MCPServerStatus { if w == nil { return nil } in := w.mgr.Status() out := make([]ipc.MCPServerStatus, 0, len(in)) for _, s := range in { out = append(out, ipc.MCPServerStatus{ Name: s.Name, Transport: s.Transport, Target: s.Target, Connected: s.Connected, Server: s.Server, Tools: s.Tools, Err: s.Err, }) } return out } func (w *mcpWiring) close() { if w == nil { return } _ = w.mgr.Close() } // caller is the tool.MCPCaller the executor gets, or nil when MCP is off. func (w *mcpWiring) caller() *mcp.Manager { if w == nil { return nil } return w.mgr }