diff --git a/mcp-servers/rust/fast-time-server/Cargo.lock b/mcp-servers/rust/fast-time-server/Cargo.lock index e7e2d31..4305e29 100644 --- a/mcp-servers/rust/fast-time-server/Cargo.lock +++ b/mcp-servers/rust/fast-time-server/Cargo.lock @@ -193,9 +193,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -203,26 +203,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -231,6 +231,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -376,6 +382,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "http" version = "1.4.2" @@ -486,6 +498,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "itoa" version = "1.0.18" @@ -757,9 +781,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rmcp" -version = "3.0.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd2b6dd3b18129368955f32661a7718969e8c152c7d8866434c09cf15a512e0" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" dependencies = [ "async-trait", "base64", @@ -769,6 +793,7 @@ dependencies = [ "http", "http-body", "http-body-util", + "indexmap", "pastey", "pin-project-lite", "rand", @@ -788,15 +813,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.0.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1aa4b9345795260a43fc23d6d05e096407c8b953903f673af7c4404b49fb2d6" +checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" dependencies = [ "darling", "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] diff --git a/mcp-servers/rust/fast-time-server/Cargo.toml b/mcp-servers/rust/fast-time-server/Cargo.toml index b1b5380..be5b717 100644 --- a/mcp-servers/rust/fast-time-server/Cargo.toml +++ b/mcp-servers/rust/fast-time-server/Cargo.toml @@ -38,7 +38,7 @@ chrono = { version = "0.4.45", features = ["serde"] } chrono-tz = "0.10.4" # Official MCP Rust SDK -rmcp = { version = "3.0", features = ["server", "macros", "transport-streamable-http-server"] } +rmcp = { version = "3", features = ["server", "macros", "transport-streamable-http-server"] } # Cancellation token for graceful shutdown of SSE streams tokio-util = "0.7" diff --git a/mcp-servers/rust/fast-time-server/README.md b/mcp-servers/rust/fast-time-server/README.md index 6fe5c55..819bc24 100644 --- a/mcp-servers/rust/fast-time-server/README.md +++ b/mcp-servers/rust/fast-time-server/README.md @@ -20,6 +20,15 @@ Ultra-fast MCP server written in Rust for performance testing and benchmarking. - `verify-protocol` - Reports the MCP protocol version active for the current request - `whoami` - Reflects the HTTP headers received with the tool call as a lowercased JSON map +- **Prompts** (each seeds a conversation that drives the tools above): + - `current_time` - Ask for the current time in a timezone (`get_system_time`) + - `convert_time` - Convert a time between timezones (`convert_time`) + - `server_diagnostics` - Health report from `get_stats` + `verify-protocol` +- **Resources** (mirroring the same surface): + - `config://timezones` - The timezone formats every time entry point accepts + - `server://info` - Server identity and protocol versions (mirrors `/version`) + - `server://stats` - Live request counter (mirrors `get_stats`) + - `time://now/{+timezone}` - Resource template mirroring `get_system_time` (RFC 6570 reserved expansion, so IANA names and `+HH:MM` offsets stay unencoded) ## Quick Start ```bash @@ -128,6 +137,82 @@ response, never in server logs. Example: } ``` +## Prompts + +Each prompt renders a user message that asks the model to drive one of this +server's own tools, with the same arguments the tools accept: + +| Name | Arguments | Drives | +|------|-----------|-------| +| `current_time` | `timezone` (optional, default `UTC`) | `get_system_time` | +| `convert_time` | `time`, `source_timezone`, `target_timezone` (all required) | `convert_time` | +| `server_diagnostics` | none | `get_stats` + `verify-protocol` | + +Modern era (`2026-07-28`, stateless — note `Mcp-Name` carries the prompt name): + +```bash +curl -s -X POST http://localhost:9080/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: prompts/get' \ + -H 'Mcp-Name: current_time' \ + -d '{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"current_time","arguments":{"timezone":"Asia/Tokyo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' +``` + +On the legacy era the same request is sent with an `mcp-session-id` header +after `initialize`, exactly like `tools/call`. + +## Resources + +Resources mirror the server's existing surface so tool and resource clients +see the same data: + +| URI | MIME type | Mirrors | +|-----|-----------|---------| +| `config://timezones` | `text/plain` | The timezone formats `parse_timezone` accepts | +| `server://info` | `application/json` | The `/version` REST endpoint | +| `server://stats` | `application/json` | The `get_stats` tool | +| `time://now/{+timezone}` (template) | `text/plain` | The `get_system_time` tool and `/api/time` | + +`resources/read` resolves template URIs exactly like `get_system_time`: +unknown URIs fail with `RESOURCE_NOT_FOUND` on the legacy era (rewritten to +invalid params at `2026-07-28`), and a bad timezone reports the same +"Invalid timezone" wording as the tools. + +```bash +# List resources and templates (stateless, 2026-07-28) +curl -s -X POST http://localhost:9080/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: resources/list' \ + -d '{"jsonrpc":"2.0","method":"resources/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' + +# Read a template instance (Mcp-Name carries the URI for resources/read) +curl -s -X POST http://localhost:9080/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: resources/read' \ + -H 'Mcp-Name: time://now/Asia/Tokyo' \ + -d '{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"time://now/Asia/Tokyo","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}},"id":2}' +``` + +### 2026-07-28 conformance + +Prompts and resources follow the same wire rules the tools already use: + +- `prompts/list`, `resources/list` and `resources/templates/list` responses + carry `resultType: "complete"` plus the SEP-2549 cache directives + (`cacheScope: "private"`, `ttlMs: 0`) on the modern era, and omit all + three on legacy sessions. +- `resources/read` responses carry the same directives with `ttlMs: 0` + (nothing this server returns is safe to cache: the time and stats + resources are live values). +- Both capabilities are advertised in `initialize` (legacy) and + `server/discover` (modern). + ### SSE Streaming The `/mcp` endpoint itself speaks SSE — there is no separate `/sse` endpoint: diff --git a/mcp-servers/rust/fast-time-server/src/main.rs b/mcp-servers/rust/fast-time-server/src/main.rs index e15aa28..5ffab15 100644 --- a/mcp-servers/rust/fast-time-server/src/main.rs +++ b/mcp-servers/rust/fast-time-server/src/main.rs @@ -13,6 +13,13 @@ // - verify-protocol: Reports the MCP protocol version of the current request // - whoami: Reflects the HTTP headers of the tool-call request // +// Prompts seed conversations that drive those tools: +// - current_time / convert_time / server_diagnostics +// +// Resources mirror the same surface: +// - config://timezones, server://info, server://stats (static/dynamic docs) +// - time://now/{+timezone} resource template (get_system_time as a resource) +// // Transport: Streamable HTTP (no auth) via the official MCP Rust SDK (rmcp). // Dual-era by default: legacy 2025-11-25 (initialize handshake + sessions) // and modern 2026-07-28 (stateless, per-request _meta) are served @@ -29,19 +36,24 @@ use chrono::{DateTime, FixedOffset, SecondsFormat, TimeZone, Utc}; use chrono_tz::Tz; use rand_distr::Distribution; use rand_distr::Normal; +use rmcp::handler::server::prompt::PromptContext; +use rmcp::handler::server::router::prompt::PromptRouter; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ - CacheScope, CallToolResult, ContentBlock, Implementation, InitializeRequestParams, - InitializeResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, - ServerInfo, + CacheScope, CallToolResult, ContentBlock, GetPromptRequestParams, GetPromptResponse, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + PromptMessage, ProtocolVersion, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, Resource, ResourceContents, ResourceTemplate, Role, ServerCapabilities, + ServerConfig, }; use rmcp::service::RequestContext; use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; use rmcp::{ErrorData as McpError, Json, RoleServer, ServerHandler, schemars}; -use rmcp::{tool, tool_handler, tool_router}; +use rmcp::{prompt, prompt_router, tool, tool_handler, tool_router}; use serde_json::json; use std::borrow::Cow; use std::collections::HashMap; @@ -65,6 +77,16 @@ const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; const MCP_PROTOCOL_VERSION_MODERN: &str = "2026-07-28"; const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28]; +/// Resource URIs and the RFC 6570 template kept in one place so +/// `resources/list` and `resources/read` cannot drift apart. The template +/// uses reserved expansion (`{+timezone}`) so RFC 6570 clients leave the +/// `/`, `+`, and `:` of IANA names and fixed offsets unencoded, which is +/// what the reader parses. +const TIMEZONES_RESOURCE_URI: &str = "config://timezones"; +const SERVER_INFO_RESOURCE_URI: &str = "server://info"; +const SERVER_STATS_RESOURCE_URI: &str = "server://stats"; +const TIME_NOW_RESOURCE_PREFIX: &str = "time://now/"; +const TIME_NOW_URI_TEMPLATE: &str = "time://now/{+timezone}"; /// MCP era selection, chosen at startup via the `MCP_PROTOCOL_MODE` /// environment variable: `legacy` serves only 2025-11-25, `modern` only @@ -270,6 +292,7 @@ struct SharedState { struct FastTimeServer { state: Arc, tool_router: ToolRouter, + prompt_router: PromptRouter, mode: ProtocolMode, } @@ -303,6 +326,22 @@ struct ConvertTimeRequest { target_timezone: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct CurrentTimePromptArgs { + /// IANA timezone name or fixed UTC offset (defaults to UTC) + timezone: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct ConvertTimePromptArgs { + /// Time value to convert, e.g. 2026-10-04T12:00:00Z or 2026-10-04 12:00:00 + time: String, + /// IANA timezone name or fixed UTC offset the time is expressed in + source_timezone: String, + /// IANA timezone name or fixed UTC offset to convert the time to + target_timezone: String, +} + #[derive(Debug, serde::Serialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] struct RecognitionResult { @@ -349,12 +388,20 @@ fn protocol_report( } } +/// True when the active request speaks the modern 2026-07-28 revision: the +/// per-request `_meta` version is present and modern. Legacy-era requests +/// carry no `_meta` version and resolve to `false`. +fn is_modern_request(context: &RequestContext) -> bool { + context.meta.protocol_version() == Some(ProtocolVersion::V_2026_07_28) +} + #[tool_router] impl FastTimeServer { fn new(mode: ProtocolMode) -> Self { Self { state: Arc::new(SharedState::default()), tool_router: Self::tool_router(), + prompt_router: Self::prompt_router(), mode, } } @@ -538,6 +585,170 @@ impl FastTimeServer { } } +/// Prompts mirror the tool surface: each renders a user message that asks +/// the model to drive one of this server's own tools, with arguments typed +/// by structs in the same style the tools use (the SDK derives the advertised +/// prompt arguments from the schema). +#[prompt_router] +impl FastTimeServer { + #[prompt( + description = "Ask the model to report the current time in a timezone using the get_system_time tool." + )] + fn current_time( + &self, + Parameters(args): Parameters, + ) -> Result, McpError> { + let timezone = args.timezone.unwrap_or_else(|| "UTC".to_string()); + let arguments = json!({ "timezone": timezone }); + Ok(vec![PromptMessage::new_text( + Role::User, + format!( + "Call the get_system_time tool with {arguments} and report the timestamp it returns, including its UTC offset." + ), + )]) + } + + #[prompt( + name = "convert_time", + description = "Ask the model to convert a time between timezones using the convert_time tool." + )] + fn convert_time_prompt( + &self, + Parameters(args): Parameters, + ) -> Result, McpError> { + let arguments = json!({ + "time": args.time, + "source_timezone": args.source_timezone, + "target_timezone": args.target_timezone, + }); + Ok(vec![PromptMessage::new_text( + Role::User, + format!("Call the convert_time tool with {arguments} and state the converted time."), + )]) + } + + #[prompt( + description = "Ask the model for a server health report built from the get_stats and verify-protocol tools." + )] + fn server_diagnostics(&self) -> Result, McpError> { + Ok(vec![PromptMessage::new_text( + Role::User, + "Call the get_stats tool, then the verify-protocol tool, and summarize the results: the number of requests handled so far, plus the MCP protocol version and transport that served the request.".to_string(), + )]) + } +} + +// ============================================================================ +// Resources +// ============================================================================ + +/// The static registry behind `resources/list`; each entry mirrors a tool or +/// REST endpoint the server already exposes. +fn resource_catalog() -> Vec { + vec![ + Resource::new(TIMEZONES_RESOURCE_URI, "supported_timezones") + .with_description( + "Timezone formats accepted by the time tools, the time://now/{+timezone} resource template, and /api/time.", + ) + .with_mime_type("text/plain"), + Resource::new(SERVER_INFO_RESOURCE_URI, "server_info") + .with_description( + "Server identity and the MCP protocol versions this instance serves (mirrors the /version endpoint).", + ) + .with_mime_type("application/json"), + Resource::new(SERVER_STATS_RESOURCE_URI, "server_stats") + .with_description("Live request counter (mirrors the get_stats tool).") + .with_mime_type("application/json"), + ] +} + +/// The single dynamic entry point: `time://now/{+timezone}` resolves exactly +fn resource_templates() -> Vec { + vec![ResourceTemplate::new(TIME_NOW_URI_TEMPLATE, "current_time") + .with_description("Current time in an IANA timezone or fixed UTC offset (mirrors the get_system_time tool).") + .with_mime_type("text/plain")] +} + +/// Documented mirror of the values `parse_timezone` accepts. +fn timezones_document() -> String { + [ + "fast-time-server accepted timezone formats", + "=========================================", + "", + "The get_system_time and convert_time tools, the time://now/{+timezone}", + "resource template, and the /api/time endpoint all accept the same values:", + "", + "- UTC or GMT (case-insensitive)", + "- IANA timezone names, e.g. America/New_York, Europe/London, Asia/Tokyo", + "- Fixed UTC offsets as +HH:MM or -HH:MM, e.g. +05:30, -08:00", + "", + "Any other value is rejected with an \"Invalid timezone\" error.", + ] + .join("\n") +} + +/// JSON mirror of the `/version` REST endpoint. +fn server_info_document(mode: ProtocolMode) -> String { + json!({ + "name": APP_NAME, + "version": APP_VERSION, + "protocol_mode": mode.as_str(), + "mcp_versions": mode + .supported_versions() + .iter() + .map(ToString::to_string) + .collect::>(), + }) + .to_string() +} + +/// JSON mirror of the `get_stats` tool payload. +fn server_stats_document(requests_handled: u64) -> String { + json!({ + "server": APP_NAME, + "version": APP_VERSION, + "requests_handled": requests_handled, + }) + .to_string() +} + +impl FastTimeServer { + /// Resolve one resource URI to its contents, or a protocol error: + /// `RESOURCE_NOT_FOUND` for unknown URIs (the SDK rewrites it to + /// `invalid params` for 2026-07-28 peers) and `invalid params` with the + /// same wording the tools use for bad timezones. + fn read_resource_contents(&self, uri: &str) -> Result { + match uri { + TIMEZONES_RESOURCE_URI => Ok(ResourceContents::text(timezones_document(), uri)), + SERVER_INFO_RESOURCE_URI => { + Ok(ResourceContents::text(server_info_document(self.mode), uri) + .with_mime_type("application/json")) + } + SERVER_STATS_RESOURCE_URI => Ok(ResourceContents::text( + server_stats_document(self.state.request_count.load(Ordering::Relaxed)), + uri, + ) + .with_mime_type("application/json")), + _ => { + let Some(timezone) = uri.strip_prefix(TIME_NOW_RESOURCE_PREFIX) else { + return Err(McpError::resource_not_found( + format!("unknown resource: {uri}"), + None, + )); + }; + parse_timezone(timezone) + .map(|tz| ResourceContents::text(tz.format_utc(Utc::now()), uri)) + .map_err(|err| { + McpError::invalid_params( + format!("Invalid timezone '{timezone}': {err}"), + None, + ) + }) + } + } + } +} + #[tool_handler(router = self.tool_router)] impl ServerHandler for FastTimeServer { /// Gate the legacy handshake to the era(s) the mode serves. The SDK's @@ -569,24 +780,96 @@ impl ServerHandler for FastTimeServer { /// tools/list is cacheable at 2026-07-28, so the modern wire format /// requires the cache directives (`cacheScope`/`ttlMs`). Legacy-era /// requests keep the fields absent: the option stays `None` and the SDK - /// strips `resultType` for legacy peers the same way. + /// strips `resultType` for legacy peers the same way. The prompts and + /// resources handlers below follow this exact rule. async fn list_tools( &self, _request: Option, context: RequestContext, ) -> Result { let mut result = ListToolsResult::with_all_items(self.tool_router.list_all()); - if context.meta.protocol_version() == Some(ProtocolVersion::V_2026_07_28) { + if is_modern_request(&context) { result = result.with_ttl_ms(0).with_cache_scope(CacheScope::Private); } Ok(result) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new(APP_NAME, APP_VERSION)) - .with_protocol_version(self.mode.supported_versions()[0].clone()) - .with_instructions("Ultra-fast MCP test server.".to_string()) + /// Hand-routed instead of `#[prompt_handler]`: that macro's generated + /// `list_prompts` hardcodes `CacheScope::Public`, which would drift from + /// `list_tools`; this keeps every list result on the same wire rules. + async fn list_prompts( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let mut result = ListPromptsResult::with_all_items(self.prompt_router.list_all()); + if is_modern_request(&context) { + result = result.with_ttl_ms(0).with_cache_scope(CacheScope::Private); + } + Ok(result) + } + + async fn get_prompt( + &self, + request: GetPromptRequestParams, + context: RequestContext, + ) -> Result { + let prompt_context = PromptContext::new(self, request.name, request.arguments, context); + self.prompt_router.get_prompt(prompt_context).await + } + + async fn list_resources( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let mut result = ListResourcesResult::with_all_items(resource_catalog()); + if is_modern_request(&context) { + result = result.with_ttl_ms(0).with_cache_scope(CacheScope::Private); + } + Ok(result) + } + + async fn list_resource_templates( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let mut result = ListResourceTemplatesResult::with_all_items(resource_templates()); + if is_modern_request(&context) { + result = result.with_ttl_ms(0).with_cache_scope(CacheScope::Private); + } + Ok(result) + } + + /// Reads carry the same 2026-07-28 cache directives with `ttlMs: 0` — + /// nothing this server returns is safe to cache (`time://now/*` and + /// `server://stats` are live values, so the static documents stay + /// uniform with them rather than special-cased). + async fn read_resource( + &self, + request: ReadResourceRequestParams, + context: RequestContext, + ) -> Result { + let contents = self.read_resource_contents(&request.uri)?; + let mut result = ReadResourceResult::new(vec![contents]); + if is_modern_request(&context) { + result = result.with_ttl_ms(0).with_cache_scope(CacheScope::Private); + } + Ok(result.into()) + } + + fn get_info(&self) -> ServerConfig { + ServerConfig::new( + ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new(APP_NAME, APP_VERSION)) + .with_protocol_version(self.mode.supported_versions()[0].clone()) + .with_instructions("Ultra-fast MCP test server.".to_string()) } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -976,17 +1259,18 @@ mod tests { session_id } - async fn legacy_tool_call( + /// Any JSON-RPC request on an established legacy session (SSE response). + async fn legacy_jsonrpc( router: &Router, session_id: &str, - name: &str, - arguments: serde_json::Value, + method: &str, + params: serde_json::Value, id: i64, ) -> serde_json::Value { let mut request = mcp_post(json!({ "jsonrpc": "2.0", - "method": "tools/call", - "params": { "name": name, "arguments": arguments }, + "method": method, + "params": params, "id": id })); request @@ -997,6 +1281,23 @@ mod tests { parse_sse_json(&response_text(response).await) } + async fn legacy_tool_call( + router: &Router, + session_id: &str, + name: &str, + arguments: serde_json::Value, + id: i64, + ) -> serde_json::Value { + legacy_jsonrpc( + router, + session_id, + "tools/call", + json!({ "name": name, "arguments": arguments }), + id, + ) + .await + } + fn modern_request(method: &str, version: &str, id: i64) -> serde_json::Value { json!({ "jsonrpc": "2.0", @@ -1013,7 +1314,9 @@ mod tests { /// The MCP-Protocol-Version header must mirror the version in `_meta`, /// and 2026-07-28 requests must carry SEP-2243 headers: `Mcp-Method` - /// matching the body method, plus `Mcp-Name` for named methods. + /// matching the body method, plus `Mcp-Name` carrying `params.name` + /// (tools/call, prompts/get) or `params.uri` (resources/read) for + /// named methods. async fn modern_call( router: &Router, body: serde_json::Value, @@ -1023,7 +1326,10 @@ mod tests { .expect("modern request should carry a version") .to_string(); let method = body["method"].as_str().expect("request method").to_string(); - let name = body["params"]["name"].as_str().map(str::to_string); + let name = body["params"]["name"] + .as_str() + .or(body["params"]["uri"].as_str()) + .map(str::to_string); let mut request = mcp_post(body); let headers = request.headers_mut(); headers.insert( @@ -1071,6 +1377,8 @@ mod tests { assert_eq!(result["protocolVersion"], MCP_PROTOCOL_VERSION); assert_eq!(result["serverInfo"]["name"], APP_NAME); assert!(result["capabilities"]["tools"].is_object()); + assert!(result["capabilities"]["prompts"].is_object()); + assert!(result["capabilities"]["resources"].is_object()); } #[tokio::test] @@ -1288,6 +1596,298 @@ mod tests { assert!(text.contains(r#""requests_handled": "#)); } + #[tokio::test] + async fn test_legacy_prompts_and_resources() { + let router = build_router(ProtocolMode::Dual); + let session_id = initialize_session(&router).await; + + // prompts/list: three tool-driven prompts, legacy era has no cache + // directives. + let body = legacy_jsonrpc(&router, &session_id, "prompts/list", json!({}), 50).await; + let result = &body["result"]; + let prompts = result["prompts"].as_array().unwrap(); + assert_eq!(prompts.len(), 3); + assert!(result.get("cacheScope").is_none()); + assert!(result.get("ttlMs").is_none()); + + let current_time = prompts + .iter() + .find(|prompt| prompt["name"] == "current_time") + .unwrap(); + let arguments = current_time["arguments"].as_array().unwrap(); + assert_eq!(arguments.len(), 1); + assert_eq!(arguments[0]["name"], "timezone"); + assert_eq!(arguments[0]["required"], false); + + // prompts/get renders a user message that names the tool and args. + let body = legacy_jsonrpc( + &router, + &session_id, + "prompts/get", + json!({ "name": "current_time", "arguments": { "timezone": "Asia/Tokyo" } }), + 51, + ) + .await; + let result = &body["result"]; + assert_eq!(result["messages"].as_array().map(Vec::len), Some(1)); + assert_eq!(result["messages"][0]["role"], "user"); + let text = result["messages"][0]["content"]["text"].as_str().unwrap(); + assert!(text.contains("get_system_time")); + assert!(text.contains("Asia/Tokyo")); + + // prompts/get without optional arguments defaults to UTC. + let body = legacy_jsonrpc( + &router, + &session_id, + "prompts/get", + json!({ "name": "current_time", "arguments": {} }), + 52, + ) + .await; + let text = body["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap(); + assert!(text.contains("UTC")); + + // Unknown prompt names are invalid-params errors. + let body = legacy_jsonrpc( + &router, + &session_id, + "prompts/get", + json!({ "name": "does_not_exist" }), + 53, + ) + .await; + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("not found") + ); + + // resources/list: three static entries mirroring tools/endpoints. + let body = legacy_jsonrpc(&router, &session_id, "resources/list", json!({}), 54).await; + let result = &body["result"]; + let uris: Vec<&str> = result["resources"] + .as_array() + .unwrap() + .iter() + .map(|resource| resource["uri"].as_str().unwrap()) + .collect(); + assert_eq!( + uris, + ["config://timezones", "server://info", "server://stats"] + ); + assert!(result.get("cacheScope").is_none()); + + // resources/templates/list: the dynamic time template. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/templates/list", + json!({}), + 55, + ) + .await; + let templates = body["result"]["resourceTemplates"].as_array().unwrap(); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0]["uriTemplate"], "time://now/{+timezone}"); + + // resources/read: static document. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": "config://timezones" }), + 56, + ) + .await; + let contents = &body["result"]["contents"][0]; + assert_eq!(contents["uri"], "config://timezones"); + assert_eq!(contents["mimeType"], "text/plain"); + assert!( + contents["text"] + .as_str() + .unwrap() + .contains("IANA timezone names") + ); + + // resources/read: server://info mirrors the /version endpoint. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": "server://info" }), + 57, + ) + .await; + let contents = &body["result"]["contents"][0]; + assert_eq!(contents["mimeType"], "application/json"); + let info: serde_json::Value = + serde_json::from_str(contents["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["name"], APP_NAME); + assert_eq!( + info["mcp_versions"], + json!([MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSION_MODERN]) + ); + + // resources/read: the template URI resolves like get_system_time. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": "time://now/Asia/Tokyo" }), + 58, + ) + .await; + let text = body["result"]["contents"][0]["text"].as_str().unwrap(); + assert!( + text.ends_with("+09:00"), + "Tokyo time should be +09:00: {text}" + ); + + // Unknown URIs keep the legacy RESOURCE_NOT_FOUND code. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": "config://does-not-exist" }), + 59, + ) + .await; + assert_eq!(body["error"]["code"], -32002); + + // Bad timezones in template reads mirror the tool's error wording. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": "time://now/Mars/Olympus" }), + 60, + ) + .await; + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("Mars/Olympus") + ); + } + + /// RFC 6570 clients expanding the advertised template must get URIs the + /// reader accepts: reserved expansion leaves the `/` of IANA names and + /// the `+`/`:` of fixed offsets unencoded, exactly what `parse_timezone` + /// expects. + #[tokio::test] + async fn test_resource_template_expansion_matches_reader() { + let router = build_router(ProtocolMode::Dual); + let session_id = initialize_session(&router).await; + + // The advertised template must use reserved expansion; simple + // expansion ({timezone}) would percent-encode the values below into + // URIs the reader rejects. + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/templates/list", + json!({}), + 64, + ) + .await; + let templates = body["result"]["resourceTemplates"].as_array().unwrap(); + assert_eq!(templates[0]["uriTemplate"], "time://now/{+timezone}"); + + for (timezone, expected_offset) in [("Asia/Tokyo", "+09:00"), ("+05:30", "+05:30")] { + let body = legacy_jsonrpc( + &router, + &session_id, + "resources/read", + json!({ "uri": format!("time://now/{timezone}") }), + 65, + ) + .await; + let text = body["result"]["contents"][0]["text"].as_str().unwrap(); + assert!( + text.ends_with(expected_offset), + "expanded {timezone} should read as {expected_offset}: {text}" + ); + } + } + + /// Hostile prompt arguments (quotes, backslashes, newlines) must survive + /// as JSON string values instead of corrupting the embedded tool + /// arguments or making them unparseable. + #[tokio::test] + async fn test_current_time_prompt_arguments_round_trip() { + let router = build_router(ProtocolMode::Dual); + let session_id = initialize_session(&router).await; + + let hostile = "UTC\", \"timezone\": \"Asia/Tokyo\\\n\\"; + let body = legacy_jsonrpc( + &router, + &session_id, + "prompts/get", + json!({ "name": "current_time", "arguments": { "timezone": hostile } }), + 66, + ) + .await; + let text = body["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap(); + let embedded = text + .strip_prefix("Call the get_system_time tool with ") + .and_then(|rest| { + rest.strip_suffix(" and report the timestamp it returns, including its UTC offset.") + }) + .expect("prompt text should embed the serialized arguments"); + let parsed: serde_json::Value = + serde_json::from_str(embedded).expect("embedded arguments should be valid JSON"); + assert_eq!(parsed, json!({ "timezone": hostile })); + } + + #[tokio::test] + async fn test_convert_time_prompt_arguments_round_trip() { + let router = build_router(ProtocolMode::Dual); + let session_id = initialize_session(&router).await; + + let hostile_time = "2026-10-04\"\\\n12:00:00"; + let hostile_zone = "UTC\", \"target_timezone\": \"Asia/Tokyo"; + let body = legacy_jsonrpc( + &router, + &session_id, + "prompts/get", + json!({ + "name": "convert_time", + "arguments": { + "time": hostile_time, + "source_timezone": hostile_zone, + "target_timezone": "-08:00" + } + }), + 67, + ) + .await; + let text = body["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap(); + let embedded = text + .strip_prefix("Call the convert_time tool with ") + .and_then(|rest| rest.strip_suffix(" and state the converted time.")) + .expect("prompt text should embed the serialized arguments"); + let parsed: serde_json::Value = + serde_json::from_str(embedded).expect("embedded arguments should be valid JSON"); + assert_eq!( + parsed, + json!({ + "time": hostile_time, + "source_timezone": hostile_zone, + "target_timezone": "-08:00" + }) + ); + } + // ======================================================================== // Modern era (2026-07-28): stateless, version in params._meta + header // ======================================================================== @@ -1434,6 +2034,64 @@ mod tests { ); } + #[tokio::test] + async fn test_modern_prompts_and_resources_lists_include_cache_directives() { + for method in ["prompts/list", "resources/list", "resources/templates/list"] { + let (status, body) = modern_call( + &build_router(ProtocolMode::Dual), + modern_request(method, MCP_PROTOCOL_VERSION_MODERN, 60), + ) + .await; + assert_eq!(status, StatusCode::OK, "{method} should succeed"); + let result = &body["result"]; + assert_eq!(result["resultType"], "complete", "{method}"); + assert_eq!(result["cacheScope"], "private", "{method}"); + assert_eq!(result["ttlMs"], 0, "{method}"); + } + } + + #[tokio::test] + async fn test_modern_prompts_get_and_resources_read() { + // prompts/get works statelessly; Mcp-Name mirrors params.name. + let mut request = modern_request("prompts/get", MCP_PROTOCOL_VERSION_MODERN, 61); + request["params"]["name"] = json!("convert_time"); + request["params"]["arguments"] = json!({ + "time": "2026-10-04T12:00:00Z", + "source_timezone": "UTC", + "target_timezone": "America/New_York" + }); + let (status, body) = modern_call(&build_router(ProtocolMode::Dual), request).await; + assert_eq!(status, StatusCode::OK); + let text = body["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap(); + assert!(text.contains("convert_time")); + assert!(text.contains("America/New_York")); + + // resources/read returns JSON content with the modern cache + // directives (ttlMs 0: never cached). + let mut request = modern_request("resources/read", MCP_PROTOCOL_VERSION_MODERN, 62); + request["params"]["uri"] = json!("server://info"); + let (status, body) = modern_call(&build_router(ProtocolMode::Dual), request).await; + assert_eq!(status, StatusCode::OK); + let result = &body["result"]; + assert_eq!(result["resultType"], "complete"); + assert_eq!(result["cacheScope"], "private"); + assert_eq!(result["ttlMs"], 0); + let contents = &result["contents"][0]; + assert_eq!(contents["uri"], "server://info"); + let info: serde_json::Value = + serde_json::from_str(contents["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["protocol_mode"], "dual"); + + // At 2026-07-28 the SDK rewrites RESOURCE_NOT_FOUND to invalid params. + let mut request = modern_request("resources/read", MCP_PROTOCOL_VERSION_MODERN, 63); + request["params"]["uri"] = json!("config://does-not-exist"); + let (status, body) = modern_call(&build_router(ProtocolMode::Dual), request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], -32602); + } + #[tokio::test] async fn test_server_discover_lists_both_eras() { let (status, body) = modern_call( @@ -1449,6 +2107,8 @@ mod tests { json!([MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSION_MODERN]) ); assert!(result["capabilities"]["tools"].is_object()); + assert!(result["capabilities"]["prompts"].is_object()); + assert!(result["capabilities"]["resources"].is_object()); assert_eq!(result["cacheScope"], "private"); assert_eq!(result["ttlMs"], 0); assert_eq!(