Files
LocalAI/core/services/studio/mcptools.go
T
Ettore Di Giacinto 6b181cd20b feat(studio): enforce the action policy of unattended runs
A trigger run or an agent job has nobody to approve a step, and so far
the approval mode auto_readonly and the act.* grants were only recorded.
The prompt was the only thing that kept such a run from acting.

Every tool call of an unattended run now goes through one check at the
call. A tool that only reads runs. Any other tool needs the grant of its
class, and the check fails closed:

- act.files for write_file, edit_file, move_file, delete_file and the
  built-in bash;
- act.addons, or act.addons:<id>, for add-on tools whose declared kind is
  not read, plus act.spend when the add-on gates the tool with a
  permission;
- act.spend for MCP tools.

A trigger holds its new acts list within the grants of its agent. A
trigger that declared read only runs, or that a binding in auto_readonly
mode made without asking, is refused every non-read tool whatever it
holds. A binding that cannot be read counts as auto_readonly. A job
holds the act grants of its agent. Sub-agents follow their parent, and a
saved agent cannot be started from an unattended run.

A refused call answers with a plain sentence the model can pass on, not
an error, and writes one audit log line. Triggers set acts through the
REST API; a model never can. A trigger whose plan names a file it writes
keeps act.files.

Assisted-by: Claude:claude-sonnet-5-5 go ginkgo swag
2026-11-03 19:12:29 +00:00

134 lines
4.7 KiB
Go

package studio
import (
"context"
"strings"
"github.com/mudler/LocalAI/core/services/studio/mcpconns"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
)
// reservedToolNames are the names the service owns. A tool from another source
// cannot take one, so the model always gets the real thing.
var reservedToolNames = map[string]bool{"spawn_agent": true, proposeTriggerName: true, listTriggersName: true,
webSearchName: true, webFetchName: true, listAgentsName: true}
// hostTools returns the tools a conversation has besides the ones the service
// adds itself: those from Deps.HostTools, the add-on tools of its user, the
// collection tools and the MCP tools of its model. A chat with a saved agent
// gets only what the agent grants. The second result lists the grants that
// could not be used, as phrases for a note to the model.
func (s *Service) hostTools(ctx context.Context, a workspace.Actor) ([]session.Tool, error) {
tools, _, err := s.scopedHostTools(ctx, a)
return tools, err
}
func (s *Service) scopedHostTools(ctx context.Context, a workspace.Actor) ([]session.Tool, []string, error) {
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
if err != nil {
return nil, nil, err
}
sc := s.scopeOf(ctx, rec)
var tools []session.Tool
if s.d.HostTools != nil {
ts, err := s.d.HostTools(ctx, a)
if err != nil {
return nil, nil, err
}
tools = append(tools, ts...)
}
if sc != nil && sc.deleted {
return tools, []string{"the agent of this chat was deleted"}, nil
}
// The chat's switches of the built-in groups also take away the add-on
// tool that replaces bash, and the collection tools.
hidden := s.hiddenFor(ctx, rec)
var missing []string
t, m, err := s.addonTools(ctx, a, rec, sc)
if err != nil {
return nil, nil, err
}
tools, missing = append(tools, dropHidden(t, hidden)...), append(missing, m...)
if !hidden[collectionToolNames[0]] {
t, m = s.collectionTools(ctx, a, sc)
tools, missing = append(tools, t...), append(missing, m...)
}
t, m = s.mcpTools(ctx, a, rec, sc, tools)
// MCP tools are external: an unattended run needs act.spend for them.
t = s.gateExternalAct(ctx, rec, t)
return append(tools, t...), append(missing, m...), nil
}
// mcpTools gives the chat its MCP tools: those of the model's own servers and
// those of the user's connectors, less what the chat switched off. A chat with
// a saved agent gets only the servers and connectors the agent names; a name
// with the prefix "connector:" is a connector, any other is a server of the
// model. A server that cannot be reached gives no tools and a warning; it never
// fails the chat. have lists the tools the chat has already, so that a name
// is never taken twice.
func (s *Service) mcpTools(ctx context.Context, a workspace.Actor, rec session.ChatRecord, sc *scope, have []session.Tool) ([]session.Tool, []string) {
off := set(rec.McpOff)
var servers, refs []string
if sc != nil {
for _, n := range sc.mcp {
if off[n] {
continue
}
if id, ok := strings.CutPrefix(n, mcpconns.Prefix); ok {
refs = append(refs, id)
} else {
servers = append(servers, n)
}
}
}
tools, missing := s.modelMCPTools(ctx, a, rec, sc, servers, off)
ct, cm := s.connectorTools(ctx, a, rec, sc, refs, off, append(append([]session.Tool(nil), have...), tools...))
return append(tools, ct...), append(missing, cm...)
}
// modelMCPTools turns the MCP tools of the conversation's model into chat
// tools. The executor is built each time: it reuses cached MCP sessions.
func (s *Service) modelMCPTools(ctx context.Context, a workspace.Actor, rec session.ChatRecord, sc *scope, servers []string, off map[string]bool) ([]session.Tool, []string) {
if s.d.MCP == nil {
return nil, nil
}
var only []string
limited := sc != nil
if sc != nil {
if len(servers) == 0 {
return nil, nil
}
only = servers
} else if s.d.MCPServers != nil && len(off) > 0 {
// A chat with no agent uses the servers the model picks by itself. To
// leave some out, ask for the others by name.
var auto, kept []string
for _, m := range s.d.MCPServers(ctx, a.Principal, rec.Model) {
if m.Auto {
auto = append(auto, m.Name)
if !off[m.Name] {
kept = append(kept, m.Name)
}
}
}
if len(kept) < len(auto) {
if len(kept) == 0 {
return nil, nil
}
only, limited = kept, true
}
}
exec, absent := s.d.MCP(ctx, a.Principal, rec.Model, only, limited)
missing := prefixed("MCP server", absent)
if exec == nil || !exec.HasTools() {
return nil, missing
}
tools, err := s.toolsOfApps(ctx, a.ChatID, exec, true)
if err != nil {
s.log.Warn("studio: MCP tools unavailable", "chat", a.ChatID, "model", rec.Model, "error", err)
return nil, missing
}
return tools, missing
}