moonjrpc

    JSON-RPC 2.0 for MoonBit: messages, a method table, two-way calls and framing.

    json-rpc
    jrpc
    jsonrpc
    rpc
    json
    peer
    bidirectional
    ndjson
    stdio
    content-length
    lsp
    mcp
    acp
    moonbit
    Download zip
    Version
    0.1.3
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    11

    #moonjrpc

    JSON-RPC 2.0 for MoonBit: messages, a method table, two-way calls and framing.

    moon add moonbitstack/moonjrpc

    #Answer calls

    A method table takes one body and gives back one body, with the errors the specification asks for. Use it where a request and its answer are one exchange, such as an HTTP POST.

    let methods = @moonjrpc.Methods::new()
    methods.typed("sum", (numbers : Array[Int32]) => numbers.fold(init=0, (a, b) => a + b))
    methods.call("no", _ => @moonjrpc.fail(7, "said no"))
    methods.notice("log", params => logged.push(params))

    let answer = methods.body(request_body) // None when there is nothing to send

    InputAnswer
    Not JSON-32700
    Not a message-32600, with its identifier if one can be read
    A method nobody registered-32601
    Parameters typed cannot decode-32602, with the reason in data
    fail(code, message, data=...)That error object
    Any other error from a handler-32603 with no detail; Methods::new(on_error=...) hears it
    An arrayA batch; batch=false refuses it with -32600

    #Call both ways

    @peer is for transports that stay open, where either side may call the other.

    let peer = @peer.Peer::new(@peer.lines(@stdio.stdin, @stdio.stdout), methods~)
    @async.with_task_group(async fn(group) {
    group.spawn_bg(no_wait=true, () => peer.run())
    let root = peer.call("roots/list")
    peer.notify("log", params=root)
    })

    Error from Peer::callMeaning
    Failed(failure)The other side answered with an error object
    TimedOut(name~, limit~)No answer in time. The default is 60 seconds; timeout_ms=None waits as long as it takes
    Closed(name~)The other side went away

    Messages are taken in the order they arrive. A notice finishes before the next message is looked at; a call starts in its turn and runs on in its own task; a reply wakes its caller in its turn. So updates sent before an answer are handled before the caller sees it, and a change sent before a call is in place when the call starts. A notice handler that waits on Peer::call waits for a reply that cannot be looked at until it returns, so it should start a task for that.

    Telling the other side a call was given up on is left to the protocol: MCP, ACP and LSP each have their own notice for it.

    #Bring your own transport

    lines is one message to a line, the stdio transport of MCP and ACP. content_length is the base protocol of LSP. Anything else implements Frames:

    pub(open) trait Frames {
    async fn read(Self) -> String?
    async fn write(Self, String) -> Unit
    }

    The HTTP shape is not here, because each protocol has its own: MCP answers on the POST's SSE stream, ACP on a GET stream, A2A in the POST's body. They build it on Methods and Peer.

    #Skill

    An Agent Skill for this library is released with each version, at the same version number:

    moonx moonbit-community/skills download moonbitstack/moonjrpc --extract ./moonjrpc-skill

    The source is docs/skills/moonjrpc/SKILL.md, which also ships inside the module.

    #Conformance

    • Every example in section 7 of the specification, regenerated from the page by scripts/vectors/gen_spec_vectors.py.
    • scripts/interop/run.mjs, in CI: vscode-jsonrpc over content_length and json-rpc-2.0 over lines, calling both ways.
    • The same 17 bodies through json-rpc-2.0 and Methods::body. Three differ, and the check fails if that changes: a method that is not a string, where the specification's own example answers -32600 and theirs answers -32601 or nothing, and a null body, where theirs throws.

    #License

    Apache-2.0.

    Failed

    pub(all) suberror Failed {
    Failed(Failure)
    TimedOut(name~ : String, limit~ : Int)
    Closed(name~ : String)
    TooLong(limit~ : Int, got~ : Int64)
    Malformed(String)
    } derive(Eq,
    Debug
    )

    What goes wrong with a call. Failed is the other side's error object, and a handler raises it to answer with one. limit~ is the bound that was passed and got~ what came.

    Failed::equal

    fn Failed::equal(Failed, Failed) -> Bool

    Failed::not_equal

    fn Failed::not_equal(x : Failed, y : Failed) -> Bool

    Failed::to_repr

    Failure

    pub(all) struct Failure {
    code : Int
    message : String
    data :
    Json
    ?
    } derive(Eq,
    Debug
    )

    The error object of a response.

    Failure::equal

    fn Failure::equal(Failure, Failure) -> Bool

    Failure::not_equal

    fn Failure::not_equal(x : Failure, y : Failure) -> Bool

    Failure::to_json

    Failure::to_repr

    pub(all) enum Id {
    Num(Double, repr~ : String?)
    Text(String)
    Null
    } derive(Eq,
    Debug
    )

    A request's identifier. A number keeps the text it was written in, as core's Json does, so an identifier Double cannot hold goes back out exactly as it came in.
    impl ToJson for Id

    Id::equal

    fn Id::equal(Id, Id) -> Bool

    Id::int

    fn Id::int(n : Int64) -> Id

    An identifier from an integer.

    Id::key

    fn Id::key(self : Id) -> String

    The same text for the same identifier, whichever side wrote it: a map key for matching replies to calls.

    Id::not_equal

    fn Id::not_equal(x : Id, y : Id) -> Bool

    Id::to_json

    fn Id::to_json(self : Id) ->
    Json

    Id::to_repr

    Message

    pub(all) enum Message {
    Call(id~ : Id, name~ : String, params~ :
    Json
    ?)
    Notice(name~ : String, params~ :
    Json
    ?)
    Reply(id~ : Id, result~ :
    Json
    )
    Refusal(id~ : Id, failure~ : Failure)
    } derive(Eq,
    Debug
    )

    One JSON-RPC 2.0 message. A Refusal is a response carrying an error.
    impl FromJson for Message

    Message::equal

    fn Message::equal(Message, Message) -> Bool

    Message::invalid

    The answer to something that is not a valid message: -32600, with its identifier if one can be read and null otherwise.

    Message::not_equal

    fn Message::not_equal(x : Message, y : Message) -> Bool

    Message::to_json

    Message::to_repr

    Message::unreadable

    fn Message::unreadable() -> Message

    The answer to text that is not JSON: -32700, with a null identifier.

    Methods

    type Methods

    The method table: what a side does with the calls and notices it receives. Registering a name again replaces it.

    Methods::answer

    async fn Methods::answer(self : Methods, message : Message) -> Message?

    What to send back for a message: the response to a call, nothing for the rest. A method nobody registered is -32601 and a raised Failed goes out as it is; any other error is -32603 with no detail, because what a handler trips over is not the caller's to read.

    Methods::body

    async fn Methods::body(self : Methods, text : StringView, batch? : Bool) -> String?

    One body in, one body out, by the specification: text that is not JSON is -32700, a value that is not a message is -32600, an array is a batch, and when nothing needs answering there is nothing to send. batch=false is for protocols that forbid arrays, as ACP does: an array is then -32600.

    Methods::call

    fn Methods::call(self : Methods, name : String, run : async (
    Json
    ?) ->
    Json
    ) -> Unit

    Answer calls to name with what run returns. The parameters arrive as they came.

    Methods::new

    fn Methods::new(on_error? : (String, Error) -> Unit) -> Methods

    A table with nothing in it. on_error hears the errors a client is not told about: a handler's failure other than Failed, which goes out as -32603 with no detail, and any failure of a notice, which has no one to go to.

    Methods::notice

    fn Methods::notice(self : Methods, name : String, run : async (
    Json
    ?) -> Unit) -> Unit

    Run run for each notice named name. Nothing goes back.

    Methods::typed

    fn[P :
    FromJson
    , R :
    ToJson
    ] Methods::typed(self : Methods, name : String, run : async (P) -> R) -> Unit

    A call whose parameters and result go through FromJson and ToJson. Parameters that do not decode are -32602, with the decoder's reason as the error's data.

    fail

    fn[T] fail(code : Int, message : String, data? :
    Json
    ) -> T raise Failed

    Raise an error object: fail(invalid_params, "no such file").

    internal_error

    let internal_error : Int

    The handler failed in a way the caller is not told about.

    invalid_params

    let invalid_params : Int

    The parameters are not what the method takes.

    invalid_request

    let invalid_request : Int

    The value is not a valid message.

    method_not_found

    let method_not_found : Int

    Nobody registered that method.

    parse_error

    let parse_error : Int

    The text is not JSON.