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
78 lines
2.2 KiB
Go
78 lines
2.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
)
|
|
|
|
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
|
st := newTestStore(t)
|
|
for name, cfg := range map[string]*config.Config{
|
|
"no block": {},
|
|
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
|
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
|
}}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if w := wireMCP(cfg, st); w != nil {
|
|
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
|
}
|
|
})
|
|
}
|
|
// nil wiring must be safe to use everywhere it is reachable.
|
|
var w *mcpWiring
|
|
w.close()
|
|
w.propose(context.Background())
|
|
if w.status() != nil || w.caller() != nil {
|
|
t.Fatal("a nil wiring must report nothing")
|
|
}
|
|
}
|
|
|
|
// An unreachable server must not stop the daemon, must be reported as down, and
|
|
// must propose nothing.
|
|
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
|
st := newTestStore(t)
|
|
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
|
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
|
}}}}, st)
|
|
if w == nil {
|
|
t.Fatal("a configured server should still wire")
|
|
}
|
|
defer w.close()
|
|
st2 := w.status()
|
|
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
|
t.Fatalf("status = %+v", st2)
|
|
}
|
|
tools, err := st.ListTools(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tools) != 0 {
|
|
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
|
}
|
|
}
|
|
|
|
// A url server whose address is private is refused by webfetch unless that
|
|
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
|
// it is asserted here too, at the wiring level.
|
|
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
|
st := newTestStore(t)
|
|
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
|
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
|
}}}}, st)
|
|
if w == nil {
|
|
t.Fatal("should wire")
|
|
}
|
|
defer w.close()
|
|
s := w.status()[0]
|
|
if s.Connected {
|
|
t.Fatal("a loopback server must not connect without allow_private")
|
|
}
|
|
if !strings.Contains(s.Err, "private address") {
|
|
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
|
}
|
|
}
|