Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,15 @@ jobs:
strategy:
fail-fast: false
matrix:
# Mirrors pixi.toml's [environments]: py310-py313 only (no py314
# Mirrors pixi.toml's [environments]: py311-py313 only (no py314
# environment defined yet, unlike rucio-mcp). windows-latest is not
# tested -- untested here, not confirmed broken.
python-version: ["3.10", "3.11", "3.12", "3.13"]
# tested -- untested here, not confirmed broken. py310 dropped:
# af-credentials' published conda-forge build requires python>=3.11
# (maniaclab/af-credentials#9 / broker-mode support), so this
# project's floor moved to 3.11 in lockstep (pyproject.toml too).
python-version: ["3.11", "3.12", "3.13"]
runs-on: [ubuntu-latest, macos-14]
include:
- python-version: "3.10"
pixi-environment: py310
- python-version: "3.11"
pixi-environment: py311
- python-version: "3.12"
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ repos:
- pytest
- mcp==2.0.0
- httpx2
- af-credentials==0.3.1

- repo: https://github.com/codespell-project/codespell
rev: "57b21406f092110c18776e39b0bda50d37c945c8" # frozen: v2.4.3
Expand Down
1,827 changes: 135 additions & 1,692 deletions pixi.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ platforms = ["linux-64", "linux-aarch64", "osx-64", "osx-arm64"]
preview = ["pixi-build"]
exclude-newer = "7d"

[exclude-newer]
af-credentials = "0d"

[dependencies]
servicex-mcp = { path = "./" }

Expand All @@ -29,9 +32,7 @@ httpx2 = ">=2.0.0"
pydantic = ">=2.11"
starlette = ">=0.27"
uvicorn = ">=0.31.1"

[feature.py310.dependencies]
python = "<3.11,>=3.10"
af-credentials = ">=0.3.1"

