fastmcp-client 0.11.0

MCP client implementation for FastMCP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Caller-owned execution of final-protocol sampling conversations.
//!
//! Hosts supply model access, whole-batch approval and tool execution. The
//! driver reuses the protocol's sampling controller, never selects a model,
//! opens a URL, grants a capability, or retries an external effect on its own.
//! It is suitable for resolving embedded sampling in an OAuth-backed MRTR
//! interaction; choosing this runner does not itself advertise sampling support.
//!
//! One absolute deadline covers model calls, consent and tools. All futures are
//! polled inline under the supplied Cx; there is no runtime or detached worker.
//! Host futures must be cancellation-correct on drop and must not hide blocking
//! work or detached children. Already-committed host effects cannot be undone.

use std::collections::BTreeMap;
use std::fmt;
use std::future::{Future, poll_fn};
use std::io::{self, Write};
use std::pin::Pin;
use std::task::Poll;
use std::time::Duration;

use asupersync::Cx;
use asupersync::channel::oneshot;
use asupersync::time::Sleep;
use asupersync::types::Time;
use fastmcp_core::McpRequestCancellation;
use fastmcp_protocol::common_types::SamplingContentBlock;
use fastmcp_protocol::sampling::{
    SamplingToolLoop, SamplingToolLoopError, SamplingToolLoopLimits, SamplingToolLoopStep,
};
use fastmcp_protocol::{
    AdmittedSchema, FinalCreateMessageResult, FinalEmbeddedCreateMessageParams, admit_final_schema,
};

/// Bounded sampling-only resolution of final input-required response maps.
pub mod inputs;

/// A host-owned operation borrowing its model/tool implementation and caller.
/// There is no 'static requirement and no task is spawned by this driver.
pub type SamplingHostFuture<'a, T> =
    Pin<Box<dyn Future<Output = Result<T, SamplingHostError>> + Send + 'a>>;

/// Fixed host errors. Provider diagnostics, arguments and credentials must not
/// be copied into these errors. A tool's ordinary application error should be
/// returned as an explicit isError tool-result block, not a transport failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SamplingHostError {
    Denied,
    Failed,
}

/// The explicit trusted host boundary. Implementations must make their own
/// capability, consent and disclosure decisions using their retained context.
/// A schema annotation, tool name or model response is never authorization.
pub trait SamplingHost: Send {
    /// Review model disclosure and perform exactly one model call. The request
    /// is borrowed and immutable; the host must not silently retry it.
    fn sample<'a>(
        &'a mut self,
        cx: &'a Cx,
        cancellation: &'a McpRequestCancellation,
        request: &'a FinalEmbeddedCreateMessageParams,
    ) -> SamplingHostFuture<'a, FinalCreateMessageResult>;

    /// Approve the entire admitted batch before its first tool is executed.
    /// A denial rejects all calls, including any earlier reviewed sibling.
    fn approve_tools<'a>(
        &'a mut self,
        cx: &'a Cx,
        cancellation: &'a McpRequestCancellation,
        calls: &'a [SamplingContentBlock],
    ) -> SamplingHostFuture<'a, ()>;

    /// Execute one approved tool-use block. Calls are sequential in model
    /// order. The result must retain this call's exact toolUseId. The host must
    /// recheck any revocable application authority at its own effect boundary.
    fn execute_tool<'a>(
        &'a mut self,
        cx: &'a Cx,
        cancellation: &'a McpRequestCancellation,
        call: &'a SamplingContentBlock,
    ) -> SamplingHostFuture<'a, SamplingContentBlock>;
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SamplingStage {
    Model,
    Approval,
    Tool,
}

/// Errors do not retain model content, tool IDs, arguments, schemas or tokens.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SamplingRunError {
    InvalidLimits,
    RuntimeUnavailable,
    Cancelled,
    TimedOut,
    Host {
        stage: SamplingStage,
        reason: SamplingHostError,
    },
    Protocol(SamplingToolLoopError),
    ToolResultByteLimit,
}

impl fmt::Display for SamplingRunError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "sampling execution: {self:?}")
    }
}
impl std::error::Error for SamplingRunError {}
impl From<SamplingToolLoopError> for SamplingRunError {
    fn from(error: SamplingToolLoopError) -> Self {
        Self::Protocol(error)
    }
}

/// Limits for the whole run, never reset between rounds. The protocol limits
/// bound retained conversation, model rounds and admitted tool calls. The
/// additional result budget counts every encoded tool-result block returned
/// during this run, including results later discarded after a failure.
#[derive(Clone, Copy, Debug)]
pub struct SamplingRunLimits {
    conversation: SamplingToolLoopLimits,
    timeout: Duration,
    tool_result_bytes: usize,
}

impl SamplingRunLimits {
    pub fn new(
        conversation: SamplingToolLoopLimits,
        timeout: Duration,
        tool_result_bytes: usize,
    ) -> Result<Self, SamplingRunError> {
        if timeout.is_zero()
            || timeout > Duration::from_secs(3600)
            || tool_result_bytes == 0
            || tool_result_bytes > 16 * 1024 * 1024
        {
            return Err(SamplingRunError::InvalidLimits);
        }
        Ok(Self {
            conversation,
            timeout,
            tool_result_bytes,
        })
    }
}

