#
tokens: 39960/50000 1/231 files (page 10/11)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 10 of 11. Use http://codebase.md/tuananh/hyper-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .cursor
│   └── rules
│       └── print-ctx-size.mdc
├── .dockerignore
├── .github
│   ├── renovate.json5
│   └── workflows
│       ├── ci.yml
│       ├── nightly.yml
│       └── release.yml
├── .gitignore
├── .gitmodules
├── .hadolint.yaml
├── .pre-commit-config.yaml
├── .windsurf
│   └── rules
│       ├── print-ctx-size.md
│       └── think.md
├── assets
│   ├── cursor-mcp-1.png
│   ├── cursor-mcp.png
│   ├── eval-py.jpg
│   └── logo.png
├── Cargo.lock
├── Cargo.toml
├── config.example.json
├── config.example.yaml
├── CREATING_PLUGINS.md
├── DEPLOYMENT.md
├── Dockerfile
├── examples
│   └── plugins
│       ├── v1
│       │   ├── arxiv
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── context7
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── crates-io
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── crypto-price
│       │   │   ├── Dockerfile
│       │   │   ├── go.mod
│       │   │   ├── go.sum
│       │   │   ├── main.go
│       │   │   ├── pdk.gen.go
│       │   │   └── README.md
│       │   ├── eval-py
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── fetch
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── fs
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── github
│       │   │   ├── .gitignore
│       │   │   ├── branches.go
│       │   │   ├── Dockerfile
│       │   │   ├── files.go
│       │   │   ├── gists.go
│       │   │   ├── go.mod
│       │   │   ├── go.sum
│       │   │   ├── issues.go
│       │   │   ├── main.go
│       │   │   ├── pdk.gen.go
│       │   │   ├── README.md
│       │   │   └── repo.go
│       │   ├── gitlab
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── gomodule
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── hash
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.lock
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── maven
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── meme-generator
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── generate_embedded.py
│       │   │   ├── README.md
│       │   │   ├── src
│       │   │   │   ├── embedded.rs
│       │   │   │   ├── lib.rs
│       │   │   │   └── pdk.rs
│       │   │   └── templates.json
│       │   ├── memory
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── myip
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.lock
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── qdrant
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       ├── pdk.rs
│       │   │       └── qdrant_client.rs
│       │   ├── qr-code
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.lock
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── serper
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── sqlite
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── think
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   └── src
│       │   │       ├── lib.rs
│       │   │       └── pdk.rs
│       │   ├── time
│       │   │   ├── .cargo
│       │   │   │   └── config.toml
│       │   │   ├── .gitignore
│       │   │   ├── Cargo.toml
│       │   │   ├── Dockerfile
│       │   │   ├── README.md
│       │   │   ├── src
│       │   │   │   ├── lib.rs
│       │   │   │   └── pdk.rs
│       │   │   └── time.wasm
│       │   └── tool-list-changed
│       │       ├── .gitignore
│       │       ├── Cargo.toml
│       │       ├── Dockerfile
│       │       ├── README.md
│       │       ├── src
│       │       │   ├── lib.rs
│       │       │   └── pdk.rs
│       │       └── tool_list_changed.wasm
│       └── v2
│           └── rstime
│               ├── .cargo
│               │   └── config.toml
│               ├── .gitignore
│               ├── Cargo.toml
│               ├── Dockerfile
│               ├── README.md
│               ├── rstime.wasm
│               └── src
│                   ├── lib.rs
│                   └── pdk
│                       ├── exports.rs
│                       ├── imports.rs
│                       ├── mod.rs
│                       └── types.rs
├── iac
│   ├── .terraform.lock.hcl
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
├── justfile
├── LICENSE
├── README.md
├── RUNTIME_CONFIG.md
├── rust-toolchain.toml
├── server.json
├── SKIP_TOOLS_GUIDE.md
├── src
│   ├── cli.rs
│   ├── config.rs
│   ├── https_auth.rs
│   ├── logging.rs
│   ├── main.rs
│   ├── naming.rs
│   ├── plugin.rs
│   ├── service.rs
│   └── wasm
│       ├── http.rs
│       ├── mod.rs
│       ├── oci.rs
│       └── s3.rs
├── templates
│   └── plugins
│       ├── go
│       │   ├── .gitignore
│       │   ├── Dockerfile
│       │   ├── exports.go
│       │   ├── go.mod
│       │   ├── go.sum
│       │   ├── imports.go
│       │   ├── main.go
│       │   ├── README.md
│       │   └── types.go
│       ├── README.md
│       └── rust
│           ├── .cargo
│           │   └── config.toml
│           ├── .gitignore
│           ├── Cargo.toml
│           ├── Dockerfile
│           ├── README.md
│           └── src
│               ├── lib.rs
│               └── pdk
│                   ├── exports.rs
│                   ├── imports.rs
│                   ├── mod.rs
│                   └── types.rs
├── tests
│   └── fixtures
│       ├── config_with_auths.json
│       ├── config_with_auths.yaml
│       ├── documentation_example.json
│       ├── documentation_example.yaml
│       ├── invalid_auth_config.yaml
│       ├── invalid_plugin_name.yaml
│       ├── invalid_structure.yaml
│       ├── invalid_url.yaml
│       ├── keyring_auth_config.yaml
│       ├── skip_tools_examples.yaml
│       ├── unsupported_config.txt
│       ├── valid_config.json
│       └── valid_config.yaml
└── xtp-plugin-schema.json
```

# Files

--------------------------------------------------------------------------------
/src/service.rs:
--------------------------------------------------------------------------------

```rust
   1 | use crate::{
   2 |     config::{Config, PluginName},
   3 |     naming::{
   4 |         create_namespaced_name, create_namespaced_uri, parse_namespaced_name, parse_namespaced_uri,
   5 |     },
   6 |     plugin::{Plugin, PluginV1, PluginV2},
   7 |     wasm,
   8 | };
   9 | use anyhow::{Error, Result};
  10 | use bytesize::ByteSize;
  11 | use dashmap::{DashMap, DashSet, Entry};
  12 | use extism::{EXTISM_USER_MODULE, Function, Manifest, UserData, Wasm, host_fn};
  13 | use extism_convert::Json;
  14 | use rmcp::{
  15 |     ErrorData as McpError, ServerHandler,
  16 |     model::*,
  17 |     service::{NotificationContext, Peer, RequestContext, RoleServer},
  18 | };
  19 | use serde::{Deserialize, Serialize};
  20 | use serde_json::Value;
  21 | use serde_with::{DurationSeconds, serde_as};
  22 | use std::{
  23 |     collections::HashMap,
  24 |     fmt::Debug,
  25 |     ops::Deref,
  26 |     str::FromStr,
  27 |     sync::{Arc, LazyLock, Mutex, RwLock, Weak},
  28 |     time::Duration,
  29 | };
  30 | use tokio::{runtime::Handle, sync::SetOnce};
  31 | use uuid::Uuid;
  32 | 
  33 | /// Check if a value contains an environment variable reference in the format ${ENVVARKEY}
  34 | /// and replace it with the actual environment variable value if it exists.
  35 | /// If the environment variable doesn't exist, returns the original value.
  36 | fn check_env_reference(value: &str) -> String {
  37 |     // Check if the value matches the pattern ${ENVVARKEY}
  38 |     if let Some(stripped) = value.strip_prefix("${").and_then(|s| s.strip_suffix("}")) {
  39 |         // Try to get the environment variable
  40 |         match std::env::var(stripped) {
  41 |             Ok(env_value) => {
  42 |                 tracing::debug!(
  43 |                     "Resolved environment variable reference ${{{stripped}}} to actual value"
  44 |                 );
  45 |                 env_value
  46 |             }
  47 |             Err(_) => {
  48 |                 tracing::warn!(
  49 |                     "Environment variable {stripped} not found, keeping original value {value}"
  50 |                 );
  51 |                 value.to_string()
  52 |             }
  53 |         }
  54 |     } else {
  55 |         value.to_string()
  56 |     }
  57 | }
  58 | 
  59 | static PLUGIN_SERVICE_INNER_REGISTRY: LazyLock<DashMap<Uuid, Weak<PluginServiceInner>>> =
  60 |     LazyLock::new(DashMap::new);
  61 | static WASM_DATA_CACHE: LazyLock<DashMap<PluginName, Vec<u8>>> = LazyLock::new(DashMap::new);
  62 | 
  63 | #[allow(dead_code)]
  64 | #[serde_as]
  65 | #[derive(Clone, Debug, Serialize)]
  66 | struct CreateElicitationRequestParamWithTimeout {
  67 |     #[serde(flatten)]
  68 |     pub inner: CreateElicitationRequestParam,
  69 |     #[serde_as(as = "Option<DurationSeconds<f64>>")]
  70 |     pub timeout: Option<Duration>,
  71 | }
  72 | 
  73 | impl<'de> Deserialize<'de> for CreateElicitationRequestParamWithTimeout {
  74 |     fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
  75 |     where
  76 |         D: serde::Deserializer<'de>,
  77 |     {
  78 |         let mut value = Value::deserialize(deserializer)?;
  79 | 
  80 |         fn patch_formats(value: &mut Value) {
  81 |             match value {
  82 |                 Value::Object(map) => {
  83 |                     if let Some(Value::String(s)) = map.get_mut("format")
  84 |                         && s == "date_time"
  85 |                     {
  86 |                         *s = "date-time".to_string();
  87 |                     }
  88 |                     for val in map.values_mut() {
  89 |                         patch_formats(val);
  90 |                     }
  91 |                 }
  92 |                 Value::Array(arr) => {
  93 |                     for val in arr.iter_mut() {
  94 |                         patch_formats(val);
  95 |                     }
  96 |                 }
  97 |                 _ => {}
  98 |             }
  99 |         }
 100 | 
 101 |         patch_formats(&mut value);
 102 | 
 103 |         #[serde_as]
 104 |         #[derive(Deserialize)]
 105 |         struct Helper {
 106 |             #[serde(flatten)]
 107 |             inner: CreateElicitationRequestParam,
 108 |             #[serde_as(as = "Option<DurationSeconds<f64>>")]
 109 |             timeout: Option<Duration>,
 110 |         }
 111 | 
 112 |         let Helper { inner, timeout } =
 113 |             Helper::deserialize(value).map_err(serde::de::Error::custom)?;
 114 |         Ok(CreateElicitationRequestParamWithTimeout { inner, timeout })
 115 |     }
 116 | }
 117 | 
 118 | #[derive(Clone, Debug)]
 119 | struct PluginServiceContext {
 120 |     handle: Handle,
 121 |     plugin_service_id: Uuid,
 122 |     plugin_name: String,
 123 | }
 124 | 
 125 | pub struct PluginServiceInner {
 126 |     config: Config,
 127 |     id: Uuid,
 128 |     logging_level: RwLock<LoggingLevel>,
 129 |     names: SetOnce<HashMap<Uuid, PluginName>>,
 130 |     peer: SetOnce<Peer<RoleServer>>,
 131 |     plugins: SetOnce<HashMap<PluginName, Box<dyn Plugin>>>,
 132 |     subscriptions: DashSet<String>,
 133 | }
 134 | 
 135 | impl Drop for PluginServiceInner {
 136 |     fn drop(&mut self) {
 137 |         PLUGIN_SERVICE_INNER_REGISTRY.remove(&self.id);
 138 |     }
 139 | }
 140 | 
 141 | pub struct PluginService(Arc<PluginServiceInner>);
 142 | 
 143 | impl Clone for PluginService {
 144 |     fn clone(&self) -> Self {
 145 |         Self(self.0.clone())
 146 |     }
 147 | }
 148 | 
 149 | impl Deref for PluginService {
 150 |     type Target = Arc<PluginServiceInner>;
 151 | 
 152 |     fn deref(&self) -> &Self::Target {
 153 |         &self.0
 154 |     }
 155 | }
 156 | 
 157 | impl PluginService {
 158 |     pub async fn new(config: &Config) -> Result<Self> {
 159 |         let inner = Arc::new(PluginServiceInner {
 160 |             config: config.clone(),
 161 |             id: Uuid::new_v4(),
 162 |             logging_level: RwLock::new(LoggingLevel::Error),
 163 |             names: SetOnce::new(),
 164 |             peer: SetOnce::new(),
 165 |             plugins: SetOnce::new(),
 166 |             subscriptions: DashSet::new(),
 167 |         });
 168 |         PLUGIN_SERVICE_INNER_REGISTRY.insert(inner.id, Arc::downgrade(&inner));
 169 |         let service = Self(inner);
 170 | 
 171 |         service.load_plugins().await?;
 172 |         Ok(service)
 173 |     }
 174 | 
 175 |     fn get(id: Uuid) -> Option<PluginService> {
 176 |         if let Some(weak_inner) = PLUGIN_SERVICE_INNER_REGISTRY.get(&id)
 177 |             && let Some(inner) = weak_inner.upgrade()
 178 |         {
 179 |             return Some(PluginService(inner));
 180 |         }
 181 |         PLUGIN_SERVICE_INNER_REGISTRY.remove(&id);
 182 |         None
 183 |     }
 184 | 
 185 |     async fn load_plugins(&self) -> Result<()> {
 186 |         let mut names = HashMap::new();
 187 |         let mut plugins: HashMap<PluginName, Box<dyn Plugin>> = HashMap::new();
 188 | 
 189 |         host_fn!(create_elicitation(ctx: PluginServiceContext; elicitation_msg: Json<CreateElicitationRequestParamWithTimeout>) -> Json<CreateElicitationResult> {
 190 |             let elicitation_msg = elicitation_msg.into_inner();
 191 |             let ctx = ctx.get()?.lock().unwrap().clone();
 192 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 193 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 194 |             })?;
 195 |             match plugin_service.peer.get() {
 196 |                 Some(peer) => {
 197 |                     if peer.supports_elicitation() {
 198 |                         if let Some(timeout) = elicitation_msg.timeout {
 199 |                             tracing::info!("Creating elicitation from {} with timeout {:?}", ctx.plugin_name, timeout);
 200 |                             ctx.handle.block_on(peer.create_elicitation_with_timeout(elicitation_msg.inner, Some(timeout))).map(Json).map_err(Error::from)
 201 |                         } else {
 202 |                             tracing::info!("Creating elicitation from {}", ctx.plugin_name);
 203 |                             ctx.handle.block_on(peer.create_elicitation(elicitation_msg.inner)).map(Json).map_err(Error::from)
 204 |                         }
 205 |                     } else {
 206 |                         tracing::info!("Peer does not support elicitation, declining from {}", ctx.plugin_name);
 207 |                         Ok(Json(CreateElicitationResult {
 208 |                             action: ElicitationAction::Decline,
 209 |                             content: None,
 210 |                         }))
 211 |                     }
 212 |                 },
 213 |                 None => Err(anyhow::anyhow!("No peer available")),
 214 |             }
 215 |         });
 216 | 
 217 |         host_fn!(create_message(ctx: PluginServiceContext; sampling_msg: Json<CreateMessageRequestParam>) -> Json<CreateMessageResult> {
 218 |             let sampling_msg = sampling_msg.into_inner();
 219 |             let ctx = ctx.get()?.lock().unwrap().clone();
 220 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 221 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 222 |             })?;
 223 |             match plugin_service.peer.get() {
 224 |                 Some(peer) => {
 225 |                     if let Some(peer_info) = peer.peer_info() && peer_info.capabilities.sampling.is_some() {
 226 |                         tracing::info!("Creating sampling message from {}", ctx.plugin_name);
 227 |                         ctx.handle.block_on(peer.create_message(sampling_msg)).map(Json).map_err(Error::from)
 228 |                     } else {
 229 |                         Err(anyhow::anyhow!("Peer does not support sampling"))
 230 |                     }
 231 |                 },
 232 |                 None => Err(anyhow::anyhow!("No peer available")),
 233 |             }
 234 |         });
 235 | 
 236 |         // Declares a host function `list_roots` that plugins can call
 237 |         host_fn!(list_roots(ctx: PluginServiceContext;) -> Json<ListRootsResult> {
 238 |             let ctx = ctx.get()?.lock().unwrap().clone();
 239 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 240 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 241 |             })?;
 242 |             match plugin_service.peer.get() {
 243 |                 Some(peer) => {
 244 |                     if let Some(peer_info) = peer.peer_info() && peer_info.capabilities.roots.is_some() {
 245 |                         tracing::info!("Listing roots from {}", ctx.plugin_name);
 246 |                         ctx.handle.block_on(peer.list_roots()).map(Json).map_err(Error::from)
 247 |                     } else {
 248 |                         Ok(Json(ListRootsResult::default()))
 249 |                     }
 250 |                 },
 251 |                 None => Err(anyhow::anyhow!("No peer available")),
 252 |             }
 253 |         });
 254 | 
 255 |         // Declares a host function `notify_logging_message` that plugins can call
 256 |         host_fn!(notify_logging_message(ctx: PluginServiceContext; log_msg: Json<LoggingMessageNotificationParam>) {
 257 |             let log_msg = log_msg.into_inner();
 258 |             let ctx = ctx.get()?.lock().unwrap().clone();
 259 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 260 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 261 |             })?;
 262 |             if (plugin_service.logging_level() as u8) <= (log_msg.level as u8) && let Some(peer) = plugin_service.peer.get() {
 263 |                 tracing::debug!("Logging message from {}", ctx.plugin_name);
 264 |                 return ctx.handle.block_on(peer.notify_logging_message(log_msg)).map_err(Error::from);
 265 |             }
 266 |             Ok(())
 267 |         });
 268 | 
 269 |         // Declares a host function `notify_progress` that plugins can call
 270 |         host_fn!(notify_progress(ctx: PluginServiceContext; progress_msg: Json<ProgressNotificationParam>) {
 271 |             let progress_msg = progress_msg.into_inner();
 272 |             let ctx = ctx.get()?.lock().unwrap().clone();
 273 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 274 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 275 |             })?;
 276 |             match plugin_service.peer.get() {
 277 |                 Some(peer) => {
 278 |                     tracing::debug!("Progress notification from {}", ctx.plugin_name);
 279 |                     ctx.handle.block_on(peer.notify_progress(progress_msg)).map_err(Error::from)
 280 |                 },
 281 |                 None => Ok(()),
 282 |             }
 283 |         });
 284 | 
 285 |         host_fn!(notify_prompt_list_changed(ctx: PluginServiceContext;) {
 286 |             let ctx = ctx.get()?.lock().unwrap().clone();
 287 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 288 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 289 |             })?;
 290 | 
 291 |             match plugin_service.peer.get() {
 292 |                 Some(peer) => {
 293 |                     tracing::info!("Notifying tool list changed from {}", ctx.plugin_name);
 294 |                     ctx.handle.block_on(peer.notify_prompt_list_changed()).map_err(Error::from)
 295 |                 },
 296 |                 None => Ok(()),
 297 |             }
 298 |         });
 299 | 
 300 |         host_fn!(notify_resource_list_changed(ctx: PluginServiceContext;) {
 301 |             let ctx = ctx.get()?.lock().unwrap().clone();
 302 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 303 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 304 |             })?;
 305 | 
 306 |             match plugin_service.peer.get() {
 307 |                 Some(peer) => {
 308 |                     tracing::info!("Notifying tool list changed from {}", ctx.plugin_name);
 309 |                     ctx.handle.block_on(peer.notify_resource_list_changed()).map_err(Error::from)
 310 |                 },
 311 |                 None => Ok(()),
 312 |             }
 313 |         });
 314 | 
 315 |         host_fn!(notify_resource_updated(ctx: PluginServiceContext; update_msg: Json<ResourceUpdatedNotificationParam>) {
 316 |             let update_msg = update_msg.into_inner();
 317 |             let ctx = ctx.get()?.lock().unwrap().clone();
 318 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 319 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 320 |             })?;
 321 |             if plugin_service.subscriptions.contains(&update_msg.uri) {
 322 |                 match plugin_service.peer.get() {
 323 |                     Some(peer) => {
 324 |                         tracing::info!("Notifying resource {} updated from {}", update_msg.uri, ctx.plugin_name);
 325 |                         ctx.handle.block_on(peer.notify_resource_updated(update_msg)).map_err(Error::from)
 326 |                     },
 327 |                     None => Ok(()),
 328 |                 }
 329 |             }
 330 |             else {
 331 |                 Ok(())
 332 |             }
 333 |         });
 334 | 
 335 |         // Declares a host function `notify_tool_list_changed` that plugins can call
 336 |         host_fn!(notify_tool_list_changed(ctx: PluginServiceContext;) {
 337 |             let ctx = ctx.get()?.lock().unwrap().clone();
 338 |             let plugin_service = PluginService::get(ctx.plugin_service_id).ok_or_else(|| {
 339 |                 anyhow::anyhow!("PluginService with ID {:?} not found", ctx.plugin_service_id)
 340 |             })?;
 341 | 
 342 |             match plugin_service.peer.get() {
 343 |                 Some(peer) => {
 344 |                     tracing::info!("Notifying tool list changed from {}", ctx.plugin_name);
 345 |                     ctx.handle.block_on(peer.notify_tool_list_changed()).map_err(Error::from)
 346 |                 },
 347 |                 None => Ok(()),
 348 |             }
 349 |         });
 350 | 
 351 |         for (plugin_name, plugin_cfg) in &self.config.plugins {
 352 |             let wasm_data = match WASM_DATA_CACHE.entry(plugin_name.clone()) {
 353 |                 Entry::Occupied(entry) => entry.get().clone(),
 354 |                 Entry::Vacant(entry) => {
 355 |                     let content = match plugin_cfg.url.scheme() {
 356 |                         "file" => tokio::fs::read(plugin_cfg.url.path()).await?,
 357 |                         "http" => wasm::http::load_wasm(&plugin_cfg.url, &None).await?,
 358 |                         "https" => {
 359 |                             wasm::http::load_wasm(&plugin_cfg.url, &self.config.auths).await?
 360 |                         }
 361 |                         "oci" => {
 362 |                             wasm::oci::load_wasm(&plugin_cfg.url, &self.config.oci, plugin_name)
 363 |                                 .await?
 364 |                         }
 365 |                         "s3" => wasm::s3::load_wasm(&plugin_cfg.url).await?,
 366 |                         unsupported => {
 367 |                             tracing::error!("Unsupported plugin URL scheme: {unsupported}");
 368 |                             return Err(anyhow::anyhow!(
 369 |                                 "Unsupported plugin URL scheme: {unsupported}"
 370 |                             ));
 371 |                         }
 372 |                     };
 373 |                     entry.insert(content.clone());
 374 |                     content
 375 |                 }
 376 |             };
 377 |             let mut manifest = Manifest::new([Wasm::data(wasm_data)]);
 378 |             if let Some(runtime_cfg) = &plugin_cfg.runtime_config {
 379 |                 tracing::info!("runtime_cfg: {runtime_cfg:?}");
 380 |                 if let Some(hosts) = &runtime_cfg.allowed_hosts {
 381 |                     for host in hosts {
 382 |                         manifest = manifest.with_allowed_host(host);
 383 |                     }
 384 |                 }
 385 |                 if let Some(paths) = &runtime_cfg.allowed_paths {
 386 |                     for path in paths {
 387 |                         // path will be available in the plugin with exact same path
 388 |                         manifest = manifest.with_allowed_path(path.clone(), path.clone());
 389 |                     }
 390 |                 }
 391 | 
 392 |                 // Add plugin configurations if present
 393 |                 if let Some(env_vars) = &runtime_cfg.env_vars {
 394 |                     for (key, value) in env_vars {
 395 |                         let resolved_value = check_env_reference(value);
 396 |                         manifest = manifest.with_config_key(key, &resolved_value);
 397 |                     }
 398 |                 }
 399 | 
 400 |                 if let Some(memory_limit) = &runtime_cfg.memory_limit {
 401 |                     match ByteSize::from_str(memory_limit) {
 402 |                         Ok(b) => {
 403 |                             // Wasm page size 64KiB, convert to number of pages
 404 |                             let num_pages = b.as_u64() / (64 * 1024);
 405 |                             manifest = manifest.with_memory_max(num_pages as u32);
 406 |                         }
 407 |                         Err(e) => {
 408 |                             tracing::error!(
 409 |                                 "Failed to parse memory_limit '{memory_limit}': {e}. Using default memory limit."
 410 |                             );
 411 |                         }
 412 |                     }
 413 |                 }
 414 |             }
 415 |             let extism_plugin = extism::Plugin::new(
 416 |                 &manifest,
 417 |                 [
 418 |                     Function::new(
 419 |                         "create_elicitation",
 420 |                         [extism::PTR],
 421 |                         [extism::PTR],
 422 |                         UserData::new(PluginServiceContext {
 423 |                             plugin_service_id: self.id,
 424 |                             handle: Handle::current(),
 425 |                             plugin_name: plugin_name.to_string(),
 426 |                         }),
 427 |                         create_elicitation,
 428 |                     )
 429 |                     .with_namespace(EXTISM_USER_MODULE),
 430 |                     Function::new(
 431 |                         "create_message",
 432 |                         [extism::PTR],
 433 |                         [extism::PTR],
 434 |                         UserData::new(PluginServiceContext {
 435 |                             plugin_service_id: self.id,
 436 |                             handle: Handle::current(),
 437 |                             plugin_name: plugin_name.to_string(),
 438 |                         }),
 439 |                         create_message,
 440 |                     )
 441 |                     .with_namespace(EXTISM_USER_MODULE),
 442 |                     Function::new(
 443 |                         "list_roots",
 444 |                         [],
 445 |                         [extism::PTR],
 446 |                         UserData::new(PluginServiceContext {
 447 |                             plugin_service_id: self.id,
 448 |                             handle: Handle::current(),
 449 |                             plugin_name: plugin_name.to_string(),
 450 |                         }),
 451 |                         list_roots,
 452 |                     )
 453 |                     .with_namespace(EXTISM_USER_MODULE),
 454 |                     Function::new(
 455 |                         "notify_logging_message",
 456 |                         [extism::PTR],
 457 |                         [],
 458 |                         UserData::new(PluginServiceContext {
 459 |                             plugin_service_id: self.id,
 460 |                             handle: Handle::current(),
 461 |                             plugin_name: plugin_name.to_string(),
 462 |                         }),
 463 |                         notify_logging_message,
 464 |                     )
 465 |                     .with_namespace(EXTISM_USER_MODULE),
 466 |                     Function::new(
 467 |                         "notify_progress",
 468 |                         [extism::PTR],
 469 |                         [],
 470 |                         UserData::new(PluginServiceContext {
 471 |                             plugin_service_id: self.id,
 472 |                             handle: Handle::current(),
 473 |                             plugin_name: plugin_name.to_string(),
 474 |                         }),
 475 |                         notify_progress,
 476 |                     )
 477 |                     .with_namespace(EXTISM_USER_MODULE),
 478 |                     Function::new(
 479 |                         "notify_prompt_list_changed",
 480 |                         [],
 481 |                         [],
 482 |                         UserData::new(PluginServiceContext {
 483 |                             plugin_service_id: self.id,
 484 |                             handle: Handle::current(),
 485 |                             plugin_name: plugin_name.to_string(),
 486 |                         }),
 487 |                         notify_prompt_list_changed,
 488 |                     )
 489 |                     .with_namespace(EXTISM_USER_MODULE),
 490 |                     Function::new(
 491 |                         "notify_resource_list_changed",
 492 |                         [],
 493 |                         [],
 494 |                         UserData::new(PluginServiceContext {
 495 |                             plugin_service_id: self.id,
 496 |                             handle: Handle::current(),
 497 |                             plugin_name: plugin_name.to_string(),
 498 |                         }),
 499 |                         notify_resource_list_changed,
 500 |                     )
 501 |                     .with_namespace(EXTISM_USER_MODULE),
 502 |                     Function::new(
 503 |                         "notify_resource_updated",
 504 |                         [extism::PTR],
 505 |                         [],
 506 |                         UserData::new(PluginServiceContext {
 507 |                             plugin_service_id: self.id,
 508 |                             handle: Handle::current(),
 509 |                             plugin_name: plugin_name.to_string(),
 510 |                         }),
 511 |                         notify_resource_updated,
 512 |                     )
 513 |                     .with_namespace(EXTISM_USER_MODULE),
 514 |                     Function::new(
 515 |                         "notify_tool_list_changed",
 516 |                         [],
 517 |                         [],
 518 |                         UserData::new(PluginServiceContext {
 519 |                             plugin_service_id: self.id,
 520 |                             handle: Handle::current(),
 521 |                             plugin_name: plugin_name.to_string(),
 522 |                         }),
 523 |                         notify_tool_list_changed,
 524 |                     )
 525 |                     .with_namespace(EXTISM_USER_MODULE),
 526 |                 ],
 527 |                 true,
 528 |             )
 529 |             .unwrap();
 530 | 
 531 |             let plugin_id = extism_plugin.id;
 532 |             let plugin: Box<dyn Plugin> = if extism_plugin.function_exists("call")
 533 |                 && extism_plugin.function_exists("describe")
 534 |             {
 535 |                 Box::new(PluginV1::new(
 536 |                     plugin_name.clone(),
 537 |                     Arc::new(Mutex::new(extism_plugin)),
 538 |                 ))
 539 |             } else {
 540 |                 Box::new(PluginV2::new(
 541 |                     plugin_name.clone(),
 542 |                     Arc::new(Mutex::new(extism_plugin)),
 543 |                 ))
 544 |             };
 545 | 
 546 |             names.insert(plugin_id, plugin_name.clone());
 547 |             plugins.insert(plugin_name.clone(), plugin);
 548 |             tracing::info!("Loaded plugin {plugin_name}");
 549 |         }
 550 |         self.names.set(names).expect("Names already set");
 551 |         self.plugins.set(plugins).expect("Plugins already set");
 552 |         Ok(())
 553 |     }
 554 | 
 555 |     pub fn logging_level(&self) -> LoggingLevel {
 556 |         *self.logging_level.read().unwrap()
 557 |     }
 558 | 
 559 |     pub fn set_logging_level(&self, level: LoggingLevel) {
 560 |         *self.logging_level.write().unwrap() = level;
 561 |     }
 562 | }
 563 | 
 564 | impl ServerHandler for PluginService {
 565 |     async fn call_tool(
 566 |         &self,
 567 |         request: CallToolRequestParam,
 568 |         context: RequestContext<RoleServer>,
 569 |     ) -> Result<CallToolResult, McpError> {
 570 |         tracing::info!("got tools/call request {:?}", request);
 571 |         let (plugin_name, tool_name) = match parse_namespaced_name(request.name.to_string()) {
 572 |             Ok((plugin_name, tool_name)) => (plugin_name, tool_name),
 573 |             Err(e) => {
 574 |                 return Err(McpError::invalid_request(
 575 |                     format!("Failed to parse tool name: {e}"),
 576 |                     None,
 577 |                 ));
 578 |             }
 579 |         };
 580 |         let plugin_config = match self.config.plugins.get(&plugin_name) {
 581 |             Some(config) => config,
 582 |             None => {
 583 |                 return Err(McpError::method_not_found::<CallToolRequestMethod>());
 584 |             }
 585 |         };
 586 |         if let Some(skip_tools) = &plugin_config
 587 |             .runtime_config
 588 |             .as_ref()
 589 |             .and_then(|rc| rc.skip_tools.clone())
 590 |             && skip_tools.is_match(&tool_name)
 591 |         {
 592 |             tracing::warn!("Tool {tool_name} in skip_tools");
 593 |             return Err(McpError::method_not_found::<CallToolRequestMethod>());
 594 |         }
 595 | 
 596 |         let request = CallToolRequestParam {
 597 |             name: std::borrow::Cow::Owned(tool_name.clone()),
 598 |             arguments: request.arguments,
 599 |         };
 600 | 
 601 |         let Some(plugins) = self.plugins.get() else {
 602 |             return Err(McpError::internal_error(
 603 |                 "Plugins not initialized".to_string(),
 604 |                 None,
 605 |             ));
 606 |         };
 607 | 
 608 |         let Some(plugin) = plugins.get(&plugin_name) else {
 609 |             return Err(McpError::method_not_found::<CallToolRequestMethod>());
 610 |         };
 611 |         plugin.call_tool(request, context).await
 612 |     }
 613 | 
 614 |     async fn complete(
 615 |         &self,
 616 |         request: CompleteRequestParam,
 617 |         context: RequestContext<RoleServer>,
 618 |     ) -> Result<CompleteResult, McpError> {
 619 |         tracing::info!("got completion/complete request {:?}", request);
 620 |         let (plugin_name, request) = match request.r#ref {
 621 |             Reference::Prompt(PromptReference { name, title }) => {
 622 |                 let (plugin_name, prompt_name) = match parse_namespaced_name(name.to_string()) {
 623 |                     Ok((plugin_name, prompt_name)) => (plugin_name, prompt_name),
 624 |                     Err(e) => {
 625 |                         return Err(McpError::invalid_request(
 626 |                             format!("Failed to parse prompt name: {e}"),
 627 |                             None,
 628 |                         ));
 629 |                     }
 630 |                 };
 631 |                 let plugin_config = match self.config.plugins.get(&plugin_name) {
 632 |                     Some(config) => config,
 633 |                     None => {
 634 |                         return Err(McpError::method_not_found::<CompleteRequestMethod>());
 635 |                     }
 636 |                 };
 637 |                 if let Some(skip_prompts) = &plugin_config
 638 |                     .runtime_config
 639 |                     .as_ref()
 640 |                     .and_then(|rc| rc.skip_prompts.clone())
 641 |                     && skip_prompts.is_match(&prompt_name)
 642 |                 {
 643 |                     tracing::warn!("Prompt {prompt_name} in skip_prompts");
 644 |                     return Err(McpError::method_not_found::<CompleteRequestMethod>());
 645 |                 }
 646 |                 (
 647 |                     plugin_name,
 648 |                     CompleteRequestParam {
 649 |                         r#ref: Reference::Prompt(PromptReference {
 650 |                             name: prompt_name,
 651 |                             title,
 652 |                         }),
 653 |                         argument: request.argument,
 654 |                         context: request.context,
 655 |                     },
 656 |                 )
 657 |             }
 658 |             Reference::Resource(ResourceReference { uri }) => {
 659 |                 let (plugin_name, resource_uri) = match parse_namespaced_uri(uri.to_string()) {
 660 |                     Ok((plugin_name, resource_uri)) => (plugin_name, resource_uri),
 661 |                     Err(e) => {
 662 |                         return Err(McpError::invalid_request(
 663 |                             format!("Failed to parse prompt name: {e}"),
 664 |                             None,
 665 |                         ));
 666 |                     }
 667 |                 };
 668 |                 let plugin_config = match self.config.plugins.get(&plugin_name) {
 669 |                     Some(config) => config,
 670 |                     None => {
 671 |                         return Err(McpError::method_not_found::<CompleteRequestMethod>());
 672 |                     }
 673 |                 };
 674 |                 if let Some(skip_resource_templates) = &plugin_config
 675 |                     .runtime_config
 676 |                     .as_ref()
 677 |                     .and_then(|rc| rc.skip_resource_templates.clone())
 678 |                     && skip_resource_templates.is_match(&resource_uri)
 679 |                 {
 680 |                     tracing::warn!("Resource {resource_uri} in skip_resources");
 681 |                     return Err(McpError::method_not_found::<CompleteRequestMethod>());
 682 |                 }
 683 |                 (
 684 |                     plugin_name,
 685 |                     CompleteRequestParam {
 686 |                         r#ref: Reference::Resource(ResourceReference { uri: resource_uri }),
 687 |                         argument: request.argument,
 688 |                         context: request.context,
 689 |                     },
 690 |                 )
 691 |             }
 692 |         };
 693 | 
 694 |         let Some(plugins) = self.plugins.get() else {
 695 |             return Err(McpError::internal_error(
 696 |                 "Plugins not initialized".to_string(),
 697 |                 None,
 698 |             ));
 699 |         };
 700 | 
 701 |         let Some(plugin) = plugins.get(&plugin_name) else {
 702 |             return Err(McpError::method_not_found::<CallToolRequestMethod>());
 703 |         };
 704 |         plugin.complete(request, context).await
 705 |     }
 706 | 
 707 |     fn get_info(&self) -> ServerInfo {
 708 |         ServerInfo {
 709 |             server_info: Implementation {
 710 |                 name: "hyper-mcp".to_string(),
 711 |                 title: Some("Hyper MCP".to_string()),
 712 |                 version: env!("CARGO_PKG_VERSION").to_string(),
 713 |                 website_url: Some("https://github.com/tuananh/hyper-mcp".to_string()),
 714 | 
 715 |                 ..Default::default()
 716 |             },
 717 |             capabilities: ServerCapabilities::builder()
 718 |                 .enable_completions()
 719 |                 .enable_logging()
 720 |                 .enable_prompts()
 721 |                 .enable_prompts_list_changed()
 722 |                 .enable_resources()
 723 |                 .enable_resources_list_changed()
 724 |                 .enable_resources_subscribe()
 725 |                 .enable_tools()
 726 |                 .enable_tool_list_changed()
 727 |                 .build(),
 728 | 
 729 |             ..Default::default()
 730 |         }
 731 |     }
 732 | 
 733 |     async fn get_prompt(
 734 |         &self,
 735 |         request: GetPromptRequestParam,
 736 |         context: RequestContext<RoleServer>,
 737 |     ) -> Result<GetPromptResult, McpError> {
 738 |         tracing::info!("got prompts/get request {:?}", request);
 739 |         let (plugin_name, prompt_name) = match parse_namespaced_name(request.name.to_string()) {
 740 |             Ok((plugin_name, prompt_name)) => (plugin_name, prompt_name),
 741 |             Err(e) => {
 742 |                 return Err(McpError::invalid_request(
 743 |                     format!("Failed to parse prompt name: {e}"),
 744 |                     None,
 745 |                 ));
 746 |             }
 747 |         };
 748 |         let plugin_config = match self.config.plugins.get(&plugin_name) {
 749 |             Some(config) => config,
 750 |             None => {
 751 |                 return Err(McpError::method_not_found::<GetPromptRequestMethod>());
 752 |             }
 753 |         };
 754 |         if let Some(skip_prompts) = &plugin_config
 755 |             .runtime_config
 756 |             .as_ref()
 757 |             .and_then(|rc| rc.skip_prompts.clone())
 758 |             && skip_prompts.is_match(&prompt_name)
 759 |         {
 760 |             tracing::warn!("Prompt {prompt_name} in skip_prompts");
 761 |             return Err(McpError::method_not_found::<GetPromptRequestMethod>());
 762 |         }
 763 | 
 764 |         let request = GetPromptRequestParam {
 765 |             name: prompt_name.clone(),
 766 |             arguments: request.arguments,
 767 |         };
 768 | 
 769 |         let Some(plugins) = self.plugins.get() else {
 770 |             return Err(McpError::internal_error(
 771 |                 "Plugins not initialized".to_string(),
 772 |                 None,
 773 |             ));
 774 |         };
 775 | 
 776 |         let Some(plugin) = plugins.get(&plugin_name) else {
 777 |             return Err(McpError::method_not_found::<GetPromptRequestMethod>());
 778 |         };
 779 |         plugin.get_prompt(request, context).await
 780 |     }
 781 | 
 782 |     async fn list_prompts(
 783 |         &self,
 784 |         request: Option<PaginatedRequestParam>,
 785 |         context: RequestContext<RoleServer>,
 786 |     ) -> Result<ListPromptsResult, McpError> {
 787 |         tracing::info!("got prompts/list request {:?}", request);
 788 |         let Some(plugins) = self.plugins.get() else {
 789 |             return Err(McpError::internal_error(
 790 |                 "Plugins not initialized".to_string(),
 791 |                 None,
 792 |             ));
 793 |         };
 794 | 
 795 |         let mut list_prompts_result = ListPromptsResult::default();
 796 | 
 797 |         for (plugin_name, plugin) in plugins.iter() {
 798 |             let plugin_prompts = plugin
 799 |                 .list_prompts(request.clone(), context.clone())
 800 |                 .await?;
 801 |             let plugin_cfg = self.config.plugins.get(plugin_name).ok_or_else(|| {
 802 |                 McpError::internal_error(
 803 |                     format!("Plugin configuration not found for {plugin_name}"),
 804 |                     None,
 805 |                 )
 806 |             })?;
 807 |             let skip_prompts = plugin_cfg
 808 |                 .runtime_config
 809 |                 .as_ref()
 810 |                 .and_then(|rc| rc.skip_prompts.clone())
 811 |                 .unwrap_or_default();
 812 |             for prompt in plugin_prompts.prompts {
 813 |                 let prompt_name = prompt.name.as_ref() as &str;
 814 |                 if skip_prompts.is_match(prompt_name) {
 815 |                     tracing::info!(
 816 |                         "Skipping prompt {} as requested in skip_prompts",
 817 |                         prompt.name
 818 |                     );
 819 |                     continue;
 820 |                 }
 821 |                 let mut new_prompt = prompt.clone();
 822 |                 new_prompt.name = create_namespaced_name(plugin_name, &prompt.name);
 823 |                 list_prompts_result.prompts.push(new_prompt);
 824 |             }
 825 |         }
 826 | 
 827 |         Ok(list_prompts_result)
 828 |     }
 829 | 
 830 |     async fn list_resources(
 831 |         &self,
 832 |         request: Option<PaginatedRequestParam>,
 833 |         context: RequestContext<RoleServer>,
 834 |     ) -> Result<ListResourcesResult, McpError> {
 835 |         tracing::info!("got resources/list request {:?}", request);
 836 |         let Some(plugins) = self.plugins.get() else {
 837 |             return Err(McpError::internal_error(
 838 |                 "Plugins not initialized".to_string(),
 839 |                 None,
 840 |             ));
 841 |         };
 842 | 
 843 |         let mut list_resources_result = ListResourcesResult::default();
 844 | 
 845 |         for (plugin_name, plugin) in plugins.iter() {
 846 |             let plugin_resources = plugin
 847 |                 .list_resources(request.clone(), context.clone())
 848 |                 .await?;
 849 |             let plugin_cfg = self.config.plugins.get(plugin_name).ok_or_else(|| {
 850 |                 McpError::internal_error(
 851 |                     format!("Plugin configuration not found for {plugin_name}"),
 852 |                     None,
 853 |                 )
 854 |             })?;
 855 |             let skip_resources = plugin_cfg
 856 |                 .runtime_config
 857 |                 .as_ref()
 858 |                 .and_then(|rc| rc.skip_resources.clone())
 859 |                 .unwrap_or_default();
 860 |             for resource in plugin_resources.resources {
 861 |                 if skip_resources.is_match(resource.uri.as_str()) {
 862 |                     tracing::info!(
 863 |                         "Skipping resource {} as requested in skip_resources",
 864 |                         resource.uri
 865 |                     );
 866 |                     continue;
 867 |                 }
 868 |                 let mut raw = resource.raw.clone();
 869 |                 raw.uri = create_namespaced_uri(plugin_name, &resource.uri)
 870 |                     .map_err(|e| McpError::internal_error(e.to_string(), None))?;
 871 |                 list_resources_result.resources.push(Resource {
 872 |                     raw,
 873 |                     annotations: resource.annotations.clone(),
 874 |                 });
 875 |             }
 876 |         }
 877 | 
 878 |         Ok(list_resources_result)
 879 |     }
 880 | 
 881 |     async fn list_resource_templates(
 882 |         &self,
 883 |         request: Option<PaginatedRequestParam>,
 884 |         context: RequestContext<RoleServer>,
 885 |     ) -> Result<ListResourceTemplatesResult, McpError> {
 886 |         tracing::info!("got resources/templates/list request {:?}", request);
 887 |         let Some(plugins) = self.plugins.get() else {
 888 |             return Err(McpError::internal_error(
 889 |                 "Plugins not initialized".to_string(),
 890 |                 None,
 891 |             ));
 892 |         };
 893 | 
 894 |         let mut list_resource_templates_result = ListResourceTemplatesResult::default();
 895 | 
 896 |         for (plugin_name, plugin) in plugins.iter() {
 897 |             let plugin_resource_templates = plugin
 898 |                 .list_resource_templates(request.clone(), context.clone())
 899 |                 .await?;
 900 |             let plugin_cfg = self.config.plugins.get(plugin_name).ok_or_else(|| {
 901 |                 McpError::internal_error(
 902 |                     format!("Plugin configuration not found for {plugin_name}"),
 903 |                     None,
 904 |                 )
 905 |             })?;
 906 |             let skip_resource_templates = plugin_cfg
 907 |                 .runtime_config
 908 |                 .as_ref()
 909 |                 .and_then(|rc| rc.skip_resource_templates.clone())
 910 |                 .unwrap_or_default();
 911 |             for resource_template in plugin_resource_templates.resource_templates {
 912 |                 if skip_resource_templates.is_match(resource_template.uri_template.as_str()) {
 913 |                     tracing::info!(
 914 |                         "Skipping resource template {} as requested in skip_resources",
 915 |                         resource_template.uri_template
 916 |                     );
 917 |                     continue;
 918 |                 }
 919 |                 let mut raw = resource_template.raw.clone();
 920 |                 raw.uri_template =
 921 |                     create_namespaced_uri(plugin_name, &resource_template.uri_template)
 922 |                         .map_err(|e| McpError::internal_error(e.to_string(), None))?;
 923 |                 list_resource_templates_result
 924 |                     .resource_templates
 925 |                     .push(ResourceTemplate {
 926 |                         raw,
 927 |                         annotations: resource_template.annotations.clone(),
 928 |                     });
 929 |             }
 930 |         }
 931 | 
 932 |         Ok(list_resource_templates_result)
 933 |     }
 934 | 
 935 |     async fn list_tools(
 936 |         &self,
 937 |         request: Option<PaginatedRequestParam>,
 938 |         context: RequestContext<RoleServer>,
 939 |     ) -> Result<ListToolsResult, McpError> {
 940 |         tracing::info!("got tools/list request {:?}", request);
 941 |         let Some(plugins) = self.plugins.get() else {
 942 |             return Err(McpError::internal_error(
 943 |                 "Plugins not initialized".to_string(),
 944 |                 None,
 945 |             ));
 946 |         };
 947 | 
 948 |         let mut list_tools_result = ListToolsResult::default();
 949 | 
 950 |         for (plugin_name, plugin) in plugins.iter() {
 951 |             let plugin_tools = plugin.list_tools(request.clone(), context.clone()).await?;
 952 |             let plugin_cfg = self.config.plugins.get(plugin_name).ok_or_else(|| {
 953 |                 McpError::internal_error(
 954 |                     format!("Plugin configuration not found for {plugin_name}"),
 955 |                     None,
 956 |                 )
 957 |             })?;
 958 |             let skip_tools = plugin_cfg
 959 |                 .runtime_config
 960 |                 .as_ref()
 961 |                 .and_then(|rc| rc.skip_tools.clone())
 962 |                 .unwrap_or_default();
 963 |             for tool in plugin_tools.tools {
 964 |                 let tool_name = tool.name.as_ref() as &str;
 965 |                 if skip_tools.is_match(tool_name) {
 966 |                     tracing::info!("Skipping tool {} as requested in skip_tools", tool.name);
 967 |                     continue;
 968 |                 }
 969 |                 let mut new_tool = tool.clone();
 970 |                 new_tool.name =
 971 |                     std::borrow::Cow::Owned(create_namespaced_name(plugin_name, &tool.name));
 972 |                 list_tools_result.tools.push(new_tool);
 973 |             }
 974 |         }
 975 | 
 976 |         Ok(list_tools_result)
 977 |     }
 978 | 
 979 |     fn on_initialized(
 980 |         &self,
 981 |         context: NotificationContext<RoleServer>,
 982 |     ) -> impl Future<Output = ()> + Send + '_ {
 983 |         tracing::info!("client initialized");
 984 |         self.peer.set(context.peer).expect("Peer already set");
 985 |         std::future::ready(())
 986 |     }
 987 | 
 988 |     async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) -> () {
 989 |         tracing::info!("got roots/list_changed notification");
 990 |         let Some(plugins) = self.plugins.get() else {
 991 |             tracing::error!("Plugins not initialized");
 992 |             return;
 993 |         };
 994 |         for (plugin_name, plugin) in plugins.iter() {
 995 |             if let Err(e) = plugin.on_roots_list_changed(context.clone()).await {
 996 |                 tracing::error!("Failed to notify plugin {plugin_name} of roots list change: {e}");
 997 |             }
 998 |         }
 999 |     }
