The Endpoint mode lets you define each MCP tool, resource, and prompt as its own Elixir module, then aggregate them in a central Endpoint.
defmodule MyApp.Echo do
use ConduitMcp.Component, type: :tool, description: "Echoes text back"
schema do
field :text, :string, "The text to echo", required: true, max_length: 500
end
@impl true
def execute(%{text: text}, _conn) do
text(text)
end
endKey points:
type: :toolmarks this as a tool componentdescription:is required for toolsschema do ... enddefines input parameters — generates JSON Schema and validation automaticallyexecute/2receives atom-keyed params and aPlug.Conn- Response helpers (
text/1,json/1,error/1,image/1) are imported automatically
defmodule MyApp.ReadUser do
use ConduitMcp.Component,
type: :resource,
uri: "user://{id}",
description: "User profile by ID",
mime_type: "application/json"
@impl true
def execute(%{id: id}, _conn) do
user = MyApp.Users.get!(id)
{:ok, %{
"contents" => [%{
"uri" => "user://#{id}",
"mimeType" => "application/json",
"text" => Jason.encode!(user)
}]
}}
end
endKey points:
uri:is required — uses{param}placeholders for dynamic segments- URI params are extracted automatically and passed as atom-keyed map to
execute/2 - No
schema do ... endneeded — params come from the URI template
defmodule MyApp.CodeReview do
use ConduitMcp.Component, type: :prompt, description: "Code review assistant"
schema do
field :code, :string, "Code to review", required: true
field :language, :string, "Programming language", default: "elixir"
end
@impl true
def execute(%{code: code, language: language}, _conn) do
{:ok, %{
"messages" => [
system("You are a #{language} code reviewer"),
user("Review this code:\n#{code}")
]
}}
end
endThe schema do ... end block defines parameters with automatic JSON Schema and validation generation.
schema do
field :name, :string, "Name", required: true
field :age, :integer, "Age", min: 0, max: 150
field :score, :number, "Score", min: 0.0, max: 100.0
field :active, :boolean, "Is active", default: true
field :tags, {:array, :string}, "Tags"
end| Option | Types | Description |
|---|---|---|
required: true |
All | Mark as required |
default: value |
All | Default value |
enum: [...] |
All | Allowed values |
min: n / max: n |
number, integer | Numeric bounds |
min_length: n / max_length: n |
string | String length bounds |
validator: fn |
All | Custom validator function |
schema do
field :address, :object, "Mailing address", required: true do
field :street, :string, "Street", required: true
field :city, :string, "City", required: true
field :zip, :string, "Zip code"
end
enddefmodule MyApp.MCPServer do
use ConduitMcp.Endpoint,
name: "My Application",
version: "1.0.0",
rate_limit: [backend: MyApp.RateLimiter, limit: 60, scale: 60_000],
message_rate_limit: [backend: MyApp.RateLimiter, limit: 50, scale: 300_000],
auth: [strategy: :bearer_token, token: "secret"]
component MyApp.Echo
component MyApp.ReadUser
component MyApp.CodeReview
end| Option | Description |
|---|---|
:name |
Server name (shown in initialize response) |
:version |
Server version (shown in initialize response) |
:rate_limit |
HTTP rate limiting config |
:message_rate_limit |
Message-level rate limiting config |
:auth |
Authentication config |
Use the component macro to register component modules. Components are validated at compile time:
- Must
use ConduitMcp.Component - Must implement
execute/2 - No duplicate names within the same type
The Endpoint's config is auto-extracted by transports — no need to duplicate rate_limit/auth/name/version:
# Minimal — endpoint config provides name, version, rate_limit, auth
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
port: 4001}Explicit transport opts always override endpoint config:
# Override server name at the transport level
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
server_name: "Overridden Name"},
port: 4001}By default, the component name is derived from the module name (MyApp.Echo → "echo"). Override with :name:
use ConduitMcp.Component,
type: :tool,
name: "my_custom_tool",
description: "Custom named tool"use ConduitMcp.Component,
type: :tool,
description: "Delete user",
scope: "users:write"use ConduitMcp.Component,
type: :tool,
description: "Fetch data",
annotations: [readOnlyHint: true, idempotent: true]All execute/2 callbacks must return {:ok, map()} or {:error, map()}. Helper macros are imported automatically.
# Plain text
text("Hello!")
# JSON-encoded data
json(%{users: [%{id: 1, name: "Alice"}]})
# Image (base64)
image(Base.encode64(png_data))
# Audio
audio(Base.encode64(wav_data), "audio/wav")
# Error
error("Something went wrong")
error("Bad input", -32602)def execute(%{topic: topic}, _conn) do
{:ok, %{"messages" => [
system("You are an expert on #{topic}"),
user("Explain #{topic} in simple terms"),
assistant("Sure! Let me break it down...")
]}}
endReturn multiple content items (e.g., text + image) by building the map directly:
def execute(%{query: query}, _conn) do
chart_data = MyApp.Charts.generate(query)
{:ok, %{
"content" => [
%{"type" => "text", "text" => "Here are the results for: #{query}"},
%{"type" => "image", "data" => chart_data, "mimeType" => "image/png"},
%{"type" => "text", "text" => "Generated at #{DateTime.utc_now()}"}
]
}}
endraw/1 bypasses MCP content wrapping — returns any map directly as {:ok, map}:
raw(%{"custom_key" => "value", "data" => [1, 2, 3]})
# => {:ok, %{"custom_key" => "value", "data" => [1, 2, 3]}}Warning: Clients expecting the standard MCP
"content"array won't parse raw responses. Use for debugging or custom integrations only.
You can always skip helpers and return the tuple directly:
def execute(%{id: id}, _conn) do
case MyApp.Repo.get(User, id) do
nil ->
{:error, %{"code" => -32002, "message" => "User #{id} not found"}}
user ->
{:ok, %{
"content" => [
%{"type" => "text", "text" => Jason.encode!(%{
id: user.id,
name: user.name,
roles: user.roles
})}
],
"isError" => false
}}
end
endThe Plug.Conn is passed as the second argument to execute/2:
def execute(_params, conn) do
user = conn.assigns[:current_user]
session = conn.private[:mcp_session_data]
text("Hello #{user}")
endThe Endpoint auto-detects capabilities from registered components. If you only register tools, only tools is advertised in the initialize response. No manual configuration needed.