Feedback on MCP support for Oxygen.jl

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!

2 Likes