Skip to content

Instantly share code, notes, and snippets.

@DePasqualeOrg
Created January 6, 2026 22:15
Show Gist options
  • Select an option

  • Save DePasqualeOrg/71e65cdb04247f9d0d84023dd0d8f759 to your computer and use it in GitHub Desktop.

Select an option

Save DePasqualeOrg/71e65cdb04247f9d0d84023dd0d8f759 to your computer and use it in GitHub Desktop.
Progress Notification Handling in Swift MCP SDK

SDK Comparison

All three official SDKs use the callback approach.

TypeScript SDK

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}`);
    }
});

Python SDK

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
)

Swift SDK (Current Implementation)

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.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 ?? "")")
    }
)

Detailed Analysis of Each Approach

Option 1: Manual Stream Subscription

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

Option 2: Callback Closure (Current Implementation)

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

Option 3: AsyncStream in Return Type

Return both the result and a progress stream.

Variant A: Tuple Return

// 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

Variant B: Concurrent Consumption

// 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.value

Pros:

  • 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

Variant C: Wrapper Type

// 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

Recommendation

Keep the callback approach (Option 2) for these reasons:

  1. SDK Consistency: Both TypeScript and Python SDKs use callbacks. Cross-SDK consistency helps developers who work with multiple MCP implementations.

  2. 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.

  3. 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
  4. Discoverable: The onProgress parameter makes progress support visible in autocomplete.

  5. No Race Conditions: The callback is registered before the request is sent, so no progress notifications are missed.

Potential Enhancement: AsyncStream Convenience

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.

Summary

Callback Signatures Across SDKs

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 Comparison

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment