package seo_test import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" ) // principal resolves the backend administrator of an access token, the // identity an MCP session acts as. func (e *testEnv) principal(t *testing.T, token string) *bouncer.Principal { t.Helper() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer "+token) _, p, err := cabana.ResolveBackend(e.app, req) if err != nil || p == nil { t.Fatalf("resolve principal: %v", err) } return p } // mcp runs one request the way pier dispatches a tool call: in-process, // without an Authorization header, with the backend dispatch marker. func (e *testEnv) mcp(t *testing.T, method, path string, p *bouncer.Principal, body any) *httptest.ResponseRecorder { t.Helper() var reader io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } reader = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, reader) req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1) req.Header.Set("Accept-Language", "en") if body != nil { req.Header.Set("Content-Type", "application/json") } req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p})) rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } // mcpExpect runs an MCP-dispatched request and fails unless it answers // status; it returns the decoded JSON body. func (e *testEnv) mcpExpect(t *testing.T, status int, method, path string, p *bouncer.Principal, body any) map[string]any { t.Helper() rec := e.mcp(t, method, path, p, body) if rec.Code != status { t.Fatalf("MCP %s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String()) } out := map[string]any{} if rec.Body.Len() > 0 && strings.HasPrefix(strings.TrimSpace(rec.Body.String()), "{") { if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode %s: %v", rec.Body.String(), err) } } return out } // TestMCPSetURLSeoTracer is the end-to-end slice of D-10/D-11: an MCP // client (an administrator dispatched for the backend guard) sets noindex // on a URL a record is behind; the change lands in the record's SEO, // resolve serves it as robots and X-Robots-Tag, and history records it // with via mcp. A URL without a record gets an exact URL rule, and an // administrator without golem15.seo.manage is refused. func TestMCPSetURLSeoTracer(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) id := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true}) agent := env.principal(t, env.admin(t, false, "golem15.seo.manage")) got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{ "url": "/pages/a", "values": map[string]any{"robots": "noindex"}, }) if got["written_to"] != "record" || got["type"] != pagesTypeKey || got["id"] != id || got["locale"] != "en" { t.Fatalf("set record = %v, want written_to record %s/%s en", got, pagesTypeKey, id) } res := env.resolve(t, "/pages/a") seoBody, _ := res["seo"].(map[string]any) if res["action"] != "ok" || !strings.HasPrefix(fmt.Sprint(seoBody["robots"]), "noindex") || !strings.HasPrefix(fmt.Sprint(seoBody["x_robots_tag"]), "noindex") { t.Fatalf("resolve /pages/a = %v, want robots and x_robots_tag noindex", res) } revs := env.revisions(t, "entry") if len(revs) != 1 || revs[0].Via != "mcp" || !strings.HasPrefix(revs[0].AuthorLabel, "MCP: ") || !strings.Contains(revs[0].After, "noindex") { t.Fatalf("entry revisions = %+v, want one via mcp with the MCP author and noindex", revs) } got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{ "url": "/landing", "values": map[string]any{"title": "Landing", "robots": "noindex,follow"}, }) ruleID, _ := got["rule_id"].(float64) if got["written_to"] != "rule" || ruleID == 0 || got["url"] != "/landing" { t.Fatalf("set /landing = %v, want written_to rule with a rule id", got) } var rules []struct { URL, Match, Title, Robots string } if err := env.db.Raw(`SELECT url, match, title, robots FROM golem15_seo_rules`).Scan(&rules).Error; err != nil { t.Fatal(err) } if len(rules) != 1 || rules[0].URL != "/landing" || rules[0].Match != "exact" || rules[0].Title != "Landing" { t.Fatalf("rules = %+v, want one exact /landing rule", rules) } if revs := env.revisions(t, "rule"); len(revs) != 1 || revs[0].Via != "mcp" { t.Fatalf("rule revisions = %+v, want one via mcp", revs) } res = env.resolve(t, "/landing") seoBody, _ = res["seo"].(map[string]any) if res["action"] != "ok" || seoBody["title"] != "Landing" { t.Fatalf("resolve /landing = %v, want the rule's title", res) } // force_rule writes a rule even with a record behind the URL. got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{ "url": "/pages/a", "values": map[string]any{"title": "Forced"}, "force_rule": true, }) if got["written_to"] != "rule" { t.Fatalf("force_rule = %v, want written_to rule", got) } // Without golem15.seo.manage: 403 over MCP and over a bearer token. plain := env.admin(t, false) if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", env.principal(t, plain), map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden { t.Fatalf("MCP without the permission = %d %s, want 403", rec.Code, rec.Body.String()) } if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", plain, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden { t.Fatalf("bearer without the permission = %d %s, want 403", rec.Code, rec.Body.String()) } if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", "", map[string]any{"url": "/pages/a"}); rec.Code != http.StatusUnauthorized { t.Fatalf("anonymous = %d, want 401", rec.Code) } if revs := env.revisions(t, "entry"); len(revs) != 1 { t.Fatalf("refused writes changed history: %+v", revs) } } // issueKinds indexes an issue report by kind and URL. func issueKinds(t *testing.T, body map[string]any) map[string]map[string]map[string]any { t.Helper() out := map[string]map[string]map[string]any{} list, _ := body["data"].([]any) for _, item := range list { issue, _ := item.(map[string]any) kind, _ := issue["kind"].(string) u, _ := issue["url"].(string) if out[kind] == nil { out[kind] = map[string]map[string]any{} } out[kind][u] = issue } return out } // TestIssues checks the CMS-data issue report (D-13, D-14 CMS part): // missing descriptions, duplicate titles across records, a URL rule over a // record, a redirect chain with its final target, a canonical pointing to // a redirected address and a noindex page robots.txt blocks, each with // the tool that fixes it. func TestIssues(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) agent := env.principal(t, env.admin(t, false, "golem15.seo.manage")) env.createPage(t, token, map[string]any{"title": "Same", "slug": "a", "excerpt": "First page", "published": true}) env.createPage(t, token, map[string]any{"title": "Same", "slug": "b", "excerpt": "", "published": true}) if _, err := env.seoPlugin(t).Service().GenerateEntries(t.Context(), ""); err != nil { t.Fatal(err) } env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/pages/a", "description": "Rule description"}) env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old2", "target": "/pages/a"}) env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old1", "target": "/old2"}) env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/b", "values": map[string]any{"canonical": "/old2"}}) env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "noindex"}, "force_rule": false}) env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /pages/a\n"}) body := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues", agent, nil) kinds := issueKinds(t, body) expect := func(kind, u, tool string) map[string]any { t.Helper() issue, ok := kinds[kind][u] if !ok { t.Fatalf("no %s issue for %s in %v", kind, u, body["data"]) } if issue["tool"] != tool { t.Fatalf("%s %s tool = %v, want %s", kind, u, issue["tool"], tool) } return issue } expect("missing_description", "/pages/b", "seo_set_url_seo") dup := expect("duplicate_title", "/pages/a", "seo_set_url_seo") if also := fmt.Sprint(dup["details"]); !strings.Contains(also, "/pages/b") { t.Fatalf("duplicate details = %s, want /pages/b", also) } expect("rule_overrides_record", "/pages/a", "seo_update_rule") chain := expect("redirect_chain", "/old1", "seo_update_redirect") if details, _ := chain["details"].(map[string]any); details["final"] != "/pages/a" { t.Fatalf("chain details = %v, want final /pages/a", chain["details"]) } expect("canonical_redirected", "/pages/b", "seo_set_url_seo") expect("noindex_blocked", "/pages/a", "seo_update_robots") if _, ok := kinds["missing_title"]; ok { t.Fatalf("missing_title reported though every page has a title: %v", kinds["missing_title"]) } only := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=redirect_chain", agent, nil) if list, _ := only["data"].([]any); len(list) != 1 { t.Fatalf("kind filter = %v, want one redirect_chain", only["data"]) } } // TestManagementAPI walks the management routes the tools bind to as an // MCP client: the effective SEO with sources and owner, URL rules, // redirects (a loop refused with redirect_loop, a chain saved with a // warning, origin mcp), the 404 log actions, robots.txt and its tester, // settings with refusals, and history. func TestManagementAPI(t *testing.T) { env := bootEnv(t, "SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=report-token") token := env.admin(t, true) agent := env.principal(t, env.admin(t, false, "golem15.seo.manage")) env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true}) got := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/seo?url=/pages/a", agent, nil) owner, _ := got["owner"].(map[string]any) sources, _ := got["sources"].(map[string]any) if got["action"] != "ok" || owner["written_to"] != "record" || sources["title"] == nil || sources["canonical"] != "default" { t.Fatalf("get_url_seo = %v", got) } if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/seo?url=nope", agent, nil); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("bad url = %d, want 422", rec.Code) } // A social image is a path or an http(s) URL on every write path, as // in the admin field (WR-16). for _, bad := range []string{"javascript:alert(1)", "data:image/png;base64,AAAA", "//evil.example/x.png", "a b.png"} { if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": bad}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) { t.Fatalf("set_url_seo og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String()) } if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/img-rule", "og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) { t.Fatalf("create_rule og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String()) } if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"default_og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"default_og_image"`) { t.Fatalf("update_settings default_og_image %q = %d %s, want 422", bad, rec.Code, rec.Body.String()) } } env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": "https://cdn.example/a.png"}}) // A locale the record does not describe is refused (WR-18); one it // does is written. for _, bad := range []string{"xx", "EN "} { if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": bad, "values": map[string]any{"title": "Lost"}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale"`) { t.Fatalf("set_url_seo locale %q = %d %s, want 422 on locale", bad, rec.Code, rec.Body.String()) } } if got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": "pl", "values": map[string]any{"title": "Po polsku"}}); got["locale"] != "pl" { t.Fatalf("set_url_seo locale pl = %v", got) } // A rule address is capped like a redirect source, so a long one is a // field refusal, not a database error from the unique index (WR-23). long := "/" + strings.Repeat("x", 3000) if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": long, "title": "Long"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"url"`) { t.Fatalf("create_rule with a 3001-byte url = %d %s, want 422 on url", rec.Code, rec.Body.String()) } if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": long, "values": map[string]any{"title": "Long"}}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("set_url_seo on a 3001-byte url = %d %s, want 422", rec.Code, rec.Body.String()) } // URL rules. rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"}) data, _ := rule["data"].(map[string]any) ruleID := fmt.Sprint(data["id"]) if data["robots"] == "" || data["match"] != "pattern" { t.Fatalf("create_rule = %v", rule) } if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "validation_failed") { t.Fatalf("duplicate rule = %d %s, want 422 validation_failed", rec.Code, rec.Body.String()) } env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/rules/"+ruleID, agent, map[string]any{"title": "Tagged"}) shown := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil) if d, _ := shown["data"].(map[string]any); d["title"] != "Tagged" || d["robots"] == "" { t.Fatalf("show_rule = %v, want the update to keep robots", shown) } list := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules?search=blog", agent, nil) if list["total"] != float64(1) { t.Fatalf("list_rules = %v", list) } env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/rules/"+ruleID, agent, nil) if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil); rec.Code != http.StatusNotFound { t.Fatalf("deleted rule = %d, want 404", rec.Code) } // A status, match or query outside the allowed values is refused, not // replaced by the default (WR-17). for field, value := range map[string]any{"status": 308, "match": "Regex", "query": "keep"} { rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/coerced", "target": "/y", field: value}) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`"`) { t.Fatalf("create_redirect %s=%v = %d %s, want 422 on %s", field, value, rec.Code, rec.Body.String(), field) } } // Redirects: a chain is saved with a warning, a loop refused. first := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/x", "target": "/y"}) if d, _ := first["data"].(map[string]any); d["origin"] != "mcp" || d["status"] != float64(301) { t.Fatalf("create_redirect = %v, want origin mcp and 301", first) } chain := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/y", "target": "/z"}) if chain["warnings"] == nil { t.Fatalf("chain = %v, want a chain warning", chain) } loop := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/z", "target": "/x"}) if loop.Code != http.StatusUnprocessableEntity || !strings.Contains(loop.Body.String(), `"error":"redirect_loop"`) || !strings.Contains(loop.Body.String(), `"path":[`) { t.Fatalf("loop = %d %s, want 422 redirect_loop with the path", loop.Code, loop.Body.String()) } if n := env.redirectCount(t); n != 2 { t.Fatalf("redirects after the refused loop = %d, want 2", n) } firstID := fmt.Sprint(first["data"].(map[string]any)["id"]) if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"source": "/z"}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("update into a loop = %d %s, want 422", rec.Code, rec.Body.String()) } env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"status": 302}) if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects/"+firstID, agent, nil)["data"].(map[string]any); d["status"] != float64(302) || d["target"] != "/y" { t.Fatalf("show_redirect = %v", d) } if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?status=302", agent, nil); l["total"] != float64(1) { t.Fatalf("list_redirects?status=302 = %v", l) } env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/redirects/"+firstID, agent, nil) // 404 log. for _, u := range []string{"/missing-1", "/missing-2", "/missing-3"} { if rec := env.report(t, "report-token", map[string]string{"url": u}); rec.Code != http.StatusNoContent { t.Fatalf("report %s = %d", u, rec.Code) } } nf := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=open", agent, nil) rows, _ := nf["data"].([]any) if len(rows) != 3 { t.Fatalf("list_not_found = %v", nf) } ids := map[string]string{} for _, row := range rows { m := row.(map[string]any) ids[fmt.Sprint(m["url"])] = fmt.Sprint(m["id"]) } if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("redirect without target = %d, want 422", rec.Code) } env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{"target": "/pages/a"}) if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/gone", agent, nil); rec.Code != http.StatusConflict { t.Fatalf("gone on a resolved address = %d %s, want 409", rec.Code, rec.Body.String()) } gone := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-2"]+"/gone", agent, nil) if d, _ := gone["data"].(map[string]any); d["status"] != float64(410) || d["origin"] != "notfound" { t.Fatalf("gone_not_found = %v", gone) } env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/notfound/"+ids["/missing-3"], agent, nil) if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=resolved", agent, nil); l["total"] != float64(2) { t.Fatalf("resolved = %v, want 2", l) } // robots.txt. if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"bots": map[string]any{"NoSuchBot": false}}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("unknown bot = %d, want 422", rec.Code) } robots := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /private\n", "bots": map[string]any{"GPTBot": false}}) if d, _ := robots["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["production"]), "GPTBot") { t.Fatalf("update_robots = %v, want a GPTBot group in production", robots) } test := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots/test?url=/private/x&agent=Googlebot", agent, nil) if res, _ := test["result"].(map[string]any); res["allowed"] != false || !strings.Contains(fmt.Sprint(res["rule"]), "/private") { t.Fatalf("test_robots = %v", test) } if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots", agent, nil)["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["content"]), "/private") { t.Fatalf("show_robots = %v", d) } // Settings. if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"title_template": "%nope% | %site%"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "title_template") { t.Fatalf("bad template = %d %s, want 422 on title_template", rec.Code, rec.Body.String()) } if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"url_host": "sometimes"}); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("bad url_host = %d, want 422", rec.Code) } settings := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{ "site_name": "Example", "csv_delimiter": ";", "types": map[string]any{pagesTypeKey: map[string]any{"title_template": "%title% – %site%"}}, }) d, _ := settings["data"].(map[string]any) values, _ := d["settings"].(map[string]any) types, _ := d["types"].(map[string]any) pageType, _ := types[pagesTypeKey].(map[string]any) if values["site_name"] != "Example" || values["csv_delimiter"] != ";" || pageType["title_template"] != "%title% – %site%" { t.Fatalf("update_settings = %v", settings) } if v, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", agent, nil)["data"].(map[string]any); fmt.Sprint(v["settings"].(map[string]any)["site_name"]) != "Example" { t.Fatalf("show_settings = %v", v) } // History: every write above is recorded with via mcp. hist := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history?via=mcp&per_page=200", agent, nil) entries, _ := hist["data"].([]any) kinds := map[string]bool{} for _, e := range entries { kinds[fmt.Sprint(e.(map[string]any)["kind"])] = true } for _, k := range []string{"rule", "redirect", "robots", "settings", "notfound"} { if !kinds[k] { t.Fatalf("history via mcp lacks %s: %v", k, kinds) } } firstEntry := entries[0].(map[string]any) one := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history/"+fmt.Sprint(firstEntry["id"]), agent, nil) if one["data"].(map[string]any)["via"] != "mcp" { t.Fatalf("show_history = %v", one) } }