feat: management API and an MCP tool per SEO admin operation

- /_seo/api/v1 routes for the effective SEO of a URL with sources and
  owner, URL rules, redirects (loops refused with redirect_loop), the
  404 log actions, robots.txt and its tester, settings and history
- the CMS-data issue report: missing and duplicate titles and
  descriptions, rules over records, redirect chains, canonicals to
  noindex or redirected addresses, noindex addresses robots.txt blocks
- 24 MCP tools on those routes (backend guard, golem15.seo.manage)
- AdminOperations maps every admin operation to its tool;
  TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift
- robots.txt writes go through classes.SaveRobots from the admin too
This commit is contained in:
2026-10-27 12:17:36 +01:00
parent cadf6ad805
commit aa0c3bcacf
22 changed files with 2723 additions and 46 deletions

View File

@@ -183,3 +183,57 @@ func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Contex
func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
return svc.DB(ctx)
}
// The page sizes of the list endpoints.
const (
defaultPerPage = 50
maxPerPage = 200
)
// ListInput is the paging and search query every list tool shares.
type ListInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address or value contains"`
}
// listParams reads page, per_page and search.
func listParams(r *http.Request) ListInput {
q := r.URL.Query()
in := ListInput{Search: strings.TrimSpace(q.Get("search"))}
in.Page, _ = strconv.Atoi(q.Get("page"))
in.PerPage, _ = strconv.Atoi(q.Get("per_page"))
if in.Page < 1 {
in.Page = 1
}
if in.PerPage < 1 {
in.PerPage = defaultPerPage
}
if in.PerPage > maxPerPage {
in.PerPage = maxPerPage
}
return in
}
// paginate counts q and loads one page of it into dest.
func paginate(q *gorm.DB, in ListInput, dest any) (int64, error) {
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return 0, err
}
if err := q.Offset((in.Page - 1) * in.PerPage).Limit(in.PerPage).Find(dest).Error; err != nil {
return 0, err
}
return total, nil
}
// listBody is a list answer: the rows and the paging.
func listBody(rows any, in ListInput, total int64) map[string]any {
return map[string]any{"data": rows, "page": in.Page, "per_page": in.PerPage, "total": total}
}
// like is a case-insensitive LIKE pattern of text.
func like(text string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return "%" + strings.ToLower(r.Replace(text)) + "%"
}