fix(tools,gitea): alias repo/index args and reject empty path segments
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 23s
CD / Deploy via GitOps (push) Has been skipped

Per-repo MCP tools 404'd while owner-level tools worked (#36). Root cause was
a parameter-name contract mismatch, not a routing fault: every per-repo tool
declares the repo identifier as 'name' (and issue/PR index as 'number'), but
every caller — the claude.ai connector, LLMs primed on gitea's own API — sends
'repo' and 'index'. The unmatched fields zero-valued the upstream path segment,
producing '/api/v1/repos/{owner}//...', which gitea answers with its generic
api-404 whose body points at /api/swagger. That swagger pointer is gitea boiler-
plate, not a misroute — the MCP dispatch was correct all along.

Two layers of defence:
- parseArgs aliases repo->name and index->number (explicit canonical wins;
  alias key left intact so pr_merge's real 'index' field is unaffected). Kills
  the recurrence by accepting the idiomatic argument names.
- the gitea client rejects any path with an empty segment before the HTTP call,
  returning ErrValidation instead of forwarding a malformed path and surfacing
  gitea's opaque swagger-404. Kills the silent-misleading-error class.

Closes #36

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:16:33 +02:00
co-authored by Claude Opus 4.8
parent e2594f45f2
commit 184d5a95dd
4 changed files with 171 additions and 1 deletions
+49 -1
View File
@@ -2,6 +2,7 @@ package tools
import (
"encoding/json"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
@@ -17,7 +18,54 @@ func parseArgs(raw json.RawMessage, dst any) error {
if len(raw) == 0 {
return json.Unmarshal([]byte("{}"), dst)
}
return json.Unmarshal(raw, dst)
return json.Unmarshal(normalizeAliases(raw), dst)
}
// normalizeAliases maps the near-universal gitea/GitHub argument names onto the
// idiosyncratic ones these tools declare: `repo` -> `name`, `index` -> `number`.
// Every MCP caller (the claude.ai connector, LLMs primed on gitea's own API)
// reaches for `repo`/`index`; without this the unmatched fields zero-valued the
// upstream path segment and produced gitea's misleading /api/swagger 404 (#36).
// The alias key is left intact so a tool whose real field IS `index`
// (e.g. pr_merge) is unaffected.
func normalizeAliases(raw json.RawMessage) json.RawMessage {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return raw // not a JSON object — leave untouched
}
changed := aliasInto(m, "name", "repo")
changed = aliasInto(m, "number", "index") || changed
if !changed {
return raw
}
if patched, err := json.Marshal(m); err == nil {
return patched
}
return raw
}
// aliasInto copies m[alias] into m[canonical] when canonical is absent or an
// empty/zero JSON value and alias carries a usable one. An explicit canonical
// value always wins. Returns true if it wrote canonical.
func aliasInto(m map[string]json.RawMessage, canonical, alias string) bool {
av, ok := m[alias]
if !ok || isEmptyJSON(av) {
return false
}
if cv, ok := m[canonical]; ok && !isEmptyJSON(cv) {
return false
}
m[canonical] = av
return true
}
func isEmptyJSON(v json.RawMessage) bool {
switch strings.TrimSpace(string(v)) {
case "", `""`, "null", "0":
return true
default:
return false
}
}
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.