From 40703bde5d7deb5c827efaad91cfcd0c41245657 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sun, 4 Oct 2026 20:58:49 +0100 Subject: [PATCH 1/2] Add prompts and resources to fast-time-server Serve prompts and resources alongside the existing tools on both MCP eras, with 2026-07-28 conformance matching the tools' wire rules: - Prompts: current_time, convert_time, and server_diagnostics each render a user message that drives one of the server's own tools, with typed arguments advertised from the same schema style the tools use. - Resources: config://timezones, server://info, and server://stats mirror the timezone rules, the /version endpoint, and the get_stats tool; the time://now/{timezone} template mirrors get_system_time and /api/time. - prompts/list, resources/list, and resources/templates/list carry resultType plus SEP-2549 cache directives (private, ttlMs 0) on the modern era only; resources/read does the same since nothing the server returns is cache-safe. - Hand-route prompts through the SDK router instead of #[prompt_handler], whose generated list hardcodes CacheScope::Public and would drift from the existing list_tools behavior. - Advertise prompts and resources capabilities in initialize and server/discover. Loosen the rmcp requirement to any 3.x and float the lockfile to 3.5.0; replace the deprecated ServerInfo alias with ServerConfig. Signed-off-by: Jonathan Springer --- mcp-servers/rust/fast-time-server/Cargo.lock | 51 +- mcp-servers/rust/fast-time-server/Cargo.toml | 2 +- mcp-servers/rust/fast-time-server/README.md | 85 +++ mcp-servers/rust/fast-time-server/src/main.rs | 579 +++++++++++++++++- 4 files changed, 685 insertions(+), 32 deletions(-) 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..93b2fcf 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` ## 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..91dd450 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,13 @@ 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. +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 +289,7 @@ struct SharedState { struct FastTimeServer { state: Arc, tool_router: ToolRouter, + prompt_router: PromptRouter, mode: ProtocolMode, } @@ -303,6 +323,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 +385,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 +582,168 @@ 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()); + Ok(vec![PromptMessage::new_text( + Role::User, + format!( + "Call the get_system_time tool with {{\"timezone\": \"{timezone}\"}} 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> { + Ok(vec![PromptMessage::new_text( + Role::User, + format!( + "Call the convert_time tool with {{\"time\": \"{}\", \"source_timezone\": \"{}\", \"target_timezone\": \"{}\"}} and state the converted time.", + args.time, args.source_timezone, args.target_timezone + ), + )]) + } + + #[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 +/// like the `get_system_time` tool and `/api/time`. +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 +775,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 +1254,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 +1276,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 +1309,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 +1321,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 +1372,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 +1591,186 @@ 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") + ); + } + // ======================================================================== // Modern era (2026-07-28): stateless, version in params._meta + header // ======================================================================== @@ -1434,6 +1917,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 +1990,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!( From 4e645275dad400f8fdc2665271c6a5ad0e5ac8dc Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Mon, 5 Oct 2026 13:36:55 +0100 Subject: [PATCH 2/2] Fix resource template expansion and prompt argument escaping per review Advertise time://now/{+timezone} (RFC 6570 reserved expansion) so client expansion of IANA names and fixed offsets produces URIs the reader accepts, and serialize prompt arguments with json! so quotes, backslashes, and newlines survive as JSON string values. Add regression tests for template expansion and prompt-argument round trips. Signed-off-by: Jonathan Springer --- mcp-servers/rust/fast-time-server/README.md | 4 +- mcp-servers/rust/fast-time-server/src/main.rs | 143 ++++++++++++++++-- 2 files changed, 132 insertions(+), 15 deletions(-) diff --git a/mcp-servers/rust/fast-time-server/README.md b/mcp-servers/rust/fast-time-server/README.md index 93b2fcf..819bc24 100644 --- a/mcp-servers/rust/fast-time-server/README.md +++ b/mcp-servers/rust/fast-time-server/README.md @@ -28,7 +28,7 @@ Ultra-fast MCP server written in Rust for performance testing and benchmarking. - `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` + - `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 @@ -173,7 +173,7 @@ see the same data: | `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` | +| `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 diff --git a/mcp-servers/rust/fast-time-server/src/main.rs b/mcp-servers/rust/fast-time-server/src/main.rs index 91dd450..5ffab15 100644 --- a/mcp-servers/rust/fast-time-server/src/main.rs +++ b/mcp-servers/rust/fast-time-server/src/main.rs @@ -18,7 +18,7 @@ // // 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) +// - 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) @@ -78,12 +78,15 @@ 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. +/// `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}"; +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 @@ -596,10 +599,11 @@ impl FastTimeServer { 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 {{\"timezone\": \"{timezone}\"}} and report the timestamp it returns, including its UTC offset." + "Call the get_system_time tool with {arguments} and report the timestamp it returns, including its UTC offset." ), )]) } @@ -612,12 +616,14 @@ impl FastTimeServer { &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 {{\"time\": \"{}\", \"source_timezone\": \"{}\", \"target_timezone\": \"{}\"}} and state the converted time.", - args.time, args.source_timezone, args.target_timezone - ), + format!("Call the convert_time tool with {arguments} and state the converted time."), )]) } @@ -642,7 +648,7 @@ 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.", + "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") @@ -656,8 +662,7 @@ fn resource_catalog() -> Vec { ] } -/// The single dynamic entry point: `time://now/{timezone}` resolves exactly -/// like the `get_system_time` tool and `/api/time`. +/// 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).") @@ -670,7 +675,7 @@ fn timezones_document() -> String { "fast-time-server accepted timezone formats", "=========================================", "", - "The get_system_time and convert_time tools, the time://now/{timezone}", + "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)", @@ -1687,7 +1692,7 @@ mod tests { .await; let templates = body["result"]["resourceTemplates"].as_array().unwrap(); assert_eq!(templates.len(), 1); - assert_eq!(templates[0]["uriTemplate"], "time://now/{timezone}"); + assert_eq!(templates[0]["uriTemplate"], "time://now/{+timezone}"); // resources/read: static document. let body = legacy_jsonrpc( @@ -1771,6 +1776,118 @@ mod tests { ); } + /// 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 // ========================================================================