diff --git a/cmd/cmd.go b/cmd/cmd.go index 83cf7eb..dda2db8 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "flag" "fmt" "os" @@ -57,6 +58,7 @@ func init() { fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n") fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n") + fmt.Fprintf(w, " GITEA_SERVERS\tJSON object mapping server names to {host, token, insecure} configs\n") fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n") w.Flush() } @@ -91,6 +93,17 @@ func init() { if os.Getenv("GITEA_INSECURE") == "true" { flagPkg.Insecure = true } + + // Parse multi-server configuration from GITEA_SERVERS env var (JSON object). + // Example: {"dev":{"host":"https://gitea.dev.example.com","token":"xxx"},"prod":{...}} + if serversJSON := os.Getenv("GITEA_SERVERS"); serversJSON != "" { + var servers map[string]flagPkg.ServerConfig + if err := json.Unmarshal([]byte(serversJSON), &servers); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to parse GITEA_SERVERS: %v\n", err) + } else { + flagPkg.Servers = servers + } + } } func Execute() { diff --git a/config.json b/config.json index 61cde97..1dfd010 100644 --- a/config.json +++ b/config.json @@ -6,11 +6,12 @@ "-t", "stdio", "--host", "https://gitea.com", "--token", "" - ] + ], "env": { "GITEA_HOST": "https://gitea.com", - "GITEA_ACCESS_TOKEN": "" + "GITEA_ACCESS_TOKEN": "", + "GITEA_SERVERS": "{\"dev\":{\"host\":\"https://gitea.dev.example.com\",\"token\":\"\"},\"prod\":{\"host\":\"https://gitea.prod.example.com\",\"token\":\"\"}}" } } } -} \ No newline at end of file +} diff --git a/operation/operation.go b/operation/operation.go index 860747f..c100d5a 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "sort" "strings" "syscall" "time" @@ -28,51 +29,109 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/log" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) var mcpServer *server.MCPServer +// serverNames returns a sorted list of configured server names for use in +// enum validation and the list_servers tool. +func serverNames() []string { + names := make([]string, 0, len(flag.Servers)) + for name := range flag.Servers { + names = append(names, name) + } + // Deterministic ordering for tool schemas. + sort.Strings(names) + return names +} + +// injectServerParam adds an optional "server" parameter to a tool and wraps +// its handler so the chosen server's host/token/insecure are injected into +// the context before the original handler runs. +func injectServerParam(tools []server.ServerTool) []server.ServerTool { + names := serverNames() + for i, st := range tools { + // Add "server" to the JSON Schema properties. + if st.Tool.InputSchema.Properties == nil { + st.Tool.InputSchema.Properties = make(map[string]any) + } + st.Tool.InputSchema.Properties["server"] = map[string]any{ + "type": "string", + "description": "Target Gitea server name. Available: " + strings.Join(names, ", ") + ". Omit to use the default server.", + "enum": names, + } + tools[i].Tool = st.Tool + + // Wrap the handler to resolve server config → context values. + origHandler := st.Handler + tools[i].Handler = func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if serverName, ok := req.GetArguments()["server"].(string); ok && serverName != "" { + cfg, exists := flag.Servers[serverName] + if !exists { + return mcp.NewToolResultText("unknown server: " + serverName + ". Available: " + strings.Join(serverNames(), ", ")), nil + } + ctx = context.WithValue(ctx, mcpContext.HostContextKey, cfg.Host) + ctx = context.WithValue(ctx, mcpContext.TokenContextKey, cfg.Token) + ctx = context.WithValue(ctx, mcpContext.InsecureContextKey, cfg.Insecure) + } + return origHandler(ctx, req) + } + } + return tools +} + +// listServersFn handles the "list_servers" tool — returns the configured servers. +func listServersFn(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if len(flag.Servers) == 0 { + defaultInfo := fmt.Sprintf("default: %s (no additional servers configured)", flag.Host) + return mcp.NewToolResultText(defaultInfo), nil + } + var b strings.Builder + b.WriteString(fmt.Sprintf("default: %s\n", flag.Host)) + for _, name := range serverNames() { + cfg := flag.Servers[name] + b.WriteString(fmt.Sprintf("%s: %s", name, cfg.Host)) + if cfg.Insecure { + b.WriteString(" (insecure)") + } + b.WriteString("\n") + } + return mcp.NewToolResultText(b.String()), nil +} + func RegisterTool(s *server.MCPServer) { - // User Tool - s.AddTools(user.Tool.Tools()...) + // Collect all tools from every domain package. + allTools := make([]server.ServerTool, 0, 200) + allTools = append(allTools, user.Tool.Tools()...) + allTools = append(allTools, actions.Tool.Tools()...) + allTools = append(allTools, repo.Tool.Tools()...) + allTools = append(allTools, notification.Tool.Tools()...) + allTools = append(allTools, issue.Tool.Tools()...) + allTools = append(allTools, label.Tool.Tools()...) + allTools = append(allTools, milestone.Tool.Tools()...) + allTools = append(allTools, pull.Tool.Tools()...) + allTools = append(allTools, search.Tool.Tools()...) + allTools = append(allTools, version.Tool.Tools()...) + allTools = append(allTools, wiki.Tool.Tools()...) + allTools = append(allTools, timetracking.Tool.Tools()...) + allTools = append(allTools, org.Tool.Tools()...) - // Actions Tool - s.AddTools(actions.Tool.Tools()...) + // If multiple servers are configured, inject the "server" parameter into every tool. + if len(flag.Servers) > 0 { + allTools = injectServerParam(allTools) - // Repo Tool - s.AddTools(repo.Tool.Tools()...) - - // Notification Tool - s.AddTools(notification.Tool.Tools()...) - - // Issue Tool - s.AddTools(issue.Tool.Tools()...) - - // Label Tool - s.AddTools(label.Tool.Tools()...) - - // Milestone Tool - s.AddTools(milestone.Tool.Tools()...) - - // Pull Tool - s.AddTools(pull.Tool.Tools()...) - - // Search Tool - s.AddTools(search.Tool.Tools()...) - - // Version Tool - s.AddTools(version.Tool.Tools()...) - - // Wiki Tool - s.AddTools(wiki.Tool.Tools()...) - - // Time Tracking Tool - s.AddTools(timetracking.Tool.Tools()...) - - // Organization Tool - s.AddTools(org.Tool.Tools()...) + // Add a list_servers meta-tool so callers can discover available servers. + allTools = append(allTools, server.ServerTool{ + Tool: mcp.NewTool("list_servers", + mcp.WithDescription("List all configured Gitea server connections"), + ), + Handler: listServersFn, + }) + } + s.AddTools(allTools...) s.DeleteTools("") } diff --git a/pkg/context/context.go b/pkg/context/context.go index 1671037..2b84697 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -3,5 +3,7 @@ package context type contextKey string const ( - TokenContextKey = contextKey("token") + TokenContextKey = contextKey("token") + HostContextKey = contextKey("host") + InsecureContextKey = contextKey("insecure") ) diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go index 9ebffa1..4ce3bce 100644 --- a/pkg/flag/flag.go +++ b/pkg/flag/flag.go @@ -1,5 +1,12 @@ package flag +// ServerConfig holds connection details for a named Gitea server. +type ServerConfig struct { + Host string `json:"host"` + Token string `json:"token"` + Insecure bool `json:"insecure,omitempty"` +} + var ( Host string Port int @@ -10,4 +17,8 @@ var ( Insecure bool ReadOnly bool Debug bool + + // Servers maps a friendly name to its connection config. + // Populated from the GITEA_SERVERS env var (JSON object). + Servers map[string]ServerConfig ) diff --git a/pkg/gitea/gitea.go b/pkg/gitea/gitea.go index a3adf4f..629b6c3 100644 --- a/pkg/gitea/gitea.go +++ b/pkg/gitea/gitea.go @@ -12,16 +12,16 @@ import ( "gitea.com/gitea/gitea-mcp/pkg/flag" ) -func NewClient(token string) (*gitea.Client, error) { +func newClientForHost(host, token string, insecure bool) (*gitea.Client, error) { httpClient := &http.Client{ - Transport: http.DefaultTransport, + Transport: http.DefaultTransport.(*http.Transport).Clone(), CheckRedirect: checkRedirect, } opts := []gitea.ClientOption{ gitea.SetToken(token), } - if flag.Insecure { + if insecure { httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } @@ -30,16 +30,20 @@ func NewClient(token string) (*gitea.Client, error) { if flag.Debug { opts = append(opts, gitea.SetDebugMode()) } - client, err := gitea.NewClient(flag.Host, opts...) + client, err := gitea.NewClient(host, opts...) if err != nil { return nil, fmt.Errorf("create gitea client err: %w", err) } - // Set user agent for the client client.SetUserAgent("gitea-mcp-server/" + flag.Version) return client, nil } +// NewClient creates a Gitea client using the default host and the given token. +func NewClient(token string) (*gitea.Client, error) { + return newClientForHost(flag.Host, token, flag.Insecure) +} + // 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. @@ -53,10 +57,21 @@ func checkRedirect(_ *http.Request, via []*http.Request) error { return nil } +// ClientFromContext creates a Gitea client using connection details from the +// context (set by the multi-server wrapper) or falling back to global defaults. func ClientFromContext(ctx context.Context) (*gitea.Client, error) { - token, ok := ctx.Value(mcpContext.TokenContextKey).(string) - if !ok { - token = flag.Token + host := flag.Host + token := flag.Token + insecure := flag.Insecure + + if h, ok := ctx.Value(mcpContext.HostContextKey).(string); ok && h != "" { + host = h } - return NewClient(token) + if t, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && t != "" { + token = t + } + if i, ok := ctx.Value(mcpContext.InsecureContextKey).(bool); ok { + insecure = i + } + return newClientForHost(host, token, insecure) }