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

@@ -1,9 +1,13 @@
package api
import (
"context"
"net/http"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// Robots serves GET /robots.txt (D-22, D-25, P-23) as text/plain. In
@@ -41,3 +45,109 @@ func Robots(service func() *classes.Service) http.HandlerFunc {
_, _ = w.Write([]byte(body))
}
}
// UpdateRobotsInput is the body of PUT /_seo/api/v1/robots.
type UpdateRobotsInput struct {
Content *string `json:"content,omitempty" jsonschema:"the robots.txt rules; Sitemap lines and the AI bot rules are added when the file is served"`
Bots map[string]bool `json:"bots,omitempty" jsonschema:"AI bot switches by user-agent token, for example {\"GPTBot\": false}; a bot switched off is disallowed"`
}
// TestRobotsInput is the query of GET /_seo/api/v1/robots/test.
type TestRobotsInput struct {
URL string `json:"url" query:"url" jsonschema:"the address to test, a path starting with /"`
Agent string `json:"agent,omitempty" query:"agent" jsonschema:"the crawler's user agent token (Googlebot by default)"`
}
// robotsJSON is the robots.txt screen in an answer: the rules, every AI
// bot with its switch, the file crawlers get in production and whether
// this site runs outside production (then crawlers get the lock).
func robotsJSON(ctx context.Context, svc *classes.Service, row models.Robots) (map[string]any, error) {
bots := make([]map[string]any, 0, len(classes.Bots))
for _, b := range classes.Bots {
bots = append(bots, map[string]any{"token": b.Token, "operator": b.Operator, "kind": b.Kind, "allowed": row.Bots.Allowed(b.Token)})
}
production := classes.ComposeRobots(row.Content, row.Bots, svc.RobotsSitemaps())
return map[string]any{
"content": row.Content,
"bots": bots,
"production": production,
"staging": svc.Staging(),
}, nil
}
// ShowRobots serves GET /_seo/api/v1/robots.
func ShowRobots(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
row, err := classes.LoadRobots(db)
if err != nil {
return 0, nil, err
}
body, err := robotsJSON(ctx, svc, row)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": body}, nil
})
}
// UpdateRobots serves PUT /_seo/api/v1/robots: the rules and the bot
// switches sent change (D-22, D-25). The answer lists the noindex
// addresses the new rules keep Googlebot from (robots.blocks_noindex).
func UpdateRobots(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in UpdateRobotsInput
if err := decode(r, classes.MaxRobotsBytes+(64<<10), &in); err != nil {
return 0, nil, err
}
var row models.Robots
var blocked []string
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, blocked, err = svc.SaveRobots(ctx, tx, classes.RobotsWrite{Content: in.Content, Bots: in.Bots})
return err
})
if err != nil {
return 0, nil, err
}
body, err := robotsJSON(ctx, svc, row)
if err != nil {
return 0, nil, err
}
out := map[string]any{"data": body}
if len(blocked) > 0 {
out["warnings"] = []map[string]any{{"kind": "blocks_noindex", "urls": blocked}}
}
return http.StatusOK, out, nil
})
}
// TestRobotsURL serves GET /_seo/api/v1/robots/test?url=&agent=: whether
// the crawler may fetch the address under the saved production file,
// through Google's matcher, with the deciding line (D-99).
func TestRobotsURL(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
path := strings.TrimSpace(r.URL.Query().Get("url"))
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > classes.MaxURLBytes {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "validation_failed", "errors": map[string][]string{"url": {svc.PhraseCtx(ctx, "golem15.seo::lang.robots.url_required", nil)}},
}}
}
agent := strings.TrimSpace(r.URL.Query().Get("agent"))
if agent == "" {
agent = "Googlebot"
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
row, err := classes.LoadRobots(db)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"url": path, "result": svc.TestRobots(row.Content, row.Bots, agent, path)}, nil
})
}