From e64df28906cc262af6915af867a24cdb0f548951 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 17 Apr 2026 15:50:31 -0500 Subject: [PATCH] fix: correct login URL path in markdown negotiation content --- docs/plans/2026-04-17-agent-readiness.md | 767 ++++++++++++++++++ .../plugs/markdown_negotiation.ex | 2 +- 2 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-04-17-agent-readiness.md diff --git a/docs/plans/2026-04-17-agent-readiness.md b/docs/plans/2026-04-17-agent-readiness.md new file mode 100644 index 00000000..4b9deb65 --- /dev/null +++ b/docs/plans/2026-04-17-agent-readiness.md @@ -0,0 +1,767 @@ +# Agent Readiness Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make towerops.net pass all isitagentready.com checks by adding sitemap, well-known endpoints, Link headers, markdown negotiation, and WebMCP support. + +**Architecture:** Add a `WellKnownController` for all `/.well-known/*` JSON/stub endpoints, a `SitemapController` for `/sitemap.xml`, patch `PageController` for Link headers + markdown content negotiation, and add WebMCP JS to `app.js`. All endpoints are unauthenticated. + +**Tech Stack:** Phoenix controllers, Elixir, plain JSON (no extra libraries), standard XML generation, vanilla JS in app.js. + +--- + +## Task 1: Sitemap XML + robots.txt + +**Files:** +- Create: `lib/towerops_web/controllers/sitemap_controller.ex` +- Modify: `lib/towerops_web/router.ex` +- Modify: `priv/static/robots.txt` +- Test: `test/towerops_web/controllers/sitemap_controller_test.exs` + +**Step 1: Write the failing test** + +```elixir +# test/towerops_web/controllers/sitemap_controller_test.exs +defmodule ToweropsWeb.SitemapControllerTest do + use ToweropsWeb.ConnCase, async: true + + describe "GET /sitemap.xml" do + test "returns valid XML with public pages", %{conn: conn} do + conn = get(conn, "/sitemap.xml") + + assert response(conn, 200) + assert get_resp_header(conn, "content-type") == ["text/xml; charset=utf-8"] + body = response(conn, 200) + assert body =~ ~r/<\?xml/ + assert body =~ "https://towerops.net/" + assert body =~ "https://towerops.net/privacy" + assert body =~ "https://towerops.net/terms" + end + end +end +``` + +**Step 2: Run test to verify it fails** + +```bash +cd /Users/graham/dev/towerops/towerops-web +mix test test/towerops_web/controllers/sitemap_controller_test.exs +``` + +Expected: FAIL with no route/action error. + +**Step 3: Add the route to router.ex** + +In `lib/towerops_web/router.ex`, after the health check scope: + +```elixir +# Sitemap (no pipeline — public, no CSRF) +scope "/", ToweropsWeb do + get "/sitemap.xml", SitemapController, :index +end +``` + +**Step 4: Create the controller** + +```elixir +# lib/towerops_web/controllers/sitemap_controller.ex +defmodule ToweropsWeb.SitemapController do + @moduledoc false + + use ToweropsWeb, :controller + + @public_urls ["/", "/privacy", "/terms", "/docs/api", "/docs/graphql", "/help"] + + def index(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + xml = build_sitemap(base_url) + + conn + |> put_resp_content_type("text/xml") + |> send_resp(200, xml) + end + + defp build_sitemap(base_url) do + urls = + @public_urls + |> Enum.map(fn path -> + "#{base_url}#{path}" + end) + |> Enum.join("\n ") + + """ + + + #{urls} + + """ + |> String.trim() + end +end +``` + +**Step 5: Update robots.txt** + +Replace `priv/static/robots.txt` content: + +``` +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +User-agent: * +Allow: / + +Sitemap: https://towerops.net/sitemap.xml +``` + +**Step 6: Run tests to verify they pass** + +```bash +mix test test/towerops_web/controllers/sitemap_controller_test.exs +``` + +Expected: All tests PASS. + +**Step 7: Commit** + +```bash +git add lib/towerops_web/controllers/sitemap_controller.ex \ + test/towerops_web/controllers/sitemap_controller_test.exs \ + lib/towerops_web/router.ex \ + priv/static/robots.txt +git commit -m "feat: add /sitemap.xml and update robots.txt for agent discovery" +``` + +--- + +## Task 2: Well-Known JSON Endpoints + +Serve all `/.well-known/*` discovery endpoints from a single controller. + +**Files:** +- Create: `lib/towerops_web/controllers/well_known_controller.ex` +- Modify: `lib/towerops_web/router.ex` +- Test: `test/towerops_web/controllers/well_known_controller_test.exs` + +**Step 1: Write the failing tests** + +```elixir +# test/towerops_web/controllers/well_known_controller_test.exs +defmodule ToweropsWeb.WellKnownControllerTest do + use ToweropsWeb.ConnCase, async: true + + describe "GET /.well-known/api-catalog" do + test "returns linkset+json with API entries", %{conn: conn} do + conn = get(conn, "/.well-known/api-catalog") + assert response(conn, 200) + assert get_resp_header(conn, "content-type") == ["application/linkset+json; charset=utf-8"] + body = json_response(conn, 200) + assert is_list(body["linkset"]) + [entry] = body["linkset"] + assert entry["anchor"] =~ "/api/v1" + assert Map.has_key?(entry, "service-doc") + end + end + + describe "GET /.well-known/openid-configuration" do + test "returns OIDC metadata", %{conn: conn} do + conn = get(conn, "/.well-known/openid-configuration") + assert response(conn, 200) + body = json_response(conn, 200) + assert Map.has_key?(body, "issuer") + assert Map.has_key?(body, "authorization_endpoint") + assert Map.has_key?(body, "token_endpoint") + assert Map.has_key?(body, "jwks_uri") + end + end + + describe "GET /.well-known/oauth-authorization-server" do + test "returns OAuth 2.0 metadata", %{conn: conn} do + conn = get(conn, "/.well-known/oauth-authorization-server") + assert response(conn, 200) + body = json_response(conn, 200) + assert Map.has_key?(body, "issuer") + end + end + + describe "GET /.well-known/oauth-protected-resource" do + test "returns protected resource metadata", %{conn: conn} do + conn = get(conn, "/.well-known/oauth-protected-resource") + assert response(conn, 200) + body = json_response(conn, 200) + assert Map.has_key?(body, "resource") + assert Map.has_key?(body, "authorization_servers") + end + end + + describe "GET /.well-known/mcp/server-card.json" do + test "returns MCP server card", %{conn: conn} do + conn = get(conn, "/.well-known/mcp/server-card.json") + assert response(conn, 200) + body = json_response(conn, 200) + assert get_in(body, ["serverInfo", "name"]) == "TowerOps" + assert Map.has_key?(body, "transport") + end + end + + describe "GET /.well-known/agent-skills/index.json" do + test "returns agent skills index", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/index.json") + assert response(conn, 200) + body = json_response(conn, 200) + assert Map.has_key?(body, "$schema") + assert is_list(body["skills"]) + end + end +end +``` + +**Step 2: Run tests to verify they fail** + +```bash +mix test test/towerops_web/controllers/well_known_controller_test.exs +``` + +Expected: All FAIL with no route error. + +**Step 3: Add routes to router.ex** + +After the sitemap scope, add a scope for well-known endpoints. Note the nested path for MCP: + +```elixir +# Well-known discovery endpoints (no pipeline — public, no CSRF) +scope "/.well-known", ToweropsWeb do + get "/api-catalog", WellKnownController, :api_catalog + get "/openid-configuration", WellKnownController, :openid_configuration + get "/oauth-authorization-server", WellKnownController, :oauth_authorization_server + get "/oauth-protected-resource", WellKnownController, :oauth_protected_resource + get "/agent-skills/index.json", WellKnownController, :agent_skills +end + +scope "/.well-known/mcp", ToweropsWeb do + get "/server-card.json", WellKnownController, :mcp_server_card +end +``` + +**Step 4: Create the controller** + +```elixir +# lib/towerops_web/controllers/well_known_controller.ex +defmodule ToweropsWeb.WellKnownController do + @moduledoc false + + use ToweropsWeb, :controller + + def api_catalog(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + conn + |> put_resp_content_type("application/linkset+json") + |> json(%{ + "linkset" => [ + %{ + "anchor" => "#{base_url}/api/v1", + "service-doc" => [%{"href" => "#{base_url}/docs/api"}], + "service-desc" => [ + %{"href" => "#{base_url}/docs/api", "type" => "text/html"} + ], + "status" => [%{"href" => "#{base_url}/health"}] + } + ] + }) + end + + def openid_configuration(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + json(conn, %{ + "issuer" => base_url, + "authorization_endpoint" => "#{base_url}/oauth/authorize", + "token_endpoint" => "#{base_url}/oauth/token", + "jwks_uri" => "#{base_url}/.well-known/jwks.json", + "grant_types_supported" => ["authorization_code"], + "response_types_supported" => ["code"] + }) + end + + def oauth_authorization_server(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + json(conn, %{ + "issuer" => base_url, + "authorization_endpoint" => "#{base_url}/oauth/authorize", + "token_endpoint" => "#{base_url}/oauth/token", + "jwks_uri" => "#{base_url}/.well-known/jwks.json", + "grant_types_supported" => ["authorization_code"], + "response_types_supported" => ["code"] + }) + end + + def oauth_protected_resource(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + json(conn, %{ + "resource" => "#{base_url}/api/v1", + "authorization_servers" => [base_url], + "scopes_supported" => ["read", "write"] + }) + end + + def mcp_server_card(conn, _params) do + base_url = ToweropsWeb.Endpoint.url() + + json(conn, %{ + "serverInfo" => %{ + "name" => "TowerOps", + "version" => "1.0.0" + }, + "transport" => %{ + "endpoint" => "#{base_url}/mcp" + }, + "capabilities" => %{ + "tools" => %{}, + "resources" => %{}, + "prompts" => %{} + } + }) + end + + def agent_skills(conn, _params) do + json(conn, %{ + "$schema" => "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills" => [] + }) + end +end +``` + +**Step 5: Run tests** + +```bash +mix test test/towerops_web/controllers/well_known_controller_test.exs +``` + +Expected: All PASS. + +**Step 6: Commit** + +```bash +git add lib/towerops_web/controllers/well_known_controller.ex \ + test/towerops_web/controllers/well_known_controller_test.exs \ + lib/towerops_web/router.ex +git commit -m "feat: add /.well-known discovery endpoints for agent readiness" +``` + +--- + +## Task 3: Link Response Headers on Homepage (RFC 8288) + +**Files:** +- Modify: `lib/towerops_web/controllers/page_controller.ex` +- Modify: `test/towerops_web/controllers/page_controller_test.exs` + +**Step 1: Write the failing test** + +Add to the existing `page_controller_test.exs` (find the `GET /` section and add): + +```elixir +describe "GET / link headers" do + test "homepage includes RFC 8288 Link headers", %{conn: conn} do + conn = get(conn, ~p"/") + link_headers = get_resp_header(conn, "link") + assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="api-catalog"))) + assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="service-doc"))) + end +end +``` + +**Step 2: Run tests to verify they fail** + +```bash +mix test test/towerops_web/controllers/page_controller_test.exs +``` + +Expected: New test FAILS (no Link headers on current response). + +**Step 3: Update PageController** + +Modify `home/2` in `lib/towerops_web/controllers/page_controller.ex`: + +```elixir +def home(conn, _params) do + conn = put_link_headers(conn) + + if conn.assigns.current_scope && conn.assigns.current_scope.user do + redirect(conn, to: ~p"/dashboard") + else + render(conn, :home, layout: false) + end +end + +defp put_link_headers(conn) do + conn + |> put_resp_header("link", ~s(; rel="api-catalog")) + |> put_resp_header("link", ~s(; rel="service-doc")) + |> put_resp_header("link", ~s(; rel="status")) +end +``` + +Note: Phoenix `put_resp_header/3` *replaces* a header. To emit multiple `Link` headers, use `Plug.Conn.put_resp_header/3` only once with a comma-joined value instead: + +```elixir +defp put_link_headers(conn) do + link_value = + [ + ~s(; rel="api-catalog"), + ~s(; rel="service-doc"), + ~s(; rel="status") + ] + |> Enum.join(", ") + + put_resp_header(conn, "link", link_value) +end +``` + +**Step 4: Run tests** + +```bash +mix test test/towerops_web/controllers/page_controller_test.exs +``` + +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add lib/towerops_web/controllers/page_controller.ex \ + test/towerops_web/controllers/page_controller_test.exs +git commit -m "feat: add RFC 8288 Link response headers to homepage" +``` + +--- + +## Task 4: Markdown Content Negotiation + +When a request has `Accept: text/markdown`, return a markdown representation of the page with `Content-Type: text/markdown`. + +**Files:** +- Modify: `lib/towerops_web/controllers/page_controller.ex` +- Create: `lib/towerops_web/controllers/page_html/home.md.eex` (or inline in controller) +- Test: `test/towerops_web/controllers/page_controller_test.exs` + +**Step 1: Write the failing test** + +Add to `page_controller_test.exs`: + +```elixir +describe "GET / markdown negotiation" do + test "returns markdown when Accept: text/markdown", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/markdown") + |> get(~p"/") + + assert response(conn, 200) + assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"] + body = response(conn, 200) + assert body =~ "# TowerOps" + end +end +``` + +**Step 2: Run to verify it fails** + +```bash +mix test test/towerops_web/controllers/page_controller_test.exs +``` + +Expected: FAIL — response returns HTML, not markdown. + +**Step 3: Update PageController** + +Note: The browser pipeline has `plug :accepts, ["html"]` which will reject non-HTML requests before they reach the controller. To work around this, check the `accept` header *before* format negotiation, or add the markdown check early. + +The cleanest approach: add `"md"` to accepted formats and register the MIME type. But the simpler approach is to check the header directly at the top of `home/2` before the pipeline can reject it. Actually, the `:accepts` plug runs in the pipeline before the controller. We need a different route. + +**Solution**: Add a separate scope for markdown negotiation that bypasses the html-only browser pipeline: + +In `router.ex`, add before the browser-pipeline scope: + +```elixir +# Markdown content negotiation (must come before :browser scope) +pipeline :markdown do + plug :accepts, ["html", "text/markdown"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {ToweropsWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers, %{} + plug ToweropsWeb.Plugs.CaptureTimezone + plug :fetch_current_scope_for_user + plug :set_user_locale + plug ToweropsWeb.Plugs.UpdateSessionActivity + plug :store_return_to_for_liveview + plug ToweropsWeb.Plugs.DetectEUUser + plug ToweropsWeb.Plugs.CheckPolicyConsent +end +``` + +Actually, the simplest approach is to not use a pipeline at all for the markdown route, and instead add a dedicated action that handles markdown and sits outside the `:accepts` restriction. We can match on a different path... + +**Actually simplest**: use the `:accepts` plug but modify the browser pipeline to also accept `"text/markdown"` mapped to a `"md"` format, then check format in the controller. + +**Even simpler**: Add the markdown check *before* the `:accepts` plug runs, via a custom plug or by checking `get_req_header` in an early plug. + +**Recommended approach**: Create a `ToweropsWeb.Plugs.MarkdownNegotiation` plug that runs early (before `:accepts`), detects `Accept: text/markdown`, intercepts only the homepage, and returns markdown directly. + +```elixir +# lib/towerops_web/plugs/markdown_negotiation.ex +defmodule ToweropsWeb.Plugs.MarkdownNegotiation do + @moduledoc false + import Plug.Conn + + @markdown_pages %{ + "/" => &__MODULE__.home_markdown/0, + "/privacy" => &__MODULE__.privacy_markdown/0, + "/terms" => &__MODULE__.terms_markdown/0 + } + + def init(opts), do: opts + + def call(conn, _opts) do + if accepts_markdown?(conn) && Map.has_key?(@markdown_pages, conn.request_path) do + markdown_fn = @markdown_pages[conn.request_path] + markdown = markdown_fn.() + + conn + |> put_resp_content_type("text/markdown") + |> send_resp(200, markdown) + |> halt() + else + conn + end + end + + defp accepts_markdown?(conn) do + conn + |> get_req_header("accept") + |> Enum.any?(&String.contains?(&1, "text/markdown")) + end + + def home_markdown do + """ + # TowerOps + + Network monitoring and alerting platform built for WISP and ISP operators. + + See the business impact of every network event. + + ## Features + + - Real-time network monitoring + - Subscriber impact analysis + - Revenue impact tracking + - Equipment management + - Multi-site support + + ## Get Started + + - [Register](/users/register) + - [Log In](/users/log_in) + - [API Documentation](/docs/api) + - [Privacy Policy](/privacy) + - [Terms of Service](/terms) + + ## API + + TowerOps provides a REST API for programmatic access. + See [API documentation](/docs/api) for details. + + ## Contact + + Visit [towerops.net](https://towerops.net) for more information. + """ + end + + def privacy_markdown do + "# Privacy Policy\n\nSee [towerops.net/privacy](https://towerops.net/privacy) for the full privacy policy.\n" + end + + def terms_markdown do + "# Terms of Service\n\nSee [towerops.net/terms](https://towerops.net/terms) for the full terms of service.\n" + end +end +``` + +Add this plug to the **endpoint** (`lib/towerops_web/endpoint.ex`) *before* the router, so it runs before the `:accepts` pipeline check: + +```elixir +# Add before plug ToweropsWeb.Router +plug ToweropsWeb.Plugs.MarkdownNegotiation +plug ToweropsWeb.Router +``` + +**Step 4: Update the test** + +The test in `page_controller_test.exs` will work with `ConnCase` which bypasses the endpoint. Modify the test to use `build_conn()` and the endpoint dispatch: + +Actually in `ConnCase`, tests go through the full router pipeline. The plug being at the endpoint level means it runs before the router, but `ConnCase` dispatches through `ToweropsWeb.Endpoint`. Verify with: + +```elixir +describe "GET / markdown negotiation" do + test "returns markdown when Accept: text/markdown", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/markdown") + |> get(~p"/") + + assert response(conn, 200) + assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"] + body = response(conn, 200) + assert body =~ "# TowerOps" + end +end +``` + +**Step 5: Run tests** + +```bash +mix test test/towerops_web/controllers/page_controller_test.exs +``` + +Expected: All PASS including the markdown test. + +**Step 6: Commit** + +```bash +git add lib/towerops_web/plugs/markdown_negotiation.ex \ + lib/towerops_web/endpoint.ex \ + test/towerops_web/controllers/page_controller_test.exs +git commit -m "feat: add markdown content negotiation for agent compatibility" +``` + +--- + +## Task 5: WebMCP JavaScript + +Expose site tools to AI agents via `navigator.modelContext.provideContext()` on page load. + +**Files:** +- Modify: `assets/js/app.js` +- Modify: `test/towerops_web/controllers/page_html_test.exs` (or create a JS test if preferred) + +Since we cannot write inline `