Current section
Files
Jump to
Current section
Files
lib/mcp_kit/router.ex
defmodule MCPKit.Router do
@moduledoc """
Phoenix router DSL for mounting an MCP endpoint and declaring exposed tools,
prompts, and resources.
Typical usage:
mcp_scope "/mcp", MyApp.MCP do
tool "project_create", Tools.ProjectCreate
tool "project_status", Tools.ProjectStatus
prompt "draft_release_notes", Prompts.DraftReleaseNotes
resource "project", Resources.Project
end
The second argument behaves like a regular Phoenix `scope` alias. Given the
example above, `Tools.ProjectCreate` resolves to `MyApp.MCP.Tools.ProjectCreate`.
`mcp_scope/2` and `mcp_scope/3` generate the underlying route automatically,
so the host router does not need a separate MCP pipeline or `forward` call.
Runtime state is handled by a host-started `MCPKit.Runtime` child. The router
infers its name from the definition module unless overridden with `runtime:`.
`resource/2` mounts MCP resources for the scope.
"""
@doc """
Mounts an MCP endpoint under the given router path.
The second argument may be either a scope alias or router options. When a
scope alias is given, `mcp_scope/2` infers a sibling `Definition` module and
runtime name from that scope.
Inside the block you may declare `tool/2`, `prompt/2`, and `resource/2`
entries.
"""
defmacro mcp_scope(path, second \\ [], do: block) do
{scope_alias, options} = parse_scope_alias_and_options(second)
build_scope(__CALLER__, path, scope_alias, options, block)
end
@doc """
Mounts an MCP endpoint under the given router path with an explicit scope alias
and options.
Supports `definition:` to override the inferred `Definition` module and
`runtime:` to override the inferred runtime name.
"""
defmacro mcp_scope(path, scope_alias, options, do: block) do
build_scope(__CALLER__, path, scope_alias, options, block)
end
@doc """
Declares a tool inside `mcp_scope`.
The tool name is exposed over MCP and the module must implement
`MCPKit.Tool`.
"""
defmacro tool(_name, _module) do
raise ArgumentError, "tool/2 may only be used inside mcp_scope"
end
@doc """
Declares a prompt inside `mcp_scope`.
The prompt name is exposed over MCP and the module must implement
`MCPKit.Prompt`.
"""
defmacro prompt(_name, _module) do
raise ArgumentError, "prompt/2 may only be used inside mcp_scope"
end
@doc """
Declares a resource inside `mcp_scope`.
The resource name is used for policy and completion routing, and the module
must implement `MCPKit.Resource`.
"""
defmacro resource(_name, _module) do
raise ArgumentError, "resource/2 may only be used inside mcp_scope"
end
defp build_scope(caller, path, scope_alias_ast, options, block) do
options = expand_options(options, caller)
scope_alias = resolve_scope_alias(scope_alias_ast, caller)
definition = resolve_definition(scope_alias, options, caller)
runtime = resolve_runtime(definition, options, caller)
scope_options = options |> Keyword.delete(:definition) |> Keyword.delete(:runtime)
%{tools: tools, prompts: prompts, resources: resources} =
parse_entries(block, scope_alias, caller)
route =
quote do
match(
:*,
"/",
MCPKit.Plug,
[
definition: unquote(definition),
prompts: unquote(Macro.escape(prompts)),
resources: unquote(Macro.escape(resources)),
runtime: unquote(runtime),
tools: unquote(Macro.escape(tools))
],
alias: false,
as: nil,
warn_on_verify: false
)
end
case scope_alias_ast do
nil ->
quote do
scope unquote(path), unquote(scope_options) do
unquote(route)
end
end
_ ->
quote do
scope unquote(path), unquote(scope_alias_ast), unquote(scope_options) do
unquote(route)
end
end
end
end
defp parse_scope_alias_and_options(second) when is_list(second), do: {nil, second}
defp parse_scope_alias_and_options(second), do: {second, []}
defp expand_options(options, caller) when is_list(options) do
Macro.prewalk(options, &expand_alias(&1, caller))
end
defp resolve_scope_alias(nil, _caller), do: nil
defp resolve_scope_alias(scope_alias_ast, caller), do: Macro.expand(scope_alias_ast, caller)
defp resolve_definition(scope_alias, options, caller) do
case Keyword.fetch(options, :definition) do
{:ok, definition_ast} ->
apply_scope_alias(Macro.expand(definition_ast, caller), scope_alias)
:error when is_atom(scope_alias) ->
Module.concat(scope_alias, Definition)
:error ->
raise ArgumentError,
"mcp_scope requires a definition: option when no scope alias is provided"
end
end
defp resolve_runtime(definition, options, caller) do
case Keyword.fetch(options, :runtime) do
{:ok, runtime_ast} -> Macro.expand(runtime_ast, caller)
:error -> MCPKit.Runtime.default_name(definition)
end
end
defp parse_entries(block, scope_alias, caller) do
entries =
block
|> block_entries()
|> Enum.map(&parse_entry(&1, scope_alias, caller))
tools = for %{kind: :tool} = entry <- entries, do: Map.delete(entry, :kind)
prompts = for %{kind: :prompt} = entry <- entries, do: Map.delete(entry, :kind)
resources = for %{kind: :resource} = entry <- entries, do: Map.delete(entry, :kind)
validate_unique_tool_names!(tools)
validate_unique_prompt_names!(prompts)
validate_unique_resource_names!(resources)
%{tools: tools, prompts: prompts, resources: resources}
end
defp block_entries({:__block__, _, entries}), do: entries
defp block_entries(entry), do: [entry]
defp parse_entry({:tool, _meta, [name, module_ast]}, scope_alias, caller) do
name = literal_name!(name, :tool)
module = module_ast |> Macro.expand(caller) |> apply_scope_alias(scope_alias)
%{kind: :tool, name: name, module: module}
end
defp parse_entry({:prompt, _meta, [name, module_ast]}, scope_alias, caller) do
name = literal_name!(name, :prompt)
module = module_ast |> Macro.expand(caller) |> apply_scope_alias(scope_alias)
%{kind: :prompt, name: name, module: module}
end
defp parse_entry({:resource, _meta, [name, module_ast]}, scope_alias, caller) do
name = literal_name!(name, :resource)
module = module_ast |> Macro.expand(caller) |> apply_scope_alias(scope_alias)
%{kind: :resource, name: name, module: module}
end
defp parse_entry(_entry, _scope_alias, _caller) do
raise ArgumentError, "mcp_scope only accepts tool/2, prompt/2, and resource/2 declarations"
end
defp literal_name!(name, _kind) when is_binary(name), do: name
defp literal_name!(_name, kind) do
raise ArgumentError, "#{kind}/2 expects the first argument to be a literal string"
end
defp validate_unique_tool_names!(tools) do
names = Enum.map(tools, & &1.name)
case names -- Enum.uniq(names) do
[] ->
:ok
[duplicate | _] ->
raise ArgumentError, "duplicate MCP tool declaration for #{inspect(duplicate)}"
end
end
defp validate_unique_prompt_names!(prompts) do
names = Enum.map(prompts, & &1.name)
case names -- Enum.uniq(names) do
[] ->
:ok
[duplicate | _] ->
raise ArgumentError, "duplicate MCP prompt declaration for #{inspect(duplicate)}"
end
end
defp validate_unique_resource_names!(resources) do
names = Enum.map(resources, & &1.name)
case names -- Enum.uniq(names) do
[] ->
:ok
[duplicate | _] ->
raise ArgumentError, "duplicate MCP resource declaration for #{inspect(duplicate)}"
end
end
defp apply_scope_alias(module, nil), do: module
defp apply_scope_alias(module, scope_alias), do: Module.concat(scope_alias, module)
defp expand_alias({:__aliases__, _, _} = alias_ast, caller), do: Macro.expand(alias_ast, caller)
defp expand_alias(other, _caller), do: other
end