impl Default for SamplingRunLimits {
    fn default() -> Self {
        Self {
            conversation: SamplingToolLoopLimits::default(),
            timeout: Duration::from_secs(300),
            tool_result_bytes: 4 * 1024 * 1024,
        }
    }
}

/// The exact final response and counts for this invocation. Initial history
/// does not count as newly executed work. Content is deliberately not Debug.
pub struct SamplingRunResult {
    pub response: FinalCreateMessageResult,
    pub model_rounds: usize,
    pub executed_tools: usize,
}

/// Runs a complete bounded sampling conversation using explicit host callbacks.
///
/// Initial schemas/history and each whole model batch are admitted before host
/// tool approval or execution. Each result is correlated and output-validated
/// before the next tool can start. Explicit null, metadata, multimodal content,
/// model names and open stop-reason values remain in their typed wire forms.
///
/// Dropping this future retires the conversation and its pending host future;
/// no controller or partial transcript is returned for accidental replay. A
/// failure after a host effect cannot roll that effect back. Callers requiring
/// durable exactly-once behavior must implement it at the tool boundary.
pub async fn run_sampling_tool_loop<H: SamplingHost + ?Sized>(
    cx: &Cx,
    cancellation: &McpRequestCancellation,
    request: FinalEmbeddedCreateMessageParams,
    limits: SamplingRunLimits,
    host: &mut H,
) -> Result<SamplingRunResult, SamplingRunError> {
    let deadline = deadline(cx, cancellation, limits.timeout)?;
    let mut conversation = SamplingToolLoop::new(request, limits.conversation)?;
    // Reuse the shared schema service for immediate per-result validation.
    // The protocol controller owns final batch admission; retaining these
    // admitted outputs avoids executing later tools after an invalid result.
    let mut outputs: BTreeMap<String, AdmittedSchema> = BTreeMap::new();
    for tool in conversation
        .request()
        .into_iter()
        .flat_map(|request| request.tools.iter().flatten())
    {
        if let Some(schema) = &tool.output_schema {
            outputs.insert(
                tool.name.clone(),
                admit_final_schema(schema.clone())
                    .map_err(|_| SamplingToolLoopError::InvalidSchema)?,
            );
        }
    }
    let mut executed_tools = 0;
    let mut result_bytes = 0_usize;
    loop {
        check(cx, cancellation, deadline)?;
        let request = conversation
            .request()
            .ok_or(SamplingToolLoopError::WrongPhase)?;
        // Callback construction itself is inside the guarded future, not an
        // eager argument expression that could perform work before admission.
        let response = within(cx, cancellation, deadline, async {
            host.sample(cx, cancellation, request)
                .await
                .map_err(|reason| SamplingRunError::Host {
                    stage: SamplingStage::Model,
                    reason,
                })
        })
        .await?;
        match conversation.accept_response(response)? {
            SamplingToolLoopStep::Complete => {
                check(cx, cancellation, deadline)?;
                let response = conversation
                    .result()
                    .ok_or(SamplingToolLoopError::WrongPhase)?
                    .clone();
                check(cx, cancellation, deadline)?;
                return Ok(SamplingRunResult {
                    response,
                    model_rounds: conversation.round_count(),
                    executed_tools,
                });
            }
            SamplingToolLoopStep::Tools { count } => {
                let calls: Vec<_> = conversation.pending_tool_calls().cloned().collect();
                within(cx, cancellation, deadline, async {
                    host.approve_tools(cx, cancellation, &calls)
                        .await
                        .map_err(|reason| SamplingRunError::Host {
                            stage: SamplingStage::Approval,
                            reason,
                        })
                })
                .await?;
                let mut results = Vec::with_capacity(count);
                for call in &calls {
                    // A cooperative boundary also gives cancellation a chance
                    // to run when every host callback completes immediately.
                    cooperate(cx, cancellation, deadline).await?;
                    let result = within(cx, cancellation, deadline, async {
                        host.execute_tool(cx, cancellation, call)
                            .await
                            .map_err(|reason| SamplingRunError::Host {
                                stage: SamplingStage::Tool,
                                reason,
                            })
                    })
                    .await?;
                    let remaining = limits.tool_result_bytes - result_bytes;
                    let bytes = encoded_size(&result, remaining)?;
                    validate_output(call, &result, &outputs)?;
                    result_bytes += bytes;
                    executed_tools += 1;
                    results.push(result);
                }
                conversation.submit_tool_results(results)?;
                cooperate(cx, cancellation, deadline).await?;
            }
        }
    }
}

