// The client: the protocol from the caller's side. It owns the request ids,
// says which revision it speaks, carries its identity on every modern request,
// and turns replies into the types of the library.
///|
/// A server said no. The code and data are the JSON-RPC error as it arrived.
pub suberror Refused {
Refused(code~ : Int32, message~ : String, data~ : Json?)
}
///|
/// A connection to a server, with the handshake behind it (← FastMCP's
/// `Client`).
///
/// On the 2026-07-28 revision there is no handshake: every request carries the
/// version, the client's identity and its capabilities in `_meta`, and the
/// server answers on its own. On the revisions before it, `connect` runs
/// `initialize` and `notifications/initialized` once.
pub struct Client {
priv transport : &Transport
priv mut next_id : Int32
priv mut server_info : @types.Implementation?
priv mut server_capabilities : @types.ServerCapabilities?
priv mut protocol_version : String
info : @types.Implementation
capabilities : @types.ClientCapabilities
priv modern : Bool
}
///|
/// Connect over `transport`. `protocol_version` picks the dialect: `2026-07-28`
/// speaks the stateless revision, anything the server lists as older runs the
/// handshake. The default follows the server's own `latest_protocol_version`.
pub async fn Client::connect(
transport : &Transport,
name~ : String = "moonmcp",
version~ : String = "0.1.0",
protocol_version~ : String = @types.latest_protocol_version,
capabilities~ : @types.ClientCapabilities = @types.ClientCapabilities::new(),
) -> Client {
let client : Client = {
transport,
next_id: 1,
server_info: None,
server_capabilities: None,
protocol_version,
info: { name, title: None, version, },
capabilities,
modern: modern_revision(protocol_version),
}
if !client.modern {
client.handshake()
}
client
}
///|
/// Whether `version` is one this library serves without a handshake.
fn modern_revision(version : String) -> Bool {
version == "2026-07-28"
}
///|
/// The handshake of the revisions before 2026-07-28, run once at connect:
/// `initialize`, then `notifications/initialized`, with the server's identity
/// and capabilities kept for the caller.
async fn Client::handshake(self : Client) -> Unit {
let reply = self.transport.ask(
self.frame(
"initialize",
{
"protocolVersion": Json::string(self.protocol_version),
"capabilities": self.capabilities.to_json(),
"clientInfo": self.info.to_json(),
},
modern=false,
),
fn(_) { () },
)
let result = result_of(reply)
match result.get("protocolVersion") {
Some(String(version)) => self.protocol_version = version
_ => ()
}
self.server_info = decode_or_none(result, "serverInfo")
self.server_capabilities = decode_or_none(result, "capabilities")
self.transport.tell(notification(@types.notification_initialized, Map([]))) catch {
_ => ()
}
}
///|
/// Field `key` of `result`, read as `T`; `None` when it is absent or does not
/// read. A server that omits part of the handshake is served as best it can
/// be rather than refused.
fn[T : @json.FromJson] decode_or_none(
result : Map[String, Json],
key : String,
) -> T? {
match result.get(key) {
Some(value) =>
try @json.from_json(value) catch {
_ => None
} noraise {
decoded => Some(decoded)
}
None => None
}
}
///|
/// Who the server says it is, once it has said.
pub fn Client::server_info(self : Client) -> @types.Implementation? {
self.server_info
}
///|
/// What the server says it can do.
pub fn Client::server_capabilities(self : Client) -> @types.ServerCapabilities? {
self.server_capabilities
}
///|
/// The revision in force on this connection.
pub fn Client::protocol_version(self : Client) -> String {
self.protocol_version
}
///|
/// The next request id, taken.
fn Client::take_id(self : Client) -> Int32 {
let id = self.next_id
self.next_id = id + 1
id
}
///|
/// One request message, with the metadata a stateless revision wants on every
/// request.
fn Client::frame(
self : Client,
operation : String,
params : Map[String, Json],
modern~ : Bool,
) -> Json {
let out = params.copy()
if modern {
let meta : Map[String, Json] = match params.get("_meta") {
Some(Object(meta)) => meta.copy()
_ => Map([])
}
meta[@types.protocol_version_meta_key] = Json::string(self.protocol_version)
meta[@types.client_info_meta_key] = self.info.to_json()
meta[@types.client_capabilities_meta_key] = self.capabilities.to_json()
out["_meta"] = Json(meta)
}
{
"jsonrpc": "2.0",
"id": Json::number(self.take_id().to_double()),
"method": Json::string(operation),
"params": Json(out),
}
}
///|
/// Ask `operation` with `params` and hand back the result, raising `Refused`
/// when the server answers with an error.
pub async fn Client::request(
self : Client,
operation : String,
params~ : Map[String, Json] = Map([]),
) -> Map[String, Json] {
self.exchange(operation, params~, on_notification=fn(_) { () })
}
///|
/// The same, with somewhere to put the notifications the server sends while
/// the answer is being worked out: progress, log messages.
pub async fn Client::exchange(
self : Client,
operation : String,
params~ : Map[String, Json] = Map([]),
on_notification~ : async (Json) -> Unit = fn(_) { () },
progress_token? : Json,
) -> Map[String, Json] {
let mut adjusted = params
if progress_token is Some(token) {
let with_meta = adjusted.copy()
let meta : Map[String, Json] = match params.get("_meta") {
Some(Object(meta)) => meta.copy()
_ => Map([])
}
meta["progressToken"] = token
with_meta["_meta"] = Json(meta)
adjusted = with_meta
}
let reply = self.transport.ask(
self.frame(operation, adjusted, modern=self.modern),
on_notification,
)
result_of(reply)
}
///|
/// Send a message that gets no reply.
pub async fn Client::notify(
self : Client,
operation : String,
params~ : Map[String, Json] = Map([]),
) -> Unit {
self.transport.tell(notification(operation, params))
}
///|
/// Open a subscription and hand every notification on it to `on_notification`
/// until the server ends it.
pub async fn Client::listen(
self : Client,
filter~ : @types.SubscriptionFilter,
on_notification~ : async (Json) -> Unit,
) -> Unit {
let message = self.frame(
@types.method_subscriptions_listen,
{ "notifications": filter.to_json() },
modern=self.modern,
)
self.transport.listen(message, on_notification)
}
///|
/// Stop asking for anything on this connection.
pub async fn Client::close(self : Client) -> Unit {
self.transport.close()
}
///|
/// A bare result with no payload.
pub async fn Client::ping(self : Client) -> Unit {
ignore(self.request(@types.method_ping))
}
///|
/// What the server supports and which revisions it speaks.
pub async fn Client::discover(self : Client) -> Map[String, Json] {
self.request(@types.method_server_discover)
}
///|
/// The tools the server offers.
pub async fn Client::list_tools(self : Client) -> Array[@types.Tool] {
let result = self.request(@types.method_tools_list)
match result.get("tools") {
Some(Array(tools)) => tools.map(tool => @json.from_json(tool))
_ =>
raise Refused(
code=@types.invalid_params,
message="tools/list carried no tools",
data=None,
)
}
}
///|
/// Call `name` with `arguments`; notifications sent before the answer go to
/// `on_notification`.
pub async fn Client::call_tool(
self : Client,
name : String,
arguments~ : Map[String, Json] = Map([]),
on_notification~ : async (Json) -> Unit = fn(_) { () },
progress_token? : Json,
) -> @types.CallToolResult {
let result = self.exchange(
@types.method_tools_call,
params={ "name": Json::string(name), "arguments": Json(arguments) },
on_notification~,
progress_token?,
)
@json.from_json(Json(result))
}
///|
/// The resources the server offers.
pub async fn Client::list_resources(self : Client) -> Array[@types.Resource] {
let result = self.request(@types.method_resources_list)
match result.get("resources") {
Some(Array(resources)) =>
resources.map(resource => @json.from_json(resource))
_ =>
raise Refused(
code=@types.invalid_params,
message="resources/list carried no resources",
data=None,
)
}
}
///|
/// Read the resource at `uri`.
pub async fn Client::read_resource(
self : Client,
uri : String,
) -> @types.ReadResourceResult {
let result = self.request(@types.method_resources_read, params={
"uri": Json::string(uri),
})
@json.from_json(Json(result))
}
///|
/// The prompts the server offers.
pub async fn Client::list_prompts(self : Client) -> Array[@types.Prompt] {
let result = self.request(@types.method_prompts_list)
match result.get("prompts") {
Some(Array(prompts)) => prompts.map(prompt => @json.from_json(prompt))
_ =>
raise Refused(
code=@types.invalid_params,
message="prompts/list carried no prompts",
data=None,
)
}
}
///|
/// Render the prompt `name` with `arguments`.
pub async fn Client::get_prompt(
self : Client,
name : String,
arguments~ : Map[String, Json] = Map([]),
) -> @types.GetPromptResult {
let result = self.request(@types.method_prompts_get, params={
"name": Json::string(name),
"arguments": Json(arguments),
})
@json.from_json(Json(result))
}
///|
/// The result of a reply, or the error it carries as `Refused`.
fn result_of(reply : Json) -> Map[String, Json] raise {
guard reply is Object(obj) else {
raise Refused(
code=@types.internal_error,
message="Reply is not an object",
data=None,
)
}
match obj.get("error") {
Some(Object(error)) =>
raise Refused(
code=match error.get("code") {
Some(Number(value, ..)) => value.to_int()
_ => @types.internal_error
},
message=match error.get("message") {
Some(String(text)) => text
_ => "The server refused"
},
data=error.get("data"),
)
_ => ()
}
match obj.get("result") {
Some(Object(result)) => result
Some(_) =>
raise Refused(
code=@types.internal_error,
message="Reply carries no result object",
data=None,
)
None =>
raise Refused(
code=@types.internal_error,
message="Reply carries no result",
data=None,
)
}
}
///|
/// One notification message.
fn notification(operation : String, params : Map[String, Json]) -> Json {
{
"jsonrpc": "2.0",
"method": Json::string(operation),
"params": Json(params),
}
}