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!

The API looks nice overall. One small ergonomic suggestion: when there is only one parameter description, could Pair be accepted in addition to Dict?

@tool :place => "extract the location info from a place" function lookup_place(place::Place)

This would avoid the extra Dict(...) ceremony for the common single-argument case.

Hi @ind258,

Not currently, it only works with:

  • Dictionary
  • NamedTuple
  • Vector{Pair}

But that’s a pretty small change since it already supports a vector of pairs. I’ll get that additional case handled later today.

Thanks! That’s the exact kind of feedback I’m looking for

Question, what if the tool argument descriptions end up taking up most of the line / you have multiple? Is it possible to write that out in multiple lines?

Yes you can, but it does tend to get cluttered pretty quickly. I’ve found myself writing a lot of tool definitions like this:

@tool "Add two integers together" Dict(
    :a => "the first addend", 
    :b => "the second addend"
    ) function add(a::Int, b::Int)
    return a + b
end

You can always move the tool definitions outside the tool macro and just pass it as a variable

add_defs = Dict(
  :a => "the first addend", 
  :b => "the second addend"
)

@tool "Add two integers together" add_defs function add(a::Int, b::Int)
    return a + b
end

You can make it even shorter if you use the code block as the tool description (it’s read by the tool macro)

defs = Dict(
  :a => "the first addend", 
  :b => "the second addend"
)

"""
Add two integers together
"""
@tool defs function add(a::Int, b::Int)
    return a + b
end

What do you think of these forms? Does this give you any ideas on how to improve the syntax?