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>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user