Compare commits

..

1 Commits

Author SHA1 Message Date
Lunny Xiao b22ee74148 feat(params): add structured argument binding helper
Add params.Bind, which unmarshals a tool call's map[string]any args into a
typed struct via JSON round-trip so JSON numbers land in the correct Go
numeric field types, and enforces `required:"true"` struct tags with clear
errors. Migrate the branch, tree, and file repo handlers to use it instead
of repeated args["x"].(string)/!ok extraction, preserving existing
validation behavior for each field.

Co-Authored-By: Codet <codet@commitgo.dev> (GPT-5-Codex)
2026-08-24 00:14:34 -07:00
7 changed files with 290 additions and 275 deletions
-98
View File
@@ -1,98 +0,0 @@
package actions
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func Test_listPullRequestActionRunsFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
pullNumber = 42
headSHA = "abc123"
)
var gotRunsQuery string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, pullNumber):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"head":{"sha":"%s"}}`, pullNumber, headSHA))
case fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs", owner, repo):
gotRunsQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"id":9,"name":"CI","status":"success"}]}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
flag.Host = server.URL
flag.Token = ""
defer func() {
flag.Host = origHost
flag.Token = origToken
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"pull_number": float64(pullNumber),
}
result, err := listPullRequestActionRunsFn(context.Background(), args)
if err != nil {
t.Fatalf("listPullRequestActionRunsFn() error = %v", err)
}
if result.IsError {
t.Fatalf("listPullRequestActionRunsFn() returned error result: %+v", result)
}
if gotRunsQuery == "" {
t.Fatalf("expected actions/runs to be called")
}
values, err := url.ParseQuery(gotRunsQuery)
if err != nil {
t.Fatalf("parse actions/runs query: %v", err)
}
if got := values.Get("head_sha"); got != headSHA {
t.Fatalf("actions/runs head_sha = %q, want %q", got, headSHA)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if len(parsed.WorkflowRuns) != 1 {
t.Fatalf("expected 1 run, got %d", len(parsed.WorkflowRuns))
}
if got := parsed.WorkflowRuns[0]["name"]; got != "CI" {
t.Fatalf("run name = %v, want %q", got, "CI")
}
}
+2 -60
View File
@@ -29,16 +29,15 @@ var (
ActionsRunReadToolName, ActionsRunReadToolName,
"Read Actions workflows, runs, jobs, logs, and artifacts.", "Read Actions workflows, runs, jobs, logs, and artifacts.",
annotation.ReadOnly("Read Actions workflow, run, job, and artifact data"), annotation.ReadOnly("Read Actions workflow, run, job, and artifact data"),
tool.String("method", tool.Required(), tool.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_pr_runs", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")), tool.String("method", tool.Required(), tool.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.String("workflow_id", tool.Description("ID or filename (for 'get_workflow')")), tool.String("workflow_id", tool.Description("ID or filename (for 'get_workflow')")),
tool.Number("run_id", tool.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")), tool.Number("run_id", tool.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")),
tool.Number("pull_number", tool.Description("pull request number (for 'list_pr_runs')")),
tool.Number("job_id", tool.Description("for 'get_job'/log methods")), tool.Number("job_id", tool.Description("for 'get_job'/log methods")),
tool.Number("artifact_id", tool.Description("for 'get_artifact'/'download_artifact'")), tool.Number("artifact_id", tool.Description("for 'get_artifact'/'download_artifact'")),
tool.String("artifact_name", tool.Description("name filter for 'list_artifacts'/'list_run_artifacts'")), tool.String("artifact_name", tool.Description("name filter for 'list_artifacts'/'list_run_artifacts'")),
tool.String("status", tool.Description("filter for 'list_runs'/'list_pr_runs'/'list_jobs'")), tool.String("status", tool.Description("filter for 'list_runs'/'list_jobs'")),
tool.Number("tail_lines", tool.Description("log tail lines"), tool.Default(200), tool.Minimum(1)), tool.Number("tail_lines", tool.Description("log tail lines"), tool.Default(200), tool.Minimum(1)),
tool.Number("max_bytes", tool.Description("max log bytes"), tool.Default(65536), tool.Minimum(1024)), tool.Number("max_bytes", tool.Description("max log bytes"), tool.Default(65536), tool.Minimum(1024)),
tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")), tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")),
@@ -79,8 +78,6 @@ func runReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, e
return listRepoActionRunsFn(ctx, args) return listRepoActionRunsFn(ctx, args)
case "get_run": case "get_run":
return getRepoActionRunFn(ctx, args) return getRepoActionRunFn(ctx, args)
case "list_pr_runs":
return listPullRequestActionRunsFn(ctx, args)
case "list_jobs": case "list_jobs":
return listRepoActionJobsFn(ctx, args) return listRepoActionJobsFn(ctx, args)
case "list_run_jobs": case "list_run_jobs":
@@ -300,61 +297,6 @@ func getRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallTool
return to.TextResult(slimActionRun(result)) return to.TextResult(slimActionRun(result))
} }
func listPullRequestActionRunsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
pullNumber, err := params.GetIndex(args, "pull_number")
if err != nil || pullNumber <= 0 {
return to.ErrorResult(errors.New("pull_number is required"))
}
page, pageSize := params.GetPagination(args, 30)
statusFilter, _ := args["status"].(string)
var pull struct {
Head struct {
SHA string `json:"sha"`
} `json:"head"`
}
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/pulls/%d", url.PathEscape(owner), url.PathEscape(repo), pullNumber),
},
nil, nil, &pull,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request err: %v", err))
}
if pull.Head.SHA == "" {
return to.ErrorResult(fmt.Errorf("pull request %d has no head sha", pullNumber))
}
query := url.Values{}
query.Set("head_sha", pull.Head.SHA)
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull request action runs err: %v", err))
}
return to.TextResult(slimActionRuns(result))
}
func cancelRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func cancelRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
+29 -32
View File
@@ -69,28 +69,26 @@ func init() {
}) })
} }
type createBranchArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Branch string `json:"branch" required:"true"`
OldBranch string `json:"old_branch"`
}
func CreateBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func CreateBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in createBranchArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(args, "branch")
if err != nil {
return to.ErrorResult(err)
}
oldBranch, _ := args["old_branch"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, _, err = client.Repositories.CreateBranch(ctx, owner, repo, gitea_sdk.CreateBranchOption{ _, _, err = client.Repositories.CreateBranch(ctx, in.Owner, in.Repo, gitea_sdk.CreateBranchOption{
BranchName: branch, BranchName: in.Branch,
OldBranchName: oldBranch, OldBranchName: in.OldBranch,
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create branch error: %v", err)) return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
@@ -99,24 +97,22 @@ func CreateBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResu
return to.TextResult("Branch Created") return to.TextResult("Branch Created")
} }
type deleteBranchArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Branch string `json:"branch" required:"true"`
}
func DeleteBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func DeleteBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in deleteBranchArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(args, "branch")
if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, _, err = client.Repositories.DeleteRepoBranch(ctx, owner, repo, branch) _, _, err = client.Repositories.DeleteRepoBranch(ctx, in.Owner, in.Repo, in.Branch)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch error: %v", err)) return to.ErrorResult(fmt.Errorf("delete branch error: %v", err))
} }
@@ -124,13 +120,14 @@ func DeleteBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResu
return to.TextResult("Branch Deleted") return to.TextResult("Branch Deleted")
} }
type listBranchesArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
}
func ListBranchesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func ListBranchesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in listBranchesArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(args, 30) page, pageSize := params.GetPagination(args, 30)
@@ -144,7 +141,7 @@ func ListBranchesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResu
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
branches, _, err := client.Repositories.ListRepoBranches(ctx, owner, repo, opt) branches, _, err := client.Repositories.ListRepoBranches(ctx, in.Owner, in.Repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list branches error: %v", err)) return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
} }
+60 -71
View File
@@ -102,30 +102,28 @@ type ContentLine struct {
Content string `json:"content"` Content string `json:"content"`
} }
type getFileContentArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Ref string `json:"ref"`
Path string `json:"path" required:"true"`
WithLines bool `json:"withLines"`
}
func GetFileContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func GetFileContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in getFileContentArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
content, _, err := client.Repositories.GetContents(ctx, owner, repo, ref, filePath) content, _, err := client.Repositories.GetContents(ctx, in.Owner, in.Repo, in.Ref, in.Path)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get file err: %v", err)) return to.ErrorResult(fmt.Errorf("get file err: %v", err))
} }
withLines, _ := args["withLines"].(bool) if in.WithLines {
if withLines {
rawContent, err := base64.StdEncoding.DecodeString(*content.Content) rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err)) return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err))
@@ -164,49 +162,45 @@ func GetFileContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolRe
return to.TextResult(slimContents(content)) return to.TextResult(slimContents(content))
} }
type getDirContentArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Ref string `json:"ref"`
Path string `json:"path" required:"true"`
}
func GetDirContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func GetDirContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in getDirContentArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
content, _, err := client.Repositories.ListContents(ctx, owner, repo, ref, filePath) content, _, err := client.Repositories.ListContents(ctx, in.Owner, in.Repo, in.Ref, in.Path)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get dir content err: %v", err)) return to.ErrorResult(fmt.Errorf("get dir content err: %v", err))
} }
return to.TextResult(slimDirEntries(content)) return to.TextResult(slimDirEntries(content))
} }
type createOrUpdateFileArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Path string `json:"path" required:"true"`
Content string `json:"content"`
Message string `json:"message"`
BranchName string `json:"branch_name"`
NewBranchName string `json:"new_branch_name"`
SHA string `json:"sha"`
}
func CreateOrUpdateFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func CreateOrUpdateFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in createOrUpdateFileArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
content, _ := args["content"].(string)
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
newBranchName, _ := args["new_branch_name"].(string)
sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -214,20 +208,20 @@ func CreateOrUpdateFileFn(ctx context.Context, args map[string]any) (*mcp.CallTo
} }
fileOpt := gitea_sdk.FileOptions{ fileOpt := gitea_sdk.FileOptions{
Message: message, Message: in.Message,
BranchName: branchName, BranchName: in.BranchName,
NewBranchName: newBranchName, NewBranchName: in.NewBranchName,
} }
targetBranch := cmp.Or(newBranchName, branchName) targetBranch := cmp.Or(in.NewBranchName, in.BranchName)
if sha != "" { if in.SHA != "" {
// Update existing file // Update existing file
opt := gitea_sdk.UpdateFileOptions{ opt := gitea_sdk.UpdateFileOptions{
SHA: sha, SHA: in.SHA,
Content: base64.StdEncoding.EncodeToString([]byte(content)), Content: base64.StdEncoding.EncodeToString([]byte(in.Content)),
FileOptions: fileOpt, FileOptions: fileOpt,
} }
_, _, err = client.Repositories.UpdateFile(ctx, owner, repo, filePath, opt) _, _, err = client.Repositories.UpdateFile(ctx, in.Owner, in.Repo, in.Path, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err)) return to.ErrorResult(fmt.Errorf("update file err: %v", err))
} }
@@ -236,47 +230,42 @@ func CreateOrUpdateFileFn(ctx context.Context, args map[string]any) (*mcp.CallTo
// Create new file // Create new file
opt := gitea_sdk.CreateFileOptions{ opt := gitea_sdk.CreateFileOptions{
Content: base64.StdEncoding.EncodeToString([]byte(content)), Content: base64.StdEncoding.EncodeToString([]byte(in.Content)),
FileOptions: fileOpt, FileOptions: fileOpt,
} }
_, _, err = client.Repositories.CreateFile(ctx, owner, repo, filePath, opt) _, _, err = client.Repositories.CreateFile(ctx, in.Owner, in.Repo, in.Path, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create file err: %v", err)) return to.ErrorResult(fmt.Errorf("create file err: %v", err))
} }
return to.TextResult("Create file success on branch " + targetBranch) return to.TextResult("Create file success on branch " + targetBranch)
} }
type deleteFileArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
Path string `json:"path" required:"true"`
Message string `json:"message"`
BranchName string `json:"branch_name"`
SHA string `json:"sha" required:"true"`
}
func DeleteFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func DeleteFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in deleteFileArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
sha, err := params.GetString(args, "sha")
if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.DeleteFileOptions{ opt := gitea_sdk.DeleteFileOptions{
FileOptions: gitea_sdk.FileOptions{ FileOptions: gitea_sdk.FileOptions{
Message: message, Message: in.Message,
BranchName: branchName, BranchName: in.BranchName,
}, },
SHA: sha, SHA: in.SHA,
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.Repositories.DeleteFile(ctx, owner, repo, filePath, opt) _, err = client.Repositories.DeleteFile(ctx, in.Owner, in.Repo, in.Path, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete file err: %v", err)) return to.ErrorResult(fmt.Errorf("delete file err: %v", err))
} }
+12 -14
View File
@@ -37,20 +37,18 @@ func init() {
}) })
} }
type getRepoTreeArgs struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
TreeSHA string `json:"tree_sha" required:"true"`
Recursive bool `json:"recursive"`
}
func GetRepoTreeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func GetRepoTreeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") var in getRepoTreeArgs
if err != nil { if err := params.Bind(args, &in); err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
treeSHA, err := params.GetString(args, "tree_sha")
if err != nil {
return to.ErrorResult(err)
}
recursive, _ := args["recursive"].(bool)
page, pageSize := params.GetPagination(args, 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListTreeOptions{ opt := gitea_sdk.ListTreeOptions{
@@ -58,14 +56,14 @@ func GetRepoTreeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResul
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
}, },
Ref: treeSHA, Ref: in.TreeSHA,
Recursive: recursive, Recursive: in.Recursive,
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
tree, _, err := client.Git.GetTrees(ctx, owner, repo, opt) tree, _, err := client.Git.GetTrees(ctx, in.Owner, in.Repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err)) return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
} }
+55
View File
@@ -0,0 +1,55 @@
package params
import (
"encoding/json"
"fmt"
"reflect"
"strings"
)
// Bind decodes args into out, a pointer to a struct, replacing the repeated
// args["x"].(string)/!ok extraction pattern. It round-trips args through JSON
// so JSON numbers land in the correct Go numeric field types.
//
// Struct fields tagged `required:"true"` must be present in args; a missing
// key, or an empty string value for a string field, returns an error naming
// the field's json tag.
func Bind(args map[string]any, out any) error {
v := reflect.ValueOf(out)
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
return fmt.Errorf("params.Bind: out must be a pointer to a struct, got %T", out)
}
if err := checkRequiredFields(args, v.Elem().Type()); err != nil {
return err
}
data, err := json.Marshal(args)
if err != nil {
return fmt.Errorf("params.Bind: marshal args: %w", err)
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("params.Bind: %w", err)
}
return nil
}
func checkRequiredFields(args map[string]any, t reflect.Type) error {
for field := range t.Fields() {
if field.Tag.Get("required") != "true" {
continue
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if name == "" || name == "-" {
continue
}
val, ok := args[name]
if !ok {
return fmt.Errorf("%s is required", name)
}
if s, isString := val.(string); isString && s == "" {
return fmt.Errorf("%s is required", name)
}
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package params
import (
"strings"
"testing"
)
func TestBind_RequiredFieldsPresent(t *testing.T) {
type args struct {
Owner string `json:"owner" required:"true"`
Repo string `json:"repo" required:"true"`
}
var out args
err := Bind(map[string]any{"owner": "gitea", "repo": "gitea-mcp"}, &out)
if err != nil {
t.Fatalf("Bind() unexpected error = %v", err)
}
if out.Owner != "gitea" || out.Repo != "gitea-mcp" {
t.Errorf("Bind() = %+v, want Owner=gitea Repo=gitea-mcp", out)
}
}
func TestBind_RequiredFieldMissing(t *testing.T) {
type args struct {
Owner string `json:"owner" required:"true"`
}
var out args
err := Bind(map[string]any{}, &out)
if err == nil {
t.Fatal("Bind() expected error, got nil")
}
if !strings.Contains(err.Error(), "owner") {
t.Errorf("Bind() error = %v, want mentioning %q", err, "owner")
}
}
func TestBind_RequiredStringFieldEmpty(t *testing.T) {
type args struct {
Owner string `json:"owner" required:"true"`
}
var out args
err := Bind(map[string]any{"owner": ""}, &out)
if err == nil {
t.Fatal("Bind() expected error for empty required string, got nil")
}
}
func TestBind_OptionalFieldDefaultsToZeroValue(t *testing.T) {
type args struct {
Owner string `json:"owner" required:"true"`
OldBranch string `json:"old_branch"`
}
var out args
err := Bind(map[string]any{"owner": "gitea"}, &out)
if err != nil {
t.Fatalf("Bind() unexpected error = %v", err)
}
if out.OldBranch != "" {
t.Errorf("Bind() OldBranch = %q, want empty", out.OldBranch)
}
}
func TestBind_NumericConversion(t *testing.T) {
type args struct {
Page int `json:"page"`
PerPage int64 `json:"per_page"`
}
var out args
err := Bind(map[string]any{"page": float64(2), "per_page": float64(40)}, &out)
if err != nil {
t.Fatalf("Bind() unexpected error = %v", err)
}
if out.Page != 2 || out.PerPage != 40 {
t.Errorf("Bind() = %+v, want Page=2 PerPage=40", out)
}
}
func TestBind_Boolean(t *testing.T) {
type args struct {
Recursive bool `json:"recursive"`
}
var out args
err := Bind(map[string]any{"recursive": true}, &out)
if err != nil {
t.Fatalf("Bind() unexpected error = %v", err)
}
if !out.Recursive {
t.Errorf("Bind() Recursive = false, want true")
}
}
func TestBind_Array(t *testing.T) {
type args struct {
Labels []string `json:"labels"`
IDs []int64 `json:"ids"`
}
var out args
err := Bind(map[string]any{
"labels": []any{"bug", "help wanted"},
"ids": []any{float64(1), float64(2)},
}, &out)
if err != nil {
t.Fatalf("Bind() unexpected error = %v", err)
}
if len(out.Labels) != 2 || out.Labels[0] != "bug" || out.Labels[1] != "help wanted" {
t.Errorf("Bind() Labels = %v, want [bug help wanted]", out.Labels)
}
if len(out.IDs) != 2 || out.IDs[0] != 1 || out.IDs[1] != 2 {
t.Errorf("Bind() IDs = %v, want [1 2]", out.IDs)
}
}
func TestBind_InvalidFieldType(t *testing.T) {
type args struct {
Page int `json:"page"`
}
var out args
err := Bind(map[string]any{"page": "not-a-number"}, &out)
if err == nil {
t.Fatal("Bind() expected error for invalid numeric field, got nil")
}
}
func TestBind_NonPointerRejected(t *testing.T) {
type args struct {
Owner string `json:"owner"`
}
err := Bind(map[string]any{}, args{})
if err == nil {
t.Fatal("Bind() expected error for non-pointer out, got nil")
}
}