mcp_host/server/
visibility.rs

1//! Visibility context for session-aware tool/resource/prompt filtering
2//!
3//! Provides contextual information that tools, resources, and prompts can use
4//! to determine whether they should be visible in a given session.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! struct GitCommitTool;
10//!
11//! impl Tool for GitCommitTool {
12//!     fn is_visible(&self, ctx: &VisibilityContext) -> bool {
13//!         ctx.environment
14//!             .map(|e| e.has_git_repo() && !e.git_is_clean())
15//!             .unwrap_or(false)
16//!     }
17//!     // ...
18//! }
19//! ```
20
21use std::path::Path;
22
23use serde_json::Value;
24
25use crate::server::multiplexer::ClientRequester;
26use crate::server::session::Session;
27
28/// Context provided to tools/resources/prompts for visibility decisions
29///
30/// Contains session information and optional environment state that can be
31/// used to determine whether a component should be visible.
32pub struct VisibilityContext<'a> {
33    /// Reference to the current session
34    pub session: &'a Session,
35
36    /// Optional environment state for contextual checks
37    pub environment: Option<&'a dyn Environment>,
38}
39
40impl<'a> VisibilityContext<'a> {
41    /// Create a new visibility context
42    pub fn new(session: &'a Session) -> Self {
43        Self {
44            session,
45            environment: None,
46        }
47    }
48
49    /// Create a visibility context with environment
50    pub fn with_environment(session: &'a Session, environment: &'a dyn Environment) -> Self {
51        Self {
52            session,
53            environment: Some(environment),
54        }
55    }
56
57    /// Get a session state value
58    pub fn get_state<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
59        self.session
60            .get_state(key)
61            .and_then(|v| serde_json::from_value(v).ok())
62    }
63
64    /// Check if session has a specific role
65    pub fn has_role(&self, role: &str) -> bool {
66        self.session
67            .get_state("roles")
68            .and_then(|v| v.as_array().cloned())
69            .map(|roles| roles.iter().any(|r| r.as_str() == Some(role)))
70            .unwrap_or(false)
71    }
72
73    /// Check if session is admin
74    pub fn is_admin(&self) -> bool {
75        self.has_role("admin")
76    }
77
78    /// Check if session is VIP
79    pub fn is_vip(&self) -> bool {
80        self.has_role("vip")
81    }
82}
83
84/// Execution context passed to tools, resources, and prompts during execution
85///
86/// Provides access to:
87/// - Input parameters (for tools) or arguments (for prompts)
88/// - Session information (roles, state, client info)
89/// - Environment state (git status, filesystem)
90///
91/// # Example
92///
93/// ```rust,ignore
94/// impl Tool for MyTool {
95///     async fn execute(&self, ctx: ExecutionContext<'_>) -> Result<Vec<Box<dyn Content>>, ToolError> {
96///         // Access session info
97///         if ctx.is_admin() {
98///             // Admin-only behavior
99///         }
100///
101///         // Access parameters
102///         let name = ctx.params.get("name").and_then(|v| v.as_str());
103///
104///         // Access environment
105///         if let Some(env) = ctx.environment {
106///             if env.has_git_repo() {
107///                 // Git-specific behavior
108///             }
109///         }
110///
111///         Ok(vec![])
112///     }
113/// }
114/// ```
115pub struct ExecutionContext<'a> {
116    /// Input parameters (from tool call or prompt arguments)
117    pub params: Value,
118
119    /// URI parameters (extracted from resource URI template)
120    pub uri_params: std::collections::HashMap<String, String>,
121
122    /// Reference to the current session
123    pub session: &'a Session,
124
125    /// Logger for sending log notifications to the client
126    pub logger: &'a crate::logging::McpLogger,
127
128    /// Optional environment state for contextual checks
129    pub environment: Option<&'a dyn Environment>,
130
131    /// Optional client requester for server→client requests
132    client_requester: Option<ClientRequester>,
133}
134
135impl<'a> ExecutionContext<'a> {
136    /// Create a new execution context
137    pub fn new(params: Value, session: &'a Session, logger: &'a crate::logging::McpLogger) -> Self {
138        Self {
139            params,
140            uri_params: std::collections::HashMap::new(),
141            session,
142            logger,
143            environment: None,
144            client_requester: None,
145        }
146    }
147
148    /// Create an execution context with environment
149    pub fn with_environment(
150        params: Value,
151        session: &'a Session,
152        logger: &'a crate::logging::McpLogger,
153        environment: &'a dyn Environment,
154    ) -> Self {
155        Self {
156            params,
157            uri_params: std::collections::HashMap::new(),
158            session,
159            logger,
160            environment: Some(environment),
161            client_requester: None,
162        }
163    }
164
165    /// Create an execution context for resources (with URI params)
166    pub fn for_resource(
167        uri_params: std::collections::HashMap<String, String>,
168        session: &'a Session,
169        logger: &'a crate::logging::McpLogger,
170    ) -> Self {
171        Self {
172            params: Value::Null,
173            uri_params,
174            session,
175            logger,
176            environment: None,
177            client_requester: None,
178        }
179    }
180
181    /// Create a resource execution context with environment
182    pub fn for_resource_with_environment(
183        uri_params: std::collections::HashMap<String, String>,
184        session: &'a Session,
185        logger: &'a crate::logging::McpLogger,
186        environment: &'a dyn Environment,
187    ) -> Self {
188        Self {
189            params: Value::Null,
190            uri_params,
191            session,
192            logger,
193            environment: Some(environment),
194            client_requester: None,
195        }
196    }
197
198    /// Set the client requester for server→client requests
199    pub fn with_client_requester(mut self, requester: ClientRequester) -> Self {
200        self.client_requester = Some(requester);
201        self
202    }
203
204    /// Get the client requester for making server→client requests
205    ///
206    /// Returns None if the server hasn't set up bidirectional communication.
207    pub fn client_requester(&self) -> Option<&ClientRequester> {
208        self.client_requester.as_ref()
209    }
210
211    /// Get a URI parameter by name
212    pub fn get_uri_param(&self, name: &str) -> Option<&str> {
213        self.uri_params.get(name).map(|s| s.as_str())
214    }
215
216    /// Get a session state value
217    pub fn get_state<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
218        self.session
219            .get_state(key)
220            .and_then(|v| serde_json::from_value(v).ok())
221    }
222
223    /// Check if session has a specific role
224    pub fn has_role(&self, role: &str) -> bool {
225        self.session
226            .get_state("roles")
227            .and_then(|v| v.as_array().cloned())
228            .map(|roles| roles.iter().any(|r| r.as_str() == Some(role)))
229            .unwrap_or(false)
230    }
231
232    /// Check if session is admin
233    pub fn is_admin(&self) -> bool {
234        self.has_role("admin")
235    }
236
237    /// Check if session is VIP
238    pub fn is_vip(&self) -> bool {
239        self.has_role("vip")
240    }
241
242    /// Get session ID
243    pub fn session_id(&self) -> &str {
244        &self.session.id
245    }
246
247    /// Get client name if available
248    pub fn client_name(&self) -> Option<&str> {
249        self.session.client_info.as_ref().map(|c| c.name.as_str())
250    }
251
252    /// Get client version if available
253    pub fn client_version(&self) -> Option<&str> {
254        self.session
255            .client_info
256            .as_ref()
257            .map(|c| c.version.as_str())
258    }
259
260    /// Get negotiated protocol version if available
261    pub fn protocol_version(&self) -> Option<&str> {
262        self.session.protocol_version()
263    }
264
265    /// Get client capabilities if available
266    pub fn client_capabilities(
267        &self,
268    ) -> Option<&crate::protocol::capabilities::ClientCapabilities> {
269        self.session.capabilities.as_ref()
270    }
271
272    /// Convert to VisibilityContext (for visibility checks)
273    pub fn as_visibility_context(&self) -> VisibilityContext<'a> {
274        match self.environment {
275            Some(env) => VisibilityContext::with_environment(self.session, env),
276            None => VisibilityContext::new(self.session),
277        }
278    }
279}
280
281/// Environment trait for external state checks
282///
283/// Implement this trait to provide contextual information about the
284/// execution environment (git status, filesystem state, etc.) that
285/// tools can use for visibility decisions.
286///
287/// # Example Implementation
288///
289/// ```rust,ignore
290/// struct GitEnvironment {
291///     repo_path: PathBuf,
292/// }
293///
294/// impl Environment for GitEnvironment {
295///     fn has_git_repo(&self) -> bool {
296///         self.repo_path.join(".git").exists()
297///     }
298///
299///     fn git_is_clean(&self) -> bool {
300///         // Check via git2 or command
301///         Command::new("git")
302///             .args(["status", "--porcelain"])
303///             .output()
304///             .map(|o| o.stdout.is_empty())
305///             .unwrap_or(true)
306///     }
307///
308///     fn git_commits_ahead(&self) -> usize {
309///         // Parse git rev-list --count origin/main..HEAD
310///         0
311///     }
312///
313///     fn cwd(&self) -> &Path {
314///         &self.repo_path
315///     }
316/// }
317/// ```
318pub trait Environment: Send + Sync {
319    /// Check if current directory is a git repository
320    fn has_git_repo(&self) -> bool {
321        false
322    }
323
324    /// Check if git working tree is clean (no uncommitted changes)
325    fn git_is_clean(&self) -> bool {
326        true
327    }
328
329    /// Get number of commits ahead of remote tracking branch
330    fn git_commits_ahead(&self) -> usize {
331        0
332    }
333
334    /// Check if there are staged changes ready to commit
335    fn git_has_staged(&self) -> bool {
336        false
337    }
338
339    /// Check if there are stashed changes
340    fn git_has_stash(&self) -> bool {
341        false
342    }
343
344    /// Get current working directory
345    fn cwd(&self) -> &Path;
346
347    /// Check if a file exists relative to cwd
348    fn file_exists(&self, path: &str) -> bool {
349        self.cwd().join(path).exists()
350    }
351
352    /// Check if a directory exists relative to cwd
353    fn dir_exists(&self, path: &str) -> bool {
354        let full_path = self.cwd().join(path);
355        full_path.exists() && full_path.is_dir()
356    }
357
358    /// Get a custom environment value
359    fn get_custom(&self, _key: &str) -> Option<String> {
360        None
361    }
362}
363
364/// Simple environment implementation backed by filesystem checks
365pub struct SimpleEnvironment {
366    cwd: std::path::PathBuf,
367}
368
369impl SimpleEnvironment {
370    /// Create a new simple environment
371    pub fn new(cwd: impl Into<std::path::PathBuf>) -> Self {
372        Self { cwd: cwd.into() }
373    }
374
375    /// Create from current directory
376    pub fn current_dir() -> std::io::Result<Self> {
377        Ok(Self {
378            cwd: std::env::current_dir()?,
379        })
380    }
381}
382
383impl Environment for SimpleEnvironment {
384    fn has_git_repo(&self) -> bool {
385        self.cwd.join(".git").exists()
386    }
387
388    fn cwd(&self) -> &Path {
389        &self.cwd
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::server::session::Session;
397
398    #[test]
399    fn test_visibility_context_creation() {
400        let session = Session::new();
401        let ctx = VisibilityContext::new(&session);
402
403        assert!(ctx.environment.is_none());
404    }
405
406    #[test]
407    fn test_visibility_context_with_environment() {
408        let session = Session::new();
409        let env = SimpleEnvironment::new("/tmp");
410        let ctx = VisibilityContext::with_environment(&session, &env);
411
412        assert!(ctx.environment.is_some());
413        assert_eq!(ctx.environment.unwrap().cwd(), Path::new("/tmp"));
414    }
415
416    #[test]
417    fn test_role_checks() {
418        let session = Session::new();
419        session.set_state("roles", serde_json::json!(["admin", "vip"]));
420
421        let ctx = VisibilityContext::new(&session);
422
423        assert!(ctx.has_role("admin"));
424        assert!(ctx.has_role("vip"));
425        assert!(!ctx.has_role("guest"));
426        assert!(ctx.is_admin());
427        assert!(ctx.is_vip());
428    }
429
430    #[test]
431    fn test_role_checks_empty() {
432        let session = Session::new();
433        let ctx = VisibilityContext::new(&session);
434
435        assert!(!ctx.has_role("admin"));
436        assert!(!ctx.is_admin());
437        assert!(!ctx.is_vip());
438    }
439
440    #[test]
441    fn test_simple_environment() {
442        let env = SimpleEnvironment::new("/tmp");
443
444        assert_eq!(env.cwd(), Path::new("/tmp"));
445        assert!(env.git_is_clean()); // Uses trait default: true
446        assert!(!env.has_git_repo()); // /tmp likely doesn't have .git
447    }
448
449    #[test]
450    fn test_environment_defaults() {
451        struct MinimalEnv;
452        impl Environment for MinimalEnv {
453            fn cwd(&self) -> &Path {
454                Path::new("/")
455            }
456        }
457
458        let env = MinimalEnv;
459        assert!(!env.has_git_repo());
460        assert!(env.git_is_clean());
461        assert_eq!(env.git_commits_ahead(), 0);
462        assert!(!env.git_has_staged());
463        assert!(!env.git_has_stash());
464        assert!(env.get_custom("anything").is_none());
465    }
466}