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

35
controllers/api/issues.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only: missing_title, missing_description, duplicate_title, duplicate_description, rule_overrides_record, redirect_chain, canonical_noindex, canonical_redirected or noindex_blocked"`
}
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14 CMS part): the
// CMS-data issue report, each issue with its URL, kind, details and the
// tool that fixes it, and a count per kind.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
issues, err := svc.Issues(ctx)
if err != nil {
return 0, nil, err
}
kind := r.URL.Query().Get("kind")
counts := map[string]int{}
out := make([]classes.Issue, 0, len(issues))
for _, issue := range issues {
counts[issue.Kind]++
if kind == "" || issue.Kind == kind {
out = append(out, issue)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil
})
}