Expose discovered MCP tools through the act allowlist (#251)
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
This commit is contained in:
@@ -2,10 +2,12 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -429,3 +431,93 @@ func (m *Manager) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNeedsArgs — the tool requires arguments that a voice verb cannot supply.
|
||||
var ErrNeedsArgs = errors.New("mcp: tool needs named arguments")
|
||||
|
||||
// CallPositional is the voice path's way in. The router gives an act a verb and
|
||||
// a tail of positional words; an MCP tool wants a named-argument object. There
|
||||
// is no general mapping between those two, and inventing one is exactly the
|
||||
// improvisation this codebase refuses, so the rule is deliberately narrow:
|
||||
//
|
||||
// - a tool with no required properties runs with no arguments (a spare tail
|
||||
// is ignored — "покажи проекты пожалуйста" should still list projects);
|
||||
// - a READ-ONLY tool with exactly one required property, of type string or
|
||||
// integer/number, gets the tail bound to it;
|
||||
// - anything else is refused with ErrNeedsArgs. Such a tool is still callable
|
||||
// with explicit arguments from the authed surface, where a human types
|
||||
// them.
|
||||
//
|
||||
// The refusal is the point, and the read-only condition on it was learned the
|
||||
// hard way while testing against the Vikunja server: `update_task` requires
|
||||
// only `task_id` and takes every other field as optional, so calling it with
|
||||
// one guessed argument and no others BLANKED the fields it did not receive. A
|
||||
// mutating tool therefore never gets a guessed argument — the one thing a
|
||||
// partially-filled write can do is destroy what it did not mention. A mutating
|
||||
// tool with nothing required is still fine: nothing was guessed, and it still
|
||||
// goes through the confirm turn.
|
||||
func (m *Manager) CallPositional(ctx context.Context, server, tool string, args []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
var schema json.RawMessage
|
||||
found, readOnly := false, false
|
||||
if c != nil {
|
||||
for _, t := range c.tools {
|
||||
if t.Name == tool {
|
||||
schema, readOnly, found = t.InputSchema, t.ReadOnly, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !found {
|
||||
return "", fmt.Errorf("mcp: %s offers no tool %q", server, tool)
|
||||
}
|
||||
named, err := bindPositional(schema, args, readOnly)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.Call(ctx, server, tool, named)
|
||||
}
|
||||
|
||||
// bindPositional implements the rule documented on CallPositional.
|
||||
func bindPositional(schema json.RawMessage, args []string, readOnly bool) (map[string]any, error) {
|
||||
var s struct {
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if len(schema) > 0 {
|
||||
if err := json.Unmarshal(schema, &s); err != nil {
|
||||
return nil, fmt.Errorf("mcp: unreadable input schema: %w", err)
|
||||
}
|
||||
}
|
||||
switch len(s.Required) {
|
||||
case 0:
|
||||
return map[string]any{}, nil
|
||||
case 1:
|
||||
name := s.Required[0]
|
||||
if !readOnly {
|
||||
return nil, fmt.Errorf("%w: %q, and a tool that writes never gets a guessed one", ErrNeedsArgs, name)
|
||||
}
|
||||
tail := strings.TrimSpace(strings.Join(args, " "))
|
||||
if tail == "" {
|
||||
return nil, fmt.Errorf("%w: %q", ErrNeedsArgs, name)
|
||||
}
|
||||
switch s.Properties[name].Type {
|
||||
case "string", "":
|
||||
return map[string]any{name: tail}, nil
|
||||
case "integer", "number":
|
||||
n, err := strconv.ParseFloat(tail, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q wants a number, got %q", ErrNeedsArgs, name, tail)
|
||||
}
|
||||
return map[string]any{name: n}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q is a %s", ErrNeedsArgs, name, s.Properties[name].Type)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrNeedsArgs, strings.Join(s.Required, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,3 +444,122 @@ func TestLocalNameAndCmd(t *testing.T) {
|
||||
t.Error("scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPositional(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, schema string
|
||||
args []string
|
||||
mutating bool
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no required runs with nothing",
|
||||
// A spare tail is fine: "покажи проекты пожалуйста" still lists them.
|
||||
schema: `{"type":"object","properties":{},"required":[]}`,
|
||||
args: []string{"пожалуйста"},
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "empty schema",
|
||||
schema: ``,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "one required string gets the tail",
|
||||
schema: `{"properties":{"q":{"type":"string"}},"required":["q"]}`,
|
||||
args: []string{"почему", "небо", "синее"},
|
||||
want: map[string]any{"q": "почему небо синее"},
|
||||
},
|
||||
{
|
||||
name: "one required string with no tail",
|
||||
schema: `{"properties":{"q":{"type":"string"}},"required":["q"]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one required integer parses",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"251"},
|
||||
want: map[string]any{"task_id": float64(251)},
|
||||
},
|
||||
{
|
||||
name: "one required integer with words",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"двести", "пятьдесят", "один"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "two required is refused rather than guessed",
|
||||
schema: `{"properties":{"a":{"type":"string"},"b":{"type":"string"}},"required":["a","b"]}`,
|
||||
args: []string{"что-то"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one required object is refused",
|
||||
schema: `{"properties":{"payload":{"type":"object"}},"required":["payload"]}`,
|
||||
args: []string{"что-то"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Learned from Vikunja's update_task: required ["task_id"], every
|
||||
// other field optional, so one guessed argument blanks the rest.
|
||||
name: "one required on a mutating tool is refused",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"251"},
|
||||
mutating: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Nothing was guessed, so there is nothing to get wrong. It still
|
||||
// goes through the confirm turn upstream.
|
||||
name: "no required on a mutating tool still runs",
|
||||
schema: `{"properties":{},"required":[]}`,
|
||||
mutating: true,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "unreadable schema",
|
||||
schema: `not json`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := bindPositional(json.RawMessage(tc.schema), tc.args, !tc.mutating)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want an error, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPositionalThroughManager(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil },
|
||||
[]ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
defer m.Close()
|
||||
// echoServer's tools declare no required properties.
|
||||
out, err := m.CallPositional(context.Background(), "fake", "read_thing", []string{"хвост"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:<nil>" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
if _, err := m.CallPositional(context.Background(), "fake", "absent", nil); err == nil {
|
||||
t.Error("an unknown tool must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user