Skip to main content

liminal_cli/mcp/
negotiate.rs

1//! MCP protocol version negotiation.
2//!
3//! The two MCP eras the ticket serves (SDK-017 R1, the revisions SDK-014 R2
4//! names): 2026-07-28, where every request carries its protocol version and
5//! capabilities in `_meta` and `server/discover` answers the server's
6//! versions; and 2025-11-25, where `initialize` opens a session scoped to
7//! this process. An `initialize` asking for any other version is answered
8//! with 2025-11-25, as the MCP lifecycle requires (the server answers a
9//! version it supports and the client decides); a client that cannot speak
10//! it disconnects. A modern request naming an unknown version is refused.
11
12use serde_json::{Map, Value, json};
13
14use super::framing::{INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION};
15
16/// The modern revision.
17pub const MODERN: &str = "2026-07-28";
18/// The legacy revision.
19pub const LEGACY: &str = "2025-11-25";
20/// The versions the ticket speaks, newest first.
21pub const SUPPORTED: [&str; 2] = [MODERN, LEGACY];
22
23const META: &str = "_meta";
24const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
25const META_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
26const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
27
28/// What the ticket says it is.
29#[must_use]
30pub fn server_info() -> Value {
31    json!({ "name": "liminal", "version": env!("CARGO_PKG_VERSION") })
32}
33
34/// The era a request is served in.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Era {
37    /// Per-request metadata; results carry `resultType` and the server's info.
38    Modern,
39    /// An `initialize` session on this process.
40    Legacy,
41}
42
43/// A refusal of a request before its method is looked at.
44#[derive(Debug, PartialEq, Eq)]
45pub struct Refusal {
46    /// The JSON-RPC error code.
47    pub code: i64,
48    /// The error's words.
49    pub message: String,
50    /// The error's data, when the code defines some.
51    pub data: Option<Value>,
52}
53
54/// The process's negotiation state: whether a legacy session was opened.
55#[derive(Debug, Default)]
56pub struct Session {
57    legacy: bool,
58}
59
60impl Session {
61    /// A process with no session yet.
62    #[must_use]
63    pub const fn new() -> Self {
64        Self { legacy: false }
65    }
66
67    /// Whether an `initialize` opened a legacy session on this process.
68    #[must_use]
69    pub const fn is_legacy(&self) -> bool {
70        self.legacy
71    }
72
73    /// Decides the era of a request from its `params`: modern when `_meta`
74    /// carries a supported protocol version and client capabilities; legacy
75    /// when a legacy session is open and the request carries no `_meta`
76    /// protocol version; refused otherwise.
77    ///
78    /// # Errors
79    /// Returns the unsupported-version error naming the supported versions,
80    /// or invalid params naming the missing `_meta` field.
81    pub fn era_of(&self, params: &Map<String, Value>) -> Result<Era, Refusal> {
82        let meta = params.get(META).and_then(Value::as_object);
83        let version = meta.and_then(|meta| meta.get(META_PROTOCOL_VERSION));
84        match version {
85            Some(Value::String(version)) if version == MODERN => {
86                if meta
87                    .and_then(|meta| meta.get(META_CLIENT_CAPABILITIES))
88                    .is_none()
89                {
90                    return Err(invalid_params(META_CLIENT_CAPABILITIES));
91                }
92                Ok(Era::Modern)
93            }
94            Some(Value::String(version)) => Err(unsupported(version)),
95            None if self.legacy => Ok(Era::Legacy),
96            Some(_) | None => Err(invalid_params(META_PROTOCOL_VERSION)),
97        }
98    }
99
100    /// Answers `initialize`: opens the legacy session when the requested
101    /// version is the legacy one.
102    ///
103    /// # Errors
104    /// Returns invalid params when no version is named.
105    pub fn initialize(&mut self, params: &Map<String, Value>) -> Result<Value, Refusal> {
106        params
107            .get("protocolVersion")
108            .and_then(Value::as_str)
109            .ok_or_else(|| invalid_params("protocolVersion"))?;
110        self.legacy = true;
111        Ok(json!({
112            "protocolVersion": LEGACY,
113            "capabilities": { "tools": {}, "logging": {}, "experimental": { "claude/channel": {} } },
114            "serverInfo": server_info(),
115        }))
116    }
117
118    /// Answers `server/discover` (modern only).
119    #[must_use]
120    pub fn discover() -> Value {
121        json!({
122            "resultType": "complete",
123            "supportedVersions": [MODERN],
124            "capabilities": { "tools": {}, "logging": {}, "experimental": { "claude/channel": {} } },
125            META: { META_SERVER_INFO: server_info() },
126            "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.",
127        })
128    }
129}
130
131/// A result shaped for its era: modern results carry `resultType` and the
132/// server's info in `_meta`.
133#[must_use]
134pub fn shaped(era: Era, mut result: Map<String, Value>) -> Value {
135    if era == Era::Modern {
136        result.insert("resultType".to_owned(), json!("complete"));
137        result.insert(META.to_owned(), json!({ META_SERVER_INFO: server_info() }));
138    }
139    Value::Object(result)
140}
141
142fn unsupported(requested: &str) -> Refusal {
143    Refusal {
144        code: UNSUPPORTED_PROTOCOL_VERSION,
145        message: "Unsupported protocol version".to_owned(),
146        data: Some(json!({ "supported": SUPPORTED, "requested": requested })),
147    }
148}
149
150fn invalid_params(field: &str) -> Refusal {
151    Refusal {
152        code: INVALID_PARAMS,
153        message: format!("the request is missing {field}"),
154        data: None,
155    }
156}
157
158#[cfg(test)]
159#[path = "negotiate_tests.rs"]
160mod tests;