custom_mcp_server_example/
custom_mcp_server_example.rs

1//! Custom MCP Server Example with OAuth 2.0 and DynamoDB
2//!
3//! This example demonstrates how to create a custom MCP server implementation
4//! and use it with the microkernel architecture. It shows how users can extend
5//! the framework by implementing their own MCP servers with custom tools and
6//! capabilities while leveraging the existing OAuth and storage infrastructure.
7//!
8//! # Features
9//! - Custom MCP server implementation with specialized tools
10//! - AWS Cognito OAuth 2.0 authentication
11//! - DynamoDB persistent storage for OAuth tokens and client data
12//! - MCP over HTTP (streamable)
13//! - MCP over Server-Sent Events (SSE)
14//! - Microkernel architecture with independent, composable handlers
15//!
16//! # Custom MCP Server Features
17//! The custom server implements:
18//! - File system operations (list, read, write files)
19//! - System information tools (CPU, memory, disk usage)
20//! - Text processing utilities (word count, text search)
21//! - Time and date utilities
22//!
23//! # Required Environment Variables
24//! ## Cognito Configuration
25//! - `COGNITO_CLIENT_ID`: Your Cognito app client ID
26//! - `COGNITO_CLIENT_SECRET`: Your Cognito app client secret (optional for public clients)
27//! - `COGNITO_DOMAIN`: Your Cognito domain (e.g., mydomain.auth.us-east-1.amazoncognito.com)
28//! - `COGNITO_REGION`: AWS region (e.g., us-east-1)
29//! - `COGNITO_USER_POOL_ID`: Your Cognito user pool ID (e.g., us-east-1_XXXXXXXXX)
30//! - `COGNITO_SCOPE`: OAuth scopes (default: 'openid email profile phone')
31//!
32//! ## AWS Configuration (for DynamoDB)
33//! - `AWS_ACCESS_KEY_ID`: Your AWS access key ID
34//! - `AWS_SECRET_ACCESS_KEY`: Your AWS secret access key
35//! - `AWS_REGION`: AWS region (should match COGNITO_REGION)
36//!
37//! ## Server Configuration
38//! - `MCP_HOST`: Server host (default: localhost)
39//! - `MCP_PORT`: Server port (default: 8080)
40//! - `DYNAMODB_TABLE_NAME`: DynamoDB table name (default: oauth-storage)
41//! - `DYNAMODB_CREATE_TABLE`: Whether to auto-create table (default: true)
42//!
43//! # Usage
44//! ```bash
45//! # Set environment variables
46//! export COGNITO_CLIENT_ID="your_client_id"
47//! export COGNITO_CLIENT_SECRET="your_client_secret"
48//! export COGNITO_DOMAIN="mydomain.auth.us-east-1.amazoncognito.com"
49//! export COGNITO_REGION="us-east-1"
50//! export COGNITO_USER_POOL_ID="us-east-1_XXXXXXXXX"
51//! export AWS_ACCESS_KEY_ID="your_aws_access_key"
52//! export AWS_SECRET_ACCESS_KEY="your_aws_secret_key"
53//! export AWS_REGION="us-east-1"
54//!
55//! # Run the server
56//! cargo run --example custom_mcp_server_example
57//! ```
58
59use oauth_provider_rs::storage::create_dynamodb_storage;
60use oauth_provider_rs::{CognitoOAuthConfig, OAuthProvider};
61use remote_mcp_kernel::{
62    config::Config, error::AppResult, handlers::SseHandlerConfig, microkernel::MicrokernelServer,
63};
64use rmcp::{
65    Error as McpError, ServerHandler,
66    handler::server::router::tool::ToolRouter,
67    handler::server::tool::Parameters,
68    model::{CallToolResult, Content, Implementation, ServerCapabilities, ServerInfo},
69    tool, tool_handler, tool_router,
70};
71use schemars::JsonSchema;
72use serde::{Deserialize, Serialize};
73use std::env;
74use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
75
76// Custom MCP Server Implementation
77// =============================================================================
78
79/// Custom MCP server with specialized tools for file operations and system utilities
80#[derive(Debug, Clone)]
81pub struct CustomMcpServer {
82    /// MCP tool router for handling tool calls
83    tool_router: ToolRouter<Self>,
84    /// Server name for identification
85    name: String,
86}
87
88// Tool parameter definitions
89#[derive(Debug, Deserialize, Serialize, JsonSchema)]
90pub struct ListFilesRequest {
91    #[schemars(description = "Directory path to list files from")]
92    pub path: String,
93    #[schemars(description = "Include hidden files")]
94    pub include_hidden: Option<bool>,
95}
96
97#[derive(Debug, Deserialize, Serialize, JsonSchema)]
98pub struct ReadFileRequest {
99    #[schemars(description = "File path to read")]
100    pub path: String,
101    #[schemars(description = "Maximum number of lines to read")]
102    pub max_lines: Option<usize>,
103}
104
105#[derive(Debug, Deserialize, Serialize, JsonSchema)]
106pub struct WriteFileRequest {
107    #[schemars(description = "File path to write to")]
108    pub path: String,
109    #[schemars(description = "Content to write")]
110    pub content: String,
111    #[schemars(description = "Append to file instead of overwriting")]
112    pub append: Option<bool>,
113}
114
115#[derive(Debug, Deserialize, Serialize, JsonSchema)]
116pub struct WordCountRequest {
117    #[schemars(description = "Text to count words in")]
118    pub text: String,
119}
120
121#[derive(Debug, Deserialize, Serialize, JsonSchema)]
122pub struct TextSearchRequest {
123    #[schemars(description = "Text to search in")]
124    pub text: String,
125    #[schemars(description = "Pattern to search for")]
126    pub pattern: String,
127    #[schemars(description = "Case sensitive search")]
128    pub case_sensitive: Option<bool>,
129}
130
131// Tool implementations using the tool_router macro
132#[tool_router]
133impl CustomMcpServer {
134    /// Create a new custom MCP server
135    pub fn new(name: String) -> Self {
136        Self {
137            tool_router: Self::tool_router(),
138            name,
139        }
140    }
141
142    /// List files in a directory
143    #[tool(description = "List files and directories in the specified path")]
144    async fn list_files(
145        &self,
146        Parameters(req): Parameters<ListFilesRequest>,
147    ) -> Result<CallToolResult, McpError> {
148        let path = std::path::Path::new(&req.path);
149
150        if !path.exists() {
151            return Ok(CallToolResult::error(vec![Content::text(format!(
152                "Path does not exist: {}",
153                req.path
154            ))]));
155        }
156
157        if !path.is_dir() {
158            return Ok(CallToolResult::error(vec![Content::text(format!(
159                "Path is not a directory: {}",
160                req.path
161            ))]));
162        }
163
164        let mut files = Vec::new();
165        let include_hidden = req.include_hidden.unwrap_or(false);
166
167        match std::fs::read_dir(path) {
168            Ok(entries) => {
169                for entry in entries {
170                    match entry {
171                        Ok(entry) => {
172                            let file_name = entry.file_name().to_string_lossy().to_string();
173                            let is_hidden = file_name.starts_with('.');
174
175                            if include_hidden || !is_hidden {
176                                let file_type = if entry.path().is_dir() {
177                                    "directory"
178                                } else {
179                                    "file"
180                                };
181                                files.push(format!("{} ({})", file_name, file_type));
182                            }
183                        }
184                        Err(e) => {
185                            return Ok(CallToolResult::error(vec![Content::text(format!(
186                                "Error reading directory entry: {}",
187                                e
188                            ))]));
189                        }
190                    }
191                }
192            }
193            Err(e) => {
194                return Ok(CallToolResult::error(vec![Content::text(format!(
195                    "Error reading directory: {}",
196                    e
197                ))]));
198            }
199        }
200
201        files.sort();
202        let result = files.join("\n");
203        Ok(CallToolResult::success(vec![Content::text(result)]))
204    }
205
206    /// Read contents of a file
207    #[tool(description = "Read the contents of a file")]
208    async fn read_file(
209        &self,
210        Parameters(req): Parameters<ReadFileRequest>,
211    ) -> Result<CallToolResult, McpError> {
212        let path = std::path::Path::new(&req.path);
213
214        if !path.exists() {
215            return Ok(CallToolResult::error(vec![Content::text(format!(
216                "File does not exist: {}",
217                req.path
218            ))]));
219        }
220
221        if !path.is_file() {
222            return Ok(CallToolResult::error(vec![Content::text(format!(
223                "Path is not a file: {}",
224                req.path
225            ))]));
226        }
227
228        match std::fs::read_to_string(path) {
229            Ok(content) => {
230                let result = if let Some(max_lines) = req.max_lines {
231                    content
232                        .lines()
233                        .take(max_lines)
234                        .collect::<Vec<_>>()
235                        .join("\n")
236                } else {
237                    content
238                };
239                Ok(CallToolResult::success(vec![Content::text(result)]))
240            }
241            Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
242                "Error reading file: {}",
243                e
244            ))])),
245        }
246    }
247
248    /// Write content to a file
249    #[tool(description = "Write content to a file")]
250    async fn write_file(
251        &self,
252        Parameters(req): Parameters<WriteFileRequest>,
253    ) -> Result<CallToolResult, McpError> {
254        let path = std::path::Path::new(&req.path);
255
256        // Create parent directories if they don't exist
257        if let Some(parent) = path.parent() {
258            if !parent.exists() {
259                if let Err(e) = std::fs::create_dir_all(parent) {
260                    return Ok(CallToolResult::error(vec![Content::text(format!(
261                        "Error creating parent directories: {}",
262                        e
263                    ))]));
264                }
265            }
266        }
267
268        let result = if req.append.unwrap_or(false) {
269            std::fs::write(path, &req.content)
270        } else {
271            std::fs::write(path, &req.content)
272        };
273
274        match result {
275            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
276                "Successfully wrote {} bytes to {}",
277                req.content.len(),
278                req.path
279            ))])),
280            Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
281                "Error writing file: {}",
282                e
283            ))])),
284        }
285    }
286
287    /// Get system information
288    #[tool(description = "Get system information including CPU, memory, and disk usage")]
289    async fn get_system_info(&self) -> Result<CallToolResult, McpError> {
290        let mut info = Vec::new();
291
292        // Get current timestamp
293        let now = std::time::SystemTime::now()
294            .duration_since(std::time::UNIX_EPOCH)
295            .unwrap()
296            .as_secs();
297        info.push(format!("Timestamp: {}", now));
298
299        // Get current working directory
300        if let Ok(cwd) = std::env::current_dir() {
301            info.push(format!("Working Directory: {}", cwd.display()));
302        }
303
304        // Get environment variables count
305        let env_count = std::env::vars().count();
306        info.push(format!("Environment Variables: {}", env_count));
307
308        // Get OS information
309        info.push(format!("OS: {}", std::env::consts::OS));
310        info.push(format!("Architecture: {}", std::env::consts::ARCH));
311
312        let result = info.join("\n");
313        Ok(CallToolResult::success(vec![Content::text(result)]))
314    }
315
316    /// Count words in text
317    #[tool(description = "Count words, lines, and characters in text")]
318    async fn count_words(
319        &self,
320        Parameters(req): Parameters<WordCountRequest>,
321    ) -> Result<CallToolResult, McpError> {
322        let text = &req.text;
323        let lines = text.lines().count();
324        let words = text.split_whitespace().count();
325        let chars = text.chars().count();
326        let bytes = text.len();
327
328        let result = format!(
329            "Lines: {}\nWords: {}\nCharacters: {}\nBytes: {}",
330            lines, words, chars, bytes
331        );
332
333        Ok(CallToolResult::success(vec![Content::text(result)]))
334    }
335
336    /// Search for text patterns
337    #[tool(description = "Search for patterns in text")]
338    async fn search_text(
339        &self,
340        Parameters(req): Parameters<TextSearchRequest>,
341    ) -> Result<CallToolResult, McpError> {
342        let text = &req.text;
343        let pattern = &req.pattern;
344        let case_sensitive = req.case_sensitive.unwrap_or(false);
345
346        let (search_text, search_pattern) = if case_sensitive {
347            (text.to_string(), pattern.to_string())
348        } else {
349            (text.to_lowercase(), pattern.to_lowercase())
350        };
351
352        let mut matches = Vec::new();
353        for (line_num, line) in search_text.lines().enumerate() {
354            if line.contains(&search_pattern) {
355                matches.push(format!(
356                    "Line {}: {}",
357                    line_num + 1,
358                    text.lines().nth(line_num).unwrap_or("")
359                ));
360            }
361        }
362
363        let result = if matches.is_empty() {
364            format!("No matches found for pattern: {}", pattern)
365        } else {
366            format!("Found {} matches:\n{}", matches.len(), matches.join("\n"))
367        };
368
369        Ok(CallToolResult::success(vec![Content::text(result)]))
370    }
371
372    /// Get current date and time
373    #[tool(description = "Get current date and time in various formats")]
374    async fn get_datetime(&self) -> Result<CallToolResult, McpError> {
375        let now = std::time::SystemTime::now();
376        let unix_timestamp = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
377
378        let result = format!(
379            "Unix Timestamp: {}\nISO 8601 (approx): {}",
380            unix_timestamp,
381            // Simple ISO 8601 approximation (not perfect but sufficient for demo)
382            chrono::DateTime::from_timestamp(unix_timestamp as i64, 0)
383                .unwrap_or_default()
384                .format("%Y-%m-%dT%H:%M:%SZ")
385        );
386
387        Ok(CallToolResult::success(vec![Content::text(result)]))
388    }
389}
390
391// Implement the ServerHandler trait for MCP protocol support
392#[tool_handler]
393impl ServerHandler for CustomMcpServer {
394    fn get_info(&self) -> ServerInfo {
395        ServerInfo {
396            protocol_version: Default::default(),
397            capabilities: ServerCapabilities::builder()
398                .enable_tools()
399                .build(),
400            server_info: Implementation {
401                name: self.name.clone(),
402                version: "1.0.0".to_string(),
403            },
404            instructions: Some("A custom MCP server with file operations, system utilities, and text processing tools".to_string()),
405        }
406    }
407}
408
409// Main application
410// =============================================================================
411
412#[tokio::main]
413async fn main() -> AppResult<()> {
414    // Load environment variables
415    dotenv::dotenv().ok();
416
417    // Load configuration
418    let config = Config::from_env()?;
419
420    // Initialize tracing
421    init_tracing(&config)?;
422
423    tracing::info!("Starting Custom MCP Server example with Cognito and DynamoDB storage...");
424
425    // Create Cognito OAuth configuration
426    let cognito_config = CognitoOAuthConfig {
427        client_id: config.cognito.client_id.clone(),
428        client_secret: config.cognito.client_secret.clone().unwrap_or_default(),
429        redirect_uri: format!(
430            "http://{}:{}/oauth/callback",
431            config.server.host, config.server.port
432        ),
433        scope: config.cognito.scope.clone(),
434        provider_name: "cognito".to_string(),
435    };
436
437    // Get DynamoDB configuration
438    let table_name =
439        env::var("DYNAMODB_TABLE_NAME").unwrap_or_else(|_| "oauth-storage".to_string());
440    let create_table = env::var("DYNAMODB_CREATE_TABLE")
441        .unwrap_or_else(|_| "true".to_string())
442        .parse::<bool>()
443        .unwrap_or(true);
444
445    // Log configuration
446    log_startup_info(&config, &table_name, create_table);
447
448    // Create DynamoDB storage
449    let (storage, client_manager) = create_dynamodb_storage(
450        table_name.clone(),
451        create_table,
452        Some("expires_at".to_string()),
453    )
454    .await
455    .map_err(|e| {
456        remote_mcp_kernel::error::AppError::Internal(format!(
457            "Failed to create DynamoDB storage: {}",
458            e
459        ))
460    })?;
461
462    // Create Cognito OAuth provider with DynamoDB storage
463    let oauth_handler = oauth_provider_rs::CognitoOAuthHandler::new_simple(
464        storage,
465        client_manager,
466        cognito_config,
467        config.cognito.cognito_domain.clone(),
468        config.cognito.region.clone(),
469        config.cognito.user_pool_id.clone(),
470    );
471
472    let oauth_provider = OAuthProvider::new(oauth_handler);
473
474    // Create custom MCP server
475    let custom_mcp_server = CustomMcpServer::new("Custom File & System MCP Server".to_string());
476
477    // Build microkernel with custom MCP server using convenience methods
478    let microkernel = MicrokernelServer::new()
479        .with_oauth_provider(oauth_provider)
480        .with_mcp_streamable_handler(custom_mcp_server.clone())
481        .with_mcp_sse_handler(custom_mcp_server, SseHandlerConfig::default());
482
483    // Start the microkernel server
484    let bind_address = config.bind_socket_addr()?;
485    tracing::info!("🚀 Starting microkernel server on {}", bind_address);
486    microkernel.serve(bind_address).await?;
487
488    Ok(())
489}
490
491fn init_tracing(config: &Config) -> AppResult<()> {
492    tracing_subscriber::registry()
493        .with(
494            tracing_subscriber::EnvFilter::try_from_default_env()
495                .unwrap_or_else(|_| config.logging.level.as_str().into()),
496        )
497        .with(tracing_subscriber::fmt::layer())
498        .init();
499
500    Ok(())
501}
502
503fn log_startup_info(config: &Config, table_name: &str, create_table: bool) {
504    println!("🚀 Starting Custom MCP Server example with Cognito and DynamoDB storage...");
505    println!("📋 Configuration:");
506    println!("  - Architecture: Microkernel (independent handlers)");
507    println!("  - MCP Server: Custom implementation with specialized tools");
508    println!("  - OAuth Provider: AWS Cognito");
509    println!("  - Storage Backend: DynamoDB");
510    println!("  - Server: {}:{}", config.server.host, config.server.port);
511    println!("  - Version: {}", config.server.version);
512    println!();
513
514    println!("🔧 Custom MCP Server Tools:");
515    println!("  - list_files: List files and directories");
516    println!("  - read_file: Read file contents");
517    println!("  - write_file: Write content to files");
518    println!("  - get_system_info: Get system information");
519    println!("  - count_words: Count words, lines, and characters");
520    println!("  - search_text: Search for patterns in text");
521    println!("  - get_datetime: Get current date and time");
522    println!();
523
524    println!("🔐 AWS Cognito Configuration:");
525    println!(
526        "  - Client ID: {}",
527        if config.cognito.client_id.is_empty() {
528            "Not configured"
529        } else {
530            "Configured"
531        }
532    );
533    println!(
534        "  - Client Secret: {}",
535        match &config.cognito.client_secret {
536            Some(secret) if !secret.is_empty() => "Configured",
537            _ => "Not configured (Public Client)",
538        }
539    );
540    println!(
541        "  - Domain: {}",
542        if config.cognito.cognito_domain.is_empty() {
543            "Not configured"
544        } else {
545            &config.cognito.cognito_domain
546        }
547    );
548    println!(
549        "  - Region: {}",
550        if config.cognito.region.is_empty() {
551            "Not configured"
552        } else {
553            &config.cognito.region
554        }
555    );
556    println!(
557        "  - User Pool ID: {}",
558        if config.cognito.user_pool_id.is_empty() {
559            "Not configured"
560        } else {
561            &config.cognito.user_pool_id
562        }
563    );
564    println!("  - Scopes: {}", config.cognito.scope);
565    println!();
566
567    println!("🗄️  DynamoDB Storage Configuration:");
568    println!("  - Table Name: {}", table_name);
569    println!("  - Auto-create Table: {}", create_table);
570    println!("  - TTL Attribute: expires_at");
571    println!();
572
573    println!("🔧 Handlers:");
574    println!("  - OAuth Provider (Cognito authentication & authorization)");
575    println!("  - Streamable HTTP Handler (MCP over HTTP with custom server)");
576    println!("  - SSE Handler (MCP over SSE with custom server)");
577    println!();
578
579    println!("🏗️  Microkernel Architecture:");
580    println!("  - Custom MCP server with specialized tools");
581    println!("  - Independent handlers that can operate standalone");
582    println!("  - Runtime composition of services");
583    println!("  - Single responsibility per handler");
584    println!("  - Easy testing and maintenance");
585    println!();
586
587    println!("🌐 MCP Protocol Endpoints:");
588    println!(
589        "  - HTTP (streamable): http://{}:{}/mcp/http",
590        config.server.host, config.server.port
591    );
592    println!(
593        "  - SSE: http://{}:{}/mcp/sse",
594        config.server.host, config.server.port
595    );
596    println!(
597        "  - SSE Messages: http://{}:{}/mcp/message",
598        config.server.host, config.server.port
599    );
600    println!();
601
602    println!("🔐 OAuth 2.0 Endpoints:");
603    println!(
604        "  - Authorization: https://{}/oauth2/authorize",
605        config.cognito.cognito_domain
606    );
607    println!(
608        "  - Token: https://{}/oauth2/token",
609        config.cognito.cognito_domain
610    );
611    println!(
612        "  - JWKS: https://{}/oauth2/jwks",
613        config.cognito.cognito_domain
614    );
615    println!(
616        "  - UserInfo: https://{}/oauth2/userInfo",
617        config.cognito.cognito_domain
618    );
619    println!();
620}