Hello Everyone,
Oxygen.jl is almost ready to ship with built-in support for the MCP protocol, and I wanted to preview the new syntax and collect feedback on the API.
This update includes macros & functions to register tools, prompts, and resources. On top of that, it will ship with a built-in swagger-like UI to explore the MCP server.
With this update, you get the same first-class experience when writing tools as you do when writing normal HTTP routes.
- Automatic schema generation (supports deeply nested structs)
- Automatic serialization on inputs & outputs
- A built-in UI to view and play with the resources (tools, prompts, etc.)
Below is an example of a basic “mixed” server where we register normal HTTP routes alongside tools.
using Oxygen
@get "/" function ()
text("welcome to our mcp server")
end
# Here's the most basic version of a tool
@tool "Add two integers" Dict(:a => "first number", :b => "second number") function add(a::Int, b::Int)
return a + b
end
# Doc strings will be used as the tool description if none is provided
"""
Subtract two integers
"""
@tool Dict(:a => "first number", :b => "second number") function subtract(a::Int, b::Int)
return a - b
end
serve() # exposes a /mcp routes
This API provides the most ergonomic way to define a tool in the Julia ecosystem. Packages like ModelContextProtocol.jl take a schema-first approach where you need to manually define the input schema, whereas Oxygen.jl takes a code-first approach by deriving the schema from the code itself.
Below are some examples that demonstrate the features mentioned above:
Nested Structs
Structs can be deeply nested and
@kwdef struct Coordinates
lat::Float64
lon::Float64
end
@kwdef struct Place
name::String
coordinates::Coordinates
tags::Vector{String} = String[]
end
"""
Look up a saved place
"""
@tool Dict(:place => "extract the location info from a place") function lookup_place(place::Place)
return Dict(
"name" => uppercase(place.name),
"lat" => place.coordinates.lat,
"lon" => place.coordinates.lon,
"tags" => place.tags
)
end
Tool Streaming
You can also stream values from tools with the mcp_stream() function, which runs your do-block in a producer task and queues each value into a bounded channel. Those values are forwarded to the client as progress notifications on the same SSE response, and the value you return from the do-block is still the final result.
@tool "Sync files" Dict(:files => "file names") function sync_files(files::Vector{String})
return mcp_stream() do stream
for file in files
sleep(0.4)
put!(stream, "synced $file") # auto progress 1, 2, 3, ...
end
return "Synced $(length(files)) files"
end
end
Support for string-based enums
Enums in Julia are integer-backed and don’t come with string handling out of the box. In this update, the serialization layer converts string values into enums when a call comes in, and the generated schema advertises the enum names as a string enum with the default stringified. On the way out, enums already serialize back to their names as strings.
@enum Greeting hello goodbye
"""
Greet the world
"""
@tool Dict(:style => "Greeting style") function greet(style::Greeting=hello)
return style == hello ? "Hello, world!" : "Goodbye, world!"
end
The tool above generates this schema for the tool, which advertises the string enums
{"type":"string","enum":["hello","goodbye"],"default":"hello","description":"Greeting style"}
Prompts
@prompt "Plan a trip to a place" function trip_plan(place::String, days::Int=3)
return "Plan a $days-day trip to $place."
end
# Can also return a multi-message responses
@prompt "Review a saved place" function review_place(place::String, style::String="concise")
return ["user" => "Write a $style review of $place.",
"assistant" => "Sure, what should it focus on?",
"user" => "Its coordinates, tags, and nearby sites."]
end
Resources
You can also add template variables to a resource URI with {name} placeholders, and each one MUST match a parameter on the attached handler. The handler can’t declare any parameters the template doesn’t name.
# Sample data used by the resource examples below.
const PLACES = Dict(
"seattle" => Place("Seattle", Coordinates(47.61, -122.33), ["coffee", "rain"]),
"kyoto" => Place("Kyoto", Coordinates(35.01, 135.77), ["temples", "gardens"]),
)
@resource "oxygen://places/{name}" "Look up a place by name" function place_resource(name::String)
place = get(PLACES, lowercase(name), nothing)
if isnothing(place)
return "Unknown place: $name"
end
return Dict(
"name" => place.name,
"lat" => place.coordinates.lat,
"lon" => place.coordinates.lon,
"tags" => place.tags,
)
end
This isn’t an exhaustive list of all the features included in this update, but I wanted to share enough examples for others to review and provide feedback.
Nothing’s been released yet, so please let me know what you like and don’t like about this api!
