Skip to content

Inside the Server

systemBlue edited this page Jun 11, 2026 · 1 revision

Inside the server

How a tool call becomes an OSC message, shown with code from the server.

This page follows one tool call through the server code, from the tool definition your MCP client reads to the OSC message REAPER receives. The excerpts below are from the server source, in Sources/DeadCatReaperCore on GitHub.

The tool definition

Each tool is declared with a name, a description, and a JSON schema for its arguments. The schema is what an MCP client shows the assistant, and it carries the bounds the server enforces.

MCPTool(
    name: "reaper_set_track_volume",
    description: "Set a REAPER track volume using a normalized value from 0.0 through 1.0.",
    inputSchema: MCPScaffold.objectSchema(
        properties: [
            "track": MCPScaffold.integerSchema(description: "One-based REAPER track number.", minimum: 1),
            "value": MCPScaffold.numberSchema(
                description: "Normalized volume from 0.0 through 1.0.",
                minimum: 0,
                maximum: 1
            ),
        ],
        required: ["track", "value"]
    ),
    annotations: MCPToolAnnotations(title: "Set track volume", destructiveHint: true, idempotentHint: true)
)

Argument validation

Arguments are validated before anything is sent to the DAW. A missing or invalid argument produces a caller error that names the argument and what was expected, so the assistant knows to fix its arguments rather than report a failure.

static func requiredTrack(in arguments: [String: JSONValue]) throws -> Int {
    let track = try requiredInt("track", in: arguments)
    guard track >= 1 else {
        throw ReaperToolError.invalidArgument("track", "expected a one-based track number")
    }
    return track
}

The command mapping

Each validated call becomes one ReaperCommand case, and each case maps to exactly one OSC message. Actions that have no dedicated OSC address use REAPER's /action interface with the action's numeric command ID.

public var oscMessage: OSCMessage {
    switch self {
    case .play:
        OSCMessage("/play", [.float(1)])
    // ...
    case .setTrackVolume(let track, let value):
        OSCMessage("/track/\(track)/volume", [.float(value)])
    case .setTrackPan(let track, let value):
        OSCMessage("/track/\(track)/pan", [.float(value)])
    case .setMasterVolume(let value):
        OSCMessage("/master/volume", [.float(value)])
    // ...
    case .insertMarker:
        OSCMessage("/action", [.int(ActionID.insertMarker)])
    case .goToTime(let seconds):
        OSCMessage("/time", [.float(seconds)])
    // ... one case per tool
    }
}

The read path

Read tools answer from a session cache fed by REAPER's feedback stream. Before answering, the server asks REAPER to refresh all control surfaces, then waits for the feedback to settle, so the values it reports are current.

public func callTool(_ call: MCPToolCall) throws -> MCPToolResult {
    if let session, ReaperReadTools.isReadTool(call.name) {
        try sender.send(ReaperCommand.refreshControlSurfaces.oscMessage)
        waitForFeedbackToSettle(in: session)
        return try ReaperReadTools.result(for: call, in: session)
    }
    if call.name == ReaperMediaInsertion.tool.name {
        return try insertMedia(call)
    }
    let command = try ReaperCommandParser.command(for: call)
    if case .insertTrack = command, let session {
        let before = session.knownTrackNumbers().count
        try sender.send(command.oscMessage)
        return .text(trackCountResult(before: before, summary: command.summary, in: session))
    }
    try sender.send(command.oscMessage)
    guard let session, command.verification(in: session) != nil else {
        return .text(command.summary)
    }
    switch waitForWriteConfirmation(of: command, in: session) {
    case .confirmed(let detail):
        return .text("\(command.summary); feedback confirmed \(detail)")
    case .unconfirmed(.some(let lastReported)):
        return .text("\(command.summary); feedback has not confirmed it, last reported \(lastReported)")
    case .unconfirmed(nil):
        return .text("\(command.summary); feedback has not confirmed it")
    }
}

Write verification

When the feedback listener is configured, a write does not stop at "sent": the server asks REAPER to re-send its state, waits for the burst to settle, and checks the cache against the commanded value. REAPER does not reliably echo a control change back to the surface that sent it, and a value the cache held before the write must not count as proof, so the refreshed state is the only state trusted.

private func waitForWriteConfirmation(
    of command: ReaperCommand,
    in session: ReaperSessionCache
) -> ReaperWriteVerification {
    guard (try? sender.send(ReaperCommand.refreshControlSurfaces.oscMessage)) != nil else {
        return .unconfirmed(lastReported: nil)
    }
    waitForFeedbackToSettle(in: session)
    return command.verification(in: session) ?? .unconfirmed(lastReported: nil)
}

Marker commands carry no feedback in REAPER's control surface protocol, so they report as sent without a confirmation clause.

Bundle unwrapping

REAPER wraps every feedback datagram in an OSC bundle, so the decoder unwraps bundles before it can see the messages. Bundles can nest, and datagrams are untrusted input, so the recursion is capped.

/// Every message in `bytes`, in encounter order.
public static func messages(decoding bytes: [UInt8]) throws -> [OSCMessage] {
    guard hasBundleHeader(bytes) else {
        return [try OSCMessage(decoding: bytes)]
    }
    var messages = [OSCMessage]()
    try appendBundleMessages(from: bytes, into: &messages, depth: 0)
    return messages
}

/// Real control surfaces nest a level or two; datagrams are untrusted, so
/// recursion is capped well before it could exhaust the stack.
private static let maximumBundleDepth = 8

Testing

The OSC layer is tested against the OSC 1.0 specification, including the bundle wrapping above. The tool router is tested name by name, so the tool list and the parser cannot drift apart. The suite runs on every change.

Clone this wiki locally