Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions loomem-server/src/mcp/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::sync::Arc;

use super::router;
use super::session;
use super::stateless;
use super::types::*;
use crate::auth::AuthContext;
use crate::AppState;
Expand Down Expand Up @@ -52,6 +53,33 @@ pub async fn mcp_post_handler(
},
Err(_) => return (StatusCode::BAD_REQUEST, Json(Value::Null)).into_response(),
};

// MCP 2026-07-28 (SEP-2575): route on the `MCP-Protocol-Version` header.
// No header, or an initialize-era version (2025-06-18+ clients send the
// header with their *negotiated* version) → the legacy session path
// below, untouched. `2026-07-28` → the stateless path. Anything else →
// `-32004` with the supported list, per the negotiation flow.
match stateless::classify_protocol_version(
headers
.get("mcp-protocol-version")
.and_then(|v| v.to_str().ok()),
) {
stateless::VersionRoute::Legacy => {}
stateless::VersionRoute::Stateless => {
return stateless::handle_stateless_post(&state, &headers, body, &auth).await
}
stateless::VersionRoute::Unsupported(v) => {
return (
StatusCode::BAD_REQUEST,
Json(JsonRpcResponse::error(
stateless::request_id_of(&body),
JsonRpcError::unsupported_protocol_version(&v),
)),
)
.into_response()
}
}

let session_id = headers
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
Expand Down
1 change: 1 addition & 0 deletions loomem-server/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod dispatcher;
pub mod handler;
pub mod router;
pub mod session;
pub mod stateless;
pub mod tools;
pub mod types;

Expand Down
120 changes: 120 additions & 0 deletions loomem-server/src/mcp/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@ pub async fn route_jsonrpc(
}
}

/// Route a JSON-RPC request on the 2026-07-28 stateless path (SEP-2575).
/// No session machinery: identity and stream come from `AuthContext` on
/// every request, exactly as on the legacy path. `initialize` and `ping`
/// are removed in this revision, so they fall through to method-not-found.
pub async fn route_stateless(
state: &Arc<AppState>,
request: JsonRpcRequest,
auth: &AuthContext,
) -> Option<JsonRpcResponse> {
let stream_id: &str = &auth.stream_id;
if request.jsonrpc != "2.0" {
return request.id.map(|id| {
JsonRpcResponse::error(id, JsonRpcError::invalid_request("jsonrpc must be \"2.0\""))
});
}
// Notifications (no id) don't get responses.
let id = match request.id {
Some(id) => id,
None => return None,
};
match request.method.as_str() {
"server/discover" => Some(handle_discover(state, id, stream_id)),
"tools/list" => Some(handle_tools_list_stateless(state, id)),
"tools/call" => Some(handle_tools_call(state, id, request.params, stream_id, auth).await),
_ => Some(JsonRpcResponse::error(
id,
JsonRpcError::method_not_found(&request.method),
)),
}
}

// Single source of truth: loomem-server/mcp_instructions.md
// Edit that file, not this constant. Embedded at compile time via include_str!.
const MCP_INSTRUCTIONS: &str = include_str!("../../mcp_instructions.md");
Expand Down Expand Up @@ -117,6 +148,51 @@ fn handle_initialize(
JsonRpcResponse::success(id, serde_json::to_value(result).unwrap())
}

/// `server/discover` (SEP-2575): advertises supported versions, capabilities,
/// server info and instructions. The ECA-13 dynamic advisories ride along in
/// `instructions` exactly as they do in the legacy `initialize` result —
/// discover has the same per-request `AuthContext`, so per-stream advisories
/// port 1:1.
fn handle_discover(state: &Arc<AppState>, id: Value, stream_id: &str) -> JsonRpcResponse {
let advisories = if state.config.advisor.enabled {
loomem_core::advisor::get_cached_advisories(&state.store, stream_id, 3)
} else {
Vec::new()
};
let instructions = build_instructions(&advisories);

let result = DiscoverResult {
supported_versions: SUPPORTED_PROTOCOL_VERSIONS
.iter()
.map(|v| (*v).to_string())
.collect(),
capabilities: ServerCapabilities {
tools: ToolsCapability {
list_changed: false,
},
},
server_info: ServerInfo {
name: "loomem-memory".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some(instructions),
};
JsonRpcResponse::success(id, serde_json::to_value(result).unwrap())
}

/// `tools/list` on the stateless path: same definitions as the legacy list
/// plus the SEP-2549 cache hints, which are required fields in 2026-07-28.
fn handle_tools_list_stateless(state: &Arc<AppState>, id: Value) -> JsonRpcResponse {
JsonRpcResponse::success(
id,
json!({
"tools": tools::tool_definitions(&state.config.mcp),
"ttlMs": TOOLS_LIST_TTL_MS,
"cacheScope": TOOLS_LIST_CACHE_SCOPE,
}),
)
}

fn handle_tools_list(state: &Arc<AppState>, id: Value) -> JsonRpcResponse {
JsonRpcResponse::success(
id,
Expand Down Expand Up @@ -198,4 +274,48 @@ mod tests {
"advisory tail line preserved",
);
}

// SEP-2575 contract: discover carries the same instructions surface as
// initialize (ECA-13 advisories included) plus the version list, in the
// exact serialized shape clients read.
#[test]
fn test_discover_result_shape_and_instruction_contract() {
let advisories = vec![AdvisoryItem {
id: "adv-1".into(),
advisory_type: AdvisoryType::HealthCheck,
message: "Memory pressure rising".into(),
suggested_action: None,
affected_chunk_ids: vec![],
priority: AdvisoryPriority::High,
created_at: 0,
}];
let result = DiscoverResult {
supported_versions: SUPPORTED_PROTOCOL_VERSIONS
.iter()
.map(|v| (*v).to_string())
.collect(),
capabilities: ServerCapabilities {
tools: ToolsCapability {
list_changed: false,
},
},
server_info: ServerInfo {
name: "loomem-memory".into(),
version: "test".into(),
},
instructions: Some(build_instructions(&advisories)),
};
let v = serde_json::to_value(result).expect("serializable");
assert_eq!(v["supportedVersions"][0], "2026-07-28");
assert_eq!(v["supportedVersions"][1], "2025-03-26");
assert_eq!(v["capabilities"]["tools"]["listChanged"], false);
assert_eq!(v["serverInfo"]["name"], "loomem-memory");
let instructions = v["instructions"].as_str().expect("instructions string");
assert!(instructions.contains(PREAMBLE), "preamble preserved");
assert!(
instructions.contains("## MEMORY ADVISORY"),
"ECA-13 advisories ride along in discover"
);
assert!(instructions.contains("- [HIGH] Memory pressure rising"));
}
}
Loading
Loading