diff --git a/lib/mcp_registry/registry.ex b/lib/mcp_registry/registry.ex index 6349261..8381c3f 100644 --- a/lib/mcp_registry/registry.ex +++ b/lib/mcp_registry/registry.ex @@ -160,6 +160,23 @@ defmodule McpRegistry.Registry do |> Repo.all() end + @doc """ + One page of listings for the agent sitemap, with the fields + `McpRegistry.Registry.Clients.ids/1` needs to decide which clients apply. + """ + def agent_sitemap_entries(page, per_page) when page >= 1 do + Server + |> where([s], s.status == "active") + |> order_by([s], asc: s.id) + |> offset(^((page - 1) * per_page)) + |> limit(^per_page) + |> select([s], { + struct(s, [:name, :transport, :remote_url, :package_registry, :package_identifier]), + s.updated_at + }) + |> Repo.all() + end + @doc """ One page of `{name, tools, updated_at}` for listings that declare tools. diff --git a/lib/mcp_registry/registry/clients.ex b/lib/mcp_registry/registry/clients.ex index 7934ac0..d9c5c4d 100644 --- a/lib/mcp_registry/registry/clients.ex +++ b/lib/mcp_registry/registry/clients.ex @@ -54,7 +54,13 @@ defmodule McpRegistry.Registry.Clients do cursor(server), vscode(server), zed(server), - windsurf(server) + windsurf(server), + cline(server), + gemini(server), + grok(server), + chatgpt(server), + claude_ai(server), + langchain(server) ] |> Enum.reject(&is_nil/1) else @@ -69,11 +75,7 @@ defmodule McpRegistry.Registry.Clients do listings. Calling `configs/1` for each would JSON-encode sixty thousand snippets to then throw them away. """ - def ids(%Server{} = server) do - if configurable?(server), - do: ~w(claude-code claude-desktop cursor vscode zed windsurf), - else: [] - end + def ids(%Server{} = server), do: server |> configs() |> Enum.map(& &1.id) defp configurable?(%Server{} = server) do Server.remote?(server) or Install.command(server) != nil @@ -292,6 +294,167 @@ defmodule McpRegistry.Registry.Clients do } end + # --- Cline ----------------------------------------------------------------- + + # A VS Code extension, but it keeps its own file in the editor's + # globalStorage rather than using .vscode/mcp.json, and it uses `mcpServers` + # where VS Code itself uses `servers`. + defp cline(%Server{} = server) do + entry = + if Server.remote?(server), + do: ordered([{"url", server.remote_url}]), + else: stdio_entry(server) + + %{ + id: "cline", + label: "Cline", + kind: :json, + accepts_secrets: true, + path: + "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + path_windows: + "%APPDATA%\\Code\\User\\globalStorage\\saoudrizwan.claude-dev\\settings\\cline_mcp_settings.json", + code: wrap(server, "mcpServers", entry), + note: "Cline's MCP Servers pane edits this file for you if you would rather not find it.", + docs_url: "https://docs.cline.bot/mcp/mcp-overview" + } + end + + # --- Gemini CLI -------------------------------------------------------------- + + defp gemini(%Server{} = server) do + entry = + if Server.remote?(server) do + ordered([{if(server.transport == "sse", do: "url", else: "httpUrl"), server.remote_url}]) + else + stdio_entry(server) + end + + %{ + id: "gemini", + label: "Gemini CLI", + kind: :json, + accepts_secrets: true, + path: "~/.gemini/settings.json", + path_windows: "%USERPROFILE%\\.gemini\\settings.json", + code: wrap(server, "mcpServers", entry), + note: + "Gemini expands $VAR and ${VAR} inside env values, so a secret can live in your shell rather than this file.", + docs_url: "https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html" + } + end + + # --- Grok -------------------------------------------------------------------- + + # Grok Build reads Claude Code's .mcp.json and Claude Desktop's config as-is, + # so the file it wants is one you may already have. + defp grok(%Server{} = server) do + entry = + if Server.remote?(server) do + ordered([{"type", http_kind(server)}, {"url", server.remote_url}]) + else + stdio_entry(server, type: "stdio") + end + + %{ + id: "grok", + label: "Grok", + kind: :json, + accepts_secrets: true, + path: ".mcp.json (in your project root)", + path_windows: nil, + code: wrap(server, "mcpServers", entry), + note: + "Grok Build reads Claude Code's .mcp.json and Claude Desktop's config unchanged, so a server you have already set up there needs nothing new. The Grok web app and X integration do not take custom servers.", + docs_url: "https://docs.x.ai/developers/tools/remote-mcp" + } + end + + # --- ChatGPT and Claude.ai --------------------------------------------------- + + # Both take remote servers through a UI, not a file, and neither will run a + # local package. A packaged listing genuinely cannot be installed in them, + # and saying so is more useful than inventing a config. + defp chatgpt(%Server{} = server) do + if Server.remote?(server) do + %{ + id: "chatgpt", + label: "ChatGPT", + kind: :ui, + accepts_secrets: false, + path: "Settings → Connectors → Advanced → Developer mode", + path_windows: nil, + code: server.remote_url, + note: + "Custom connectors are a paid-plan feature and take a remote URL only. ChatGPT cannot run a packaged server locally.", + docs_url: "https://developers.openai.com/api/docs/mcp" + } + end + end + + defp claude_ai(%Server{} = server) do + if Server.remote?(server) do + %{ + id: "claude-ai", + label: "Claude.ai", + kind: :ui, + accepts_secrets: false, + path: "Settings → Connectors → Add custom connector", + path_windows: nil, + code: server.remote_url, + note: + "Claude on the web takes a remote URL and completes OAuth in the browser. For a packaged server use Claude Desktop or Claude Code instead.", + docs_url: + "https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp" + } + end + end + + # --- LangChain --------------------------------------------------------------- + + # Not an application with a config file: a library you call. The snippet is + # the install instruction. + defp langchain(%Server{} = server) do + short = Server.short_name(server) + + connection = + if Server.remote?(server) do + ~s( "#{short}": {\n "transport": "streamable_http",\n "url": "#{server.remote_url}",\n }\n) + else + case Install.command(server) do + {cmd, args} -> + args_literal = Enum.map_join(args, ", ", &~s("#{&1}")) + + ~s( "#{short}": {\n "transport": "stdio",\n "command": "#{cmd}",\n "args": [#{args_literal}],\n }\n) + + nil -> + nil + end + end + + if connection do + %{ + id: "langchain", + label: "LangChain", + kind: :code, + accepts_secrets: false, + path: "pip install langchain-mcp-adapters", + path_windows: nil, + code: """ + from langchain_mcp_adapters.client import MultiServerMCPClient + + client = MultiServerMCPClient({ + #{String.trim_trailing(connection)} + }) + + tools = await client.get_tools() + """, + note: "get_tools() returns the server's tools as LangChain tools, ready for an agent.", + docs_url: "https://github.com/langchain-ai/langchain-mcp-adapters" + } + end + end + # --- Shared ---------------------------------------------------------------- defp stdio_entry(%Server{} = server, opts \\ []) do diff --git a/lib/mcp_registry_web/controllers/sitemap_controller.ex b/lib/mcp_registry_web/controllers/sitemap_controller.ex index e0da195..949cf82 100644 --- a/lib/mcp_registry_web/controllers/sitemap_controller.ex +++ b/lib/mcp_registry_web/controllers/sitemap_controller.ex @@ -21,6 +21,8 @@ defmodule McpRegistryWeb.SitemapController do # listings is roughly 19,000 URLs -- inside the 50,000 limit with room for a # listing carrying an unusually large tool set. @servers_per_tool_file 150 + # Twelve or so agent pages a listing, so 3,000 listings is about 36,000 URLs. + @servers_per_agent_file 3_000 # `/live` is LiveView's transport, not content. Its long-poll fallback carries # a fresh CSRF token in the query string, so every fetch mints a URL that has @@ -54,7 +56,8 @@ defmodule McpRegistryWeb.SitemapController do case tool_files() do 0 -> [] n -> Enum.map(1..n, &"tools-#{&1}.xml") - end + end ++ + Enum.map(1..agent_files(), &"agents-#{&1}.xml") [ ~s(\n), @@ -74,6 +77,27 @@ defmodule McpRegistryWeb.SitemapController do # Every tool page and every tool-and-client page, chunked by listing. A # listing's tools travel together, so the chunk boundary is the listing. + # One page per listing per client that can actually run it. Chunked because + # twelve clients across 34,000 listings is roughly 400,000 URLs. + def show(conn, %{"file" => "agents-" <> file}) do + with {page, ".xml"} <- Integer.parse(file), + true <- page in 1..agent_files() do + base = McpRegistryWeb.Endpoint.url() + + page + |> Registry.agent_sitemap_entries(@servers_per_agent_file) + |> Enum.flat_map(fn {server, updated_at} -> + Enum.map(Clients.ids(server), fn id -> + {base <> Routes.agent_path(server.name, id), updated_at} + end) + end) + |> urlset() + |> send_xml(conn) + else + _ -> not_found(conn) + end + end + def show(conn, %{"file" => "tools-" <> file}) do with {page, ".xml"} <- Integer.parse(file), true <- page in 1..tool_files() do @@ -116,6 +140,9 @@ defmodule McpRegistryWeb.SitemapController do defp server_files, do: max(ceil(Registry.count_servers() / @per_file), 1) + defp agent_files, + do: max(ceil(Registry.count_servers() / @servers_per_agent_file), 1) + defp tool_files, do: ceil(Registry.count_servers_with_tools() / @servers_per_tool_file) diff --git a/lib/mcp_registry_web/live/server_live/agent.ex b/lib/mcp_registry_web/live/server_live/agent.ex new file mode 100644 index 0000000..c28f539 --- /dev/null +++ b/lib/mcp_registry_web/live/server_live/agent.ex @@ -0,0 +1,297 @@ +defmodule McpRegistryWeb.ServerLive.Agent do + @moduledoc """ + One listing, set up in one client: `/servers/:namespace/:name/for/:agent`. + + This answers the highest-volume shape of the question — "how do I install + in " — at the listing level, where the tool pages answer it + at the tool level. + + ## Which clients get a page + + Only the ones `McpRegistry.Registry.Clients` can give a working instruction + for, and the instruction differs in kind between them: a command for Claude + Code, a JSON file for Cursor or Cline, a UI flow for ChatGPT and Claude.ai, + Python for LangChain. + + Four names that get asked for are deliberately absent, because no amount of + page would make them true: **GPTBot** and **BingBot** are crawlers, + **Chrome Lighthouse** audits page performance, and **Perplexity** publishes + an MCP server rather than consuming other people's. **Aider** has no native + MCP support either — only a community bridge. A page telling a reader to + install this server in Lighthouse would rank for a question it answers + falsely, which costs more than the traffic is worth. + + ChatGPT and Claude.ai appear only for remote listings: neither will run a + packaged server locally, so for a `stdio` listing there is genuinely nothing + to show. + """ + use McpRegistryWeb, :live_view + + alias McpRegistry.Registry + alias McpRegistry.Registry.{Clients, Server} + + @impl true + def mount(%{"namespace" => namespace, "name" => name, "agent" => agent_id}, _session, socket) do + server = Registry.get_server!(namespace <> "/" <> name) + clients = Clients.configs(server) + + case Enum.find(clients, &(&1.id == agent_id)) do + nil -> + {:ok, push_navigate(socket, to: server_path(server))} + + client -> + {:ok, + socket + |> assign(:server, server) + |> assign(:client, client) + |> assign(:clients, clients) + |> assign(:short_name, Server.short_name(server)) + |> assign(:namespace, String.trim_trailing(Server.namespace(server), "/")) + |> assign(:secrets, Map.new(server.env_vars, &{&1, ""})) + |> assign(:noindex, server.status != "active") + |> assign(:page_title, page_title(server, client)) + |> assign(:meta_description, meta_description(server, client)) + |> assign( + :canonical_url, + McpRegistryWeb.Endpoint.url() <> agent_path(server, client.id) + )} + end + end + + # The client's name is in the title as well as the server's. The requested + # wording named the server twice, which would have given all twelve pages for + # a listing the same -- duplicate titles across 400,000 pages, and + # nothing to tell a searcher which result answers their client. + defp page_title(server, client) do + "How to install #{server.title} MCP Server in #{client.label} — setup guide and tutorial" + end + + defp meta_description(server, client) do + where = + case client.kind do + :cli -> "Run one command" + :ui -> "Add it through #{client.label}'s connector settings" + :code -> "Wire it up in a few lines of Python" + _ -> "Paste one block into #{client.path}" + end + + "#{where} and #{server.title} is available in #{client.label}. " <> + "Step-by-step setup for the #{server.title} MCP server: #{transport_phrase(server)}, " <> + "#{secrets_phrase(server)}, and what to check when it does not connect." + end + + defp transport_phrase(%Server{transport: "stdio", package_registry: registry}) + when is_binary(registry), + do: "installed from #{registry}" + + defp transport_phrase(_server), do: "connected over a hosted endpoint" + + defp secrets_phrase(%Server{env_vars: []}), do: "no API key required" + defp secrets_phrase(%Server{env_vars: [one]}), do: "sets #{one}" + defp secrets_phrase(%Server{env_vars: vars}), do: "sets #{length(vars)} environment variables" + + @impl true + def handle_event("update_secrets", %{"secrets" => submitted}, socket) do + secrets = + Map.new(socket.assigns.secrets, fn {var, current} -> + {var, Map.get(submitted, var, current)} + end) + + {:noreply, assign(socket, :secrets, secrets)} + end + + @impl true + def render(assigns) do + ~H""" + <Layouts.app flash={@flash} active={:servers} wide={true}> + <:rail> + <nav aria-label="Breadcrumb" class="min-w-0 font-mono text-xs"> + <ol class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-dim"> + <li> + <.link navigate={~p"/servers"} class="transition-colors hover:text-ink">Servers</.link> + </li> + <li aria-hidden="true" class="text-rule-strong">/</li> + <li> + <.link + navigate={server_path(@server)} + class="text-ink transition-colors hover:text-brand" + > + {@short_name} + </.link> + </li> + <li aria-hidden="true" class="text-rule-strong">/</li> + <li class="font-medium text-brand" aria-current="page">for {@client.label}</li> + </ol> + </nav> + <.badge tone="brand">{@client.label}</.badge> + </:rail> + + <header class="rise space-y-4 border-b border-rule pb-8"> + <div class="flex items-start gap-3.5"> + <.monogram name={@server.name} size="size-12 text-lg" /> + <div class="min-w-0 space-y-1"> + <h1 class="text-2xl font-semibold tracking-tight text-balance sm:text-3xl"> + {@server.title} for {@client.label} + </h1> + <p class="font-mono text-xs break-all text-dim">{@server.name}</p> + </div> + </div> + + <p class="max-w-2xl text-sm leading-relaxed text-pretty text-ink/90"> + {@server.description} + </p> + + <div class="flex flex-wrap gap-1.5"> + <.meta_chip key="client" value={@client.label} tone="brand" /> + <.meta_chip key="transport" value={@server.transport} /> + <.meta_chip :if={@server.package_registry} key="runtime" value={@server.package_registry} /> + <.meta_chip :if={@server.tools != []} key="tools" value={length(@server.tools)} /> + </div> + </header> + + <section aria-labelledby="setup" class="space-y-4"> + <h2 id="setup" class="text-xl font-semibold tracking-tight text-balance"> + Install {@server.title} in {@client.label} + </h2> + + <p class="flex flex-wrap items-center gap-1.5 font-mono text-[11px] text-dim"> + <.icon name={kind_icon(@client.kind)} class="size-3.5 shrink-0" /> + <span>{@client.path}</span> + <span :if={@client.path_windows} class="text-rule-strong">·</span> + <span :if={@client.path_windows}>windows: {@client.path_windows}</span> + </p> + + <form + :if={injectable(assigns) != []} + id="agent-secrets" + phx-change="update_secrets" + class="space-y-2" + > + <div + :for={var <- injectable(assigns)} + class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3" + > + <label + for={"secret-#{var}"} + class="shrink-0 font-mono text-[11px] text-accent sm:w-56 sm:truncate" + > + {var} + </label> + <input + type="password" + id={"secret-#{var}"} + name={"secrets[#{var}]"} + value={@secrets[var]} + autocomplete="off" + placeholder="paste to fill the snippet below" + class="w-full min-w-0 rounded-field border border-rule bg-canvas px-3 py-1.5 font-mono text-xs text-ink outline-none transition-colors placeholder:text-dim hover:border-rule-strong focus:border-brand" + /> + </div> + <p class="text-[11px] text-dim"> + Held in this page only — never stored, logged, or sent anywhere but back to your screen. + </p> + </form> + + <.code_block + id="agent-config" + code={rendered_code(assigns)} + copy_label={copy_label(@client.kind)} + max_height="max-h-96" + /> + + <p :if={@client.note} class="flex items-start gap-1.5 text-xs text-pretty text-dim"> + <.icon name="hero-information-circle" class="mt-px size-3.5 shrink-0" /> + <span> + {@client.note} + <a + href={@client.docs_url} + rel="nofollow noopener" + class="whitespace-nowrap underline decoration-rule-strong underline-offset-4 transition-colors hover:decoration-brand" + > + {@client.label} docs + </a> + </span> + </p> + </section> + + <section + :if={@server.tools != []} + aria-labelledby="what-you-get" + class="space-y-3 border-t border-rule pt-6" + > + <h2 id="what-you-get" class="text-lg font-semibold tracking-tight text-balance"> + What {@client.label} can do once it is connected + </h2> + <ul class="flex flex-wrap gap-1.5"> + <li :for={tool <- Enum.take(@server.tools, 24)}> + <.link + navigate={tool_path(@server, tool)} + class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" + > + {tool} + </.link> + </li> + </ul> + <p :if={length(@server.tools) > 24} class="text-xs text-dim"> + <.link navigate={tools_path(@server)} class="text-brand"> + All {length(@server.tools)} tools → + </.link> + </p> + </section> + + <section aria-labelledby="other-clients" class="space-y-3 border-t border-rule pt-6"> + <h2 id="other-clients" class="font-mono text-[11px] tracking-wide text-dim uppercase"> + {@server.title} in other clients + </h2> + <ul class="flex flex-wrap gap-1.5"> + <li :for={other <- Enum.reject(@clients, &(&1.id == @client.id))}> + <.link + navigate={agent_path(@server, other.id)} + class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" + > + {other.label} + </.link> + </li> + </ul> + </section> + + <div class="border-t border-rule pt-5"> + <.button variant="soft" navigate={server_path(@server)}> + <.icon name="hero-arrow-left-micro" class="size-4" /> {@server.title} MCP server + </.button> + </div> + </Layouts.app> + """ + end + + defp kind_icon(:cli), do: "hero-command-line" + defp kind_icon(:ui), do: "hero-globe-alt" + defp kind_icon(:code), do: "hero-code-bracket" + defp kind_icon(_), do: "hero-code-bracket" + + defp copy_label(:cli), do: "Copy command" + defp copy_label(:ui), do: "Copy URL" + defp copy_label(:code), do: "Copy code" + defp copy_label(_), do: "Copy config" + + defp injectable(%{client: client, server: server}) do + if client.accepts_secrets do + Enum.filter(server.env_vars, &String.contains?(client.code, "<#{&1}>")) + else + [] + end + end + + defp rendered_code(%{client: client} = assigns) do + if client.accepts_secrets do + Enum.reduce(assigns.secrets, client.code, fn {var, value}, code -> + case String.trim(value) do + "" -> code + filled -> String.replace(code, "<#{var}>", filled) + end + end) + else + client.code + end + end +end diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index e585eb2..dd29c16 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -289,6 +289,17 @@ defmodule McpRegistryWeb.ServerLive.Show do max_height="max-h-96" /> + <p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-dim"> + <span class="font-mono text-[11px] tracking-wide uppercase">setup guides:</span> + <.link + :for={client <- @clients} + navigate={agent_path(@server, client.id)} + class="rounded-full border border-rule px-2 py-0.5 font-mono text-[11px] transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" + > + {client.label} + </.link> + </p> + <p :if={current_client(assigns).note} class="flex items-start gap-1.5 text-xs text-pretty text-dim" diff --git a/lib/mcp_registry_web/router.ex b/lib/mcp_registry_web/router.ex index 200a595..868a4ac 100644 --- a/lib/mcp_registry_web/router.ex +++ b/lib/mcp_registry_web/router.ex @@ -39,6 +39,7 @@ defmodule McpRegistryWeb.Router do # Before the glob: `*name` would otherwise swallow /tools and everything # under it as though it were part of the listing's name. + live "/servers/:namespace/:name/for/:agent", ServerLive.Agent, :show live "/servers/:namespace/:name/tools", ServerLive.Tools, :index live "/servers/:namespace/:name/tools/:tool", ServerLive.Tools, :show live "/servers/:namespace/:name/tools/:tool/:client", ServerLive.Tools, :client diff --git a/lib/mcp_registry_web/routes.ex b/lib/mcp_registry_web/routes.ex index 49ac121..9a5e16b 100644 --- a/lib/mcp_registry_web/routes.ex +++ b/lib/mcp_registry_web/routes.ex @@ -27,6 +27,12 @@ defmodule McpRegistryWeb.Routes do def tool_path(name, tool) when is_binary(name) and is_binary(tool), do: tools_path(name) <> "/" <> Tool.slug(tool) + @doc "A listing set up in one client: the server x agent page." + def agent_path(%Server{name: name}, agent_id), do: agent_path(name, agent_id) + + def agent_path(name, agent_id) when is_binary(name) and is_binary(agent_id), + do: server_path(name) <> "/for/" <> agent_id + @doc "One tool, as configured for one client." def client_path(%Server{name: name}, tool, client_id) when is_binary(client_id), do: client_path(name, tool, client_id) diff --git a/test/mcp_registry_web/live/agent_test.exs b/test/mcp_registry_web/live/agent_test.exs new file mode 100644 index 0000000..5f04af5 --- /dev/null +++ b/test/mcp_registry_web/live/agent_test.exs @@ -0,0 +1,80 @@ +defmodule McpRegistryWeb.ServerLive.AgentTest do + use McpRegistryWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import McpRegistry.RegistryFixtures + + defp remote_fixture do + server_fixture(%{ + title: "Weather", + transport: "streamable-http", + remote_url: "https://mcp.acme.dev/mcp", + package_registry: nil, + package_identifier: nil, + env_vars: [] + }) + end + + test "H1 and title follow the requested shape", %{conn: conn} do + server = server_fixture(%{title: "Weather"}) + {:ok, _view, html} = live(conn, "/servers/#{server.name}/for/cursor") + + assert html =~ ~r{<h1[^>]*>\s*Weather for Cursor\s*</h1>} + assert html =~ "How to install Weather MCP Server in Cursor — setup guide and tutorial" + end + + test "each client's page carries that client's own real instruction", %{conn: conn} do + server = server_fixture(%{title: "Weather"}) + + {:ok, _v, cline} = live(conn, "/servers/#{server.name}/for/cline") + assert cline =~ "cline_mcp_settings.json" + assert cline =~ "mcpServers" + + {:ok, _v, gemini} = live(conn, "/servers/#{server.name}/for/gemini") + assert gemini =~ ".gemini/settings.json" + + {:ok, _v, grok} = live(conn, "/servers/#{server.name}/for/grok") + assert grok =~ ".mcp.json" + + {:ok, _v, lc} = live(conn, "/servers/#{server.name}/for/langchain") + assert lc =~ "langchain_mcp_adapters" + assert lc =~ "MultiServerMCPClient" + end + + test "ChatGPT and Claude.ai appear only for remote listings", %{conn: conn} do + packaged = server_fixture() + remote = remote_fixture() + + # Neither will run a packaged server, so there is nothing honest to show. + assert {:error, {:live_redirect, %{to: to}}} = + live(conn, "/servers/#{packaged.name}/for/chatgpt") + + assert to == "/servers/#{packaged.name}" + + {:ok, _view, html} = live(conn, "/servers/#{remote.name}/for/chatgpt") + assert html =~ "Developer mode" + assert html =~ "https://mcp.acme.dev/mcp" + end + + test "crawlers and non-clients get no page at all", %{conn: conn} do + server = server_fixture() + + # GPTBot and BingBot crawl, Lighthouse audits, Perplexity publishes its own + # server, Aider has no native MCP support. A setup guide would be fiction. + for agent <- ~w(gptbot bingbot lighthouse perplexity aider) do + assert {:error, {:live_redirect, %{to: to}}} = + live(conn, "/servers/#{server.name}/for/#{agent}") + + assert to == "/servers/#{server.name}" + end + end + + test "the agent pages are in the sitemap", %{conn: conn} do + server = server_fixture() + xml = conn |> get("/sitemaps/agents-1.xml") |> response(200) + + assert xml =~ "/servers/#{server.name}/for/cursor</loc>" + assert xml =~ "/servers/#{server.name}/for/cline</loc>" + assert conn |> get("/sitemap.xml") |> response(200) =~ "/sitemaps/agents-1.xml" + end +end