Skip to content

Commit 5b32e6a

Browse files
committed
refactor(fast-time): use rmcp SDK for Streamable HTTP transport and tools
Replace the hand-rolled MCP layer with the official Rust SDK (rmcp): - /mcp Streamable HTTP transport is now served by rmcp's StreamableHttpService + LocalSessionManager, removing the bespoke session map and JSON-RPC dispatch (streamable_http.rs). A lightweight gate caps concurrent sessions at MAX_ACTIVE_SESSIONS (rmcp's session manager has no built-in cap); existing sessions are cleaned up by rmcp on disconnect/DELETE. - Tools are declared once via #[tool] macros with schemas derived from their parameter types, removing the hand-written tool schemas and dispatch (mcp.rs). The schema_error/schema_success fixtures retain their declared outputSchema. - The legacy HTTP+SSE transport remains a hand-rolled shim (documented exception) because rmcp does not reproduce the bespoke ContextForge legacy-SSE semantics (endpoint event, sessionId/session_id aliases, 202/404/410 codes). It delegates tools/list and tools/call back to the rmcp server so schemas and logic never drift. - serverInfo is built explicitly so /mcp reports the server identity rather than rmcp's own crate name. Behavior parity (DST conversion, echo delay limit, protocol-version echo, output schemas) is covered by unit tests; both transports verified end-to-end. Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent f56eec4 commit 5b32e6a

10 files changed

Lines changed: 602 additions & 885 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mcp-servers/rust/fast-time-server/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ tokio-stream = "0.1.17"
1616
# Web framework
1717
axum.workspace = true
1818

19+
# MCP SDK (official Rust SDK) — drives the /mcp Streamable HTTP transport and
20+
# tool schemas. The legacy SSE transport is a hand-rolled shim (see transports/sse.rs).
21+
rmcp = { workspace = true, features = ["server", "macros", "transport-streamable-http-server"] }
22+
1923
# Serialization
2024
serde.workspace = true
2125
serde_json.workspace = true

mcp-servers/rust/fast-time-server/src/app.rs

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use axum::Router;
5+
use axum::extract::{Request, State};
6+
use axum::http::StatusCode;
7+
use axum::middleware::{Next, from_fn_with_state};
8+
use axum::response::{IntoResponse, Response};
59
use axum::serve::ListenerExt;
10+
use rmcp::transport::streamable_http_server::StreamableHttpService;
11+
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
612
use serde_json::json;
713
use std::env;
14+
use std::sync::Arc;
815
use tracing::info;
916
use tracing::trace;
1017
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
1118

12-
use crate::config::{APP_NAME, APP_VERSION, DEFAULT_BIND_ADDRESS, MCP_PROTOCOL_VERSION};
19+
use crate::config::{
20+
APP_NAME, APP_VERSION, DEFAULT_BIND_ADDRESS, MAX_ACTIVE_SESSIONS, MCP_PROTOCOL_VERSION,
21+
SESSION_HEADER,
22+
};
1323
use crate::rest;
14-
use crate::transports::{sse, streamable_http};
24+
use crate::server::FastTimeServer;
25+
use crate::transports::sse;
1526

