From 95ab3a4b7386cd3a075e4c2ed517940accd27f08 Mon Sep 17 00:00:00 2001 From: Thierry PROST <3kynox@noreply.gitea.com> Date: Sat, 27 Sep 2025 07:29:10 +0000 Subject: [PATCH] feat: add wiki management tools (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #94 ## Summary This PR adds wiki management support to gitea-mcp adding new tools: creating, reading, updating, and deleting wiki pages. ## Changes - Added `operation/wiki/wiki.go` with wiki tools - Updated `operation/operation.go` to register it - Updated `README.md` ## New Tools - `list_wiki_pages` - List all wiki pages in a repository - `get_wiki_page` - Get wiki page content and metadata - `get_wiki_revisions` - Get revision history of a wiki page - `create_wiki_page` - Create a new wiki page - `update_wiki_page` - Update an existing wiki page - `delete_wiki_page` - Delete a wiki page ## Implementation Details - Uses direct HTTP calls to Gitea wiki API endpoints (v1.16.0+) - Follows existing MCP patterns and error handling - Includes fallback logic to prevent "unnamed" pages during updates - Proper base64 content encoding as per Gitea API spec ## Testing - All 6 tools tested and working correctly - Error handling validated - Integration with existing MCP server confirmed - Made a test repo & simulated a drone construction using Claude Code (in french sorry) at https://git.kernelpanik.fr/Test-Organization/test_wiki_tools/wiki Ready for review. Closes #[94] Co-authored-by: nox Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/95 Reviewed-by: Lunny Xiao Reviewed-by: Bo-Yi Wu (吳柏毅) Co-authored-by: Thierry PROST <3kynox@noreply.gitea.com> Co-committed-by: Thierry PROST <3kynox@noreply.gitea.com> --- README.md | 6 + operation/operation.go | 4 + operation/wiki/wiki.go | 363 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 operation/wiki/wiki.go diff --git a/README.md b/README.md index 8ebfbcc..beeb6f9 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,12 @@ The Gitea MCP Server supports the following tools: | search_org_teams | Organization | Search for teams in an organization | | search_repos | Repository | Search for repositories | | get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server | +| list_wiki_pages | Wiki | List all wiki pages in a repository | +| get_wiki_page | Wiki | Get a wiki page content and metadata | +| get_wiki_revisions | Wiki | Get revisions history of a wiki page | +| create_wiki_page | Wiki | Create a new wiki page | +| update_wiki_page | Wiki | Update an existing wiki page | +| delete_wiki_page | Wiki | Delete a wiki page | ## 🐛 Debugging diff --git a/operation/operation.go b/operation/operation.go index ccc4e94..65daa86 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -14,6 +14,7 @@ import ( "gitea.com/gitea/gitea-mcp/operation/search" "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" @@ -45,6 +46,9 @@ func RegisterTool(s *server.MCPServer) { // Version Tool s.AddTools(version.Tool.Tools()...) + // Wiki Tool + s.AddTools(wiki.Tool.Tools()...) + s.DeleteTools("") } diff --git a/operation/wiki/wiki.go b/operation/wiki/wiki.go new file mode 100644 index 0000000..e00016f --- /dev/null +++ b/operation/wiki/wiki.go @@ -0,0 +1,363 @@ +package wiki + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + ListWikiPagesToolName = "list_wiki_pages" + GetWikiPageToolName = "get_wiki_page" + GetWikiRevisionsToolName = "get_wiki_revisions" + CreateWikiPageToolName = "create_wiki_page" + UpdateWikiPageToolName = "update_wiki_page" + DeleteWikiPageToolName = "delete_wiki_page" +) + +var ( + ListWikiPagesTool = mcp.NewTool( + ListWikiPagesToolName, + mcp.WithDescription("List all wiki pages in a repository"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + ) + + GetWikiPageTool = mcp.NewTool( + GetWikiPageToolName, + mcp.WithDescription("Get a wiki page content and metadata"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")), + ) + + GetWikiRevisionsTool = mcp.NewTool( + GetWikiRevisionsToolName, + mcp.WithDescription("Get revisions history of a wiki page"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")), + ) + + CreateWikiPageTool = mcp.NewTool( + CreateWikiPageToolName, + mcp.WithDescription("Create a new wiki page"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("title", mcp.Required(), mcp.Description("wiki page title")), + mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")), + mcp.WithString("message", mcp.Description("commit message (optional)")), + ) + + UpdateWikiPageTool = mcp.NewTool( + UpdateWikiPageToolName, + mcp.WithDescription("Update an existing wiki page"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("pageName", mcp.Required(), mcp.Description("current wiki page name")), + mcp.WithString("title", mcp.Description("new page title (optional)")), + mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")), + mcp.WithString("message", mcp.Description("commit message (optional)")), + ) + + DeleteWikiPageTool = mcp.NewTool( + DeleteWikiPageToolName, + mcp.WithDescription("Delete a wiki page"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name to delete")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: ListWikiPagesTool, + Handler: ListWikiPagesFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetWikiPageTool, + Handler: GetWikiPageFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetWikiRevisionsTool, + Handler: GetWikiRevisionsFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: CreateWikiPageTool, + Handler: CreateWikiPageFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: UpdateWikiPageTool, + Handler: UpdateWikiPageFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeleteWikiPageTool, + Handler: DeleteWikiPageFn, + }) +} + +func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListWikiPagesFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + // Use direct HTTP request because SDK does not support yet wikis + result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err)) + } + + return to.TextResult(result) +} + +func GetWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetWikiPageFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + pageName, ok := req.GetArguments()["pageName"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("pageName is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func GetWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetWikiRevisionsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + pageName, ok := req.GetArguments()["pageName"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("pageName is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err)) + } + + return to.TextResult(result) +} + +func CreateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called CreateWikiPageFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + title, ok := req.GetArguments()["title"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("title is required")) + } + contentBase64, ok := req.GetArguments()["content_base64"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("content_base64 is required")) + } + + message, _ := req.GetArguments()["message"].(string) + if message == "" { + message = fmt.Sprintf("Create wiki page '%s'", title) + } + + requestBody := map[string]string{ + "title": title, + "content_base64": contentBase64, + "message": message, + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + result, err := makeWikiAPIRequest(ctx, client, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), requestBody) + if err != nil { + return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func UpdateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UpdateWikiPageFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + pageName, ok := req.GetArguments()["pageName"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("pageName is required")) + } + contentBase64, ok := req.GetArguments()["content_base64"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("content_base64 is required")) + } + + requestBody := map[string]string{ + "content_base64": contentBase64, + } + + // If title is given, use it. Otherwise, keep current page name + if title, ok := req.GetArguments()["title"].(string); ok && title != "" { + requestBody["title"] = title + } else { + // Utiliser pageName comme fallback pour éviter "unnamed" + requestBody["title"] = pageName + } + + if message, ok := req.GetArguments()["message"].(string); ok && message != "" { + requestBody["message"] = message + } else { + requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + result, err := makeWikiAPIRequest(ctx, client, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), requestBody) + if err != nil { + return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err)) + } + + return to.TextResult(result) +} + +func DeleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteWikiPageFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + pageName, ok := req.GetArguments()["pageName"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("pageName is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + _, err = makeWikiAPIRequest(ctx, client, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err)) + } + + return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"}) +} + +// Helper function to make HTTP requests to Gitea Wiki API +func makeWikiAPIRequest(ctx context.Context, client interface{}, method, path string, body interface{}) (interface{}, error) { + // Use flags to get base URL and token + apiURL := fmt.Sprintf("%s/api/v1/%s", flag.Host, path) + + httpClient := &http.Client{} + + var reqBody io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = strings.NewReader(string(bodyBytes)) + } + + req, err := http.NewRequestWithContext(ctx, method, apiURL, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("token %s", flag.Token)) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) + } + + if method == "DELETE" { + return map[string]string{"message": "success"}, nil + } + + var result interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result, nil +}