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 |
```