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.
377 lines
13 KiB
Go
377 lines
13 KiB
Go
package user
|
|
|
|
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"
|
|
)
|
|
|
|
const (
|
|
CheckNotificationsToolName = "check_notifications"
|
|
GetNotificationThreadToolName = "get_notification_thread"
|
|
MarkNotificationReadToolName = "mark_notification_read"
|
|
MarkAllNotificationsReadToolName = "mark_all_notifications_read"
|
|
ListRepoNotificationsToolName = "list_repo_notifications"
|
|
MarkRepoNotificationsReadToolName = "mark_repo_notifications_read"
|
|
)
|
|
|
|
var (
|
|
CheckNotificationsTool = mcp.NewTool(
|
|
CheckNotificationsToolName,
|
|
mcp.WithDescription("Check and list user notifications"),
|
|
mcp.WithBoolean("all", mcp.Description("Include read notifications (default: false)")),
|
|
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)),
|
|
)
|
|
|
|
GetNotificationThreadTool = mcp.NewTool(
|
|
GetNotificationThreadToolName,
|
|
mcp.WithDescription("Get detailed info on a single notification thread"),
|
|
mcp.WithNumber("id", mcp.Description("Notification ID"), mcp.Required()),
|
|
)
|
|
|
|
MarkNotificationReadTool = mcp.NewTool(
|
|
MarkNotificationReadToolName,
|
|
mcp.WithDescription("Mark a single notification thread as read"),
|
|
mcp.WithNumber("id", mcp.Description("Notification ID"), mcp.Required()),
|
|
)
|
|
|
|
MarkAllNotificationsReadTool = mcp.NewTool(
|
|
MarkAllNotificationsReadToolName,
|
|
mcp.WithDescription("Acknowledge all notifications"),
|
|
mcp.WithString("last_read_at", mcp.Description("Optional RFC3339 time")),
|
|
)
|
|
|
|
ListRepoNotificationsTool = mcp.NewTool(
|
|
ListRepoNotificationsToolName,
|
|
mcp.WithDescription("Filter notifications scoped to a single repository"),
|
|
mcp.WithString("owner", mcp.Description("Repository owner"), mcp.Required()),
|
|
mcp.WithString("repo", mcp.Description("Repository name"), mcp.Required()),
|
|
mcp.WithBoolean("all", mcp.Description("Include read notifications (default: false)")),
|
|
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)),
|
|
)
|
|
|
|
MarkRepoNotificationsReadTool = mcp.NewTool(
|
|
MarkRepoNotificationsReadToolName,
|
|
mcp.WithDescription("Mark all notifications in a specific repo as read"),
|
|
mcp.WithString("owner", mcp.Description("Repository owner"), mcp.Required()),
|
|
mcp.WithString("repo", mcp.Description("Repository name"), mcp.Required()),
|
|
mcp.WithString("last_read_at", mcp.Description("Optional RFC3339 time")),
|
|
)
|
|
)
|
|
|
|
func CheckNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, CheckNotificationsToolName)
|
|
start := time.Now()
|
|
|
|
log.LogMCPToolStart(ctx, CheckNotificationsToolName, req.GetArguments())
|
|
|
|
// Parse arguments
|
|
all, ok := req.GetArguments()["all"].(bool)
|
|
if !ok {
|
|
all = false
|
|
}
|
|
sinceStr, _ := req.GetArguments()["since"].(string)
|
|
beforeStr, _ := req.GetArguments()["before"].(string)
|
|
page, _ := to.Float64(req.GetArguments()["page"])
|
|
if !ok {
|
|
page = 1
|
|
}
|
|
limit, _ := to.Float64(req.GetArguments()["limit"])
|
|
if !ok {
|
|
limit = 20
|
|
}
|
|
|
|
opt := forgejo_sdk.ListNotificationOptions{
|
|
ListOptions: forgejo_sdk.ListOptions{
|
|
Page: int(page),
|
|
PageSize: int(limit),
|
|
},
|
|
}
|
|
|
|
if !all {
|
|
opt.Status = []forgejo_sdk.NotifyStatus{forgejo_sdk.NotifyStatusUnread}
|
|
}
|
|
|
|
if sinceStr != "" {
|
|
t, err := time.Parse(time.RFC3339, sinceStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, CheckNotificationsToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid since time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.Since = t
|
|
}
|
|
|
|
if beforeStr != "" {
|
|
t, err := time.Parse(time.RFC3339, beforeStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, CheckNotificationsToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid before time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.Before = t
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
notifications, resp, err := client.ListNotifications(opt)
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "GET", "/notifications", duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, CheckNotificationsToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, CheckNotificationsToolName, duration, fmt.Sprintf("Retrieved %d notifications", len(notifications)))
|
|
|
|
return to.TextResult(notifications)
|
|
}
|
|
|
|
func GetNotificationThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, GetNotificationThreadToolName)
|
|
start := time.Now()
|
|
log.LogMCPToolStart(ctx, GetNotificationThreadToolName, req.GetArguments())
|
|
|
|
id, err := to.Float64(req.GetArguments()["id"])
|
|
if err != nil {
|
|
err := fmt.Errorf("id is required and must be a number")
|
|
log.LogMCPToolError(ctx, GetNotificationThreadToolName, time.Since(start), err)
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
thread, resp, err := client.GetNotification(int64(id))
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "GET", fmt.Sprintf("/notifications/threads/%d", int64(id)), duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, GetNotificationThreadToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("get notification thread err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, GetNotificationThreadToolName, duration, "Retrieved notification thread")
|
|
return to.TextResult(thread)
|
|
}
|
|
|
|
func MarkNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, MarkNotificationReadToolName)
|
|
start := time.Now()
|
|
log.LogMCPToolStart(ctx, MarkNotificationReadToolName, req.GetArguments())
|
|
|
|
id, err := to.Float64(req.GetArguments()["id"])
|
|
if err != nil {
|
|
err := fmt.Errorf("id is required and must be a number")
|
|
log.LogMCPToolError(ctx, MarkNotificationReadToolName, time.Since(start), err)
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
thread, resp, err := client.ReadNotification(int64(id))
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "PATCH", fmt.Sprintf("/notifications/threads/%d", int64(id)), duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, MarkNotificationReadToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("mark notification read err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, MarkNotificationReadToolName, duration, "Marked notification as read")
|
|
return to.TextResult(thread)
|
|
}
|
|
|
|
func MarkAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, MarkAllNotificationsReadToolName)
|
|
start := time.Now()
|
|
log.LogMCPToolStart(ctx, MarkAllNotificationsReadToolName, req.GetArguments())
|
|
|
|
opt := forgejo_sdk.MarkNotificationOptions{}
|
|
if lastReadAtStr, ok := req.GetArguments()["last_read_at"].(string); ok && lastReadAtStr != "" {
|
|
t, err := time.Parse(time.RFC3339, lastReadAtStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, MarkAllNotificationsReadToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid last_read_at time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.LastReadAt = t
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
_, resp, err := client.ReadNotifications(opt)
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "PUT", "/notifications", duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, MarkAllNotificationsReadToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, MarkAllNotificationsReadToolName, duration, "Marked all notifications as read")
|
|
return to.TextResult("All notifications marked as read")
|
|
}
|
|
|
|
func ListRepoNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, ListRepoNotificationsToolName)
|
|
start := time.Now()
|
|
log.LogMCPToolStart(ctx, ListRepoNotificationsToolName, req.GetArguments())
|
|
|
|
owner, ok1 := req.GetArguments()["owner"].(string)
|
|
repo, ok2 := req.GetArguments()["repo"].(string)
|
|
if !ok1 || !ok2 {
|
|
err := fmt.Errorf("owner and repo are required")
|
|
log.LogMCPToolError(ctx, ListRepoNotificationsToolName, time.Since(start), err)
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
all, ok := req.GetArguments()["all"].(bool)
|
|
if !ok {
|
|
all = false
|
|
}
|
|
sinceStr, _ := req.GetArguments()["since"].(string)
|
|
beforeStr, _ := req.GetArguments()["before"].(string)
|
|
page, _ := to.Float64(req.GetArguments()["page"])
|
|
if !ok {
|
|
page = 1
|
|
}
|
|
limit, _ := to.Float64(req.GetArguments()["limit"])
|
|
if !ok {
|
|
limit = 20
|
|
}
|
|
|
|
opt := forgejo_sdk.ListNotificationOptions{
|
|
ListOptions: forgejo_sdk.ListOptions{
|
|
Page: int(page),
|
|
PageSize: int(limit),
|
|
},
|
|
}
|
|
|
|
if !all {
|
|
opt.Status = []forgejo_sdk.NotifyStatus{forgejo_sdk.NotifyStatusUnread}
|
|
}
|
|
|
|
if sinceStr != "" {
|
|
t, err := time.Parse(time.RFC3339, sinceStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, ListRepoNotificationsToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid since time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.Since = t
|
|
}
|
|
|
|
if beforeStr != "" {
|
|
t, err := time.Parse(time.RFC3339, beforeStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, ListRepoNotificationsToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid before time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.Before = t
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
notifications, resp, err := client.ListRepoNotifications(owner, repo, opt)
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "GET", fmt.Sprintf("/repos/%s/%s/notifications", owner, repo), duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, ListRepoNotificationsToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("list repo notifications err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, ListRepoNotificationsToolName, duration, fmt.Sprintf("Retrieved %d notifications for repo %s/%s", len(notifications), owner, repo))
|
|
return to.TextResult(notifications)
|
|
}
|
|
|
|
func MarkRepoNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
ctx, _ = log.WithMCPContext(ctx, MarkRepoNotificationsReadToolName)
|
|
start := time.Now()
|
|
log.LogMCPToolStart(ctx, MarkRepoNotificationsReadToolName, req.GetArguments())
|
|
|
|
owner, ok1 := req.GetArguments()["owner"].(string)
|
|
repo, ok2 := req.GetArguments()["repo"].(string)
|
|
if !ok1 || !ok2 {
|
|
err := fmt.Errorf("owner and repo are required")
|
|
log.LogMCPToolError(ctx, MarkRepoNotificationsReadToolName, time.Since(start), err)
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
opt := forgejo_sdk.MarkNotificationOptions{}
|
|
if lastReadAtStr, ok := req.GetArguments()["last_read_at"].(string); ok && lastReadAtStr != "" {
|
|
t, err := time.Parse(time.RFC3339, lastReadAtStr)
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, MarkRepoNotificationsReadToolName, time.Since(start), err)
|
|
return to.ErrorResult(fmt.Errorf("invalid last_read_at time format (expected RFC3339): %v", err))
|
|
}
|
|
opt.LastReadAt = t
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
_, resp, err := client.ReadRepoNotifications(owner, repo, opt)
|
|
duration := time.Since(start)
|
|
|
|
statusCode := 0
|
|
if resp != nil {
|
|
statusCode = resp.StatusCode
|
|
}
|
|
forgejo.LogAPICall(ctx, "PUT", fmt.Sprintf("/repos/%s/%s/notifications", owner, repo), duration, statusCode, err)
|
|
|
|
if err != nil {
|
|
log.LogMCPToolError(ctx, MarkRepoNotificationsReadToolName, duration, err)
|
|
return to.ErrorResult(fmt.Errorf("mark repo notifications read err: %v", err))
|
|
}
|
|
|
|
log.LogMCPToolComplete(ctx, MarkRepoNotificationsReadToolName, duration, fmt.Sprintf("Marked all notifications for repo %s/%s as read", owner, repo))
|
|
return to.TextResult("Repo notifications marked as read")
|
|
}
|