package operation import ( "context" "errors" "fmt" "net/http" "os" "os/signal" "strings" "syscall" "time" "gitea.com/gitea/gitea-mcp/operation/actions" "gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/notification" "gitea.com/gitea/gitea-mcp/operation/packages" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/search" "gitea.com/gitea/gitea-mcp/operation/timetracking" "gitea.com/gitea/gitea-mcp/operation/user" "gitea.com/gitea/gitea-mcp/operation/version" "gitea.com/gitea/gitea-mcp/operation/wiki" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" "gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/middleware" "gitea.com/gitea/gitea-mcp/pkg/tool" "github.com/mark3labs/mcp-go/server" "go.uber.org/zap" ) var ( mcpServer *server.MCPServer domainTools = []*tool.Tool{ user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, version.Tool, wiki.Tool, timetracking.Tool, } ) func RegisterTool(s *server.MCPServer) { for _, t := range domainTools { s.AddTools(t.Tools()...) } tool.WarnUnmatchedAllowedTools(domainTools...) } // parseAuthToken extracts the token from an Authorization header. // Supports "Bearer " (case-insensitive per RFC 7235) and // Gitea-style "token " formats. // Returns the token and true if valid, empty string and false otherwise. func parseAuthToken(authHeader string) (string, bool) { if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") { token := strings.TrimSpace(authHeader[7:]) if token != "" { return token, true } } if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") { token := strings.TrimSpace(authHeader[6:]) if token != "" { return token, true } } return "", false } func getContextWithToken(ctx context.Context, r *http.Request) context.Context { authHeader := r.Header.Get("Authorization") if authHeader == "" { return ctx } token, ok := parseAuthToken(authHeader) if !ok { return ctx } ctx = context.WithValue(ctx, mcpContext.TokenContextKey, token) // Mark per-request header tokens so debug logs can show whether a tool // call was authenticated via the header (per-request) or fell through // to the process-wide flag/env token. ctx = context.WithValue(ctx, mcpContext.TokenSourceContextKey, "header") return ctx } func Run() error { mcpServer = newMCPServer(flag.Version) RegisterTool(mcpServer) logEffectiveConfig() switch flag.Mode { case "stdio": if err := server.ServeStdio( mcpServer, ); err != nil { return err } case "http": httpServer := server.NewStreamableHTTPServer( mcpServer, server.WithLogger(log.Default().Sugar()), server.WithHeartbeatInterval(30*time.Second), server.WithHTTPContextFunc(getContextWithToken), ) log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port) // Graceful shutdown setup sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) shutdownDone := make(chan struct{}) go func() { <-sigCh log.Infof("Shutdown signal received, gracefully stopping HTTP server...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { log.Errorf("HTTP server shutdown error: %v", err) } close(shutdownDone) }() if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } <-shutdownDone // Wait for shutdown to finish default: return fmt.Errorf("invalid transport type: %s. Must be 'stdio' or 'http'", flag.Mode) } return nil } func newMCPServer(version string) *server.MCPServer { return server.NewMCPServer( "Gitea MCP Server", version, server.WithToolCapabilities(true), server.WithLogging(), server.WithRecovery(), // ToolLogging emits structured entry/exit logs and attaches a // request_id + tool name to the context so downstream layers // (the gitea RoundTripper, pkg/to result logging) can correlate. server.WithToolHandlerMiddleware(middleware.ToolLogging), ) } // logEffectiveConfig records the runtime configuration once at startup // when debug mode is on. This makes "did my env var actually take effect?" // answerable from the log file without having to instrument the process. // // Token *values* are never logged — only the source (flag / env / env-file) // so the operator can tell which auth path was taken. func logEffectiveConfig() { if !flag.DebugScopeEnabled("config") { return } tokenSource := flag.TokenSource if tokenSource == "" { tokenSource = "none" } scopes := "all" if len(flag.DebugScopes) > 0 { keys := make([]string, 0, len(flag.DebugScopes)) for k := range flag.DebugScopes { keys = append(keys, k) } scopes = strings.Join(keys, ",") } log.Info("gitea-mcp effective config", zap.String("version", flag.Version), zap.String("host", flag.Host), zap.String("mode", flag.Mode), zap.Int("port", flag.Port), zap.Bool("read_only", flag.ReadOnly), zap.Bool("insecure", flag.Insecure), zap.Bool("debug", flag.Debug), zap.String("debug_scopes", scopes), zap.String("token_source", tokenSource), zap.Int("allowed_tools", len(flag.AllowedTools)), ) }