From 0bb76dbe6899a1aec6f6ada79579c21f9f2c42e3 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 17 Apr 2026 14:06:51 -0500 Subject: [PATCH] feat: add /.well-known discovery endpoints for agent readiness Adds WellKnownController with 6 public, no-auth endpoints: api-catalog, openid-configuration, oauth-authorization-server, oauth-protected-resource, mcp/server-card.json, and agent-skills/index.json. --- .../controllers/well_known_controller.ex | 84 +++++++++++++++++++ lib/towerops_web/router.ex | 13 +++ .../well_known_controller_test.exs | 70 ++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 lib/towerops_web/controllers/well_known_controller.ex create mode 100644 test/towerops_web/controllers/well_known_controller_test.exs diff --git a/lib/towerops_web/controllers/well_known_controller.ex b/lib/towerops_web/controllers/well_known_controller.ex new file mode 100644 index 00000000..d3a946c4 --- /dev/null +++ b/lib/towerops_web/controllers/well_known_controller.ex @@ -0,0 +1,84 @@ +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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 63f6213e..cf5950ea 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -85,6 +85,19 @@ defmodule ToweropsWeb.Router do get "/sitemap.xml", SitemapController, :index end + # Well-known discovery endpoints (no pipeline, public access, no auth/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 + scope "/", ToweropsWeb do pipe_through :browser diff --git a/test/towerops_web/controllers/well_known_controller_test.exs b/test/towerops_web/controllers/well_known_controller_test.exs new file mode 100644 index 00000000..1b893034 --- /dev/null +++ b/test/towerops_web/controllers/well_known_controller_test.exs @@ -0,0 +1,70 @@ +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 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 is_binary(entry["anchor"]) + assert is_list(entry["service-doc"]) + assert is_list(entry["service-desc"]) + assert is_list(entry["status"]) + end + end + + describe "GET /.well-known/openid-configuration" do + test "returns OIDC discovery metadata", %{conn: conn} do + conn = get(conn, "/.well-known/openid-configuration") + body = json_response(conn, 200) + assert is_binary(body["issuer"]) + assert is_binary(body["authorization_endpoint"]) + assert is_binary(body["token_endpoint"]) + assert is_binary(body["jwks_uri"]) + assert is_list(body["grant_types_supported"]) + assert is_list(body["response_types_supported"]) + end + end + + describe "GET /.well-known/oauth-authorization-server" do + test "returns OAuth 2.0 server metadata", %{conn: conn} do + conn = get(conn, "/.well-known/oauth-authorization-server") + body = json_response(conn, 200) + assert is_binary(body["issuer"]) + assert is_binary(body["authorization_endpoint"]) + assert is_binary(body["token_endpoint"]) + 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") + body = json_response(conn, 200) + assert is_binary(body["resource"]) + assert is_list(body["authorization_servers"]) + assert is_list(body["scopes_supported"]) + 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") + body = json_response(conn, 200) + assert get_in(body, ["serverInfo", "name"]) == "TowerOps" + assert is_binary(get_in(body, ["serverInfo", "version"])) + assert is_binary(get_in(body, ["transport", "endpoint"])) + assert is_map(body["capabilities"]) + end + end + + describe "GET /.well-known/agent-skills/index.json" do + test "returns agent skills discovery index", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/index.json") + body = json_response(conn, 200) + assert body["$schema"] =~ "agentskills.io" + assert is_list(body["skills"]) + end + end +end