From b694d46ec19c3028a47f5e7142dbfdf68ea90002 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:00:54 +0000 Subject: [PATCH 1/2] feat(mcp): accept separate Client-Id/Client-Secret headers via fastmcp-extensions 0.15.0 Co-Authored-By: AJ Steers --- airbyte/mcp/__init__.py | 12 +++++++++--- airbyte/mcp/_client_credentials.py | 12 +++++++----- pyproject.toml | 6 ++++-- uv.lock | 8 ++++---- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 584344625..ba111d0cb 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -198,18 +198,24 @@ } ``` -### Opt-in: static client credentials via HTTP Basic +### Opt-in: static client credentials The headless bearer path assumes the client can re-mint a token every ~15 min. An agent that can only set a **static** `Authorization` value (no refresh hook) can't do that. For this case the server can optionally accept the long-lived -`client_id` / `client_secret` directly on the inbound request, via standard HTTP -Basic (the same credential encoding OAuth's `client_secret_basic` uses): +`client_id` / `client_secret` directly on the inbound request, in either form — +standard HTTP Basic (the same credential encoding OAuth's `client_secret_basic` +uses), or separate flat headers for clients that can't Base64-encode the pair: ```http Authorization: Basic base64(client_id:client_secret) ``` +```http +Client-Id: +Client-Secret: +``` + When `AIRBYTE_MCP_AUTH_ALLOW_CLIENT_CREDENTIALS` is truthy, the server runs a client-credentials exchange server-side (posting the credentials to the Airbyte token endpoint) for a short-lived token, caches it until shortly before expiry, diff --git a/airbyte/mcp/_client_credentials.py b/airbyte/mcp/_client_credentials.py index 460be9fe0..2af2db2c8 100644 --- a/airbyte/mcp/_client_credentials.py +++ b/airbyte/mcp/_client_credentials.py @@ -1,5 +1,5 @@ # Copyright (c) 2025 Airbyte, Inc., all rights reserved. -"""Opt-in HTTP Basic client-credentials transport auth for the MCP server. +"""Opt-in client-credentials transport auth for the MCP server. The headless bearer path verifies an already-minted, short-lived (~15 min) JWT. That works for MCP clients that run the OAuth flow and refresh tokens @@ -8,9 +8,11 @@ This module bridges that gap, behind an opt-in flag. When enabled, the server accepts the long-lived `client_id` / `client_secret` presented on the inbound MCP -request via standard HTTP Basic auth +request either via standard HTTP Basic auth (`Authorization: Basic base64(client_id:client_secret)`, the same credential -encoding OAuth's `client_secret_basic` uses). The server then runs a +encoding OAuth's `client_secret_basic` uses) or via separate flat `Client-Id` / +`Client-Secret` request headers for clients that can't Base64-encode the pair. +The server then runs a client-credentials exchange against the Airbyte token endpoint to obtain a short-lived access token and rewrites the request to `Authorization: Bearer ` so the existing `JWTVerifier` validates it unchanged. The agent thus @@ -51,7 +53,7 @@ def client_credentials_enabled(env: Mapping[str, str] | None = None) -> bool: - """Return whether the opt-in HTTP Basic client-credentials grant is enabled.""" + """Return whether the opt-in client-credentials grant is enabled.""" source = env if env is not None else os.environ return source.get(ALLOW_CLIENT_CREDENTIALS_ENV, "").strip().lower() in _TRUTHY @@ -72,7 +74,7 @@ def wrap_if_enabled(app: ASGIApp) -> ASGIApp: Returns `app` unchanged when the opt-in flag is unset, so the standard bearer/OIDC transport auth is the only path. When enabled, wraps `app` as the outermost ASGI layer (via `fastmcp_extensions.wrap_client_credentials`) so the - Basic-to-Bearer rewrite happens before FastMCP's auth verifier runs. + credentials-to-Bearer rewrite happens before FastMCP's auth verifier runs. """ return wrap_client_credentials( app, diff --git a/pyproject.toml b/pyproject.toml index 53ed5c2dd..10e53931f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,10 @@ dependencies = [ "uuid7>=0.1.0,<1.0", "fastmcp>=3.0,<4.0", # 0.14.0 drops the library's env-var auth mapping (`resolve_mcp_auth`) in - # favor of the pure typed `build_mcp_auth` API this server now consumes. - "fastmcp-extensions>=0.14.0,<1.0.0", + # favor of the pure typed `build_mcp_auth` API this server now consumes; 0.15.0 + # adds the separate `Client-Id` / `Client-Secret` credential-header form to the + # client-credentials exchange middleware (`wrap_client_credentials`). + "fastmcp-extensions>=0.15.0,<1.0.0", # Transitive dep of `fastmcp` that we import directly (in `airbyte.mcp`). "starlette", "uv>=0.5.0,<0.9.0", diff --git a/uv.lock b/uv.lock index a6b90404f..0bccd48c2 100644 --- a/uv.lock +++ b/uv.lock @@ -202,7 +202,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=3.0,<4.0" }, - { name = "fastmcp-extensions", specifier = ">=0.14.0,<1.0.0" }, + { name = "fastmcp-extensions", specifier = ">=0.15.0,<1.0.0" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1131,7 +1131,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1143,9 +1143,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/de/f148a60bae3f7f0f31ff57f821d253e690a0e53fc4caad9f52aa01c8699c/fastmcp_extensions-0.14.0.tar.gz", hash = "sha256:4773b0d92168d6f64604dd66977010e17aef09e8b8440cf2a18df576d0a02a60", size = 215412, upload-time = "2026-07-24T00:50:16.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/f8/9ed34ec9bc78fb5e8b70189947f3452b6743920e008bb9b457bd3490440c/fastmcp_extensions-0.15.0.tar.gz", hash = "sha256:d70ec3b063ebc90689c26b6b4c419813810c8693b041621697dff2dd13df6111", size = 218120, upload-time = "2026-07-24T05:57:42.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/35/b2bdc80cc2e3ae26549dfd8ab404440ce287d08b5b157d1da96e66cf06da/fastmcp_extensions-0.14.0-py3-none-any.whl", hash = "sha256:b709062b3eb764b20a40b803c1eccd55d386367933390f196fb6541b0ec6f57d", size = 76398, upload-time = "2026-07-24T00:50:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2c/cb520d3a25c013704e03ce6fa734194deea4bfae410a1f099fdad0016594/fastmcp_extensions-0.15.0-py3-none-any.whl", hash = "sha256:3aab06883bc0ed7b5c1d056484228c555023825c9c4a619f9bfdc1742b3a2692", size = 78161, upload-time = "2026-07-24T05:57:41.23Z" }, ] [[package]] From e91e7d907993ba14da5ea041c561f81a5d757759 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:08:19 +0000 Subject: [PATCH 2/2] docs(mcp): note both credential forms on ALLOW_CLIENT_CREDENTIALS bullet Co-Authored-By: AJ Steers --- airbyte/mcp/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index ba111d0cb..d6d88d3e1 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -249,7 +249,8 @@ algorithm; default to Airbyte Cloud (`.../realms/_airbyte-application-clients`, `account`, `RS256`). - `AIRBYTE_MCP_AUTH_ALLOW_CLIENT_CREDENTIALS` — opt-in flag (off by default) that - enables the inbound HTTP Basic client-credentials path described above. + enables the inbound static client-credentials path described above (Basic or + the `Client-Id`/`Client-Secret` headers). - `AIRBYTE_MCP_AUTH_CLIENT_CREDENTIALS_TOKEN_URL` — token endpoint used for that exchange; defaults to Airbyte Cloud's `https://api.airbyte.com/v1/applications/token`.