DSL for defining MCP servers with a clean, declarative syntax.
The DSL provides macros for defining tools, prompts, and resources without manually building JSON schemas and callback functions.
Example
defmodule MyApp.MCPServer do
use ConduitMcp.Server
tool "greet", "Greets a person" do
param :name, :string, "Name to greet", required: true
param :style, :string, "Greeting style", enum: ["formal", "casual"]
handle fn _conn, params ->
name = params["name"]
style = params["style"] || "casual"
greeting = if style == "formal", do: "Good day", else: "Hey"
text("#{greeting}, #{name}!")
end
end
tool "calculate", "Math operations" do
param :op, :string, "Operation", enum: ~w(add sub mul div), required: true
param :a, :number, "First number", required: true
param :b, :number, "Second number", required: true
handle MyMath, :calculate # MFA
end
prompt "code_review", "Code review assistant" do
arg :code, :string, "Code to review", required: true
arg :language, :string, "Language", default: "elixir"
get fn _conn, args ->
[
system("You are a code reviewer"),
user("Review this #{args["language"]} code:\n#{args["code"]}")
]
end
end
resource "user://{id}" do
description "User profile"
mime_type "application/json"
read fn _conn, params, _opts ->
user = MyApp.Users.get!(params["id"])
json(user)
end
end
endThe DSL automatically generates:
- Tool/prompt/resource schemas
- Input validation (JSON Schema)
- handlelist* callbacks
- handle_call_tool/handle_get_prompt/handle_read_resource callbacks
- handle_complete, when any block declares
complete/2
Completions
complete/2 inside a prompt or resource block makes one prompt
argument or resource template parameter answer completion/complete:
prompt "code_review", "Code review assistant" do
arg :language, :string, "Language"
get fn _conn, args -> [user("Review my #{args["language"]}")] end
complete :language, fn _conn, prefix ->
Enum.filter(~w(elixir erlang python), &String.starts_with?(&1, prefix))
end
end
resource "file:///{path}" do
read MyFiles, :read
complete :path, MyFiles, :complete_path
endThe client's ref selects the block (ref/prompt by prompt name,
ref/resource by the URI template exactly as declared) and
argument.name the completion; the handler gets the conn and
argument.value and returns a list of strings. The answer carries the
first 100 values, total and hasMore. An argument without a
completion answers no values; an unknown prompt or resource is -32602.
A block's scope/1 also guards its completions. Declaring any completion
generates handle_complete/3, which advertises the completions
capability (ConduitMcp.Capabilities).
Summary
Functions
Sets annotations for a tool.
Defines an MCP App — a tool with a linked UI resource.
Defines a prompt argument.
Declares cache hints for the enclosing tool, resource or prompt on the
modern (2026-07-28) era: ttl_ms (non-negative integer) and scope
(:private or :public), each falling back to the global
config :conduit_mcp, cache_ttl_ms: ..., cache_scope: ....
Pre-compiles a URI template into a regex and parameter name list.
Declares the autocompletion of one argument of the enclosing prompt, or
one parameter of the enclosing resource template, for
completion/complete.
Sets the description for a resource.
Matches a URI against a pre-compiled template regex.
Defines a nested field within an object parameter.
Defines the get handler for a prompt.
Defines the handler function for a tool.
Sets icon descriptors for a tool. Added in MCP spec 2025-11-25.
Defines the item type for an array parameter.
Attaches arbitrary _meta metadata to a tool definition.
Sets the MIME type for a resource.
Sets the JSON Schema for the tool's structured output.
Defines a parameter for a tool.
Defines a parameter with nested fields (for objects) or items (for arrays).
Stores a compiled URI template regex in :persistent_term so that it can
be looked up at request time without paying the per-process
Regex.recompile/1 cost incurred when a Regex struct is embedded via
Macro.escape/1.
Defines an MCP prompt.
Defines the read handler for a resource.
Defines an MCP resource.
Sets a required OAuth scope for a tool, resource, or prompt.
Declares the tool's support for asynchronous task execution.
Fetches a {param_names, regex} tuple previously stored by
precompile_template_regex/2, falling back to recompiling on cache miss
(defensive — should not normally happen).
Sets a human-readable display title for a tool.
Defines an MCP tool.
Shortcut for declaring a UI resource URI on a tool (MCP Apps).
Specifies the HTML file path for an MCP App.
Functions
Sets annotations for a tool.
Annotations provide hints about tool behavior to clients.
Options
:read_only- Tool doesn't modify state (default: not set):destructive- Tool may perform destructive operations (default: not set):idempotent- Calling multiple times has same effect (default: not set):open_world- Tool may interact with external systems (default: not set)
Example
tool "delete_user", "Deletes a user" do
annotations destructive: true, idempotent: true
param :id, :string, "User ID", required: true
handle fn _conn, %{"id" => id} -> ... end
end
Defines an MCP App — a tool with a linked UI resource.
The app macro is a convenience that registers both:
- A tool with
_meta.ui.resourceUripointing to aui://resource - A resource that serves the HTML file at that URI
Use view/1 inside the block to specify the HTML file path.
The ui:// URI is derived automatically from the tool name and filename.
Example
app "dashboard", "Health dashboard" do
param :format, :string, "Output format", default: "json"
view "priv/mcp_apps/dashboard.html"
handle fn _conn, params ->
json(%{cpu: 42, memory: 128})
end
endThis is equivalent to:
tool "dashboard", "Health dashboard" do
ui "ui://dashboard/dashboard.html"
param :format, :string, "Output format", default: "json"
handle fn _conn, params -> json(%{cpu: 42, memory: 128}) end
end
resource "ui://dashboard/dashboard.html" do
description "UI for dashboard"
mime_type "text/html;profile=mcp-app"
read fn _conn, _params, _opts ->
app_html(File.read!("priv/mcp_apps/dashboard.html"))
end
endNote: The
viewpath is read withFile.read!/1at runtime. For OTP releases where the working directory differs, use a manualtool+resourcepair withApplication.app_dir/2instead.
Defines a prompt argument.
Examples
arg :code, :string, "Code to review", required: true
arg :language, :string, "Programming language", default: "elixir"
Declares cache hints for the enclosing tool, resource or prompt on the
modern (2026-07-28) era: ttl_ms (non-negative integer) and scope
(:private or :public), each falling back to the global
config :conduit_mcp, cache_ttl_ms: ..., cache_scope: ....
tool "catalog", "Lists the public catalog" do
cache ttl_ms: 5_000, scope: :public
handle fn _conn, _params -> text("...") end
endA list result takes the minimum TTL of its items and is private if any
item is private or scoped; see ConduitMcp.CacheHints. scope: :public
together with scope/1 is a compile error (a scoped response must stay
private).
Pre-compiles a URI template into a regex and parameter name list.
Returns {param_names, compiled_regex} suitable for passing to
extract_uri_params_compiled/3. Call this at compile time and store
the result to avoid re-compiling the regex on every request.
Declares the autocompletion of one argument of the enclosing prompt, or
one parameter of the enclosing resource template, for
completion/complete.
The handler receives the conn and the value typed so far ("" when the
client sent none) and returns a list of strings; the server answers with
the first 100, total set to the length of the list and hasMore set
when it was longer. Give it as an anonymous function, a capture, or a
module and function name (called as module.function(conn, value)).
Examples
prompt "code_review", "Code review assistant" do
arg :language, :string, "Language"
get fn _conn, args -> [user("Review my #{args["language"]}")] end
complete :language, fn _conn, prefix ->
~w(elixir python javascript rust)
|> Enum.filter(&String.starts_with?(&1, prefix))
end
end
resource "file:///{path}" do
read MyFiles, :read
complete :path, &MyFiles.autocomplete/2
end
resource "user://{id}" do
read MyUsers, :read
complete :id, MyUsers, :complete_id
endThe name must be one of the prompt's args or one of the template's
{params} (a compile error otherwise), at most once per block.
Sets the description for a resource.
Example
resource "user://{id}" do
description "User profile information"
read MyUsers, :read
end
Matches a URI against a pre-compiled template regex.
Uses the output of compile_uri_template/1 to avoid runtime regex compilation.
Defines a nested field within an object parameter.
Examples
param :user, :object, "User data" do
field :name, :string, "Name", required: true
field :email, :string, "Email", required: true
field :address, :object, "Address" do
field :city, :string, "City"
field :zip, :string, "Zip code"
end
end
Defines the get handler for a prompt.
The handler should return a list of message objects. On a modern-era
request it may instead return {:input_required, input_requests, state},
which is passed through unwrapped; see "Multi round-trip requests (MRTR)"
in ConduitMcp.Server.
Examples
get fn _conn, args ->
[
system("You are helpful"),
user("Question: #{args["question"]}")
]
end
get MyPrompts, :get_review
Defines the handler function for a tool.
Accepts either an anonymous function or an MFA tuple.
Examples
# Anonymous function
handle fn _conn, params ->
text("Result: #{params["input"]}")
end
# Module, function
handle MyModule, :my_function
# Function capture
handle &MyModule.my_function/2Asking the client for input (MRTR)
On a modern-era (2026-07-28) request the handler may return
{:input_required, input_requests, state}; the retry finds the restored
state and the client's answers in conn.assigns[:mcp_input]. See
"Multi round-trip requests (MRTR)" in ConduitMcp.Server.
handle fn conn, params ->
case conn.assigns[:mcp_input] do
nil ->
{:input_required,
%{"approval" => ConduitMcp.MRTR.elicit("Approve deploy?", schema)},
%{step: :awaiting_approval, target: params["target"]}}
%{state: %{"target" => target}, responses: %{"approval" => %{"action" => "accept"}}} ->
text("Deployed #{target}")
_declined ->
text("Cancelled")
end
end
Sets icon descriptors for a tool. Added in MCP spec 2025-11-25.
Accepts a list of icon maps, each with src (URL or data URI),
optional mimeType, width, height. Clients use these to render
the tool in their UI.
Example
tool "search", "Search the catalog" do
icons [%{"src" => "https://example.com/search.svg", "mimeType" => "image/svg+xml"}]
handle MyCatalog, :search
end
Defines the item type for an array parameter.
Item schemas are published to clients in the JSON Schema but are not enforced server-side — NimbleOptions cannot attach a nested schema to a list element type. Validate item contents in your handler.
Examples
param :tags, :array, "Tags" do
items :string
end
param :users, :array, "Users" do
items :object do
field :name, :string, "Name"
field :email, :string, "Email"
end
end
Attaches arbitrary _meta metadata to a tool definition.
The _meta field is an open extension point in the MCP spec. Any map
can be passed — atom keys are automatically converted to string keys
in the JSON output.
Example
tool "dashboard", "Health dashboard" do
meta %{ui: %{resourceUri: "ui://dashboard/app.html"}}
handle fn _conn, _params -> json(%{ok: true}) end
endThe tool's tools/list entry will include:
%{
"name" => "dashboard",
"_meta" => %{"ui" => %{"resourceUri" => "ui://dashboard/app.html"}}
}
Sets the MIME type for a resource.
Example
resource "file://{path}" do
mime_type "text/plain"
read MyFiles, :read
end
Sets the JSON Schema for the tool's structured output.
When a tool returns a structured payload (via structured/2 helper
or by including "structuredContent" in the result map), this schema
describes its shape so clients can render/validate it. Added in MCP
spec 2025-11-25.
Example
tool "get_user", "Fetch a user" do
param :id, :string, "User id", required: true
output_schema %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string"},
"email" => %{"type" => "string"}
},
"required" => ["id", "email"]
}
handle MyUsers, :get
end
Defines a parameter for a tool.
Basic Options
:required- Mark parameter as required (default: false):enum- List of allowed values:default- Default value if not provided
Header Option (2026-07-28)
:header- Mirrors the argument into anMcp-Param-{Name}HTTP request header:param :region, :string, "Region", header: "Region"publishes"x-mcp-header": "Region"on the property. Modern clients then send the argument asMcp-Param-Region, so intermediaries can route on it, andConduitMcp.Plugs.HeaderValidationrejects a request whose header disagrees with the body. The build fails (CompileError) unless the annotation is valid: the param must be a:string,:integeror:booleanreachable through object properties only (never inside arrayitems), and the name must be an RFC 9110 token unique case-insensitively within the tool. Not a validation rule: the argument is validated as usual. A browser client also needs the header name in the transport's:cors_headers.
Enhanced Validation Options (NimbleOptions)
:min- Minimum value for numbers (inclusive):max- Maximum value for numbers (inclusive):min_length- Minimum string length:max_length- Maximum string length:validator- Custom validation functionfn(value) -> boolean():type_coercion-trueorfalse; overrides the global:type_coercionsetting ofConduitMcp.Validationfor this param. The fields of an object param inherit it unless they set their own.
Object Options
:additional_properties- Whether keys the block did not declare are accepted. Defaults tofalsewhen the object declares fields andtruewhen it does not. Also drives"additionalProperties"in the generated JSON Schema, so the published schema always matches what is enforced.
Object and Array Parameters
Pass a block to declare structure. Nested fields are enforced at runtime to
any depth — required, types, and every constraint above — and undeclared keys
are rejected. opts may be omitted; param :bag, :object do ... end and
param :bag, :object, "desc" do ... end both work.
# Nested object, enforced
param :user, :object, "User data", required: true do
field :name, :string, "Full name", required: true
field :address, :object, "Address" do
field :city, :string, "City", required: true
end
end
# Open object — any keys, nothing enforced
param :metadata, :object, "Arbitrary metadata"
# Declared fields enforced, everything else passed through
param :bag, :object, "Bag", additional_properties: true do
field :name, :string, "Name", required: true
end
# Array item type
param :rows, :array, "Rows" do
items :object do
field :id, :integer, "Row id", required: true
end
endArray item schemas are published for clients but not enforced server-side: NimbleOptions cannot attach a nested schema to a list element type.
Handlers always receive string keys, at every depth.
Examples
# Basic validation
param :name, :string, "User name", required: true
param :role, :string, "User role", enum: ["admin", "user", "guest"]
param :active, :boolean, "Active status", default: true
# Enhanced validation
param :age, :integer, "Age in years", min: 0, max: 150, required: true
param :username, :string, "Username", min_length: 3, max_length: 30
param :score, :number, "Score", min: 0.0, max: 100.0, default: 50.0
param :email, :string, "Email", validator: &ConduitMcp.Validation.Validators.email/1
# Complex validation with multiple constraints
param :priority, :string, "Task priority",
required: true,
enum: ["low", "medium", "high", "critical"],
validator: &MyApp.validate_priority/1
# Array with validation
param :tags, {:array, :string}, "Tags", max_length: 10
Defines a parameter with nested fields (for objects) or items (for arrays).
Stores a compiled URI template regex in :persistent_term so that it can
be looked up at request time without paying the per-process
Regex.recompile/1 cost incurred when a Regex struct is embedded via
Macro.escape/1.
Called at module load time from generated @on_load hooks.
Defines an MCP prompt.
Example
prompt "code_review", "Code review assistant" do
arg :code, :string, "Code to review", required: true
arg :language, :string, "Language", default: "elixir"
get fn _conn, args ->
[
system("You are a code reviewer"),
user("Review this code: #{args["code"]}")
]
end
complete :language, fn _conn, prefix ->
~w(elixir python javascript) |> Enum.filter(&String.starts_with?(&1, prefix))
end
end
Defines the read handler for a resource.
Handler signature: (conn, uri_params, opts) -> result. On a modern-era
request result may be {:input_required, input_requests, state}; see
"Multi round-trip requests (MRTR)" in ConduitMcp.Server.
Examples
read fn _conn, %{"id" => id}, _opts ->
user = MyApp.Users.get!(id)
json(user)
end
read MyFiles, :read
Defines an MCP resource.
Examples
resource "user://{id}" do
description "User profile data"
mime_type "application/json"
read fn _conn, params, _opts ->
user = MyApp.Users.get!(params["id"])
json(user)
end
end
resource "file://{path}" do
mime_type "text/plain"
read MyFiles, :read
complete :path, &MyFiles.autocomplete/2
end
Sets a required OAuth scope for a tool, resource, or prompt.
ConduitMcp.Handler checks the declared scope against
ConduitMcp.Principal.scopes/1 before dispatching. A request with no
principal has no scopes, so a scoped declaration fails closed.
Must be called inside a tool, app, resource or prompt block; calling
it anywhere else raises at compile time rather than silently enforcing
nothing.
Examples
tool "delete_user", "Deletes a user" do
scope "users:write"
param :id, :string, "User ID", required: true
handle fn _conn, %{"id" => id} -> ... end
end
resource "vault://{id}" do
scope "vault:read"
read fn _conn, params, _opts -> ... end
end
prompt "code_review", "Reviews code" do
scope "review:run"
get fn _conn, args -> ... end
endA scope declared on a URI template covers every URI that template serves. A URI served by a static resource, or by a template declared earlier, is governed by that resource's scope instead, even when it declares none:
resource "user://{id}" do
scope "admin"
read fn _conn, params, _opts -> ... end
end
# Served by its own static clause, so it needs no scope.
resource "user://me" do
read fn _conn, _params, _opts -> ... end
endMultiple scopes are required by passing a space-separated string; all of them must be present:
tool "admin_action", "Admin only" do
scope "admin users:write"
handle fn _conn, _params -> ... end
end
Declares the tool's support for asynchronous task execution.
Values:
:none(default, not emitted) — tool always returns synchronously:supported— tool MAY return a task id for long-running operations; clients can polltasks/getand receive results viatasks/result:required— tool ALWAYS returns a task id; the immediate response contains no result, only a task id to poll
Added in MCP spec 2025-11-25.
Example
tool "render_video", "Render a video from a script" do
task_support :supported
param :script, :string, "Script source", required: true
handle MyRenderer, :start
end
Fetches a {param_names, regex} tuple previously stored by
precompile_template_regex/2, falling back to recompiling on cache miss
(defensive — should not normally happen).
Sets a human-readable display title for a tool.
Distinct from name (the machine identifier used in tools/call),
title is shown to end users by MCP clients. Added in MCP spec
2025-11-25.
Example
tool "create_user", "Create a user account" do
title "Create User"
param :email, :string, "Email address", required: true
handle MyUsers, :create
end
Defines an MCP tool.
Examples
# Simple tool with inline handler
tool "greet", "Greets someone" do
param :name, :string, "Name", required: true
handle fn _conn, %{"name" => n} -> text("Hello #{n}!") end
end
# Tool with MFA handler
tool "calculate", "Calculator" do
param :a, :number, required: true
param :b, :number, required: true
handle MyMath, :add
end
# Tool with nested object
tool "create_user", "Creates user" do
param :user, :object, "User data", required: true do
field :name, :string, "Full name", required: true
field :email, :string, "Email", required: true
end
handle MyUsers, :create
end
Shortcut for declaring a UI resource URI on a tool (MCP Apps).
This is sugar for meta %{ui: %{resourceUri: uri}}. It links the tool
to an interactive HTML resource that MCP Apps-compatible hosts will
render as a sandboxed iframe.
Example
tool "dashboard", "Health dashboard" do
ui "ui://dashboard/app.html"
handle fn _conn, _params -> json(%{ok: true}) end
endSee the MCP Apps guide for full details.
Specifies the HTML file path for an MCP App.
Used inside an app block to set the HTML file that will be served
as the ui:// resource.
Example
app "dashboard", "Health dashboard" do
view "priv/mcp_apps/dashboard.html"
handle fn _conn, _params -> json(%{ok: true}) end
end