fn validate_output(
    call: &SamplingContentBlock,
    result: &SamplingContentBlock,
    outputs: &BTreeMap<String, AdmittedSchema>,
) -> Result<(), SamplingRunError> {
    let SamplingContentBlock::ToolUse { id, name, .. } = call else {
        return Err(SamplingToolLoopError::InvalidResponse.into());
    };
    let SamplingContentBlock::ToolResult {
        tool_use_id,
        structured_content,
        is_error,
        ..
    } = result
    else {
        return Err(SamplingToolLoopError::InvalidToolResults.into());
    };
    if tool_use_id != id {
        return Err(SamplingToolLoopError::InvalidToolResults.into());
    }
    if *is_error != Some(true) {
        if let Some(schema) = outputs.get(name) {
            let value = structured_content
                .as_ref()
                .ok_or(SamplingToolLoopError::InvalidToolOutput)?;
            schema
                .validate(value)
                .map_err(|_| SamplingToolLoopError::InvalidToolOutput)?;
        }
    }
    Ok(())
}

// Measure without allocating an extra unbounded serialization buffer.
fn encoded_size(value: &impl serde::Serialize, maximum: usize) -> Result<usize, SamplingRunError> {
    struct Counter {
        bytes: usize,
        maximum: usize,
        exceeded: bool,
    }
    impl Write for Counter {
        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
            if bytes.len() > self.maximum - self.bytes {
                self.exceeded = true;
                return Err(io::Error::other("sampling result budget"));
            }
            self.bytes += bytes.len();
            Ok(bytes.len())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }
    let mut counter = Counter {
        bytes: 0,
        maximum,
        exceeded: false,
    };
    if serde_json::to_writer(&mut counter, value).is_err() {
        return Err(if counter.exceeded {
            SamplingRunError::ToolResultByteLimit
        } else {
            SamplingToolLoopError::InvalidToolResults.into()
        });
    }
    Ok(counter.bytes)
}

fn deadline(
    cx: &Cx,
    cancellation: &McpRequestCancellation,
    timeout: Duration,
) -> Result<Time, SamplingRunError> {
    check(cx, cancellation, Time::from_nanos(u64::MAX))?;
    if cx.timer_driver().is_none() {
        return Err(SamplingRunError::RuntimeUnavailable);
    }
    let nanos = u64::try_from(timeout.as_nanos()).map_err(|_| SamplingRunError::InvalidLimits)?;
    let end = cx
        .now()
        .as_nanos()
        .checked_add(nanos)
        .ok_or(SamplingRunError::InvalidLimits)?;
    let deadline = Time::from_nanos(end);
    check(cx, cancellation, deadline)?;
    Ok(deadline)
}

fn check(
    cx: &Cx,
    cancellation: &McpRequestCancellation,
    deadline: Time,
) -> Result<(), SamplingRunError> {
    use asupersync::{CancelKind, error::ErrorKind};
    if cancellation.is_cancel_requested() {
        return Err(SamplingRunError::Cancelled);
    }
    if cx.now() >= deadline || cx.budget().deadline.is_some_and(|end| cx.now() >= end) {
        return Err(SamplingRunError::TimedOut);
    }
    cx.checkpoint()
        .map_err(|error| match cx.cancel_reason().map(|reason| reason.kind) {
            Some(CancelKind::Deadline | CancelKind::Timeout) => SamplingRunError::TimedOut,
            Some(_) => SamplingRunError::Cancelled,
            None => match error.kind() {
                ErrorKind::DeadlineExceeded | ErrorKind::CancelTimeout => {
                    SamplingRunError::TimedOut
                }
                _ => SamplingRunError::Cancelled,
            },
        })
}

async fn within<T>(
    cx: &Cx,
    cancellation: &McpRequestCancellation,
    deadline: Time,
    future: impl Future<Output = Result<T, SamplingRunError>>,
) -> Result<T, SamplingRunError> {
    let deadline = cx
        .budget()
        .deadline
        .map_or(deadline, |parent| parent.min(deadline));
    let mut sleep = std::pin::pin!(Sleep::new(deadline));
    let mut cancelled = std::pin::pin!(cancellation.cancelled());
    let (_sender, mut receiver) = oneshot::channel::<()>();
    let mut caller_cancelled = std::pin::pin!(receiver.recv(cx));
    let mut future = std::pin::pin!(future);
    poll_fn(|task| {
        let _caller = Cx::set_current(Some(cx.clone()));
        check(cx, cancellation, deadline)?;
        if cancelled.as_mut().poll(task).is_ready()
            || caller_cancelled.as_mut().poll(task).is_ready()
        {
            return Poll::Ready(Err(SamplingRunError::Cancelled));
        }
        if sleep.as_mut().poll(task).is_ready() {
            return Poll::Ready(Err(SamplingRunError::TimedOut));
        }
        let result = future.as_mut().poll(task);
        check(cx, cancellation, deadline)?;
        result
    })
    .await
}

async fn cooperate(
    cx: &Cx,
    cancellation: &McpRequestCancellation,
    deadline: Time,
) -> Result<(), SamplingRunError> {
    let mut yielded = false;
    within(
        cx,
        cancellation,
        deadline,
        poll_fn(|task| {
            if yielded {
                Poll::Ready(Ok(()))
            } else {
                yielded = true;
                task.waker().wake_by_ref();
                Poll::Pending
            }
        }),
    )
    .await
}