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) }) }