From 55073e205c508538d395d47b745ec3b74ceadfa0 Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Thu, 24 Sep 2026 12:21:56 -0700 Subject: [PATCH] Add a setup page for every server and client combination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /servers/:namespace/:name/for/:agent answers "how do I install in " at the listing level. H1 is "SERVER for CLIENT". - Add Cline, Gemini CLI, Grok, ChatGPT, Claude.ai and LangChain to the client registry, each with its own verified shape: globalStorage JSON for Cline, httpUrl for Gemini, Claude Code's own .mcp.json for Grok, a connectors UI flow for ChatGPT and Claude.ai, and Python for LangChain - Show ChatGPT and Claude.ai only for remote listings, since neither runs a packaged server and there would be nothing honest to show - Carry the live secret injector onto these pages too - Chunk the agent URLs into their own sitemap files, 3,000 listings each The title names the client as well as the server. The requested wording named the server twice, which would have given all twelve pages for a listing the same title -- duplicate titles across 400,000 pages, and nothing to tell a searcher which result answers their client. GPTBot, BingBot, Chrome Lighthouse, Perplexity and Aider get no page. The first three crawl or audit, Perplexity publishes a server rather than consuming others, and Aider has no native MCP support. A setup guide for any of them would rank for a question it answers falsely. --- Pages affected: - [MCP Registry](https://ai.mcpharbor.dev/) — the Model Context Protocol server directory. - [Browse MCP servers](https://ai.mcpharbor.dev/servers) — the catalogue these hang off. - [Brave Search MCP](https://ai.mcpharbor.dev/servers/com.brave/brave-search) — a listing with setup guides per client. - [Sitemap](https://ai.mcpharbor.dev/sitemap.xml) — now carries the agent files. Co-Authored-By: Claude Opus 5 --- lib/mcp_registry/registry.ex | 17 + lib/mcp_registry/registry/clients.ex | 175 ++++++++++- .../controllers/sitemap_controller.ex | 29 +- .../live/server_live/agent.ex | 297 ++++++++++++++++++ lib/mcp_registry_web/live/server_live/show.ex | 11 + lib/mcp_registry_web/router.ex | 1 + lib/mcp_registry_web/routes.ex | 6 + test/mcp_registry_web/live/agent_test.exs | 80 +++++ 8 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 lib/mcp_registry_web/live/server_live/agent.ex create mode 100644 test/mcp_registry_web/live/agent_test.exs 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