1627
pub async fn run() -> anyhow::Result<()> {
1728
init_logging();
@@ -67,18 +78,50 @@ fn init_logging() {
6778
}
6879

6980
fn router() -> Router {
81+
// Modern Streamable HTTP transport, served by the rmcp SDK. We hold a handle
82+
// to the session manager so a lightweight gate can cap concurrent sessions —
83+
// rmcp's LocalSessionManager has no built-in cap. Existing sessions are
84+
// cleaned up by rmcp on client disconnect / HTTP DELETE.
85+
let session_manager: Arc<LocalSessionManager> = Arc::new(LocalSessionManager::default());
86+
let mcp = StreamableHttpService::new(
87+
|| Ok(FastTimeServer::new()),
88+
session_manager.clone(),
89+
Default::default(),
90+
);
91+
let mcp = Router::new()
92+
.fallback_service(mcp)
93+
.layer(from_fn_with_state(session_manager, session_cap_gate));
94+
7095
Router::new()
7196
.route("/health", axum::routing::get(health_handler))
7297
.route("/version", axum::routing::get(version_handler))
7398
.route("/api/echo", axum::routing::post(rest::echo_handler))
7499
.route("/api/time", axum::routing::get(rest::time_handler))
75-
.route(
76-
"/mcp",
77-
axum::routing::post(streamable_http::handler).delete(streamable_http::delete_handler),
78-
)
100+
// Legacy HTTP+SSE transport — hand-rolled shim (see transports/sse.rs).
79101
.route("/sse", axum::routing::get(sse::handler))
80102
.route("/messages", axum::routing::post(sse::message_handler))
81103
.route("/message", axum::routing::post(sse::message_handler))
104+
.nest("/mcp", mcp)
105+
}
106+
107+
/// Cap concurrent Streamable HTTP sessions at `MAX_ACTIVE_SESSIONS`. Requests
108+
/// carrying an existing `mcp-session-id` always pass so in-flight sessions keep
109+
/// working; only session-creating requests (no session header) are rejected
110+
/// with `503` once the cap is reached.
111+
async fn session_cap_gate(
112+
State(session_manager): State<Arc<LocalSessionManager>>,
113+
request: Request,
114+
next: Next,
115+
) -> Response {
116+
let creates_new_session = !request.headers().contains_key(SESSION_HEADER);
117+
if creates_new_session && session_manager.sessions.read().await.len() >= MAX_ACTIVE_SESSIONS {
118+
return (
119+
StatusCode::SERVICE_UNAVAILABLE,
120+
"Maximum active sessions reached",
121+
)
122+
.into_response();
123+
}
124+
next.run(request).await
82125
}
83126

84127
async fn health_handler() -> axum::Json<serde_json::Value> {
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
// Copyright 2026
22
// SPDX-License-Identifier: Apache-2.0
33

4-
use std::time::Duration;
5-
64
pub(crate) const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:9080";
75
pub(crate) const APP_NAME: &str = "fast-time-server";
86
pub(crate) const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
7+
/// Latest protocol version advertised by the legacy SSE shim and `/version`.
8+
/// The `/mcp` Streamable HTTP transport negotiates independently inside rmcp.
99
pub(crate) const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
10-
/// Protocol versions echoed back during initialize negotiation; anything else
11-
/// falls back to `MCP_PROTOCOL_VERSION`.
10+
/// Protocol versions echoed back during legacy SSE initialize negotiation;
11+
/// anything else falls back to `MCP_PROTOCOL_VERSION`.
1212
pub(crate) const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[
1313
"2024-11-05",
1414
"2025-03-26",
1515
"2025-06-18",
1616
MCP_PROTOCOL_VERSION,
1717
];
18+
/// Session id header for the `/mcp` Streamable HTTP transport; used by the
19+
/// session-cap gate to tell session-creating requests from existing ones.
1820
pub(crate) const SESSION_HEADER: &str = "mcp-session-id";
21+
/// Cap on concurrent sessions, applied to both the legacy SSE transport and the
22+
/// `/mcp` Streamable HTTP transport.
1923
pub(crate) const MAX_ACTIVE_SESSIONS: usize = 10_000;
20-
/// Idle lifetime for streamable-HTTP sessions before they are evicted.
21-
pub(crate) const SESSION_IDLE_TTL: Duration = Duration::from_secs(300);
2224
pub(crate) const SSE_CHANNEL_CAPACITY: usize = 64;
2325
pub(crate) const MAX_DELAY_MS: u64 = 60_000;

mcp-servers/rust/fast-time-server/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
mod app;
77
mod config;
88
mod delay;
9-
mod mcp;
109
mod rest;
10+
mod server;
1111
mod time;
1212
mod transports;
1313

0 commit comments

Comments
 (0)