Add stdio MCP server for token-scoped ticket access.
CI / test-and-build (push) Failing after 1m25s
CI / demo (push) Skipped

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:
2026-11-01 16:56:11 +01:00
co-authored by Cursor
parent 2dbf0918de
commit 846bb31dc9
10 changed files with 510 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"tm": {
"command": "${workspaceFolder}/bin/tm",
"args": ["mcp"]
}
}
}
+3
View File
@@ -1,5 +1,8 @@
/bin/
.dev-home/
.tm_token
**/.tm_token
*.exe
*.test
*.out
+30
View File
@@ -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=<projekt-id>
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).
+17 -1
View File
@@ -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-<hash>/` (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`)
+25 -2
View File
@@ -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)
}
+3
View File
@@ -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
+24
View File
@@ -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=
+278
View File
@@ -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)
})
}
+79
View File
@@ -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
}
+43
View File
@@ -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")
}
}