fix(mcp): honor client UI extension over stateless HTTP - #1097
fix(mcp): honor client UI extension over stateless HTTP#1097Aaron ("AJ") Steers (aaronsteers) wants to merge 3 commits into
Conversation
…ensions Co-Authored-By: AJ Steers <aj@airbyte.io>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This PyAirbyte VersionYou can test this version of PyAirbyte using the following: # Run PyAirbyte CLI from this branch:
uvx --from 'git+https://github.com/airbytehq/PyAirbyte.git@devin/1785634148-mcp-ui-http-extensions' pyairbyte --help
# Install PyAirbyte from this branch for development:
pip install 'git+https://github.com/airbytehq/PyAirbyte.git@devin/1785634148-mcp-ui-http-extensions'PR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
📚 Show Repo GuidanceHelpful ResourcesCommunity SupportQuestions? Join the #pyairbyte channel in our Slack workspace. |
📝 WalkthroughWalkthroughThe MCP server now detects MCP Apps ChangesMCP Apps UI capability detection
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant MCPClient
participant StatelessHTTP
participant ToolFilter
MCPClient->>StatelessHTTP: Send X-MCP-Extensions header
StatelessHTTP->>ToolFilter: Provide request headers
ToolFilter->>ToolFilter: Parse declared extension IDs
ToolFilter-->>MCPClient: Return interactive-ui tools when supported
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
Code Coverage OverviewLanguages: Python Python / code-coverage/pytest-fastThe overall coverage in commit d7db9bd in the Show a code coverage summary of the most impacted files.
Python / code-coverage/pytest-no-credsThe overall coverage in commit d7db9bd in the Show a code coverage summary of the most impacted files.
Python / code-coverage/pytestThe overall coverage in commit d7db9bd in the Show a code coverage summary of the most impacted files.
Updated |
Runtime verificationVerified against real server processes — the actual
HTTP-with-header now matches stdio exactly, and the set diff vs. the no-header run adds only the three tools/call gatingWith the header: Without the header: The gate holds at call time, not just in listing. Header parser on the wire
Note: a whitespace-padded value like Regression: browser landing pageGET on the MCP path still serves the landing page; MCP POST traffic on the same path is unaffected. No failures. This is a protocol/CLI change with no GUI surface, so the evidence is verbatim client output rather than screenshots. |
Co-Authored-By: AJ Steers <aj@airbyte.io>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
airbyte/mcp/_tool_utils.py:546
_client_declared_extensions_from_headers()assumesget_http_headers()returns a dict keyed by the lowercased header name and does a direct.get(header_key). Elsewhere in this module (_resolve_transport_bearer_token) the code treats the returned header keys as potentially non-normalized and matches case-insensitively. To avoid coupling tool visibility to a specificget_http_headers()normalization behavior, make this lookup case-insensitive too (and keep theincludeoptimization).
def _client_declared_extensions_from_headers() -> set[str]:
"""Return comma- or whitespace-separated extension IDs from HTTP headers."""
header_key = MCP_EXTENSIONS_HEADER.lower()
header_value = get_http_headers(include={header_key}).get(header_key, "")
return set(header_value.replace(",", " ").split())
Co-Authored-By: AJ Steers <aj@airbyte.io>
Closes #1096.
Requested by AJ Steers.
Summary
MCP Apps
interactive-uitools (show_connectors_list,show_workspace_sync_status,show_connection_sync_history) were visible to a UI-capable client over stdio but never over streamable HTTP. Reproduced: stdio 44 tools, HTTP 39.Root cause: the hosted HTTP entrypoint runs
stateless_http=True, and mcp'sStreamableHTTPSessionManagerbuilds a freshServerSessionper request (mcp/server/streamable_http_manager.py, stateless branch). By the timetools/listis served,ServerSession._client_params is None, so FastMCP'sclient_supports_extension()— which only readsClientCapabilities.extensionscaptured atinitialize(fastmcp/server/low_level.py, unchanged through fastmcp 3.4.5) — returnsFalseandairbyte_ui_support_filterhides the tools. Nothing about the client is wrong; the capability simply doesn't survive to the request that needs it.The fix keeps the server stateless (statefulness would require LB session affinity and would only paper over the per-request gap) and instead lets an HTTP client re-declare its extensions per request:
X-MCP-Extensionsis a comma-separated list of extension IDs, e.g.X-MCP-Extensions: io.modelcontextprotocol/ui. The filter never fails open: with no session capabilities and no header, the tools stay hidden.Why a header, and why
X--prefixedThere is no standardized MCP header for client capabilities. The spec-aligned stateless mechanism is per-request
_metaunderio.modelcontextprotocol/clientCapabilities(MCP 2026-07-28 / SEP-2575;CLIENT_CAPABILITIES_META_KEYinmcp-types2.0.0). That path is not usable yet on our stack: withmcp1.25 /fastmcp3.2, request-params_metais not propagated into the serverRequestContextconsulted by tool filters (mcp/server/lowlevel/server.pybuilds the context from transportmessage.request_meta, notparams._meta), andget_context().request_context.metaisNoneduring a statelesstools/listeven when the client sends it. Rather than ship dead code, this PR documents that path as the future direction and uses the non-standardX--prefixed header as the interim escape hatch. When FastMCP surfaces per-request capabilities,_client_supports_ui()gains one more branch and the header can be retired.Upstream-fix framing for FastMCP, if we want to file it:
MiddlewareServerSession.client_supports_extension()should fall back to the current request's_meta[io.modelcontextprotocol/clientCapabilities]when_client_paramsisNone, which would make capability declaration work in stateless mode without any vendor-specific header.Changes
airbyte/mcp/_tool_utils.py:_client_supports_ui()falls back to_client_declared_extensions_from_headers(); generic parser returns the declared extension ID set (comma/whitespace tolerant, blank-safe, header name matched case-insensitively via FastMCP's lowercasedget_http_headers()).airbyte/constants.py:MCP_EXTENSIONS_HEADER = "X-MCP-Extensions".airbyte/mcp/__init__.pyandairbyte/mcp/http_main.pytell HTTP clients to send the header and explain the stateless capability loss.tests/unit_tests/test_mcp_http.py: new regression coverage.Test plan
tests/unit_tests/test_mcp_http.pyasserts, against a real in-process ASGI streamable-HTTP server plus a real stdio subprocess server:extensions: {"io.modelcontextprotocol/ui": {}}at initialize → all threeshow_tools visible; non-declaring client → hidden.X-MCP-Extensions: io.modelcontextprotocol/ui(sent mixed-case on the wire, asserted via an httpx event hook) → all three visible; no header or blank header → hidden.Local:
ruff format,ruff check,pyrefly checkclean; new tests 10 passed; fast suite 473 passed / 1 skipped.tests/integration_tests/test_install.py::test_install_failure_log_pypifails identically on a cleanorigin/mainworktree (pre-existing, unrelated).Link to Devin session: https://app.devin.ai/sessions/26b42c83920e467ea4a8242915c17dc7
Requested by: Aaron ("AJ") Steers (@aaronsteers)
Summary by CodeRabbit
New Features
interactive-uicapabilities through theX-MCP-Extensionsrequest header.Documentation
Tests