Expose tm mcp tools against the local daemon, document agent setup, and ignore workspace .tm_token files. Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
7.9 KiB
Go
279 lines
7.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"task/internal/daemon/client"
|
|
"task/internal/ticket"
|
|
)
|
|
|
|
// ServeStdio runs the MCP server on stdin/stdout using auth from .tm_token.
|
|
func ServeStdio(cli *client.Client, tf TokenFile) error {
|
|
s := server.NewMCPServer(
|
|
"task-master",
|
|
"1.0.0",
|
|
server.WithToolCapabilities(true),
|
|
)
|
|
registerTools(s, cli, tf)
|
|
return server.ServeStdio(s)
|
|
}
|
|
|
|
func registerTools(s *server.MCPServer, cli *client.Client, tf TokenFile) {
|
|
jsonResult := func(v any) (*mcp.CallToolResult, error) {
|
|
b, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
return mcp.NewToolResultText(string(b)), nil
|
|
}
|
|
fail := func(err error) (*mcp.CallToolResult, error) {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
s.AddTool(mcp.NewTool("list_projects",
|
|
mcp.WithDescription("List the TM project bound to .tm_token"),
|
|
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
list, err := cli.ProjectStore().List()
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(list)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("list_tickets",
|
|
mcp.WithDescription("List tickets in the token project"),
|
|
mcp.WithString("status", mcp.Description("Optional status filter: todo, in_progress, done")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
list, err := cli.TicketStore().List(tf.Project)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
status := req.GetString("status", "")
|
|
if status != "" {
|
|
filtered := list[:0]
|
|
for _, t := range list {
|
|
if string(t.Status) == status {
|
|
filtered = append(filtered, t)
|
|
}
|
|
}
|
|
list = filtered
|
|
}
|
|
return jsonResult(list)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("get_ticket",
|
|
mcp.WithDescription("Get one ticket by id"),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
id, err := req.RequireString("id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
t, err := cli.TicketStore().Get(id)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(t)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("create_ticket",
|
|
mcp.WithDescription("Create a ticket in the token project"),
|
|
mcp.WithString("title", mcp.Required(), mcp.Description("Ticket title")),
|
|
mcp.WithString("description", mcp.Description("Markdown body")),
|
|
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
title, err := req.RequireString("title")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
st := ticket.Status(req.GetString("status", string(ticket.StatusTodo)))
|
|
t := ticket.Ticket{
|
|
Title: title,
|
|
Description: req.GetString("description", ""),
|
|
Status: st,
|
|
ProjectID: tf.Project,
|
|
}
|
|
created, err := cli.TicketStore().Create(t)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(created)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("update_ticket",
|
|
mcp.WithDescription("Update ticket title, description, and/or status"),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
|
|
mcp.WithString("title", mcp.Description("New title")),
|
|
mcp.WithString("description", mcp.Description("New description")),
|
|
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
id, err := req.RequireString("id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
before, err := cli.TicketStore().Get(id)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
after := before
|
|
if v := req.GetString("title", ""); v != "" {
|
|
after.Title = v
|
|
}
|
|
if _, ok := req.GetArguments()["description"]; ok {
|
|
after.Description = req.GetString("description", "")
|
|
}
|
|
if v := req.GetString("status", ""); v != "" {
|
|
after.Status = ticket.Status(v)
|
|
}
|
|
if err := cli.TicketStore().Update(after); err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(after)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("move_ticket",
|
|
mcp.WithDescription("Move a ticket (MCP only allows same project id)"),
|
|
mcp.WithString("id", mcp.Required()),
|
|
mcp.WithString("to_project_id", mcp.Required()),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
id, err := req.RequireString("id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
to, err := req.RequireString("to_project_id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
moved, err := cli.TicketStore().Move(id, to)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(moved)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("delete_ticket",
|
|
mcp.WithDescription("Delete a ticket by id"),
|
|
mcp.WithString("id", mcp.Required()),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
id, err := req.RequireString("id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
if err := cli.TicketStore().Delete(id); err != nil {
|
|
return fail(err)
|
|
}
|
|
return mcp.NewToolResultText("deleted"), nil
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("list_time",
|
|
mcp.WithDescription("List time entries for the token project"),
|
|
mcp.WithString("ticket_id", mcp.Description("Optional ticket filter")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
ticketID := req.GetString("ticket_id", "")
|
|
var list []ticket.TimeEntry
|
|
var err error
|
|
if ticketID != "" {
|
|
list, err = cli.TimeStore().ListByTicket(ticketID)
|
|
} else {
|
|
list, err = cli.TimeStore().List()
|
|
}
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(list)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("add_time",
|
|
mcp.WithDescription("Add a manual time entry"),
|
|
mcp.WithString("ticket_id", mcp.Required()),
|
|
mcp.WithNumber("minutes", mcp.Required(), mcp.Description("Duration in minutes")),
|
|
mcp.WithString("note", mcp.Description("Optional note")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
ticketID, err := req.RequireString("ticket_id")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
mins, err := req.RequireFloat("minutes")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
tk, err := cli.TicketStore().Get(ticketID)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
dur := time.Duration(mins * float64(time.Minute))
|
|
end := time.Now()
|
|
start := end.Add(-dur)
|
|
entry := ticket.TimeEntry{
|
|
TicketID: ticketID,
|
|
ProjectID: tk.ProjectID,
|
|
Start: start,
|
|
End: end,
|
|
Duration: dur,
|
|
Source: ticket.SourceManual,
|
|
Note: req.GetString("note", ""),
|
|
}
|
|
created, err := cli.TimeStore().Create(entry)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(created)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("undo",
|
|
mcp.WithDescription("Undo the last daemon command"),
|
|
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
if err := cli.Executor().Undo(); err != nil {
|
|
return fail(err)
|
|
}
|
|
return mcp.NewToolResultText("ok"), nil
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("redo",
|
|
mcp.WithDescription("Redo the last undone daemon command"),
|
|
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
if err := cli.Executor().Redo(); err != nil {
|
|
return fail(err)
|
|
}
|
|
return mcp.NewToolResultText("ok"), nil
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("sync_pull",
|
|
mcp.WithDescription("Pull tickets/time from the configured backend for the token project"),
|
|
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
res, err := cli.SyncPull(tf.Project)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(res)
|
|
})
|
|
|
|
s.AddTool(mcp.NewTool("sync_push",
|
|
mcp.WithDescription("Push tickets/time to the configured backend for the token project"),
|
|
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
_ = ctx
|
|
res, err := cli.SyncPush(tf.Project)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return jsonResult(res)
|
|
})
|
|
}
|