[feature.py311.dependencies]
python = "<3.12,>=3.11"
Expand Down Expand Up @@ -99,7 +100,6 @@ build-check = { cmd = "twine check --strict ./dist/*", depends-on = ["build"], d
docs = ["docs"]
dev = ["dev"]
helm = { features = ["helm"], no-default-feature = true }
py310 = ["test", "py310"]
py311 = ["test", "py311"]
py312 = ["test", "py312"]
py313 = ["test", "py313"]
Expand Down
14 changes: 10 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ description = "MCP Server for ServiceX"
readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 1 - Planning",
"Intended Audience :: Science/Research",
Expand All @@ -20,7 +20,6 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -35,6 +34,13 @@ dependencies = [
"pydantic>=2.11",
"starlette>=0.27",
"uvicorn>=0.31.1",
# Broker-mode client factory (BrokerServiceXClientFactory) redeems a
# ServiceX access token from the AF MCP broker via ProxyClient's
# kind="servicex"/access_token() support, which first shipped in
# af-credentials v0.3.1 (maniaclab/af-credentials#9) -- 0.3.0 predates it.
# Requires python>=3.11 (af-credentials' published conda-forge build's
# floor), which is why this project's own floor is 3.11 now.
"af-credentials>=0.3.1",
]

[project.scripts]
Expand Down Expand Up @@ -92,7 +98,7 @@ report.exclude_also = [

[tool.mypy]
files = ["src", "tests"]
python_version = "3.10"
python_version = "3.11"
warn_unused_configs = true
strict = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
Expand Down Expand Up @@ -125,7 +131,7 @@ ignore = ["PLR09", "PLR2004"]
"tests/**" = ["T20"]

[tool.pylint]
py-version = "3.10"
py-version = "3.11"
ignore-paths = [".*/_version.py"]
reports.output-format = "colorized"
similarities.ignore-imports = "yes"
Expand Down
148 changes: 147 additions & 1 deletion src/servicex_mcp/auth/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import hashlib
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Protocol

from servicex.configuration import Configuration
from servicex.query_cache import QueryCache
Expand All @@ -16,6 +16,34 @@
from servicex_mcp.auth.session_cache import SessionCache


class _RedeemedAccessToken(Protocol):
"""Structural shape of a redeemed ServiceX access token.

A read-only property, not a plain attribute: a plain ``access_token:
str`` annotation would require write access too (PEP 544's default for
protocol attributes), which af-credentials' actual ``ServiceXAccessToken``
-- a frozen dataclass -- cannot satisfy.
"""

@property
def access_token(self) -> str: ...


class ServiceXRedeemer(Protocol):
"""Redeems a ServiceX access token from the AF MCP broker for a given bearer.

Matches ``af_credentials.proxy.ProxyClient``'s ``kind="servicex"``
support (maniaclab/af-credentials#9, shipped in af-credentials v0.3.1)
via structural typing rather than a hard import: ``ProxyClient(broker_url,
kind="servicex")`` satisfies this protocol directly, but the one concrete
construction site lives in ``server.py``, keeping this module's tests
independent of af-credentials being installed at all.
"""

async def access_token(self, bearer: str) -> _RedeemedAccessToken:
"""Redeem *bearer* (an AF Broker Identity Token) for a ServiceX access token."""


class ServiceXClientFactory(ABC):
"""Returns the ServiceXClient appropriate for the current request/session."""

Expand Down Expand Up @@ -78,6 +106,64 @@ def build_http_servicex_client(
return client


class _BrokerServiceXAdapter(ServiceXAdapter): # type: ignore[misc]
"""A ServiceXAdapter that redeems its access token from the AF MCP broker instead of exchanging a ServiceX refresh token via ServiceX's own /token/refresh.

``# type: ignore[misc]`` above: ``servicex`` ships no type stubs
(``ignore_missing_imports`` for ``servicex.*`` in ``pyproject.toml``),
so mypy sees ``ServiceXAdapter`` as ``Any`` and flags subclassing it —
a real constraint of this codebase's typing setup, not specific to this
class.

Only ``_get_token`` is overridden — ``_get_authorization``'s existing
JWT-expiry-aware fast path (serve ``self.token`` while its own ``exp``
claim leaves more than 60s, else re-authenticate) is untouched, so this
adapter re-redeems exactly as often as the stock refresh-token path
would re-call ``/token/refresh``: lazily, on first real use and
whenever the cached access token is about to expire.

``bearer`` (the caller's AF Broker Identity Token) is stored in the
base class's ``refresh_token`` slot. That is deliberate, not an
oversight: ``_get_authorization``'s ``if not bearer_token and not
self.refresh_token: return {}`` guard needs *something* truthy there to
ever attempt authentication at all, and this is the only value this
adapter has to offer it. The base class's own ``_get_token`` (which
would POST that value to ServiceX's ``/token/refresh``) is never
reached — it is fully replaced below.
"""

def __init__(self, url: str, *, redeemer: ServiceXRedeemer, bearer: str) -> None:
"""Construct against *url*, redeeming *bearer* via *redeemer* on demand."""
super().__init__(url, refresh_token=bearer)
self._redeemer = redeemer

async def _get_token(self) -> None:
"""Redeem the stored bearer via the broker and store the resulting access token."""
token = await self._redeemer.access_token(self.refresh_token)
self.token = token.access_token


def build_broker_servicex_client(
*, url: str, redeemer: ServiceXRedeemer, bearer: str, cache_dir: str
) -> ServiceXClient:
"""Build a ServiceXClient whose adapter redeems its access token from the AF MCP broker.

Mirrors ``build_http_servicex_client``'s ``object.__new__`` construction
exactly, substituting ``_BrokerServiceXAdapter`` for the plain
``ServiceXAdapter`` — see that function's docstring for why this
bypasses ``ServiceXClient.__init__`` entirely, and re-check both against
``ServiceXClient.__init__`` on any ``servicex`` version bump.
"""
client = object.__new__(ServiceXClient)
config = Configuration(api_endpoints=[], cache_path=cache_dir)
client.config = config
client.endpoints = {}
client.servicex = _BrokerServiceXAdapter(url, redeemer=redeemer, bearer=bearer)
client.query_cache = QueryCache(config)
client._code_generators = None # pylint: disable=protected-access
return client


def _extract_request_auth(ctx: Any) -> tuple[str, str]:
"""Extract (session_id, bearer_token) from the request."""
req = ctx.request_context.request
Expand Down Expand Up @@ -142,3 +228,63 @@ def get_client(self, ctx: Any) -> ServiceXClient:
def close(self) -> None:
"""Evict all cached clients."""
self._cache.close()


class BrokerServiceXClientFactory(ServiceXClientFactory):
"""Broker-mode HTTP factory: builds and caches one broker-backed ServiceXClient per MCP session.

The client bearer this factory extracts is an AF Broker Identity Token
(forwarded by the af-mcp-broker aggregator, which already authenticated
the caller), not a ServiceX personal refresh token — the actual
ServiceX access token is redeemed from the broker's own
``POST /v1/credentials/servicex/redeem`` (maniaclab/af-mcp-platform#295)
via *redeemer*, lazily, the first time the returned client makes a real
ServiceX call (see ``_BrokerServiceXAdapter``). *redeemer* is injected
rather than constructed here so this class stays independent of
af-credentials' concrete ``ProxyClient``, whose ``kind="servicex"``
support is still landing (maniaclab/af-credentials#9) — the caller
(``server.py``) wires the concrete redeemer.
"""

def __init__(
self,
*,
cache: SessionCache,
redeemer: ServiceXRedeemer,
backend_url: str,
cache_dir: str,
) -> None:
"""Store the session cache, redeemer, backend URL, and download cache directory."""
self._cache = cache
self._redeemer = redeemer
self._backend_url = backend_url
self._cache_dir = cache_dir

def get_client(self, ctx: Any) -> ServiceXClient:
"""Return a cached or newly built ServiceXClient for this session.

Mirrors ``BearerTokenClientFactory.get_client`` exactly (same
cache-key binding, same fixed 300s session TTL) — no redeem call
happens here. The difference is entirely inside the adapter:
``_BrokerServiceXAdapter`` redeems from the broker on first real use
and whenever its cached access token nears its own expiry, rather
than exchanging a refresh token with ServiceX directly.
"""
session_id, bearer = _extract_request_auth(ctx)
cache_key = _cache_key(session_id, bearer) if session_id else None
cached = self._cache.get(cache_key) if cache_key else None
if cached is not None:
return cached
client = build_broker_servicex_client(
url=self._backend_url,
redeemer=self._redeemer,
bearer=bearer,
cache_dir=self._cache_dir,
)
if cache_key:
self._cache.put(cache_key, client, time.time() + 300)
return client

def close(self) -> None:
"""Evict all cached clients."""
self._cache.close()
12 changes: 12 additions & 0 deletions src/servicex_mcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ def main() -> None:
metavar="URL",
help="Public URL of this MCP server (required for --transport http).",
)
serve_parser.add_argument(
"--broker-url",
default=None,
metavar="URL",
help="Base URL of an AF MCP broker (maniaclab/af-mcp-platform). When "
"set, --transport http runs in broker mode: clients authenticate "
"with an AF Broker Identity Token (not a ServiceX personal refresh "
"token), redeemed for a ServiceX access token via the broker's "
"POST /v1/credentials/servicex/redeem. Requires "
"maniaclab/af-mcp-platform's ServiceXTokenProvider (issue #295).",
)
serve_parser.add_argument("--host", default="127.0.0.1")
serve_parser.add_argument("--port", type=int, default=8000)
serve_parser.add_argument(
Expand Down Expand Up @@ -92,6 +103,7 @@ def main() -> None:
serve_http(
backend_url=args.backend_url,
resource_url=args.resource_url,
broker_url=args.broker_url,
host=args.host,
port=args.port,
read_only=args.read_only,
Expand Down
Loading
Loading