586 lines
24 KiB
Rust
586 lines
24 KiB
Rust
//! Capability-gating helpers used during MCP server startup.
|
|
//!
|
|
//! Each `query_*_for` function pairs a capability check with the actual list
|
|
//! call from rmcp, gating the call on advertisement and failing soft on errors.
|
|
//! They take the list call as a closure so unit tests can drive the gate-and-
|
|
//! fail-soft control flow with a fake `RunningService` substitute.
|
|
|
|
use std::collections::HashMap;
|
|
use std::future::Future;
|
|
|
|
use cfg_if::cfg_if;
|
|
use cloud_object_models::{StaticEnvVar, TransportType};
|
|
use futures::FutureExt as _;
|
|
use rmcp::transport::ConfigureCommandExt as _;
|
|
use rmcp::ServiceExt as _;
|
|
use simple_logger::SimpleLogger;
|
|
use tokio::io::AsyncBufReadExt as _;
|
|
use uuid::Uuid;
|
|
|
|
use super::TemplatableMCPServerInfo;
|
|
|
|
type ReqwestHttpTransport = rmcp::transport::StreamableHttpClientTransport<reqwest::Client>;
|
|
type ReqwestSseTransport = crate::sse_transport::SseClientTransport<reqwest::Client>;
|
|
|
|
/// Convert an rmcp error to a user-friendly error message.
|
|
pub fn error_to_user_message(error: &rmcp::RmcpError) -> String {
|
|
match error {
|
|
rmcp::RmcpError::ClientInitialize(err) => {
|
|
format!("Failed to initialize client: {}", err)
|
|
}
|
|
rmcp::RmcpError::ServerInitialize(err) => {
|
|
format!("Failed to initialize server: {}", err)
|
|
}
|
|
rmcp::RmcpError::TransportCreation { error, .. } => {
|
|
format!("Failed to establish connection: {}", error)
|
|
}
|
|
rmcp::RmcpError::Runtime(err) => {
|
|
format!("Runtime error: {}", err)
|
|
}
|
|
rmcp::RmcpError::Service(err) => match err {
|
|
rmcp::ServiceError::McpError(_) => {
|
|
"Server returned an error. Please check server logs for details.".to_string()
|
|
}
|
|
rmcp::ServiceError::TransportSend(_) => {
|
|
"Failed to send data to server. Connection may have been lost.".to_string()
|
|
}
|
|
rmcp::ServiceError::TransportClosed => {
|
|
"Connection closed unexpectedly. The server may have crashed.".to_string()
|
|
}
|
|
rmcp::ServiceError::UnexpectedResponse => {
|
|
"Server sent an unexpected response. The server may be incompatible.".to_string()
|
|
}
|
|
rmcp::ServiceError::Cancelled { reason } => format!(
|
|
"Operation was cancelled with reason: {}",
|
|
reason.clone().unwrap_or("Unknown reason".to_string())
|
|
),
|
|
rmcp::ServiceError::Timeout { timeout } => {
|
|
format!(
|
|
"Connection timed out after {} seconds. The server may be unresponsive.",
|
|
timeout.as_secs()
|
|
)
|
|
}
|
|
_ => format!("Service error: {}", err),
|
|
},
|
|
// The enum is marked as non-exhaustive, so we need a catch-all.
|
|
_ => {
|
|
format!("Error: {error}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds a `HeaderMap` from a `HashMap<String, String>` of user-provided headers.
|
|
///
|
|
/// Invalid header names or values are skipped.
|
|
fn build_header_map(headers: &HashMap<String, String>) -> reqwest::header::HeaderMap {
|
|
headers.try_into().unwrap_or_default()
|
|
}
|
|
|
|
/// Builds a reqwest client with custom headers for MCP HTTP/SSE connections.
|
|
#[allow(clippy::result_large_err)]
|
|
pub fn build_client_with_headers(
|
|
headers: &HashMap<String, String>,
|
|
) -> Result<reqwest::Client, rmcp::RmcpError> {
|
|
let header_map = build_header_map(headers);
|
|
|
|
reqwest::Client::builder()
|
|
.default_headers(header_map)
|
|
.build()
|
|
.map_err(|e| {
|
|
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(format!(
|
|
"Failed to build client with headers: {e}",
|
|
))
|
|
})
|
|
}
|
|
|
|
/// Spawns a new MCP server from a given [`TransportType`].
|
|
#[allow(clippy::result_large_err)]
|
|
pub async fn spawn_server(
|
|
server_name: String,
|
|
description: Option<String>,
|
|
uuid: Uuid,
|
|
transport_type: TransportType,
|
|
logger: SimpleLogger,
|
|
auth_context: Option<crate::oauth::AuthContext>,
|
|
) -> Result<TemplatableMCPServerInfo, rmcp::RmcpError> {
|
|
logger.log("[note] Attention! There may be sensitive information (such as API keys) in these logs. Make sure to redact any secrets before sharing with others.".to_string());
|
|
|
|
let mut is_authenticated_transport = false;
|
|
let service = match transport_type {
|
|
TransportType::CLIServer(cli_server) => {
|
|
logger.log("[info] MCP: Using stdio transport".to_string());
|
|
|
|
cfg_if! {
|
|
if #[cfg(windows)] {
|
|
// We wrap the command in cmd.exe /c to allow Windows to be responsible for resolving the
|
|
// PATH variable rather than depending on the `Command` implementation, which only looks for
|
|
// `.exe` files in directories found in PATH.
|
|
// https://github.com/rust-lang/rust/issues/37519
|
|
let command = "cmd.exe".to_owned();
|
|
let args = std::iter::once("/c".to_owned())
|
|
.chain(std::iter::once(cli_server.command))
|
|
.chain(cli_server.args)
|
|
.collect::<Vec<String>>();
|
|
} else {
|
|
let command = cli_server.command;
|
|
let args = cli_server.args;
|
|
}
|
|
}
|
|
|
|
// Capture the command and configured cwd for diagnostics before they're
|
|
// moved into the Command builder closure.
|
|
let command_for_log = command.clone();
|
|
let cwd_for_log = cli_server.cwd_parameter.clone();
|
|
|
|
// Try to spawn the child process.
|
|
let (transport, stderr) = rmcp::transport::TokioChildProcess::builder(
|
|
tokio::process::Command::new(command).configure(|cmd| {
|
|
cmd.args(args);
|
|
if let Some(cwd) = cli_server.cwd_parameter {
|
|
cmd.current_dir(cwd);
|
|
}
|
|
for StaticEnvVar { name, value } in cli_server.static_env_vars.iter() {
|
|
if value.is_empty() {
|
|
// Skip empty/unset environment variables so that, in the CLI, they can be inherited.
|
|
logger.log(format!(
|
|
"[warn] MCP: Skipping empty environment variable: {name}"
|
|
));
|
|
continue;
|
|
}
|
|
cmd.env(name, value);
|
|
}
|
|
|
|
// On Windows, ensure that no console window is shown.
|
|
#[cfg(windows)]
|
|
cmd.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
|
|
}),
|
|
)
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.map_err(|err| {
|
|
if err.kind() == std::io::ErrorKind::NotFound {
|
|
let cwd_display = cwd_for_log
|
|
.as_deref()
|
|
.unwrap_or("<inherited from Warp's process cwd>");
|
|
logger.log(format!(
|
|
"[error] MCP: Failed to spawn '{server_name}': command '{command_for_log}' \
|
|
not found (cwd: {cwd_display}). If your MCP server depends on a specific \
|
|
working directory, set the `working_directory` field in your config to \
|
|
override the default."
|
|
));
|
|
}
|
|
rmcp::RmcpError::transport_creation::<rmcp::transport::TokioChildProcess>(err)
|
|
})?;
|
|
|
|
let pid = transport
|
|
.id()
|
|
.map(|pid| pid.to_string())
|
|
.unwrap_or("??".to_string());
|
|
|
|
// We always expect to have an stderr, but this is marginally safer than unwrapping.
|
|
if let Some(stderr) = stderr {
|
|
let logger = logger.clone();
|
|
// Spawn a background task to forward from the child process's stderr to our logger.
|
|
tokio::spawn(async move {
|
|
let mut buf = String::new();
|
|
let mut reader = tokio::io::BufReader::new(stderr);
|
|
loop {
|
|
match reader.read_line(&mut buf).await {
|
|
// EOF.
|
|
Ok(0) => return,
|
|
// Read some data.
|
|
Ok(_) => logger.log(format!("[info] MCP [pid: {pid}] stderr: {buf}")),
|
|
// Failed to read from the child process's stderr.
|
|
Err(e) => {
|
|
log::error!("Failed to read stderr: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wrap the transport in a logging wrapper.
|
|
let transport = TransportLoggingWrapper {
|
|
transport,
|
|
logger: logger.clone(),
|
|
};
|
|
|
|
// Create the MCP client and connect to the server.
|
|
Ok::<_, rmcp::RmcpError>(make_client_info().into_dyn().serve(transport).await?)
|
|
}
|
|
TransportType::ServerSentEvents(sse_server) => {
|
|
let headers: HashMap<String, String> = sse_server
|
|
.headers
|
|
.iter()
|
|
.map(|h| (h.name.clone(), h.value.clone()))
|
|
.collect();
|
|
match determine_transport(server_name.clone(), &sse_server.url, &headers, auth_context)
|
|
.await
|
|
{
|
|
// TODO: these need headers also?
|
|
Ok(Transport::Http(Some(client))) => {
|
|
is_authenticated_transport = true;
|
|
|
|
logger.log("[info] MCP: Using Streaming HTTP transport".to_string());
|
|
let transport = rmcp::transport::StreamableHttpClientTransport::with_client(
|
|
client,
|
|
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
|
|
sse_server.url.clone(),
|
|
),
|
|
);
|
|
let transport = TransportLoggingWrapper {
|
|
transport,
|
|
logger: logger.clone(),
|
|
};
|
|
Ok(make_client_info().into_dyn().serve(transport).await?)
|
|
}
|
|
Ok(Transport::Http(None)) => {
|
|
logger.log("[info] MCP: Using Streaming HTTP transport".to_string());
|
|
let transport = if headers.is_empty() {
|
|
rmcp::transport::StreamableHttpClientTransport::from_uri(
|
|
sse_server.url.clone(),
|
|
)
|
|
} else {
|
|
let client = build_client_with_headers(&headers)?;
|
|
rmcp::transport::StreamableHttpClientTransport::with_client(
|
|
client,
|
|
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
|
|
sse_server.url.clone(),
|
|
),
|
|
)
|
|
};
|
|
let transport = TransportLoggingWrapper {
|
|
transport,
|
|
logger: logger.clone(),
|
|
};
|
|
Ok(make_client_info().into_dyn().serve(transport).await?)
|
|
}
|
|
Ok(Transport::Sse(Some(client))) => {
|
|
is_authenticated_transport = true;
|
|
|
|
logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string());
|
|
let transport = crate::sse_transport::SseClientTransport::start_with_client(
|
|
client,
|
|
crate::sse_transport::SseClientConfig {
|
|
sse_endpoint: sse_server.url.into(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.map_err(rmcp::RmcpError::transport_creation::<ReqwestSseTransport>)?;
|
|
let transport = TransportLoggingWrapper {
|
|
transport,
|
|
logger: logger.clone(),
|
|
};
|
|
Ok(make_client_info().into_dyn().serve(transport).await?)
|
|
}
|
|
Ok(Transport::Sse(None)) => {
|
|
logger.log("[info] MCP: Using (legacy) SSE transport (due to preflight failing with a 404)".to_string());
|
|
let transport = if headers.is_empty() {
|
|
crate::sse_transport::SseClientTransport::start(sse_server.url.clone())
|
|
.await
|
|
.map_err(|e| {
|
|
rmcp::RmcpError::transport_creation::<ReqwestSseTransport>(e)
|
|
})?
|
|
} else {
|
|
let client = build_client_with_headers(&headers)?;
|
|
crate::sse_transport::SseClientTransport::start_with_client(
|
|
client,
|
|
crate::sse_transport::SseClientConfig {
|
|
sse_endpoint: sse_server.url.clone().into(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.map_err(rmcp::RmcpError::transport_creation::<ReqwestSseTransport>)?
|
|
};
|
|
let transport = TransportLoggingWrapper {
|
|
transport,
|
|
logger: logger.clone(),
|
|
};
|
|
Ok(make_client_info().into_dyn().serve(transport).await?)
|
|
}
|
|
Err(err) => {
|
|
logger.log(format!(
|
|
"[error] MCP: preflight connection to MCP server failed: {err:#}"
|
|
));
|
|
Err(err)?
|
|
}
|
|
}
|
|
}
|
|
}?;
|
|
|
|
let server_info = service.peer_info();
|
|
logger.log(format!("[info] MCP: Connected to server: {server_info:#?}"));
|
|
|
|
let capabilities = server_info.as_ref().map(|info| &info.capabilities);
|
|
|
|
let resources =
|
|
query_resources_for(capabilities, &server_name, || service.list_all_resources()).await;
|
|
let tools = query_tools_for(capabilities, &server_name, || service.list_all_tools()).await;
|
|
|
|
Ok(TemplatableMCPServerInfo {
|
|
name: server_name,
|
|
service,
|
|
resources,
|
|
tools,
|
|
installation_id: uuid,
|
|
description,
|
|
is_authenticated_transport,
|
|
})
|
|
}
|
|
|
|
/// The transport to use for MCP.
|
|
enum Transport {
|
|
/// The HTTP transport, with an optional authenticated client.
|
|
Http(Option<rmcp::transport::auth::AuthClient<reqwest::Client>>),
|
|
/// The SSE transport, with an optional authenticated client.
|
|
Sse(Option<rmcp::transport::auth::AuthClient<reqwest::Client>>),
|
|
}
|
|
|
|
/// Determines which transport to use.
|
|
///
|
|
/// This sends a "preflight" InitializeRequest to the server to determine whether the
|
|
/// server supports the HTTP transport (or needs to use the SSE transport), and if
|
|
/// authentication is required.
|
|
#[allow(clippy::result_large_err)]
|
|
async fn determine_transport(
|
|
server_name: String,
|
|
url: &str,
|
|
headers: &HashMap<String, String>,
|
|
auth_context: Option<crate::oauth::AuthContext>,
|
|
) -> Result<Transport, rmcp::RmcpError> {
|
|
use reqwest::StatusCode;
|
|
|
|
fn unexpected_error(status: reqwest::StatusCode) -> rmcp::RmcpError {
|
|
rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(format!(
|
|
"Unexpected status code: {status}"
|
|
))
|
|
}
|
|
match send_initialize_request(url, headers, None).await? {
|
|
StatusCode::OK => Ok(Transport::Http(None)),
|
|
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => Ok(Transport::Sse(None)),
|
|
StatusCode::UNAUTHORIZED => {
|
|
let Some(mut auth_context) = auth_context else {
|
|
return Err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>(
|
|
"Server requires authentication, which is not yet supported.".to_string(),
|
|
));
|
|
};
|
|
|
|
// Grab the post-authentication callback so we can invoke it once we know for sure that we successfully
|
|
// went through the OAuth flow for a server and were able to successfully send an initialize request.
|
|
let authenticated_callback = std::mem::take(&mut auth_context.authenticated);
|
|
|
|
// Go through the OAuth flow to get an authenticated client.
|
|
// This will first attempt to use cached credentials before starting interactive OAuth.
|
|
let (client, did_require_login) =
|
|
crate::oauth::make_authenticated_client(url, auth_context)
|
|
.await
|
|
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
|
|
|
|
// Define a helper function to invoke when we've successfully authenticated.
|
|
let emit_authenticated_notification = async move || {
|
|
if did_require_login {
|
|
if let Some(authenticated_callback) = authenticated_callback {
|
|
if let Err(err) = authenticated_callback(server_name).await {
|
|
log::warn!("Failed to emit MCP authenticated notification: {err:?}");
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
match send_initialize_request(url, headers, Some(&client)).await? {
|
|
StatusCode::OK => {
|
|
emit_authenticated_notification().await;
|
|
Ok(Transport::Http(Some(client)))
|
|
}
|
|
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
|
|
emit_authenticated_notification().await;
|
|
Ok(Transport::Sse(Some(client)))
|
|
}
|
|
other => Err(unexpected_error(other)),
|
|
}
|
|
}
|
|
status => Err(unexpected_error(status)),
|
|
}
|
|
}
|
|
|
|
/// Sends an InitializeRequest to the server, and returns the HTTP status code from the response.
|
|
#[allow(clippy::result_large_err)]
|
|
async fn send_initialize_request(
|
|
url: &str,
|
|
headers: &HashMap<String, String>,
|
|
auth_client: Option<&rmcp::transport::auth::AuthClient<reqwest::Client>>,
|
|
) -> Result<reqwest::StatusCode, rmcp::RmcpError> {
|
|
use rmcp::transport::common::http_header::{EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE};
|
|
|
|
let request = rmcp::model::InitializeRequest::new(make_client_info());
|
|
let request = rmcp::model::ClientJsonRpcMessage::request(
|
|
rmcp::model::ClientRequest::InitializeRequest(request),
|
|
rmcp::model::RequestId::Number(0),
|
|
);
|
|
|
|
let mut request = build_client_with_headers(headers)?
|
|
.post(url)
|
|
.header(
|
|
http::header::ACCEPT,
|
|
[EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "),
|
|
)
|
|
.json(&request);
|
|
|
|
if let Some(auth_client) = auth_client.as_ref() {
|
|
let access_token = auth_client
|
|
.get_access_token()
|
|
.await
|
|
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
|
|
request = request.bearer_auth(access_token);
|
|
}
|
|
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(rmcp::RmcpError::transport_creation::<ReqwestHttpTransport>)?;
|
|
|
|
Ok(response.status())
|
|
}
|
|
|
|
/// Creates a [`ClientInfo`] for the MCP client.
|
|
///
|
|
/// This tells the MCP server who we are and what capabilities we have.
|
|
fn make_client_info() -> rmcp::model::ClientInfo {
|
|
rmcp::model::ClientInfo::new(
|
|
Default::default(),
|
|
rmcp::model::Implementation::new(
|
|
warp_core::channel::ChannelState::app_id().to_string(),
|
|
warp_core::channel::ChannelState::app_version()
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_default(),
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Whether to query `resources/list` for a server with the given capabilities.
|
|
///
|
|
/// Per the MCP spec, the client should only invoke a list method when the server
|
|
/// has advertised the corresponding capability during initialization.
|
|
fn should_query_resources(capabilities: Option<&rmcp::model::ServerCapabilities>) -> bool {
|
|
capabilities.is_some_and(|c| c.resources.is_some())
|
|
}
|
|
|
|
/// Whether to query `tools/list` for a server with the given capabilities.
|
|
///
|
|
/// Per the MCP spec, the client should only invoke a list method when the server
|
|
/// has advertised the corresponding capability during initialization.
|
|
fn should_query_tools(capabilities: Option<&rmcp::model::ServerCapabilities>) -> bool {
|
|
capabilities.is_some_and(|c| c.tools.is_some())
|
|
}
|
|
|
|
/// Query `resources/list` for a connected MCP server.
|
|
///
|
|
/// Skips the call entirely when `resources` was not advertised. Treats any
|
|
/// listing error as "no resources" (fail-soft) so a flaky `resources/list`
|
|
/// does not abort the entire server startup. Mirrors the behavior of
|
|
/// [`query_tools_for`] so the two capabilities are handled symmetrically.
|
|
async fn query_resources_for<F, Fut>(
|
|
capabilities: Option<&rmcp::model::ServerCapabilities>,
|
|
server_name: &str,
|
|
list_resources: F,
|
|
) -> Vec<rmcp::model::Resource>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: Future<Output = Result<Vec<rmcp::model::Resource>, rmcp::ServiceError>>,
|
|
{
|
|
if !should_query_resources(capabilities) {
|
|
return Vec::new();
|
|
}
|
|
match list_resources().await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
log::warn!("Failed to list resources for MCP server '{server_name}': {err}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query `tools/list` for a connected MCP server.
|
|
///
|
|
/// Skips the call entirely when `tools` was not advertised. Treats any listing
|
|
/// error as "no tools" (fail-soft) so a transient `tools/list` failure does
|
|
/// not abort the entire server startup — the user-visible regression #6798
|
|
/// was rooted in the prior asymmetric handling, where a tools-list error on
|
|
/// a server with healthy resources would propagate and fail startup.
|
|
async fn query_tools_for<F, Fut>(
|
|
capabilities: Option<&rmcp::model::ServerCapabilities>,
|
|
server_name: &str,
|
|
list_tools: F,
|
|
) -> Vec<rmcp::model::Tool>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: Future<Output = Result<Vec<rmcp::model::Tool>, rmcp::ServiceError>>,
|
|
{
|
|
if !should_query_tools(capabilities) {
|
|
return Vec::new();
|
|
}
|
|
match list_tools().await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
log::warn!("Failed to list tools for MCP server '{server_name}': {err}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A wrapper around a [`rmcp::transport::Transport`] that logs all requests and responses.
|
|
struct TransportLoggingWrapper<T> {
|
|
transport: T,
|
|
logger: SimpleLogger,
|
|
}
|
|
|
|
impl<T: rmcp::transport::Transport<R>, R: rmcp::service::ServiceRole> rmcp::transport::Transport<R>
|
|
for TransportLoggingWrapper<T>
|
|
{
|
|
type Error = T::Error;
|
|
|
|
fn send(
|
|
&mut self,
|
|
item: rmcp::service::TxJsonRpcMessage<R>,
|
|
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
|
if let Ok(json) = serde_json::to_string(&item) {
|
|
self.logger
|
|
.log(format!("[info] MCP: Sending request: {json}"));
|
|
}
|
|
|
|
let logger = self.logger.clone();
|
|
self.transport.send(item).map(move |result| {
|
|
if let Err(e) = &result {
|
|
logger.log(format!("[warn] MCP: Failed to send request: {e:#}"));
|
|
}
|
|
result
|
|
})
|
|
}
|
|
|
|
fn receive(
|
|
&mut self,
|
|
) -> impl Future<Output = Option<rmcp::service::RxJsonRpcMessage<R>>> + Send {
|
|
let logger = self.logger.clone();
|
|
async move {
|
|
let result = self.transport.receive().await;
|
|
if let Some(item) = &result {
|
|
if let Ok(json) = serde_json::to_string(item) {
|
|
logger.log(format!("[info] MCP: Received response: {json}"));
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
|
|
self.transport.close()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "runtime_tests.rs"]
|
|
mod tests;
|