1000 | 
1001 |     async fn read_resource(
1002 |         &self,
1003 |         request: ReadResourceRequestParam,
1004 |         context: RequestContext<RoleServer>,
1005 |     ) -> Result<ReadResourceResult, McpError> {
1006 |         tracing::info!("got resources/read request {:?}", request);
1007 |         let (plugin_name, resource_uri) = match parse_namespaced_uri(request.uri.to_string()) {
1008 |             Ok((plugin_name, resource_uri)) => (plugin_name, resource_uri),
1009 |             Err(e) => {
1010 |                 return Err(McpError::invalid_request(
1011 |                     format!("Failed to parse prompt name: {e}"),
1012 |                     None,
1013 |                 ));
1014 |             }
1015 |         };
1016 |         let plugin_config = match self.config.plugins.get(&plugin_name) {
1017 |             Some(config) => config,
1018 |             None => {
1019 |                 return Err(McpError::method_not_found::<ReadResourceRequestMethod>());
1020 |             }
1021 |         };
1022 |         if let Some(skip_resources) = &plugin_config
1023 |             .runtime_config
1024 |             .as_ref()
1025 |             .and_then(|rc| rc.skip_resources.clone())
1026 |             && skip_resources.is_match(&resource_uri)
1027 |         {
1028 |             tracing::warn!("Resource {resource_uri} in skip_resources");
1029 |             return Err(McpError::method_not_found::<ReadResourceRequestMethod>());
1030 |         }
1031 | 
1032 |         let request = ReadResourceRequestParam {
1033 |             uri: resource_uri.clone(),
1034 |         };
1035 | 
1036 |         let Some(plugins) = self.plugins.get() else {
1037 |             return Err(McpError::internal_error(
1038 |                 "Plugins not initialized".to_string(),
1039 |                 None,
1040 |             ));
1041 |         };
1042 | 
1043 |         let Some(plugin) = plugins.get(&plugin_name) else {
1044 |             return Err(McpError::method_not_found::<GetPromptRequestMethod>());
1045 |         };
1046 |         plugin.read_resource(request, context).await
1047 |     }
1048 | 
1049 |     fn set_level(
1050 |         &self,
1051 |         request: SetLevelRequestParam,
1052 |         _context: RequestContext<RoleServer>,
1053 |     ) -> impl Future<Output = Result<(), McpError>> + Send + '_ {
1054 |         self.set_logging_level(request.level);
1055 |         std::future::ready(Ok(()))
1056 |     }
1057 | 
1058 |     fn subscribe(
1059 |         &self,
1060 |         request: SubscribeRequestParam,
1061 |         _context: RequestContext<RoleServer>,
1062 |     ) -> impl Future<Output = std::result::Result<(), McpError>> + Send + '_ {
1063 |         self.subscriptions.insert(request.uri);
1064 |         std::future::ready(Ok(()))
1065 |     }
1066 | 
1067 |     fn unsubscribe(
1068 |         &self,
1069 |         request: UnsubscribeRequestParam,
1070 |         _context: RequestContext<RoleServer>,
1071 |     ) -> impl Future<Output = std::result::Result<(), McpError>> + Send + '_ {
1072 |         self.subscriptions.remove(&request.uri);
1073 |         std::future::ready(Ok(()))
1074 |     }
1075 | }
1076 | 
1077 | #[cfg(test)]
1078 | mod tests {
1079 |     use super::*;
1080 |     use crate::{cli::Cli, config::load_config};
1081 |     use rmcp::{
1082 |         ClientHandler,
1083 |         model::ClientInfo,
1084 |         service::{RoleClient, RunningService, Service, serve_client, serve_server},
1085 |     };
1086 |     use std::{
1087 |         path::PathBuf,
1088 |         sync::atomic::{AtomicUsize, Ordering},
1089 |     };
1090 |     use tempfile::TempDir;
1091 |     use tokio::io::duplex;
1092 |     use tokio_test::assert_ok;
1093 |     use tokio_util::sync::CancellationToken;
1094 | 
1095 |     struct TestClientInner {
1096 |         tool_list_changed_count: AtomicUsize,
1097 |     }
1098 | 
1099 |     struct TestClient(Arc<TestClientInner>);
1100 | 
1101 |     impl Clone for TestClient {
1102 |         fn clone(&self) -> Self {
1103 |             Self(Arc::clone(&self.0))
1104 |         }
1105 |     }
1106 | 
1107 |     impl Deref for TestClient {
1108 |         type Target = Arc<TestClientInner>;
1109 | 
1110 |         fn deref(&self) -> &Self::Target {
1111 |             &self.0
1112 |         }
1113 |     }
1114 | 
1115 |     impl ClientHandler for TestClient {
1116 |         fn on_tool_list_changed(
1117 |             &self,
1118 |             _context: NotificationContext<RoleClient>,
1119 |         ) -> impl Future<Output = ()> + Send + '_ {
1120 |             self.tool_list_changed_count.fetch_add(1, Ordering::SeqCst);
1121 |             std::future::ready(())
1122 |         }
1123 |     }
1124 | 
1125 |     impl TestClient {
1126 |         fn new() -> Self {
1127 |             Self(Arc::new(TestClientInner {
1128 |                 tool_list_changed_count: AtomicUsize::new(0),
1129 |             }))
1130 |         }
1131 | 
1132 |         fn get_tool_list_changed_count(&self) -> usize {
1133 |             self.tool_list_changed_count.load(Ordering::SeqCst)
1134 |         }
1135 |     }
1136 | 
1137 |     async fn create_temp_config_file(content: &str) -> anyhow::Result<(TempDir, PathBuf)> {
1138 |         let temp_dir = TempDir::new()?;
1139 |         let config_path = temp_dir.path().join("test_config.yaml");
1140 |         tokio::fs::write(&config_path, content).await?;
1141 |         Ok((temp_dir, config_path))
1142 |     }
1143 | 
1144 |     fn create_test_cli() -> Cli {
1145 |         crate::cli::Cli::default()
1146 |     }
1147 | 
1148 |     fn create_test_ctx(
1149 |         running: &RunningService<RoleServer, PluginService>,
1150 |     ) -> RequestContext<RoleServer> {
1151 |         RequestContext {
1152 |             ct: CancellationToken::new(),
1153 |             extensions: Extensions::default(),
1154 |             id: RequestId::Number(1),
1155 |             meta: Meta::default(),
1156 |             peer: running.peer().clone(),
1157 |         }
1158 |     }
1159 | 
1160 |     fn create_test_service(config: Config) -> PluginService {
1161 |         PluginService(Arc::new(PluginServiceInner {
1162 |             config,
1163 |             id: Uuid::new_v4(),
1164 |             logging_level: RwLock::new(LoggingLevel::Info),
1165 |             names: SetOnce::new(),
1166 |             peer: SetOnce::new(),
1167 |             plugins: SetOnce::new(),
1168 |             subscriptions: DashSet::new(),
1169 |         }))
1170 |     }
1171 | 
1172 |     async fn create_test_pair<S, C>(
1173 |         service: S,
1174 |         client: C,
1175 |     ) -> (RunningService<RoleServer, S>, RunningService<RoleClient, C>)
1176 |     where
1177 |         S: Service<RoleServer>,
1178 |         C: Service<RoleClient>,
1179 |     {
1180 |         let (srv_io, cli_io) = duplex(64 * 1024);
1181 |         tokio::try_join!(
1182 |             async {
1183 |                 serve_server(service, srv_io)
1184 |                     .await
1185 |                     .map_err(anyhow::Error::from)
1186 |             },
1187 |             async {
1188 |                 serve_client(client, cli_io)
1189 |                     .await
1190 |                     .map_err(anyhow::Error::from)
1191 |             }
1192 |         )
1193 |         .expect("Failed to create test pair")
1194 |     }
1195 | 
1196 |     fn get_test_wasm_path() -> PathBuf {
1197 |         let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1198 |         path.push("examples");
1199 |         path.push("plugins");
1200 |         path.push("v1");
1201 |         path.push("time");
1202 |         path.push("time.wasm");
1203 |         path
1204 |     }
1205 | 
1206 |     fn test_wasm_exists() -> bool {
1207 |         get_test_wasm_path().exists()
1208 |     }
1209 | 
1210 |     fn get_tool_list_changed_wasm_path() -> PathBuf {
1211 |         let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1212 |         path.push("examples");
1213 |         path.push("plugins");
1214 |         path.push("v1");
1215 |         path.push("tool-list-changed");
1216 |         path.push("tool_list_changed.wasm");
1217 |         path
1218 |     }
1219 | 
1220 |     fn test_tool_list_changed_wasm_exists() -> bool {
1221 |         get_tool_list_changed_wasm_path().exists()
1222 |     }
1223 | 
1224 |     fn get_rstime_wasm_path() -> PathBuf {
1225 |         let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1226 |         path.push("examples");
1227 |         path.push("plugins");
1228 |         path.push("v2");
1229 |         path.push("rstime");
1230 |         path.push("rstime.wasm");
1231 |         path
1232 |     }
1233 | 
1234 |     fn test_rstime_wasm_exists() -> bool {
1235 |         get_rstime_wasm_path().exists()
1236 |     }
1237 | 
1238 |     // Helper function to create a dummy request context for compilation
1239 |     // These tests will be skipped at runtime since we can't easily mock contexts
1240 |     // PluginService creation tests
1241 | 
1242 |     #[tokio::test]
1243 |     async fn test_plugin_service_creation_empty_config() {
1244 |         let config_content = r#"
1245 | plugins: {}
1246 | "#;
1247 |         let (_temp_dir, config_path) = create_temp_config_file(config_content).await.unwrap();
1248 |         let mut cli = create_test_cli();
1249 |         cli.config_file = Some(config_path);
1250 |         let config = load_config(&cli).await.unwrap();
1251 | 
1252 |         let result = PluginService::new(&config).await;
1253 |         assert!(
1254 |             result.is_ok(),
1255 |             "Should create service with empty plugin config"
1256 |         );
1257 | 
1258 |         let service = result.unwrap();
1259 |         let Some(plugins) = service.plugins.get() else {
1260 |             panic!("Plugins should be initialized");
1261 |         };
1262 |         assert!(plugins.is_empty(), "Should have no plugins loaded");
1263 |     }
1264 | 
1265 |     #[tokio::test]
1266 |     async fn test_plugin_service_creation_with_file_plugin() {
1267 |         let wasm_path = get_test_wasm_path();
1268 |         if !test_wasm_exists() {
1269 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1270 |             return;
1271 |         }
1272 | 
1273 |         let config_content = format!(
1274 |             r#"
1275 | plugins:
1276 |   time_plugin:
1277 |     url: "file://{}"
1278 |     runtime_config:
1279 |       memory_limit: "1MB"
1280 |       env_vars:
1281 |         TEST_MODE: "true"
1282 | "#,
1283 |             wasm_path.display()
1284 |         );
1285 | 
1286 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1287 |         let mut cli = create_test_cli();
1288 |         cli.config_file = Some(config_path);
1289 |         let config = load_config(&cli).await.unwrap();
1290 | 
1291 |         let result = PluginService::new(&config).await;
1292 |         assert!(
1293 |             result.is_ok(),
1294 |             "Should create service with valid WASM plugin"
1295 |         );
1296 | 
1297 |         let service = result.unwrap();
1298 |         let Some(plugins) = service.plugins.get() else {
1299 |             panic!("Plugins should be initialized");
1300 |         };
1301 |         assert_eq!(plugins.len(), 1, "Should have one plugin loaded");
1302 |         assert!(plugins.contains_key(&PluginName::from_str("time_plugin").unwrap()));
1303 |     }
1304 | 
1305 |     #[tokio::test]
1306 |     async fn test_plugin_service_creation_with_nonexistent_file() {
1307 |         let config_content = r#"
1308 | plugins:
1309 |   missing_plugin:
1310 |     url: "file:///nonexistent/path/plugin.wasm"
1311 | "#;
1312 | 
1313 |         let (_temp_dir, config_path) = create_temp_config_file(config_content).await.unwrap();
1314 |         let mut cli = create_test_cli();
1315 |         cli.config_file = Some(config_path);
1316 |         let config = load_config(&cli).await.unwrap();
1317 | 
1318 |         let result = PluginService::new(&config).await;
1319 |         assert!(result.is_err(), "Should fail with nonexistent plugin file");
1320 |     }
1321 | 
1322 |     #[tokio::test]
1323 |     async fn test_plugin_service_creation_with_invalid_memory_limit() {
1324 |         let wasm_path = get_test_wasm_path();
1325 |         if !test_wasm_exists() {
1326 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1327 |             return;
1328 |         }
1329 | 
1330 |         let config_content = format!(
1331 |             r#"
1332 | plugins:
1333 |   time_plugin:
1334 |     url: "file://{}"
1335 |     runtime_config:
1336 |       memory_limit: "invalid_size"
1337 | "#,
1338 |             wasm_path.display()
1339 |         );
1340 | 
1341 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1342 |         let mut cli = create_test_cli();
1343 |         cli.config_file = Some(config_path);
1344 |         let config = load_config(&cli).await.unwrap();
1345 | 
1346 |         let result = PluginService::new(&config).await;
1347 |         // Should still succeed but log an error about invalid memory limit
1348 |         assert!(
1349 |             result.is_ok(),
1350 |             "Should handle invalid memory limit gracefully"
1351 |         );
1352 |     }
1353 | 
1354 |     // ServerHandler tests
1355 | 
1356 |     #[test]
1357 |     fn test_plugin_service_get_info() {
1358 |         let config = Config::default();
1359 |         let service = create_test_service(config);
1360 | 
1361 |         let info = rmcp::ServerHandler::get_info(&service);
1362 |         assert_eq!(info.protocol_version, ProtocolVersion::LATEST);
1363 |         assert_eq!(info.server_info.name, "hyper-mcp");
1364 |         assert!(!info.server_info.version.is_empty());
1365 |         assert!(info.capabilities.tools.is_some());
1366 |     }
1367 | 
1368 |     #[tokio::test]
1369 |     async fn test_plugin_service_list_tools_with_plugin() {
1370 |         let wasm_path = get_test_wasm_path();
1371 |         if !test_wasm_exists() {
1372 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1373 |             return;
1374 |         }
1375 | 
1376 |         let config_content = format!(
1377 |             r#"
1378 | plugins:
1379 |   time_plugin:
1380 |     url: "file://{}"
1381 | "#,
1382 |             wasm_path.display()
1383 |         );
1384 | 
1385 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1386 |         let mut cli = create_test_cli();
1387 |         cli.config_file = Some(config_path);
1388 |         let config = load_config(&cli).await.unwrap();
1389 | 
1390 |         let (server, client) = create_test_pair(
1391 |             PluginService::new(&config).await.unwrap(),
1392 |             ClientInfo::default(),
1393 |         )
1394 |         .await;
1395 |         // Verify the service was created successfully
1396 |         let Some(plugins) = server.service().plugins.get() else {
1397 |             panic!("Plugins should be initialized");
1398 |         };
1399 |         assert!(!plugins.is_empty(), "Should have loaded plugin");
1400 | 
1401 |         // Test the list_tools function
1402 |         let request = None; // No pagination for this test
1403 |         let ctx = create_test_ctx(&server);
1404 |         let result = server.service().list_tools(request, ctx).await;
1405 |         assert!(result.is_ok(), "list_tools should succeed");
1406 | 
1407 |         let list_tools_result = result.unwrap();
1408 |         assert!(
1409 |             !list_tools_result.tools.is_empty(),
1410 |             "Should have tools from the loaded plugin"
1411 |         );
1412 | 
1413 |         // Verify we get the expected tools from time.wasm plugin
1414 |         let expected_tools = vec!["time_plugin-time"];
1415 | 
1416 |         let actual_tool_names: Vec<String> = list_tools_result
1417 |             .tools
1418 |             .iter()
1419 |             .map(|tool| tool.name.to_string())
1420 |             .collect();
1421 | 
1422 |         for expected_tool in &expected_tools {
1423 |             assert!(
1424 |                 actual_tool_names.contains(&expected_tool.to_string()),
1425 |                 "Expected tool '{expected_tool}' not found in actual tools: {actual_tool_names:?}"
1426 |             );
1427 |         }
1428 | 
1429 |         assert_eq!(
1430 |             list_tools_result.tools.len(),
1431 |             expected_tools.len(),
1432 |             "Expected {} tools but got {}: {:?}",
1433 |             expected_tools.len(),
1434 |             list_tools_result.tools.len(),
1435 |             actual_tool_names
1436 |         );
1437 | 
1438 |         // Verify the time tool has the expected operations in its schema
1439 |         let time_tool = list_tools_result
1440 |             .tools
1441 |             .iter()
1442 |             .find(|tool| tool.name == "time_plugin-time")
1443 |             .expect("time_plugin-time tool should exist");
1444 | 
1445 |         // Check that the tool description mentions the expected operations
1446 |         let description = time_tool
1447 |             .description
1448 |             .as_ref()
1449 |             .expect("Tool should have description");
1450 |         let expected_operations = vec!["get_time_utc", "parse_time", "time_offset"];
1451 |         for operation in &expected_operations {
1452 |             assert!(
1453 |                 description.contains(operation),
1454 |                 "Tool description should mention operation '{operation}': {description}"
1455 |             );
1456 |         }
1457 | 
1458 |         // Check that the input schema includes the expected operations in the enum
1459 |         let schema_value = &time_tool.input_schema;
1460 |         if let Some(properties) = schema_value.get("properties") {
1461 |             if let Some(name_property) = properties.get("name") {
1462 |                 if let Some(enum_values) = name_property.get("enum") {
1463 |                     if let Some(enum_array) = enum_values.as_array() {
1464 |                         let schema_operations: Vec<String> = enum_array
1465 |                             .iter()
1466 |                             .filter_map(|v| v.as_str().map(|s| s.to_string()))
1467 |                             .collect();
1468 | 
1469 |                         for operation in &expected_operations {
1470 |                             assert!(
1471 |                                 schema_operations.contains(&operation.to_string()),
1472 |                                 "Input schema should include operation '{operation}' in enum: {schema_operations:?}"
1473 |                             );
1474 |                         }
1475 |                     }
1476 |                 }
1477 |             }
1478 |         }
1479 |         // Cleanup
1480 |         assert_ok!(server.cancel().await);
1481 |         assert_ok!(client.cancel().await);
1482 |     }
1483 | 
1484 |     #[tokio::test]
1485 |     async fn test_plugin_service_list_tools_with_skip_tools() {
1486 |         let wasm_path = get_test_wasm_path();
1487 |         if !test_wasm_exists() {
1488 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1489 |             return;
1490 |         }
1491 | 
1492 |         let config_content = format!(
1493 |             r#"
1494 | plugins:
1495 |   time_plugin:
1496 |     url: "file://{}"
1497 |     runtime_config:
1498 |       skip_tools:
1499 |         - "time"
1500 | "#,
1501 |             wasm_path.display()
1502 |         );
1503 | 
1504 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1505 |         let mut cli = create_test_cli();
1506 |         cli.config_file = Some(config_path);
1507 |         let config = load_config(&cli).await.unwrap();
1508 | 
1509 |         let (server, client) = create_test_pair(
1510 |             PluginService::new(&config).await.unwrap(),
1511 |             ClientInfo::default(),
1512 |         )
1513 |         .await;
1514 |         let Some(plugins) = server.service().plugins.get() else {
1515 |             panic!("Plugins should be initialized");
1516 |         };
1517 |         assert!(!plugins.is_empty(), "Should have loaded plugin");
1518 | 
1519 |         // Test the list_tools function with skip_tools configuration
1520 |         let request = None; // No pagination for this test
1521 |         let ctx = create_test_ctx(&server);
1522 |         let result = server.service().list_tools(request, ctx).await;
1523 |         assert!(result.is_ok(), "list_tools should succeed");
1524 | 
1525 |         let list_tools_result = result.unwrap();
1526 | 
1527 |         // Since we're skipping the "time" tool, the tools list should be empty
1528 |         assert!(
1529 |             list_tools_result.tools.is_empty(),
1530 |             "Should have no tools since 'time' tool is skipped. Found tools: {:?}",
1531 |             list_tools_result
1532 |                 .tools
1533 |                 .iter()
1534 |                 .map(|t| t.name.as_ref() as &str)
1535 |                 .collect::<Vec<&str>>()
1536 |         );
1537 | 
1538 |         // Verify specifically that the time-plugin::time tool is not present
1539 |         let tool_names: Vec<String> = list_tools_result
1540 |             .tools
1541 |             .iter()
1542 |             .map(|tool| tool.name.to_string())
1543 |             .collect();
1544 | 
1545 |         assert!(
1546 |             !tool_names.contains(&"time_plugin-time".to_string()),
1547 |             "time_plugin-time should be skipped but was found in tools: {tool_names:?}"
1548 |         );
1549 | 
1550 |         // Verify that the plugin itself was loaded (skip_tools should not prevent plugin loading)
1551 |         {
1552 |             let plugin_name: PluginName = "time_plugin".parse().unwrap();
1553 |             assert!(
1554 |                 plugins.contains_key(&plugin_name),
1555 |                 "Plugin 'time_plugin' should still be loaded even with skip_tools configuration"
1556 |             );
1557 |         } // plugins guard dropped here
1558 | 
1559 |         // Verify the plugin configuration includes skip_tools
1560 |         let plugin_name: PluginName = "time_plugin".parse().unwrap();
1561 |         let plugin_config = server.service().config.plugins.get(&plugin_name).unwrap();
1562 |         let skip_tools = plugin_config
1563 |             .runtime_config
1564 |             .as_ref()
1565 |             .and_then(|rc| rc.skip_tools.as_ref())
1566 |             .unwrap();
1567 | 
1568 |         assert!(
1569 |             skip_tools.is_match(&"time"),
1570 |             "Configuration should include 'time' in skip_tools list: {skip_tools:?}"
1571 |         );
1572 | 
1573 |         assert_eq!(
1574 |             skip_tools.len(),
1575 |             1,
1576 |             "Should have exactly one tool in skip_tools list: {skip_tools:?}"
1577 |         );
1578 | 
1579 |         // Cleanup
1580 |         assert_ok!(server.cancel().await);
1581 |         assert_ok!(client.cancel().await);
1582 |     }
1583 | 
1584 |     #[tokio::test]
1585 |     async fn test_plugin_service_call_tool_invalid_format() {
1586 |         let config = Config::default();
1587 |         let (server, client) =
1588 |             create_test_pair(create_test_service(config), ClientInfo::default()).await;
1589 | 
1590 |         // Test calling tool with invalid format (missing plugin name separator)
1591 |         let request = CallToolRequestParam {
1592 |             name: std::borrow::Cow::Borrowed("invalid_tool_name"),
1593 |             arguments: None,
1594 |         };
1595 | 
1596 |         let ctx = create_test_ctx(&server);
1597 |         let result = server.service().call_tool(request, ctx).await;
1598 |         assert!(result.is_err(), "Should fail with invalid tool name format");
1599 | 
1600 |         if let Err(error) = result {
1601 |             // Should be an invalid_request error
1602 |             assert!(
1603 |                 error.to_string().contains("Failed to parse tool name"),
1604 |                 "Error should mention parsing failure: {error}"
1605 |             );
1606 |         }
1607 | 
1608 |         // Test with empty tool name
1609 |         let request = CallToolRequestParam {
1610 |             name: std::borrow::Cow::Borrowed(""),
1611 |             arguments: None,
1612 |         };
1613 | 
1614 |         let ctx = create_test_ctx(&server);
1615 |         let result = server.service().call_tool(request, ctx).await;
1616 |         assert!(result.is_err(), "Should fail with empty tool name");
1617 |         assert_ok!(server.cancel().await);
1618 |         assert_ok!(client.cancel().await);
1619 |     }
1620 | 
1621 |     #[tokio::test]
1622 |     async fn test_plugin_service_call_tool_nonexistent_plugin() {
1623 |         let config = Config::default();
1624 |         let (server, client) =
1625 |             create_test_pair(create_test_service(config), ClientInfo::default()).await;
1626 | 
1627 |         // Test calling tool on nonexistent plugin
1628 |         let request = CallToolRequestParam {
1629 |             name: std::borrow::Cow::Borrowed("nonexistent_plugin-some_tool"),
1630 |             arguments: None,
1631 |         };
1632 | 
1633 |         let ctx = create_test_ctx(&server);
1634 |         let result = server.service().call_tool(request, ctx).await;
1635 |         assert!(result.is_err(), "Should fail with nonexistent plugin");
1636 | 
1637 |         if let Err(error) = result {
1638 |             // Should be a method_not_found error since plugin doesn't exist
1639 |             let error_str = error.to_string();
1640 |             assert!(
1641 |                 error_str.contains("-32601") || error_str.contains("tools/call"),
1642 |                 "Error should indicate method not found: {error}"
1643 |             );
1644 |         }
1645 |         assert_ok!(server.cancel().await);
1646 |         assert_ok!(client.cancel().await);
1647 |     }
1648 | 
1649 |     #[tokio::test]
1650 |     async fn test_plugin_service_call_tool_with_plugin() {
1651 |         let wasm_path = get_test_wasm_path();
1652 |         if !test_wasm_exists() {
1653 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1654 |             return;
1655 |         }
1656 | 
1657 |         let config_content = format!(
1658 |             r#"
1659 | plugins:
1660 |   time_plugin:
1661 |     url: "file://{}"
1662 | "#,
1663 |             wasm_path.display()
1664 |         );
1665 | 
1666 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1667 |         let mut cli = create_test_cli();
1668 |         cli.config_file = Some(config_path);
1669 |         let config = load_config(&cli).await.unwrap();
1670 | 
1671 |         let (server, client) = create_test_pair(
1672 |             PluginService::new(&config).await.unwrap(),
1673 |             ClientInfo::default(),
1674 |         )
1675 |         .await;
1676 |         let Some(plugins) = server.service().plugins.get() else {
1677 |             panic!("Plugins should be initialized");
1678 |         };
1679 |         assert!(!plugins.is_empty(), "Should have loaded plugin");
1680 | 
1681 |         // Test calling the time tool with get_time_utc operation
1682 |         let request = CallToolRequestParam {
1683 |             name: std::borrow::Cow::Borrowed("time_plugin-time"),
1684 |             arguments: Some({
1685 |                 let mut map = serde_json::Map::new();
1686 |                 map.insert(
1687 |                     "name".to_string(),
1688 |                     serde_json::Value::String("get_time_utc".to_string()),
1689 |                 );
1690 |                 map
1691 |             }),
1692 |         };
1693 | 
1694 |         let ctx = create_test_ctx(&server);
1695 |         let result = server.service().call_tool(request, ctx).await;
1696 |         assert!(
1697 |             result.is_ok(),
1698 |             "Should successfully call time tool: {result:?}"
1699 |         );
1700 | 
1701 |         let call_result = result.unwrap();
1702 | 
1703 |         assert!(
1704 |             !call_result.content.is_empty(),
1705 |             "call_result.content should not be empty"
1706 |         );
1707 | 
1708 |         // Test calling with parse_time operation
1709 |         let request = CallToolRequestParam {
1710 |             name: std::borrow::Cow::Borrowed("time_plugin-time"),
1711 |             arguments: Some({
1712 |                 let mut map = serde_json::Map::new();
1713 |                 map.insert(
1714 |                     "name".to_string(),
1715 |                     serde_json::Value::String("parse_time".to_string()),
1716 |                 );
1717 |                 map.insert(
1718 |                     "time_rfc2822".to_string(),
1719 |                     serde_json::Value::String("Wed, 18 Feb 2015 23:16:09 GMT".to_string()),
1720 |                 );
1721 |                 map
1722 |             }),
1723 |         };
1724 | 
1725 |         let ctx = create_test_ctx(&server);
1726 |         let result = server.service().call_tool(request, ctx).await;
1727 |         assert!(
1728 |             result.is_ok(),
1729 |             "Should successfully call parse_time operation: {result:?}"
1730 |         );
1731 | 
1732 |         let call_result = result.unwrap();
1733 |         // Verify the parse_time operation returns content
1734 | 
1735 |         assert!(
1736 |             !call_result.content.is_empty(),
1737 |             "Parse time operation should return non-empty content"
1738 |         );
1739 |         assert_ok!(server.cancel().await);
1740 |         assert_ok!(client.cancel().await);
1741 |     }
1742 | 
1743 |     #[tokio::test]
1744 |     async fn test_plugin_service_call_tool_with_skipped_tool() {
1745 |         let wasm_path = get_test_wasm_path();
1746 |         if !test_wasm_exists() {
1747 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1748 |             return;
1749 |         }
1750 | 
1751 |         let config_content = format!(
1752 |             r#"
1753 | plugins:
1754 |   time_plugin:
1755 |     url: "file://{}"
1756 |     runtime_config:
1757 |       skip_tools:
1758 |         - "time"
1759 | "#,
1760 |             wasm_path.display()
1761 |         );
1762 | 
1763 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1764 |         let mut cli = create_test_cli();
1765 |         cli.config_file = Some(config_path);
1766 |         let config = load_config(&cli).await.unwrap();
1767 | 
1768 |         let (server, client) = create_test_pair(
1769 |             PluginService::new(&config).await.unwrap(),
1770 |             ClientInfo::default(),
1771 |         )
1772 |         .await;
1773 |         let Some(plugins) = server.service().plugins.get() else {
1774 |             panic!("Plugins should be initialized");
1775 |         };
1776 |         assert!(!plugins.is_empty(), "Should have loaded plugin");
1777 | 
1778 |         // Test calling the skipped time tool
1779 |         let request = CallToolRequestParam {
1780 |             name: std::borrow::Cow::Borrowed("time_plugin-time"),
1781 |             arguments: Some({
1782 |                 let mut map = serde_json::Map::new();
1783 |                 map.insert(
1784 |                     "name".to_string(),
1785 |                     serde_json::Value::String("get_time_utc".to_string()),
1786 |                 );
1787 |                 map
1788 |             }),
1789 |         };
1790 | 
1791 |         let ctx = create_test_ctx(&server);
1792 |         let result = server.service().call_tool(request, ctx).await;
1793 |         assert!(result.is_err(), "Should fail when calling skipped tool");
1794 | 
1795 |         if let Err(error) = result {
1796 |             // Should be a method_not_found error since tool is skipped
1797 |             let error_str = error.to_string();
1798 |             assert!(
1799 |                 error_str.contains("-32601") || error_str.contains("tools/call"),
1800 |                 "Error should indicate method not found for skipped tool: {error}"
1801 |             );
1802 |         }
1803 |         assert_ok!(server.cancel().await);
1804 |         assert_ok!(client.cancel().await);
1805 |     }
1806 | 
1807 |     #[test]
1808 |     fn test_plugin_service_ping() {
1809 |         let config = Config::default();
1810 |         let service = create_test_service(config);
1811 | 
1812 |         // Test that the service implements ServerHandler
1813 |         assert_eq!(
1814 |             rmcp::ServerHandler::get_info(&service).server_info.name,
1815 |             "hyper-mcp"
1816 |         );
1817 |     }
1818 | 
1819 |     #[test]
1820 |     fn test_plugin_service_initialize() {
1821 |         let config = Config::default();
1822 |         let service = create_test_service(config);
1823 | 
1824 |         // Test server info
1825 |         let info = rmcp::ServerHandler::get_info(&service);
1826 |         assert_eq!(info.protocol_version, ProtocolVersion::LATEST);
1827 |         assert_eq!(info.server_info.name, "hyper-mcp");
1828 |     }
1829 | 
1830 |     #[test]
1831 |     fn test_plugin_service_methods_exist() {
1832 |         let config = Config::default();
1833 |         let service = create_test_service(config);
1834 | 
1835 |         // Test that ServerHandler methods exist by calling get_info
1836 |         let info = rmcp::ServerHandler::get_info(&service);
1837 |         assert_eq!(info.server_info.name, "hyper-mcp");
1838 |         assert!(info.capabilities.tools.is_some());
1839 |     }
1840 | 
1841 |     #[tokio::test]
1842 |     async fn test_plugin_service_multiple_plugins() {
1843 |         let wasm_path = get_test_wasm_path();
1844 |         if !test_wasm_exists() {
1845 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1846 |             return;
1847 |         }
1848 | 
1849 |         let config_content = format!(
1850 |             r#"
1851 | plugins:
1852 |   time_plugin_1:
1853 |     url: "file://{}"
1854 |     runtime_config:
1855 |       memory_limit: "1MB"
1856 |   time_plugin_2:
1857 |     url: "file://{}"
1858 |     runtime_config:
1859 |       memory_limit: "2MB"
1860 | "#,
1861 |             wasm_path.display(),
1862 |             wasm_path.display()
1863 |         );
1864 | 
1865 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1866 |         let mut cli = create_test_cli();
1867 |         cli.config_file = Some(config_path);
1868 |         let config = load_config(&cli).await.unwrap();
1869 | 
1870 |         let service = PluginService::new(&config).await.unwrap();
1871 |         let Some(plugins) = service.plugins.get() else {
1872 |             panic!("Plugins should be initialized");
1873 |         };
1874 | 
1875 |         assert_eq!(plugins.len(), 2, "Should have loaded two plugins");
1876 |         assert!(plugins.contains_key(&PluginName::from_str("time_plugin_1").unwrap()));
1877 |         assert!(plugins.contains_key(&PluginName::from_str("time_plugin_2").unwrap()));
1878 |     }
1879 | 
1880 |     #[tokio::test]
1881 |     async fn test_plugin_service_call_tool_with_cancellation() {
1882 |         let wasm_path = get_test_wasm_path();
1883 |         if !test_wasm_exists() {
1884 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1885 |             return;
1886 |         }
1887 | 
1888 |         let config_content = format!(
1889 |             r#"
1890 | plugins:
1891 |   time_plugin:
1892 |     url: "file://{}"
1893 |     runtime_config:
1894 |       max_memory_mb: 10
1895 |       max_execution_time_ms: 5000
1896 | "#,
1897 |             wasm_path.to_string_lossy()
1898 |         );
1899 | 
1900 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1901 |         let mut cli = create_test_cli();
1902 |         cli.config_file = Some(config_path);
1903 |         let config = load_config(&cli).await.unwrap();
1904 | 
1905 |         let (server, client) = create_test_pair(
1906 |             PluginService::new(&config).await.unwrap(),
1907 |             ClientInfo::default(),
1908 |         )
1909 |         .await;
1910 | 
1911 |         // Create a cancellation token
1912 |         let cancellation_token = CancellationToken::new();
1913 | 
1914 |         // Create request context with the cancellation token
1915 |         let ctx = RequestContext {
1916 |             ct: cancellation_token.clone(),
1917 |             extensions: Extensions::default(),
1918 |             id: RequestId::Number(1),
1919 |             meta: Meta::default(),
1920 |             peer: server.peer().clone(),
1921 |         };
1922 | 
1923 |         let request = CallToolRequestParam {
1924 |             name: std::borrow::Cow::Borrowed("time_plugin-time"),
1925 |             arguments: Some({
1926 |                 let mut map = serde_json::Map::new();
1927 |                 map.insert(
1928 |                     "name".to_string(),
1929 |                     serde_json::Value::String("get_time_utc".to_string()),
1930 |                 );
1931 |                 map
1932 |             }),
1933 |         };
1934 | 
1935 |         // Cancel the token before executing call_tool to force cancellation path
1936 |         cancellation_token.cancel();
1937 | 
1938 |         // Execute call_tool with the already-cancelled token
1939 |         let result = server.service().call_tool(request, ctx).await;
1940 | 
1941 |         assert!(result.is_err(), "Expected cancellation error");
1942 |         let error = result.unwrap_err();
1943 |         let error_message = error.to_string();
1944 |         assert!(
1945 |             error_message.contains("cancelled") || error_message.contains("canceled"),
1946 |             "Expected cancellation error message, got: {error_message}"
1947 |         );
1948 |         assert_ok!(server.cancel().await);
1949 |         assert_ok!(client.cancel().await);
1950 |     }
1951 | 
1952 |     #[tokio::test]
1953 |     async fn test_plugin_service_list_tools_with_cancellation() {
1954 |         let wasm_path = get_test_wasm_path();
1955 |         if !test_wasm_exists() {
1956 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
1957 |             return;
1958 |         }
1959 | 
1960 |         let config_content = format!(
1961 |             r#"
1962 | plugins:
1963 |   time_plugin:
1964 |     url: "file://{}"
1965 |     runtime_config:
1966 |       max_memory_mb: 10
1967 |       max_execution_time_ms: 5000
1968 | "#,
1969 |             wasm_path.display()
1970 |         );
1971 | 
1972 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
1973 |         let mut cli = create_test_cli();
1974 |         cli.config_file = Some(config_path);
1975 |         let config = load_config(&cli).await.unwrap();
1976 | 
1977 |         let (server, client) = create_test_pair(
1978 |             PluginService::new(&config).await.unwrap(),
1979 |             ClientInfo::default(),
1980 |         )
1981 |         .await;
1982 | 
1983 |         // Create a cancellation token
1984 |         let cancellation_token = CancellationToken::new();
1985 | 
1986 |         // Create request context with the cancellation token
1987 |         let ctx = RequestContext {
1988 |             ct: cancellation_token.clone(),
1989 |             extensions: Extensions::default(),
1990 |             id: RequestId::Number(1),
1991 |             meta: Meta::default(),
1992 |             peer: server.peer().clone(),
1993 |         };
1994 | 
1995 |         // Cancel the token before executing list_tools to force cancellation path
1996 |         cancellation_token.cancel();
1997 | 
1998 |         // Execute list_tools with the already-cancelled token
1999 |         let result = server.service().list_tools(None, ctx).await;
2000 | 
2001 |         assert!(result.is_err(), "Expected cancellation error");
2002 |         let error = result.unwrap_err();
2003 |         let error_message = error.to_string();
2004 |         assert!(
2005 |             error_message.contains("cancelled") || error_message.contains("canceled"),
2006 |             "Expected cancellation error message, got: {error_message}"
2007 |         );
2008 |         assert_ok!(server.cancel().await);
2009 |         assert_ok!(client.cancel().await);
2010 |     }
2011 | 
2012 |     // ========================================================================
2013 |     // Tests for notify_tool_list_changed host function
2014 |     // ========================================================================
2015 | 
2016 |     #[tokio::test]
2017 |     async fn test_notify_tool_list_changed_basic() {
2018 |         let wasm_path = get_tool_list_changed_wasm_path();
2019 |         if !test_tool_list_changed_wasm_exists() {
2020 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2021 |             return;
2022 |         }
2023 | 
2024 |         let config_content = format!(
2025 |             r#"
2026 | plugins:
2027 |   tool_list_changed_plugin:
2028 |     url: "file://{}"
2029 |     runtime_config:
2030 |       max_memory_mb: 10
2031 |       max_execution_time_ms: 5000
2032 | "#,
2033 |             wasm_path.display()
2034 |         );
2035 | 
2036 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2037 |         let mut cli = create_test_cli();
2038 |         cli.config_file = Some(config_path);
2039 |         let config = load_config(&cli).await.unwrap();
2040 | 
2041 |         let (server, client) = create_test_pair(
2042 |             PluginService::new(&config).await.unwrap(),
2043 |             ClientInfo::default(),
2044 |         )
2045 |         .await;
2046 |         let ctx = create_test_ctx(&server);
2047 | 
2048 |         // List tools to verify the plugin loaded and has initial tools
2049 |         let result = server.service().list_tools(None, ctx).await;
2050 |         assert!(result.is_ok(), "list_tools should succeed");
2051 | 
2052 |         let tools = result.unwrap();
2053 |         assert!(
2054 |             !tools.tools.is_empty(),
2055 |             "tool_list_changed_plugin should have at least one tool"
2056 |         );
2057 | 
2058 |         // Verify add_tool exists
2059 |         let tool_names: Vec<String> = tools.tools.iter().map(|t| t.name.to_string()).collect();
2060 |         assert!(
2061 |             tool_names.contains(&"tool_list_changed_plugin-add_tool".to_string()),
2062 |             "add_tool should be in the tool list"
2063 |         );
2064 | 
2065 |         assert_ok!(server.cancel().await);
2066 |         assert_ok!(client.cancel().await);
2067 |     }
2068 | 
2069 |     #[tokio::test]
2070 |     async fn test_notify_tool_list_changed_triggers_on_add() {
2071 |         let wasm_path = get_tool_list_changed_wasm_path();
2072 |         if !test_tool_list_changed_wasm_exists() {
2073 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2074 |             return;
2075 |         }
2076 | 
2077 |         let config_content = format!(
2078 |             r#"
2079 | plugins:
2080 |   tool_list_changed_plugin:
2081 |     url: "file://{}"
2082 |     runtime_config:
2083 |       max_memory_mb: 10
2084 |       max_execution_time_ms: 5000
2085 | "#,
2086 |             wasm_path.display()
2087 |         );
2088 | 
2089 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2090 |         let mut cli = create_test_cli();
2091 |         cli.config_file = Some(config_path);
2092 |         let config = load_config(&cli).await.unwrap();
2093 | 
2094 |         let (server, client) = create_test_pair(
2095 |             PluginService::new(&config).await.unwrap(),
2096 |             TestClient::new(),
2097 |         )
2098 |         .await;
2099 |         let ctx = create_test_ctx(&server);
2100 | 
2101 |         // Get initial tool list
2102 |         let initial_tools = server.service().list_tools(None, ctx.clone()).await;
2103 |         assert!(initial_tools.is_ok());
2104 |         let initial_result = initial_tools.unwrap();
2105 |         let initial_count = initial_result.tools.len();
2106 | 
2107 |         // Call add_tool
2108 |         let add_tool_request = CallToolRequestParam {
2109 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2110 |             arguments: Some(serde_json::Map::new()),
2111 |         };
2112 | 
2113 |         let result = server
2114 |             .service()
2115 |             .call_tool(add_tool_request, ctx.clone())
2116 |             .await;
2117 |         assert!(
2118 |             result.is_ok(),
2119 |             "add_tool should succeed. Error: {:?}",
2120 |             result.err()
2121 |         );
2122 | 
2123 |         assert!(client.service().get_tool_list_changed_count() == 1);
2124 | 
2125 |         // Get updated tool list
2126 |         let ctx2 = create_test_ctx(&server);
2127 |         let updated_tools = server.service().list_tools(None, ctx2).await;
2128 |         assert!(updated_tools.is_ok());
2129 |         let updated_result = updated_tools.unwrap();
2130 |         let updated_count = updated_result.tools.len();
2131 | 
2132 |         // Verify tool list grew
2133 |         assert!(
2134 |             updated_count > initial_count,
2135 |             "Tool count should increase after add_tool. Initial: {}, Updated: {}",
2136 |             initial_count,
2137 |             updated_count
2138 |         );
2139 | 
2140 |         assert_ok!(server.cancel().await);
2141 |         assert_ok!(client.cancel().await);
2142 |     }
2143 | 
2144 |     #[tokio::test]
2145 |     async fn test_notify_tool_list_changed_multiple_additions() {
2146 |         let wasm_path = get_tool_list_changed_wasm_path();
2147 |         if !test_tool_list_changed_wasm_exists() {
2148 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2149 |             return;
2150 |         }
2151 | 
2152 |         let config_content = format!(
2153 |             r#"
2154 | plugins:
2155 |   tool_list_changed_plugin:
2156 |     url: "file://{}"
2157 |     runtime_config:
2158 |       max_memory_mb: 10
2159 |       max_execution_time_ms: 5000
2160 | "#,
2161 |             wasm_path.display()
2162 |         );
2163 | 
2164 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2165 |         let mut cli = create_test_cli();
2166 |         cli.config_file = Some(config_path);
2167 |         let config = load_config(&cli).await.unwrap();
2168 | 
2169 |         let (server, client) = create_test_pair(
2170 |             PluginService::new(&config).await.unwrap(),
2171 |             TestClient::new(),
2172 |         )
2173 |         .await;
2174 | 
2175 |         // Call add_tool three times
2176 |         for i in 1..=3 {
2177 |             let ctx = create_test_ctx(&server);
2178 |             let add_tool_request = CallToolRequestParam {
2179 |                 name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2180 |                 arguments: Some(serde_json::Map::new()),
2181 |             };
2182 | 
2183 |             let result = server.service().call_tool(add_tool_request, ctx).await;
2184 |             assert!(result.is_ok(), "add_tool call {} should succeed", i);
2185 |         }
2186 | 
2187 |         assert!(client.service().get_tool_list_changed_count() == 3);
2188 | 
2189 |         // Get final tool list
2190 |         let ctx = create_test_ctx(&server);
2191 |         let final_tools = server.service().list_tools(None, ctx).await;
2192 |         assert!(final_tools.is_ok());
2193 | 
2194 |         let final_result = final_tools.unwrap();
2195 |         let tool_names: Vec<String> = final_result
2196 |             .tools
2197 |             .iter()
2198 |             .map(|t| t.name.to_string())
2199 |             .collect();
2200 | 
2201 |         // Verify all three tools exist
2202 |         assert!(
2203 |             tool_names.contains(&"tool_list_changed_plugin-tool_1".to_string()),
2204 |             "tool_1 should exist in tool list"
2205 |         );
2206 |         assert!(
2207 |             tool_names.contains(&"tool_list_changed_plugin-tool_2".to_string()),
2208 |             "tool_2 should exist in tool list"
2209 |         );
2210 |         assert!(
2211 |             tool_names.contains(&"tool_list_changed_plugin-tool_3".to_string()),
2212 |             "tool_3 should exist in tool list"
2213 |         );
2214 | 
2215 |         assert_ok!(server.cancel().await);
2216 |         assert_ok!(client.cancel().await);
2217 |     }
2218 | 
2219 |     #[tokio::test]
2220 |     async fn test_notify_tool_list_changed_tool_callable_after_add() {
2221 |         let wasm_path = get_tool_list_changed_wasm_path();
2222 |         if !test_tool_list_changed_wasm_exists() {
2223 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2224 |             return;
2225 |         }
2226 | 
2227 |         let config_content = format!(
2228 |             r#"
2229 | plugins:
2230 |   tool_list_changed_plugin:
2231 |     url: "file://{}"
2232 |     runtime_config:
2233 |       max_memory_mb: 10
2234 |       max_execution_time_ms: 5000
2235 | "#,
2236 |             wasm_path.display()
2237 |         );
2238 | 
2239 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2240 |         let mut cli = create_test_cli();
2241 |         cli.config_file = Some(config_path);
2242 |         let config = load_config(&cli).await.unwrap();
2243 | 
2244 |         let (server, client) = create_test_pair(
2245 |             PluginService::new(&config).await.unwrap(),
2246 |             ClientInfo::default(),
2247 |         )
2248 |         .await;
2249 | 
2250 |         // Add a tool
2251 |         let ctx = create_test_ctx(&server);
2252 |         let add_tool_request = CallToolRequestParam {
2253 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2254 |             arguments: Some(serde_json::Map::new()),
2255 |         };
2256 | 
2257 |         let result = server.service().call_tool(add_tool_request, ctx).await;
2258 |         assert!(result.is_ok(), "add_tool should succeed");
2259 | 
2260 |         // Call the newly created tool_1
2261 |         let ctx2 = create_test_ctx(&server);
2262 |         let tool_request = CallToolRequestParam {
2263 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-tool_1"),
2264 |             arguments: Some(serde_json::Map::new()),
2265 |         };
2266 | 
2267 |         let result = server.service().call_tool(tool_request, ctx2).await;
2268 |         assert!(result.is_ok(), "tool_1 should be callable after creation");
2269 | 
2270 |         let response = result.unwrap();
2271 |         assert!(!response.content.is_empty(), "tool_1 should return content");
2272 | 
2273 |         assert_ok!(server.cancel().await);
2274 |         assert_ok!(client.cancel().await);
2275 |     }
2276 | 
2277 |     #[tokio::test]
2278 |     async fn test_notify_tool_list_changed_response_format() {
2279 |         let wasm_path = get_tool_list_changed_wasm_path();
2280 |         if !test_tool_list_changed_wasm_exists() {
2281 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2282 |             return;
2283 |         }
2284 | 
2285 |         let config_content = format!(
2286 |             r#"
2287 | plugins:
2288 |   tool_list_changed_plugin:
2289 |     url: "file://{}"
2290 |     runtime_config:
2291 |       max_memory_mb: 10
2292 |       max_execution_time_ms: 5000
2293 | "#,
2294 |             wasm_path.display()
2295 |         );
2296 | 
2297 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2298 |         let mut cli = create_test_cli();
2299 |         cli.config_file = Some(config_path);
2300 |         let config = load_config(&cli).await.unwrap();
2301 | 
2302 |         let (server, client) = create_test_pair(
2303 |             PluginService::new(&config).await.unwrap(),
2304 |             ClientInfo::default(),
2305 |         )
2306 |         .await;
2307 |         let ctx = create_test_ctx(&server);
2308 | 
2309 |         // Call add_tool and verify response format
2310 |         let add_tool_request = CallToolRequestParam {
2311 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2312 |             arguments: Some(serde_json::Map::new()),
2313 |         };
2314 | 
2315 |         let result = server.service().call_tool(add_tool_request, ctx).await;
2316 |         assert!(result.is_ok());
2317 | 
2318 |         let response = result.unwrap();
2319 |         assert!(!response.content.is_empty(), "Response should have content");
2320 | 
2321 |         // Just verify that we got content back - the content structure is handled by rmcp
2322 |         assert_eq!(
2323 |             response.is_error,
2324 |             Some(false),
2325 |             "Response should not be an error"
2326 |         );
2327 | 
2328 |         assert_ok!(server.cancel().await);
2329 |         assert_ok!(client.cancel().await);
2330 |     }
2331 | 
2332 |     #[tokio::test]
2333 |     async fn test_notify_tool_list_changed_sequential_tool_numbers() {
2334 |         let wasm_path = get_tool_list_changed_wasm_path();
2335 |         if !test_tool_list_changed_wasm_exists() {
2336 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2337 |             return;
2338 |         }
2339 | 
2340 |         let config_content = format!(
2341 |             r#"
2342 | plugins:
2343 |   tool_list_changed_plugin:
2344 |     url: "file://{}"
2345 |     runtime_config:
2346 |       max_memory_mb: 10
2347 |       max_execution_time_ms: 5000
2348 | "#,
2349 |             wasm_path.display()
2350 |         );
2351 | 
2352 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2353 |         let mut cli = create_test_cli();
2354 |         cli.config_file = Some(config_path);
2355 |         let config = load_config(&cli).await.unwrap();
2356 | 
2357 |         let (server, client) = create_test_pair(
2358 |             PluginService::new(&config).await.unwrap(),
2359 |             ClientInfo::default(),
2360 |         )
2361 |         .await;
2362 | 
2363 |         // Add 5 tools and verify tool_count in responses
2364 |         for expected_count in 1..=5 {
2365 |             let ctx = create_test_ctx(&server);
2366 |             let add_tool_request = CallToolRequestParam {
2367 |                 name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2368 |                 arguments: Some(serde_json::Map::new()),
2369 |             };
2370 | 
2371 |             let result = server.service().call_tool(add_tool_request, ctx).await;
2372 |             assert!(result.is_ok());
2373 | 
2374 |             let response = result.unwrap();
2375 |             // Verify response indicates success
2376 |             assert_eq!(
2377 |                 response.is_error,
2378 |                 Some(false),
2379 |                 "add_tool call {} should succeed",
2380 |                 expected_count
2381 |             );
2382 |         }
2383 | 
2384 |         assert_ok!(server.cancel().await);
2385 |         assert_ok!(client.cancel().await);
2386 |     }
2387 | 
2388 |     #[tokio::test]
2389 |     async fn test_notify_tool_list_changed_invalid_tool_call() {
2390 |         let wasm_path = get_tool_list_changed_wasm_path();
2391 |         if !test_tool_list_changed_wasm_exists() {
2392 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2393 |             return;
2394 |         }
2395 | 
2396 |         let config_content = format!(
2397 |             r#"
2398 | plugins:
2399 |   tool_list_changed_plugin:
2400 |     url: "file://{}"
2401 |     runtime_config:
2402 |       max_memory_mb: 10
2403 |       max_execution_time_ms: 5000
2404 | "#,
2405 |             wasm_path.display()
2406 |         );
2407 | 
2408 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2409 |         let mut cli = create_test_cli();
2410 |         cli.config_file = Some(config_path);
2411 |         let config = load_config(&cli).await.unwrap();
2412 | 
2413 |         let (server, client) = create_test_pair(
2414 |             PluginService::new(&config).await.unwrap(),
2415 |             ClientInfo::default(),
2416 |         )
2417 |         .await;
2418 | 
2419 |         // Try to call a tool that doesn't exist yet (tool_5 when only tool_1 exists)
2420 |         let ctx = create_test_ctx(&server);
2421 |         let invalid_tool_request = CallToolRequestParam {
2422 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-tool_5"),
2423 |             arguments: Some(serde_json::Map::new()),
2424 |         };
2425 | 
2426 |         let result = server.service().call_tool(invalid_tool_request, ctx).await;
2427 |         assert!(
2428 |             result.is_ok(),
2429 |             "Tool call should complete, but indicate error"
2430 |         );
2431 | 
2432 |         let response = result.unwrap();
2433 |         assert!(
2434 |             response.is_error == Some(true),
2435 |             "Calling non-existent tool should return error"
2436 |         );
2437 | 
2438 |         assert_ok!(server.cancel().await);
2439 |         assert_ok!(client.cancel().await);
2440 |     }
2441 | 
2442 |     #[tokio::test]
2443 |     async fn test_notify_tool_list_changed_add_tool_failure_propagates() {
2444 |         let wasm_path = get_tool_list_changed_wasm_path();
2445 |         if !test_tool_list_changed_wasm_exists() {
2446 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2447 |             return;
2448 |         }
2449 | 
2450 |         let config_content = format!(
2451 |             r#"
2452 | plugins:
2453 |   tool_list_changed_plugin:
2454 |     url: "file://{}"
2455 |     runtime_config:
2456 |       max_memory_mb: 10
2457 |       max_execution_time_ms: 5000
2458 | "#,
2459 |             wasm_path.display()
2460 |         );
2461 | 
2462 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2463 |         let mut cli = create_test_cli();
2464 |         cli.config_file = Some(config_path);
2465 |         let config = load_config(&cli).await.unwrap();
2466 | 
2467 |         let (server, client) = create_test_pair(
2468 |             PluginService::new(&config).await.unwrap(),
2469 |             ClientInfo::default(),
2470 |         )
2471 |         .await;
2472 | 
2473 |         // Call add_tool with additional arguments (should still work but they're ignored)
2474 |         let ctx = create_test_ctx(&server);
2475 |         let mut args = serde_json::Map::new();
2476 |         args.insert(
2477 |             "extra_param".to_string(),
2478 |             serde_json::Value::String("should_be_ignored".to_string()),
2479 |         );
2480 | 
2481 |         let add_tool_request = CallToolRequestParam {
2482 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2483 |             arguments: Some(args),
2484 |         };
2485 | 
2486 |         let result = server.service().call_tool(add_tool_request, ctx).await;
2487 |         assert!(
2488 |             result.is_ok(),
2489 |             "add_tool should succeed even with extra params"
2490 |         );
2491 | 
2492 |         assert_ok!(server.cancel().await);
2493 |         assert_ok!(client.cancel().await);
2494 |     }
2495 | 
2496 |     #[tokio::test]
2497 |     async fn test_notify_tool_list_changed_new_tools_appear_in_list() {
2498 |         let wasm_path = get_tool_list_changed_wasm_path();
2499 |         if !test_tool_list_changed_wasm_exists() {
2500 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2501 |             return;
2502 |         }
2503 | 
2504 |         let config_content = format!(
2505 |             r#"
2506 | plugins:
2507 |   tool_list_changed_plugin:
2508 |     url: "file://{}"
2509 |     runtime_config:
2510 |       max_memory_mb: 10
2511 |       max_execution_time_ms: 5000
2512 | "#,
2513 |             wasm_path.display()
2514 |         );
2515 | 
2516 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2517 |         let mut cli = create_test_cli();
2518 |         cli.config_file = Some(config_path);
2519 |         let config = load_config(&cli).await.unwrap();
2520 | 
2521 |         let (server, client) = create_test_pair(
2522 |             PluginService::new(&config).await.unwrap(),
2523 |             ClientInfo::default(),
2524 |         )
2525 |         .await;
2526 | 
2527 |         // Get initial tools
2528 |         let ctx = create_test_ctx(&server);
2529 |         let initial_result = server.service().list_tools(None, ctx).await;
2530 |         assert!(initial_result.is_ok());
2531 |         let initial_tools = initial_result.unwrap();
2532 |         let initial_names: Vec<String> = initial_tools
2533 |             .tools
2534 |             .iter()
2535 |             .map(|t| t.name.to_string())
2536 |             .collect();
2537 | 
2538 |         // Verify tool_1 doesn't exist yet
2539 |         assert!(
2540 |             !initial_names.contains(&"tool_list_changed_plugin-tool_1".to_string()),
2541 |             "tool_1 should not exist initially"
2542 |         );
2543 | 
2544 |         // Add tool_1
2545 |         let ctx = create_test_ctx(&server);
2546 |         let add_tool_request = CallToolRequestParam {
2547 |             name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2548 |             arguments: Some(serde_json::Map::new()),
2549 |         };
2550 |         let _ = server.service().call_tool(add_tool_request, ctx).await;
2551 | 
2552 |         // Get updated tools
2553 |         let ctx = create_test_ctx(&server);
2554 |         let updated_result = server.service().list_tools(None, ctx).await;
2555 |         assert!(updated_result.is_ok());
2556 |         let updated_tools = updated_result.unwrap();
2557 |         let updated_names: Vec<String> = updated_tools
2558 |             .tools
2559 |             .iter()
2560 |             .map(|t| t.name.to_string())
2561 |             .collect();
2562 | 
2563 |         // Verify tool_1 exists now
2564 |         assert!(
2565 |             updated_names.contains(&"tool_list_changed_plugin-tool_1".to_string()),
2566 |             "tool_1 should exist after add_tool"
2567 |         );
2568 | 
2569 |         assert_ok!(server.cancel().await);
2570 |         assert_ok!(client.cancel().await);
2571 |     }
2572 | 
2573 |     #[tokio::test]
2574 |     async fn test_notify_tool_list_changed_tool_descriptions() {
2575 |         let wasm_path = get_tool_list_changed_wasm_path();
2576 |         if !test_tool_list_changed_wasm_exists() {
2577 |             println!("Skipping test - tool-list-changed WASM file not found at {wasm_path:?}");
2578 |             return;
2579 |         }
2580 | 
2581 |         let config_content = format!(
2582 |             r#"
2583 | plugins:
2584 |   tool_list_changed_plugin:
2585 |     url: "file://{}"
2586 |     runtime_config:
2587 |       max_memory_mb: 10
2588 |       max_execution_time_ms: 5000
2589 | "#,
2590 |             wasm_path.display()
2591 |         );
2592 | 
2593 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2594 |         let mut cli = create_test_cli();
2595 |         cli.config_file = Some(config_path);
2596 |         let config = load_config(&cli).await.unwrap();
2597 | 
2598 |         let (server, client) = create_test_pair(
2599 |             PluginService::new(&config).await.unwrap(),
2600 |             ClientInfo::default(),
2601 |         )
2602 |         .await;
2603 | 
2604 |         // Add two tools
2605 |         for _ in 0..2 {
2606 |             let ctx = create_test_ctx(&server);
2607 |             let add_tool_request = CallToolRequestParam {
2608 |                 name: std::borrow::Cow::Borrowed("tool_list_changed_plugin-add_tool"),
2609 |                 arguments: Some(serde_json::Map::new()),
2610 |             };
2611 |             let _ = server.service().call_tool(add_tool_request, ctx).await;
2612 |         }
2613 | 
2614 |         // Get tool list and verify descriptions
2615 |         let ctx = create_test_ctx(&server);
2616 |         let result = server.service().list_tools(None, ctx).await;
2617 |         assert!(result.is_ok());
2618 | 
2619 |         let tools = result.unwrap();
2620 |         let tool_map: std::collections::HashMap<String, &Tool> = tools
2621 |             .tools
2622 |             .iter()
2623 |             .map(|t| (t.name.to_string(), t))
2624 |             .collect();
2625 | 
2626 |         // Verify tool descriptions exist and are meaningful
2627 |         if let Some(add_tool) = tool_map.get("tool_list_changed_plugin-add_tool") {
2628 |             if let Some(desc) = &add_tool.description {
2629 |                 assert!(!desc.is_empty(), "add_tool should have a description");
2630 |                 assert!(
2631 |                     desc.to_lowercase().contains("add"),
2632 |                     "add_tool description should mention 'add'"
2633 |                 );
2634 |             }
2635 |         }
2636 | 
2637 |         if let Some(tool_1) = tool_map.get("tool_list_changed_plugin-tool_1") {
2638 |             if let Some(desc) = &tool_1.description {
2639 |                 assert!(!desc.is_empty(), "tool_1 should have a description");
2640 |                 assert!(
2641 |                     desc.to_lowercase().contains("tool"),
2642 |                     "tool_1 description should mention 'tool'"
2643 |                 );
2644 |             }
2645 |         }
2646 | 
2647 |         assert_ok!(server.cancel().await);
2648 |         assert_ok!(client.cancel().await);
2649 |     }
2650 | 
2651 |     // Comprehensive tests for rstime v2 plugin
2652 | 
2653 |     #[tokio::test]
2654 |     async fn test_rstime_list_tools() {
2655 |         let wasm_path = get_rstime_wasm_path();
2656 |         if !test_rstime_wasm_exists() {
2657 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
2658 |             return;
2659 |         }
2660 | 
2661 |         let config_content = format!(
2662 |             r#"
2663 | plugins:
2664 |   rstime:
2665 |     url: "file://{}"
2666 |     runtime_config:
2667 |       allowed_hosts:
2668 |         - "www.timezoneconverter.com"
2669 | "#,
2670 |             wasm_path.display()
2671 |         );
2672 | 
2673 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2674 |         let mut cli = create_test_cli();
2675 |         cli.config_file = Some(config_path);
2676 |         let config = load_config(&cli).await.unwrap();
2677 | 
2678 |         let (server, client) = create_test_pair(
2679 |             PluginService::new(&config).await.unwrap(),
2680 |             ClientInfo::default(),
2681 |         )
2682 |         .await;
2683 | 
2684 |         let Some(plugins) = server.service().plugins.get() else {
2685 |             panic!("Plugins should be initialized");
2686 |         };
2687 |         assert!(!plugins.is_empty(), "Should have loaded rstime plugin");
2688 | 
2689 |         let request = None;
2690 |         let ctx = create_test_ctx(&server);
2691 |         let result = server.service().list_tools(request, ctx).await;
2692 |         assert!(result.is_ok(), "list_tools should succeed");
2693 | 
2694 |         let list_tools_result = result.unwrap();
2695 |         assert!(
2696 |             !list_tools_result.tools.is_empty(),
2697 |             "Should have tools from rstime plugin"
2698 |         );
2699 | 
2700 |         // Verify expected tools: get_time and parse_time
2701 |         let tool_names: Vec<String> = list_tools_result
2702 |             .tools
2703 |             .iter()
2704 |             .map(|tool| tool.name.to_string())
2705 |             .collect();
2706 | 
2707 |         assert!(
2708 |             tool_names.iter().any(|name| name.contains("get_time")),
2709 |             "Should have get_time tool"
2710 |         );
2711 |         assert!(
2712 |             tool_names.iter().any(|name| name.contains("parse_time")),
2713 |             "Should have parse_time tool"
2714 |         );
2715 | 
2716 |         // Verify tool properties
2717 |         for tool in &list_tools_result.tools {
2718 |             assert!(!tool.name.is_empty(), "Tool should have a name");
2719 |             assert!(tool.description.is_some(), "Tool should have a description");
2720 |             // Just verify the tool exists, schema validation happens at plugin level
2721 |         }
2722 | 
2723 |         assert_ok!(server.cancel().await);
2724 |         assert_ok!(client.cancel().await);
2725 |     }
2726 | 
2727 |     #[tokio::test]
2728 |     async fn test_rstime_list_prompts() {
2729 |         let wasm_path = get_rstime_wasm_path();
2730 |         if !test_rstime_wasm_exists() {
2731 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
2732 |             return;
2733 |         }
2734 | 
2735 |         let config_content = format!(
2736 |             r#"
2737 | plugins:
2738 |   rstime:
2739 |     url: "file://{}"
2740 |     runtime_config:
2741 |       allowed_hosts:
2742 |         - "www.timezoneconverter.com"
2743 | "#,
2744 |             wasm_path.display()
2745 |         );
2746 | 
2747 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2748 |         let mut cli = create_test_cli();
2749 |         cli.config_file = Some(config_path);
2750 |         let config = load_config(&cli).await.unwrap();
2751 | 
2752 |         let (server, client) = create_test_pair(
2753 |             PluginService::new(&config).await.unwrap(),
2754 |             ClientInfo::default(),
2755 |         )
2756 |         .await;
2757 | 
2758 |         let Some(plugins) = server.service().plugins.get() else {
2759 |             panic!("Plugins should be initialized");
2760 |         };
2761 |         assert!(!plugins.is_empty(), "Should have loaded rstime plugin");
2762 | 
2763 |         let request = None;
2764 |         let ctx = create_test_ctx(&server);
2765 |         let result = server.service().list_prompts(request, ctx).await;
2766 |         assert!(result.is_ok(), "list_prompts should succeed");
2767 | 
2768 |         let list_prompts_result = result.unwrap();
2769 |         assert!(
2770 |             !list_prompts_result.prompts.is_empty(),
2771 |             "Should have prompts from rstime plugin"
2772 |         );
2773 | 
2774 |         // Verify the get_time_with_timezone prompt exists
2775 |         let prompt_names: Vec<String> = list_prompts_result
2776 |             .prompts
2777 |             .iter()
2778 |             .map(|p| p.name.to_string())
2779 |             .collect();
2780 | 
2781 |         assert!(
2782 |             prompt_names
2783 |                 .iter()
2784 |                 .any(|name| name.contains("get_time_with_timezone")),
2785 |             "Should have get_time_with_timezone prompt"
2786 |         );
2787 | 
2788 |         // Verify prompt properties
2789 |         for prompt in &list_prompts_result.prompts {
2790 |             assert!(!prompt.name.is_empty(), "Prompt should have a name");
2791 |             assert!(
2792 |                 prompt.description.is_some(),
2793 |                 "Prompt should have a description"
2794 |             );
2795 |             assert!(prompt.arguments.is_some(), "Prompt should have arguments");
2796 |         }
2797 | 
2798 |         assert_ok!(server.cancel().await);
2799 |         assert_ok!(client.cancel().await);
2800 |     }
2801 | 
2802 |     #[tokio::test]
2803 |     async fn test_rstime_list_resource_templates() {
2804 |         let wasm_path = get_rstime_wasm_path();
2805 |         if !test_rstime_wasm_exists() {
2806 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
2807 |             return;
2808 |         }
2809 | 
2810 |         let config_content = format!(
2811 |             r#"
2812 | plugins:
2813 |   rstime:
2814 |     url: "file://{}"
2815 |     runtime_config:
2816 |       allowed_hosts:
2817 |         - "www.timezoneconverter.com"
2818 | "#,
2819 |             wasm_path.display()
2820 |         );
2821 | 
2822 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2823 |         let mut cli = create_test_cli();
2824 |         cli.config_file = Some(config_path);
2825 |         let config = load_config(&cli).await.unwrap();
2826 | 
2827 |         let (server, client) = create_test_pair(
2828 |             PluginService::new(&config).await.unwrap(),
2829 |             ClientInfo::default(),
2830 |         )
2831 |         .await;
2832 | 
2833 |         let Some(plugins) = server.service().plugins.get() else {
2834 |             panic!("Plugins should be initialized");
2835 |         };
2836 |         assert!(!plugins.is_empty(), "Should have loaded rstime plugin");
2837 | 
2838 |         let request = None;
2839 |         let ctx = create_test_ctx(&server);
2840 |         let result = server.service().list_resource_templates(request, ctx).await;
2841 |         assert!(result.is_ok(), "list_resource_templates should succeed");
2842 | 
2843 |         let list_templates_result = result.unwrap();
2844 |         assert!(
2845 |             !list_templates_result.resource_templates.is_empty(),
2846 |             "Should have resource templates from rstime plugin"
2847 |         );
2848 | 
2849 |         // Verify the time_zone_converter template exists
2850 |         let template_names: Vec<String> = list_templates_result
2851 |             .resource_templates
2852 |             .iter()
2853 |             .map(|t| t.name.to_string())
2854 |             .collect();
2855 | 
2856 |         assert!(
2857 |             template_names
2858 |                 .iter()
2859 |                 .any(|name| name.contains("time_zone_converter")),
2860 |             "Should have time_zone_converter resource template"
2861 |         );
2862 | 
2863 |         // Verify template properties
2864 |         for template in &list_templates_result.resource_templates {
2865 |             assert!(!template.name.is_empty(), "Template should have a name");
2866 |             assert!(
2867 |                 template.description.is_some(),
2868 |                 "Template should have a description"
2869 |             );
2870 |             assert!(
2871 |                 template.uri_template.contains("{timezone}"),
2872 |                 "Template should have URI template with timezone placeholder"
2873 |             );
2874 |             assert!(
2875 |                 template.mime_type.is_some(),
2876 |                 "Template should have a MIME type"
2877 |             );
2878 |         }
2879 | 
2880 |         assert_ok!(server.cancel().await);
2881 |         assert_ok!(client.cancel().await);
2882 |     }
2883 | 
2884 |     #[tokio::test]
2885 |     async fn test_rstime_list_resources() {
2886 |         let wasm_path = get_rstime_wasm_path();
2887 |         if !test_rstime_wasm_exists() {
2888 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
2889 |             return;
2890 |         }
2891 | 
2892 |         let config_content = format!(
2893 |             r#"
2894 | plugins:
2895 |   rstime:
2896 |     url: "file://{}"
2897 |     runtime_config:
2898 |       allowed_hosts:
2899 |         - "www.timezoneconverter.com"
2900 | "#,
2901 |             wasm_path.display()
2902 |         );
2903 | 
2904 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2905 |         let mut cli = create_test_cli();
2906 |         cli.config_file = Some(config_path);
2907 |         let config = load_config(&cli).await.unwrap();
2908 | 
2909 |         let (server, client) = create_test_pair(
2910 |             PluginService::new(&config).await.unwrap(),
2911 |             ClientInfo::default(),
2912 |         )
2913 |         .await;
2914 | 
2915 |         let Some(plugins) = server.service().plugins.get() else {
2916 |             panic!("Plugins should be initialized");
2917 |         };
2918 |         assert!(!plugins.is_empty(), "Should have loaded rstime plugin");
2919 | 
2920 |         let request = None;
2921 |         let ctx = create_test_ctx(&server);
2922 |         let result = server.service().list_resources(request, ctx).await;
2923 |         assert!(result.is_ok(), "list_resources should succeed");
2924 | 
2925 |         let list_resources_result = result.unwrap();
2926 |         // rstime plugin returns empty resources list, which is expected
2927 |         assert_eq!(
2928 |             list_resources_result.resources.len(),
2929 |             0,
2930 |             "rstime should return empty resources"
2931 |         );
2932 | 
2933 |         assert_ok!(server.cancel().await);
2934 |         assert_ok!(client.cancel().await);
2935 |     }
2936 | 
2937 |     #[tokio::test]
2938 |     async fn test_rstime_call_get_time_tool() {
2939 |         let wasm_path = get_rstime_wasm_path();
2940 |         if !test_rstime_wasm_exists() {
2941 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
2942 |             return;
2943 |         }
2944 | 
2945 |         let config_content = format!(
2946 |             r#"
2947 | plugins:
2948 |   rstime:
2949 |     url: "file://{}"
2950 | "#,
2951 |             wasm_path.display()
2952 |         );
2953 | 
2954 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
2955 |         let mut cli = create_test_cli();
2956 |         cli.config_file = Some(config_path);
2957 |         let config = load_config(&cli).await.unwrap();
2958 | 
2959 |         let (server, client) = create_test_pair(
2960 |             PluginService::new(&config).await.unwrap(),
2961 |             ClientInfo::default(),
2962 |         )
2963 |         .await;
2964 | 
2965 |         // Test calling get_time with UTC (default)
2966 |         let request = CallToolRequestParam {
2967 |             name: std::borrow::Cow::Owned("rstime-get_time".to_string()),
2968 |             arguments: None,
2969 |         };
2970 | 
2971 |         let ctx = create_test_ctx(&server);
2972 |         let result = server.service().call_tool(request, ctx).await;
2973 |         assert!(
2974 |             result.is_ok(),
2975 |             "Should successfully call get_time tool: {result:?}"
2976 |         );
2977 | 
2978 |         let call_result = result.unwrap();
2979 |         assert!(
2980 |             !call_result.content.is_empty(),
2981 |             "get_time should return content"
2982 |         );
2983 | 
2984 |         // Verify structured content contains current_time
2985 |         assert!(
2986 |             call_result.structured_content.is_some(),
2987 |             "Should have structured content"
2988 |         );
2989 | 
2990 |         let structured = call_result.structured_content.unwrap();
2991 |         let has_current_time = if let Some(map) = structured.as_object() {
2992 |             map.contains_key("current_time")
2993 |         } else {
2994 |             false
2995 |         };
2996 |         assert!(
2997 |             has_current_time,
2998 |             "Structured content should have current_time field"
2999 |         );
3000 | 
3001 |         assert_ok!(server.cancel().await);
3002 |         assert_ok!(client.cancel().await);
3003 |     }
3004 | 
3005 |     #[tokio::test]
3006 |     async fn test_rstime_call_get_time_with_timezone() {
3007 |         let wasm_path = get_rstime_wasm_path();
3008 |         if !test_rstime_wasm_exists() {
3009 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3010 |             return;
3011 |         }
3012 | 
3013 |         let config_content = format!(
3014 |             r#"
3015 | plugins:
3016 |   rstime:
3017 |     url: "file://{}"
3018 | "#,
3019 |             wasm_path.display()
3020 |         );
3021 | 
3022 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3023 |         let mut cli = create_test_cli();
3024 |         cli.config_file = Some(config_path);
3025 |         let config = load_config(&cli).await.unwrap();
3026 | 
3027 |         let (server, client) = create_test_pair(
3028 |             PluginService::new(&config).await.unwrap(),
3029 |             ClientInfo::default(),
3030 |         )
3031 |         .await;
3032 | 
3033 |         // Test calling get_time with a specific timezone
3034 |         let mut args = serde_json::Map::new();
3035 |         args.insert(
3036 |             "timezone".to_string(),
3037 |             serde_json::Value::String("America/New_York".to_string()),
3038 |         );
3039 | 
3040 |         let request = CallToolRequestParam {
3041 |             name: std::borrow::Cow::Owned("rstime-get_time".to_string()),
3042 |             arguments: Some(args),
3043 |         };
3044 | 
3045 |         let ctx = create_test_ctx(&server);
3046 |         let result = server.service().call_tool(request, ctx).await;
3047 |         assert!(
3048 |             result.is_ok(),
3049 |             "Should successfully call get_time with timezone: {result:?}"
3050 |         );
3051 | 
3052 |         let call_result = result.unwrap();
3053 |         assert!(
3054 |             !call_result.content.is_empty(),
3055 |             "get_time with timezone should return content"
3056 |         );
3057 |         assert!(
3058 |             call_result.structured_content.is_some(),
3059 |             "Should have structured content"
3060 |         );
3061 | 
3062 |         assert_ok!(server.cancel().await);
3063 |         assert_ok!(client.cancel().await);
3064 |     }
3065 | 
3066 |     #[tokio::test]
3067 |     async fn test_rstime_call_parse_time_tool() {
3068 |         let wasm_path = get_rstime_wasm_path();
3069 |         if !test_rstime_wasm_exists() {
3070 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3071 |             return;
3072 |         }
3073 | 
3074 |         let config_content = format!(
3075 |             r#"
3076 | plugins:
3077 |   rstime:
3078 |     url: "file://{}"
3079 | "#,
3080 |             wasm_path.display()
3081 |         );
3082 | 
3083 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3084 |         let mut cli = create_test_cli();
3085 |         cli.config_file = Some(config_path);
3086 |         let config = load_config(&cli).await.unwrap();
3087 | 
3088 |         let (server, client) = create_test_pair(
3089 |             PluginService::new(&config).await.unwrap(),
3090 |             ClientInfo::default(),
3091 |         )
3092 |         .await;
3093 | 
3094 |         // Test calling parse_time with a valid RFC2822 timestamp
3095 |         let mut args = serde_json::Map::new();
3096 |         args.insert(
3097 |             "time".to_string(),
3098 |             serde_json::Value::String("Wed, 18 Feb 2015 23:16:09 GMT".to_string()),
3099 |         );
3100 | 
3101 |         let request = CallToolRequestParam {
3102 |             name: std::borrow::Cow::Owned("rstime-parse_time".to_string()),
3103 |             arguments: Some(args),
3104 |         };
3105 | 
3106 |         let ctx = create_test_ctx(&server);
3107 |         let result = server.service().call_tool(request, ctx).await;
3108 |         assert!(
3109 |             result.is_ok(),
3110 |             "Should successfully call parse_time tool: {result:?}"
3111 |         );
3112 | 
3113 |         let call_result = result.unwrap();
3114 |         assert!(
3115 |             !call_result.content.is_empty(),
3116 |             "parse_time should return content"
3117 |         );
3118 | 
3119 |         // Verify it parsed correctly and returned a timestamp
3120 |         assert!(
3121 |             call_result.structured_content.is_some(),
3122 |             "Should have structured content"
3123 |         );
3124 | 
3125 |         let structured = call_result.structured_content.unwrap();
3126 |         let has_timestamp = if let Some(map) = structured.as_object() {
3127 |             map.contains_key("timestamp")
3128 |         } else {
3129 |             false
3130 |         };
3131 |         assert!(
3132 |             has_timestamp,
3133 |             "Structured content should have timestamp field"
3134 |         );
3135 | 
3136 |         assert_ok!(server.cancel().await);
3137 |         assert_ok!(client.cancel().await);
3138 |     }
3139 | 
3140 |     #[tokio::test]
3141 |     async fn test_rstime_call_parse_time_invalid() {
3142 |         let wasm_path = get_rstime_wasm_path();
3143 |         if !test_rstime_wasm_exists() {
3144 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3145 |             return;
3146 |         }
3147 | 
3148 |         let config_content = format!(
3149 |             r#"
3150 | plugins:
3151 |   rstime:
3152 |     url: "file://{}"
3153 | "#,
3154 |             wasm_path.display()
3155 |         );
3156 | 
3157 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3158 |         let mut cli = create_test_cli();
3159 |         cli.config_file = Some(config_path);
3160 |         let config = load_config(&cli).await.unwrap();
3161 | 
3162 |         let (server, client) = create_test_pair(
3163 |             PluginService::new(&config).await.unwrap(),
3164 |             ClientInfo::default(),
3165 |         )
3166 |         .await;
3167 | 
3168 |         // Test calling parse_time with invalid timestamp
3169 |         let mut args = serde_json::Map::new();
3170 |         args.insert(
3171 |             "time".to_string(),
3172 |             serde_json::Value::String("invalid timestamp".to_string()),
3173 |         );
3174 | 
3175 |         let request = CallToolRequestParam {
3176 |             name: std::borrow::Cow::Owned("rstime-parse_time".to_string()),
3177 |             arguments: Some(args),
3178 |         };
3179 | 
3180 |         let ctx = create_test_ctx(&server);
3181 |         let result = server.service().call_tool(request, ctx).await;
3182 |         assert!(
3183 |             result.is_ok(),
3184 |             "Should return result (may indicate error in content)"
3185 |         );
3186 | 
3187 |         let call_result = result.unwrap();
3188 |         // Tool returns error flag when parsing fails
3189 |         assert_eq!(
3190 |             call_result.is_error,
3191 |             Some(true),
3192 |             "Should mark result as error for invalid input"
3193 |         );
3194 | 
3195 |         assert_ok!(server.cancel().await);
3196 |         assert_ok!(client.cancel().await);
3197 |     }
3198 | 
3199 |     #[tokio::test]
3200 |     async fn test_rstime_get_prompt() {
3201 |         let wasm_path = get_rstime_wasm_path();
3202 |         if !test_rstime_wasm_exists() {
3203 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3204 |             return;
3205 |         }
3206 | 
3207 |         let config_content = format!(
3208 |             r#"
3209 | plugins:
3210 |   rstime:
3211 |     url: "file://{}"
3212 | "#,
3213 |             wasm_path.display()
3214 |         );
3215 | 
3216 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3217 |         let mut cli = create_test_cli();
3218 |         cli.config_file = Some(config_path);
3219 |         let config = load_config(&cli).await.unwrap();
3220 | 
3221 |         let (server, client) = create_test_pair(
3222 |             PluginService::new(&config).await.unwrap(),
3223 |             ClientInfo::default(),
3224 |         )
3225 |         .await;
3226 | 
3227 |         // Test getting the prompt without timezone argument
3228 |         let request = GetPromptRequestParam {
3229 |             name: "rstime-get_time_with_timezone".to_string(),
3230 |             arguments: None,
3231 |         };
3232 | 
3233 |         let ctx = create_test_ctx(&server);
3234 |         let result = server.service().get_prompt(request, ctx).await;
3235 |         assert!(result.is_ok(), "Should successfully get prompt: {result:?}");
3236 | 
3237 |         let prompt_result = result.unwrap();
3238 |         assert!(
3239 |             !prompt_result.messages.is_empty(),
3240 |             "Prompt should have messages"
3241 |         );
3242 | 
3243 |         assert_ok!(server.cancel().await);
3244 |         assert_ok!(client.cancel().await);
3245 |     }
3246 | 
3247 |     #[tokio::test]
3248 |     async fn test_rstime_get_prompt_with_timezone() {
3249 |         let wasm_path = get_rstime_wasm_path();
3250 |         if !test_rstime_wasm_exists() {
3251 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3252 |             return;
3253 |         }
3254 | 
3255 |         let config_content = format!(
3256 |             r#"
3257 | plugins:
3258 |   rstime:
3259 |     url: "file://{}"
3260 | "#,
3261 |             wasm_path.display()
3262 |         );
3263 | 
3264 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3265 |         let mut cli = create_test_cli();
3266 |         cli.config_file = Some(config_path);
3267 |         let config = load_config(&cli).await.unwrap();
3268 | 
3269 |         let (server, client) = create_test_pair(
3270 |             PluginService::new(&config).await.unwrap(),
3271 |             ClientInfo::default(),
3272 |         )
3273 |         .await;
3274 | 
3275 |         // Test getting the prompt with timezone argument
3276 |         let mut args = serde_json::Map::new();
3277 |         args.insert(
3278 |             "timezone".to_string(),
3279 |             serde_json::Value::String("Europe/London".to_string()),
3280 |         );
3281 | 
3282 |         let request = GetPromptRequestParam {
3283 |             name: "rstime-get_time_with_timezone".to_string(),
3284 |             arguments: Some(args),
3285 |         };
3286 | 
3287 |         let ctx = create_test_ctx(&server);
3288 |         let result = server.service().get_prompt(request, ctx).await;
3289 |         assert!(
3290 |             result.is_ok(),
3291 |             "Should successfully get prompt with timezone: {result:?}"
3292 |         );
3293 | 
3294 |         let prompt_result = result.unwrap();
3295 |         assert!(
3296 |             !prompt_result.messages.is_empty(),
3297 |             "Prompt should have messages"
3298 |         );
3299 | 
3300 |         // Verify description mentions the timezone
3301 |         assert!(
3302 |             prompt_result.description.is_some(),
3303 |             "Prompt should have description"
3304 |         );
3305 |         let desc = prompt_result.description.unwrap();
3306 |         assert!(
3307 |             desc.contains("London"),
3308 |             "Prompt description should mention the timezone"
3309 |         );
3310 | 
3311 |         assert_ok!(server.cancel().await);
3312 |         assert_ok!(client.cancel().await);
3313 |     }
3314 | 
3315 |     #[tokio::test]
3316 |     async fn test_rstime_read_resource() {
3317 |         let wasm_path = get_rstime_wasm_path();
3318 |         if !test_rstime_wasm_exists() {
3319 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3320 |             return;
3321 |         }
3322 | 
3323 |         let config_content = format!(
3324 |             r#"
3325 | plugins:
3326 |   rstime:
3327 |     url: "file://{}"
3328 |     runtime_config:
3329 |       allowed_hosts:
3330 |         - "www.timezoneconverter.com"
3331 | "#,
3332 |             wasm_path.display()
3333 |         );
3334 | 
3335 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3336 |         let mut cli = create_test_cli();
3337 |         cli.config_file = Some(config_path);
3338 |         let config = load_config(&cli).await.unwrap();
3339 | 
3340 |         let (server, client) = create_test_pair(
3341 |             PluginService::new(&config).await.unwrap(),
3342 |             ClientInfo::default(),
3343 |         )
3344 |         .await;
3345 | 
3346 |         // Test reading a resource with timezone - use namespaced URI
3347 |         // Format: scheme://host/plugin-name/path?query (as created by create_namespaced_uri)
3348 |         // Test reading a resource with timezone
3349 |         // Resource URIs are namespaced with plugin name inserted into the path
3350 |         // Format: scheme://host/plugin-name/rest-of-path
3351 |         // With allowed_hosts configured, the plugin can make HTTP requests
3352 |         let request = ReadResourceRequestParam {
3353 |             uri: "https://www.timezoneconverter.com/rstime/cgi-bin/zoneinfo?tz=America/New_York"
3354 |                 .to_string(),
3355 |         };
3356 | 
3357 |         let ctx = create_test_ctx(&server);
3358 |         let result = server.service().read_resource(request, ctx).await;
3359 |         // With allowed_hosts configured, the plugin should be able to fetch the resource
3360 |         match result {
3361 |             Ok(read_result) => {
3362 |                 // If successful, verify we got contents
3363 |                 assert!(
3364 |                     !read_result.contents.is_empty(),
3365 |                     "Should return resource contents from HTTP response"
3366 |                 );
3367 |             }
3368 |             Err(e) => {
3369 |                 // If there's an error (e.g., network unavailable in test env),
3370 |                 // at least verify it's a reasonable error and not a parsing error
3371 |                 let error_msg = e.message.to_lowercase();
3372 |                 assert!(
3373 |                     !error_msg.contains("parse"),
3374 |                     "Should not have parsing errors with allowed_hosts: {:?}",
3375 |                     e.message
3376 |                 );
3377 |             }
3378 |         }
3379 | 
3380 |         assert_ok!(server.cancel().await);
3381 |         assert_ok!(client.cancel().await);
3382 |     }
3383 | 
3384 |     #[tokio::test]
3385 |     async fn test_rstime_complete_prompt_timezone() {
3386 |         let wasm_path = get_rstime_wasm_path();
3387 |         if !test_rstime_wasm_exists() {
3388 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3389 |             return;
3390 |         }
3391 | 
3392 |         let config_content = format!(
3393 |             r#"
3394 | plugins:
3395 |   rstime:
3396 |     url: "file://{}"
3397 |     runtime_config:
3398 |       allowed_hosts:
3399 |         - "www.timezoneconverter.com"
3400 | "#,
3401 |             wasm_path.display()
3402 |         );
3403 | 
3404 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3405 |         let mut cli = create_test_cli();
3406 |         cli.config_file = Some(config_path);
3407 |         let config = load_config(&cli).await.unwrap();
3408 | 
3409 |         let (server, client) = create_test_pair(
3410 |             PluginService::new(&config).await.unwrap(),
3411 |             ClientInfo::default(),
3412 |         )
3413 |         .await;
3414 | 
3415 |         // Test calling the complete() function for prompt timezone argument
3416 |         let argument_info = ArgumentInfo {
3417 |             name: "timezone".to_string(),
3418 |             value: "Ame".to_string(),
3419 |         };
3420 | 
3421 |         let complete_request = CompleteRequestParam {
3422 |             r#ref: Reference::Prompt(PromptReference {
3423 |                 name: "rstime-get_time_with_timezone".to_string(),
3424 |                 title: None,
3425 |             }),
3426 |             argument: argument_info,
3427 |             context: Some(CompletionContext {
3428 |                 arguments: Some(HashMap::new()),
3429 |             }),
3430 |         };
3431 | 
3432 |         let ctx = create_test_ctx(&server);
3433 |         let result = server.service().complete(complete_request, ctx).await;
3434 |         assert!(
3435 |             result.is_ok(),
3436 |             "Should successfully call complete() for prompt timezone: {result:?}"
3437 |         );
3438 | 
3439 |         let complete_result = result.unwrap();
3440 |         // Verify completion results include timezone suggestions
3441 |         assert!(
3442 |             !complete_result.completion.values.is_empty(),
3443 |             "Completion should return timezone suggestions"
3444 |         );
3445 | 
3446 |         // Verify we get timezone suggestions starting with "Ame"
3447 |         let suggestions: Vec<String> = complete_result
3448 |             .completion
3449 |             .values
3450 |             .iter()
3451 |             .map(|v| v.to_string())
3452 |             .collect();
3453 | 
3454 |         assert!(
3455 |             suggestions
3456 |                 .iter()
3457 |                 .any(|s| s.contains("America") || s.contains("ame")),
3458 |             "Should suggest timezones matching 'Ame' pattern: {suggestions:?}"
3459 |         );
3460 | 
3461 |         // Verify completion metadata
3462 |         assert!(
3463 |             complete_result.completion.total.unwrap_or(0) > 0,
3464 |             "Completion should have total count > 0"
3465 |         );
3466 | 
3467 |         assert_ok!(server.cancel().await);
3468 |         assert_ok!(client.cancel().await);
3469 |     }
3470 | 
3471 |     #[tokio::test]
3472 |     async fn test_rstime_complete_resource_template_timezone() {
3473 |         let wasm_path = get_rstime_wasm_path();
3474 |         if !test_rstime_wasm_exists() {
3475 |             println!("Skipping test - WASM file not found at {wasm_path:?}");
3476 |             return;
3477 |         }
3478 | 
3479 |         let config_content = format!(
3480 |             r#"
3481 | plugins:
3482 |   rstime:
3483 |     url: "file://{}"
3484 |     runtime_config:
3485 |       allowed_hosts:
3486 |         - "www.timezoneconverter.com"
3487 | "#,
3488 |             wasm_path.display()
3489 |         );
3490 | 
3491 |         let (_temp_dir, config_path) = create_temp_config_file(&config_content).await.unwrap();
3492 |         let mut cli = create_test_cli();
3493 |         cli.config_file = Some(config_path);
3494 |         let config = load_config(&cli).await.unwrap();
3495 | 
3496 |         let (server, client) = create_test_pair(
3497 |             PluginService::new(&config).await.unwrap(),
3498 |             ClientInfo::default(),
3499 |         )
3500 |         .await;
3501 | 
3502 |         // First verify that resource templates exist and have proper structure
3503 |         let list_ctx = create_test_ctx(&server);
3504 |         let list_result = server
3505 |             .service()
3506 |             .list_resource_templates(None, list_ctx)
3507 |             .await;
3508 |         assert!(
3509 |             list_result.is_ok(),
3510 |             "Should successfully list resource templates"
3511 |         );
3512 | 
3513 |         let templates = list_result.unwrap();
3514 |         assert!(
3515 |             !templates.resource_templates.is_empty(),
3516 |             "Should have resource templates available"
3517 |         );
3518 | 
3519 |         // Verify the time_zone_converter template exists with proper URI template
3520 |         let tz_template = templates
3521 |             .resource_templates
3522 |             .iter()
3523 |             .find(|t| t.name.contains("time_zone_converter"))
3524 |             .expect("Should have time_zone_converter resource template");
3525 | 
3526 |         assert!(
3527 |             tz_template.uri_template.contains("{timezone}"),
3528 |             "Resource template should have timezone parameter placeholder"
3529 |         );
3530 | 
3531 |         // Now test calling the complete() function for resource template timezone parameter
3532 |         // Use the namespaced URI format with plugin name inserted
3533 |         let resource_uri =
3534 |             "https://www.timezoneconverter.com/rstime/cgi-bin/zoneinfo?tz=Eur".to_string();
3535 | 
3536 |         let argument_info = ArgumentInfo {
3537 |             name: "timezone".to_string(),
3538 |             value: "Eur".to_string(),
3539 |         };
3540 | 
3541 |         let complete_request = CompleteRequestParam {
3542 |             r#ref: Reference::Resource(ResourceReference { uri: resource_uri }),
3543 |             argument: argument_info,
3544 |             context: None,
3545 |         };
3546 | 
3547 |         let ctx = create_test_ctx(&server);
3548 |         let result = server.service().complete(complete_request, ctx).await;
3549 | 
3550 |         // The rstime plugin may not implement completion for resource URIs,
3551 |         // so we verify the interface works even if completion isn't supported
3552 |         match result {
3553 |             Ok(complete_result) => {
3554 |                 // If completion is supported, verify results
3555 |                 assert!(
3556 |                     !complete_result.completion.values.is_empty(),
3557 |                     "Completion should return timezone suggestions for resource template"
3558 |                 );
3559 | 
3560 |                 let suggestions: Vec<String> = complete_result
3561 |                     .completion
3562 |                     .values
3563 |                     .iter()
3564 |                     .map(|v| v.to_string())
3565 |                     .collect();
3566 | 
3567 |                 assert!(
3568 |                     suggestions
3569 |                         .iter()
3570 |                         .any(|s| s.contains("Europe") || s.contains("eur")),
3571 |                     "Should suggest timezones matching 'Eur' pattern: {suggestions:?}"
3572 |                 );
3573 | 
3574 |                 assert!(
3575 |                     complete_result.completion.total.unwrap_or(0) > 0,
3576 |                     "Completion should have total count > 0 for resource templates"
3577 |                 );
3578 |             }
3579 |             Err(e) => {
3580 |                 // If resource completion is not implemented, that's acceptable
3581 |                 // The important part is that the complete() method was called successfully
3582 |                 let error_msg = e.message.to_lowercase();
3583 |                 assert!(
3584 |                     error_msg.contains("not implemented") || error_msg.contains("completion"),
3585 |                     "If completion fails for resources, it should be a clear error: {}",
3586 |                     e.message
3587 |                 );
3588 |             }
3589 |         }
3590 | 
3591 |         assert_ok!(server.cancel().await);
3592 |         assert_ok!(client.cancel().await);
3593 |     }
3594 | }
3595 | 
```
Page 10/11FirstPrevNextLast