Files
skrueckenandCursor 846bb31dc9
CI / test-and-build (push) Failing after 1m25s
CI / demo (push) Skipped
Add stdio MCP server for token-scoped ticket access.
Expose tm mcp tools against the local daemon, document agent setup, and
ignore workspace .tm_token files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:56:11 +01:00

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