12729b5166
- backend/mcp.go - backend/tools.go Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
11 KiB
Go
418 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ToolResult is the uniform shape returned to the chat loop after a tool call.
|
|
type ToolResult struct {
|
|
OK bool `json:"ok"`
|
|
Result any `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func okResult(v any) ToolResult { return ToolResult{OK: true, Result: v} }
|
|
func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.Error()} }
|
|
func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} }
|
|
|
|
// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult.
|
|
// Tools that mutate the board return ok=true on success; read-only tools include their data in result.
|
|
func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
|
|
switch name {
|
|
case "list_board":
|
|
return toolListBoard(db)
|
|
case "create_column":
|
|
return toolCreateColumn(db, input)
|
|
case "update_column":
|
|
return toolUpdateColumn(db, input)
|
|
case "rename_column": // alias for backwards compat
|
|
return toolUpdateColumn(db, input)
|
|
case "delete_column":
|
|
return toolDeleteColumn(db, input)
|
|
case "reorder_columns":
|
|
return toolReorderColumns(db, input)
|
|
case "create_card":
|
|
return toolCreateCard(db, input)
|
|
case "update_card":
|
|
return toolUpdateCard(db, input)
|
|
case "delete_card":
|
|
return toolDeleteCard(db, input)
|
|
case "move_card":
|
|
return toolMoveCard(db, input)
|
|
case "card_history":
|
|
return toolCardHistory(db, input)
|
|
case "find_cards":
|
|
return toolFindCards(db, input)
|
|
case "list_users":
|
|
return toolListUsers(db)
|
|
case "assign_card":
|
|
return toolAssignCard(db, input)
|
|
case "add_comment":
|
|
return toolAddComment(db, input)
|
|
case "list_comments":
|
|
return toolListComments(db, input)
|
|
default:
|
|
return errMsg("unknown tool: " + name)
|
|
}
|
|
}
|
|
|
|
// toolMutates reports whether a successful invocation modifies the board state.
|
|
func toolMutates(name string) bool {
|
|
switch name {
|
|
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
|
|
"create_card", "update_card", "delete_card", "move_card", "assign_card",
|
|
"add_comment":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toolListBoard(db *DB) ToolResult {
|
|
cols, err := db.ListColumns()
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
cards, err := db.ListCardsWithTime()
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(map[string]any{"columns": cols, "cards": cards})
|
|
}
|
|
|
|
func toolCreateColumn(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct{ Name string `json:"name"` }
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
return errMsg("name required")
|
|
}
|
|
c, err := db.CreateColumn(in.Name)
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(c)
|
|
}
|
|
|
|
func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
ID string `json:"id"`
|
|
Name *string `json:"name"`
|
|
Location *string `json:"location"`
|
|
Width *int `json:"width"`
|
|
WIPLimit *int `json:"wip_limit"`
|
|
IsDone *bool `json:"is_done"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" {
|
|
return errMsg("id required")
|
|
}
|
|
if in.Name == nil && in.Location == nil && in.Width == nil && in.WIPLimit == nil && in.IsDone == nil {
|
|
return errMsg("at least one of name/location/width/wip_limit/is_done required")
|
|
}
|
|
if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width, WIPLimit: in.WIPLimit, IsDone: in.IsDone}); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolDeleteColumn(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct{ ID string }
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" {
|
|
return errMsg("id required")
|
|
}
|
|
if err := db.DeleteColumn(in.ID); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolReorderColumns(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct{ IDs []string }
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if len(in.IDs) == 0 {
|
|
return errMsg("ids required")
|
|
}
|
|
if err := db.ReorderColumns(in.IDs); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
ColumnID string `json:"column_id"`
|
|
Requester string `json:"requester"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" {
|
|
return errMsg("column_id and title required")
|
|
}
|
|
c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description, "")
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(c)
|
|
}
|
|
|
|
func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(input, &raw); err != nil {
|
|
return errResult(err)
|
|
}
|
|
id, _ := raw["id"].(string)
|
|
if id == "" {
|
|
return errMsg("id required")
|
|
}
|
|
patch := CardPatch{}
|
|
if v, ok := raw["requester"].(string); ok {
|
|
patch.Requester = &v
|
|
}
|
|
if v, ok := raw["title"].(string); ok {
|
|
patch.Title = &v
|
|
}
|
|
if v, ok := raw["description"].(string); ok {
|
|
patch.Description = &v
|
|
}
|
|
if v, ok := raw["color"].(string); ok {
|
|
patch.Color = &v
|
|
}
|
|
if v, ok := raw["locked"].(bool); ok {
|
|
patch.Locked = &v
|
|
}
|
|
if v, present := raw["assignee_id"]; present {
|
|
patch.HasAssignee = true
|
|
if v == nil {
|
|
empty := ""
|
|
patch.AssigneeID = &empty
|
|
} else if s, ok := v.(string); ok {
|
|
patch.AssigneeID = &s
|
|
}
|
|
}
|
|
if err := db.UpdateCard(id, patch); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolListUsers(db *DB) ToolResult {
|
|
users, err := db.ListUsers()
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(users)
|
|
}
|
|
|
|
func toolAssignCard(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
ID string `json:"id"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" {
|
|
return errMsg("id required")
|
|
}
|
|
patch := CardPatch{HasAssignee: true}
|
|
if in.AssigneeID == nil {
|
|
empty := ""
|
|
patch.AssigneeID = &empty
|
|
} else {
|
|
patch.AssigneeID = in.AssigneeID
|
|
}
|
|
if err := db.UpdateCard(in.ID, patch); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolDeleteCard(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct{ ID string }
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" {
|
|
return errMsg("id required")
|
|
}
|
|
if err := db.DeleteCard(in.ID); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
// toolMoveCard accepts {id, column_id, ordered_ids?}. If ordered_ids is missing,
|
|
// the card is appended to the end of the destination column.
|
|
func toolMoveCard(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
ID string `json:"id"`
|
|
ColumnID string `json:"column_id"`
|
|
OrderedIDs []string `json:"ordered_ids"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" || in.ColumnID == "" {
|
|
return errMsg("id and column_id required")
|
|
}
|
|
if len(in.OrderedIDs) == 0 {
|
|
cards, err := db.ListCardsWithTime()
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
var dest []Card
|
|
for _, c := range cards {
|
|
if c.ColumnID == in.ColumnID && c.ID != in.ID {
|
|
dest = append(dest, c)
|
|
}
|
|
}
|
|
sort.Slice(dest, func(i, j int) bool { return dest[i].Position < dest[j].Position })
|
|
ids := make([]string, 0, len(dest)+1)
|
|
for _, c := range dest {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
ids = append(ids, in.ID)
|
|
in.OrderedIDs = ids
|
|
}
|
|
if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs, ""); err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(nil)
|
|
}
|
|
|
|
func toolCardHistory(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct{ ID string }
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.ID == "" {
|
|
return errMsg("id required")
|
|
}
|
|
hist, err := db.CardHistory(in.ID)
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(hist)
|
|
}
|
|
|
|
func toolFindCards(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
Query string `json:"query"`
|
|
ColumnID string `json:"column_id"`
|
|
Requester string `json:"requester"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
cards, err := db.ListCardsWithTime()
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
q := strings.ToLower(strings.TrimSpace(in.Query))
|
|
col := in.ColumnID
|
|
req := strings.ToLower(strings.TrimSpace(in.Requester))
|
|
out := make([]Card, 0, len(cards))
|
|
for _, c := range cards {
|
|
if col != "" && c.ColumnID != col {
|
|
continue
|
|
}
|
|
if req != "" && !strings.Contains(strings.ToLower(c.Requester), req) {
|
|
continue
|
|
}
|
|
if q != "" {
|
|
hay := strings.ToLower(c.Title + " " + c.Description + " " + c.Requester)
|
|
if !strings.Contains(hay, q) {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return okResult(out)
|
|
}
|
|
|
|
// validateToolName fails fast with clearer error than the dispatch's default.
|
|
func validateToolName(name string) error {
|
|
known := map[string]bool{
|
|
"list_board": true, "create_column": true, "update_column": true, "rename_column": true,
|
|
"delete_column": true, "reorder_columns": true, "create_card": true,
|
|
"update_card": true, "delete_card": true, "move_card": true,
|
|
"card_history": true, "find_cards": true,
|
|
"list_users": true, "assign_card": true,
|
|
"add_comment": true, "list_comments": true,
|
|
}
|
|
if !known[name] {
|
|
return fmt.Errorf("unknown tool: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toolAddComment appends a comment (card_message) to a card. Accepts either
|
|
// {card_id, body, author_id} or {card_id, body, author_username}. Resolves
|
|
// the username to an id when needed.
|
|
func toolAddComment(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
CardID string `json:"card_id"`
|
|
Body string `json:"body"`
|
|
AuthorID string `json:"author_id"`
|
|
AuthorUsername string `json:"author_username"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.CardID == "" {
|
|
return errMsg("card_id required")
|
|
}
|
|
if strings.TrimSpace(in.Body) == "" {
|
|
return errMsg("body required")
|
|
}
|
|
authorID := strings.TrimSpace(in.AuthorID)
|
|
if authorID == "" {
|
|
if in.AuthorUsername == "" {
|
|
return errMsg("author_id or author_username required")
|
|
}
|
|
u, _, err := db.GetUserByUsername(in.AuthorUsername)
|
|
if err != nil {
|
|
return errResult(fmt.Errorf("author_username: %w", err))
|
|
}
|
|
authorID = u.ID
|
|
}
|
|
m, err := db.CreateCardMessage(in.CardID, authorID, in.Body)
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(m)
|
|
}
|
|
|
|
// toolListComments returns every comment (card_message) attached to a card
|
|
// sorted by created_at ascending.
|
|
func toolListComments(db *DB, input json.RawMessage) ToolResult {
|
|
var in struct {
|
|
CardID string `json:"card_id"`
|
|
}
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return errResult(err)
|
|
}
|
|
if in.CardID == "" {
|
|
return errMsg("card_id required")
|
|
}
|
|
msgs, err := db.ListCardMessages(in.CardID)
|
|
if err != nil {
|
|
return errResult(err)
|
|
}
|
|
return okResult(msgs)
|
|
}
|