# Module name is `Mix.Tasks.AttestoMcp.Install` (not `AttestoMCP`) because Mix
# resolves `mix attesto_mcp.install` via `Mix.Utils.command_to_module_name/1`,
# which camelizes each underscore-delimited segment and yields `AttestoMcp`. The
# task will not be found under any other casing. Every reference to the library
# itself keeps the `AttestoMCP` acronym casing.
defmodule Mix.Tasks.AttestoMcp.Install.Docs do
@moduledoc false
@spec short_doc() :: String.t()
def short_doc, do: "Scaffolds an MCP protected resource into a Phoenix application"
@spec example() :: String.t()
def example, do: "mix attesto_mcp.install --resource-path /mcp --scopes mcp:use"
@spec long_doc() :: String.t()
def long_doc do
"""
#{short_doc()}
Wires the building blocks an MCP server needs to act as an OAuth 2.0
protected resource into a host Phoenix application:
* The OAuth 2.0 Protected Resource Metadata endpoint (RFC 9728 Section 3),
mounted from the per-resource well-known path (RFC 9728 Section 3.1) with
the same route form emitted by
`AttestoMCP.Router.attesto_mcp_protected_resource_metadata/2`.
* A Phoenix pipeline that enforces bearer-token authentication and the
required OAuth scopes (RFC 6750 Bearer Token Usage, RFC 6749 Section 3.3
scope semantics) via `AttestoMCP.Plug.ProtectResource`.
This task is idempotent: re-running it will not duplicate the pipeline or the
scopes that a previous run already added. Igniter matches the pipeline by name
and the scope by its exact contents, so a second run is a no-op.
## Example
```sh
#{example()}
```
## Options
* `--resource-path` - the path component of the protected resource being
served, for example `/mcp` (RFC 9728 Section 3.1). The metadata endpoint is
mounted at `/.well-known/oauth-protected-resource<resource-path>` and the
protecting pipeline is piped through the matching scope. Defaults to `/mcp`.
* `--scopes` - a comma-separated list of OAuth scope strings the bearer token
must carry to access the protected resource (RFC 6749 Section 3.3). Defaults
to `mcp:use`.
* `--router` - the Phoenix router module to wire the routes into. Defaults to
the application's discovered router.
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AttestoMcp.Install do
# `@shortdoc` is a compile-time module attribute evaluated before any `alias`
# below takes effect, so it cannot reference the sibling `...Install.Docs`
# module by an alias; the literal is inlined here (kept in sync with
# `Docs.short_doc/0`) rather than fully qualifying the nested module.
@shortdoc "Scaffolds an MCP protected resource into a Phoenix application"
@moduledoc Mix.Tasks.AttestoMcp.Install.Docs.long_doc()
use Igniter.Mix.Task
alias Igniter.Libs.Phoenix
alias Igniter.Mix.Task.Info
alias Mix.Tasks.AttestoMcp.Install.Docs
@default_resource_path "/mcp"
@default_scopes "mcp:use"
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Info{
group: :attesto_mcp,
example: Docs.example(),
schema: [resource_path: :string, scopes: :string, router: :string],
aliases: []
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
options = igniter.args.options
resource_path = normalize_resource_path(options[:resource_path] || @default_resource_path)
scopes = parse_scopes(options[:scopes] || @default_scopes)
router = resolve_router(igniter, options)
pipeline_name = pipeline_name(resource_path)
igniter
|> add_protect_pipeline(router, pipeline_name, resource_path, scopes)
|> add_metadata_scope(router, resource_path, scopes)
|> add_protected_scope(router, pipeline_name, resource_path)
|> Igniter.add_notice("""
AttestoMCP protected resource scaffolded for #{resource_path}.
The host router now mounts the RFC 9728 protected resource metadata
endpoint and a #{inspect(pipeline_name)} pipeline that enforces the
#{inspect(scopes)} scope(s) via AttestoMCP.Plug.ProtectResource.
Next steps:
* Mount your MCP transport plug inside the #{inspect(resource_path)} scope.
* Configure the metadata document (authorization servers, resource
identifier) per the AttestoMCP README.
""")
end
# The metadata endpoint is mounted from the bare scope (RFC 9728 Section 3.1
# serves it from the well-known path, unprotected, so clients can discover the
# authorization server before they hold a token).
#
# The routes are emitted as plain `get` calls to `AttestoMCP.MetadataController`
# rather than through the `attesto_mcp_protected_resource_metadata/2` router
# macro, because that macro is only available after `use AttestoMCP.Router` at
# the module level, and `Igniter.Libs.Phoenix.add_scope/4` injects a scope body
# without touching the module's `use` declarations. The `get` form needs no
# import, compiles in any Phoenix router, and is exactly the shape RFC 9728
# Section 3.1 (path-suffixed) and its root-compatibility companion require. The
# `:scopes` private is read by the controller and served as `scopes_supported`.
defp add_metadata_scope(igniter, router, resource_path, scopes) do
private =
"%{attesto_mcp_metadata_opts: [scopes: #{inspect(scopes)}], attesto_mcp_resource_path: #{inspect(resource_path)}}"
Phoenix.add_scope(
igniter,
"/.well-known",
"""
get "/oauth-protected-resource#{resource_path}", AttestoMCP.MetadataController, :show, private: #{private}
get "/oauth-protected-resource", AttestoMCP.MetadataController, :show, private: #{private}
""",
router: router
)
end
# The protected resource itself is piped through the bearer-token pipeline
# (RFC 6750). The MCP transport plug is mounted here by the application.
defp add_protected_scope(igniter, router, pipeline_name, resource_path) do
Phoenix.add_scope(
igniter,
resource_path,
"""
pipe_through #{inspect(pipeline_name)}
# Mount your MCP transport plug here.
""",
router: router
)
end
defp add_protect_pipeline(igniter, router, pipeline_name, _resource_path, scopes) do
Phoenix.add_pipeline(
igniter,
pipeline_name,
"""
plug :accepts, ["json"]
plug AttestoMCP.Plug.ProtectResource, scopes: #{inspect(scopes)}
""",
router: router
)
end
defp resolve_router(igniter, options) do
case options[:router] do
nil ->
case Phoenix.select_router(igniter) do
{_igniter, nil} -> Mix.raise("No Phoenix router found")
{_igniter, router} -> router
end
router ->
Igniter.Project.Module.parse(router)
end
end
# RFC 9728 Section 3.1 keys metadata off the resource path component; the
# leading slash is required for the well-known suffix and the protected scope.
defp normalize_resource_path("/" <> _ = path), do: path
defp normalize_resource_path(path), do: "/" <> path
defp parse_scopes(scopes) do
scopes
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
# Derive a stable, unique pipeline atom from the resource path so re-runs
# match the same pipeline (idempotency) and distinct resources do not collide.
defp pipeline_name(resource_path) do
suffix =
resource_path
|> String.replace(~r/[^a-zA-Z0-9]+/, "_")
|> String.trim("_")
:"mcp_protected_#{suffix}"
end
end
else
defmodule Mix.Tasks.AttestoMcp.Install do
@shortdoc "#{Mix.Tasks.AttestoMcp.Install.Docs.short_doc()} | Install `igniter` to use"
@moduledoc Mix.Tasks.AttestoMcp.Install.Docs.long_doc()
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'attesto_mcp.install' requires igniter. Install `igniter` and run it with:
mix igniter.install attesto_mcp
or add `{:igniter, "~> 0.5"}` to your deps and run `mix attesto_mcp.install` again.
""")
exit({:shutdown, 1})
end
end
end