ExMCP DSL Guide
View SourceOverview
ExMCP provides a powerful Domain-Specific Language (DSL) for defining MCP tools, resources, and prompts. The DSL is designed around three core principles:
- Consistency - Same syntax patterns across all DSL types
- Developer Experience - Clean, intuitive syntax that feels natural in Elixir
- Extensibility - Easy to extend with new metadata fields and features
Core Design: The Meta Block Pattern
The Problem We Solved
Early versions of the DSL suffered from naming conflicts and inconsistent syntax:
# POOR DESIGN: Inconsistent and conflicting naming
deftool "say_hello" do
name "Hello Tool" # Clean syntax for tools
description "Says hello"
end
defresource "config://app" do
Resource.name "App Config" # Clunky qualified syntax
Resource.description "Config" # Different pattern!
end
defprompt "greeting" do
Prompt.name "Greeting" # Yet another pattern!
Prompt.description "Template"
endProblems with this approach:
- Import conflicts - Multiple DSLs exporting
nameanddescriptionfunctions - Inconsistent syntax - Different qualification patterns across DSL types
- Poor developer experience - Developers had to remember different patterns
- Not extensible - Adding new metadata fields would create more conflicts
The Solution: Meta Block Pattern
We implemented a meta block pattern that provides consistent syntax across all DSL types:
# GOOD DESIGN: Consistent and clean
deftool "say_hello" do
meta do
name "Hello Tool"
description "Says hello to someone"
version "1.0.0" # Extensible!
author "Development Team"
tags ["greeting", "demo"]
end
input_schema %{
type: "object",
properties: %{name: %{type: "string"}},
required: ["name"]
}
end
defresource "config://app" do
meta do
name "App Configuration" # Same syntax everywhere!
description "Application configuration data"
version "2.1.0"
author "System Team"
end
mime_type "application/json"
subscribable true
end
defprompt "greeting" do
meta do
name "Greeting Template" # Perfect consistency!
description "A customizable greeting template"
version "1.5.0"
author "UI Team"
end
arguments do
arg :style, description: "Greeting style (formal/casual)"
arg :name, required: true, description: "Person to greet"
end
endBenefits of the Meta Block Pattern
1. Perfect Consistency
Every DSL type uses the exact same syntax for metadata. Developers learn one pattern and can apply it everywhere.
2. Zero Import Conflicts
The meta macro creates its own scope where name, description, etc. are defined. No more qualified imports or namespace conflicts.
3. Excellent Developer Experience
- Intuitive syntax that feels natural in Elixir
- IDE autocompletion works perfectly
- Clear separation between metadata and configuration
- Easy to read and maintain
4. Future Extensible
Adding new metadata fields is trivial - just add them to the meta block:
meta do
name "Advanced Tool"
description "Does advanced operations"
version "3.0.0"
author "Core Team"
tags ["advanced", "analytics"]
deprecated false # New field - easy to add!
stability "stable" # Another new field!
category "data-processing"
end5. Follows Elixir Best Practices
This pattern is common in the Elixir ecosystem:
- Ecto schemas use block syntax for field definitions
- Phoenix routes use
scopeandpipelineblocks - GenServer uses
useblocks with options
DSL Reference
Tool DSL (deftool)
Define MCP tools that clients can invoke:
deftool "calculate_sum" do
meta do
name "Calculator"
description "Adds two numbers together"
version "1.0.0"
author "Math Team"
tags ["math", "basic"]
end
# Option 1: Direct JSON Schema (recommended for complex schemas)
input_schema %{
type: "object",
properties: %{
a: %{type: "number", description: "First number"},
b: %{type: "number", description: "Second number"}
},
required: ["a", "b"]
}
end
# Option 2: Elixir-native schema syntax (compiles to JSON Schema)
deftool "user_search" do
meta do
name "User Search"
description "Search for users by various criteria"
end
args do
field :query, :string, required: true, description: "Search query"
field :limit, :integer, description: "Maximum results"
field :include_inactive, :boolean, default: false
# Nested object fields
field :filters, :object do
field :department, :string
field :role, :string
field :created_after, :string
end
end
endImplementation:
@impl true
def handle_tool_call("calculate_sum", %{"a" => a, "b" => b}, state) do
result = a + b
response = text("Result: #{result}")
{:ok, [response], state}
endResource DSL (defresource)
Define MCP resources that clients can read:
# Static resource
defresource "config://database" do
meta do
name "Database Configuration"
description "Current database connection settings"
author "Infrastructure Team"
end
mime_type "application/json"
annotations %{
audience: ["admin", "developer"],
sensitive: true
}
end
# Pattern-based resource with subscriptions
defresource "logs://app/*.log" do
meta do
name "Application Logs"
description "Real-time application log files"
tags ["logging", "monitoring"]
end
mime_type "text/plain"
list_pattern true # Supports listing multiple resources
subscribable true # Clients can subscribe to changes
size "variable" # Indicate resource size characteristics
end
# Resource template for dynamic URIs
defresource_template "users://{user_id}/profile" do
meta do
name "User Profile"
description "Individual user profile data"
end
mime_type "application/json"
annotations %{
cache_ttl: 300,
privacy: "user-controlled"
}
endImplementation:
@impl true
def handle_resource_read("config://database", _uri, state) do
config = json(%{
host: "localhost",
port: 5432,
database: "myapp_prod"
})
{:ok, [config], state}
end
@impl true
def handle_resource_read("logs://app/" <> filename, _uri, state) do
case File.read("/var/log/app/#{filename}") do
{:ok, content} -> {:ok, [text(content)], state}
{:error, _} -> {:error, "Log file not found", state}
end
endPrompt DSL (defprompt)
Define MCP prompt templates with arguments:
# Simple prompt without arguments
defprompt "daily_standup" do
meta do
name "Daily Standup Template"
description "Template for daily standup meetings"
author "Agile Team"
end
end
# Complex prompt with typed arguments
defprompt "code_review" do
meta do
name "Code Review Assistant"
description "Comprehensive code review with configurable focus areas"
version "2.0.0"
tags ["development", "quality"]
end
arguments do
arg :code, required: true, description: "Code to review"
arg :language, description: "Programming language (auto-detected if not provided)"
arg :focus, description: "Review focus areas", default: ["security", "performance"]
arg :style, description: "Review style", enum: ["friendly", "formal", "detailed"]
arg :include_examples, description: "Include example fixes", type: :boolean, default: false
end
endImplementation:
@impl true
def handle_prompt_get("code_review", args, state) do
code = args["code"]
language = args["language"] || detect_language(code)
focus = args["focus"] || ["security", "performance"]
style = args["style"] || "friendly"
messages = [
system("You are a #{style} code reviewer focusing on: #{Enum.join(focus, ", ")}"),
user("Please review this #{language} code:\n\n#{code}")
]
{:ok, %{messages: messages}, state}
endAdvanced Patterns
Metadata Inheritance
While not currently implemented, the meta block pattern makes it easy to add metadata inheritance:
# Future feature concept
defmodule MyServer do
use ExMCP.Server
# Global metadata for all definitions
default_meta do
author "Development Team"
version "1.0.0"
tags ["internal"]
end
deftool "example" do
meta do
name "Example Tool"
description "An example tool"
# author, version, tags inherited from default_meta
tags ["internal", "example"] # Merge with default
end
input_schema %{...}
end
endConditional Metadata
The meta block supports dynamic metadata generation:
@environment Application.compile_env(:my_app, :environment)
deftool "debug_info" do
meta do
name "Debug Information"
description "System debugging information"
# Conditional metadata based on compile-time environment
if @environment == :dev do
author "Development Team"
tags ["debug", "development"]
else
author "Operations Team"
tags ["debug", "production"]
end
end
input_schema %{...}
endCustom Metadata Fields
Add custom fields specific to your application:
deftool "analytics_query" do
meta do
name "Analytics Query Engine"
description "Execute analytics queries on user data"
# Standard fields
version "2.1.0"
author "Analytics Team"
# Custom application-specific fields
data_sensitivity "high"
requires_approval true
estimated_cost_usd 0.05
max_execution_time_seconds 30
supported_data_sources ["postgresql", "bigquery", "snowflake"]
end
args do
field :query, :string, required: true
field :data_source, :string, required: true,
enum: ["postgresql", "bigquery", "snowflake"]
end
endMigration Guide
From Legacy Syntax
If you have existing code using the old syntax, migration is straightforward:
Before:
deftool "old_tool" do
description "Old style description" # Legacy
input_schema %{...}
end
defresource "old://resource" do
name "Old Resource Name" # Legacy
description "Old description" # Legacy
mime_type "text/plain"
endAfter:
deftool "old_tool" do
meta do
name "Old Tool" # New: add explicit name
description "Old style description" # New: move to meta block
end
input_schema %{...}
end
defresource "old://resource" do
meta do
name "Old Resource Name" # New: move to meta block
description "Old description" # New: move to meta block
end
mime_type "text/plain"
endBenefits of Migration
- Consistency - All your DSL definitions follow the same pattern
- Extensibility - Easy to add version, author, tags later
- Future-proof - Ready for new metadata features
- Better tooling - IDE support and documentation generation
Design Rationale
Why We Chose the Meta Block Pattern
We evaluated several approaches for handling metadata in the DSL:
Option 1: Unified Vocabulary (Rejected)
# Using different terms to avoid conflicts
deftool "example" do
title "Example Tool" # Instead of "name"
summary "Description" # Instead of "description"
endRejected because: Sacrifices semantic accuracy for technical convenience. title isn't the right word for all contexts.
Option 2: Context-Specific Vocabulary (Rejected)
# Verbose, context-specific naming
deftool "example" do
tool_name "Example Tool"
tool_description "Description"
end
defresource "example" do
resource_name "Example Resource"
resource_description "Description"
endRejected because: Highly verbose and redundant. Violates DRY principle and creates poor ergonomics.
Option 3: Keyword Syntax (Considered)
# Function arguments for metadata
deftool "example", name: "Example", description: "Description" do
input_schema %{...}
endConsidered but not chosen because: Less flexible for complex metadata and doesn't scale well with many fields.
Option 4: Meta Block Pattern (Chosen)
# Block-based organization
deftool "example" do
meta do
name "Example Tool"
description "Description"
version "1.0.0"
end
input_schema %{...}
endChosen because:
- Solves naming conflicts elegantly
- Excellent organization and readability
- Highly extensible for future fields
- Follows common Elixir DSL patterns
- Creates clear separation between metadata and configuration
Inspiration from the Elixir Ecosystem
The meta block pattern follows established conventions in Elixir:
Ecto Schemas:
schema "users" do
field :name, :string
field :email, :string
timestamps()
endPhoenix Routes:
scope "/api/v1", MyAppWeb do
pipe_through :api
resources "/users", UserController
endLiveView:
def mount(_params, _session, socket) do
socket = assign(socket,
name: "World",
count: 0
)
{:ok, socket}
endAll of these use block syntax to create scoped contexts with their own vocabulary, just like our meta blocks.
Handler Implementation Patterns
The Separation of Definition and Implementation
ExMCP intentionally separates DSL definitions from handler implementations. This follows Elixir's behaviour pattern and provides several benefits:
- Testability - Handlers are public functions that can be unit tested directly
- Organization - Definitions grouped at the top, implementations below
- Flexibility - Complex handlers can use full Elixir features
- Clarity - Clear separation of "what" (definitions) from "how" (implementations)
Standard Handler Pattern
After defining tools, resources, and prompts with the DSL, implement handlers using callbacks:
defmodule MyServer do
use ExMCP.Server
# Definitions at the top
deftool "say_hello" do
meta do
name "Hello Tool"
description "Greets someone"
end
input_schema %{
type: "object",
properties: %{name: %{type: "string"}},
required: ["name"]
}
end
# Implementations below
@impl true
def handle_tool_call("say_hello", %{"name" => name}, state) do
response = text("Hello, #{name}!")
{:ok, [response], state}
end
endThe defhandler Macro
To reduce boilerplate while maintaining the separation principle, ExMCP provides the defhandler macro:
# Instead of:
@impl true
def handle_tool_call("say_hello", %{"name" => name}, state) do
response = text("Hello, #{name}!")
{:ok, [response], state}
end
# You can write:
defhandler :tool, "say_hello", %{"name" => name}, state do
response = text("Hello, #{name}!")
{:ok, [response], state}
endBenefits of defhandler
- Reduced Boilerplate - No need to remember function names or add
@impl true - Explicit Intent - The handler type (
:tool,:resource,:prompt) is clear - Full Elixir Power - Supports pattern matching, guards, and multiple clauses
- Zero Runtime Cost - Compile-time macro that generates standard functions
Using defhandler
The macro supports all Elixir function features:
# Simple tool handler
defhandler :tool, "echo", %{"message" => msg}, state do
{:ok, [text(msg)], state}
end
# Pattern matching with guards
defhandler :tool, "divide", %{"a" => a, "b" => b}, state when b != 0 do
result = a / b
{:ok, [text("Result: #{result}")], state}
end
defhandler :tool, "divide", %{"a" => _a, "b" => 0}, state do
{:error, "Division by zero", state}
end
# Resource handler with pattern matching on URI
defhandler :resource, "file://" <> path, _uri, state do
case File.read(path) do
{:ok, content} -> {:ok, [text(content)], state}
{:error, reason} -> {:error, reason, state}
end
end
# Prompt handler
defhandler :prompt, "greeting", %{"style" => style}, state do
template = case style do
"formal" -> "Good day, {name}. How may I assist you?"
"casual" -> "Hey {name}! What's up?"
_ -> "Hello, {name}!"
end
{:ok, %{template: template}, state}
endEscape Hatches
The defhandler macro is optional. You can always use standard function definitions:
# Mix both styles in the same module
defhandler :tool, "simple", args, state do
{:ok, [text("Simple response")], state}
end
# Use standard def for complex handlers
@impl true
def handle_tool_call("complex", args, state) do
with {:ok, validated} <- validate_args(args),
{:ok, result} <- complex_operation(validated, state),
{:ok, formatted} <- format_result(result) do
{:ok, [formatted], state}
else
{:error, reason} -> {:error, reason, state}
end
endHandler Design Analysis
Why Separate Handlers from DSL?
We carefully evaluated whether to include handlers directly in the DSL (inline handlers) versus keeping them separate. The decision to keep them separate was based on:
Technical Reasons:
- Testability - Inline handlers would create untestable closures. Separate handlers are public functions that can be unit tested directly.
- Debugging - Stack traces from
def handle_tool_callare clear. Traces from macro-generated lambdas are cryptic. - Compilation - Simple data registration is faster than AST manipulation for inline handlers.
- Hot Code Reloading - Can update handler implementations without recompiling DSL definitions.
Design Reasons:
- Elixir Idioms - Behaviours with
@callbackand@impl trueare the standard pattern. - Separation of Concerns - DSL defines metadata, handlers implement behavior.
- Scalability - Easy to organize handlers in large modules or split into separate modules.
- Flexibility - Full access to Elixir features without macro limitations.
Example: Why Separation Works Better
# BAD: Inline handler (not supported)
deftool "process_data" do
meta do
name "Data Processor"
description "Processes data with retries"
end
input_schema %{...}
# This would be hard to test, debug, and extend
handler do |args, state|
# Complex logic buried in macro...
end
end
# GOOD: Separate handler (recommended)
deftool "process_data" do
meta do
name "Data Processor"
description "Processes data with retries"
end
input_schema %{...}
end
# Clear, testable, debuggable
@impl true
def handle_tool_call("process_data", args, state) do
with {:ok, data} <- validate_data(args),
{:ok, result} <- process_with_retry(data, 3),
{:ok, stored} <- store_result(result, state) do
{:ok, [json(stored)], state}
else
{:error, :validation_failed} = err ->
{:error, "Invalid data format", state}
{:error, reason} ->
Logger.error("Processing failed: #{inspect(reason)}")
{:error, "Processing failed", state}
end
end
# Easy to test
test "process_data handles validation errors" do
result = MyServer.handle_tool_call("process_data", %{invalid: true}, %{})
assert {:error, "Invalid data format", _} = result
endConclusion
The ExMCP DSL provides a comprehensive toolkit for building MCP servers:
- Meta Block Pattern - Consistent syntax for all DSL types
- Separated Handlers - Clear distinction between definition and implementation
defhandlerMacro - Optional convenience for reducing boilerplate- Full Elixir Power - No limitations on what you can implement
This design ensures that ExMCP's DSL feels natural to Elixir developers while providing a solid foundation for future enhancements.