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

@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
@@ -116,43 +114,24 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension {
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
row, ok := in.Record.(*models.Robots)
if !ok || row == nil {
if row, ok := in.Record.(*models.Robots); !ok || row == nil {
return pact.AdminFormSaveResult{}, nil
}
before := robotsValues(*row)
var w classes.RobotsWrite
if raw, ok := in.Values["content"]; ok {
if text, ok := raw.(string); ok {
row.Content = text
w.Content = &text
}
}
bots := models.BotSwitches{}
for token, on := range row.Bots {
bots[token] = on
}
for _, b := range classes.Bots {
if raw, ok := in.Values[classes.BotField(b.Token)]; ok {
bots[b.Token] = truthy(raw)
if w.Bots == nil {
w.Bots = map[string]bool{}
}
w.Bots[b.Token] = truthy(raw)
}
}
row.Bots = bots
now := time.Now().UTC()
row.UpdatedAt = &now
if err := in.DB.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
"content": row.Content, "bots": row.Bots, "updated_at": now,
}).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectRobots,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: svc.Phrase("golem15.seo::lang.history.what_robots", nil),
Before: before,
After: robotsValues(*row),
}); err != nil {
return pact.AdminFormSaveResult{}, err
}
blocked, err := svc.BlockedNoindex(in.DB, row.Content, row.Bots)
_, blocked, err := svc.SaveRobots(ctx, in.DB, w)
if err != nil || len(blocked) == 0 {
return pact.AdminFormSaveResult{}, err
}
@@ -169,15 +148,6 @@ func (p *Plugin) robotsForm() *pact.AdminFormExtension {
}
}
// robotsValues are the robots row's values for history.
func robotsValues(r models.Robots) map[string]any {
bots := map[string]any{}
for _, b := range classes.Bots {
bots[b.Token] = r.Bots.Allowed(b.Token)
}
return map[string]any{"content": r.Content, "bots": bots}
}
// robotsTestPayload is the tester's request: op context answers the
// crawler list and the environment; op test answers whether agent may
// fetch url under the editor's unsaved text and switches.