Initial commit: Gitea MCP Server
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package context
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
TokenContextKey = contextKey("token")
|
||||
)
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
package flag
|
||||
|
||||
var (
|
||||
Host string
|
||||
Port int
|
||||
Token string
|
||||
Version string
|
||||
Mode string
|
||||
|
||||
Insecure bool
|
||||
ReadOnly bool
|
||||
Debug bool
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user