googleapis / googleapis/mcp-toolbox-sdk-python

fix(core): cancelled waiter cancels shared MCP initialization

Open
#743 0 comments 0 reactions 1 assignee Claimed by @anubhav756 View on GitHub
priority: p2
Dominant language
Python
Stars
191
Forks
60
Avg merge
5d 20m
Merged PRs (30d)
13

Description

### Prerequisites

- [x] I searched current and closed issues and pull requests, including comments, for shared initialization, cancellation, `CancelledError`, `asyncio.shield`, `_ensure_initialized`, and `_init_task`.
- [x] I reproduced this on current `upstream/main` at `88cc7959d9c7ebc1d92dedeae90e56fb10d2a943`.

### Toolbox version

Not applicable. The bug is deterministic in the Python SDK transport base and does not require a live Toolbox server.

### Environment

- macOS
- Python 3.13.5
- toolbox-core from current `upstream/main`

### Expected behavior

When concurrent first requests share the MCP transport initialization task, cancelling one request should cancel only that waiter. Other concurrent requests and later requests should still be able to complete the same one-time initialization.

### Current behavior

`_McpHttpTransportBase._ensure_initialized()` awaits its shared `_init_task` directly. Cancelling any waiter propagates cancellation into that shared task, which cancels other waiters and causes every later request to raise `CancelledError`.

Observed output from a deterministic local reproduction:

```text
first_waiter=cancelled
second_waiter=cancelled
shared_init_cancelled=True
later_initialization=cancelled
```

### Steps to reproduce

1. Create a concrete `_McpHttpTransportBase` whose `_initialize_session()` waits on an `asyncio.Event`.
2. Start two concurrent `_ensure_initialized()` waiters.
3. Cancel the first waiter while initialization is pending.
4. Release initialization and await the second waiter.
5. Call `_ensure_initialized()` again.

The second and later waits are cancelled even though only the first caller was cancelled.

### Root cause and proposed scope

The one shared task is awaited without cancellation isolation. The narrow fix is to await it through `asyncio.shield()`, with a regression test proving one cancelled waiter does not cancel the shared initialization. Genuine initialization failures should continue to propagate unchanged.

This is independent of open PRs #735, #736, #737, #740, and #741 and does not touch their files or behaviors.

### Additional details

No credentials, live services, or timing sleeps are required; the regression can use controlled asyncio events. This report and proposed patch were prepared with AI assistance and manually validated against current upstream source and tests.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.