chore: import upstream snapshot with attribution
MCP Bash Server CI / Test MCP Bash Server (dev) (push) Has been cancelled
MCP Bash Server CI / Test MCP Bash Server (release) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:11:39 +08:00
commit c8cebdfeee
4654 changed files with 626756 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//! Configuration management for the MCP Bash Server
//!
//! This module handles reading and parsing configuration from TOML files,
//! including server settings and security blacklists for command validation.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub settings: Settings,
pub blacklist: Blacklist,
pub whitelist: Whitelist,
}
/// Security whitelist configuration for command validation
/// Contains lists of allowed commands and operations
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Whitelist {
/// List of command names that are allowed to be executed
pub commands: Vec<String>,
/// List of regular expressions for commands that are allowed
/// These patterns are matched against the full command line
pub regex: Vec<String>,
}
/// Security blacklist configuration for command validation
/// Contains lists of forbidden commands and regex patterns
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Blacklist {
/// List of command names that are not allowed to be executed
pub commands: Vec<String>,
/// List of regular expressions for commands that are not allowed
/// These patterns are matched against the full command line
pub regex: Vec<String>,
}
/// Server runtime settings including network configuration
#[derive(Debug, Deserialize, Serialize)]
pub struct Settings {
pub port: u16,
pub host: String,
pub env: Option<String>, // "development" or "production"
}
impl Config {
/// Read and parse configuration from a TOML file
/// Returns parsed Config structure or error if file cannot be read/parsed
pub fn read_config(file: &str) -> Result<Config> {
let toml_str = fs::read_to_string(file)?;
let config: Config = toml::from_str(&toml_str)?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_config_parsing() {
let config_content = r#"
[settings]
port = 4000
host = "127.0.0.1"
env = "development"
[whitelist]
commands = ["echo hello", "ls -la"]
regex = ["echo.*", "ls.*"]
[blacklist]
commands = ["rm", "dd"]
regex = [".*[|&].*", "^sudo .*"]
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{config_content}").unwrap();
let temp_path = temp_file.path().to_str().unwrap();
let config = Config::read_config(temp_path).unwrap();
assert_eq!(config.settings.port, 4000);
assert_eq!(config.settings.host, "127.0.0.1");
assert_eq!(config.settings.env, Some("development".to_string()));
assert_eq!(config.whitelist.commands.len(), 2);
assert!(
config
.whitelist
.commands
.contains(&"echo hello".to_string())
);
assert!(config.whitelist.commands.contains(&"ls -la".to_string()));
assert_eq!(config.whitelist.regex.len(), 2);
assert!(config.whitelist.regex.contains(&"echo.*".to_string()));
assert!(config.whitelist.regex.contains(&"ls.*".to_string()));
assert_eq!(config.blacklist.commands.len(), 2);
assert!(config.blacklist.commands.contains(&"rm".to_string()));
assert!(config.blacklist.commands.contains(&"dd".to_string()));
assert_eq!(config.blacklist.regex.len(), 2);
assert!(config.blacklist.regex.contains(&".*[|&].*".to_string()));
assert!(config.blacklist.regex.contains(&"^sudo .*".to_string()));
}
#[test]
fn test_config_invalid_file() {
let result = Config::read_config("non_existent_file.toml");
assert!(result.is_err());
}
#[test]
fn test_config_invalid_toml() {
let invalid_config = r#"
[settings
port = 4000
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{invalid_config}").unwrap();
let temp_path = temp_file.path().to_str().unwrap();
let result = Config::read_config(temp_path);
assert!(result.is_err());
}
#[test]
fn test_whitelist_creation() {
let commands = vec!["echo".to_string(), "ls".to_string()];
let regex = vec!["test.*".to_string()];
let whitelist = Whitelist {
commands: commands.clone(),
regex: regex.clone(),
};
assert_eq!(whitelist.commands, commands);
assert_eq!(whitelist.regex, regex);
}
#[test]
fn test_blacklist_creation() {
let commands = vec!["rm".to_string(), "dd".to_string()];
let regex = vec![".*[|&].*".to_string(), "^sudo .*".to_string()];
let blacklist = Blacklist {
commands: commands.clone(),
regex: regex.clone(),
};
assert_eq!(blacklist.commands, commands);
assert_eq!(blacklist.regex, regex);
}
#[test]
fn test_settings_creation() {
let settings = Settings {
port: 8080,
host: "localhost".to_string(),
env: Some("production".to_string()),
};
assert_eq!(settings.port, 8080);
assert_eq!(settings.host, "localhost");
assert_eq!(settings.env, Some("production".to_string()));
}
}
@@ -0,0 +1,29 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//! Common utilities and shared components for the MCP Bash Server
//!
//! This module contains the core functionality including:
//! - Bash command execution server implementation
//! - Configuration management
//! - OAuth2 authentication system
//! - Command validation and security enforcement
pub mod bash_server;
pub mod config;
pub mod oauth;
pub mod validator;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//! Command validation module for security enforcement
//!
//! This module provides command validation functionality to prevent execution
//! of dangerous commands and operations based on configurable blacklists.
use tracing::debug;
use crate::{common::config::Whitelist, config::Blacklist};
use rmcp::model::ErrorData;
use tracing::error;
/// Command validator that checks commands against security blacklists
/// Prevents execution of dangerous commands and operations
#[derive(Debug, Clone)]
pub struct Validator {
/// Security blacklist configuration containing forbidden commands and operations
blacklist: Blacklist,
/// Security whitelist configuration containing secure commands
whitelist: Whitelist,
}
impl Validator {
/// Create a new validator with the specified blacklist configuration
pub fn new(blacklist: Blacklist, whitelist: Whitelist) -> Self {
Validator {
blacklist,
whitelist,
}
}
/// Validate a command against the security blacklist and whitelist
/// Returns Ok(()) if command is safe, Err(ErrorData) if command is blocked
///
/// Security Logic:
/// 1. All commands are denied by default
/// 2. Blacklist has higher priority than whitelist
/// 3. If command matches any blacklist pattern (exact or regex), deny
/// 4. If command doesn't match blacklist and matches whitelist (exact or regex), allow
/// 5. Otherwise, deny
pub fn is_unsafe_command(&self, args: &str) -> Result<(), ErrorData> {
// Handle empty command - deny by default
if args.is_empty() {
return Err(ErrorData::invalid_request(
"Empty command not allowed".to_string(),
None,
));
}
let full_cmd = args.to_string();
debug!("Validating command: {}", full_cmd);
// First check blacklist - if any match, deny immediately
// Check blacklist exact commands - match against the full command
for blacklisted_cmd in &self.blacklist.commands {
if &full_cmd == blacklisted_cmd {
error!(
"Command blocked by blacklist exact match: {}, full command: {}",
blacklisted_cmd, full_cmd
);
return Err(ErrorData::invalid_request(
format!("Command blocked by blacklist: {blacklisted_cmd}"),
None,
));
}
}
// Check blacklist regex patterns
for pattern in &self.blacklist.regex {
match regex::Regex::new(pattern) {
Ok(regex) => {
if regex.is_match(&full_cmd) {
error!(
"Command blocked by blacklist regex pattern: {}, full command: {}",
pattern, full_cmd
);
return Err(ErrorData::invalid_request(
format!("Command blocked by blacklist pattern: {pattern}"),
None,
));
}
}
Err(e) => {
error!(
"Invalid regex pattern in blacklist: {}, error: {}",
pattern, e
);
}
}
}
// Now check whitelist - if any match, allow
// Check whitelist exact commands
if self.whitelist.commands.contains(&full_cmd) {
debug!("Command allowed by whitelist exact match: {}", full_cmd);
return Ok(());
}
// Check whitelist regex patterns
for pattern in &self.whitelist.regex {
match regex::Regex::new(pattern) {
Ok(regex) => {
if regex.is_match(&full_cmd) {
debug!("Command allowed by whitelist regex pattern: {}", pattern);
return Ok(());
}
}
Err(e) => {
error!(
"Invalid regex pattern in whitelist: {}, error: {}",
pattern, e
);
}
}
}
// Default deny - command didn't match whitelist
error!("Command denied - not in whitelist: {full_cmd}");
Err(ErrorData::invalid_request(
format!("Command not allowed: {full_cmd}"),
None,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_validator() -> Validator {
let blacklist = Blacklist {
commands: vec![
"rm".to_string(),
"dd".to_string(),
"shutdown".to_string(),
"kill".to_string(),
],
regex: vec![
".*[|&;><].*".to_string(), // Block commands with dangerous operators
"^sudo .*".to_string(), // Block sudo commands
".*/etc/.*".to_string(), // Block access to /etc
".*passwd.*".to_string(), // Block password-related commands
],
};
let whitelist = Whitelist {
commands: vec![
"echo hello".to_string(),
"ls -la".to_string(),
"pwd".to_string(),
],
regex: vec![
"^echo [a-zA-Z0-9 ]+$".to_string(), // Only simple echo commands
"^ls [a-zA-Z0-9 /-]*$".to_string(), // Only simple ls commands
"^(pwd|whoami|date|uptime)$".to_string(), // Basic system info commands
],
};
Validator::new(blacklist, whitelist)
}
#[test]
fn test_validator_creation() {
let validator = create_test_validator();
assert_eq!(validator.blacklist.commands.len(), 4);
assert_eq!(validator.blacklist.regex.len(), 4);
assert_eq!(validator.whitelist.commands.len(), 3);
assert_eq!(validator.whitelist.regex.len(), 3);
}
#[test]
fn test_safe_command() {
let validator = create_test_validator();
let result = validator.is_unsafe_command("pwd");
assert!(result.is_ok()); // pwd is whitelisted
}
#[test]
fn test_whitelisted_command() {
let validator = create_test_validator();
let result = validator.is_unsafe_command("echo hello");
assert!(result.is_ok()); // "echo hello" is exactly whitelisted
}
#[test]
fn test_whitelisted_regex_command() {
let validator = create_test_validator();
let result = validator.is_unsafe_command("echo test123");
assert!(result.is_ok()); // Matches whitelist regex "^echo [a-zA-Z0-9 ]+$"
}
#[test]
fn test_blacklisted_command() {
let validator = create_test_validator();
// Test a command that contains a blacklisted command but isn't exact match
let result = validator.is_unsafe_command("rm -rf /");
// Should be denied because "rm -rf /" is not in whitelist (default deny)
// not because "rm" is blacklisted (since we need exact command match)
assert!(result.is_err());
}
#[test]
fn test_blacklisted_exact_command() {
let validator = create_test_validator();
// Test exact match against blacklist
let result = validator.is_unsafe_command("rm");
assert!(result.is_err()); // "rm" exactly matches blacklist
}
#[test]
fn test_blacklisted_regex_operation() {
let validator = create_test_validator();
// Test pipe operation which should be blocked by regex
let result = validator.is_unsafe_command("echo test | cat");
assert!(result.is_err()); // Contains "|" which matches ".*[|&;><].*" regex
}
#[test]
fn test_multiple_blacklisted_commands() {
let validator = create_test_validator();
// Test commands that are exactly in the blacklist
let dangerous_commands = vec!["rm", "dd", "shutdown", "kill"];
for cmd in dangerous_commands {
let result = validator.is_unsafe_command(cmd);
assert!(result.is_err(), "Command '{cmd}' should be blocked");
}
}
#[test]
fn test_multiple_blacklisted_regex_operations() {
let validator = create_test_validator();
let dangerous_ops = vec!["|", "&", ";", ">"];
for op in dangerous_ops {
let cmd = format!("echo test {op}");
let result = validator.is_unsafe_command(&cmd);
assert!(
result.is_err(),
"Operation '{op}' should be blocked by regex"
);
}
}
#[test]
fn test_empty_command() {
let validator = create_test_validator();
let result = validator.is_unsafe_command("");
assert!(result.is_err()); // Empty commands are now denied by default
}
#[test]
fn test_complex_safe_command() {
let validator = create_test_validator();
let result = validator.is_unsafe_command("find /tmp -name *.txt");
assert!(result.is_err()); // Not in whitelist, so denied by default
}
#[test]
fn test_invalid_regex_in_whitelist() {
let blacklist = Blacklist {
commands: vec![],
regex: vec![],
};
let whitelist = Whitelist {
commands: vec![],
regex: vec!["[invalid_regex".to_string()], // Invalid regex
};
let validator = Validator::new(blacklist, whitelist);
let result = validator.is_unsafe_command("test");
// Should be denied because command is not in whitelist and default is deny
assert!(result.is_err());
}
#[test]
fn test_case_sensitivity() {
let validator = create_test_validator();
// Test uppercase version of blacklisted command
let result = validator.is_unsafe_command("RM");
// Should be denied because not in whitelist (default deny)
assert!(result.is_err());
// Test exact case match for blacklisted command
let result = validator.is_unsafe_command("rm");
assert!(result.is_err()); // Should be blocked by blacklist
}
#[test]
fn test_partial_command_match() {
let validator = create_test_validator();
// Test command that contains blacklisted word but isn't exact match
let result = validator.is_unsafe_command("remove"); // contains "rm" but shouldn't match
assert!(result.is_err()); // Should be denied because not in whitelist (default deny)
}
#[test]
fn test_blacklist_priority_over_whitelist() {
// Test that blacklist has higher priority than whitelist
let blacklist = Blacklist {
commands: vec!["echo hello".to_string()], // Changed to exact match
regex: vec![],
};
let whitelist = Whitelist {
commands: vec!["echo hello".to_string()],
regex: vec!["^echo .*".to_string()],
};
let validator = Validator::new(blacklist, whitelist);
let result = validator.is_unsafe_command("echo hello");
assert!(result.is_err()); // Should be blocked because "echo hello" is in blacklist
}
#[test]
fn test_blacklist_regex_priority() {
// Test that blacklist regex has higher priority than whitelist
let blacklist = Blacklist {
commands: vec![],
regex: vec![".*sudo.*".to_string()],
};
let whitelist = Whitelist {
commands: vec!["sudo ls".to_string()],
regex: vec!["^sudo .*".to_string()],
};
let validator = Validator::new(blacklist, whitelist);
let result = validator.is_unsafe_command("sudo ls");
assert!(result.is_err()); // Should be blocked by blacklist regex
}
#[test]
fn test_default_deny_behavior() {
// Test that commands not in whitelist are denied by default
let blacklist = Blacklist {
commands: vec![],
regex: vec![],
};
let whitelist = Whitelist {
commands: vec!["pwd".to_string()],
regex: vec![],
};
let validator = Validator::new(blacklist, whitelist);
// Command in whitelist should be allowed
let result = validator.is_unsafe_command("pwd");
assert!(result.is_ok());
// Command not in whitelist should be denied
let result = validator.is_unsafe_command("ls");
assert!(result.is_err());
}
#[test]
fn test_exact_vs_partial_blacklist_matching() {
// Test the fix: blacklist should match exactly, not partially
let blacklist = Blacklist {
commands: vec!["rm".to_string()], // Only "rm" exactly
regex: vec![],
};
let whitelist = Whitelist {
commands: vec!["rm -rf /tmp/test".to_string()], // Allow this specific command
regex: vec![],
};
let validator = Validator::new(blacklist, whitelist);
// "rm" exactly should be blocked by blacklist
let result = validator.is_unsafe_command("rm");
assert!(
result.is_err(),
"Exact match 'rm' should be blocked by blacklist"
);
// "rm -rf /tmp/test" should be allowed by whitelist (not blocked by blacklist)
let result = validator.is_unsafe_command("rm -rf /tmp/test");
assert!(
result.is_ok(),
"'rm -rf /tmp/test' should be allowed by whitelist"
);
// "rm -rf /" should be denied (not in whitelist, not exact blacklist match)
let result = validator.is_unsafe_command("rm -rf /");
assert!(result.is_err(), "'rm -rf /' should be denied by default");
}
}
@@ -0,0 +1,82 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
<!DOCTYPE html>
<html>
<head>
<title>MCP OAuth Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px auto; max-width: 800px; line-height: 1.6; }
h1, h2 { color: #333; }
code { background: #f4f4f4; padding: 2px 5px; border-radius: 3px; }
.endpoint { background: #f9f9f9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
.flow { background: #e8f5e9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
</style>
</head>
<body>
<h1>MCP OAuth Server</h1>
<p>This is an MCP server with OAuth 2.0 integration to a third-party authorization server.</p>
<h2>Available Endpoints:</h2>
<div class="endpoint">
<h3>Authorization Endpoint</h3>
<p><code>GET /authorize</code></p>
<p>Parameters:</p>
<ul>
<li><code>response_type</code> - Must be "code"</li>
<li><code>client_id</code> - Client identifier (e.g., "mcp-client")</li>
<li><code>redirect_uri</code> - URI to redirect after authorization</li>
<li><code>scope</code> - Optional requested scope</li>
<li><code>state</code> - Optional state value for CSRF prevention</li>
</ul>
</div>
<div class="endpoint">
<h3>Token Endpoint</h3>
<p><code>POST /token</code></p>
<p>Parameters:</p>
<ul>
<li><code>grant_type</code> - Must be "authorization_code"</li>
<li><code>code</code> - The authorization code</li>
<li><code>client_id</code> - Client identifier</li>
<li><code>client_secret</code> - Client secret</li>
<li><code>redirect_uri</code> - Redirect URI used in authorization request</li>
</ul>
</div>
<div class="endpoint">
<h3>MCP streamablehttp Endpoints</h3>
<p><code>/mcp</code> - Streamablehttp connection endpoint (requires OAuth token)</p>
</div>
<div class="flow">
<h2>OAuth Flow:</h2>
<ol>
<li>MCP Client initiates OAuth flow with this MCP Server</li>
<li>MCP Server redirects to Third-Party OAuth Server</li>
<li>User authenticates with Third-Party Server</li>
<li>Third-Party Server redirects back to MCP Server with auth code</li>
<li>MCP Server exchanges the code for a third-party access token</li>
<li>MCP Server generates its own token bound to the third-party session</li>
<li>MCP Server completes the OAuth flow with the MCP Client</li>
</ol>
</div>
</body>
</html>
+618
View File
@@ -0,0 +1,618 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//! MCP Bash Server - A Model Context Protocol server for executing bash commands
//!
//! This server provides secure bash command execution capabilities through the MCP protocol.
//! It includes OAuth2 authentication, command validation, and cross-platform support.
//!
//! Features:
//! - Secure command execution with blacklist validation
//! - OAuth2 authentication for client authorization
//! - Cross-platform shell support (Linux, Windows, macOS)
//! - Built-in system information tools
//! - Configurable timeout and environment settings
use std::sync::OnceLock;
use std::{net::SocketAddr, sync::Arc};
use anyhow::Result;
use axum::{
Router,
body::Body,
http::{HeaderMap, Request},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Import modules
mod common;
use common::bash_server::BashServer;
use common::config;
use common::oauth::{
McpOAuthStore, oauth_approve, oauth_authorization_server, oauth_authorize, oauth_register,
oauth_token, validate_token_middleware,
};
const INDEX_HTML: &str = include_str!("html/mcp_oauth_index.html");
/// Global storage for server bind address, initialized once at startup
// Init once from environment variable BIND_ADDRESS
pub static BIND_ADDRESS: OnceLock<String> = OnceLock::new();
/// Root path handler
/// Serves the main OAuth authorization index page
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
/// Wrapper function for oauth_authorization_server to handle BIND_ADDRESS
async fn oauth_authorization_server_handler(headers: HeaderMap) -> impl IntoResponse {
let bind_address = BIND_ADDRESS
.get()
.expect("BIND_ADDRESS must be initialized in main()");
oauth_authorization_server(bind_address, headers).await
}
/// HTTP request logging middleware
/// Logs all incoming requests including method, URI, headers and response status
async fn log_request(request: Request<Body>, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let version = request.version();
// Log headers
let headers = request.headers().clone();
let mut header_log = String::new();
for (key, value) in headers.iter() {
let value_str = value.to_str().unwrap_or("<binary>");
header_log.push_str(&format!("\n {key}: {value_str}"));
}
// Try to get request body for form submissions
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let request_info = if content_type.contains("application/x-www-form-urlencoded")
|| content_type.contains("application/json")
{
format!("{method} {uri} {version:?}{header_log}\nContent-Type: {content_type}")
} else {
format!("{method} {uri} {version:?}{header_log}")
};
info!("REQUEST: {}", request_info);
// Call the actual handler
let response = next.run(request).await;
// Log response status
let status = response.status();
info!("RESPONSE: {} for {} {}", status, method, uri);
response
}
/// Main application entry point
/// Sets up logging, OAuth store, HTTP server, and starts the MCP bash server
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
let logs = tracing_appender::rolling::daily("logs", "mcp.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(logs);
let log_setting = tracing_subscriber::fmt::layer().with_writer(non_blocking);
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "debug".to_string().into()),
)
.with(tracing_subscriber::fmt::layer())
.with(log_setting)
.init();
// Read environment mode from config file, default to "production"
let config = config::Config::read_config("config.toml")?;
let env_mode = config
.settings
.env
.clone()
.unwrap_or_else(|| "production".to_string());
let is_dev = env_mode == "development";
// Create the OAuth store
let oauth_store = Arc::new(McpOAuthStore::new());
let host = config.settings.host.clone();
let port = config.settings.port;
let bind_address = format!("{host}:{port}");
let addr = bind_address.parse::<SocketAddr>()?;
let _ = BIND_ADDRESS.set(bind_address);
// Create StreamableHttpServer
let service = StreamableHttpService::new(
|| Ok(BashServer::new()),
LocalSessionManager::default().into(),
Default::default(),
);
let server_router = Router::new().nest_service("/mcp", service);
// Add OAuth authentication middleware only if not in development mode
let protected_server_router = if is_dev {
server_router
} else {
server_router.layer(middleware::from_fn_with_state(
oauth_store.clone(),
validate_token_middleware,
))
};
// Create CORS layer for the oauth authorization server endpoint
let cors_layer = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Create a sub-router for the oauth authorization server endpoint with CORS
let oauth_server_router = Router::new()
.route(
"/.well-known/oauth-authorization-server",
get(oauth_authorization_server_handler).options(oauth_authorization_server_handler),
)
.route("/token", post(oauth_token).options(oauth_token))
.route("/register", post(oauth_register).options(oauth_register))
.layer(cors_layer)
.with_state(oauth_store.clone());
// Create HTTP router with request logging middleware
let app = Router::new()
.route("/", get(index))
.route("/authorize", get(oauth_authorize))
.route("/approve", post(oauth_approve))
.merge(oauth_server_router) // Merge the CORS-enabled oauth server router
.merge(protected_server_router)
.with_state(oauth_store.clone())
.layer(middleware::from_fn(log_request));
// Start HTTP server
info!("MCP OAuth Server started on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async { tokio::signal::ctrl_c().await.unwrap() })
.await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Method;
use axum::http::Request;
#[tokio::test]
async fn test_index_handler() {
let response = index().await;
let html_content = response.0;
// Verify it returns the expected HTML content
assert_eq!(html_content, INDEX_HTML);
assert!(html_content.contains("OAuth"));
}
#[tokio::test]
async fn test_oauth_authorization_server_handler() {
use axum::http::HeaderMap;
// Set up BIND_ADDRESS for testing
let _ = BIND_ADDRESS.set("localhost:8080".to_string());
let mut headers = HeaderMap::new();
headers.insert("host", "localhost:8080".parse().unwrap());
let response = oauth_authorization_server_handler(headers).await;
// Test that the handler returns a response
// We can't easily test the exact content without mocking, but we can verify it doesn't panic
let _response_body = response.into_response();
}
#[test]
fn test_bind_address_initialization() {
// Create a new OnceLock for testing to avoid conflicts
let test_bind_address: OnceLock<String> = OnceLock::new();
// Test that we can set the value once
let result = test_bind_address.set("127.0.0.1:9090".to_string());
assert!(result.is_ok());
// Test that we can get the value
let value = test_bind_address.get();
assert!(value.is_some());
assert_eq!(value.unwrap(), "127.0.0.1:9090");
// Test that we can't set it again
let result2 = test_bind_address.set("different:port".to_string());
assert!(result2.is_err());
}
#[test]
fn test_index_html_constant() {
// Test that INDEX_HTML is not empty and contains expected content
assert!(INDEX_HTML.contains("html") || INDEX_HTML.contains("HTML"));
}
#[tokio::test]
async fn test_log_request_middleware_functionality() {
// Test basic properties of log_request function
// Since it requires complex setup with actual middleware,
// we focus on testing the types and structure
let request = Request::builder()
.method(Method::GET)
.uri("/test")
.body(Body::empty())
.unwrap();
// Verify request properties that log_request would process
assert_eq!(request.method(), Method::GET);
assert_eq!(request.uri().path(), "/test");
assert!(request.headers().is_empty());
}
#[test]
fn test_module_imports() {
// Test that our modules are properly imported and accessible
let _server = BashServer::new();
let _store = McpOAuthStore::new();
// Test config module
let config_result = config::Config::read_config("nonexistent.toml");
assert!(config_result.is_err()); // Should fail gracefully
}
#[test]
fn test_error_handling_types() {
// Test that Result type is properly used
let test_result: Result<String> = Ok("test".to_string());
assert!(test_result.is_ok());
let test_error: Result<String> = Err(anyhow::anyhow!("test error"));
assert!(test_error.is_err());
}
#[test]
fn test_dependencies_availability() {
// Test that critical dependencies are available
use std::sync::Arc;
let _arc_store = Arc::new(McpOAuthStore::new());
// Test that we can create basic types
let _socket_addr: Result<SocketAddr, _> = "127.0.0.1:8080".parse();
}
// ========== OAuth Mock Tests ==========
#[tokio::test]
async fn test_oauth_store_functionality() {
use common::oauth::{AuthToken, McpOAuthStore};
use oauth2::{AccessToken, EmptyExtraTokenFields};
let store = McpOAuthStore::new();
// Test client validation
let client = store
.validate_client("mcp-client", "http://localhost:8080/callback")
.await;
assert!(client.is_some());
let invalid_client = store
.validate_client("invalid-client", "http://localhost:8080/callback")
.await;
assert!(invalid_client.is_none());
// Test auth session creation
let session_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile email".to_string()),
Some("test-state".to_string()),
"test-session-123".to_string(),
)
.await;
assert_eq!(session_id, "test-session-123");
// Test token update and MCP token creation
let auth_token = AuthToken::new(
AccessToken::new("mock-third-party-token".to_string()),
oauth2::basic::BasicTokenType::Bearer,
EmptyExtraTokenFields {},
);
let update_result = store
.update_auth_session_token(&session_id, auth_token)
.await;
assert!(update_result.is_ok());
let mcp_token = store.create_mcp_token(&session_id).await;
assert!(mcp_token.is_ok());
let token = mcp_token.unwrap();
assert!(token.access_token.starts_with("mcp-token-"));
assert_eq!(token.client_id, "mcp-client");
// Test token validation
let validated = store.validate_token(&token.access_token).await;
assert!(validated.is_some());
}
#[tokio::test]
async fn test_oauth_authorization_flow_mock() {
use common::oauth::{AuthorizeQuery, McpOAuthStore};
use std::sync::Arc;
let store = Arc::new(McpOAuthStore::new());
// Mock authorization request
let auth_query = AuthorizeQuery {
response_type: "code".to_string(),
client_id: "mcp-client".to_string(),
redirect_uri: "http://localhost:8080/callback".to_string(),
scope: Some("profile email".to_string()),
state: Some("test-state-456".to_string()),
};
// Test that oauth_authorize function can be called
// Note: In a real test, we'd use test frameworks like tower::ServiceExt
// but here we're testing the basic functionality
let store_clone = store.clone();
let sessions_before = store_clone.auth_sessions.read().await.len();
// Verify store is accessible and functional
assert_eq!(sessions_before, 0);
// Test client validation within the flow
let client_validation = store
.validate_client(&auth_query.client_id, &auth_query.redirect_uri)
.await;
assert!(client_validation.is_some());
}
#[tokio::test]
async fn test_oauth_token_exchange_mock() {
use common::oauth::AuthToken;
use common::oauth::{McpOAuthStore, TokenRequest};
use oauth2::{AccessToken, EmptyExtraTokenFields};
use std::sync::Arc;
let store = Arc::new(McpOAuthStore::new());
// Create a session and add auth token (simulating successful OAuth flow)
let session_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile".to_string()),
Some("test-state".to_string()),
"token-exchange-session".to_string(),
)
.await;
let auth_token = AuthToken::new(
AccessToken::new("mock-external-token".to_string()),
oauth2::basic::BasicTokenType::Bearer,
EmptyExtraTokenFields {},
);
store
.update_auth_session_token(&session_id, auth_token)
.await
.unwrap();
// Mock token request (just for structure validation)
let _token_request = TokenRequest {
grant_type: "authorization_code".to_string(),
code: "mock-auth-code".to_string(),
client_id: "mcp-client".to_string(),
client_secret: "mcp-client-secret".to_string(),
redirect_uri: "http://localhost:8080/callback".to_string(),
code_verifier: None,
refresh_token: "".to_string(),
};
// Test token creation
let mcp_token_result = store.create_mcp_token(&session_id).await;
assert!(mcp_token_result.is_ok());
let mcp_token = mcp_token_result.unwrap();
assert_eq!(mcp_token.token_type, "bearer");
assert_eq!(mcp_token.expires_in, Some(3600));
assert!(mcp_token.refresh_token.is_some());
// Verify token can be validated
let validation_result = store.validate_token(&mcp_token.access_token).await;
assert!(validation_result.is_some());
}
#[tokio::test]
async fn test_oauth_middleware_functionality() {
use common::oauth::AuthToken;
use common::oauth::McpOAuthStore;
use oauth2::{AccessToken, EmptyExtraTokenFields};
use std::sync::Arc;
let store = Arc::new(McpOAuthStore::new());
// Create a valid token for middleware testing
let session_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile".to_string()),
None,
"middleware-test-session".to_string(),
)
.await;
let auth_token = AuthToken::new(
AccessToken::new("middleware-test-token".to_string()),
oauth2::basic::BasicTokenType::Bearer,
EmptyExtraTokenFields {},
);
store
.update_auth_session_token(&session_id, auth_token)
.await
.unwrap();
let mcp_token = store.create_mcp_token(&session_id).await.unwrap();
// Test token validation (simulating middleware behavior)
let valid_token_check = store.validate_token(&mcp_token.access_token).await;
assert!(valid_token_check.is_some());
// Test invalid token
let invalid_token_check = store.validate_token("invalid-token-12345").await;
assert!(invalid_token_check.is_none());
// Test empty token
let empty_token_check = store.validate_token("").await;
assert!(empty_token_check.is_none());
}
#[tokio::test]
async fn test_oauth_error_handling() {
use common::oauth::McpOAuthStore;
use std::sync::Arc;
let store = Arc::new(McpOAuthStore::new());
// Test creating MCP token without session
let no_session_result = store.create_mcp_token("nonexistent-session").await;
assert!(no_session_result.is_err());
assert_eq!(no_session_result.unwrap_err(), "Session not found");
// Test creating MCP token without auth token in session
let session_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile".to_string()),
None,
"no-auth-token-session".to_string(),
)
.await;
let no_auth_token_result = store.create_mcp_token(&session_id).await;
assert!(no_auth_token_result.is_err());
assert_eq!(
no_auth_token_result.unwrap_err(),
"No third-party token available for session"
);
// Test updating nonexistent session
let auth_token = oauth2::StandardTokenResponse::new(
oauth2::AccessToken::new("test-token".to_string()),
oauth2::basic::BasicTokenType::Bearer,
oauth2::EmptyExtraTokenFields {},
);
let update_nonexistent = store
.update_auth_session_token("nonexistent", auth_token)
.await;
assert!(update_nonexistent.is_err());
assert_eq!(update_nonexistent.unwrap_err(), "Session not found");
}
#[tokio::test]
async fn test_oauth_security_validations() {
use common::oauth::McpOAuthStore;
use std::sync::Arc;
let store = Arc::new(McpOAuthStore::new());
// Test invalid client ID
let invalid_client = store
.validate_client("malicious-client", "http://localhost:8080/callback")
.await;
assert!(invalid_client.is_none());
// Test invalid redirect URI (potential open redirect attack)
let malicious_redirect = store
.validate_client("mcp-client", "http://evil.com/steal-tokens")
.await;
assert!(malicious_redirect.is_none());
// Test valid client with valid redirect URI
let valid_client = store
.validate_client("mcp-client", "http://localhost:8080/callback")
.await;
assert!(valid_client.is_some());
// Test that tokens are properly random and unique
let session1_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile".to_string()),
None,
"security-test-1".to_string(),
)
.await;
let session2_id = store
.create_auth_session(
"mcp-client".to_string(),
Some("profile".to_string()),
None,
"security-test-2".to_string(),
)
.await;
// Add auth tokens to both sessions
for (i, session_id) in [&session1_id, &session2_id].iter().enumerate() {
let auth_token = oauth2::StandardTokenResponse::new(
oauth2::AccessToken::new(format!("security-token-{i}")),
oauth2::basic::BasicTokenType::Bearer,
oauth2::EmptyExtraTokenFields {},
);
store
.update_auth_session_token(session_id, auth_token)
.await
.unwrap();
}
let token1 = store.create_mcp_token(&session1_id).await.unwrap();
let token2 = store.create_mcp_token(&session2_id).await.unwrap();
// Tokens should be different
assert_ne!(token1.access_token, token2.access_token);
assert_ne!(token1.refresh_token, token2.refresh_token);
// Both should be valid
assert!(store.validate_token(&token1.access_token).await.is_some());
assert!(store.validate_token(&token2.access_token).await.is_some());
}
}