Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
package context
type contextKey string
const (
TokenContextKey = contextKey("token")
)
+532
View File
@@ -0,0 +1,532 @@
// Package errors provides error translation and enhancement for Gitea SDK errors.
// It maps cryptic SDK error messages to human-readable descriptions and adds
// context about the operation being performed.
package errors
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// ErrorCategory represents the category of an error for easier handling.
type ErrorCategory string
const (
// CategoryFile represents file/directory related errors.
CategoryFile ErrorCategory = "file"
// CategoryAuth represents authentication/authorization errors.
CategoryAuth ErrorCategory = "auth"
// CategoryRepo represents repository related errors.
CategoryRepo ErrorCategory = "repo"
// CategoryIssue represents issue related errors.
CategoryIssue ErrorCategory = "issue"
// CategoryPull represents pull request related errors.
CategoryPull ErrorCategory = "pull"
// CategoryBranch represents branch/tag related errors.
CategoryBranch ErrorCategory = "branch"
// CategoryActions represents Actions CI/CD related errors.
CategoryActions ErrorCategory = "actions"
// CategoryNetwork represents network/timeout related errors.
CategoryNetwork ErrorCategory = "network"
// CategoryUnknown represents unknown/uncategorized errors.
CategoryUnknown ErrorCategory = "unknown"
)
// EnhancedError wraps an error with a human-readable translation and context.
type EnhancedError struct {
// Original is the underlying error from the SDK or API.
Original error
// Translated is the human-readable error message.
Translated string
// Category helps identify the type of error for programmatic handling.
Category ErrorCategory
// Operation is the name of the operation that failed (e.g., "GetFile").
Operation string
// Context contains additional contextual information (e.g., parameters).
Context map[string]string
// Timestamp is when the error was created.
Timestamp time.Time
}
// Error returns the human-readable translated error message.
func (e *EnhancedError) Error() string {
if e.Translated != "" {
return e.Translated
}
if e.Original != nil {
return e.Original.Error()
}
return "unknown error"
}
// Unwrap returns the original error for error chain inspection.
func (e *EnhancedError) Unwrap() error {
return e.Original
}
// WithContext adds context information to the error and returns a new EnhancedError.
func (e *EnhancedError) WithContext(key, value string) *EnhancedError {
if e.Context == nil {
e.Context = make(map[string]string)
}
e.Context[key] = value
return e
}
// WithOperation sets the operation name for the error and returns the error for chaining.
// This is a fluent API method for building error context.
//
// Example:
//
// err := errors.TranslateError(sdkErr, nil).
// WithOperation("GetFile").
// WithParam("owner", "gitea").
// WithParam("repo", "tea")
func (e *EnhancedError) WithOperation(op string) *EnhancedError {
e.Operation = op
return e
}
// WithParam adds a single context parameter to the error and returns the error for chaining.
// This is a fluent API method for building error context one parameter at a time.
//
// Example:
//
// err := errors.TranslateError(sdkErr, nil).
// WithOperation("GetFile").
// WithParam("owner", "gitea").
// WithParam("path", "README.md")
func (e *EnhancedError) WithParam(key, value string) *EnhancedError {
return e.WithContext(key, value)
}
// FormatDetailed returns a JSON-like structured representation of the error.
// This format is suitable for logging and debugging, providing all error details
// in a machine-readable format.
//
// Example output:
//
// {
// "error": "File or directory not found",
// "category": "file",
// "operation": "GetFile",
// "timestamp": "2024-01-15T10:30:00Z",
// "context": {
// "owner": "gitea",
// "repo": "tea",
// "path": "README.md"
// },
// "original": "GetContents failed with status 404"
// }
func (e *EnhancedError) FormatDetailed() string {
details := map[string]any{
"error": e.Error(),
"category": e.Category,
"timestamp": e.Timestamp.Format(time.RFC3339),
}
if e.Operation != "" {
details["operation"] = e.Operation
}
if len(e.Context) > 0 {
details["context"] = e.Context
}
if e.Original != nil && e.Original.Error() != e.Error() {
details["original"] = e.Original.Error()
}
jsonBytes, err := json.MarshalIndent(details, "", " ")
if err != nil {
// Fallback to simple format if JSON marshaling fails
return e.Format()
}
return string(jsonBytes)
}
// Format returns a detailed error message including context.
func (e *EnhancedError) Format() string {
var parts []string
if e.Operation != "" {
parts = append(parts, fmt.Sprintf("Operation: %s", e.Operation))
}
parts = append(parts, fmt.Sprintf("Error: %s", e.Error()))
if e.Category != "" && e.Category != CategoryUnknown {
parts = append(parts, fmt.Sprintf("Category: %s", e.Category))
}
if len(e.Context) > 0 {
var ctxParts []string
for k, v := range e.Context {
ctxParts = append(ctxParts, fmt.Sprintf("%s=%s", k, v))
}
parts = append(parts, fmt.Sprintf("Context: %s", strings.Join(ctxParts, ", ")))
}
if e.Original != nil && e.Original.Error() != e.Error() {
parts = append(parts, fmt.Sprintf("Original: %s", e.Original.Error()))
}
return strings.Join(parts, " | ")
}
// TranslateError translates a Gitea SDK error to a human-readable error
// with context enhancement. The context map can contain operation name,
// parameters, or any other relevant information.
//
// Example:
//
// err := someGiteaOperation()
// if err != nil {
// return TranslateError(err, map[string]string{
// "operation": "GetFile",
// "owner": "gitea",
// "repo": "tea",
// "path": "README.md",
// })
// }
func TranslateError(err error, context map[string]string) error {
if err == nil {
return nil
}
// If already an EnhancedError, just add context
var existing *EnhancedError
if errors.As(err, &existing) {
if context != nil {
for k, v := range context {
existing.WithContext(k, v)
}
}
return existing
}
// Determine translation based on error content
translated, category := translateErrorMessage(err)
operation := ""
if context != nil {
operation = context["operation"]
}
enhanced := &EnhancedError{
Original: err,
Translated: translated,
Category: category,
Operation: operation,
Context: context,
Timestamp: time.Now().UTC(),
}
return enhanced
}
// translateErrorMessage maps SDK error strings to human-readable messages.
func translateErrorMessage(err error) (string, ErrorCategory) {
if err == nil {
return "", CategoryUnknown
}
msg := err.Error()
lowerMsg := strings.ToLower(msg)
// HTTP status code based translations (for HTTPError)
if strings.Contains(msg, "status 404") || strings.Contains(msg, "404") {
// Check for specific API operations in the error message
if strings.Contains(lowerMsg, "getcontents") || strings.Contains(lowerMsg, "listcontents") {
return "File or directory not found", CategoryFile
}
if strings.Contains(lowerMsg, "getuser") || strings.Contains(lowerMsg, "getuserbyname") {
return "User or organization not found", CategoryAuth
}
if strings.Contains(lowerMsg, "getrepo") {
return "Repository not found", CategoryRepo
}
if strings.Contains(lowerMsg, "getissue") {
return "Issue not found", CategoryIssue
}
if strings.Contains(lowerMsg, "getpullrequest") || strings.Contains(lowerMsg, "getpull") {
return "Pull request not found", CategoryPull
}
if strings.Contains(lowerMsg, "getbranch") || strings.Contains(lowerMsg, "gettag") {
return "Branch or tag not found", CategoryBranch
}
return "Resource not found", CategoryUnknown
}
if strings.Contains(msg, "status 401") || strings.Contains(msg, "401") {
return "Authentication failed - check your access token", CategoryAuth
}
if strings.Contains(msg, "status 403") || strings.Contains(msg, "403") {
return "Permission denied - you don't have access to this resource", CategoryAuth
}
// SDK method name based translations
translations := []struct {
pattern string
message string
category ErrorCategory
}{
{"GetContents", "File or directory not found", CategoryFile},
{"GetContentsOrList", "File or directory not found", CategoryFile},
{"GetUserByName", "User or organization not found", CategoryAuth},
{"GetUser", "User or organization not found", CategoryAuth},
{"GetRepo", "Repository not found", CategoryRepo},
{"GetIssue", "Issue not found", CategoryIssue},
{"GetPullRequest", "Pull request not found", CategoryPull},
{"GetBranch", "Branch not found", CategoryBranch},
{"GetTag", "Tag not found", CategoryBranch},
{"ListContents", "Directory not found or empty", CategoryFile},
{"CreateFile", "Failed to create file - it may already exist", CategoryFile},
{"UpdateFile", "Failed to update file - it may not exist or SHA mismatch", CategoryFile},
{"DeleteFile", "Failed to delete file - it may not exist", CategoryFile},
{"CreateBranch", "Failed to create branch", CategoryBranch},
{"DeleteBranch", "Failed to delete branch - it may not exist or be protected", CategoryBranch},
{"CreateIssue", "Failed to create issue", CategoryIssue},
{"EditIssue", "Failed to update issue - it may not exist", CategoryIssue},
{"CreatePullRequest", "Failed to create pull request", CategoryPull},
{"EditPullRequest", "Failed to update pull request", CategoryPull},
{"CreateRelease", "Failed to create release", CategoryRepo},
{"EditRelease", "Failed to update release", CategoryRepo},
{"CreateWikiPage", "Failed to create wiki page", CategoryRepo},
{"EditWikiPage", "Failed to update wiki page", CategoryRepo},
{"AddCollaborator", "Failed to add collaborator", CategoryAuth},
{"RemoveCollaborator", "Failed to remove collaborator", CategoryAuth},
{"CreateDeployKey", "Failed to create deploy key", CategoryAuth},
{"DeleteDeployKey", "Failed to delete deploy key", CategoryAuth},
}
for _, t := range translations {
if strings.Contains(msg, t.pattern) {
return t.message, t.category
}
}
// Timeout and network errors
if strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "deadline exceeded") {
return "Request timed out - the server took too long to respond", CategoryNetwork
}
if strings.Contains(lowerMsg, "connection refused") || strings.Contains(lowerMsg, "no such host") {
return "Network error - cannot connect to server", CategoryNetwork
}
// Default: return original message with unknown category
return msg, CategoryUnknown
}
// IsNotFound checks if an error is a "not found" type error.
// It works with EnhancedError and HTTPError types.
func IsNotFound(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
switch enhanced.Category {
case CategoryFile, CategoryRepo, CategoryIssue, CategoryPull, CategoryBranch:
return true
}
return strings.Contains(enhanced.Translated, "not found")
}
// Check for HTTP 404
var httpErr interface{ Error() string }
if errors.As(err, &httpErr) {
if strings.Contains(httpErr.Error(), "404") || strings.Contains(httpErr.Error(), "status 404") {
return true
}
}
// Check error message
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "404")
}
// IsAuthError checks if an error is an authentication or authorization error.
// This includes 401 (unauthorized) and 403 (forbidden) HTTP errors.
func IsAuthError(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryAuth
}
// Check for HTTP 401/403
msg := err.Error()
if strings.Contains(msg, "401") || strings.Contains(msg, "status 401") ||
strings.Contains(msg, "403") || strings.Contains(msg, "status 403") {
return true
}
// Check error message
lowerMsg := strings.ToLower(msg)
return strings.Contains(lowerMsg, "authentication") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "permission denied") ||
strings.Contains(lowerMsg, "forbidden") ||
strings.Contains(lowerMsg, "access token")
}
// IsActionsAPIUnavailable checks if an error indicates that the Actions API
// is not available on the current Gitea version.
func IsActionsAPIUnavailable(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryActions ||
strings.Contains(enhanced.Translated, "not supported on this Gitea version")
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "actions") &&
(strings.Contains(msg, "not found") ||
strings.Contains(msg, "method not allowed") ||
strings.Contains(msg, "404") ||
strings.Contains(msg, "405"))
}
// IsTimeout checks if an error is a timeout error.
func IsTimeout(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryNetwork ||
strings.Contains(enhanced.Translated, "timed out")
}
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "deadline exceeded") ||
strings.Contains(lowerMsg, "context deadline")
}
// IsNetworkError checks if an error is a network connectivity error.
func IsNetworkError(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryNetwork
}
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "no such host") ||
strings.Contains(lowerMsg, "dial tcp")
}
// NewEnhancedError creates a new EnhancedError with the given parameters.
func NewEnhancedError(original error, translated string, category ErrorCategory) *EnhancedError {
return &EnhancedError{
Original: original,
Translated: translated,
Category: category,
Context: make(map[string]string),
Timestamp: time.Now().UTC(),
}
}
// Wrap wraps an error with additional context information.
func Wrap(err error, operation string) error {
if err == nil {
return nil
}
return TranslateError(err, map[string]string{"operation": operation})
}
// HTTPError represents an HTTP error response.
// This interface is used to check for HTTP status codes.
type HTTPError interface {
error
Status() int
}
// statusError is a simple implementation of HTTPError for testing.
type statusError struct {
status int
message string
}
func (e *statusError) Error() string { return e.message }
func (e *statusError) Status() int { return e.status }
// IsHTTPError checks if an error is an HTTP error with the given status code.
func IsHTTPError(err error, statusCode int) bool {
if err == nil {
return false
}
// Check if it's our HTTPError type
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.Status() == statusCode
}
// Check error message for status code
msg := err.Error()
return strings.Contains(msg, fmt.Sprintf("status %d", statusCode)) ||
strings.Contains(msg, fmt.Sprintf("%d", statusCode))
}
// Common HTTP status check helpers
// IsUnauthorized checks if the error is an HTTP 401 Unauthorized.
func IsUnauthorized(err error) bool {
return IsHTTPError(err, http.StatusUnauthorized)
}
// IsForbidden checks if the error is an HTTP 403 Forbidden.
func IsForbidden(err error) bool {
return IsHTTPError(err, http.StatusForbidden)
}
// IsNotFoundHTTP checks if the error is an HTTP 404 Not Found.
func IsNotFoundHTTP(err error) bool {
return IsHTTPError(err, http.StatusNotFound)
}
// IsServerError checks if the error is an HTTP 5xx server error.
func IsServerError(err error) bool {
if err == nil {
return false
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.Status() >= 500 && httpErr.Status() < 600
}
msg := err.Error()
for i := 500; i < 600; i++ {
if strings.Contains(msg, fmt.Sprintf("status %d", i)) ||
strings.Contains(msg, fmt.Sprintf("%d", i)) {
return true
}
}
return false
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
package flag
var (
Host string
Port int
Token string
Version string
Mode string
Insecure bool
ReadOnly bool
Debug bool
)
+75
View File
@@ -0,0 +1,75 @@
package gitea
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"code.gitea.io/sdk/gitea"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func NewClient(token string) (*gitea.Client, error) {
httpClient := &http.Client{
Transport: http.DefaultTransport,
CheckRedirect: checkRedirect,
}
opts := []gitea.ClientOption{
gitea.SetToken(token),
}
if flag.Insecure {
httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
if flag.Debug {
opts = append(opts, gitea.SetDebugMode())
}
client, err := gitea.NewClient(flag.Host, opts...)
if err != nil {
return nil, fmt.Errorf("create gitea client err: %w", err)
}
client.SetUserAgent("gitea-mcp-server/" + flag.Version)
user, _, err := client.GetMyUserInfo()
if err != nil {
return client, nil
}
client2, err := gitea.NewClient(flag.Host,
gitea.SetBasicAuth(user.UserName, token),
gitea.SetHTTPClient(httpClient),
)
if err != nil {
return client, nil
}
client2.SetUserAgent("gitea-mcp-server/" + flag.Version)
return client2, nil
}
// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.)
// to GET when following 301/302/303 redirects, which would drop the request body and
// make writes appear to succeed when they didn't.
func checkRedirect(_ *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
return http.ErrUseLastResponse
}
return nil
}
func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok {
token = flag.Token
}
return NewClient(token)
}
+120
View File
@@ -0,0 +1,120 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestCheckRedirect(t *testing.T) {
for _, tc := range []struct {
name string
method string
wantErr error
}{
{"allows GET", http.MethodGet, nil},
{"allows HEAD", http.MethodHead, nil},
{"blocks PATCH", http.MethodPatch, http.ErrUseLastResponse},
{"blocks POST", http.MethodPost, http.ErrUseLastResponse},
{"blocks PUT", http.MethodPut, http.ErrUseLastResponse},
{"blocks DELETE", http.MethodDelete, http.ErrUseLastResponse},
} {
t.Run(tc.name, func(t *testing.T) {
via := []*http.Request{{Method: tc.method}}
err := checkRedirect(nil, via)
if err != tc.wantErr {
t.Fatalf("expected %v, got %v", tc.wantErr, err)
}
})
}
t.Run("stops after 10 redirects", func(t *testing.T) {
via := make([]*http.Request, 10)
for i := range via {
via[i] = &http.Request{Method: http.MethodGet}
}
err := checkRedirect(nil, via)
if err == nil || err == http.ErrUseLastResponse {
t.Fatalf("expected redirect limit error, got %v", err)
}
})
}
// TestDoJSON_RepoRenameRedirect is a regression test for the bug where a PATCH
// request to a renamed repo got a 301 redirect, Go's http.Client silently
// changed the method to GET, and the write appeared to succeed without error.
func TestDoJSON_RepoRenameRedirect(t *testing.T) {
// Simulate a Gitea API that returns 301 for the old repo name (like a renamed repo).
mux := http.NewServeMux()
mux.HandleFunc("PATCH /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("PATCH /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"updated"}`)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"not-updated"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodPatch, "repos/owner/old-name/pulls/1", nil, map[string]string{"title": "updated"}, &result)
if err != nil {
// The redirect should be blocked, returning the 301 response directly.
// DoJSON treats non-2xx as an error, which is the correct behavior.
if status != http.StatusMovedPermanently {
t.Fatalf("expected status 301, got %d (err: %v)", status, err)
}
return
}
// If we reach here without error, the redirect was followed. Verify the
// method was preserved (title should be "updated", not "not-updated").
title, _ := result["title"].(string)
if title == "not-updated" {
t.Fatal("PATCH was silently converted to GET on 301 redirect — write was lost")
}
}
// TestDoJSON_GETRedirectFollowed verifies that GET requests still follow redirects normally.
func TestDoJSON_GETRedirectFollowed(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/old-name/pulls/1", nil, nil, &result)
if err != nil {
t.Fatalf("GET redirect should be followed, got error: %v (status %d)", err, status)
}
title, _ := result["title"].(string)
if title != "found" {
t.Fatalf("expected title 'found', got %q", title)
}
}
+327
View File
@@ -0,0 +1,327 @@
package gitea
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"go.uber.org/zap"
)
type HTTPError struct {
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("request failed with status %d", e.StatusCode)
}
return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Body)
}
func tokenFromContext(ctx context.Context) string {
if ctx != nil {
if token, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && token != "" {
return token
}
}
return flag.Token
}
func newRESTHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
if flag.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
}
return &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
}
}
func buildAPIURL(path string, query url.Values) (string, error) {
host := strings.TrimRight(flag.Host, "/")
if host == "" {
return "", errors.New("gitea host is empty")
}
p := strings.TrimLeft(path, "/")
u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p))
if err != nil {
return "", err
}
if query != nil {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil).
// It returns the HTTP status code.
func DoJSON(ctx context.Context, method, path string, query url.Values, body, respOut any) (int, error) {
correlationID := log.GetCorrelationID(ctx)
if correlationID == "" {
ctx = log.WithCorrelationID(ctx, "")
correlationID = log.GetCorrelationID(ctx)
}
operation := log.GetOperation(ctx)
if operation == "" {
operation = fmt.Sprintf("%s %s", method, path)
}
logger := log.WithContext(ctx)
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
logger.Error("failed to marshal request body",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
)
return 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
logger.Error("failed to build API URL",
zap.Error(err),
zap.String("operation", operation),
zap.String("path", path),
)
return 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
logger.Error("failed to create HTTP request",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("url", u),
)
return 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := newRESTHTTPClient()
logger.Debug("sending API request",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.String("correlation_id", correlationID),
)
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
logger.Error("API request failed",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
)
return 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
logger.Error("API request returned error status",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
zap.String("response_body", strings.TrimSpace(string(bodySnippet))),
)
return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
logger.Debug("API request completed",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
)
if respOut == nil {
_, _ = io.Copy(io.Discard, resp.Body) // best-effort
return resp.StatusCode, nil
}
if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil {
logger.Error("failed to decode API response",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
)
return resp.StatusCode, fmt.Errorf("decode response: %w", err)
}
return resp.StatusCode, nil
}
// DoBytes performs an API request and returns the raw response bytes.
// It returns the HTTP status code.
func DoBytes(ctx context.Context, method, path string, query url.Values, body any, accept string) ([]byte, int, error) {
correlationID := log.GetCorrelationID(ctx)
if correlationID == "" {
ctx = log.WithCorrelationID(ctx, "")
correlationID = log.GetCorrelationID(ctx)
}
operation := log.GetOperation(ctx)
if operation == "" {
operation = fmt.Sprintf("%s %s", method, path)
}
logger := log.WithContext(ctx)
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
logger.Error("failed to marshal request body",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
)
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
logger.Error("failed to build API URL",
zap.Error(err),
zap.String("operation", operation),
zap.String("path", path),
)
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
logger.Error("failed to create HTTP request",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("url", u),
)
return nil, 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := newRESTHTTPClient()
logger.Debug("sending API request",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.String("correlation_id", correlationID),
)
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
logger.Error("API request failed",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
)
return nil, 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
logger.Error("failed to read response body",
zap.Error(err),
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
)
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet := respBytes
if len(bodySnippet) > 8192 {
bodySnippet = bodySnippet[:8192]
}
logger.Error("API request returned error status",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
zap.String("response_body", strings.TrimSpace(string(bodySnippet))),
)
return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
logger.Debug("API request completed",
zap.String("operation", operation),
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", resp.StatusCode),
zap.Duration("duration", duration),
zap.String("correlation_id", correlationID),
)
return respBytes, resp.StatusCode, nil
}
+30
View File
@@ -0,0 +1,30 @@
package gitea
import (
"context"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestTokenFromContext(t *testing.T) {
orig := flag.Token
defer func() { flag.Token = orig }()
flag.Token = "flag-token"
t.Run("context token wins", func(t *testing.T) {
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "ctx-token")
if got := tokenFromContext(ctx); got != "ctx-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "ctx-token")
}
})
t.Run("fallback to flag token", func(t *testing.T) {
ctx := context.Background()
if got := tokenFromContext(ctx); got != "flag-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "flag-token")
}
})
}
+323
View File
@@ -0,0 +1,323 @@
// Package log provides structured logging with context support for request tracing.
package log
import (
"context"
"fmt"
"sync"
"time"
"go.uber.org/zap"
)
// contextKey is a type for context keys to avoid collisions.
type contextKey string
const (
// correlationIDKey is the context key for correlation ID.
correlationIDKey contextKey = "correlation_id"
// operationKey is the context key for operation name.
operationKey contextKey = "operation"
// startTimeKey is the context key for operation start time.
startTimeKey contextKey = "start_time"
)
var (
// correlationIDGenerator provides thread-safe ID generation.
correlationIDGenerator = &idGenerator{}
)
// idGenerator generates unique correlation IDs.
type idGenerator struct {
mu sync.Mutex
seq uint64
}
// Generate creates a new unique correlation ID.
func (g *idGenerator) Generate() string {
g.mu.Lock()
defer g.mu.Unlock()
g.seq++
return time.Now().Format("20060102-150405") + "-" + string(rune(g.seq))
}
// WithCorrelationID adds a correlation ID to the context for request tracing.
// If no ID is provided, a new one will be generated.
func WithCorrelationID(ctx context.Context, id string) context.Context {
if id == "" {
id = correlationIDGenerator.Generate()
}
return context.WithValue(ctx, correlationIDKey, id)
}
// WithOperation adds an operation name to the context.
func WithOperation(ctx context.Context, operation string) context.Context {
return context.WithValue(ctx, operationKey, operation)
}
// WithStartTime adds operation start time to the context.
func WithStartTime(ctx context.Context) context.Context {
return context.WithValue(ctx, startTimeKey, time.Now())
}
// GetCorrelationID retrieves the correlation ID from context.
func GetCorrelationID(ctx context.Context) string {
if ctx == nil {
return ""
}
if id, ok := ctx.Value(correlationIDKey).(string); ok {
return id
}
return ""
}
// GetOperation retrieves the operation name from context.
func GetOperation(ctx context.Context) string {
if ctx == nil {
return ""
}
if op, ok := ctx.Value(operationKey).(string); ok {
return op
}
return ""
}
// GetStartTime retrieves the operation start time from context.
func GetStartTime(ctx context.Context) time.Time {
if ctx == nil {
return time.Time{}
}
if t, ok := ctx.Value(startTimeKey).(time.Time); ok {
return t
}
return time.Time{}
}
// Duration returns the elapsed time since the operation started.
// Returns 0 if no start time is set in context.
func Duration(ctx context.Context) time.Duration {
start := GetStartTime(ctx)
if start.IsZero() {
return 0
}
return time.Since(start)
}
// Logger provides request-scoped logging with context fields.
type Logger struct {
*zap.Logger
ctx context.Context
}
// WithContext creates a new Logger with context fields.
func WithContext(ctx context.Context) *Logger {
return &Logger{
Logger: Default(),
ctx: ctx,
}
}
// WithLogger creates a new Logger with a specific zap logger and context.
func WithLogger(logger *zap.Logger, ctx context.Context) *Logger {
return &Logger{
Logger: logger,
ctx: ctx,
}
}
// contextFields returns zap fields from context values.
func (l *Logger) contextFields() []zap.Field {
if l.ctx == nil {
return nil
}
var fields []zap.Field
if id := GetCorrelationID(l.ctx); id != "" {
fields = append(fields, zap.String("correlation_id", id))
}
if op := GetOperation(l.ctx); op != "" {
fields = append(fields, zap.String("operation", op))
}
if start := GetStartTime(l.ctx); !start.IsZero() {
fields = append(fields, zap.Duration("duration", time.Since(start)))
}
return fields
}
// Debug logs a message at debug level with context fields.
func (l *Logger) Debug(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Debug(msg, allFields...)
}
// Info logs a message at info level with context fields.
func (l *Logger) Info(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Info(msg, allFields...)
}
// Warn logs a message at warn level with context fields.
func (l *Logger) Warn(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Warn(msg, allFields...)
}
// Error logs a message at error level with context fields.
func (l *Logger) Error(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Error(msg, allFields...)
}
// Fatal logs a message at fatal level with context fields.
func (l *Logger) Fatal(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Fatal(msg, allFields...)
}
// Panic logs a message at panic level with context fields.
func (l *Logger) Panic(msg string, fields ...zap.Field) {
allFields := append(l.contextFields(), fields...)
l.Logger.Panic(msg, allFields...)
}
// Debugf logs a formatted message at debug level with context fields.
func (l *Logger) Debugf(format string, args ...any) {
l.Debug(fmt.Sprintf(format, args...))
}
// Infof logs a formatted message at info level with context fields.
func (l *Logger) Infof(format string, args ...any) {
l.Info(fmt.Sprintf(format, args...))
}
// Warnf logs a formatted message at warn level with context fields.
func (l *Logger) Warnf(format string, args ...any) {
l.Warn(fmt.Sprintf(format, args...))
}
// Errorf logs a formatted message at error level with context fields.
func (l *Logger) Errorf(format string, args ...any) {
l.Error(fmt.Sprintf(format, args...))
}
// Fatalf logs a formatted message at fatal level with context fields.
func (l *Logger) Fatalf(format string, args ...any) {
l.Fatal(fmt.Sprintf(format, args...))
}
// Panicf logs a formatted message at panic level with context fields.
func (l *Logger) Panicf(format string, args ...any) {
l.Panic(fmt.Sprintf(format, args...))
}
// With creates a child logger with additional fields.
func (l *Logger) With(fields ...zap.Field) *Logger {
return &Logger{
Logger: l.Logger.With(fields...),
ctx: l.ctx,
}
}
// WithError creates an error log entry with error details.
func (l *Logger) WithError(err error) *ErrorEntry {
return &ErrorEntry{
Logger: l,
err: err,
}
}
// ErrorEntry provides structured error logging.
type ErrorEntry struct {
*Logger
err error
}
// Log logs the error with additional context.
func (e *ErrorEntry) Log(msg string) {
if e.err == nil {
e.Error(msg)
return
}
fields := []zap.Field{
zap.Error(e.err),
zap.String("error_type", fmt.Sprintf("%T", e.err)),
}
e.Error(msg, fields...)
}
// LogWithStatus logs the error with HTTP status code.
func (e *ErrorEntry) LogWithStatus(msg string, statusCode int) {
if e.err == nil {
e.Error(msg, zap.Int("status_code", statusCode))
return
}
fields := []zap.Field{
zap.Error(e.err),
zap.String("error_type", fmt.Sprintf("%T", e.err)),
zap.Int("status_code", statusCode),
}
e.Error(msg, fields...)
}
// OperationLogger provides a convenient way to log operation execution.
type OperationLogger struct {
logger *Logger
operation string
start time.Time
}
// StartOperation begins logging an operation with timing.
func StartOperation(ctx context.Context, operation string) *OperationLogger {
logger := WithContext(WithOperation(ctx, operation))
logger.ctx = WithStartTime(logger.ctx)
return &OperationLogger{
logger: logger,
operation: operation,
start: time.Now(),
}
}
// Start begins the operation logging with a start message.
func (o *OperationLogger) Start(msg string) {
o.logger.Info(msg, zap.String("phase", "start"))
}
// Success logs a successful operation completion.
func (o *OperationLogger) Success(msg string) {
duration := time.Since(o.start)
o.logger.Info(msg,
zap.String("phase", "complete"),
zap.String("status", "success"),
zap.Duration("duration", duration),
)
}
// Failure logs a failed operation.
func (o *OperationLogger) Failure(msg string, err error) {
duration := time.Since(o.start)
fields := []zap.Field{
zap.String("phase", "complete"),
zap.String("status", "failure"),
zap.Duration("duration", duration),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
o.logger.Error(msg, fields...)
}
// Duration returns the elapsed time since operation started.
func (o *OperationLogger) Duration() time.Duration {
return time.Since(o.start)
}
+126
View File
@@ -0,0 +1,126 @@
package log
import (
"context"
"os"
"sync"
"time"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
defaultLoggerOnce sync.Once
defaultLogger *zap.Logger
)
func Default() *zap.Logger {
defaultLoggerOnce.Do(func() {
if defaultLogger != nil {
return
}
ec := zap.NewProductionEncoderConfig()
ec.EncodeTime = zapcore.TimeEncoderOfLayout(time.DateTime)
ec.EncodeLevel = zapcore.CapitalLevelEncoder
var ws zapcore.WriteSyncer
var wss []zapcore.WriteSyncer
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
logDir := home + "/.gitea-mcp"
if err := os.MkdirAll(logDir, 0o700); err != nil {
// Fallback to temp directory if creation fails
logDir = os.TempDir()
}
wss = append(wss, zapcore.AddSync(&lumberjack.Logger{
Filename: logDir + "/gitea-mcp.log",
MaxSize: 100,
MaxBackups: 10,
MaxAge: 30,
}))
if flag.Mode == "http" {
wss = append(wss, zapcore.AddSync(os.Stdout))
}
ws = zapcore.NewMultiWriteSyncer(wss...)
enc := zapcore.NewConsoleEncoder(ec)
var level zapcore.Level
if flag.Debug {
level = zapcore.DebugLevel
} else {
level = zapcore.InfoLevel
}
core := zapcore.NewCore(enc, ws, level)
options := []zap.Option{
zap.AddStacktrace(zapcore.DPanicLevel),
zap.AddCaller(),
zap.AddCallerSkip(1),
}
defaultLogger = zap.New(core, options...)
})
return defaultLogger
}
func SetDefault(logger *zap.Logger) {
if logger != nil {
defaultLogger = logger
}
}
// New creates a new Logger with the default zap logger.
// This is a compatibility wrapper for the MCP server.
func New() *Logger {
return WithContext(context.Background())
}
func Debug(msg string, fields ...zap.Field) {
Default().Debug(msg, fields...)
}
func Info(msg string, fields ...zap.Field) {
Default().Info(msg, fields...)
}
func Warn(msg string, fields ...zap.Field) {
Default().Warn(msg, fields...)
}
func Error(msg string, fields ...zap.Field) {
Default().Error(msg, fields...)
}
func Panic(msg string, fields ...zap.Field) {
Default().Panic(msg, fields...)
}
func Debugf(format string, args ...any) {
Default().Sugar().Debugf(format, args...)
}
func Infof(format string, args ...any) {
Default().Sugar().Infof(format, args...)
}
func Warnf(format string, args ...any) {
Default().Sugar().Warnf(format, args...)
}
func Errorf(format string, args ...any) {
Default().Sugar().Errorf(format, args...)
}
func Fatalf(format string, args ...any) {
Default().Sugar().Fatalf(format, args...)
}
+139
View File
@@ -0,0 +1,139 @@
package params
import (
"fmt"
"strconv"
)
// GetString extracts a required string parameter from MCP tool arguments.
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
if !ok {
return "", fmt.Errorf("%s is required", key)
}
return val, nil
}
// GetOptionalString extracts an optional string parameter with a default value.
func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok {
return val
}
return defaultVal
}
// GetStringSlice extracts an optional string slice parameter from MCP tool arguments.
func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key]
if !ok {
return nil
}
sliceVal, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(sliceVal))
for _, item := range sliceVal {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// GetPagination extracts page and perPage parameters, returning them as ints.
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize))
}
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and
// string representations. Returns false if the value cannot be converted.
func ToInt64(val any) (int64, bool) {
switch v := val.(type) {
case float64:
return int64(v), true
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
// GetIndex extracts a required integer parameter from MCP tool arguments.
// It accepts both numeric (float64 from JSON) and string representations.
// This provides better UX for LLM callers that may naturally use strings
// for identifiers like issue/PR numbers.
func GetIndex(args map[string]any, key string) (int64, error) {
val, exists := args[key]
if !exists {
return 0, fmt.Errorf("%s is required", key)
}
if i, ok := ToInt64(val); ok {
return i, nil
}
if s, ok := val.(string); ok {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
}
return 0, fmt.Errorf("%s must be a number or numeric string", key)
}
// GetInt64Slice extracts a required int64 slice parameter from MCP tool arguments.
func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any)
if !ok {
return nil, fmt.Errorf("%s (array of IDs) is required", key)
}
out := make([]int64, 0, len(raw))
for _, v := range raw {
id, ok := ToInt64(v)
if !ok {
return nil, fmt.Errorf("invalid ID in %s array", key)
}
out = append(out, id)
}
return out, nil
}
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts both float64 (JSON number) and string representations.
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key]
if !exists {
return defaultVal
}
if i, ok := ToInt64(val); ok {
return i
}
return defaultVal
}
// GetOptionalBool extracts an optional boolean parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts bool, float64 (1=true, 0=false), and string representations.
func GetOptionalBool(args map[string]any, key string, defaultVal bool) bool {
val, exists := args[key]
if !exists {
return defaultVal
}
switch v := val.(type) {
case bool:
return v
case float64:
return v != 0
case string:
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return defaultVal
}
+161
View File
@@ -0,0 +1,161 @@
package params
import (
"strings"
"testing"
)
func TestToInt64(t *testing.T) {
tests := []struct {
name string
val any
want int64
ok bool
}{
{"float64", float64(42), 42, true},
{"float64 zero", float64(0), 0, true},
{"float64 negative", float64(-5), -5, true},
{"string", "123", 123, true},
{"string zero", "0", 0, true},
{"string negative", "-10", -10, true},
{"invalid string", "abc", 0, false},
{"decimal string", "1.5", 0, false},
{"bool", true, 0, false},
{"nil", nil, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ToInt64(tt.val)
if ok != tt.ok {
t.Errorf("ToInt64() ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Errorf("ToInt64() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetOptionalInt(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
defaultVal int64
want int64
}{
{"present float64", map[string]any{"page": float64(3)}, "page", 1, 3},
{"present string", map[string]any{"page": "5"}, "page", 1, 5},
{"missing key", map[string]any{}, "page", 1, 1},
{"invalid string", map[string]any{"page": "abc"}, "page", 1, 1},
{"invalid type", map[string]any{"page": true}, "page", 1, 1},
{"zero value", map[string]any{"id": float64(0)}, "id", 99, 0},
{"string zero", map[string]any{"id": "0"}, "id", 99, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetOptionalInt(tt.args, tt.key, tt.defaultVal)
if got != tt.want {
t.Errorf("GetOptionalInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetIndex(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
wantIndex int64
wantErr bool
errMsg string
}{
{
name: "valid float64",
args: map[string]any{"index": float64(123)},
key: "index",
wantIndex: 123,
wantErr: false,
},
{
name: "valid string",
args: map[string]any{"index": "456"},
key: "index",
wantIndex: 456,
wantErr: false,
},
{
name: "valid string with large number",
args: map[string]any{"index": "999999"},
key: "index",
wantIndex: 999999,
wantErr: false,
},
{
name: "missing parameter",
args: map[string]any{},
key: "index",
wantErr: true,
errMsg: "index is required",
},
{
name: "invalid string (not a number)",
args: map[string]any{"index": "abc"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid string (decimal)",
args: map[string]any{"index": "12.34"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid type (bool)",
args: map[string]any{"index": true},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "invalid type (map)",
args: map[string]any{"index": map[string]string{"foo": "bar"}},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "custom key name",
args: map[string]any{"pr_index": "789"},
key: "pr_index",
wantIndex: 789,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIndex, err := GetIndex(tt.args, tt.key)
if tt.wantErr {
if err == nil {
t.Errorf("GetIndex() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("GetIndex() error = %v, want error containing %q", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("GetIndex() unexpected error = %v", err)
return
}
if gotIndex != tt.wantIndex {
t.Errorf("GetIndex() = %v, want %v", gotIndex, tt.wantIndex)
}
})
}
}
+23
View File
@@ -0,0 +1,23 @@
package to
import (
"encoding/json"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/mcp"
)
func TextResult(v any) (*mcp.CallToolResult, error) {
resultBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal result err: %v", err)
}
log.Debugf("Text Result: %s", string(resultBytes))
return mcp.NewToolResultText(string(resultBytes)), nil
}
func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf(err.Error())
return nil, err
}
+37
View File
@@ -0,0 +1,37 @@
package tool
import (
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/server"
)
type Tool struct {
write []server.ServerTool
read []server.ServerTool
}
func New() *Tool {
return &Tool{
write: make([]server.ServerTool, 0, 100),
read: make([]server.ServerTool, 0, 100),
}
}
func (t *Tool) RegisterWrite(s server.ServerTool) {
t.write = append(t.write, s)
}
func (t *Tool) RegisterRead(s server.ServerTool) {
t.read = append(t.read, s)
}
func (t *Tool) Tools() []server.ServerTool {
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if flag.ReadOnly {
tools = append(tools, t.read...)
return tools
}
tools = append(tools, t.write...)
tools = append(tools, t.read...)
return tools
}