All three official SDKs use the callback approach.
From packages/core/src/shared/protocol.ts:
// Type definition
export type ProgressCallback = (progress: Progress) => void;
// RequestOptions interface
export type RequestOptions = {
/**
* If set, requests progress notifications from the remote end (if supported).
* When progress notifications are received, this callback will be invoked.
*/
onprogress?: ProgressCallback;
// ... other options
}
// Progress type (from types/types.ts)
export const ProgressSchema = z.object({
progress: z.number(),
total: z.optional(z.number()),
message: z.optional(z.string())
});Usage:
const result = await protocol.request(request, schema, {
onprogress: (progress) => {
console.log(`Progress: ${progress.progress}/${progress.total}`);
}
});From src/mcp/shared/session.py:
class ProgressFnT(Protocol):
"""Protocol for progress notification callbacks."""
async def __call__(
self, progress: float, total: float | None, message: str | None
) -> None: ...From src/mcp/client/session.py:
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
meta: dict[str, Any] | None = None,
) -> types.CallToolResult:
"""Send a tools/call request with optional progress callback support."""Usage:
async def my_progress_callback(
progress: float, total: float | None, message: str | None
) -> None:
print(f"Progress: {progress}/{total} - {message}")
result = await session.call_tool(
"slow_operation",
{"steps": 5},
progress_callback=my_progress_callback
)From Sources/MCP/Base/Progress.swift:
/// A callback invoked when a progress notification is received.
public typealias ProgressCallback = @Sendable (Progress) async -> Void
/// Progress information received during a long-running operation.
public struct Progress: Sendable, Hashable {
public let value: Double
public let total: Double?
public let message: String?
}From Sources/MCP/Client/Client+Requests.swift:
public func send<M: Method>(
_ request: Request<M>,
onProgress: @escaping ProgressCallback
) async throws -> M.ResultUsage:
let result = try await client.send(
CallTool.request(.init(name: "slow_operation", arguments: ["steps": 5])),
onProgress: { progress in
print("Progress: \(progress.value)/\(progress.total ?? 0) - \(progress.message ?? "")")
}
)The caller manually sets a progressToken and subscribes to the notification stream.
// Example API
extension Client {
/// Stream of all progress notifications
public var progressNotifications: AsyncStream<ProgressNotification.Parameters> { get }
}
// Usage
let progressToken: ProgressToken = "my-request-123"
// Must manually inject the token
var request = CallTool.request(.init(name: "slow_operation"))
request.params._meta = RequestMeta(progressToken: progressToken)
// Start listening BEFORE sending (or risk missing notifications)
Task {
for await notification in client.progressNotifications {
if notification.progressToken == progressToken {
print("Progress: \(notification.progress)")
}
}
}
// Send the request
let result = try await client.send(request)Pros:
- Maximum flexibility
- No API changes to existing methods
- Caller has full control over token generation
Cons:
- Verbose and error-prone
- Easy to miss notifications (race condition if listener starts after send)
- Caller must manually correlate tokens
- Not discoverable - progress support isn't obvious from API
Pass a closure that receives progress updates.
// Current API
public func send<M: Method>(
_ request: Request<M>,
onProgress: @escaping ProgressCallback
) async throws -> M.Result
// Usage
let result = try await client.send(
CallTool.request(.init(name: "slow_operation", arguments: ["steps": 5])),
onProgress: { progress in
print("Progress: \(progress.value)/\(progress.total ?? 0) - \(progress.message ?? "")")
}
)
Pros:
- Consistent with TypeScript and Python SDKs
- Simple, discoverable API
- Automatic token generation and injection
- Return type unchanged - easy to ignore progress if not needed
- Callback is
@Sendable async- works with structured concurrency
Cons:
- Callbacks feel like a pre-async/await pattern to some Swift developers
- Less composable than AsyncSequence
Return both the result and a progress stream.
// Hypothetical API
public func sendWithProgress<M: Method>(
_ request: Request<M>
) async throws -> (progress: AsyncStream<Progress>, result: M.Result)
// Usage - awkward because you need to consume stream while awaiting result
let (progressStream, result) = try await client.sendWithProgress(request)
// Problem: result is already available, progress stream may be stale
// This doesn't work well because the result comes after progress completes// Hypothetical API
public func sendWithProgress<M: Method>(
_ request: Request<M>
) -> (progress: AsyncStream<Progress>, result: Task<M.Result, Error>)
// Usage
let (progressStream, resultTask) = client.sendWithProgress(request)
// Must handle progress in separate task
Task {
for await progress in progressStream {
print("Progress: \(progress.value)")
}
}
// Await the result
let result = try await resultTask.valuePros:
- More "Swift-y" - uses AsyncSequence patterns
- Composable with other async sequences
Cons:
- Awkward API - caller must manage two concurrent streams
- Forces all callers to deal with tuple/wrapper even if they don't want progress
- Unclear ownership - who cancels what?
- Doesn't match TypeScript/Python SDK patterns
// Hypothetical API
public struct ProgressingRequest<Result: Sendable>: AsyncSequence {
public typealias Element = ProgressOrResult<Result>
public enum ProgressOrResult<R> {
case progress(Progress)
case result(R)
}
// AsyncSequence implementation...
}
public func sendWithProgress<M: Method>(
_ request: Request<M>
) -> ProgressingRequest<M.Result>
// Usage
for try await event in client.sendWithProgress(request) {
switch event {
case .progress(let p):
print("Progress: \(p.value)")
case .result(let r):
print("Result: \(r)")
}
}Pros:
- Single stream to consume
- Type-safe discrimination between progress and result
Cons:
- Significantly more complex API
- Every caller must handle the enum even if they only want the result
- Need convenience methods to just get result (back to Option 2 essentially)
- Unusual pattern - not idiomatic for request/response
Keep the callback approach (Option 2) for these reasons:
-
SDK Consistency: Both TypeScript and Python SDKs use callbacks. Cross-SDK consistency helps developers who work with multiple MCP implementations.
-
Modern Swift Callbacks: The callback is
@Sendable (Progress) async -> Void, which is modern Swift concurrency, not old Objective-C completion handlers. It integrates well with structured concurrency. -
API Simplicity: The return type stays
M.Result. Callers who don't need progress get the simple API:let result = try await client.send(request) // No progress let result = try await client.send(request, onProgress: { ... }) // With progress
-
Discoverable: The
onProgressparameter makes progress support visible in autocomplete. -
No Race Conditions: The callback is registered before the request is sent, so no progress notifications are missed.
If there's strong community preference, we could add an AsyncStream-based convenience in addition to the callback API:
extension Client {
/// Send a request and receive progress updates as an AsyncStream.
///
/// This is a convenience wrapper around `send(_:onProgress:)` for developers
/// who prefer AsyncSequence patterns.
public func sendWithProgressStream<M: Method>(
_ request: Request<M>
) -> (progress: AsyncStream<Progress>, result: Task<M.Result, Error>) {
let (stream, continuation) = AsyncStream<Progress>.makeStream()
let resultTask = Task {
defer { continuation.finish() }
return try await send(request, onProgress: { progress in
continuation.yield(progress)
})
}
return (stream, resultTask)
}
}
// Usage
let (progressStream, resultTask) = client.sendWithProgressStream(request)
await withTaskGroup(of: Void.self) { group in
group.addTask {
for await progress in progressStream {
print("Progress: \(progress.value)")
}
}
group.addTask {
let result = try? await resultTask.value
print("Done: \(result)")
}
}This gives developers a choice without changing the core API or breaking SDK consistency.
| SDK | Callback Type | Async? |
|---|---|---|
| TypeScript | (progress: Progress) => void |
No |
| Python | async def __call__(progress, total, message) -> None |
Yes |
| Swift | @Sendable (Progress) async -> Void |
Yes |
Note: TypeScript uses a synchronous callback, while Python and Swift use async callbacks. Swift's callback is additionally marked @Sendable for safe concurrent use.
| Approach | SDK Consistency | API Simplicity | Swift Idioms | Recommendation |
|---|---|---|---|---|
| Option 1: Manual subscription | N/A | Poor | Neutral | Not recommended |
| Option 2: Callback | Matches TS/Python | Good | Modern async | Current choice |
| Option 3: AsyncStream | Differs from TS/Python | Complex | Very Swift-y | Optional addition |
The callback approach strikes the best balance between SDK consistency, API simplicity, and modern Swift patterns. An AsyncStream convenience could be added as an alternative for developers who prefer that pattern.