fix: stabilize cli and mcp dependency compatibility
This commit is contained in:
@@ -13,6 +13,8 @@ For a presentation-oriented summary of the current product path and demo flow,
|
|||||||
see [`workflow platform presentation`](add/2026-06-workflow-platform-presentation.md).
|
see [`workflow platform presentation`](add/2026-06-workflow-platform-presentation.md).
|
||||||
For running and auditing external-agent workflow challenges, see
|
For running and auditing external-agent workflow challenges, see
|
||||||
[`agent challenge evaluation`](runbooks/agent-challenge-evaluation.md).
|
[`agent challenge evaluation`](runbooks/agent-challenge-evaluation.md).
|
||||||
|
For verified Python 3.14 dependency constraints and their removal criteria, see
|
||||||
|
[`dependency compatibility`](runbooks/dependency-compatibility.md).
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Dependency Compatibility
|
||||||
|
|
||||||
|
This project targets Python 3.14. The constraints below document verified
|
||||||
|
dependency regressions rather than general version preferences.
|
||||||
|
|
||||||
|
## AnyIO 4.14.x And FastMCP Teardown
|
||||||
|
|
||||||
|
The project currently pins `anyio<4.14`. With Python 3.14 on Windows,
|
||||||
|
FastMCP 3.4.2 client teardown fails under AnyIO 4.14.0 and 4.14.1 with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
|
||||||
|
```
|
||||||
|
|
||||||
|
The same focused tests pass with AnyIO 4.12.0 and 4.13.0. The full project
|
||||||
|
suite passes with AnyIO 4.13.0.
|
||||||
|
|
||||||
|
Reproduce the failing version with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with anyio==4.14.1 pytest tests/wf_mcp/server/test_tools.py::test_server_search_mode_pins_stable_control_and_workflow_tools -q -n0
|
||||||
|
```
|
||||||
|
|
||||||
|
AnyIO issue [#1179](https://github.com/agronholm/anyio/issues/1179) describes a
|
||||||
|
related 4.14.0 pytest-runner regression and was closed by
|
||||||
|
[#1180](https://github.com/agronholm/anyio/pull/1180). The FastMCP 3.4.2
|
||||||
|
teardown case above still reproduces with AnyIO 4.14.1, so that AnyIO release
|
||||||
|
alone is not sufficient for this project.
|
||||||
|
|
||||||
|
FastMCP PR [#4363](https://github.com/PrefectHQ/fastmcp/pull/4363) shields
|
||||||
|
stateful proxy disconnect during session teardown. The focused test fails at
|
||||||
|
the parent commit `5fa4f32c` and passes at the merged fix commit `ddcdf648` with
|
||||||
|
AnyIO 4.14.1. Current FastMCP `main` at `de521e65` also passes.
|
||||||
|
|
||||||
|
Remove the pin after a FastMCP release includes PR #4363, the focused test
|
||||||
|
passes with AnyIO 4.14.1 or newer, and the full suite remains clean.
|
||||||
|
|
||||||
|
### Verified Matrix
|
||||||
|
|
||||||
|
| FastMCP | AnyIO | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 3.4.2 | 4.13.0 | Pass |
|
||||||
|
| 3.4.2 | 4.14.0 | Fail |
|
||||||
|
| 3.4.2 | 4.14.1 | Fail |
|
||||||
|
| `5fa4f32c` before PR #4363 | 4.14.1 | Fail |
|
||||||
|
| `ddcdf648` from PR #4363 | 4.14.1 | Pass |
|
||||||
|
| `main` at `de521e65` | 4.14.1 | Pass |
|
||||||
|
|
||||||
|
## Typer Command Callback Errors
|
||||||
|
|
||||||
|
With Typer 0.26.8 and Click 8.4.2, a nested Typer command that raises
|
||||||
|
`click.ClickException` exits with code 1 under `typer.testing.CliRunner`, but
|
||||||
|
both captured output streams are empty. The unformatted `ClickException`
|
||||||
|
remains on `result.exception`. Single-command applications behave the same way,
|
||||||
|
and direct invocation renders a traceback instead of a concise error.
|
||||||
|
|
||||||
|
The CLI therefore writes its concise remote-operation error explicitly to
|
||||||
|
stderr and raises `typer.Exit(1)` instead of relying on standalone Click
|
||||||
|
exception formatting. This keeps real CLI and test-runner behavior aligned.
|
||||||
|
|
||||||
|
Minimal reproducer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import click
|
||||||
|
import typer
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def fail() -> None:
|
||||||
|
raise click.ClickException("broken")
|
||||||
|
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr == "" # Expected to contain "Error: broken".
|
||||||
|
assert isinstance(result.exception, click.ClickException)
|
||||||
|
```
|
||||||
|
|
||||||
|
This regression is tracked as Typer issue
|
||||||
|
[#1867](https://github.com/fastapi/typer/issues/1867). The repository copy of
|
||||||
|
the verified issue report is
|
||||||
|
[`2026-06-29-typer-click-exception-regression.md`](../superpowers/research/2026-06-29-typer-click-exception-regression.md).
|
||||||
|
Typer 0.24.2 and 0.25.0 format the exception normally. Vendoring Click in Typer
|
||||||
|
0.26.0 introduced the exception-class identity mismatch; Typer 0.26.8 and
|
||||||
|
current `master` at `b210c0e2` reproduce the failure.
|
||||||
|
|
||||||
|
## Separate Warning Backlog
|
||||||
|
|
||||||
|
The full suite currently emits Python 3.14 deprecation warnings from
|
||||||
|
`fastapi-jsonrpc` use of `asyncio.iscoroutinefunction`. Those warnings are
|
||||||
|
separate from both regressions above and are not addressed by these workarounds.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
### Description
|
||||||
|
|
||||||
|
After Typer vendored Click in 0.26.0, exceptions imported from the external
|
||||||
|
`click` package are no longer handled as normal Typer CLI errors.
|
||||||
|
|
||||||
|
A real invocation renders a Rich traceback ending in
|
||||||
|
`ClickException: broken`. Under `typer.testing.CliRunner`, both stdout and
|
||||||
|
stderr are empty and the raw `click.ClickException` remains in
|
||||||
|
`result.exception`.
|
||||||
|
|
||||||
|
Typer 0.25.0 renders the expected concise error panel and returns `SystemExit`.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
Typer commit `1829d73` vendored Click as `typer._click`. Typer's `_main()`
|
||||||
|
catches `typer._click.exceptions.ClickException`, but an application that
|
||||||
|
raises `click.exceptions.ClickException` raises a different class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import click
|
||||||
|
import typer._click
|
||||||
|
|
||||||
|
assert click.ClickException is not typer._click.ClickException
|
||||||
|
```
|
||||||
|
|
||||||
|
The exception therefore bypasses Typer's Click-exception handler. This affects
|
||||||
|
the external Click variants of `ClickException`, `UsageError`, `BadParameter`,
|
||||||
|
and other subclasses.
|
||||||
|
|
||||||
|
Typer publicly re-exports some vendored exception types, including
|
||||||
|
`typer.BadParameter` and `typer.Exit`, but it does not currently expose a public
|
||||||
|
`typer.ClickException` or `typer.UsageError` equivalent.
|
||||||
|
|
||||||
|
### Minimal Reproduction
|
||||||
|
|
||||||
|
```python
|
||||||
|
import click
|
||||||
|
import typer
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def fail() -> None:
|
||||||
|
raise click.ClickException("broken")
|
||||||
|
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app)
|
||||||
|
print("exit:", result.exit_code)
|
||||||
|
print("stdout:", repr(result.stdout))
|
||||||
|
print("stderr:", repr(result.stderr))
|
||||||
|
print("exception:", type(result.exception).__name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same behavior occurs in nested Typer command groups.
|
||||||
|
|
||||||
|
### Version Matrix
|
||||||
|
|
||||||
|
Tested with Click 8.4.2 unless noted otherwise:
|
||||||
|
|
||||||
|
| Typer | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| 0.24.2 | Concise error output; `SystemExit` |
|
||||||
|
| 0.25.0 | Concise error output; `SystemExit` |
|
||||||
|
| 0.26.0 | Empty runner output; raw `ClickException` |
|
||||||
|
| 0.26.8 with Click 8.1.7 | Empty runner output; raw `ClickException` |
|
||||||
|
| 0.26.8 with Click 8.4.2 | Empty runner output; raw `ClickException` |
|
||||||
|
| `master` at `b210c0e2` | Empty runner output; raw `ClickException` |
|
||||||
|
|
||||||
|
Test environment:
|
||||||
|
|
||||||
|
- Python 3.14.3
|
||||||
|
- Windows 11
|
||||||
|
- Click 8.1.7 and 8.4.2
|
||||||
|
|
||||||
|
### Expected Behavior
|
||||||
|
|
||||||
|
Typer should provide a public way to raise its concise general-purpose CLI
|
||||||
|
exception after vendoring Click. Possible resolutions include:
|
||||||
|
|
||||||
|
- re-exporting the vendored `ClickException` and `UsageError` classes from the
|
||||||
|
`typer` namespace;
|
||||||
|
- preserving compatibility with exceptions imported from external Click; or
|
||||||
|
- documenting that external Click exceptions are no longer compatible and
|
||||||
|
identifying their supported Typer replacements.
|
||||||
|
|
||||||
|
### Workaround
|
||||||
|
|
||||||
|
Applications can write the message explicitly and raise `typer.Exit(1)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
typer.echo("Error: something went wrong", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Importing from `typer._click` also works, but relies on a private API.
|
||||||
@@ -6,6 +6,9 @@ readme = "readme.md"
|
|||||||
authors = [{ name = "lda", email = "[email protected]" }]
|
authors = [{ name = "lda", email = "[email protected]" }]
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
# AnyIO 4.14.x exposes a FastMCP 3.4.2 stateful-proxy teardown bug.
|
||||||
|
# Remove after a FastMCP release includes PR #4363; see docs/runbooks/dependency-compatibility.md.
|
||||||
|
"anyio<4.14",
|
||||||
"authlib>=1.7.0",
|
"authlib>=1.7.0",
|
||||||
"fastapi-jsonrpc>=3.5.0",
|
"fastapi-jsonrpc>=3.5.0",
|
||||||
"fastmcp>=3.2.4",
|
"fastmcp>=3.2.4",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Repeat `--input` and `--bind-output` once per mapping. Do not put multiple
|
|||||||
mappings after one flag.
|
mappings after one flag.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wf draft add-step <workspace_id> --revision <n> --step render --capability local.report.render_markdown_report --input state.title=title --input state.summary=summary --bind-output markdown=state.markdown --bind-output title=state.title
|
wf draft add-step <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --input state.title=title --input state.summary=summary --bind-output markdown=state.markdown --bind-output title=state.title
|
||||||
```
|
```
|
||||||
|
|
||||||
`wf draft compile` prints the raw plan JSON directly on success. Do not expect a
|
`wf draft compile` prints the raw plan JSON directly on success. Do not expect a
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ deployment binding:
|
|||||||
wf deploy save <deployment_id> \
|
wf deploy save <deployment_id> \
|
||||||
--artifact <artifact_id> \
|
--artifact <artifact_id> \
|
||||||
--version 1 \
|
--version 1 \
|
||||||
--binding local.report=local.report
|
--binding <logical_source>=<concrete_source>
|
||||||
```
|
```
|
||||||
|
|
||||||
If no suggestion is present, do not guess an account-like source binding; inspect
|
If no suggestion is present, do not guess an account-like source binding; inspect
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import asyncio
|
|||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
import click
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import typer
|
||||||
|
|
||||||
from wf_cli.context import CliContext
|
from wf_cli.context import CliContext
|
||||||
|
|
||||||
@@ -25,7 +25,10 @@ def run_cli_operation(context: CliContext, operation: Coroutine[Any, Any, T]) ->
|
|||||||
except (RuntimeError, httpx.HTTPError) as exc:
|
except (RuntimeError, httpx.HTTPError) as exc:
|
||||||
if context.verbose:
|
if context.verbose:
|
||||||
raise
|
raise
|
||||||
raise click.ClickException(_operation_error_message(exc)) from exc
|
# Typer 0.26 vendors Click, so external ClickException classes bypass
|
||||||
|
# its formatter. Keep this boundary entirely on Typer's public API.
|
||||||
|
typer.echo(f"Error: {_operation_error_message(exc)}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
|
||||||
def _operation_error_message(exc: RuntimeError | httpx.HTTPError) -> str:
|
def _operation_error_message(exc: RuntimeError | httpx.HTTPError) -> str:
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.14.1"
|
version = "4.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -632,6 +632,7 @@ name = "lda-wf"
|
|||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
{ name = "fastapi-jsonrpc" },
|
{ name = "fastapi-jsonrpc" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
@@ -657,6 +658,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "anyio", specifier = "<4.14" },
|
||||||
{ name = "authlib", specifier = ">=1.7.0" },
|
{ name = "authlib", specifier = ">=1.7.0" },
|
||||||
{ name = "fastapi-jsonrpc", specifier = ">=3.5.0" },
|
{ name = "fastapi-jsonrpc", specifier = ">=3.5.0" },
|
||||||
{ name = "fastmcp", specifier = ">=3.2.4" },
|
{ name = "fastmcp", specifier = ">=3.2.4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user