// Package tools implements the Fabric-specific MCP tools the plugin // exposes to Plexum agents (port of openclaw plugin's tools.ts, F-4 + // F-5 + F-7 split). Each tool here is bound to a Plexum context that // gives it access to: // // - fabric.Client for REST calls // - tokens.Cache for per-agent session reuse + auto-refresh // - identity.Registry to look up agent → fabric user_id // - the plugin's logger // // Per-tool agent identity: Plexum SDK doesn't propagate the calling // agent id through CallTool today, so every agent-scoped tool here // requires `agent_id` in input args. The agent learns its own id from // the host (env var or system prompt — operator concern). package tools import ( "context" "encoding/json" "fmt" "strings" plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens" ) // Deps bundles what every tool needs. type Deps struct { Client *fabric.Client Tokens *tokens.Cache Identities *identity.Registry } // HandlerFunc is the tool's call-time function. Returns a ToolResult // (text content) or an in-band error result (IsError=true). type HandlerFunc func(ctx context.Context, input json.RawMessage) (plugin.ToolResult, error) // Registry maps tool name → HandlerFunc. Build once at plugin init. type Registry struct { deps Deps handlers map[string]HandlerFunc } // New constructs a Registry with the agent-facing tools wired. func New(deps Deps) *Registry { r := &Registry{deps: deps, handlers: map[string]HandlerFunc{}} r.registerSendMessage() r.registerSendSysMsg() r.registerChannelList() r.registerGuildList() r.registerMessageHistory() r.registerChannelSetPurpose() r.registerChannel() r.registerCanvas() return r } // Names returns the registered tool names (for manifest generation). func (r *Registry) Names() []string { out := make([]string, 0, len(r.handlers)) for k := range r.handlers { out = append(out, k) } return out } // Handler returns the function for `name`, or nil if not registered. func (r *Registry) Handler(name string) HandlerFunc { return r.handlers[name] } // Contracts returns the MCP ToolContracts for all registered tools. // Used by install.sh / manifest generation; also useful for runtime // introspection. func (r *Registry) Contracts() []plugin.ToolContract { return allContracts } // ---- helpers ---- func textResult(text string) plugin.ToolResult { return plugin.NewTextResult(text) } func errResult(msg string) plugin.ToolResult { return plugin.ToolResult{ Content: []plugin.ContentBlock{{Type: "text", Text: msg}}, IsError: true, } } func jsonResult(label string, body any) plugin.ToolResult { raw, err := json.MarshalIndent(body, "", " ") if err != nil { return errResult(label + ": marshal: " + err.Error()) } return textResult(label + ":\n" + string(raw)) } // agentGuild resolves the agent's session + the specified guild's // endpoint + token. Common preamble for every tool that hits a guild. func (r *Registry) agentGuild(ctx context.Context, agentID, guildNodeID string) (sess *fabric.Session, endpoint, token string, err error) { if agentID == "" { err = fmt.Errorf("agent_id is required") return } sess, err = r.deps.Tokens.Get(ctx, agentID) if err != nil { return } for _, g := range sess.Guilds { if g.NodeID == guildNodeID { endpoint = g.Endpoint break } } for _, t := range sess.GuildAccessTokens { if t.GuildNodeID == guildNodeID { token = t.Token break } } if endpoint == "" || token == "" { err = fmt.Errorf("agent %s has no access to guild %s", agentID, guildNodeID) } return } // pickFirstGuild returns the first guild from the session when caller // omits guild_node_id. Most agents are in exactly one guild, so this is // a no-arg sane default. func pickFirstGuild(sess *fabric.Session) (string, error) { for _, g := range sess.Guilds { if g.NodeID != "" { return g.NodeID, nil } } return "", fmt.Errorf("agent has no guilds") } // ---- tool: fabric-send-message ---- func (r *Registry) registerSendMessage() { r.handlers["fabric-send-message"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` Content string `json:"content"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-send-message: parse: " + err.Error()), nil } if in.ChannelID == "" || in.Content == "" { return errResult("fabric-send-message: channel_id and content required"), nil } sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { if in.GuildNodeID == "" && in.AgentID != "" { // Try first-guild fallback. if s2, err2 := r.deps.Tokens.Get(ctx, in.AgentID); err2 == nil { if gn, err3 := pickFirstGuild(s2); err3 == nil { in.GuildNodeID = gn sess, endpoint, token, err = r.agentGuild(ctx, in.AgentID, in.GuildNodeID) } } } if err != nil { return errResult("fabric-send-message: " + err.Error()), nil } } if err := r.deps.Client.PostMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil { return errResult("fabric-send-message: post: " + err.Error()), nil } return textResult(fmt.Sprintf("sent: guild=%s channel=%s len=%d", in.GuildNodeID, in.ChannelID, len(in.Content))), nil } } // ---- tool: fabric-send-sys-msg ---- func (r *Registry) registerSendSysMsg() { r.handlers["fabric-send-sys-msg"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` Content string `json:"content"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-send-sys-msg: parse: " + err.Error()), nil } if in.ChannelID == "" || in.Content == "" { return errResult("fabric-send-sys-msg: channel_id and content required"), nil } sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-send-sys-msg: " + err.Error()), nil } if err := r.deps.Client.PostSystemMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil { return errResult("fabric-send-sys-msg: post: " + err.Error()), nil } return textResult("system message sent"), nil } } // ---- tool: fabric-channel-list ---- func (r *Registry) registerChannelList() { r.handlers["fabric-channel-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-channel-list: parse: " + err.Error()), nil } _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-channel-list: " + err.Error()), nil } channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID) if err != nil { return errResult("fabric-channel-list: " + err.Error()), nil } // Compact rendering — table-ish. var b strings.Builder fmt.Fprintf(&b, "channels in guild %s (%d):\n", in.GuildNodeID, len(channels)) for _, c := range channels { purpose := "" if c.Purpose != nil { purpose = " — " + *c.Purpose } fmt.Fprintf(&b, " %s [%s] %s%s\n", c.ID, c.XType, c.Name, purpose) } return textResult(b.String()), nil } } // ---- tool: fabric-guild-list ---- func (r *Registry) registerGuildList() { r.handlers["fabric-guild-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-guild-list: parse: " + err.Error()), nil } if in.AgentID == "" { return errResult("fabric-guild-list: agent_id required"), nil } sess, err := r.deps.Tokens.Get(ctx, in.AgentID) if err != nil { return errResult("fabric-guild-list: " + err.Error()), nil } var b strings.Builder fmt.Fprintf(&b, "guilds for agent %s (%d):\n", in.AgentID, len(sess.Guilds)) for _, g := range sess.Guilds { purpose := "" if g.Purpose != nil { purpose = " — " + *g.Purpose } fmt.Fprintf(&b, " %s @ %s (%s)%s\n", g.NodeID, g.Endpoint, g.Name, purpose) } return textResult(b.String()), nil } } // ---- tool: fabric-message-history ---- func (r *Registry) registerMessageHistory() { r.handlers["fabric-message-history"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` SeqFrom int `json:"seq_from"` SeqTo int `json:"seq_to"` Limit int `json:"limit"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-message-history: parse: " + err.Error()), nil } if in.ChannelID == "" { return errResult("fabric-message-history: channel_id required"), nil } _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-message-history: " + err.Error()), nil } page, err := r.deps.Client.ListMessages(ctx, endpoint, token, in.ChannelID, fabric.ListMessagesOpts{SeqFrom: in.SeqFrom, SeqTo: in.SeqTo, Limit: in.Limit}) if err != nil { return errResult("fabric-message-history: " + err.Error()), nil } var b strings.Builder fmt.Fprintf(&b, "history channel=%s seq=%d-%d returned=%d hasMore=%v\n", in.ChannelID, page.Page.SeqFrom, page.Page.SeqTo, page.Page.Returned, page.Page.HasMore) for _, m := range page.Items { marker := "" if m.IsDeleted { marker = " (deleted)" } else if m.EditedAt != nil { marker = " (edited)" } fmt.Fprintf(&b, " seq=%d %s by %s%s: %s\n", m.Seq, m.CreatedAt, m.AuthorUserID, marker, m.Content) } return textResult(b.String()), nil } } // ---- tool: fabric-channel-set-purpose ---- func (r *Registry) registerChannelSetPurpose() { r.handlers["fabric-channel-set-purpose"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` Purpose string `json:"purpose"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-channel-set-purpose: parse: " + err.Error()), nil } if in.ChannelID == "" { return errResult("fabric-channel-set-purpose: channel_id required"), nil } _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-channel-set-purpose: " + err.Error()), nil } if err := r.deps.Client.SetChannelPurpose(ctx, endpoint, token, in.ChannelID, in.Purpose); err != nil { return errResult("fabric-channel-set-purpose: " + err.Error()), nil } return textResult(fmt.Sprintf("purpose updated for %s", in.ChannelID)), nil } } // ---- tool: fabric-channel (meta about one channel) ---- func (r *Registry) registerChannel() { r.handlers["fabric-channel"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-channel: parse: " + err.Error()), nil } if in.ChannelID == "" { return errResult("fabric-channel: channel_id required"), nil } _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-channel: " + err.Error()), nil } // Find by scanning channel list — guild has no GET-one endpoint. channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID) if err != nil { return errResult("fabric-channel: " + err.Error()), nil } for _, c := range channels { if c.ID == in.ChannelID { members, _ := r.deps.Client.ChannelMembers(ctx, endpoint, token, in.ChannelID) return jsonResult("channel", map[string]any{ "channel": c, "members": members, }), nil } } return errResult("fabric-channel: channel " + in.ChannelID + " not found in guild"), nil } } // ---- tool: fabric-canvas (get / share / update / remove) ---- func (r *Registry) registerCanvas() { r.handlers["fabric-canvas"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { var in struct { AgentID string `json:"agent_id"` GuildNodeID string `json:"guild_node_id"` ChannelID string `json:"channel_id"` Op string `json:"op"` // "get" | "share" | "update" | "remove" Title string `json:"title"` Format string `json:"format"` // "md" | "html" | "text" Source string `json:"source"` } if err := json.Unmarshal(raw, &in); err != nil { return errResult("fabric-canvas: parse: " + err.Error()), nil } if in.ChannelID == "" { return errResult("fabric-canvas: channel_id required"), nil } if in.Op == "" { in.Op = "get" } _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) if err != nil { return errResult("fabric-canvas: " + err.Error()), nil } switch in.Op { case "get": canvas, err := r.deps.Client.GetCanvas(ctx, endpoint, token, in.ChannelID) if err != nil { return errResult("fabric-canvas: " + err.Error()), nil } if canvas == nil { return textResult("(no canvas set on this channel)"), nil } return jsonResult("canvas", canvas), nil case "share": if in.Title == "" || in.Source == "" || in.Format == "" { return errResult("fabric-canvas share: title + format + source required"), nil } canvas, err := r.deps.Client.ShareCanvas(ctx, endpoint, token, in.ChannelID, in.Title, fabric.CanvasFormat(in.Format), in.Source) if err != nil { return errResult("fabric-canvas share: " + err.Error()), nil } return jsonResult("canvas shared", canvas), nil case "update": canvas, err := r.deps.Client.UpdateCanvas(ctx, endpoint, token, in.ChannelID, in.Title, in.Source, fabric.CanvasFormat(in.Format)) if err != nil { return errResult("fabric-canvas update: " + err.Error()), nil } return jsonResult("canvas updated", canvas), nil case "remove": if err := r.deps.Client.RemoveCanvas(ctx, endpoint, token, in.ChannelID); err != nil { return errResult("fabric-canvas remove: " + err.Error()), nil } return textResult("canvas removed"), nil default: return errResult("fabric-canvas: unknown op " + in.Op + " (want get|share|update|remove)"), nil } } }