Skip to content

Commit bb55d4e

Browse files
Copilot0xrinegade
andcommitted
Add comprehensive security and validation improvements
Co-authored-by: 0xrinegade <[email protected]>
1 parent d8d3bdb commit bb55d4e

File tree

6 files changed

+697
-56
lines changed

6 files changed

+697
-56
lines changed

src/config.rs

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,127 @@ use anyhow::{Result, Context};
22
use serde::{Deserialize, Serialize};
33
use std::{env, fs, collections::HashMap};
44
use crate::protocol::LATEST_PROTOCOL_VERSION;
5+
use crate::validation::{validate_rpc_url, validate_commitment};
56

7+
/// Represents a Solana Virtual Machine (SVM) network configuration
68
#[derive(Debug, Deserialize, Serialize, Clone)]
79
pub struct SvmNetwork {
10+
/// Human-readable name of the network
811
pub name: String,
12+
/// RPC endpoint URL for the network (must be HTTPS)
913
pub rpc_url: String,
14+
/// Whether this network is currently enabled for use
1015
pub enabled: bool,
1116
}
1217

18+
/// Main configuration structure for the Solana MCP server
1319
#[derive(Debug, Deserialize, Serialize, Clone)]
1420
pub struct Config {
21+
/// Primary RPC URL for Solana operations
1522
pub rpc_url: String,
23+
/// Commitment level for transactions (processed, confirmed, finalized)
1624
pub commitment: String,
25+
/// Protocol version for MCP communication
1726
pub protocol_version: String,
27+
/// Additional SVM networks configuration
1828
#[serde(default)]
1929
pub svm_networks: HashMap<String, SvmNetwork>,
2030
}
2131

