From f81d6c07d8b4039c9b05bb3be914c373278875e5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:30:52 -0400 Subject: [PATCH] Load task settings from .env; gate root publish on fastmcp-tasks; fix worker command DocketSettings now loads the same dotenv source as core settings, so a FASTMCP_DOCKET_* value in .env configures the backend instead of silently using memory://. The root fastmcp publish waits for the matching fastmcp-tasks to appear on PyPI before uploading, so the [tasks] extra is never installable but unresolvable. And the example README uses the real worker entry point (python -m fastmcp_tasks.worker_cli worker). --- .github/workflows/publish-fastmcp.yml | 52 +++++++++++++++++++++++++ examples/tasks/README.md | 4 +- fastmcp_tasks/fastmcp_tasks/settings.py | 7 ++++ tests/tasks/server/test_task_config.py | 16 ++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml index 3e22fe915..52b319d5c 100644 --- a/.github/workflows/publish-fastmcp.yml +++ b/.github/workflows/publish-fastmcp.yml @@ -115,6 +115,58 @@ jobs: echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2 exit 1 + - name: Verify matching fastmcp-tasks is published + run: | + TASKS_VERSION=$(python - <<'PY' + import email.parser + import re + import zipfile + from pathlib import Path + + wheel = next(Path("dist").glob("fastmcp-*.whl")) + metadata_name = next( + name for name in zipfile.ZipFile(wheel).namelist() + if name.endswith(".dist-info/METADATA") + ) + metadata = email.parser.Parser().parsestr( + zipfile.ZipFile(wheel).read(metadata_name).decode() + ) + # fastmcp-tasks is pinned via the optional `tasks` extra, so its + # Requires-Dist entry carries an `extra == "tasks"` marker — unlike the + # base slim dependency, do not skip marked entries here. + for value in metadata.get_all("Requires-Dist", []): + requirement, _, _marker = value.partition(";") + match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip()) + if match: + print(match.group(1)) + break + else: + raise RuntimeError("Could not find the fastmcp-tasks extra dependency") + PY + ) + + for attempt in {1..12}; do + if python - "$TASKS_VERSION" <<'PY' + import json + import sys + import urllib.request + + version = sys.argv[1] + url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json" + with urllib.request.urlopen(url, timeout=30) as response: + json.load(response) + PY + then + exit 0 + fi + + echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)." + sleep 10 + done + + echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2 + exit 1 + - name: Publish fastmcp to PyPI run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl diff --git a/examples/tasks/README.md b/examples/tasks/README.md index 81f9285f3..7a711f61f 100644 --- a/examples/tasks/README.md +++ b/examples/tasks/README.md @@ -59,8 +59,8 @@ cd examples/tasks docker compose up -d export FASTMCP_DOCKET_URL=redis://localhost:24242/0 # or: direnv allow -python server.py # in one terminal -fastmcp tasks worker server.py # extra worker(s) in others +python server.py # in one terminal +python -m fastmcp_tasks.worker_cli worker server.py # extra worker(s) in others ``` | Backend | Workers | diff --git a/fastmcp_tasks/fastmcp_tasks/settings.py b/fastmcp_tasks/fastmcp_tasks/settings.py index 3b22d10cc..294f0d280 100644 --- a/fastmcp_tasks/fastmcp_tasks/settings.py +++ b/fastmcp_tasks/fastmcp_tasks/settings.py @@ -9,18 +9,25 @@ constructor overrides the env defaults). from __future__ import annotations import inspect +import os from datetime import timedelta from typing import Annotated from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +# Load the same dotenv source as core FastMCP settings, so a deployment that +# puts FASTMCP_DOCKET_* in `.env` (or a FASTMCP_ENV_FILE) configures the backend +# rather than silently falling back to memory://. +_ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") + class DocketSettings(BaseSettings): """Docket worker configuration.""" model_config = SettingsConfigDict( env_prefix="FASTMCP_DOCKET_", + env_file=_ENV_FILE, extra="ignore", ) diff --git a/tests/tasks/server/test_task_config.py b/tests/tasks/server/test_task_config.py index bfd2533a8..42c5e7e98 100644 --- a/tests/tasks/server/test_task_config.py +++ b/tests/tasks/server/test_task_config.py @@ -38,6 +38,22 @@ async def _opted_in_call(server: FastMCP, name: str, arguments: dict | None = No return await server.call_tool(name, arguments or {}) +def test_docket_settings_load_from_dotenv(tmp_path, monkeypatch): + """`FASTMCP_DOCKET_*` in a `.env` file configures the backend. + + A distributed deployment that puts its Redis URL in `.env` must not silently + fall back to `memory://` — DocketSettings loads the same dotenv source as + core FastMCP settings. + """ + from fastmcp_tasks.settings import DocketSettings + + (tmp_path / ".env").write_text("FASTMCP_DOCKET_URL=redis://dotenv-host:6379/2\n") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("FASTMCP_DOCKET_URL", raising=False) + + assert DocketSettings().url == "redis://dotenv-host:6379/2" + + async def test_interceptor_tasks_the_requested_version_not_the_highest(): """A versioned tools/call tasks the version the caller asked for.