forgejo-mcp/operation/tracking/tracking.go
Byte Flavour 4969ee5881
feat: robust stateless auth with security fixes and improved tests
This commit addresses the review feedback on PR #138:
- Fixed a privilege escalation vulnerability where failed ephemeral client
  creation would silently fall back to the global singleton token.
- Refactored forgejo.Client(ctx) to return (*forgejo.Client, error).
- Updated all 100+ tool handlers to properly handle client initialization errors.
- Improved token extraction to handle schemes (token/bearer) case-insensitively.
- Enhanced Unit Tests (pkg/forgejo/forgejo_test.go) to verify that different
  concurrent requests with different tokens correctly use their respective
  tokens in the Authorization header.
- Updated README and demos to reflect case-insensitivity support.
2026-05-24 13:44:48 +02:00

410 lines
14 KiB
Go

package tracking
import (
"context"
"fmt"
"time"
"codeberg.org/goern/forgejo-mcp/v2/operation/params"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
"codeberg.org/goern/forgejo-mcp/v2/pkg/to"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListIssueTrackedTimesToolName = "list_issue_tracked_times"
ListRepoTrackedTimesToolName = "list_repo_tracked_times"
ListMyTrackedTimesToolName = "list_my_tracked_times"
AddIssueTimeToolName = "add_issue_time"
ResetIssueTimeToolName = "reset_issue_time"
DeleteIssueTimeEntryToolName = "delete_issue_time_entry"
StartIssueStopwatchToolName = "start_issue_stopwatch"
StopIssueStopwatchToolName = "stop_issue_stopwatch"
CancelIssueStopwatchToolName = "cancel_issue_stopwatch"
ListMyStopwatchesToolName = "list_my_stopwatches"
indexDescIssueOrPR = "Issue or pull request index (Forgejo shares index namespace between the two)"
)
var (
ListIssueTrackedTimesTool = mcp.NewTool(
ListIssueTrackedTimesToolName,
mcp.WithDescription("List tracked time entries on an issue or pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
mcp.WithString("since", mcp.Description(params.Since)),
mcp.WithString("before", mcp.Description(params.Before)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
)
ListRepoTrackedTimesTool = mcp.NewTool(
ListRepoTrackedTimesToolName,
mcp.WithDescription("List tracked time entries across all issues and PRs in a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("since", mcp.Description(params.Since)),
mcp.WithString("before", mcp.Description(params.Before)),
mcp.WithString("user", mcp.Description(params.TimeUserFilter)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
)
ListMyTrackedTimesTool = mcp.NewTool(
ListMyTrackedTimesToolName,
mcp.WithDescription("List tracked time entries for the authenticated user across all repositories"),
)
AddIssueTimeTool = mcp.NewTool(
AddIssueTimeToolName,
mcp.WithDescription("Log time against an issue or pull request. Provide exactly one of 'seconds' or 'duration'."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
mcp.WithNumber("seconds", mcp.Description(params.TimeSeconds)),
mcp.WithString("duration", mcp.Description(params.TimeDuration)),
mcp.WithString("created_at", mcp.Description(params.TimeCreatedAt)),
mcp.WithString("user_name", mcp.Description(params.TimeUserName)),
)
ResetIssueTimeTool = mcp.NewTool(
ResetIssueTimeToolName,
mcp.WithDescription("Delete ALL tracked time entries on an issue or pull request (including entries from other users). This is destructive and cannot be undone."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
)
DeleteIssueTimeEntryTool = mcp.NewTool(
DeleteIssueTimeEntryToolName,
mcp.WithDescription("Delete a single tracked time entry by its ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
mcp.WithNumber("time_id", mcp.Required(), mcp.Description(params.TimeID)),
)
StartIssueStopwatchTool = mcp.NewTool(
StartIssueStopwatchToolName,
mcp.WithDescription("Start a stopwatch on an issue or pull request. Only one stopwatch per issue; fails if one is already running."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
)
StopIssueStopwatchTool = mcp.NewTool(
StopIssueStopwatchToolName,
mcp.WithDescription("Stop a running stopwatch and record the elapsed time as a tracked time entry"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
)
CancelIssueStopwatchTool = mcp.NewTool(
CancelIssueStopwatchToolName,
mcp.WithDescription("Cancel a running stopwatch without recording a tracked time entry"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(indexDescIssueOrPR)),
)
ListMyStopwatchesTool = mcp.NewTool(
ListMyStopwatchesToolName,
mcp.WithDescription("List all currently running stopwatches for the authenticated user"),
)
)
func RegisterTool(s *server.MCPServer) {
s.AddTool(ListIssueTrackedTimesTool, ListIssueTrackedTimesFn)
s.AddTool(ListRepoTrackedTimesTool, ListRepoTrackedTimesFn)
s.AddTool(ListMyTrackedTimesTool, ListMyTrackedTimesFn)
s.AddTool(AddIssueTimeTool, AddIssueTimeFn)
s.AddTool(ResetIssueTimeTool, ResetIssueTimeFn)
s.AddTool(DeleteIssueTimeEntryTool, DeleteIssueTimeEntryFn)
s.AddTool(StartIssueStopwatchTool, StartIssueStopwatchFn)
s.AddTool(StopIssueStopwatchTool, StopIssueStopwatchFn)
s.AddTool(CancelIssueStopwatchTool, CancelIssueStopwatchFn)
s.AddTool(ListMyStopwatchesTool, ListMyStopwatchesFn)
}
func listOptions(args map[string]any) (forgejo_sdk.ListOptions, error) {
page, _ := to.Float64(args["page"])
if page == 0 {
page = 1
}
limit, _ := to.Float64(args["limit"])
if limit == 0 {
limit = 20
}
return forgejo_sdk.ListOptions{Page: int(page), PageSize: int(limit)}, nil
}
func parseTimeFilters(args map[string]any) (since, before time.Time, err error) {
if s, ok := args["since"].(string); ok && s != "" {
since, err = time.Parse(time.RFC3339, s)
if err != nil {
return since, before, fmt.Errorf("invalid since (expected RFC3339): %v", err)
}
}
if b, ok := args["before"].(string); ok && b != "" {
before, err = time.Parse(time.RFC3339, b)
if err != nil {
return since, before, fmt.Errorf("invalid before (expected RFC3339): %v", err)
}
}
return since, before, nil
}
func ListIssueTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssueTrackedTimesFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
lo, err := listOptions(args)
if err != nil {
return to.ErrorResult(err)
}
since, before, err := parseTimeFilters(args)
if err != nil {
return to.ErrorResult(err)
}
opt := forgejo_sdk.ListTrackedTimesOptions{
ListOptions: lo,
Since: since,
Before: before,
}
// user filter is ignored by the issue-level endpoint; see docs/plans/issue-time-tracking.md
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
times, _, err := client.ListIssueTrackedTimes(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list issue tracked times err: %v", err))
}
return to.TextResult(times)
}
func ListRepoTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoTrackedTimesFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
user, _ := args["user"].(string)
lo, err := listOptions(args)
if err != nil {
return to.ErrorResult(err)
}
since, before, err := parseTimeFilters(args)
if err != nil {
return to.ErrorResult(err)
}
opt := forgejo_sdk.ListTrackedTimesOptions{
ListOptions: lo,
Since: since,
Before: before,
User: user,
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
times, _, err := client.ListRepoTrackedTimes(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo tracked times err: %v", err))
}
return to.TextResult(times)
}
func ListMyTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyTrackedTimesFn")
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
times, _, err := client.GetMyTrackedTimes()
if err != nil {
return to.ErrorResult(fmt.Errorf("list my tracked times err: %v", err))
}
return to.TextResult(times)
}
// resolveSeconds picks the positive integer number of seconds to log from
// either the `seconds` or `duration` argument. Exactly one must be provided.
func resolveSeconds(args map[string]any) (int64, error) {
secsF, hasSecs := to.Float64Ok(args["seconds"])
durStr, _ := args["duration"].(string)
hasDur := durStr != ""
switch {
case hasSecs && hasDur:
return 0, fmt.Errorf("provide exactly one of 'seconds' or 'duration', not both")
case !hasSecs && !hasDur:
return 0, fmt.Errorf("one of 'seconds' or 'duration' is required")
case hasSecs:
if secsF <= 0 {
return 0, fmt.Errorf("seconds must be positive, got %v", secsF)
}
return int64(secsF), nil
default:
d, err := time.ParseDuration(durStr)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: %v", durStr, err)
}
if d <= 0 {
return 0, fmt.Errorf("duration must be positive, got %s", d)
}
return int64(d / time.Second), nil
}
}
func AddIssueTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called AddIssueTimeFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
seconds, err := resolveSeconds(args)
if err != nil {
return to.ErrorResult(err)
}
opt := forgejo_sdk.AddTimeOption{Time: seconds}
if u, ok := args["user_name"].(string); ok && u != "" {
opt.User = u
}
if ts, ok := args["created_at"].(string); ok && ts != "" {
parsed, err := time.Parse(time.RFC3339, ts)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid created_at (expected RFC3339): %v", err))
}
opt.Created = parsed
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
entry, _, err := client.AddTime(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add issue time err: %v", err))
}
return to.TextResult(entry)
}
func ResetIssueTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ResetIssueTimeFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
_, err = client.ResetIssueTime(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("reset issue time err: %v", err))
}
return to.TextResult("Reset tracked time success")
}
func DeleteIssueTimeEntryFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteIssueTimeEntryFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
timeID, _ := to.Float64(args["time_id"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
_, err = client.DeleteTime(owner, repo, int64(index), int64(timeID))
if err != nil {
return to.ErrorResult(fmt.Errorf("delete time entry err: %v", err))
}
return to.TextResult("Delete time entry success")
}
func StartIssueStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called StartIssueStopwatchFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
_, err = client.StartIssueStopWatch(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("start issue stopwatch err: %v", err))
}
return to.TextResult("Stopwatch started")
}
func StopIssueStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called StopIssueStopwatchFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
_, err = client.StopIssueStopWatch(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("stop issue stopwatch err: %v", err))
}
return to.TextResult("Stopwatch stopped; elapsed time recorded as a tracked time entry")
}
func CancelIssueStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CancelIssueStopwatchFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
_, err = client.DeleteIssueStopwatch(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel issue stopwatch err: %v", err))
}
return to.TextResult("Stopwatch cancelled")
}
func ListMyStopwatchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyStopwatchesFn")
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
watches, _, err := client.GetMyStopwatches()
if err != nil {
return to.ErrorResult(fmt.Errorf("list my stopwatches err: %v", err))
}
return to.TextResult(watches)
}