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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tokenFileName = ".tm_token"
|
||||
|
||||
// TokenFile holds credentials from a workspace .tm_token file.
|
||||
type TokenFile struct {
|
||||
Project string
|
||||
Token string
|
||||
Path string
|
||||
}
|
||||
|
||||
// FindTokenFile walks from start (usually cwd) upward looking for .tm_token.
|
||||
func FindTokenFile(start string) (TokenFile, error) {
|
||||
dir, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
for {
|
||||
path := filepath.Join(dir, tokenFileName)
|
||||
if st, err := os.Stat(path); err == nil && !st.IsDir() {
|
||||
tf, err := ParseTokenFile(path)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return TokenFile{}, fmt.Errorf("no %s found (create an MCP token in the TUI with g, then place project=… and token=… in %s)", tokenFileName, tokenFileName)
|
||||
}
|
||||
|
||||
// ParseTokenFile reads project= and token= lines from path.
|
||||
func ParseTokenFile(path string) (TokenFile, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
tf := TokenFile{Path: path}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "project":
|
||||
tf.Project = val
|
||||
case "token":
|
||||
tf.Token = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
if tf.Project == "" || tf.Token == "" {
|
||||
return TokenFile{}, fmt.Errorf("%s must contain project= and token= lines", path)
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAndFindTokenFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "a", "b")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, ".tm_token")
|
||||
content := "# comment\nproject=inbox\ntoken=tm_mcp_abc\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tf, err := FindTokenFile(sub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tf.Project != "inbox" || tf.Token != "tm_mcp_abc" {
|
||||
t.Fatalf("got %+v", tf)
|
||||
}
|
||||
|
||||
_, err = FindTokenFile(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing token error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenFileIncomplete(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".tm_token")
|
||||
if err := os.WriteFile(path, []byte("project=only\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseTokenFile(path); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user