From 846bb31dc993799d2bb9712b8cc773715bfcaa68 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 1 Nov 2026 16:56:11 +0100 Subject: [PATCH] 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 --- .cursor/mcp.json | 8 ++ .gitignore | 3 + AGENTS.md | 30 ++++ README.md | 18 ++- cmd/task-master/main.go | 27 +++- go.mod | 3 + go.sum | 24 ++++ internal/mcp/server.go | 278 +++++++++++++++++++++++++++++++++++++ internal/mcp/token.go | 79 +++++++++++ internal/mcp/token_test.go | 43 ++++++ 10 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 AGENTS.md create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/token.go create mode 100644 internal/mcp/token_test.go diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..18f196f --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tm": { + "command": "${workspaceFolder}/bin/tm", + "args": ["mcp"] + } + } +} diff --git a/.gitignore b/.gitignore index 55f5550..a715641 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /bin/ .dev-home/ +.tm_token +**/.tm_token + *.exe *.test *.out diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f9b7851 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Agent notes — task-master + +## Tickets über MCP + +Tickets lesen und bearbeiten nur über den lokalen MCP-Server von task-master — nicht über JSON-Dateien oder die TUI. + +### Setup + +1. Binary: `./bin/tm` (oder `tm` im PATH) +2. Projekt-MCP: [`.cursor/mcp.json`](.cursor/mcp.json) (`tm` → `args: ["mcp"]`) +3. Im Repo (oder darüber) `.tm_token` mit dem Projekt, für das die TUI ein Token erzeugt hat (`p` → Projekt wählen → `g`): + +``` +project= +token=tm_mcp_… +``` + +`.tm_token` nicht committen. + +### Verhalten + +- Ohne gültiges `.tm_token` darf der MCP-Server den Daemon nicht nutzen. +- Mit Token sind alle Tools auf genau dieses eine TM-Projekt beschränkt. +- TUI/`tm sync` brauchen kein Token; die KI schon. + +### Typische Tools + +`list_projects`, `list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, `move_ticket`, `delete_ticket`, `list_time`, `add_time`, `undo`, `redo`, `sync_pull`, `sync_push` + +Mehr Details: [README.md](README.md) (Abschnitt MCP). diff --git a/README.md b/README.md index bc2e12c..5a3611c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,23 @@ tm help `tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`). -MCP-Token: in der Projektliste `g` erzeugt ein Token (Klartext einmal in der Statuszeile); `G` widerruft. Speichere es als `.tm_token` im Repo für den MCP-Server (`tm mcp`, folgt / siehe Help). +### MCP (`tm mcp`) + +1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft. +2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**: + +``` +project=inbox +token=tm_mcp_… +``` + +3. Cursor / MCP-Client: + +```json +{ "command": "tm", "args": ["mcp"] } +``` + +Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt. ### Web-Server (`tm_server`) diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index 32ed0d1..1f80175 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -12,6 +12,7 @@ import ( "task/internal/daemon" "task/internal/daemon/api" "task/internal/daemon/client" + mcpserver "task/internal/mcp" "task/internal/ui" "task/internal/version" ) @@ -215,7 +216,29 @@ func runSync(args []string) error { } func runMCP() error { - return fmt.Errorf("mcp server not implemented yet — coming in phase 2") + cwd, err := os.Getwd() + if err != nil { + return err + } + tf, err := mcpserver.FindTokenFile(cwd) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + paths, err := daemon.Ensure(cfg) + if err != nil { + return fmt.Errorf("daemon: %w", err) + } + cli := client.Dial(client.Options{ + Socket: paths.Socket, + Kind: api.ClientMCP, + Token: tf.Token, + Project: tf.Project, + }) + return mcpserver.ServeStdio(cli, tf) } func printHelp() { @@ -238,7 +261,7 @@ Env: The TUI talks to a per-DataDir background daemon over a Unix socket so multiple instances stay consistent. Create an MCP token from the project list (key g); -place it in a repo as .tm_token for AI access. +place project= and token= in a repo .tm_token file, then run: tm mcp `, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName) } diff --git a/go.mod b/go.mod index 737e1f6..0239456 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/google/uuid v1.6.0 + github.com/mark3labs/mcp-go v0.32.0 github.com/muesli/termenv v0.16.0 golang.org/x/crypto v0.58.0 modernc.org/sqlite v1.61.0 @@ -42,7 +43,9 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.60.0 // indirect diff --git a/go.sum b/go.sum index b9c73c3..414ac02 100644 --- a/go.sum +++ b/go.sum @@ -38,12 +38,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -54,10 +60,16 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= +github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -77,16 +89,26 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= @@ -110,6 +132,8 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys= modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4= diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..c64f792 --- /dev/null +++ b/internal/mcp/server.go @@ -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) + }) +} diff --git a/internal/mcp/token.go b/internal/mcp/token.go new file mode 100644 index 0000000..39c0995 --- /dev/null +++ b/internal/mcp/token.go @@ -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 +} diff --git a/internal/mcp/token_test.go b/internal/mcp/token_test.go new file mode 100644 index 0000000..127c018 --- /dev/null +++ b/internal/mcp/token_test.go @@ -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") + } +}