8d5e357b57
Second half of the MCP client: the tools the manager discovers become rows in the existing act allowlist instead of a parallel capability system. An MCP tool is encoded in the columns that already exist — cmd ["mcp",<server>,<tool>], scope mcp:<server> — so no migration, and ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no changes. One branch in Executor.Exec routes such a row to the manager instead of exec, and "mcp" is never run as a binary. Discovery only ever PROPOSES. destructive comes from the inverse of the MCP readOnlyHint, so a tool that does not promise to be read-only inherits the confirm turn, and enabling stays on /tools behind step-up. Voice args are positional and MCP args are named, so CallPositional binds only what it can defend: no required properties runs bare, and a read-only tool with exactly one required string or number gets the tail. Everything else refuses with ErrNeedsArgs rather than guessing. The read-only condition was learned against the live Vikunja server: update_task requires only task_id and takes the rest as optional, so one guessed argument blanked the fields it did not mention. A partially-filled write destroys what it omits, so a mutating tool never receives a guessed argument. Also: a read-only mcp_servers IPC method and an "MCP servers" card on /tools showing transport, target and state, with the trust level of a local target spelled out. There is deliberately no call-a-tool IPC method and no run button, so mutation keeps exactly one path. Vikunja #251
155 lines
4.3 KiB
Go
155 lines
4.3 KiB
Go
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
|
|
}
|