Migration Guide: Upgrading to Struct-Based API
View SourceThis guide will help you migrate your MCP server from the map-based API to the new struct-based API introduced in version 0.4.0.
Overview
Starting with version 0.4.0, the MCP server library uses typed Elixir structs instead of plain maps for all MCP protocol structures. This provides better type safety, improved IDE support, and clearer error messages.
Good news: Most of your code will continue to work without changes! The Router DSL and controller function signatures remain the same.
Breaking Changes Summary
What Changed
- Router list functions now return structs instead of maps
- Controller helper functions now return structs instead of maps
- Field access requires using struct notation (
.field) instead of map notation (["field"])
What Hasn't Changed
- Router DSL syntax (
tool,prompt,resourcemacros) - exactly the same - JSON output format - identical structure, same camelCase field names
- HTTP transport layer - no changes needed
- Validation logic - same rules
- Error handling patterns - same approach
The core DSL you learned remains the same - you're just getting better types and needing to thread conn through your controllers!
Migration Steps
1. Update Router List Function Usage
Router list functions now require a conn parameter AND return structs instead of plain maps.
Before (v0.3.x - No conn, Map Access)
tools = MyRouter.tools_list()
tool_names = Enum.map(tools, & &1["name"])
first_tool = List.first(tools)
IO.puts("Tool: #{first_tool["name"]} - #{first_tool["description"]}")After (v0.4.0+ - With conn, Struct Access)
{:ok, tools} = MyRouter.list_tools(conn)
tool_names = Enum.map(tools, & &1.name)
first_tool = List.first(tools)
IO.puts("Tool: #{first_tool.name} - #{first_tool.description}")Changes Required:
- Function name:
tools_list()→list_tools(conn) - Function name:
prompts_list()→prompts_list(conn) - Add
connparameter to all list function calls - Handle
{:ok, results}tuple return value - Replace all
["field_name"]with.field_namefor field access
2. Update Controller Function Signatures
Controller functions now receive a conn parameter as the first argument.
Before (v0.3.x - Arity 1, Return Maps)
defmodule MyApp.Tools do
def my_tool(args) do
# No conn parameter available
name = args["name"]
"Hello, #{name}!"
end
end
defmodule MyApp.Resources do
import McpServer.Controller
def read_config(_opts) do
%{
"contents" => [
content("config.json", "file:///config.json",
text: Jason.encode!(%{setting: "value"}),
mimeType: "application/json"
)
]
}
end
endAfter (v0.4.0+ - Arity 2, Return Structs)
defmodule MyApp.Tools do
def my_tool(conn, args) do
# conn parameter now available for session info
IO.inspect(conn.session_id)
name = args["name"]
"Hello, #{name}!"
end
end
defmodule MyApp.Resources do
import McpServer.Controller
def read_config(conn, _opts) do
# Can access conn.session_id, conn.private, etc.
McpServer.Resource.ReadResult.new(
contents: [
content("config.json", "file:///config.json",
text: Jason.encode!(%{setting: "value"}),
mimeType: "application/json"
)
]
)
end
endChanges Required:
- Add
connas first parameter to ALL controller functions - Tool functions:
def my_tool(args)→def my_tool(conn, args) - Prompt get functions:
def get_prompt(args)→def get_prompt(conn, args) - Prompt complete functions:
def complete(arg, prefix)→def complete(conn, arg, prefix) - Resource read functions:
def read(opts)→def read(conn, opts) - Resource complete functions:
def complete(arg, prefix)→def complete(conn, arg, prefix) - Return
ReadResultstruct for resource read handlers - Use struct field access (
.field) instead of map access (["field"])
3. Update Test Assertions
If you have tests that verify Router or controller output, update the assertions.
Before (Map Assertions)
test "lists tools" do
{:ok, tools} = MyRouter.list_tools(conn)
assert length(tools) == 3
assert Enum.any?(tools, & &1["name"] == "my_tool")
tool = Enum.find(tools, & &1["name"] == "my_tool")
assert tool["description"] == "My tool description"
assert tool["inputSchema"]["type"] == "object"
end
test "creates completion" do
result = completion(["foo", "bar"], total: 10, has_more: true)
assert result["values"] == ["foo", "bar"]
assert result["total"] == 10
assert result["hasMore"] == true
endAfter (Struct Assertions)
test "lists tools" do
{:ok, tools} = MyRouter.list_tools(conn)
assert length(tools) == 3
assert Enum.any?(tools, & &1.name == "my_tool")
tool = Enum.find(tools, & &1.name == "my_tool")
assert %McpServer.Tool{} = tool
assert tool.description == "My tool description"
assert tool.input_schema.type == "object"
end
test "creates completion" do
result = completion(["foo", "bar"], total: 10, has_more: true)
assert %McpServer.Completion{} = result
assert result.values == ["foo", "bar"]
assert result.total == 10
assert result.has_more == true
# Verify JSON encoding still works
json = Jason.encode!(result)
decoded = Jason.decode!(json)
assert decoded["hasMore"] == true # camelCase in JSON
endQuick Reference: Struct Types
Tools
%McpServer.Tool{
name: String.t(),
description: String.t(),
input_schema: McpServer.Schema.t(),
annotations: McpServer.Tool.Annotations.t()
}Prompts
%McpServer.Prompt{
name: String.t(),
description: String.t(),
arguments: [McpServer.Prompt.Argument.t()]
}Resources (Static)
%McpServer.Resource{
name: String.t(),
uri: String.t(),
description: String.t() | nil,
mime_type: String.t() | nil,
title: String.t() | nil
}Resources (Templated)
%McpServer.ResourceTemplate{
name: String.t(),
uri_template: String.t(),
description: String.t() | nil,
mime_type: String.t() | nil,
title: String.t() | nil
}Content
%McpServer.Resource.Content{
name: String.t(),
uri: String.t(),
mime_type: String.t() | nil,
text: String.t() | nil,
blob: String.t() | nil,
title: String.t() | nil
}Messages
%McpServer.Prompt.Message{
role: String.t(),
content: McpServer.Prompt.MessageContent.t()
}Completion
%McpServer.Completion{
values: [String.t()],
total: integer() | nil,
has_more: boolean() | nil
}Schema
%McpServer.Schema{
type: String.t(),
properties: map() | nil,
required: [String.t()] | nil,
description: String.t() | nil,
enum: [any()] | nil,
default: any() | nil
}Field Name Mapping: Struct vs JSON
When working with structs, remember that field names use snake_case in Elixir but are automatically converted to camelCase in JSON:
| Struct Field (Elixir) | JSON Field |
|---|---|
mime_type | mimeType |
uri_template | uriTemplate |
has_more | hasMore |
read_only_hint | readOnlyHint |
destructive_hint | destructiveHint |
idempotent_hint | idempotentHint |
open_world_hint | openWorldHint |
input_schema | inputSchema |
Important: Always use snake_case when working with structs in Elixir code. The JSON encoder handles the conversion automatically.
Common Migration Patterns
Pattern 1: Iterating Over Lists
# Before (v0.3.x) - No conn, map access
tools = MyRouter.tools_list()
Enum.each(tools, fn tool ->
IO.puts("#{tool["name"]}: #{tool["description"]}")
end)
# After (v0.4.0+) - With conn, struct access
{:ok, tools} = MyRouter.list_tools(conn)
Enum.each(tools, fn tool ->
IO.puts("#{tool.name}: #{tool.description}")
end)Pattern 2: Controller Functions with conn
# Before (v0.3.x) - Arity 1, no conn
defmodule MyApp.Tools do
def greet(args) do
name = args["name"]
"Hello, #{name}!"
end
end
# After (v0.4.0+) - Arity 2, with conn
defmodule MyApp.Tools do
def greet(conn, args) do
# Can now access session info
IO.inspect(conn.session_id)
name = args["name"]
"Hello, #{name}!"
end
endPattern 3: Building Resource Responses
# Before (v0.3.x) - No conn, return map with string keys
def read_file(%{"path" => path}) do
file_content = File.read!(path)
%{
"contents" => [
%{
"name" => Path.basename(path),
"uri" => "file://#{path}",
"mimeType" => "text/plain",
"text" => file_content
}
]
}
end
# After (v0.4.0+) - With conn, return ReadResult struct
def read_file(conn, %{"path" => path}) do
file_content = File.read!(path)
McpServer.Resource.ReadResult.new(
contents: [
content(
Path.basename(path),
"file://#{path}",
mime_type: "text/plain",
text: file_content
)
]
)
endPattern 4: Creating Completions
# Before (v0.3.x) - Arity 2, return map
def complete_language("lang", prefix) do
languages = ["elixir", "erlang", "javascript"]
filtered = Enum.filter(languages, &String.starts_with?(&1, prefix))
%{
"values" => filtered,
"total" => length(languages),
"hasMore" => false
}
end
# After (v0.4.0+) - Arity 3 with conn, return struct
def complete_language(conn, "lang", prefix) do
languages = ["elixir", "erlang", "javascript"]
filtered = Enum.filter(languages, &String.starts_with?(&1, prefix))
completion(filtered, total: length(languages), has_more: false)
endTroubleshooting
Error: function fetch/2 is undefined
Problem:
** (UndefinedFunctionError) function McpServer.Tool.fetch/2 is undefined
(McpServer.Tool does not implement the Access behaviour)Solution: You're trying to access a struct with map syntax. Change struct["field"] to struct.field.
# Wrong
tool["name"]
# Correct
tool.nameError: key :name not found
Problem:
** (KeyError) key :name not found in: [description: "...", uri: "..."]Solution: You're missing a required field when creating a struct. Check the struct definition for @enforce_keys.
# Wrong - missing required :name field
McpServer.Resource.new(uri: "file:///test", description: "Test")
# Correct - includes all required fields
McpServer.Resource.new(name: "test", uri: "file:///test", description: "Test")Error: Pattern match failed
Problem:
# Test fails: pattern match (=) failed
assert result["values"] == ["foo", "bar"]Solution: Update assertions to use struct field access.
# Correct
assert result.values == ["foo", "bar"]Benefits of the New API
1. Better Type Safety
# Compiler catches typos
tool.descripption # Compile error: unknown field
tool.description # Works!2. Better IDE Support
Your IDE can now provide autocomplete and inline documentation for all fields.
3. Clearer Error Messages
# Before (map)
%{name: "test"} # Silently accepts any fields
# After (struct)
McpServer.Tool.new(name: "test")
# ** (KeyError) key :description not found
# Clear error showing what's missing!4. Guaranteed Field Names
# Before (map) - typos create bugs
%{"mimeType" => "text/plain"} # Oops! Should be "mimeType" or "mime_type"?
# After (struct) - typos cause compile errors
content(..., mime_type: "text/plain") # Compiler validates field nameNeed Help?
If you encounter issues during migration:
- Check the STRUCTURES.md document for detailed struct definitions
- Review the INTEGRATION_SUMMARY.md for implementation details
- Look at the test files in
test/mcp_server/for usage examples - Open an issue on GitHub if you find a bug or need assistance
Version Compatibility
- Version 0.3.x and earlier: Map-based API
- Version 0.4.0 and later: Struct-based API (this guide)
If you need to support both versions, you can use pattern matching:
def process_tool(tool) do
case tool do
%McpServer.Tool{name: name, description: desc} ->
# Version 0.4.0+ (struct)
{name, desc}
%{"name" => name, "description" => desc} ->
# Version 0.3.x (map)
{name, desc}
end
endSummary
The migration requires a few systematic changes:
- Add
connparameter to ALL controller functions (first parameter) - Update Router function calls:
tools_list()→list_tools(conn)and handle tuple returns - Replace
["field"]with.fieldfor accessing returned values - Use helper functions (
content/3,message/3,completion/2) for return values - Wrap resource responses in
ReadResult.new() - Update tests to use struct assertions