liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! MCP protocol version negotiation.
//!
//! The two MCP eras the ticket serves (SDK-017 R1, the revisions SDK-014 R2
//! names): 2026-07-28, where every request carries its protocol version and
//! capabilities in `_meta` and `server/discover` answers the server's
//! versions; and 2025-11-25, where `initialize` opens a session scoped to
//! this process. An `initialize` asking for any other version is answered
//! with 2025-11-25, as the MCP lifecycle requires (the server answers a
//! version it supports and the client decides); a client that cannot speak
//! it disconnects. A modern request naming an unknown version is refused.

use serde_json::{Map, Value, json};

use super::framing::{INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION};

/// The modern revision.
pub const MODERN: &str = "2026-07-28";
/// The legacy revision.
pub const LEGACY: &str = "2025-11-25";
/// The versions the ticket speaks, newest first.
pub const SUPPORTED: [&str; 2] = [MODERN, LEGACY];

const META: &str = "_meta";
const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";

/// What the ticket says it is.
#[must_use]
pub fn server_info() -> Value {
    json!({ "name": "liminal", "version": env!("CARGO_PKG_VERSION") })
}

/// The era a request is served in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Era {
    /// Per-request metadata; results carry `resultType` and the server's info.
    Modern,
    /// An `initialize` session on this process.
    Legacy,
}

/// A refusal of a request before its method is looked at.
#[derive(Debug, PartialEq, Eq)]
pub struct Refusal {
    /// The JSON-RPC error code.
    pub code: i64,
    /// The error's words.
    pub message: String,
    /// The error's data, when the code defines some.
    pub data: Option<Value>,
}

/// The process's negotiation state: whether a legacy session was opened.
#[derive(Debug, Default)]
pub struct Session {
    legacy: bool,
}

impl Session {
    /// A process with no session yet.
    #[must_use]
    pub const fn new() -> Self {
        Self { legacy: false }
    }

    /// Whether an `initialize` opened a legacy session on this process.
    #[must_use]
    pub const fn is_legacy(&self) -> bool {
        self.legacy
    }

    /// Decides the era of a request from its `params`: modern when `_meta`
    /// carries a supported protocol version and client capabilities; legacy
    /// when a legacy session is open and the request carries no `_meta`
    /// protocol version; refused otherwise.
    ///
    /// # Errors
    /// Returns the unsupported-version error naming the supported versions,
    /// or invalid params naming the missing `_meta` field.
    pub fn era_of(&self, params: &Map<String, Value>) -> Result<Era, Refusal> {
        let meta = params.get(META).and_then(Value::as_object);
        let version = meta.and_then(|meta| meta.get(META_PROTOCOL_VERSION));
        match version {
            Some(Value::String(version)) if version == MODERN => {
                if meta
                    .and_then(|meta| meta.get(META_CLIENT_CAPABILITIES))
                    .is_none()
                {
                    return Err(invalid_params(META_CLIENT_CAPABILITIES));
                }
                Ok(Era::Modern)
            }
            Some(Value::String(version)) => Err(unsupported(version)),
            None if self.legacy => Ok(Era::Legacy),
            Some(_) | None => Err(invalid_params(META_PROTOCOL_VERSION)),
        }
    }

    /// Answers `initialize`: opens the legacy session when the requested
    /// version is the legacy one.
    ///
    /// # Errors
    /// Returns invalid params when no version is named.
    pub fn initialize(&mut self, params: &Map<String, Value>) -> Result<Value, Refusal> {
        params
            .get("protocolVersion")
            .and_then(Value::as_str)
            .ok_or_else(|| invalid_params("protocolVersion"))?;
        self.legacy = true;
        Ok(json!({
            "protocolVersion": LEGACY,
            "capabilities": { "tools": {}, "logging": {}, "experimental": { "claude/channel": {} } },
            "serverInfo": server_info(),
        }))
    }

    /// Answers `server/discover` (modern only).
    #[must_use]
    pub fn discover() -> Value {
        json!({
            "resultType": "complete",
            "supportedVersions": [MODERN],
            "capabilities": { "tools": {}, "logging": {}, "experimental": { "claude/channel": {} } },
            META: { META_SERVER_INFO: server_info() },
            "instructions": "The ticket to a liminal network: list the stations, describe one, call one by its subject, or run a flow of calls. What call may reach is the pass's grant.",
        })
    }
}

/// A result shaped for its era: modern results carry `resultType` and the
/// server's info in `_meta`.
#[must_use]
pub fn shaped(era: Era, mut result: Map<String, Value>) -> Value {
    if era == Era::Modern {
        result.insert("resultType".to_owned(), json!("complete"));
        result.insert(META.to_owned(), json!({ META_SERVER_INFO: server_info() }));
    }
    Value::Object(result)
}

fn unsupported(requested: &str) -> Refusal {
    Refusal {
        code: UNSUPPORTED_PROTOCOL_VERSION,
        message: "Unsupported protocol version".to_owned(),
        data: Some(json!({ "supported": SUPPORTED, "requested": requested })),
    }
}

fn invalid_params(field: &str) -> Refusal {
    Refusal {
        code: INVALID_PARAMS,
        message: format!("the request is missing {field}"),
        data: None,
    }
}

#[cfg(test)]
#[path = "negotiate_tests.rs"]
mod tests;