logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
594
crates/context_server/src/client.rs
Normal file
594
crates/context_server/src/client.rs
Normal file
@@ -0,0 +1,594 @@
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::HashMap;
|
||||
use futures::{FutureExt, StreamExt, channel::oneshot, future, select};
|
||||
use futures_lite::future::yield_now;
|
||||
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task};
|
||||
use parking_lot::Mutex;
|
||||
use postage::barrier;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use slotmap::SlotMap;
|
||||
use std::{
|
||||
fmt,
|
||||
path::PathBuf,
|
||||
pin::pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI32, Ordering::SeqCst},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
|
||||
use crate::{
|
||||
transport::{StdioTransport, Transport},
|
||||
types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled},
|
||||
};
|
||||
|
||||
const JSON_RPC_VERSION: &str = "2.0";
|
||||
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
pub const PARSE_ERROR: i32 = -32700;
|
||||
pub const INVALID_REQUEST: i32 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i32 = -32601;
|
||||
pub const INVALID_PARAMS: i32 = -32602;
|
||||
pub const INTERNAL_ERROR: i32 = -32603;
|
||||
|
||||
type ResponseHandler = Box<dyn Send + FnOnce(String)>;
|
||||
type NotificationHandler = Box<dyn Send + FnMut(Value, AsyncApp)>;
|
||||
type RequestHandler = Box<dyn Send + FnMut(RequestId, &RawValue, AsyncApp)>;
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
Int(i32),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
pub(crate) struct Client {
|
||||
server_id: ContextServerId,
|
||||
next_id: AtomicI32,
|
||||
outbound_tx: async_channel::Sender<String>,
|
||||
name: Arc<str>,
|
||||
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(dead_code)]
|
||||
io_tasks: Mutex<Option<(Task<Option<()>>, Task<Option<()>>)>>,
|
||||
#[allow(dead_code)]
|
||||
output_done_rx: Mutex<Option<barrier::Receiver>>,
|
||||
executor: BackgroundExecutor,
|
||||
#[allow(dead_code)]
|
||||
transport: Arc<dyn Transport>,
|
||||
request_timeout: Option<Duration>,
|
||||
/// Single-slot side channel for the last transport-level error. When the
|
||||
/// output task encounters a send failure it stashes the error here and
|
||||
/// exits; the next request to observe cancellation `.take()`s it so it can
|
||||
/// propagate a typed error (e.g. `TransportError::AuthRequired`) instead
|
||||
/// of a generic "cancelled". This works because `initialize` is the sole
|
||||
/// in-flight request at startup, but would need rethinking if concurrent
|
||||
/// requests are ever issued during that phase.
|
||||
last_transport_error: Arc<Mutex<Option<anyhow::Error>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(transparent)]
|
||||
pub(crate) struct ContextServerId(pub Arc<str>);
|
||||
|
||||
fn is_null_value<T: Serialize>(value: &T) -> bool {
|
||||
matches!(serde_json::to_value(value), Ok(Value::Null))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Request<'a, T> {
|
||||
pub jsonrpc: &'static str,
|
||||
pub id: RequestId,
|
||||
pub method: &'a str,
|
||||
#[serde(skip_serializing_if = "is_null_value")]
|
||||
pub params: T,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AnyRequest<'a> {
|
||||
pub jsonrpc: &'a str,
|
||||
pub id: RequestId,
|
||||
pub method: &'a str,
|
||||
#[serde(skip_serializing_if = "is_null_value")]
|
||||
pub params: Option<&'a RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AnyResponse<'a> {
|
||||
jsonrpc: &'a str,
|
||||
id: RequestId,
|
||||
#[serde(default)]
|
||||
error: Option<Error>,
|
||||
#[serde(borrow)]
|
||||
result: Option<&'a RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct Response<T> {
|
||||
pub jsonrpc: &'static str,
|
||||
pub id: RequestId,
|
||||
#[serde(flatten)]
|
||||
pub value: CspResult<T>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CspResult<T> {
|
||||
#[serde(rename = "result")]
|
||||
Ok(Option<T>),
|
||||
#[allow(dead_code)]
|
||||
Error(Option<Error>),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Notification<'a, T> {
|
||||
jsonrpc: &'static str,
|
||||
#[serde(borrow)]
|
||||
method: &'a str,
|
||||
#[serde(skip_serializing_if = "is_null_value")]
|
||||
params: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AnyNotification<'a> {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "Part of the JSON-RPC protocol - we expect the field to be present in a valid JSON-RPC notification"
|
||||
)]
|
||||
jsonrpc: &'a str,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct Error {
|
||||
pub message: String,
|
||||
pub code: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ModelContextServerBinary {
|
||||
pub executable: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Creates a new Client instance for a context server.
|
||||
///
|
||||
/// This function initializes a new Client by spawning a child process for the context server,
|
||||
/// setting up communication channels, and initializing handlers for input/output operations.
|
||||
/// It takes a server ID, binary information, and an async app context as input.
|
||||
pub fn stdio(
|
||||
server_id: ContextServerId,
|
||||
binary: ModelContextServerBinary,
|
||||
working_directory: &Option<PathBuf>,
|
||||
cx: AsyncApp,
|
||||
) -> Result<Self> {
|
||||
log::debug!(
|
||||
"starting context server (executable={:?}, args={:?})",
|
||||
binary.executable,
|
||||
&binary.args
|
||||
);
|
||||
|
||||
let server_name = binary
|
||||
.executable
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
let timeout = binary.timeout.map(Duration::from_secs);
|
||||
let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
|
||||
Self::new(server_id, server_name.into(), transport, timeout, cx)
|
||||
}
|
||||
|
||||
/// Creates a new Client instance for a context server.
|
||||
pub fn new(
|
||||
server_id: ContextServerId,
|
||||
server_name: Arc<str>,
|
||||
transport: Arc<dyn Transport>,
|
||||
request_timeout: Option<Duration>,
|
||||
cx: AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let (outbound_tx, outbound_rx) = async_channel::unbounded::<String>();
|
||||
let (output_done_tx, output_done_rx) = barrier::channel();
|
||||
|
||||
let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default()));
|
||||
let response_handlers =
|
||||
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
|
||||
let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
|
||||
|
||||
let receive_input_task = cx.spawn({
|
||||
let subscription_set = subscription_set.clone();
|
||||
let response_handlers = response_handlers.clone();
|
||||
let request_handlers = request_handlers.clone();
|
||||
let transport = transport.clone();
|
||||
async move |cx| {
|
||||
Self::handle_input(
|
||||
transport,
|
||||
subscription_set,
|
||||
request_handlers,
|
||||
response_handlers,
|
||||
cx,
|
||||
)
|
||||
.log_err()
|
||||
.await
|
||||
}
|
||||
});
|
||||
let receive_err_task = cx.spawn({
|
||||
let transport = transport.clone();
|
||||
async move |_| Self::handle_err(transport).log_err().await
|
||||
});
|
||||
let input_task = cx.spawn(async move |_| {
|
||||
let (input, err) = futures::join!(receive_input_task, receive_err_task);
|
||||
input.or(err)
|
||||
});
|
||||
|
||||
let last_transport_error: Arc<Mutex<Option<anyhow::Error>>> = Arc::new(Mutex::new(None));
|
||||
let output_task = cx.background_spawn({
|
||||
let transport = transport.clone();
|
||||
let last_transport_error = last_transport_error.clone();
|
||||
Self::handle_output(
|
||||
transport,
|
||||
outbound_rx,
|
||||
output_done_tx,
|
||||
response_handlers.clone(),
|
||||
last_transport_error,
|
||||
)
|
||||
.log_err()
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
server_id,
|
||||
subscription_set,
|
||||
response_handlers,
|
||||
name: server_name,
|
||||
next_id: Default::default(),
|
||||
outbound_tx,
|
||||
executor: cx.background_executor().clone(),
|
||||
io_tasks: Mutex::new(Some((input_task, output_task))),
|
||||
output_done_rx: Mutex::new(Some(output_done_rx)),
|
||||
transport,
|
||||
request_timeout,
|
||||
last_transport_error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handles input from the server's stdout.
|
||||
///
|
||||
/// This function continuously reads lines from the provided stdout stream,
|
||||
/// parses them as JSON-RPC responses or notifications, and dispatches them
|
||||
/// to the appropriate handlers. It processes both responses (which are matched
|
||||
/// to pending requests) and notifications (which trigger registered handlers).
|
||||
async fn handle_input(
|
||||
transport: Arc<dyn Transport>,
|
||||
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
|
||||
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut receiver = transport.receive();
|
||||
|
||||
while let Some(message) = receiver.next().await {
|
||||
log::trace!("recv: {}", &message);
|
||||
if let Ok(request) = serde_json::from_str::<AnyRequest>(&message) {
|
||||
let mut request_handlers = request_handlers.lock();
|
||||
if let Some(handler) = request_handlers.get_mut(request.method) {
|
||||
handler(
|
||||
request.id,
|
||||
request.params.unwrap_or(RawValue::NULL),
|
||||
cx.clone(),
|
||||
);
|
||||
}
|
||||
} else if let Ok(response) = serde_json::from_str::<AnyResponse>(&message) {
|
||||
if let Some(handlers) = response_handlers.lock().as_mut()
|
||||
&& let Some(handler) = handlers.remove(&response.id)
|
||||
{
|
||||
handler(message.to_string());
|
||||
}
|
||||
} else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
|
||||
subscription_set.lock().notify(
|
||||
¬ification.method,
|
||||
notification.params.unwrap_or(Value::Null),
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
log::error!("Unhandled JSON from context_server: {}", message);
|
||||
}
|
||||
}
|
||||
|
||||
yield_now().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the stderr output from the context server.
|
||||
/// Continuously reads and logs any error messages from the server.
|
||||
async fn handle_err(transport: Arc<dyn Transport>) -> anyhow::Result<()> {
|
||||
while let Some(err) = transport.receive_err().next().await {
|
||||
log::debug!("context server stderr: {}", err.trim());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the output to the context server's stdin.
|
||||
/// This function continuously receives messages from the outbound channel,
|
||||
/// writes them to the server's stdin, and manages the lifecycle of response handlers.
|
||||
async fn handle_output(
|
||||
transport: Arc<dyn Transport>,
|
||||
outbound_rx: async_channel::Receiver<String>,
|
||||
output_done_tx: barrier::Sender,
|
||||
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
|
||||
last_transport_error: Arc<Mutex<Option<anyhow::Error>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _clear_response_handlers = util::defer({
|
||||
let response_handlers = response_handlers.clone();
|
||||
move || {
|
||||
response_handlers.lock().take();
|
||||
}
|
||||
});
|
||||
while let Ok(message) = outbound_rx.recv().await {
|
||||
log::trace!("outgoing message: {}", message);
|
||||
if let Err(err) = transport.send(message).await {
|
||||
log::debug!("transport send failed: {:#}", err);
|
||||
*last_transport_error.lock() = Some(err);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
drop(output_done_tx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a JSON-RPC request to the context server and waits for a response.
|
||||
/// This function handles serialization, deserialization, timeout, and error handling.
|
||||
pub async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: impl Serialize,
|
||||
) -> Result<T> {
|
||||
self.request_with(
|
||||
method,
|
||||
params,
|
||||
None,
|
||||
self.request_timeout.or(Some(DEFAULT_REQUEST_TIMEOUT)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn request_with<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: impl Serialize,
|
||||
cancel_rx: Option<oneshot::Receiver<()>>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<T> {
|
||||
let id = self.next_id.fetch_add(1, SeqCst);
|
||||
let request = serde_json::to_string(&Request {
|
||||
jsonrpc: JSON_RPC_VERSION,
|
||||
id: RequestId::Int(id),
|
||||
method,
|
||||
params,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let handle_response = self
|
||||
.response_handlers
|
||||
.lock()
|
||||
.as_mut()
|
||||
.context("server shut down")
|
||||
.map(|handlers| {
|
||||
handlers.insert(
|
||||
RequestId::Int(id),
|
||||
Box::new(move |result| {
|
||||
let _ = tx.send(result);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
let send = self
|
||||
.outbound_tx
|
||||
.try_send(request)
|
||||
.context("failed to write to context server's stdin");
|
||||
|
||||
let executor = self.executor.clone();
|
||||
let started = Instant::now();
|
||||
handle_response?;
|
||||
send?;
|
||||
|
||||
let mut timeout_fut = pin!(
|
||||
match timeout {
|
||||
Some(timeout) => future::Either::Left(executor.timer(timeout)),
|
||||
None => future::Either::Right(future::pending()),
|
||||
}
|
||||
.fuse()
|
||||
);
|
||||
let mut cancel_fut = pin!(
|
||||
match cancel_rx {
|
||||
Some(rx) => future::Either::Left(async {
|
||||
rx.await.log_err();
|
||||
}),
|
||||
None => future::Either::Right(future::pending()),
|
||||
}
|
||||
.fuse()
|
||||
);
|
||||
|
||||
select! {
|
||||
response = rx.fuse() => {
|
||||
let elapsed = started.elapsed();
|
||||
log::trace!("took {elapsed:?} to receive response to {method:?} id {id}");
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let parsed: AnyResponse = serde_json::from_str(&response)?;
|
||||
if let Some(error) = parsed.error {
|
||||
Err(anyhow!(error.message))
|
||||
} else if let Some(result) = parsed.result {
|
||||
Ok(serde_json::from_str(result.get())?)
|
||||
} else {
|
||||
anyhow::bail!("Invalid response: no result or error");
|
||||
}
|
||||
}
|
||||
Err(_canceled) => {
|
||||
if let Some(err) = self.last_transport_error.lock().take() {
|
||||
return Err(err);
|
||||
}
|
||||
anyhow::bail!("cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_fut => {
|
||||
self.notify(
|
||||
Cancelled::METHOD,
|
||||
ClientNotification::Cancelled(CancelledParams {
|
||||
request_id: RequestId::Int(id),
|
||||
reason: None
|
||||
})
|
||||
).log_err();
|
||||
anyhow::bail!(RequestCanceled)
|
||||
}
|
||||
_ = timeout_fut => {
|
||||
log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", timeout.unwrap());
|
||||
anyhow::bail!("Context server request timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a notification to the context server without expecting a response.
|
||||
/// This function serializes the notification and sends it through the outbound channel.
|
||||
pub fn notify(&self, method: &str, params: impl Serialize) -> Result<()> {
|
||||
let notification = serde_json::to_string(&Notification {
|
||||
jsonrpc: JSON_RPC_VERSION,
|
||||
method,
|
||||
params,
|
||||
})
|
||||
.unwrap();
|
||||
self.outbound_tx.try_send(notification)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notify the underlying transport of the negotiated MCP protocol version
|
||||
/// so it can stamp subsequent requests (e.g. HTTP's `MCP-Protocol-Version`
|
||||
/// header required from 2025-06-18 onward).
|
||||
pub(crate) fn set_protocol_version(&self, version: &str) {
|
||||
self.transport.set_protocol_version(version);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn on_notification(
|
||||
&self,
|
||||
method: &'static str,
|
||||
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
|
||||
) -> NotificationSubscription {
|
||||
let mut notification_subscriptions = self.subscription_set.lock();
|
||||
|
||||
NotificationSubscription {
|
||||
id: notification_subscriptions.add_handler(method, f),
|
||||
set: self.subscription_set.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RequestCanceled;
|
||||
|
||||
impl std::error::Error for RequestCanceled {}
|
||||
|
||||
impl std::fmt::Display for RequestCanceled {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Context server request was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContextServerId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Client {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Context Server Client")
|
||||
.field("id", &self.server_id.0)
|
||||
.field("name", &self.name)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
slotmap::new_key_type! {
|
||||
struct NotificationSubscriptionId;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NotificationSubscriptionSet {
|
||||
// we have very few subscriptions at the moment
|
||||
methods: Vec<(&'static str, Vec<NotificationSubscriptionId>)>,
|
||||
handlers: SlotMap<NotificationSubscriptionId, NotificationHandler>,
|
||||
}
|
||||
|
||||
impl NotificationSubscriptionSet {
|
||||
#[must_use]
|
||||
fn add_handler(
|
||||
&mut self,
|
||||
method: &'static str,
|
||||
handler: NotificationHandler,
|
||||
) -> NotificationSubscriptionId {
|
||||
let id = self.handlers.insert(handler);
|
||||
if let Some((_, handler_ids)) = self
|
||||
.methods
|
||||
.iter_mut()
|
||||
.find(|(probe_method, _)| method == *probe_method)
|
||||
{
|
||||
debug_assert!(
|
||||
handler_ids.len() < 20,
|
||||
"Too many MCP handlers for {}. Consider using a different data structure.",
|
||||
method
|
||||
);
|
||||
|
||||
handler_ids.push(id);
|
||||
} else {
|
||||
self.methods.push((method, vec![id]));
|
||||
};
|
||||
id
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) {
|
||||
let Some((_, handler_ids)) = self
|
||||
.methods
|
||||
.iter_mut()
|
||||
.find(|(probe_method, _)| method == *probe_method)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for handler_id in handler_ids {
|
||||
if let Some(handler) = self.handlers.get_mut(*handler_id) {
|
||||
handler(payload.clone(), cx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationSubscription {
|
||||
id: NotificationSubscriptionId,
|
||||
set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
}
|
||||
|
||||
impl Drop for NotificationSubscription {
|
||||
fn drop(&mut self) {
|
||||
let mut set = self.set.lock();
|
||||
set.handlers.remove(self.id);
|
||||
set.methods.retain_mut(|(_, handler_ids)| {
|
||||
handler_ids.retain(|id| *id != self.id);
|
||||
!handler_ids.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
164
crates/context_server/src/context_server.rs
Normal file
164
crates/context_server/src/context_server.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
pub mod client;
|
||||
pub mod listener;
|
||||
pub mod oauth;
|
||||
pub mod protocol;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
pub mod transport;
|
||||
pub mod types;
|
||||
|
||||
use collections::HashMap;
|
||||
use http_client::HttpClient;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{fmt::Display, path::PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use client::Client;
|
||||
use gpui::AsyncApp;
|
||||
use parking_lot::RwLock;
|
||||
pub use settings::ContextServerCommand;
|
||||
use url::Url;
|
||||
|
||||
use crate::transport::HttpTransport;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ContextServerId(pub Arc<str>);
|
||||
|
||||
impl Display for ContextServerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
enum ContextServerTransport {
|
||||
Stdio(ContextServerCommand, Option<PathBuf>),
|
||||
Custom(Arc<dyn crate::transport::Transport>),
|
||||
}
|
||||
|
||||
pub struct ContextServer {
|
||||
id: ContextServerId,
|
||||
client: RwLock<Option<Arc<crate::protocol::InitializedContextServerProtocol>>>,
|
||||
configuration: ContextServerTransport,
|
||||
request_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ContextServer {
|
||||
pub fn stdio(
|
||||
id: ContextServerId,
|
||||
command: ContextServerCommand,
|
||||
working_directory: Option<Arc<Path>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
client: RwLock::new(None),
|
||||
configuration: ContextServerTransport::Stdio(
|
||||
command,
|
||||
working_directory.map(|directory| directory.to_path_buf()),
|
||||
),
|
||||
request_timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http(
|
||||
id: ContextServerId,
|
||||
endpoint: &Url,
|
||||
headers: HashMap<String, String>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
executor: gpui::BackgroundExecutor,
|
||||
request_timeout: Option<Duration>,
|
||||
) -> Result<Self> {
|
||||
let transport = match endpoint.scheme() {
|
||||
"http" | "https" => {
|
||||
log::info!("Using HTTP transport for {}", endpoint);
|
||||
let transport =
|
||||
HttpTransport::new(http_client, endpoint.to_string(), headers, executor);
|
||||
Arc::new(transport) as _
|
||||
}
|
||||
_ => anyhow::bail!("unsupported MCP url scheme {}", endpoint.scheme()),
|
||||
};
|
||||
Ok(Self::new_with_timeout(id, transport, request_timeout))
|
||||
}
|
||||
|
||||
pub fn new(id: ContextServerId, transport: Arc<dyn crate::transport::Transport>) -> Self {
|
||||
Self::new_with_timeout(id, transport, None)
|
||||
}
|
||||
|
||||
pub fn new_with_timeout(
|
||||
id: ContextServerId,
|
||||
transport: Arc<dyn crate::transport::Transport>,
|
||||
request_timeout: Option<Duration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
client: RwLock::new(None),
|
||||
configuration: ContextServerTransport::Custom(transport),
|
||||
request_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> ContextServerId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
pub fn client(&self) -> Option<Arc<crate::protocol::InitializedContextServerProtocol>> {
|
||||
self.client.read().clone()
|
||||
}
|
||||
|
||||
pub async fn start(&self, cx: &AsyncApp) -> Result<()> {
|
||||
self.initialize(self.new_client(cx)?).await
|
||||
}
|
||||
|
||||
fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
|
||||
Ok(match &self.configuration {
|
||||
ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
|
||||
client::ContextServerId(self.id.0.clone()),
|
||||
client::ModelContextServerBinary {
|
||||
executable: Path::new(&command.path).to_path_buf(),
|
||||
args: command.args.clone(),
|
||||
env: command.env.clone(),
|
||||
timeout: command.timeout,
|
||||
},
|
||||
working_directory,
|
||||
cx.clone(),
|
||||
)?,
|
||||
ContextServerTransport::Custom(transport) => Client::new(
|
||||
client::ContextServerId(self.id.0.clone()),
|
||||
self.id().0,
|
||||
transport.clone(),
|
||||
self.request_timeout,
|
||||
cx.clone(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialize(&self, client: Client) -> Result<()> {
|
||||
log::debug!("starting context server {}", self.id);
|
||||
let protocol = crate::protocol::ModelContextProtocol::new(client);
|
||||
let client_info = types::Implementation {
|
||||
name: "Zed".to_string(),
|
||||
title: None,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
description: None,
|
||||
};
|
||||
let initialized_protocol = protocol.initialize(client_info).await?;
|
||||
|
||||
log::debug!(
|
||||
"context server {} initialized: {:?}",
|
||||
self.id,
|
||||
initialized_protocol.initialize,
|
||||
);
|
||||
|
||||
*self.client.write() = Some(Arc::new(initialized_protocol));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let mut client = self.client.write();
|
||||
if let Some(protocol) = client.take() {
|
||||
drop(protocol);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
438
crates/context_server/src/listener.rs
Normal file
438
crates/context_server/src/listener.rs
Normal file
@@ -0,0 +1,438 @@
|
||||
use ::serde::{Deserialize, Serialize};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use futures::AsyncReadExt;
|
||||
use futures::stream::StreamExt;
|
||||
use futures::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt,
|
||||
channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
|
||||
io::BufReader,
|
||||
select_biased,
|
||||
};
|
||||
use gpui::{App, AppContext, AsyncApp, Task};
|
||||
use net::async_net::{UnixListener, UnixStream};
|
||||
use schemars::JsonSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, value::RawValue};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{
|
||||
client::{CspResult, RequestId, Response},
|
||||
types::{
|
||||
CallToolParams, CallToolResponse, ListToolsResponse, Request, Tool, ToolAnnotations,
|
||||
ToolResponseContent,
|
||||
requests::{CallTool, ListTools},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct McpServer {
|
||||
socket_path: PathBuf,
|
||||
tools: Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
|
||||
handlers: Rc<RefCell<HashMap<&'static str, RequestHandler>>>,
|
||||
_server_task: Task<()>,
|
||||
}
|
||||
|
||||
struct RegisteredTool {
|
||||
tool: Tool,
|
||||
handler: ToolHandler,
|
||||
}
|
||||
|
||||
type ToolHandler = Box<
|
||||
dyn Fn(
|
||||
Option<serde_json::Value>,
|
||||
&mut AsyncApp,
|
||||
) -> Task<Result<ToolResponse<serde_json::Value>>>,
|
||||
>;
|
||||
type RequestHandler = Box<dyn Fn(RequestId, Option<Box<RawValue>>, &App) -> Task<String>>;
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(cx: &AsyncApp) -> Task<Result<Self>> {
|
||||
let task = cx.background_spawn(async move {
|
||||
let temp_dir = tempfile::Builder::new().prefix("zed-mcp").tempdir()?;
|
||||
let socket_path = temp_dir.path().join("mcp.sock");
|
||||
let listener = UnixListener::bind(&socket_path).context("creating mcp socket")?;
|
||||
|
||||
anyhow::Ok((temp_dir, socket_path, listener))
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (temp_dir, socket_path, listener) = task.await?;
|
||||
let tools = Rc::new(RefCell::new(HashMap::default()));
|
||||
let handlers = Rc::new(RefCell::new(HashMap::default()));
|
||||
let server_task = cx.spawn({
|
||||
let tools = tools.clone();
|
||||
let handlers = handlers.clone();
|
||||
async move |cx| {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
Self::serve_connection(stream, tools.clone(), handlers.clone(), cx);
|
||||
}
|
||||
drop(temp_dir)
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
socket_path,
|
||||
_server_task: server_task,
|
||||
tools,
|
||||
handlers,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_tool<T: McpServerTool + Clone + 'static>(&mut self, tool: T) {
|
||||
let mut settings = schemars::generate::SchemaSettings::draft07();
|
||||
settings.inline_subschemas = true;
|
||||
let mut generator = settings.into_generator();
|
||||
|
||||
let input_schema = generator.root_schema_for::<T::Input>();
|
||||
|
||||
let description = input_schema
|
||||
.get("description")
|
||||
.and_then(|desc| desc.as_str())
|
||||
.map(|desc| desc.to_string());
|
||||
debug_assert!(
|
||||
description.is_some(),
|
||||
"Input schema struct must include a doc comment for the tool description"
|
||||
);
|
||||
|
||||
let registered_tool = RegisteredTool {
|
||||
tool: Tool {
|
||||
name: T::NAME.into(),
|
||||
title: None,
|
||||
description,
|
||||
input_schema: input_schema.into(),
|
||||
output_schema: if TypeId::of::<T::Output>() == TypeId::of::<()>() {
|
||||
None
|
||||
} else {
|
||||
Some(generator.root_schema_for::<T::Output>().into())
|
||||
},
|
||||
annotations: Some(tool.annotations()),
|
||||
},
|
||||
handler: Box::new({
|
||||
move |input_value, cx| {
|
||||
let input = match input_value {
|
||||
Some(input) => serde_json::from_value(input),
|
||||
None => serde_json::from_value(serde_json::Value::Null),
|
||||
};
|
||||
|
||||
let tool = tool.clone();
|
||||
match input {
|
||||
Ok(input) => cx.spawn(async move |cx| {
|
||||
let output = tool.run(input, cx).await?;
|
||||
|
||||
Ok(ToolResponse {
|
||||
content: output.content,
|
||||
structured_content: serde_json::to_value(output.structured_content)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}),
|
||||
Err(err) => Task::ready(Err(err.into())),
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
self.tools.borrow_mut().insert(T::NAME, registered_tool);
|
||||
}
|
||||
|
||||
pub fn handle_request<R: Request>(
|
||||
&mut self,
|
||||
f: impl Fn(R::Params, &App) -> Task<Result<R::Response>> + 'static,
|
||||
) {
|
||||
let f = Box::new(f);
|
||||
self.handlers.borrow_mut().insert(
|
||||
R::METHOD,
|
||||
Box::new(move |req_id, opt_params, cx| {
|
||||
let result = match opt_params {
|
||||
Some(params) => serde_json::from_str(params.get()),
|
||||
None => serde_json::from_value(serde_json::Value::Null),
|
||||
};
|
||||
|
||||
let params: R::Params = match result {
|
||||
Ok(params) => params,
|
||||
Err(e) => {
|
||||
return Task::ready(
|
||||
serde_json::to_string(&Response::<R::Response> {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Error(Some(crate::client::Error {
|
||||
message: format!("{e}"),
|
||||
code: -32700,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let task = f(params, cx);
|
||||
cx.background_spawn(async move {
|
||||
match task.await {
|
||||
Ok(result) => serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Ok(Some(result)),
|
||||
})
|
||||
.unwrap(),
|
||||
Err(e) => serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Error::<R::Response>(Some(crate::client::Error {
|
||||
message: format!("{e}"),
|
||||
code: -32603,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
fn serve_connection(
|
||||
stream: UnixStream,
|
||||
tools: Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
|
||||
handlers: Rc<RefCell<HashMap<&'static str, RequestHandler>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
let (read, write) = stream.split();
|
||||
let (incoming_tx, mut incoming_rx) = unbounded();
|
||||
let (outgoing_tx, outgoing_rx) = unbounded();
|
||||
|
||||
cx.background_spawn(Self::handle_io(outgoing_rx, incoming_tx, write, read))
|
||||
.detach();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
while let Some(request) = incoming_rx.next().await {
|
||||
let Some(request_id) = request.id.clone() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if request.method == CallTool::METHOD {
|
||||
Self::handle_call_tool(request_id, request.params, &tools, &outgoing_tx, cx)
|
||||
.await;
|
||||
} else if request.method == ListTools::METHOD {
|
||||
Self::handle_list_tools(request.id.unwrap(), &tools, &outgoing_tx);
|
||||
} else if let Some(handler) = handlers.borrow().get(&request.method.as_ref()) {
|
||||
let outgoing_tx = outgoing_tx.clone();
|
||||
|
||||
let task = cx.update(|cx| handler(request_id, request.params, cx));
|
||||
cx.spawn(async move |_| {
|
||||
let response = task.await;
|
||||
outgoing_tx.unbounded_send(response).ok();
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
Self::send_err(
|
||||
request_id,
|
||||
format!("unhandled method {}", request.method),
|
||||
&outgoing_tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn handle_list_tools(
|
||||
request_id: RequestId,
|
||||
tools: &Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
|
||||
outgoing_tx: &UnboundedSender<String>,
|
||||
) {
|
||||
let response = ListToolsResponse {
|
||||
tools: tools.borrow().values().map(|t| t.tool.clone()).collect(),
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
outgoing_tx
|
||||
.unbounded_send(
|
||||
serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: request_id,
|
||||
value: CspResult::Ok(Some(response)),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
async fn handle_call_tool(
|
||||
request_id: RequestId,
|
||||
params: Option<Box<RawValue>>,
|
||||
tools: &Rc<RefCell<HashMap<&'static str, RegisteredTool>>>,
|
||||
outgoing_tx: &UnboundedSender<String>,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
let result: Result<CallToolParams, serde_json::Error> = match params.as_ref() {
|
||||
Some(params) => serde_json::from_str(params.get()),
|
||||
None => serde_json::from_value(serde_json::Value::Null),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(params) => {
|
||||
if let Some(tool) = tools.borrow().get(¶ms.name.as_ref()) {
|
||||
let outgoing_tx = outgoing_tx.clone();
|
||||
|
||||
let task = (tool.handler)(params.arguments, cx);
|
||||
cx.spawn(async move |_| {
|
||||
let response = match task.await {
|
||||
Ok(result) => CallToolResponse {
|
||||
content: result.content,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
structured_content: if result.structured_content.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(result.structured_content)
|
||||
},
|
||||
},
|
||||
Err(err) => CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: err.to_string(),
|
||||
}],
|
||||
is_error: Some(true),
|
||||
meta: None,
|
||||
structured_content: None,
|
||||
},
|
||||
};
|
||||
|
||||
outgoing_tx
|
||||
.unbounded_send(
|
||||
serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: request_id,
|
||||
value: CspResult::Ok(Some(response)),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
Self::send_err(
|
||||
request_id,
|
||||
format!("Tool not found: {}", params.name),
|
||||
outgoing_tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
Self::send_err(request_id, err.to_string(), outgoing_tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_err(
|
||||
request_id: RequestId,
|
||||
message: impl Into<String>,
|
||||
outgoing_tx: &UnboundedSender<String>,
|
||||
) {
|
||||
outgoing_tx
|
||||
.unbounded_send(
|
||||
serde_json::to_string(&Response::<()> {
|
||||
jsonrpc: "2.0",
|
||||
id: request_id,
|
||||
value: CspResult::Error(Some(crate::client::Error {
|
||||
message: message.into(),
|
||||
code: -32601,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
async fn handle_io(
|
||||
mut outgoing_rx: UnboundedReceiver<String>,
|
||||
incoming_tx: UnboundedSender<RawRequest>,
|
||||
mut outgoing_bytes: impl Unpin + AsyncWrite,
|
||||
incoming_bytes: impl Unpin + AsyncRead,
|
||||
) -> Result<()> {
|
||||
let mut output_reader = BufReader::new(incoming_bytes);
|
||||
let mut incoming_line = String::new();
|
||||
loop {
|
||||
select_biased! {
|
||||
message = outgoing_rx.next().fuse() => {
|
||||
if let Some(message) = message {
|
||||
log::trace!("send: {}", &message);
|
||||
outgoing_bytes.write_all(message.as_bytes()).await?;
|
||||
outgoing_bytes.write_all(&[b'\n']).await?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
log::trace!("recv: {}", &incoming_line);
|
||||
match serde_json::from_str(&incoming_line) {
|
||||
Ok(message) => {
|
||||
incoming_tx.unbounded_send(message).log_err();
|
||||
}
|
||||
Err(error) => {
|
||||
outgoing_bytes.write_all(serde_json::to_string(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"error": json!({
|
||||
"code": -32603,
|
||||
"message": format!("Failed to parse: {error}"),
|
||||
}),
|
||||
}))?.as_bytes()).await?;
|
||||
outgoing_bytes.write_all(&[b'\n']).await?;
|
||||
log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
|
||||
}
|
||||
}
|
||||
incoming_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait McpServerTool {
|
||||
type Input: DeserializeOwned + JsonSchema;
|
||||
type Output: Serialize + JsonSchema;
|
||||
|
||||
const NAME: &'static str;
|
||||
|
||||
fn annotations(&self) -> ToolAnnotations {
|
||||
ToolAnnotations {
|
||||
title: None,
|
||||
read_only_hint: None,
|
||||
destructive_hint: None,
|
||||
idempotent_hint: None,
|
||||
open_world_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
input: Self::Input,
|
||||
cx: &mut AsyncApp,
|
||||
) -> impl Future<Output = Result<ToolResponse<Self::Output>>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolResponse<T> {
|
||||
pub content: Vec<ToolResponseContent>,
|
||||
pub structured_content: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RawRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<RequestId>,
|
||||
method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
2794
crates/context_server/src/oauth.rs
Normal file
2794
crates/context_server/src/oauth.rs
Normal file
File diff suppressed because it is too large
Load Diff
132
crates/context_server/src/protocol.rs
Normal file
132
crates/context_server/src/protocol.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! This module implements parts of the Model Context Protocol.
|
||||
//!
|
||||
//! It handles the lifecycle messages, and provides a general interface to
|
||||
//! interacting with an MCP server. It uses the generic JSON-RPC client to
|
||||
//! read/write messages and the types from types.rs for serialization/deserialization
|
||||
//! of messages.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::AsyncApp;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, NotificationSubscription};
|
||||
use crate::types::{self, Notification, Request};
|
||||
|
||||
pub struct ModelContextProtocol {
|
||||
inner: Client,
|
||||
}
|
||||
|
||||
impl ModelContextProtocol {
|
||||
pub(crate) fn new(inner: Client) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
fn supported_protocols() -> Vec<types::ProtocolVersion> {
|
||||
vec![
|
||||
types::ProtocolVersion(types::LATEST_PROTOCOL_VERSION.to_string()),
|
||||
types::ProtocolVersion(types::VERSION_2025_06_18.to_string()),
|
||||
types::ProtocolVersion(types::VERSION_2025_03_26.to_string()),
|
||||
types::ProtocolVersion(types::VERSION_2024_11_05.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn initialize(
|
||||
self,
|
||||
client_info: types::Implementation,
|
||||
) -> Result<InitializedContextServerProtocol> {
|
||||
let params = types::InitializeParams {
|
||||
protocol_version: types::ProtocolVersion(types::LATEST_PROTOCOL_VERSION.to_string()),
|
||||
capabilities: types::ClientCapabilities {
|
||||
experimental: None,
|
||||
sampling: None,
|
||||
roots: None,
|
||||
},
|
||||
meta: None,
|
||||
client_info,
|
||||
};
|
||||
|
||||
let response: types::InitializeResponse = self
|
||||
.inner
|
||||
.request(types::requests::Initialize::METHOD, params)
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
Self::supported_protocols().contains(&response.protocol_version),
|
||||
"Unsupported protocol version: {:?}",
|
||||
response.protocol_version
|
||||
);
|
||||
|
||||
log::trace!("mcp server info {:?}", response.server_info);
|
||||
|
||||
// Per MCP 2025-06-18, HTTP transport must attach the negotiated version
|
||||
// as `MCP-Protocol-Version` on every post-initialize request.
|
||||
self.inner
|
||||
.set_protocol_version(&response.protocol_version.0);
|
||||
|
||||
let initialized_protocol = InitializedContextServerProtocol {
|
||||
inner: self.inner,
|
||||
initialize: response,
|
||||
};
|
||||
|
||||
initialized_protocol.notify::<types::notifications::Initialized>(())?;
|
||||
|
||||
Ok(initialized_protocol)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InitializedContextServerProtocol {
|
||||
inner: Client,
|
||||
pub initialize: types::InitializeResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum ServerCapability {
|
||||
Experimental,
|
||||
Logging,
|
||||
Prompts,
|
||||
Resources,
|
||||
Tools,
|
||||
}
|
||||
|
||||
impl InitializedContextServerProtocol {
|
||||
/// Check if the server supports a specific capability
|
||||
pub fn capable(&self, capability: ServerCapability) -> bool {
|
||||
match capability {
|
||||
ServerCapability::Experimental => self.initialize.capabilities.experimental.is_some(),
|
||||
ServerCapability::Logging => self.initialize.capabilities.logging.is_some(),
|
||||
ServerCapability::Prompts => self.initialize.capabilities.prompts.is_some(),
|
||||
ServerCapability::Resources => self.initialize.capabilities.resources.is_some(),
|
||||
ServerCapability::Tools => self.initialize.capabilities.tools.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request<T: Request>(&self, params: T::Params) -> Result<T::Response> {
|
||||
self.inner.request(T::METHOD, params).await
|
||||
}
|
||||
|
||||
pub async fn request_with<T: Request>(
|
||||
&self,
|
||||
params: T::Params,
|
||||
cancel_rx: Option<oneshot::Receiver<()>>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<T::Response> {
|
||||
self.inner
|
||||
.request_with(T::METHOD, params, cancel_rx, timeout)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn notify<T: Notification>(&self, params: T::Params) -> Result<()> {
|
||||
self.inner.notify(T::METHOD, params)
|
||||
}
|
||||
|
||||
pub fn on_notification(
|
||||
&self,
|
||||
method: &'static str,
|
||||
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
|
||||
) -> NotificationSubscription {
|
||||
self.inner.on_notification(method, f)
|
||||
}
|
||||
}
|
||||
132
crates/context_server/src/test.rs
Normal file
132
crates/context_server/src/test.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use anyhow::Context as _;
|
||||
use collections::HashMap;
|
||||
use futures::{FutureExt, Stream, StreamExt as _, future::BoxFuture, lock::Mutex};
|
||||
use gpui::BackgroundExecutor;
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
transport::Transport,
|
||||
types::{Implementation, InitializeResponse, ProtocolVersion, ServerCapabilities},
|
||||
};
|
||||
|
||||
pub fn create_fake_transport(
|
||||
name: impl Into<String>,
|
||||
executor: BackgroundExecutor,
|
||||
) -> FakeTransport {
|
||||
let name = name.into();
|
||||
FakeTransport::new(executor).on_request::<crate::types::requests::Initialize, _>(
|
||||
move |_params| {
|
||||
let name = name.clone();
|
||||
async move { create_initialize_response(name.clone()) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn create_initialize_response(server_name: String) -> InitializeResponse {
|
||||
InitializeResponse {
|
||||
protocol_version: ProtocolVersion(crate::types::LATEST_PROTOCOL_VERSION.to_string()),
|
||||
server_info: Implementation {
|
||||
name: server_name,
|
||||
title: None,
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
},
|
||||
capabilities: ServerCapabilities::default(),
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FakeTransport {
|
||||
request_handlers: HashMap<
|
||||
&'static str,
|
||||
Arc<dyn Send + Sync + Fn(serde_json::Value) -> BoxFuture<'static, serde_json::Value>>,
|
||||
>,
|
||||
tx: futures::channel::mpsc::UnboundedSender<String>,
|
||||
rx: Arc<Mutex<futures::channel::mpsc::UnboundedReceiver<String>>>,
|
||||
executor: BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl FakeTransport {
|
||||
pub fn new(executor: BackgroundExecutor) -> Self {
|
||||
let (tx, rx) = futures::channel::mpsc::unbounded();
|
||||
Self {
|
||||
request_handlers: Default::default(),
|
||||
tx,
|
||||
rx: Arc::new(Mutex::new(rx)),
|
||||
executor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_request<T, Fut>(
|
||||
mut self,
|
||||
handler: impl 'static + Send + Sync + Fn(T::Params) -> Fut,
|
||||
) -> Self
|
||||
where
|
||||
T: crate::types::Request,
|
||||
Fut: 'static + Send + Future<Output = T::Response>,
|
||||
{
|
||||
self.request_handlers.insert(
|
||||
T::METHOD,
|
||||
Arc::new(move |value| {
|
||||
let params = value
|
||||
.get("params")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let params: T::Params =
|
||||
serde_json::from_value(params).expect("Invalid parameters received");
|
||||
let response = handler(params);
|
||||
async move { serde_json::to_value(response.await).unwrap() }.boxed()
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Transport for FakeTransport {
|
||||
async fn send(&self, message: String) -> anyhow::Result<()> {
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&message) {
|
||||
let id = msg.get("id").and_then(|id| id.as_u64()).unwrap_or(0);
|
||||
|
||||
if let Some(method) = msg.get("method") {
|
||||
let method = method.as_str().expect("Invalid method received");
|
||||
if let Some(handler) = self.request_handlers.get(method) {
|
||||
let payload = handler(msg).await;
|
||||
let response = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": payload
|
||||
});
|
||||
self.tx
|
||||
.unbounded_send(response.to_string())
|
||||
.context("sending a message")?;
|
||||
} else {
|
||||
log::debug!("No handler registered for MCP request '{method}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
let rx = self.rx.clone();
|
||||
let executor = self.executor.clone();
|
||||
Box::pin(futures::stream::unfold(rx, move |rx| {
|
||||
let executor = executor.clone();
|
||||
async move {
|
||||
let mut rx_guard = rx.lock().await;
|
||||
executor.simulate_random_delay().await;
|
||||
if let Some(message) = rx_guard.next().await {
|
||||
drop(rx_guard);
|
||||
Some((message, rx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
}
|
||||
22
crates/context_server/src/transport.rs
Normal file
22
crates/context_server/src/transport.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
pub mod http;
|
||||
mod stdio_transport;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub use http::*;
|
||||
pub use stdio_transport::*;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Transport: Send + Sync {
|
||||
async fn send(&self, message: String) -> Result<()>;
|
||||
fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>>;
|
||||
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>>;
|
||||
|
||||
/// Called after the MCP initialize handshake completes so transports that
|
||||
/// need the negotiated version (currently only HTTP, which must attach an
|
||||
/// `MCP-Protocol-Version` header from 2025-06-18 onward) can pick it up.
|
||||
fn set_protocol_version(&self, _version: &str) {}
|
||||
}
|
||||
786
crates/context_server/src/transport/http.rs
Normal file
786
crates/context_server/src/transport/http.rs
Normal file
@@ -0,0 +1,786 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use collections::HashMap;
|
||||
use futures::{Stream, StreamExt};
|
||||
use gpui::BackgroundExecutor;
|
||||
use http_client::{AsyncBody, HttpClient, Request, Response, http::Method};
|
||||
use parking_lot::Mutex as SyncMutex;
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
use crate::oauth::{self, OAuthTokenProvider, WwwAuthenticate};
|
||||
use crate::transport::Transport;
|
||||
use crate::types;
|
||||
|
||||
/// Typed errors returned by the HTTP transport that callers can downcast from
|
||||
/// `anyhow::Error` to handle specific failure modes.
|
||||
#[derive(Debug)]
|
||||
pub enum TransportError {
|
||||
/// The server returned 401 and token refresh either wasn't possible or
|
||||
/// failed. The caller should initiate the OAuth authorization flow.
|
||||
AuthRequired { www_authenticate: WwwAuthenticate },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TransportError::AuthRequired { .. } => {
|
||||
write!(f, "OAuth authorization required")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TransportError {}
|
||||
|
||||
// Constants from MCP spec
|
||||
const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
|
||||
const HEADER_PROTOCOL_VERSION: &str = "MCP-Protocol-Version";
|
||||
const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
|
||||
const JSON_MIME_TYPE: &str = "application/json";
|
||||
|
||||
/// HTTP Transport with session management and SSE support
|
||||
pub struct HttpTransport {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
endpoint: String,
|
||||
session_id: Arc<SyncMutex<Option<String>>>,
|
||||
/// Negotiated MCP protocol version, populated by `set_protocol_version`
|
||||
/// after the initialize handshake. From 2025-06-18 onward the server
|
||||
/// requires clients to echo this in the `MCP-Protocol-Version` header on
|
||||
/// every subsequent request.
|
||||
protocol_version: Arc<SyncMutex<Option<String>>>,
|
||||
executor: BackgroundExecutor,
|
||||
response_tx: async_channel::Sender<String>,
|
||||
response_rx: async_channel::Receiver<String>,
|
||||
error_tx: async_channel::Sender<String>,
|
||||
error_rx: async_channel::Receiver<String>,
|
||||
/// Static headers to include in every request (e.g. from server config).
|
||||
headers: HashMap<String, String>,
|
||||
/// When set, the transport attaches `Authorization: Bearer` headers and
|
||||
/// handles 401 responses with token refresh + retry.
|
||||
token_provider: Option<Arc<dyn OAuthTokenProvider>>,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
pub fn new(
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
endpoint: String,
|
||||
headers: HashMap<String, String>,
|
||||
executor: BackgroundExecutor,
|
||||
) -> Self {
|
||||
Self::new_with_token_provider(http_client, endpoint, headers, executor, None)
|
||||
}
|
||||
|
||||
pub fn new_with_token_provider(
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
endpoint: String,
|
||||
headers: HashMap<String, String>,
|
||||
executor: BackgroundExecutor,
|
||||
token_provider: Option<Arc<dyn OAuthTokenProvider>>,
|
||||
) -> Self {
|
||||
let (response_tx, response_rx) = async_channel::unbounded();
|
||||
let (error_tx, error_rx) = async_channel::unbounded();
|
||||
|
||||
Self {
|
||||
http_client,
|
||||
executor,
|
||||
endpoint,
|
||||
session_id: Arc::new(SyncMutex::new(None)),
|
||||
protocol_version: Arc::new(SyncMutex::new(None)),
|
||||
response_tx,
|
||||
response_rx,
|
||||
error_tx,
|
||||
error_rx,
|
||||
headers,
|
||||
token_provider,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a POST request for the given message body, attaching all standard
|
||||
/// headers (content-type, accept, session ID, static headers, and bearer
|
||||
/// token if available).
|
||||
fn build_request(&self, message: &[u8]) -> Result<http_client::Request<AsyncBody>> {
|
||||
let mut request_builder = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&self.endpoint)
|
||||
.header("Content-Type", JSON_MIME_TYPE)
|
||||
.header(
|
||||
"Accept",
|
||||
format!("{}, {}", JSON_MIME_TYPE, EVENT_STREAM_MIME_TYPE),
|
||||
);
|
||||
|
||||
for (key, value) in &self.headers {
|
||||
request_builder = request_builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
// Attach bearer token when a token provider is present.
|
||||
if let Some(token) = self.token_provider.as_ref().and_then(|p| p.access_token()) {
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
// Add session ID if we have one (except for initialize).
|
||||
if let Some(ref session_id) = *self.session_id.lock() {
|
||||
request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_str());
|
||||
}
|
||||
|
||||
// Echo the negotiated protocol version once initialization has
|
||||
// completed. Required by servers speaking MCP 2025-06-18 or later.
|
||||
if let Some(ref version) = *self.protocol_version.lock()
|
||||
&& types::requires_protocol_version_header(version)
|
||||
{
|
||||
request_builder = request_builder.header(HEADER_PROTOCOL_VERSION, version.as_str());
|
||||
}
|
||||
|
||||
Ok(request_builder.body(AsyncBody::from(message.to_vec()))?)
|
||||
}
|
||||
|
||||
/// Send a message and handle the response based on content type.
|
||||
async fn send_message(&self, message: String) -> Result<()> {
|
||||
let is_notification =
|
||||
!message.contains("\"id\":") || message.contains("notifications/initialized");
|
||||
|
||||
// If we currently have no access token, try refreshing before sending
|
||||
// the request so restored but expired sessions do not need an initial
|
||||
// 401 round-trip before they can recover.
|
||||
if let Some(ref provider) = self.token_provider {
|
||||
if provider.access_token().is_none() {
|
||||
provider.try_refresh().await.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
|
||||
let request = self.build_request(message.as_bytes())?;
|
||||
let mut response = self.http_client.send(request).await?;
|
||||
|
||||
// On 401, try refreshing the token and retry once.
|
||||
if response.status().as_u16() == 401 {
|
||||
let www_auth_header = response
|
||||
.headers()
|
||||
.get("www-authenticate")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("Bearer");
|
||||
|
||||
let www_authenticate =
|
||||
oauth::parse_www_authenticate(www_auth_header).unwrap_or(WwwAuthenticate {
|
||||
resource_metadata: None,
|
||||
scope: None,
|
||||
error: None,
|
||||
error_description: None,
|
||||
});
|
||||
|
||||
if let Some(ref provider) = self.token_provider {
|
||||
if provider.try_refresh().await.unwrap_or(false) {
|
||||
// Retry with the refreshed token.
|
||||
let retry_request = self.build_request(message.as_bytes())?;
|
||||
response = self.http_client.send(retry_request).await?;
|
||||
|
||||
// If still 401 after refresh, give up.
|
||||
if response.status().as_u16() == 401 {
|
||||
return Err(TransportError::AuthRequired { www_authenticate }.into());
|
||||
}
|
||||
} else {
|
||||
return Err(TransportError::AuthRequired { www_authenticate }.into());
|
||||
}
|
||||
} else {
|
||||
return Err(TransportError::AuthRequired { www_authenticate }.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different response types based on status and content-type.
|
||||
match response.status() {
|
||||
status if status.is_success() => {
|
||||
// Check content type
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// Extract session ID from response headers if present
|
||||
if let Some(session_id) = response
|
||||
.headers()
|
||||
.get(HEADER_SESSION_ID)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
*self.session_id.lock() = Some(session_id.to_string());
|
||||
log::debug!("Session ID set: {}", session_id);
|
||||
}
|
||||
|
||||
match content_type {
|
||||
Some(ct) if ct.starts_with(JSON_MIME_TYPE) => {
|
||||
// JSON response - read and forward immediately
|
||||
let mut body = String::new();
|
||||
futures::AsyncReadExt::read_to_string(response.body_mut(), &mut body)
|
||||
.await?;
|
||||
|
||||
// Only send non-empty responses
|
||||
if !body.is_empty() {
|
||||
self.response_tx
|
||||
.send(body)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to send JSON response"))?;
|
||||
}
|
||||
}
|
||||
Some(ct) if ct.starts_with(EVENT_STREAM_MIME_TYPE) => {
|
||||
// SSE stream - set up streaming
|
||||
self.setup_sse_stream(response).await?;
|
||||
}
|
||||
_ => {
|
||||
// For notifications, 202 Accepted with no content type is ok
|
||||
if is_notification && status.as_u16() == 202 {
|
||||
log::debug!("Notification accepted");
|
||||
} else {
|
||||
return Err(anyhow!("Unexpected content type: {:?}", content_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status if status.as_u16() == 202 => {
|
||||
// Accepted - notification acknowledged, no response needed
|
||||
log::debug!("Notification accepted");
|
||||
}
|
||||
_ => {
|
||||
let mut error_body = String::new();
|
||||
futures::AsyncReadExt::read_to_string(response.body_mut(), &mut error_body).await?;
|
||||
|
||||
self.error_tx
|
||||
.send(format!("HTTP {}: {}", response.status(), error_body))
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to send error"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up SSE streaming from the response
|
||||
async fn setup_sse_stream(&self, mut response: Response<AsyncBody>) -> Result<()> {
|
||||
let response_tx = self.response_tx.clone();
|
||||
let error_tx = self.error_tx.clone();
|
||||
|
||||
// Spawn a task to handle the SSE stream
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let reader = futures::io::BufReader::new(response.body_mut());
|
||||
let mut lines = futures::AsyncBufReadExt::lines(reader);
|
||||
|
||||
let mut data_buffer = Vec::new();
|
||||
let mut in_message = false;
|
||||
|
||||
while let Some(line_result) = lines.next().await {
|
||||
match line_result {
|
||||
Ok(line) => {
|
||||
if line.is_empty() {
|
||||
// Empty line signals end of event
|
||||
if !data_buffer.is_empty() {
|
||||
let message = data_buffer.join("\n");
|
||||
|
||||
// Filter out ping messages and empty data
|
||||
if !message.trim().is_empty() && message != "ping" {
|
||||
if let Err(e) = response_tx.send(message).await {
|
||||
log::error!("Failed to send SSE message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
data_buffer.clear();
|
||||
}
|
||||
in_message = false;
|
||||
} else if let Some(data) = line.strip_prefix("data: ") {
|
||||
// Handle data lines
|
||||
let data = data.trim();
|
||||
if !data.is_empty() {
|
||||
// Check if this is a ping message
|
||||
if data == "ping" {
|
||||
log::trace!("Received SSE ping");
|
||||
continue;
|
||||
}
|
||||
data_buffer.push(data.to_string());
|
||||
in_message = true;
|
||||
}
|
||||
} else if line.starts_with("event:")
|
||||
|| line.starts_with("id:")
|
||||
|| line.starts_with("retry:")
|
||||
{
|
||||
// Ignore other SSE fields
|
||||
continue;
|
||||
} else if in_message {
|
||||
// Continuation of data
|
||||
data_buffer.push(line);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = error_tx.send(format!("SSE stream error: {}", e)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for HttpTransport {
|
||||
async fn send(&self, message: String) -> Result<()> {
|
||||
self.send_message(message).await
|
||||
}
|
||||
|
||||
fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
Box::pin(self.response_rx.clone())
|
||||
}
|
||||
|
||||
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
Box::pin(self.error_rx.clone())
|
||||
}
|
||||
|
||||
fn set_protocol_version(&self, version: &str) {
|
||||
*self.protocol_version.lock() = Some(version.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HttpTransport {
|
||||
fn drop(&mut self) {
|
||||
// Try to cleanup session on drop
|
||||
let http_client = self.http_client.clone();
|
||||
let endpoint = self.endpoint.clone();
|
||||
let session_id = self.session_id.lock().clone();
|
||||
let protocol_version = self.protocol_version.lock().clone();
|
||||
let headers = self.headers.clone();
|
||||
let access_token = self.token_provider.as_ref().and_then(|p| p.access_token());
|
||||
|
||||
if let Some(session_id) = session_id {
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut request_builder = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(&endpoint)
|
||||
.header(HEADER_SESSION_ID, &session_id);
|
||||
|
||||
// Add static authentication headers.
|
||||
for (key, value) in headers {
|
||||
request_builder = request_builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
// Attach bearer token if available.
|
||||
if let Some(token) = access_token {
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
// Stamp the negotiated MCP protocol version on the DELETE
|
||||
// too, matching what `build_request` does for POSTs.
|
||||
if let Some(ref version) = protocol_version
|
||||
&& types::requires_protocol_version_header(version)
|
||||
{
|
||||
request_builder =
|
||||
request_builder.header(HEADER_PROTOCOL_VERSION, version.as_str());
|
||||
}
|
||||
|
||||
let request = request_builder.body(AsyncBody::empty());
|
||||
|
||||
if let Ok(request) = request {
|
||||
let _ = http_client.send(request).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use gpui::TestAppContext;
|
||||
use parking_lot::Mutex as SyncMutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// A mock token provider that returns a configurable token and tracks
|
||||
/// refresh attempts.
|
||||
struct FakeTokenProvider {
|
||||
token: SyncMutex<Option<String>>,
|
||||
refreshed_token: SyncMutex<Option<String>>,
|
||||
refresh_succeeds: AtomicBool,
|
||||
refresh_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FakeTokenProvider {
|
||||
fn new(token: Option<&str>, refresh_succeeds: bool) -> Arc<Self> {
|
||||
Self::with_refreshed_token(token, None, refresh_succeeds)
|
||||
}
|
||||
|
||||
fn with_refreshed_token(
|
||||
token: Option<&str>,
|
||||
refreshed_token: Option<&str>,
|
||||
refresh_succeeds: bool,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
token: SyncMutex::new(token.map(String::from)),
|
||||
refreshed_token: SyncMutex::new(refreshed_token.map(String::from)),
|
||||
refresh_succeeds: AtomicBool::new(refresh_succeeds),
|
||||
refresh_count: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn set_token(&self, token: &str) {
|
||||
*self.token.lock() = Some(token.to_string());
|
||||
}
|
||||
|
||||
fn refresh_count(&self) -> usize {
|
||||
self.refresh_count.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OAuthTokenProvider for FakeTokenProvider {
|
||||
fn access_token(&self) -> Option<String> {
|
||||
self.token.lock().clone()
|
||||
}
|
||||
|
||||
async fn try_refresh(&self) -> Result<bool> {
|
||||
self.refresh_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let refresh_succeeds = self.refresh_succeeds.load(Ordering::SeqCst);
|
||||
if refresh_succeeds {
|
||||
if let Some(token) = self.refreshed_token.lock().clone() {
|
||||
*self.token.lock() = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(refresh_succeeds)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_fake_http_client(
|
||||
handler: impl Fn(
|
||||
http_client::Request<AsyncBody>,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = anyhow::Result<Response<AsyncBody>>> + Send>,
|
||||
> + Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Arc<dyn HttpClient> {
|
||||
http_client::FakeHttpClient::create(handler) as Arc<dyn HttpClient>
|
||||
}
|
||||
|
||||
fn json_response(status: u16, body: &str) -> anyhow::Result<Response<AsyncBody>> {
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(AsyncBody::from(body.as_bytes().to_vec()))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bearer_token_attached_to_requests(cx: &mut TestAppContext) {
|
||||
// Capture the Authorization header from the request.
|
||||
let captured_auth = Arc::new(SyncMutex::new(None::<String>));
|
||||
let captured_auth_clone = captured_auth.clone();
|
||||
|
||||
let client = make_fake_http_client(move |req| {
|
||||
let auth = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
*captured_auth_clone.lock() = auth;
|
||||
Box::pin(async { json_response(200, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#) })
|
||||
});
|
||||
|
||||
let provider = FakeTokenProvider::new(Some("test-access-token"), false);
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider),
|
||||
);
|
||||
|
||||
transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.expect("send should succeed");
|
||||
|
||||
assert_eq!(
|
||||
captured_auth.lock().as_deref(),
|
||||
Some("Bearer test-access-token"),
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_no_bearer_token_without_provider(cx: &mut TestAppContext) {
|
||||
let captured_auth = Arc::new(SyncMutex::new(None::<String>));
|
||||
let captured_auth_clone = captured_auth.clone();
|
||||
|
||||
let client = make_fake_http_client(move |req| {
|
||||
let auth = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
*captured_auth_clone.lock() = auth;
|
||||
Box::pin(async { json_response(200, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#) })
|
||||
});
|
||||
|
||||
let transport = HttpTransport::new(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
);
|
||||
|
||||
transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.expect("send should succeed");
|
||||
|
||||
assert!(captured_auth.lock().is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_missing_token_triggers_refresh_before_first_request(cx: &mut TestAppContext) {
|
||||
let captured_auth = Arc::new(SyncMutex::new(None::<String>));
|
||||
let captured_auth_clone = captured_auth.clone();
|
||||
|
||||
let client = make_fake_http_client(move |req| {
|
||||
let auth = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
*captured_auth_clone.lock() = auth;
|
||||
Box::pin(async { json_response(200, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#) })
|
||||
});
|
||||
|
||||
let provider = FakeTokenProvider::with_refreshed_token(None, Some("refreshed-token"), true);
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider.clone()),
|
||||
);
|
||||
|
||||
transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.expect("send should succeed after proactive refresh");
|
||||
|
||||
assert_eq!(provider.refresh_count(), 1);
|
||||
assert_eq!(
|
||||
captured_auth.lock().as_deref(),
|
||||
Some("Bearer refreshed-token"),
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_invalid_token_still_triggers_refresh_and_retry(cx: &mut TestAppContext) {
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_clone = request_count.clone();
|
||||
|
||||
let client = make_fake_http_client(move |_req| {
|
||||
let count = request_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
if count == 0 {
|
||||
Ok(Response::builder()
|
||||
.status(401)
|
||||
.header(
|
||||
"WWW-Authenticate",
|
||||
r#"Bearer error="invalid_token", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
|
||||
)
|
||||
.body(AsyncBody::from(b"Unauthorized".to_vec()))
|
||||
.unwrap())
|
||||
} else {
|
||||
json_response(200, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let provider = FakeTokenProvider::with_refreshed_token(
|
||||
Some("old-token"),
|
||||
Some("refreshed-token"),
|
||||
true,
|
||||
);
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider.clone()),
|
||||
);
|
||||
|
||||
transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.expect("send should succeed after refresh");
|
||||
|
||||
assert_eq!(provider.refresh_count(), 1);
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_401_triggers_refresh_and_retry(cx: &mut TestAppContext) {
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_clone = request_count.clone();
|
||||
|
||||
let client = make_fake_http_client(move |_req| {
|
||||
let count = request_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
if count == 0 {
|
||||
// First request: 401.
|
||||
Ok(Response::builder()
|
||||
.status(401)
|
||||
.header(
|
||||
"WWW-Authenticate",
|
||||
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
|
||||
)
|
||||
.body(AsyncBody::from(b"Unauthorized".to_vec()))
|
||||
.unwrap())
|
||||
} else {
|
||||
// Retry after refresh: 200.
|
||||
json_response(200, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let provider = FakeTokenProvider::new(Some("old-token"), true);
|
||||
// Simulate the refresh updating the token.
|
||||
let provider_ref = provider.clone();
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider.clone()),
|
||||
);
|
||||
|
||||
// Set the new token that will be used on retry.
|
||||
provider_ref.set_token("refreshed-token");
|
||||
|
||||
transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.expect("send should succeed after refresh");
|
||||
|
||||
assert_eq!(provider_ref.refresh_count(), 1);
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_401_returns_auth_required_when_refresh_fails(cx: &mut TestAppContext) {
|
||||
let client = make_fake_http_client(|_req| {
|
||||
Box::pin(async {
|
||||
Ok(Response::builder()
|
||||
.status(401)
|
||||
.header(
|
||||
"WWW-Authenticate",
|
||||
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="read write""#,
|
||||
)
|
||||
.body(AsyncBody::from(b"Unauthorized".to_vec()))
|
||||
.unwrap())
|
||||
})
|
||||
});
|
||||
|
||||
// Refresh returns false — no new token available.
|
||||
let provider = FakeTokenProvider::new(Some("stale-token"), false);
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider.clone()),
|
||||
);
|
||||
|
||||
let err = transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let transport_err = err
|
||||
.downcast_ref::<TransportError>()
|
||||
.expect("error should be TransportError");
|
||||
match transport_err {
|
||||
TransportError::AuthRequired { www_authenticate } => {
|
||||
assert_eq!(
|
||||
www_authenticate
|
||||
.resource_metadata
|
||||
.as_ref()
|
||||
.map(|u| u.as_str()),
|
||||
Some("https://mcp.example.com/.well-known/oauth-protected-resource"),
|
||||
);
|
||||
assert_eq!(
|
||||
www_authenticate.scope,
|
||||
Some(vec!["read".to_string(), "write".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(provider.refresh_count(), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_401_returns_auth_required_without_provider(cx: &mut TestAppContext) {
|
||||
let client = make_fake_http_client(|_req| {
|
||||
Box::pin(async {
|
||||
Ok(Response::builder()
|
||||
.status(401)
|
||||
.header("WWW-Authenticate", "Bearer")
|
||||
.body(AsyncBody::from(b"Unauthorized".to_vec()))
|
||||
.unwrap())
|
||||
})
|
||||
});
|
||||
|
||||
// No token provider at all.
|
||||
let transport = HttpTransport::new(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
);
|
||||
|
||||
let err = transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let transport_err = err
|
||||
.downcast_ref::<TransportError>()
|
||||
.expect("error should be TransportError");
|
||||
match transport_err {
|
||||
TransportError::AuthRequired { www_authenticate } => {
|
||||
assert!(www_authenticate.resource_metadata.is_none());
|
||||
assert!(www_authenticate.scope.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_401_after_successful_refresh_still_returns_auth_required(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
// Both requests return 401 — the server rejects the refreshed token too.
|
||||
let client = make_fake_http_client(|_req| {
|
||||
Box::pin(async {
|
||||
Ok(Response::builder()
|
||||
.status(401)
|
||||
.header("WWW-Authenticate", "Bearer")
|
||||
.body(AsyncBody::from(b"Unauthorized".to_vec()))
|
||||
.unwrap())
|
||||
})
|
||||
});
|
||||
|
||||
let provider = FakeTokenProvider::new(Some("token"), true);
|
||||
let transport = HttpTransport::new_with_token_provider(
|
||||
client,
|
||||
"http://mcp.example.com/mcp".to_string(),
|
||||
HashMap::default(),
|
||||
cx.background_executor.clone(),
|
||||
Some(provider.clone()),
|
||||
);
|
||||
|
||||
let err = transport
|
||||
.send(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
err.downcast_ref::<TransportError>()
|
||||
.expect("error should be TransportError");
|
||||
// Refresh was attempted exactly once.
|
||||
assert_eq!(provider.refresh_count(), 1);
|
||||
}
|
||||
}
|
||||
148
crates/context_server/src/transport/stdio_transport.rs
Normal file
148
crates/context_server/src/transport/stdio_transport.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::io::{BufReader, BufWriter};
|
||||
use futures::{
|
||||
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
|
||||
};
|
||||
use gpui::AsyncApp;
|
||||
|
||||
use util::TryFutureExt as _;
|
||||
use util::process::Child;
|
||||
use util::shell::Shell;
|
||||
use util::shell_builder::ShellBuilder;
|
||||
|
||||
use crate::client::ModelContextServerBinary;
|
||||
use crate::transport::Transport;
|
||||
|
||||
pub struct StdioTransport {
|
||||
stdout_sender: async_channel::Sender<String>,
|
||||
stdin_receiver: async_channel::Receiver<String>,
|
||||
stderr_receiver: async_channel::Receiver<String>,
|
||||
server: Child,
|
||||
}
|
||||
|
||||
impl StdioTransport {
|
||||
pub fn new(
|
||||
binary: ModelContextServerBinary,
|
||||
working_directory: &Option<PathBuf>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
|
||||
let mut command =
|
||||
builder.build_std_command(Some(binary.executable.display().to_string()), &binary.args);
|
||||
|
||||
command.envs(binary.env.unwrap_or_default());
|
||||
|
||||
if let Some(working_directory) = working_directory {
|
||||
command.current_dir(working_directory);
|
||||
}
|
||||
|
||||
let mut server = Child::spawn(
|
||||
command,
|
||||
std::process::Stdio::piped(),
|
||||
std::process::Stdio::piped(),
|
||||
std::process::Stdio::piped(),
|
||||
)?;
|
||||
|
||||
let stdin = server.stdin.take().unwrap();
|
||||
let stdout = server.stdout.take().unwrap();
|
||||
let stderr = server.stderr.take().unwrap();
|
||||
|
||||
let (stdin_sender, stdin_receiver) = async_channel::unbounded::<String>();
|
||||
let (stdout_sender, stdout_receiver) = async_channel::unbounded::<String>();
|
||||
let (stderr_sender, stderr_receiver) = async_channel::unbounded::<String>();
|
||||
|
||||
cx.spawn(async move |_| Self::handle_output(stdin, stdout_receiver).log_err().await)
|
||||
.detach();
|
||||
|
||||
cx.spawn(async move |_| Self::handle_input(stdout, stdin_sender).await)
|
||||
.detach();
|
||||
|
||||
cx.spawn(async move |_| Self::handle_err(stderr, stderr_sender).await)
|
||||
.detach();
|
||||
|
||||
Ok(Self {
|
||||
stdout_sender,
|
||||
stdin_receiver,
|
||||
stderr_receiver,
|
||||
server,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_input<Stdout>(stdin: Stdout, inbound_rx: async_channel::Sender<String>)
|
||||
where
|
||||
Stdout: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let mut stdin = BufReader::new(stdin);
|
||||
let mut line = String::new();
|
||||
while let Ok(n) = stdin.read_line(&mut line).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if inbound_rx.send(line.clone()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_output<Stdin>(
|
||||
stdin: Stdin,
|
||||
outbound_rx: async_channel::Receiver<String>,
|
||||
) -> Result<()>
|
||||
where
|
||||
Stdin: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let mut stdin = BufWriter::new(stdin);
|
||||
let mut pinned_rx = Box::pin(outbound_rx);
|
||||
while let Some(message) = pinned_rx.next().await {
|
||||
log::trace!("outgoing message: {}", message);
|
||||
|
||||
stdin.write_all(message.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_err<Stderr>(stderr: Stderr, stderr_tx: async_channel::Sender<String>)
|
||||
where
|
||||
Stderr: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let mut stderr = BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
while let Ok(n) = stderr.read_line(&mut line).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if stderr_tx.send(line.clone()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for StdioTransport {
|
||||
async fn send(&self, message: String) -> Result<()> {
|
||||
Ok(self.stdout_sender.send(message).await?)
|
||||
}
|
||||
|
||||
fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
Box::pin(self.stdin_receiver.clone())
|
||||
}
|
||||
|
||||
fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
|
||||
Box::pin(self.stderr_receiver.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StdioTransport {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.server.kill();
|
||||
}
|
||||
}
|
||||
811
crates/context_server/src/types.rs
Normal file
811
crates/context_server/src/types.rs
Normal file
@@ -0,0 +1,811 @@
|
||||
use collections::HashMap;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::client::RequestId;
|
||||
|
||||
pub const VERSION_2024_11_05: &str = "2024-11-05";
|
||||
pub const VERSION_2025_03_26: &str = "2025-03-26";
|
||||
pub const VERSION_2025_06_18: &str = "2025-06-18";
|
||||
pub const LATEST_PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
|
||||
/// Protocol versions that include the streamable HTTP transport's
|
||||
/// `MCP-Protocol-Version` header requirement on post-initialize requests.
|
||||
pub fn requires_protocol_version_header(version: &str) -> bool {
|
||||
matches!(version, VERSION_2025_06_18 | LATEST_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
pub mod requests {
|
||||
use super::*;
|
||||
|
||||
macro_rules! request {
|
||||
($method:expr, $name:ident, $params:ty, $response:ty) => {
|
||||
pub struct $name;
|
||||
|
||||
impl Request for $name {
|
||||
type Params = $params;
|
||||
type Response = $response;
|
||||
const METHOD: &'static str = $method;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
request!(
|
||||
"initialize",
|
||||
Initialize,
|
||||
InitializeParams,
|
||||
InitializeResponse
|
||||
);
|
||||
request!("tools/call", CallTool, CallToolParams, CallToolResponse);
|
||||
request!(
|
||||
"resources/unsubscribe",
|
||||
ResourcesUnsubscribe,
|
||||
ResourcesUnsubscribeParams,
|
||||
()
|
||||
);
|
||||
request!(
|
||||
"resources/subscribe",
|
||||
ResourcesSubscribe,
|
||||
ResourcesSubscribeParams,
|
||||
()
|
||||
);
|
||||
request!(
|
||||
"resources/read",
|
||||
ResourcesRead,
|
||||
ResourcesReadParams,
|
||||
ResourcesReadResponse
|
||||
);
|
||||
request!("resources/list", ResourcesList, (), ResourcesListResponse);
|
||||
request!(
|
||||
"logging/setLevel",
|
||||
LoggingSetLevel,
|
||||
LoggingSetLevelParams,
|
||||
()
|
||||
);
|
||||
request!(
|
||||
"prompts/get",
|
||||
PromptsGet,
|
||||
PromptsGetParams,
|
||||
PromptsGetResponse
|
||||
);
|
||||
request!("prompts/list", PromptsList, (), PromptsListResponse);
|
||||
request!(
|
||||
"completion/complete",
|
||||
CompletionComplete,
|
||||
CompletionCompleteParams,
|
||||
CompletionCompleteResponse
|
||||
);
|
||||
request!("ping", Ping, (), ());
|
||||
request!("tools/list", ListTools, (), ListToolsResponse);
|
||||
request!(
|
||||
"resources/templates/list",
|
||||
ListResourceTemplates,
|
||||
(),
|
||||
ListResourceTemplatesResponse
|
||||
);
|
||||
request!("roots/list", ListRoots, (), ListRootsResponse);
|
||||
}
|
||||
|
||||
pub trait Request {
|
||||
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
|
||||
type Response: DeserializeOwned + Serialize + Send + Sync + 'static;
|
||||
const METHOD: &'static str;
|
||||
}
|
||||
|
||||
pub mod notifications {
|
||||
use super::*;
|
||||
|
||||
macro_rules! notification {
|
||||
($method:expr, $name:ident, $params:ty) => {
|
||||
pub struct $name;
|
||||
|
||||
impl Notification for $name {
|
||||
type Params = $params;
|
||||
const METHOD: &'static str = $method;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
notification!("notifications/initialized", Initialized, ());
|
||||
notification!("notifications/progress", Progress, ProgressParams);
|
||||
notification!("notifications/message", Message, MessageParams);
|
||||
notification!("notifications/cancelled", Cancelled, CancelledParams);
|
||||
notification!(
|
||||
"notifications/resources/updated",
|
||||
ResourcesUpdated,
|
||||
ResourcesUpdatedParams
|
||||
);
|
||||
notification!(
|
||||
"notifications/resources/list_changed",
|
||||
ResourcesListChanged,
|
||||
()
|
||||
);
|
||||
notification!("notifications/tools/list_changed", ToolsListChanged, ());
|
||||
notification!("notifications/prompts/list_changed", PromptsListChanged, ());
|
||||
notification!("notifications/roots/list_changed", RootsListChanged, ());
|
||||
}
|
||||
|
||||
pub trait Notification {
|
||||
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
|
||||
const METHOD: &'static str;
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageParams {
|
||||
pub level: LoggingLevel,
|
||||
pub logger: Option<String>,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesUpdatedParams {
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProtocolVersion(pub String);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeParams {
|
||||
pub protocol_version: ProtocolVersion,
|
||||
pub capabilities: ClientCapabilities,
|
||||
pub client_info: Implementation,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolParams {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<serde_json::Value>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesUnsubscribeParams {
|
||||
pub uri: Url,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesSubscribeParams {
|
||||
pub uri: Url,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesReadParams {
|
||||
pub uri: Url,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoggingSetLevelParams {
|
||||
pub level: LoggingLevel,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsGetParams {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<HashMap<String, String>>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionCompleteParams {
|
||||
#[serde(rename = "ref")]
|
||||
pub reference: CompletionReference,
|
||||
pub argument: CompletionArgument,
|
||||
/// Previously-resolved argument values so the server can provide
|
||||
/// context-sensitive completions (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<CompletionContext>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionContext {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CompletionReference {
|
||||
Prompt(PromptReference),
|
||||
Resource(ResourceReference),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptReference {
|
||||
#[serde(rename = "type")]
|
||||
pub ty: PromptReferenceType,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceReference {
|
||||
#[serde(rename = "type")]
|
||||
pub ty: PromptReferenceType,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PromptReferenceType {
|
||||
#[serde(rename = "ref/prompt")]
|
||||
Prompt,
|
||||
#[serde(rename = "ref/resource")]
|
||||
Resource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionArgument {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeResponse {
|
||||
pub protocol_version: ProtocolVersion,
|
||||
pub capabilities: ServerCapabilities,
|
||||
pub server_info: Implementation,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesReadResponse {
|
||||
pub contents: Vec<ResourceContentsType>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResourceContentsType {
|
||||
Text(TextResourceContents),
|
||||
Blob(BlobResourceContents),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesListResponse {
|
||||
pub resources: Vec<Resource>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SamplingMessage {
|
||||
pub role: Role,
|
||||
pub content: MessageContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateMessageRequest {
|
||||
pub messages: Vec<SamplingMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_preferences: Option<ModelPreferences>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_context: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_sequences: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateMessageResult {
|
||||
pub role: Role,
|
||||
pub content: MessageContent,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptMessage {
|
||||
pub role: Role,
|
||||
pub content: MessageContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MessageContent {
|
||||
#[serde(rename = "text")]
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<MessageAnnotations>,
|
||||
},
|
||||
#[serde(rename = "image", rename_all = "camelCase")]
|
||||
Image {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<MessageAnnotations>,
|
||||
},
|
||||
#[serde(rename = "audio", rename_all = "camelCase")]
|
||||
Audio {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<MessageAnnotations>,
|
||||
},
|
||||
#[serde(rename = "resource")]
|
||||
Resource {
|
||||
resource: ResourceContents,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<MessageAnnotations>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageAnnotations {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audience: Option<Vec<Role>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsGetResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub messages: Vec<PromptMessage>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsListResponse {
|
||||
pub prompts: Vec<Prompt>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionCompleteResponse {
|
||||
pub completion: CompletionResult,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionResult {
|
||||
pub values: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_more: Option<bool>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Prompt {
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<PromptArgument>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptArgument {
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sampling: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub roots: Option<RootsCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completions: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompts: Option<PromptsCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<ResourcesCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RootsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub annotations: Option<ToolAnnotations>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolAnnotations {
|
||||
/// A human-readable title for the tool.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
/// If true, the tool does not modify its environment.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_only_hint: Option<bool>,
|
||||
/// If true, the tool may perform destructive updates to its environment.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub destructive_hint: Option<bool>,
|
||||
/// If true, calling the tool repeatedly with the same arguments will have no additional effect on its environment.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub idempotent_hint: Option<bool>,
|
||||
/// If true, this tool may interact with an "open world" of external entities.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub open_world_hint: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Implementation {
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
pub version: String,
|
||||
/// Human-readable description of the implementation (added in MCP 2025-11-25).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Resource {
|
||||
pub uri: Url,
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceContents {
|
||||
pub uri: Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextResourceContents {
|
||||
pub uri: Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlobResourceContents {
|
||||
pub uri: Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
pub blob: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceTemplate {
|
||||
pub uri_template: String,
|
||||
pub name: String,
|
||||
/// Human-readable display name (added in MCP 2025-06-18).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LoggingLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Notice,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
Alert,
|
||||
Emergency,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelPreferences {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hints: Option<Vec<ModelHint>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_priority: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speed_priority: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub intelligence_priority: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelHint {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ClientNotification {
|
||||
Initialized,
|
||||
Progress(ProgressParams),
|
||||
RootsListChanged,
|
||||
Cancelled(CancelledParams),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelledParams {
|
||||
pub request_id: RequestId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ProgressToken {
|
||||
String(String),
|
||||
Number(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProgressParams {
|
||||
pub progress_token: ProgressToken,
|
||||
pub progress: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<f64>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
pub enum CompletionTotal {
|
||||
Exact(u32),
|
||||
HasMore,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CompletionTotal {
|
||||
pub fn from_options(has_more: Option<bool>, total: Option<u32>) -> Self {
|
||||
match (has_more, total) {
|
||||
(_, Some(count)) => CompletionTotal::Exact(count),
|
||||
(Some(true), _) => CompletionTotal::HasMore,
|
||||
_ => CompletionTotal::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Completion {
|
||||
pub values: Vec<String>,
|
||||
pub total: CompletionTotal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolResponse {
|
||||
pub content: Vec<ToolResponseContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub structured_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl CallToolResponse {
|
||||
pub fn text_contents(&self) -> String {
|
||||
let mut text = String::new();
|
||||
for chunk in &self.content {
|
||||
if let ToolResponseContent::Text { text: chunk } = chunk {
|
||||
text.push_str(chunk)
|
||||
};
|
||||
}
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolResponseContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image", rename_all = "camelCase")]
|
||||
Image { data: String, mime_type: String },
|
||||
#[serde(rename = "audio", rename_all = "camelCase")]
|
||||
Audio { data: String, mime_type: String },
|
||||
#[serde(rename = "resource")]
|
||||
Resource { resource: ResourceContents },
|
||||
/// Link to an MCP resource on the server, without inlining its contents.
|
||||
/// Added in MCP 2025-06-18.
|
||||
#[serde(rename = "resource_link", rename_all = "camelCase")]
|
||||
ResourceLink {
|
||||
uri: Url,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolResponseContent {
|
||||
pub fn text(&self) -> Option<&str> {
|
||||
if let ToolResponseContent::Text { text } = self {
|
||||
Some(text)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListToolsResponse {
|
||||
pub tools: Vec<Tool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListResourceTemplatesResponse {
|
||||
pub resource_templates: Vec<ResourceTemplate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListRootsResponse {
|
||||
pub roots: Vec<Root>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Root {
|
||||
pub uri: Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user