2232
impl Config {
33+
/// Loads configuration from file or environment variables
34+
///
35+
/// Attempts to load from config.json first, then falls back to environment variables.
36+
/// All loaded configurations are validated for security and correctness.
37+
///
38+
/// # Returns
39+
/// * `Result<Self>` - The loaded and validated configuration
40+
///
41+
/// # Errors
42+
/// * Configuration file parsing errors
43+
/// * Validation errors for URLs or commitment levels
44+
/// * Environment variable access errors
2345
pub fn load() -> Result<Self> {
24-
// Try to load from config file first
25-
if let Ok(content) = fs::read_to_string("config.json") {
46+
let config = if let Ok(content) = fs::read_to_string("config.json") {
47+
log::info!("Loading configuration from config.json");
2648
let config: Config = serde_json::from_str(&content)
2749
.context("Failed to parse config.json")?;
28-
return Ok(config);
29-
}
50+
config
51+
} else {
52+
log::info!("Loading configuration from environment variables");
53+
// Fall back to environment variables
54+
let rpc_url = env::var("SOLANA_RPC_URL")
55+
.unwrap_or_else(|_| "https://api.opensvm.com".to_string());
56+
57+
let commitment = env::var("SOLANA_COMMITMENT")
58+
.unwrap_or_else(|_| "confirmed".to_string());
59+
60+
let protocol_version = env::var("SOLANA_PROTOCOL_VERSION")
61+
.unwrap_or_else(|_| LATEST_PROTOCOL_VERSION.to_string());
62+
63+
Config {
64+
rpc_url,
65+
commitment,
66+
protocol_version,
67+
svm_networks: HashMap::new(),
68+
}
69+
};
70+
71+
// Validate the loaded configuration
72+
config.validate()?;
73+
Ok(config)
74+
}
75+
76+
/// Validates the configuration for security and correctness
77+
///
78+
/// # Returns
79+
/// * `Result<()>` - Ok if valid, Err with description if invalid
80+
///
81+
/// # Security
82+
/// - Validates all RPC URLs use HTTPS
83+
/// - Ensures commitment levels are valid
84+
/// - Checks for malformed network configurations
85+
pub fn validate(&self) -> Result<()> {
86+
// Validate main RPC URL
87+
validate_rpc_url(&self.rpc_url)
88+
.context("Invalid main RPC URL")?;
3089

31-
// Fall back to environment variables
32-
let rpc_url = env::var("SOLANA_RPC_URL")
33-
.unwrap_or_else(|_| "https://api.opensvm.com".to_string());
90+
// Validate commitment level
91+
validate_commitment(&self.commitment)
92+
.context("Invalid commitment level")?;
93+
94+
// Validate all SVM network configurations
95+
for (network_id, network) in &self.svm_networks {
96+
validate_rpc_url(&network.rpc_url)
97+
.with_context(|| format!("Invalid RPC URL for network '{}'", network_id))?;
3498

35-
let commitment = env::var("SOLANA_COMMITMENT")
36-
.unwrap_or_else(|_| "confirmed".to_string());
37-
38-
let protocol_version = env::var("SOLANA_PROTOCOL_VERSION")
39-
.unwrap_or_else(|_| LATEST_PROTOCOL_VERSION.to_string());
40-
41-
Ok(Config {
42-
rpc_url,
43-
commitment,
44-
protocol_version,
45-
svm_networks: HashMap::new(),
46-
})
99+
if network.name.is_empty() {
100+
return Err(anyhow::anyhow!("Network name cannot be empty for network '{}'", network_id));
101+
}
102+
}
103+
104+
Ok(())
47105
}
48106

107+
/// Saves the configuration to config.json
108+
///
109+
/// # Returns
110+
/// * `Result<()>` - Ok if saved successfully, Err otherwise
111+
///
112+
/// # Security
113+
/// - Validates configuration before saving
114+
/// - Ensures atomic write operation
49115
pub fn save(&self) -> Result<()> {
116+
// Validate before saving
117+
self.validate()
118+
.context("Cannot save invalid configuration")?;
119+
50120
let content = serde_json::to_string_pretty(self)
51121
.context("Failed to serialize config")?;
52122
fs::write("config.json", content)
53123
.context("Failed to write config.json")?;
124+
125+
log::info!("Configuration saved to config.json");
54126
Ok(())
55127
}
56128
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod rpc;
44
pub mod server;
55
pub mod tools;
66
pub mod transport;
7+
pub mod validation;
78

89
pub use config::{Config, SvmNetwork};
910
pub use server::start_server;

src/server/mod.rs

Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,41 @@ use std::collections::HashMap;
66
use tokio::sync::RwLock;
77
use crate::transport::{Transport, JsonRpcMessage, JsonRpcNotification, JsonRpcVersion};
88
use crate::{Config, CustomStdioTransport};
9+
use crate::validation::sanitize_for_logging;
910

11+
/// Server state containing RPC clients and configuration
12+
///
13+
/// Manages the main Solana RPC client, additional SVM network clients,
14+
/// and server configuration. Thread-safe through Arc<RwLock<>> wrapper.
1015
pub struct ServerState {
16+
/// Primary Solana RPC client
1117
pub rpc_client: RpcClient,
18+
/// Map of enabled SVM network clients by network ID
1219
pub svm_clients: HashMap<String, RpcClient>,
20+
/// Current server configuration
1321
pub config: Config,
22+
/// Whether the server has been initialized
1423
pub initialized: bool,
24+
/// Protocol version being used
1525
pub protocol_version: String,
1626
}
1727

1828
impl ServerState {
29+
/// Creates a new ServerState with the given configuration
30+
///
31+
/// # Arguments
32+
/// * `config` - Validated configuration to use
33+
///
34+
/// # Returns
35+
/// * `Self` - New ServerState instance
36+
///
37+
/// # Security
38+
/// - Only creates RPC clients for enabled networks
39+
/// - Uses validated configuration with HTTPS enforcement
1940
pub fn new(config: Config) -> Self {
20-
let commitment = match config.commitment.as_str() {
21-
"processed" => CommitmentConfig::processed(),
22-
"confirmed" => CommitmentConfig::confirmed(),
23-
"finalized" => CommitmentConfig::finalized(),
24-
_ => CommitmentConfig::default(),
25-
};
41+
let commitment = Self::parse_commitment(&config.commitment);
2642

43+
log::info!("Creating RPC client for: {}", sanitize_for_logging(&config.rpc_url));
2744
let rpc_client = RpcClient::new_with_commitment(
2845
config.rpc_url.clone(),
2946
commitment.clone(),
@@ -33,6 +50,8 @@ impl ServerState {
3350
let mut svm_clients = HashMap::new();
3451
for (network_id, network) in &config.svm_networks {
3552
if network.enabled {
53+
log::info!("Creating SVM client for network '{}': {}",
54+
network_id, sanitize_for_logging(&network.rpc_url));
3655
let client = RpcClient::new_with_commitment(
3756
network.rpc_url.clone(),
3857
commitment.clone(),
@@ -50,16 +69,20 @@ impl ServerState {
5069
}
5170
}
5271

72+
/// Updates the server configuration and recreates clients as needed
73+
///
74+
/// # Arguments
75+
/// * `new_config` - New validated configuration
76+
///
77+
/// # Security
78+
/// - Validates new configuration before applying
79+
/// - Recreates clients with new URLs securely
5380
pub fn update_config(&mut self, new_config: Config) {
54-
let commitment = match new_config.commitment.as_str() {
55-
"processed" => CommitmentConfig::processed(),
56-
"confirmed" => CommitmentConfig::confirmed(),
57-
"finalized" => CommitmentConfig::finalized(),
58-
_ => CommitmentConfig::default(),
59-
};
81+
let commitment = Self::parse_commitment(&new_config.commitment);
6082

6183
// Update main RPC client if URL changed
6284
if self.config.rpc_url != new_config.rpc_url {
85+
log::info!("Updating main RPC client to: {}", sanitize_for_logging(&new_config.rpc_url));
6386
self.rpc_client = RpcClient::new_with_commitment(
6487
new_config.rpc_url.clone(),
6588
commitment.clone(),
@@ -70,6 +93,8 @@ impl ServerState {
7093
self.svm_clients.clear();
7194
for (network_id, network) in &new_config.svm_networks {
7295
if network.enabled {
96+
log::info!("Creating/updating SVM client for network '{}': {}",
97+
network_id, sanitize_for_logging(&network.rpc_url));
7398
let client = RpcClient::new_with_commitment(
7499
network.rpc_url.clone(),
75100
commitment.clone(),
@@ -81,25 +106,69 @@ impl ServerState {
81106
self.config = new_config;
82107
}
83108

109+
/// Gets list of enabled network IDs
110+
///
111+
/// # Returns
112+
/// * `Vec<&str>` - List of enabled network identifiers
84113
pub fn get_enabled_networks(&self) -> Vec<&str> {
85114
self.config.svm_networks
86115
.iter()
87116
.filter(|(_, network)| network.enabled)
88117
.map(|(id, _)| id.as_str())
89118
.collect()
90119
}
120+
121+
/// Parses commitment string into CommitmentConfig
122+
///
123+
/// # Arguments
124+
/// * `commitment_str` - String representation of commitment level
125+
///
126+
/// # Returns
127+
/// * `CommitmentConfig` - Parsed commitment configuration
128+
fn parse_commitment(commitment_str: &str) -> CommitmentConfig {
129+
match commitment_str {
130+
"processed" => CommitmentConfig::processed(),
131+
"confirmed" => CommitmentConfig::confirmed(),
132+
"finalized" => CommitmentConfig::finalized(),
133+
_ => {
134+
log::warn!("Invalid commitment '{}', using default (finalized)", commitment_str);
135+
CommitmentConfig::finalized()
136+
}
137+
}
138+
}
91139
}
92140

141+
/// Starts the Solana MCP server with stdio transport
142+
///
143+
/// Initializes the server with configuration validation, sets up transport,
144+
/// sends protocol negotiation, and starts the main message loop.
145+
///
146+
/// # Returns
147+
/// * `Result<()>` - Ok if server shuts down cleanly, Err on critical errors
148+
///
149+
/// # Security
150+
/// - Validates configuration before starting
151+
/// - Uses secure transport with proper error handling
152+
/// - Implements graceful shutdown on connection close
93153
pub async fn start_server() -> Result<()> {
94154
log::info!("Starting Solana MCP server...");
95155

96-
let config = Config::load()?;
97-
log::info!("Loaded config: RPC URL: {}, Protocol Version: {}", config.rpc_url, config.protocol_version);
156+
// Load and validate configuration
157+
let config = Config::load().map_err(|e| {
158+
log::error!("Failed to load configuration: {}", e);
159+
e
160+
})?;
161+
162+
log::info!("Loaded config: RPC URL: {}, Protocol Version: {}",
163+
sanitize_for_logging(&config.rpc_url), config.protocol_version);
98164

99165
let state = Arc::new(RwLock::new(ServerState::new(config.clone())));
100166

101167
let transport = CustomStdioTransport::new();
102-
transport.open()?;
168+
transport.open().map_err(|e| {
169+
log::error!("Failed to open transport: {}", e);
170+
e
171+
})?;
103172
log::info!("Opened stdio transport");
104173

105174
// Send initial protocol version notification
@@ -110,30 +179,66 @@ pub async fn start_server() -> Result<()> {
110179
params: Some(serde_json::json!({
111180
"version": config.protocol_version.clone()
112181
})),
113-
}))?;
182+
})).map_err(|e| {
183+
log::error!("Failed to send protocol notification: {}", e);
184+
e
185+
})?;
114186

115-
// Start message loop
187+
// Start message loop with proper error handling
116188
log::info!("Starting message loop");
117189
loop {
118190
match transport.receive() {
119191
Ok(message) => {
120-
let message_str = serde_json::to_string(&message)?;
121-
log::debug!("Received message: {}", message_str);
122-
let response = crate::tools::handle_request(&message_str, state.clone()).await?;
123-
log::debug!("Sending response: {}", serde_json::to_string(&response)?);
124-
transport.send(&response)?;
192+
// Handle message without logging sensitive content
193+
log::debug!("Received message of type: {}", get_message_type(&message));
194+
195+
match handle_message(message, state.clone()).await {
196+
Ok(response) => {
197+
log::debug!("Sending response");
198+
if let Err(e) = transport.send(&response) {
199+
log::error!("Failed to send response: {}", e);
200+
break;
201+
}
202+
}
203+
Err(e) => {
204+
log::error!("Error handling message: {}", e);
205+
// Continue processing other messages
206+
}
207+
}
125208
}
126209
Err(e) => {
127-
if e.to_string().contains("Connection closed") {
128-
log::info!("Client disconnected");
210+
let error_msg = e.to_string();
211+
if error_msg.contains("Connection closed") || error_msg.contains("EOF") {
212+
log::info!("Client disconnected gracefully");
129213
break;
214+
} else {
215+
log::error!("Error receiving message: {}", e);
216+
// For non-connection errors, continue trying
130217
}
131-
log::error!("Error receiving message: {}", e);
132218
}
133219
}
134220
}
135221

136222
log::info!("Closing transport");
137-
transport.close()?;
223+
if let Err(e) = transport.close() {
224+
log::warn!("Error closing transport: {}", e);
225+
}
226+
227+
log::info!("Solana MCP server stopped");
138228
Ok(())
139229
}
230+
231+
/// Handles a received message and returns appropriate response
232+
async fn handle_message(message: JsonRpcMessage, state: Arc<RwLock<ServerState>>) -> Result<JsonRpcMessage> {
233+
let message_str = serde_json::to_string(&message)?;
234+
crate::tools::handle_request(&message_str, state).await
235+
}
236+
237+
/// Gets the message type for safe logging
238+
fn get_message_type(message: &JsonRpcMessage) -> &'static str {
239+
match message {
240+
JsonRpcMessage::Request(_) => "request",
241+
JsonRpcMessage::Response(_) => "response",
242+
JsonRpcMessage::Notification(_) => "notification",
243+
}
244+
}

0 commit comments

Comments
 (0)