Skip to content

Clean MCP sessions and deduplicate tool notifications - #1286

Open
liuzqk wants to merge 1 commit into
CoplayDev:betafrom
liuzqk:fix/mcp-session-notification-lifecycle
Open

Clean MCP sessions and deduplicate tool notifications#1286
liuzqk wants to merge 1 commit into
CoplayDev:betafrom
liuzqk:fix/mcp-session-notification-lifecycle

Conversation

@liuzqk

@liuzqk liuzqk commented Jul 28, 2026

Copy link
Copy Markdown

Description

Prevent stale MCP sessions and repeated unchanged tool registrations from causing tools/list_changed broadcast growth, while preserving retry delivery for transiently failed sessions.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Test update

Changes Made

  • Track FastMCP sessions symmetrically across __aenter__/__aexit__, including module reloads.
  • Notify sessions concurrently with a bounded timeout; prune only confirmed closed/broken transports.
  • Fingerprint the visible FastMCP 3.x list_tools() wire schema after publication.
  • Coalesce one publication transaction into one notification per active session.
  • Retain transiently failed sessions in a pending set and retry only those sessions when the same schema is registered again.
  • Retry pending delivery on both WebSocket and stdio paths even when the current visibility-sync attempt fails.

Compatibility / Package Source

  • Unity version(s) tested: N/A (Python server-only change)
  • Package source used (#beta, #main, tag, branch, or file:): branch derived directly from official beta (bd72241ab91c664176091789c9408d676783abec)
  • Resolved commit hash from Packages/packages-lock.json (if using a Git package URL): N/A

The implementation targets the declared fastmcp>=3.0.2,<4 dependency and uses its supported list_tools() API.

Testing/Screenshots/Recordings

  • Python tests (cd Server && uv run pytest tests/ -v)

  • Unity EditMode tests

  • Unity PlayMode tests

  • Package import/compile check

  • Not applicable (explain why in Additional Notes)

  • Full Python suite: 1342 passed, 3 skipped.

  • Targeted session/notification/reload/stdio suite: 31 passed.

  • Direct runtime smoke used the locked FastMCP 3.0.2 dependency.

Documentation Updates

  • I have added/removed/modified tools or resources
  • If yes, I have updated all documentation files using:
    • The LLM prompt at tools/UPDATE_DOCS_PROMPT.md (recommended)
    • Manual review of the generated changes

Related Issues

N/A.

Additional Notes

Twenty connect/disconnect cycles return to an empty active-session set. Reordered identical schemas are deduplicated; a real change notifies once, and transient failures retry without rebroadcasting to sessions that already succeeded. Targeted Ruff and git diff --check pass.

Summary by CodeRabbit

  • Bug Fixes

    • Reduced redundant tool-list update notifications by only notifying when the published tool set changes.
    • Improved robustness of tool-list notifications with per-session timeouts and safe handling of closed or failing sessions.
    • Aligned tool visibility sync behavior so overall syncing can succeed even when visibility update recording fails.
  • Stability

    • Strengthened MCP client session tracking across connect/disconnect cycles and ensured pending retries only target unconfirmed sessions.
  • Tests

    • Added unit coverage for session lifecycle and notification retry/timeout scenarios.
    • Added an integration test for stdio tool visibility sync when updates fail.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Tool visibility synchronization now records whether FastMCP’s published tool list changed before notifying MCP clients. Notifications use tracked sessions, bounded concurrent delivery, stale-session cleanup, and retry tracking. Tests cover lifecycle, fingerprinting, inspection, and notification behavior.

Tool publication synchronization

Layer / File(s) Summary
Publication fingerprinting and sync
Server/src/transport/plugin_hub.py, Server/src/services/tools/__init__.py
Visibility synchronization records stable published-tool fingerprints and routes changes through the combined recording and notification path.
Session tracking and notification delivery
Server/src/transport/plugin_hub.py
MCP sessions are tracked across middleware contexts, while notifications use concurrent delivery, timeouts, stale-session removal, and retries for transient failures.
Synchronization and lifecycle validation
Server/tests/test_plugin_hub_session_lifecycle.py, Server/tests/integration/test_stdio_custom_tool_sync.py
Tests cover session lifecycle, fingerprint stability, inspection failures, visibility-sync fallback, notification failures, timeouts, and retry behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Unity
  participant PluginHub
  participant FastMCP
  participant MCPClients
  Unity->>PluginHub: register_tools
  PluginHub->>FastMCP: sync visibility and inspect tools
  FastMCP-->>PluginHub: published tool snapshot
  PluginHub->>PluginHub: compare fingerprint
  PluginHub->>MCPClients: concurrently send tools/list_changed
Loading

Suggested labels: bug

Suggested reviewers: scriptwonder

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: MCP session cleanup and deduplicated tool notifications.
Description check ✅ Passed The description follows the template well and includes the required sections, change summary, compatibility, testing, and notes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@liuzqk
liuzqk force-pushed the fix/mcp-session-notification-lifecycle branch from 0217aa5 to 2ccdf40 Compare July 28, 2026 10:07
@liuzqk
liuzqk marked this pull request as ready for review July 28, 2026 10:19

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ccdf404e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Server/src/transport/plugin_hub.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 630-671: Update _record_published_tool_list to use only the
supported FastMCP list_tools() API and remove the get_tools() fallback and
related RuntimeError path. Preserve the existing published-tools fallback when
FastMCP is unavailable and continue fingerprinting the resulting list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ea81ae2-a647-4ace-8b23-186c590dc634

📥 Commits

Reviewing files that changed from the base of the PR and between bd72241 and 2ccdf40.

📒 Files selected for processing (3)
  • Server/src/services/tools/__init__.py
  • Server/src/transport/plugin_hub.py
  • Server/tests/test_plugin_hub_session_lifecycle.py

Comment thread Server/src/transport/plugin_hub.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
Server/src/transport/plugin_hub.py (1)

594-609: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Retry gate and log message can diverge from actual state.

Two related nits in this block:

  • Retrying _pending_tool_list_notifications (line 601) is gated on visibility_synced for this call. If _sync_server_tool_visibility throws on this invocation, previously-failed pending sessions won't be retried until the next successful sync, even though their retry is unrelated to the current sync outcome.
  • If published_tools_changed is True but there are currently no active MCP sessions, _pending_tool_list_notifications stays empty after the .update(), so execution falls into the elif visibility_synced: branch and logs "Published tool schema unchanged; skipping tools/list_changed" — which is misleading since the schema did change; there simply weren't any sessions to notify.

Neither is a functional defect (both self-heal / have no visible client impact), but decoupling the retry from visibility_synced and distinguishing the "changed but nobody to notify" case from "unchanged" would make behavior and logs more accurate for future debugging.

♻️ Possible refactor
-        published_tools_changed = (
-            visibility_synced
-            and await cls._record_published_tool_list(payload.tools)
-        )
-        if published_tools_changed:
-            cls._pending_tool_list_notifications.update(_active_mcp_sessions)
-
-        if visibility_synced and cls._pending_tool_list_notifications:
-            # Retry only sessions that have not yet observed the latest
-            # published schema, avoiding duplicate notifications to peers that
-            # already succeeded.
-            await cls._notify_mcp_tool_list_changed(
-                list(cls._pending_tool_list_notifications)
-            )
-        elif visibility_synced:
-            logger.debug("Published tool schema unchanged; skipping tools/list_changed")
+        published_tools_changed = (
+            visibility_synced
+            and await cls._record_published_tool_list(payload.tools)
+        )
+        if published_tools_changed:
+            cls._pending_tool_list_notifications.update(_active_mcp_sessions)
+
+        if cls._pending_tool_list_notifications:
+            # Retry sessions that have not yet observed the latest published
+            # schema, regardless of whether this particular sync succeeded.
+            await cls._notify_mcp_tool_list_changed(
+                list(cls._pending_tool_list_notifications)
+            )
+        elif visibility_synced:
+            logger.debug("Published tool schema unchanged; skipping tools/list_changed")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Server/src/transport/plugin_hub.py` around lines 594 - 609, Decouple the
retry in the published-tool notification flow from the current
`visibility_synced` result so `_pending_tool_list_notifications` is retried
whenever it is non-empty, including after a failed
`_sync_server_tool_visibility` attempt. Update the logging branches around
`published_tools_changed` and `_pending_tool_list_notifications` so a changed
schema with no active sessions is not logged as unchanged, while preserving the
existing unchanged-schema message only when `published_tools_changed` is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 611-639: The _tool_fingerprint method currently calls
tool.to_mcp_tool() without its required overrides argument, causing fingerprint
generation to fail for FastMCP tools. Update the conversion in _tool_fingerprint
to pass an empty overrides mapping, or retain the original tool when no
overrides are needed, while preserving the existing serialization and hashing
flow.

In `@Server/tests/test_plugin_hub_session_lifecycle.py`:
- Around line 104-125: Update
test_patched_session_context_is_tracked_symmetrically to install session
tracking once, preserve the existing MiddlewareServerSession WeakSet, then
emulate a module reload before exiting a session created before reload. Verify
_install_session_tracking reuses the preserved set and that exiting the
pre-reload session removes it from plugin_hub._active_mcp_sessions.
- Around line 128-139: Update test_unchanged_tools_do_not_rebroadcast to invoke
the real publication transaction or caller that combines
_record_published_tool_list with _notify_mcp_tool_list_changed twice using the
same tools. Assert the first run notifies each session once and the second run
leaves both notification counts unchanged, rather than calling
_notify_mcp_tool_list_changed separately before the unchanged result.

---

Nitpick comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 594-609: Decouple the retry in the published-tool notification
flow from the current `visibility_synced` result so
`_pending_tool_list_notifications` is retried whenever it is non-empty,
including after a failed `_sync_server_tool_visibility` attempt. Update the
logging branches around `published_tools_changed` and
`_pending_tool_list_notifications` so a changed schema with no active sessions
is not logged as unchanged, while preserving the existing unchanged-schema
message only when `published_tools_changed` is false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a05b9a4-d596-45ba-91a3-0479f0824baa

📥 Commits

Reviewing files that changed from the base of the PR and between 2ccdf40 and dcd568e.

📒 Files selected for processing (3)
  • Server/src/services/tools/__init__.py
  • Server/src/transport/plugin_hub.py
  • Server/tests/test_plugin_hub_session_lifecycle.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • Server/src/services/tools/init.py

Comment thread Server/src/transport/plugin_hub.py
Comment thread Server/tests/test_plugin_hub_session_lifecycle.py
Comment thread Server/tests/test_plugin_hub_session_lifecycle.py
@liuzqk
liuzqk force-pushed the fix/mcp-session-notification-lifecycle branch from dcd568e to a4d7002 Compare July 28, 2026 10:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Server/src/transport/plugin_hub.py (1)

667-682: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

else log claims "unchanged" even when the schema did change.

When changed is True but there are no active MCP sessions, the pending set stays empty and this falls into the else, logging that the schema was unchanged. Misleading while debugging notification delivery.

♻️ Distinguish "unchanged" from "no sessions"
         if cls._pending_tool_list_notifications:
             # Retry only sessions that have not yet observed the latest
             # published schema, avoiding duplicate notifications to peers that
             # already succeeded.
             await cls._notify_mcp_tool_list_changed(
                 list(cls._pending_tool_list_notifications)
             )
+        elif changed:
+            logger.debug(
+                "Published tool schema changed but no MCP sessions are active; "
+                "skipping tools/list_changed"
+            )
         else:
             logger.debug(
                 "Published tool schema unchanged; skipping tools/list_changed"
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Server/src/transport/plugin_hub.py` around lines 667 - 682, Update the
logging branch in the tool-schema publication flow around
_pending_tool_list_notifications so it distinguishes an unchanged schema from a
changed schema with no active MCP sessions. Preserve notification behavior, but
ensure the “unchanged” message is emitted only when changed is false and use an
appropriate log message for the no-session case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 667-682: Update the logging branch in the tool-schema publication
flow around _pending_tool_list_notifications so it distinguishes an unchanged
schema from a changed schema with no active MCP sessions. Preserve notification
behavior, but ensure the “unchanged” message is emitted only when changed is
false and use an appropriate log message for the no-session case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aea9f90e-2334-4d67-a136-469baeedcc10

📥 Commits

Reviewing files that changed from the base of the PR and between dcd568e and a4d7002.

📒 Files selected for processing (3)
  • Server/src/services/tools/__init__.py
  • Server/src/transport/plugin_hub.py
  • Server/tests/test_plugin_hub_session_lifecycle.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • Server/tests/test_plugin_hub_session_lifecycle.py

@liuzqk
liuzqk force-pushed the fix/mcp-session-notification-lifecycle branch from a4d7002 to 74d3218 Compare July 28, 2026 10:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Server/src/transport/plugin_hub.py (1)

651-670: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Fingerprint compare-and-set is unsynchronized across two concurrent call sites.

_record_and_notify_tool_list_change is reachable from both _handle_register_tools and services/tools/__init__.py::sync_tool_visibility_from_unity. The read of _published_tool_fingerprint (Line 651), the write (Line 654), and the pending-set seeding (Line 668) are not atomic, so overlapping publications can both observe "changed" and double-broadcast, or the loser can overwrite a newer digest and suppress a real notification.

Worst case today is a redundant tools/list_changed, but guarding the transaction with a dedicated asyncio.Lock would make it deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Server/src/transport/plugin_hub.py` around lines 651 - 670, Protect the
fingerprint compare-and-set and pending-session seeding in
_record_and_notify_tool_list_change with a dedicated asyncio.Lock shared by all
callers. Hold the lock across _record_published_tool_list and the conditional
_pending_tool_list_notifications update so concurrent publications are
serialized, while preserving the existing notify=False behavior and return
value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 651-670: Protect the fingerprint compare-and-set and
pending-session seeding in _record_and_notify_tool_list_change with a dedicated
asyncio.Lock shared by all callers. Hold the lock across
_record_published_tool_list and the conditional _pending_tool_list_notifications
update so concurrent publications are serialized, while preserving the existing
notify=False behavior and return value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 09205491-034e-4acc-86ed-455f7f51e73b

📥 Commits

Reviewing files that changed from the base of the PR and between a4d7002 and 74d3218.

📒 Files selected for processing (3)
  • Server/src/services/tools/__init__.py
  • Server/src/transport/plugin_hub.py
  • Server/tests/test_plugin_hub_session_lifecycle.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • Server/src/services/tools/init.py
  • Server/tests/test_plugin_hub_session_lifecycle.py

@liuzqk
liuzqk force-pushed the fix/mcp-session-notification-lifecycle branch from 74d3218 to 16c901a Compare July 28, 2026 11:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Server/tests/integration/test_stdio_custom_tool_sync.py (1)

134-156: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Docstring overstates what this test verifies.

_record_and_notify_tool_list_change is fully mocked, so no retry or pending-notification behavior is exercised — the test only asserts the call path is reached when visibility sync fails. Consider rewording the docstring (retry behavior is covered in Server/tests/test_plugin_hub_session_lifecycle.py).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Server/tests/integration/test_stdio_custom_tool_sync.py` around lines 134 -
156, The docstring for
test_sync_records_published_tools_when_visibility_update_fails overstates
coverage by claiming pending notifications retry. Reword it to describe only
that the tool-list change notification path is invoked when visibility
synchronization fails, leaving the test assertions and mocks unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Server/tests/integration/test_stdio_custom_tool_sync.py`:
- Around line 134-156: The docstring for
test_sync_records_published_tools_when_visibility_update_fails overstates
coverage by claiming pending notifications retry. Reword it to describe only
that the tool-list change notification path is invoked when visibility
synchronization fails, leaving the test assertions and mocks unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 861cfba9-5bac-492a-8a71-9de164985382

📥 Commits

Reviewing files that changed from the base of the PR and between 74d3218 and 16c901a.

📒 Files selected for processing (4)
  • Server/src/services/tools/__init__.py
  • Server/src/transport/plugin_hub.py
  • Server/tests/integration/test_stdio_custom_tool_sync.py
  • Server/tests/test_plugin_hub_session_lifecycle.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • Server/tests/test_plugin_hub_session_lifecycle.py

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant