mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
Compare commits
329 commits
published-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab6442aeb |
||
|
|
258554d93c |
||
|
|
49ff7f56cd |
||
|
|
743ddffd0a |
||
|
|
75de8b352e |
||
|
|
609f79b8a1 |
||
|
|
92465c7f1f |
||
|
|
bfcdfa59ec |
||
|
|
b2025212d8 |
||
|
|
0d86007617 |
||
|
|
c9cf23779e |
||
|
|
542cde677b |
||
|
|
3a2d12abcf |
||
|
|
37321449d9 |
||
|
|
59487837ee |
||
|
|
46399056db |
||
|
|
addb8fa541 |
||
|
|
bab1073da2 |
||
|
|
6ccbb570cc |
||
|
|
38c054be1d |
||
|
|
ba283ddb4c |
||
|
|
fe93371d04 |
||
|
|
3dd0886156 |
||
|
|
822c82c93f |
||
|
|
19d9360cc1 |
||
|
|
2061bc46c7 |
||
|
|
e108bc539e |
||
|
|
6be0ac8e15 |
||
|
|
052889a359 |
||
|
|
bba8c44f7b |
||
|
|
6475650fc7 |
||
|
|
8a1820f1c3 |
||
|
|
06fee6d300 |
||
|
|
706f7d2695 |
||
|
|
1ac8fc6060 |
||
|
|
04f9971120 |
||
|
|
803da5319c |
||
|
|
9feb1f378b |
||
|
|
75fb116e36 |
||
|
|
6fb34e9383 |
||
|
|
c8b88b3a37 |
||
|
|
2bee9aeb58 |
||
|
|
875e8e18bd |
||
|
|
959daf2321 |
||
|
|
8661193411 |
||
|
|
e4d8ca648a |
||
|
|
2c2f98691f |
||
|
|
b9b7ea6914 |
||
|
|
4f28dceac8 |
||
|
|
db92d44ef5 |
||
|
|
886776f5fc |
||
|
|
d267792653 |
||
|
|
a7e9b70919 |
||
|
|
022547ad8c |
||
|
|
34bdd480c9 |
||
|
|
c428a08fea |
||
|
|
10b158baf3 |
||
|
|
9034a2eb4b |
||
|
|
40c3e122e8 |
||
|
|
bc07264529 |
||
|
|
bcef61d806 |
||
|
|
0f18a258d4 |
||
|
|
44c0907dda |
||
|
|
07f6eafd99 |
||
|
|
a22f778dbf |
||
|
|
0792ac812c |
||
|
|
7339936980 |
||
|
|
7a77805159 |
||
|
|
baced6281c |
||
|
|
5a98ceb5ca |
||
|
|
8b76710e66 |
||
|
|
81b1e818e5 |
||
|
|
a8b5da9770 |
||
|
|
0175bc9235 |
||
|
|
d6b9daecb1 |
||
|
|
78c61415b5 |
||
|
|
90ea26f337 |
||
|
|
f4ae8bb0af |
||
|
|
d382943012 |
||
|
|
7674645761 |
||
|
|
75b9f92504 |
||
|
|
c3cbe8b9a3 |
||
|
|
ffea4d6a3e |
||
|
|
794bfe9567 |
||
|
|
11ee46bf3b |
||
|
|
cc02df94c5 |
||
|
|
ea7fb8cb2e |
||
|
|
1550eea886 |
||
|
|
e4ccf06baf |
||
|
|
886c85e5f5 |
||
|
|
95edc1d2f3 |
||
|
|
4e136e60d6 |
||
|
|
27a5921bff |
||
|
|
920cb47778 |
||
|
|
0c1c42f151 |
||
|
|
6c4ba6b420 |
||
|
|
a42faab783 |
||
|
|
b1e0586d4e |
||
|
|
9ea5a40728 |
||
|
|
b2b2b0f918 |
||
|
|
a2bec08e76 |
||
|
|
62afdca775 |
||
|
|
c4dcf833ca |
||
|
|
0172e4c4d4 |
||
|
|
aaaca09a91 |
||
|
|
0e9cab86fd |
||
|
|
4a616d6e39 |
||
|
|
7fe3c1e8bd | ||
|
|
d5ff831602 |
||
|
|
cf7edc895c |
||
|
|
46c3b74346 |
||
|
|
98ac0402df |
||
|
|
dec25ba6be |
||
|
|
fecced2b5c |
||
|
|
5745323ecd |
||
|
|
46cd0c7933 |
||
|
|
f7eed91aa8 |
||
|
|
8768921fd9 |
||
|
|
2d3ad9ca0d |
||
|
|
4ef7d16419 |
||
|
|
7ca58583fd |
||
|
|
96e12569b1 |
||
|
|
a7248480e2 |
||
|
|
18aa6a09d6 |
||
|
|
66d842d6e9 |
||
|
|
681d5a7120 |
||
|
|
c4c72ac240 |
||
|
|
b9fcef1889 |
||
|
|
7699deb99c |
||
|
|
1593257f2a |
||
|
|
90f2e190d0 |
||
|
|
2a93404e8c |
||
|
|
1a43a3b8e9 |
||
|
|
fca339084b |
||
|
|
704b74b3ab |
||
|
|
e056a3946e |
||
|
|
b07f9ce9ae |
||
|
|
e4a87f2afe |
||
|
|
4ebb3fd5e6 |
||
|
|
c4cb1910a3 |
||
|
|
bc770c6fc0 |
||
|
|
ac83711ff8 |
||
|
|
4b09a040be |
||
|
|
387a063aec |
||
|
|
caadfe6413 |
||
|
|
2f992f71ea |
||
|
|
37fb0ad803 |
||
|
|
cb5f6abdd0 |
||
|
|
cf021d1a70 |
||
|
|
f896f5acb5 |
||
|
|
078c44d835 |
||
|
|
39148870af |
||
|
|
601903436b |
||
|
|
79ba8f180d |
||
|
|
76c6f1a64e |
||
|
|
856844cae7 |
||
|
|
cc3d1c18a3 |
||
|
|
d8ac6cbfde |
||
|
|
c556f07a66 |
||
|
|
1c57079b9b |
||
|
|
3f746b91fc |
||
|
|
cb4419ed02 |
||
|
|
8363ec4d26 |
||
|
|
53741dc9c7 |
||
|
|
95f766cb74 |
||
|
|
f81d6c07d8 |
||
|
|
c3ad5e9ecb |
||
|
|
a194acdc5f |
||
|
|
fbee629ed9 |
||
|
|
9019a7af70 |
||
|
|
f627170088 |
||
|
|
733801ed6c |
||
|
|
99327084d2 |
||
|
|
06aa84943c |
||
|
|
1d442ffa36 |
||
|
|
110943fc61 |
||
|
|
1c7ade215b |
||
|
|
edb54bddf3 |
||
|
|
b75dde3b5c |
||
|
|
19c5c507cc |
||
|
|
e5ca0269cb |
||
|
|
bb3ef39a89 |
||
|
|
74e01d5e08 |
||
|
|
8efa405833 |
||
|
|
d41ff5bcd8 |
||
|
|
ef29b731ea |
||
|
|
b0d3e653b9 |
||
|
|
bc22e517fd |
||
|
|
5fa2883670 |
||
|
|
6fce4e538f |
||
|
|
d756b99bf6 |
||
|
|
242850c0f3 |
||
|
|
094738f68a |
||
|
|
4402b48954 |
||
|
|
44d6d739ea |
||
|
|
36caaa6f56 |
||
|
|
30044c7864 |
||
|
|
d0f1468fce |
||
|
|
74b8f1bc1c |
||
|
|
16a09f0151 |
||
|
|
f038cf3be7 |
||
|
|
7417e974f4 |
||
|
|
611a35861d |
||
|
|
10af989563 |
||
|
|
7814d95990 |
||
|
|
e32a2098f9 |
||
|
|
07fa270652 |
||
|
|
2db431f5e5 |
||
|
|
18b898e42a |
||
|
|
9d9bf2b717 |
||
|
|
4ec2757b6d |
||
|
|
d927762003 |
||
|
|
2bcfae3412 |
||
|
|
afbe342587 |
||
|
|
e01c5932bf |
||
|
|
5f428aaced |
||
|
|
57bbc9859e |
||
|
|
52b37b9a0c |
||
|
|
d048c7e690 |
||
|
|
de14ed1b7c |
||
|
|
f2ccb6a32c |
||
|
|
bcf90d5ae7 |
||
|
|
ae43039d8e |
||
|
|
bf352d8a4a |
||
|
|
1e1882851d |
||
|
|
35c2b52652 |
||
|
|
0ba3db1a56 |
||
|
|
1a788cf349 |
||
|
|
effbc568ff |
||
|
|
dd803a0d7f |
||
|
|
c8b8911226 |
||
|
|
c33a3c3b29 |
||
|
|
b0e782a2ee |
||
|
|
7934124fb5 |
||
|
|
d2ac7ed3d2 |
||
|
|
33ed688a8f |
||
|
|
2676864163 |
||
|
|
c6e31a3be6 |
||
|
|
a3163bc275 |
||
|
|
3213776b25 |
||
|
|
cef327d0f2 |
||
|
|
1e529ee27d |
||
|
|
b9b1deacb6 |
||
|
|
eee5e91334 |
||
|
|
717f3535f6 |
||
|
|
0781e723c2 |
||
|
|
87fa361239 |
||
|
|
3041aa241e |
||
|
|
149a7aa2ce |
||
|
|
d3b7922615 |
||
|
|
67e8448389 |
||
|
|
252a29e5e6 |
||
|
|
2899ffb6f3 |
||
|
|
cee327d8f7 |
||
|
|
81fada4922 |
||
|
|
a57f1c8b20 |
||
|
|
654335607c |
||
|
|
62d32c6a90 |
||
|
|
2ebf19e5e1 |
||
|
|
83702a41f8 |
||
|
|
243f054f65 |
||
|
|
998b37f32b |
||
|
|
f018f68bbf |
||
|
|
7d76c9d055 |
||
|
|
3fdeedb567 |
||
|
|
c241cd4698 |
||
|
|
7e077186fc |
||
|
|
16383a64d6 |
||
|
|
00cab8ba8f |
||
|
|
d7eda92a2b |
||
|
|
981a69d839 |
||
|
|
18b5ab5852 |
||
|
|
66c0270bc1 |
||
|
|
bdb76ef4b2 |
||
|
|
a3ecd1edb1 |
||
|
|
d779414f8a |
||
|
|
918b85f9b2 |
||
|
|
ff2fc234b2 |
||
|
|
b623c23183 |
||
|
|
977f02347b |
||
|
|
6202008cf3 |
||
|
|
1fca15abe6 |
||
|
|
291fab8789 |
||
|
|
a04f6fd911 |
||
|
|
266c129b62 |
||
|
|
836ceac30e |
||
|
|
1d932cc778 |
||
|
|
9f251bad00 |
||
|
|
4ad78a60ef |
||
|
|
00a7745994 |
||
|
|
fd5d98bd13 |
||
|
|
8ba5b89918 |
||
|
|
3c43038860 |
||
|
|
515a2244a2 |
||
|
|
f30f847e1f |
||
|
|
f0e350942f |
||
|
|
7832f884c6 |
||
|
|
3cb34034d2 |
||
|
|
bf3a079f87 |
||
|
|
4a8852af10 |
||
|
|
7c2133a52f |
||
|
|
805ce96689 |
||
|
|
45475bd072 |
||
|
|
da5754671b |
||
|
|
7f032bb82e |
||
|
|
396fb876d3 |
||
|
|
508a08475f |
||
|
|
6a6fdcb2bb |
||
|
|
14373cc60b |
||
|
|
e5d9d1f17d |
||
|
|
be8aee3a96 |
||
|
|
ad02710e9b |
||
|
|
36cbef6813 |
||
|
|
d74f7b5996 |
||
|
|
d1c51ea4db |
||
|
|
9d95e34e19 |
||
|
|
497ed2ea00 |
||
|
|
e89763d709 |
||
|
|
6621024ce4 |
||
|
|
ac78e6f693 |
||
|
|
023a578279 |
||
|
|
13b9ab80ab |
||
|
|
77131edc00 |
||
|
|
05be554e85 |
||
|
|
b023aae51a |
||
|
|
389e3b78e7 |
||
|
|
17954f569d |
||
|
|
1966e619f7 |
||
|
|
3522a98766 |
51 changed files with 2243 additions and 204 deletions
95
.claude/skills/review-security-report/SKILL.md
Normal file
95
.claude/skills/review-security-report/SKILL.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
name: review-security-report
|
||||
description: Review FastMCP vulnerability reports before accepting, rejecting, patching, scoring, or publishing them. Use for security advisories, bug-bounty submissions, OAuth or MCP vulnerability claims, and proposed security fixes.
|
||||
---
|
||||
|
||||
# Review a FastMCP security report
|
||||
|
||||
A working proof of concept establishes behavior, not ownership or classification. Identify the
|
||||
component that violates a promised security boundary before changing code or advisory state.
|
||||
|
||||
## Preserve the evidence
|
||||
|
||||
Before editing an advisory, export the report, comments, proof of concept, configuration, and
|
||||
claimed affected versions. Record the reproduced commit and derive affected releases from history.
|
||||
|
||||
Keep the investigation read-only until classification. Do not mutate the advisory or prepare a fix
|
||||
merely because the proof of concept works.
|
||||
|
||||
## Reproduce the claim
|
||||
|
||||
Use the smallest end-to-end reproduction against a supported release. Record:
|
||||
|
||||
- defaults, non-default arguments, environment, and deployment assumptions;
|
||||
- what the attacker controls, what the victim does, and what authority the attacker gains;
|
||||
- the check or trust boundary allegedly bypassed; and
|
||||
- whether the attacker's prerequisites already provide equal or greater authority than the claimed
|
||||
impact.
|
||||
|
||||
A non-default configuration may still be vulnerable; a default may intentionally delegate a
|
||||
security decision elsewhere.
|
||||
|
||||
## Identify the responsible layer
|
||||
|
||||
- **FastMCP vulnerability:** FastMCP violates a promised boundary in a supported configuration.
|
||||
- **FastMCP bug:** FastMCP behaves incorrectly without creating attacker capability.
|
||||
- **Upstream vulnerability:** The defect belongs to a standard, dependency, identity provider,
|
||||
client, proxy, or platform.
|
||||
- **Insecure deployment:** The operator omits, disables, or delegates a required control without
|
||||
supplying the replacement required by its contract.
|
||||
- **Expected behavior:** The result follows the documented API contract or protocol.
|
||||
- **Documentation gap:** The implementation follows its intended contract, but the guidance creates
|
||||
a reasonable expectation of protection.
|
||||
|
||||
Attribute the violated boundary to its owner. Neither a dangerous configuration nor an available
|
||||
external mitigation decides ownership by itself. Distinguish failure of a primary control from
|
||||
failure of defense-in-depth.
|
||||
|
||||
## Establish FastMCP's contract
|
||||
|
||||
Read the code, released documentation, tests, and history. Determine:
|
||||
|
||||
1. What FastMCP promises for the exact configuration, supported API, and affected releases.
|
||||
2. Whether the proof of concept bypasses, disables, or delegates that protection.
|
||||
3. Whether the behavior follows the implemented protocol despite any stricter FastMCP promise.
|
||||
|
||||
If the intended contract remains unclear, ask the subsystem maintainer before accepting the report
|
||||
or proposing a fix. Current code alone does not define supported product behavior.
|
||||
|
||||
## Separate OAuth proxy boundaries
|
||||
|
||||
For OAuth proxy reports, check each layer independently:
|
||||
|
||||
- Open DCR lets unknown clients register redirects; a matching attacker callback is not a redirect
|
||||
validation bypass.
|
||||
- FastMCP consent binds the downstream client; ordinary upstream consent binds FastMCP's shared app.
|
||||
- `require_authorization_consent=False` removes consent; `"external"` delegates equivalent consent
|
||||
and transaction binding outside FastMCP.
|
||||
- PKCE protects the verifier, not a code issued for a malicious client's own challenge.
|
||||
- Redirect allowlists are optional policy and can break hosted clients or open DCR.
|
||||
|
||||
## Review the proposed fix independently
|
||||
|
||||
A patch can block the proof of concept and still be wrong. Verify that it:
|
||||
|
||||
- fixes the violated boundary at its source and enforces the claimed property;
|
||||
- does not silently change an intentional default or opt-in contract; and
|
||||
- preserves supported workflows without assuming one replacement control is the only valid design.
|
||||
|
||||
Treat a change to the supported product contract as an enhancement requiring maintainer agreement,
|
||||
independently of the security report.
|
||||
|
||||
## Classification checkpoint
|
||||
|
||||
Before any GitHub mutation, state the decisive configuration and affected versions, boundary owner,
|
||||
realistic impact, and one disposition:
|
||||
|
||||
- accept a draft advisory and prepare a private patch;
|
||||
- request specific missing evidence;
|
||||
- route as a normal FastMCP bug or upstream issue;
|
||||
- close as expected behavior or an insecure deployment;
|
||||
- ask the relevant maintainer to decide the contract.
|
||||
|
||||
Separately recommend any documentation or warning clarification warranted by the investigation.
|
||||
|
||||
When rejecting a report, acknowledge any valid reproduction and explain the decisive precondition.
|
||||
2
.github/actions/setup-uv/action.yml
vendored
2
.github/actions/setup-uv/action.yml
vendored
|
|
@ -15,7 +15,7 @@ runs:
|
|||
using: "composite"
|
||||
steps:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
|
|
|||
2
.github/workflows/auto-close-duplicates.yml
vendored
2
.github/workflows/auto-close-duplicates.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Auto-close duplicate issues
|
||||
run: uv run scripts/auto_close_duplicates.py
|
||||
|
|
|
|||
2
.github/workflows/auto-close-needs-mre.yml
vendored
2
.github/workflows/auto-close-needs-mre.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Auto-close needs MRE issues
|
||||
run: uv run scripts/auto_close_needs_mre.py
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install UV
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
|
|
|||
2
.github/workflows/marvin-comment-on-pr.yml
vendored
2
.github/workflows/marvin-comment-on-pr.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Install UV
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
|
|
|||
12
.github/workflows/marvin-dedupe-issues.yml
vendored
12
.github/workflows/marvin-dedupe-issues.yml
vendored
|
|
@ -36,6 +36,12 @@ jobs:
|
|||
with:
|
||||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
# Match the job's `permissions:` block above. Unscoped, the token
|
||||
# inherits the App installation's full set — which includes
|
||||
# contents: write and actions: write, neither of which this job
|
||||
# declares and both of which end up in the model's shell as GH_TOKEN.
|
||||
permission-contents: read
|
||||
permission-issues: write
|
||||
|
||||
- name: Set dedupe prompt
|
||||
id: dedupe-prompt
|
||||
|
|
@ -114,8 +120,12 @@ jobs:
|
|||
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
|
||||
allowed_non_write_users: "*"
|
||||
# No `Bash(gh api:*)`: it reaches every endpoint the token can, which
|
||||
# is the reason marvin-label-triage routes its one write through
|
||||
# .github/scripts/triage-label.sh instead. Dedupe searches, reads and
|
||||
# comments — the four verbs below cover that.
|
||||
claude_args: |
|
||||
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task
|
||||
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-sonnet-5",
|
||||
|
|
|
|||
9
.github/workflows/marvin-label-triage.yml
vendored
9
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -56,7 +56,14 @@ jobs:
|
|||
with:
|
||||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
owner: PrefectHQ
|
||||
# No `owner:` — with it set and `repositories:` empty the token is
|
||||
# scoped to every repo in the PrefectHQ installation. Triage only
|
||||
# ever touches this one. The permissions below match the job's
|
||||
# `permissions:` block; unscoped the token would also carry
|
||||
# contents: write and actions: write from the App installation.
|
||||
permission-contents: read
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Set triage prompt
|
||||
id: triage-prompt
|
||||
|
|
|
|||
2
.github/workflows/marvin-test-failure.yml
vendored
2
.github/workflows/marvin-test-failure.yml
vendored
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
|
||||
# Install UV package manager
|
||||
- name: Install UV
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-remote.yml
vendored
2
.github/workflows/publish-fastmcp-remote.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Build fastmcp-remote
|
||||
run: uv build --package fastmcp-remote
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-slim.yml
vendored
2
.github/workflows/publish-fastmcp-slim.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Build fastmcp-slim
|
||||
run: uv build --package fastmcp-slim
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp-tasks.yml
vendored
2
.github/workflows/publish-fastmcp-tasks.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Build fastmcp-tasks
|
||||
if: steps.package_present.outputs.present == 'true'
|
||||
|
|
|
|||
2
.github/workflows/publish-fastmcp.yml
vendored
2
.github/workflows/publish-fastmcp.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Build fastmcp
|
||||
run: uv build --package fastmcp
|
||||
|
|
|
|||
2
.github/workflows/run-schema-crash-test.yml
vendored
2
.github/workflows/run-schema-crash-test.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
|
|
|||
2
.github/workflows/run-static.yml
vendored
2
.github/workflows/run-static.yml
vendored
|
|
@ -37,6 +37,6 @@ jobs:
|
|||
resolution: locked
|
||||
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@v2
|
||||
uses: j178/prek-action@v3.0.0
|
||||
env:
|
||||
SKIP: no-commit-to-branch
|
||||
|
|
|
|||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
|
|
@ -90,7 +90,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- name: Run conformance tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
|
|
|
|||
2
.github/workflows/run-upgrade-checks.yml
vendored
2
.github/workflows/run-upgrade-checks.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
resolution: upgrade
|
||||
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@v2
|
||||
uses: j178/prek-action@v3.0.0
|
||||
env:
|
||||
SKIP: no-commit-to-branch
|
||||
|
||||
|
|
|
|||
2
.github/workflows/update-config-schema.yml
vendored
2
.github/workflows/update-config-schema.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
|
|
|||
2
.github/workflows/update-sdk-docs.yml
vendored
2
.github/workflows/update-sdk-docs.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ fastmcp --help
|
|||
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
|
||||
| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
|
||||
| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
|
||||
| `login` | Sign in to Prefect Horizon with a browser device flow |
|
||||
| `whoami` | Show the current Horizon account |
|
||||
| `logout` | Revoke the current Horizon key and remove the local credential |
|
||||
| `version` | Print version info (`--copy` to copy to clipboard) |
|
||||
|
||||
## Server Targets
|
||||
|
|
@ -81,6 +84,38 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
|
|||
|
||||
## Authentication
|
||||
|
||||
### Prefect Horizon Account
|
||||
|
||||
Use the top-level account commands to manage the credential for Prefect Horizon.
|
||||
|
||||
```bash
|
||||
fastmcp login
|
||||
fastmcp whoami
|
||||
fastmcp logout
|
||||
```
|
||||
|
||||
`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
|
||||
When login needs a new key, it shows a verification URL and code.
|
||||
It opens a browser when the terminal supports it.
|
||||
If the browser does not open, use the shown URL and code on another device.
|
||||
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
|
||||
|
||||
Login stores only the personal Horizon API key.
|
||||
It does not select or store a deployment organization.
|
||||
`fastmcp whoami` gets the current user from Horizon.
|
||||
`fastmcp logout` attempts to revoke the stored key and always removes its local credential.
|
||||
|
||||
Set `HORIZON_API_KEY` to use an environment credential instead.
|
||||
The CLI gives that value first precedence and never stores it.
|
||||
When this variable controls the session, logout does not revoke or remove any credential.
|
||||
Remove the variable from your environment to sign out.
|
||||
|
||||
Use `--json` for stable command results.
|
||||
During JSON login, the verification challenge goes to stderr and the final result goes to stdout.
|
||||
JSON mode does not open a browser or ask a question.
|
||||
|
||||
### MCP Server Authentication
|
||||
|
||||
When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
|
||||
|
||||
To skip authentication entirely — useful for local development servers — pass `--auth none`:
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ async with Client("https://example.com/mcp", mode="auto") as client:
|
|||
<Note>
|
||||
`mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`.
|
||||
|
||||
The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) is likewise legacy-only, because it mounts each backend behind a legacy-era proxy; a single-server config mirrors its one backend transport's era.
|
||||
The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) negotiates the best era shared by every connected backend: it stays modern when every backend is modern-capable and reconnects every leg under the handshake era when any backend requires legacy. Pinning `mode="legacy"` or a modern version applies that mode to every backend. All-modern configurations follow normal modern semantics; applications that rely on handshake-era server-initiated sampling or elicitation should pin `mode="legacy"`.
|
||||
</Note>
|
||||
|
||||
## Response caching
|
||||
|
|
|
|||
|
|
@ -13,6 +13,39 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste
|
|||
Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users.
|
||||
</Info>
|
||||
|
||||
## FastMCP CLI Account
|
||||
|
||||
Sign in to Horizon from the FastMCP CLI with the device authorization flow.
|
||||
|
||||
```bash
|
||||
fastmcp login
|
||||
```
|
||||
|
||||
The command uses an environment key or a valid stored key when one is available.
|
||||
When login needs a new key, it shows a verification URL and code before it opens the browser.
|
||||
If the browser cannot open, visit the shown URL and enter the code.
|
||||
To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
|
||||
New users can register and create their first Horizon organization in the browser.
|
||||
|
||||
Check the active account after login.
|
||||
|
||||
```bash
|
||||
fastmcp whoami
|
||||
```
|
||||
|
||||
Remove the local credential and revoke the active personal API key when possible.
|
||||
|
||||
```bash
|
||||
fastmcp logout
|
||||
```
|
||||
|
||||
Login does not select or store a deployment organization.
|
||||
|
||||
For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key.
|
||||
The CLI never writes the environment value to its credential file.
|
||||
When this variable controls the session, logout does not revoke or remove any credential.
|
||||
Remove the variable from the environment to sign out.
|
||||
|
||||
## The Platform
|
||||
|
||||
Horizon is organized into four integrated pillars:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ One change is silent, so go looking for it: an `except httpx.ConnectError:` arou
|
|||
|
||||
`fastmcp.Client` defaults to `mode="auto"` as of FastMCP 4, so it negotiates the newest era both sides speak rather than pinning the handshake. Over streamable HTTP or stdio to a FastMCP server that means the sessionless `2026-07-28` protocol, where FastMCP 3 connected at `2025-11-25`.
|
||||
|
||||
Two transports are exceptions: SSE predates the sessionless era and cannot carry it, and a multi-server `MCPConfigTransport` mounts each backend behind a legacy-era composite. Under `mode="auto"` the client recognizes both and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. Pinning a modern version explicitly on either skips that substitution and asks the transport for something it cannot serve, so leave them on auto or legacy.
|
||||
SSE is the transport exception: it predates the sessionless era and cannot carry it. Under `mode="auto"` the client recognizes SSE and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. A multi-server `MCPConfigTransport` resolves one era for the entire aggregate: all-modern backends negotiate `2026-07-28`, while any legacy backend moves the composite and every backend leg to the handshake era. Pinning a mode applies it to every backend in the configuration. All-modern configurations follow normal modern semantics; applications that rely on handshake-era server-initiated sampling or elicitation should pin `mode="legacy"`.
|
||||
|
||||
The client probes `server/discover` and adopts the modern protocol when the server answers, falling back to the `initialize` handshake for anything that is not positive evidence of a modern peer — so a mixed fleet of servers still connects. Pin the old behavior per client with `Client(url, mode="legacy")`. See [Protocol negotiation](/clients/client#protocol-negotiation).
|
||||
|
||||
|
|
|
|||
|
|
@ -193,16 +193,16 @@ Extract headers from the current HTTP request if available.
|
|||
Never raises an exception, even if there is no active HTTP request (in which case
|
||||
an empty dict is returned).
|
||||
|
||||
By default, strips problematic headers like `content-length` and `authorization`
|
||||
that cause issues if forwarded to downstream services. If `include_all` is True,
|
||||
all headers are returned.
|
||||
By default, strips problematic headers like `content-length`, and credential
|
||||
headers like `authorization` and `cookie`, that cause issues if forwarded to
|
||||
downstream services. If `include_all` is True, all headers are returned.
|
||||
|
||||
The `include` parameter allows specific headers to be included even if they would
|
||||
normally be excluded. This is useful for proxy transports that need to forward
|
||||
authorization headers to upstream MCP servers.
|
||||
|
||||
|
||||
### `get_access_token` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L605" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `get_access_token` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L606" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
get_access_token() -> AccessToken | None
|
||||
|
|
@ -220,7 +220,7 @@ request is available.
|
|||
- The access token if an authenticated user is available, None otherwise.
|
||||
|
||||
|
||||
### `without_injected_parameters` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L664" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `without_injected_parameters` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L665" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]
|
||||
|
|
@ -249,7 +249,7 @@ thread-affinity libraries (e.g. Windows COM). Ignored for async fns.
|
|||
- Async wrapper function without injected parameters
|
||||
|
||||
|
||||
### `resolve_dependencies` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L828" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `resolve_dependencies` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L829" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]
|
||||
|
|
@ -278,7 +278,7 @@ time, so all injection goes through the unified DI system.
|
|||
which will be filtered out)
|
||||
|
||||
|
||||
### `CurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L956" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `CurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L957" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
CurrentContext() -> Context
|
||||
|
|
@ -297,7 +297,7 @@ current MCP operation (tool/resource/prompt call).
|
|||
- `RuntimeError`: If no active context found (during resolution)
|
||||
|
||||
|
||||
### `OptionalCurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L981" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `OptionalCurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L982" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
OptionalCurrentContext() -> Context | None
|
||||
|
|
@ -307,7 +307,7 @@ OptionalCurrentContext() -> Context | None
|
|||
Get the current FastMCP Context, or None when no context is active.
|
||||
|
||||
|
||||
### `CurrentFastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1001" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `CurrentFastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1002" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
CurrentFastMCP() -> FastMCP
|
||||
|
|
@ -325,7 +325,7 @@ This dependency provides access to the active FastMCP server.
|
|||
- `RuntimeError`: If no server in context (during resolution)
|
||||
|
||||
|
||||
### `CurrentRequest` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1041" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `CurrentRequest` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1042" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
CurrentRequest() -> Request
|
||||
|
|
@ -345,7 +345,7 @@ current HTTP request. Only available when running over HTTP transports
|
|||
- `RuntimeError`: If no HTTP request in context (e.g., STDIO transport)
|
||||
|
||||
|
||||
### `CurrentHeaders` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1082" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `CurrentHeaders` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1086" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
CurrentHeaders() -> dict[str, str]
|
||||
|
|
@ -355,15 +355,15 @@ CurrentHeaders() -> dict[str, str]
|
|||
Get the current HTTP request headers.
|
||||
|
||||
This dependency provides access to the HTTP headers for the current request,
|
||||
including the authorization header. Returns an empty dictionary when no HTTP
|
||||
request is available, making it safe to use in code that might run over any
|
||||
transport.
|
||||
including the `authorization` and `cookie` headers, which `get_http_headers()`
|
||||
withholds by default. Returns an empty dictionary when no HTTP request is
|
||||
available, making it safe to use in code that might run over any transport.
|
||||
|
||||
**Returns:**
|
||||
- A dependency that resolves to a dictionary of header name -> value
|
||||
|
||||
|
||||
### `CurrentAccessToken` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1300" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `CurrentAccessToken` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1304" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
CurrentAccessToken() -> AccessToken
|
||||
|
|
@ -382,7 +382,7 @@ authenticated request. Raises an error if no authentication is present.
|
|||
- `RuntimeError`: If no authenticated user (use get_access_token() for optional)
|
||||
|
||||
|
||||
### `TokenClaim` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1357" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `TokenClaim` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1361" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
TokenClaim(name: str) -> str
|
||||
|
|
@ -425,7 +425,7 @@ distributed-trace parent. Those live in the raw params dict under ``_meta``,
|
|||
which this wrapper lifts once so downstream consumers have a stable surface.
|
||||
|
||||
|
||||
### `ProgressLike` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `ProgressLike` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1114" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
|
||||
Protocol for progress tracking interface.
|
||||
|
|
@ -436,7 +436,7 @@ and Docket's Progress (worker context).
|
|||
|
||||
**Methods:**
|
||||
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1122" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
current(self) -> int | None
|
||||
|
|
@ -445,7 +445,7 @@ current(self) -> int | None
|
|||
Current progress value.
|
||||
|
||||
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1123" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1127" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
total(self) -> int
|
||||
|
|
@ -454,7 +454,7 @@ total(self) -> int
|
|||
Total/target progress value.
|
||||
|
||||
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
message(self) -> str | None
|
||||
|
|
@ -463,7 +463,7 @@ message(self) -> str | None
|
|||
Current progress message.
|
||||
|
||||
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_total(self, total: int) -> None
|
||||
|
|
@ -472,7 +472,7 @@ set_total(self, total: int) -> None
|
|||
Set the total/target value for progress tracking.
|
||||
|
||||
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
increment(self, amount: int = 1) -> None
|
||||
|
|
@ -481,7 +481,7 @@ increment(self, amount: int = 1) -> None
|
|||
Atomically increment the current progress value.
|
||||
|
||||
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1144" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_message(self, message: str | None) -> None
|
||||
|
|
@ -490,7 +490,7 @@ set_message(self, message: str | None) -> None
|
|||
Update the progress status message.
|
||||
|
||||
|
||||
### `InMemoryProgress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1145" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `InMemoryProgress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
|
||||
In-memory progress tracker for immediate tool execution.
|
||||
|
|
@ -502,25 +502,25 @@ progress doesn't need to be observable across processes.
|
|||
|
||||
**Methods:**
|
||||
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1174" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
current(self) -> int | None
|
||||
```
|
||||
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1174" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1178" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
total(self) -> int
|
||||
```
|
||||
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1178" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1182" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
message(self) -> str | None
|
||||
```
|
||||
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_total(self, total: int) -> None
|
||||
|
|
@ -529,7 +529,7 @@ set_total(self, total: int) -> None
|
|||
Set the total/target value for progress tracking.
|
||||
|
||||
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1187" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1191" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
increment(self, amount: int = 1) -> None
|
||||
|
|
@ -538,7 +538,7 @@ increment(self, amount: int = 1) -> None
|
|||
Atomically increment the current progress value.
|
||||
|
||||
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1200" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_message(self, message: str | None) -> None
|
||||
|
|
@ -547,7 +547,7 @@ set_message(self, message: str | None) -> None
|
|||
Update the progress status message.
|
||||
|
||||
|
||||
### `Progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `Progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1205" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
|
||||
Progress dependency that works in both server and worker contexts.
|
||||
|
|
@ -562,7 +562,7 @@ share mutable state.
|
|||
|
||||
**Methods:**
|
||||
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1246" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
current(self) -> int | None
|
||||
|
|
@ -571,7 +571,7 @@ current(self) -> int | None
|
|||
Current progress value.
|
||||
|
||||
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1248" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1252" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
total(self) -> int
|
||||
|
|
@ -580,7 +580,7 @@ total(self) -> int
|
|||
Total/target progress value.
|
||||
|
||||
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
message(self) -> str | None
|
||||
|
|
@ -589,7 +589,7 @@ message(self) -> str | None
|
|||
Current progress message.
|
||||
|
||||
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1263" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_total(self, total: int) -> None
|
||||
|
|
@ -598,7 +598,7 @@ set_total(self, total: int) -> None
|
|||
Set the total/target value for progress tracking.
|
||||
|
||||
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1264" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1268" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
increment(self, amount: int = 1) -> None
|
||||
|
|
@ -607,7 +607,7 @@ increment(self, amount: int = 1) -> None
|
|||
Atomically increment the current progress value.
|
||||
|
||||
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1269" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1273" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
set_message(self, message: str | None) -> None
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ Uses Client to get the standard MCP protocol format with camelCase fields.
|
|||
Includes version metadata at the top level.
|
||||
|
||||
|
||||
### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L502" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L503" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
||||
```python
|
||||
format_info(mcp: FastMCP[Any] | SDKServer, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes
|
||||
|
|
|
|||
|
|
@ -199,7 +199,15 @@ def get_user_agent() -> str:
|
|||
return headers.get("user-agent", "Unknown")
|
||||
```
|
||||
|
||||
By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers.
|
||||
By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin.
|
||||
|
||||
To read a credential header, ask for it by name:
|
||||
|
||||
```python
|
||||
headers = get_http_headers(include={"cookie"})
|
||||
```
|
||||
|
||||
`CurrentHeaders()` already includes both credential headers, since it exposes the current request to your handler rather than forwarding it. Use `get_http_headers(include_all=True)` to include every header.
|
||||
|
||||
### Access Token
|
||||
|
||||
|
|
|
|||
101
examples/testing_demo/uv.lock
generated
101
examples/testing_demo/uv.lock
generated
|
|
@ -15,13 +15,12 @@ exclude-newer-span = "P1W"
|
|||
[options.exclude-newer-package]
|
||||
mcp-types = false
|
||||
prefab-ui = false
|
||||
truststore = false
|
||||
fastmcp-slim = false
|
||||
pydocket = false
|
||||
uncalled-for = false
|
||||
fastmcp = false
|
||||
mcp = false
|
||||
httpcore2 = false
|
||||
fastmcp-remote = false
|
||||
httpx2 = false
|
||||
fastmcp-slim = false
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
|
|
@ -259,59 +258,59 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "49.0.0"
|
||||
version = "50.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import fastmcp
|
|||
from fastmcp.cli import run as run_module
|
||||
from fastmcp.cli.auth import auth_app
|
||||
from fastmcp.cli.client import call_command, discover_command, list_command
|
||||
from fastmcp.cli.deploy.command import login, logout, whoami
|
||||
from fastmcp.cli.generate import generate_cli_command
|
||||
from fastmcp.cli.install import install_app
|
||||
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
|
||||
|
|
@ -1134,6 +1135,11 @@ app.command(generate_cli_command, name="generate-cli")
|
|||
# Add auth subcommand group (includes CIMD commands)
|
||||
app.command(auth_app)
|
||||
|
||||
# Add Prefect Horizon account commands
|
||||
app.command(login)
|
||||
app.command(logout)
|
||||
app.command(whoami)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
|||
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal file
398
fastmcp_slim/fastmcp/cli/deploy/command.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""Public Prefect Horizon authentication commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from cyclopts import Parameter
|
||||
from pydantic import SecretStr
|
||||
from rich.status import Status
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.cli.deploy.authentication import (
|
||||
DeviceAuthorizationDeniedError,
|
||||
DeviceAuthorizationError,
|
||||
DeviceAuthorizationExpiredError,
|
||||
authorize_device,
|
||||
)
|
||||
from fastmcp.cli.deploy.configuration import (
|
||||
ConfigurationStore,
|
||||
HorizonConfiguration,
|
||||
)
|
||||
from fastmcp.cli.deploy.credentials import (
|
||||
AuthenticationRequiredError,
|
||||
CredentialStore,
|
||||
ResolvedCredential,
|
||||
)
|
||||
from fastmcp.cli.deploy.horizon_client import (
|
||||
DeviceAuthorization,
|
||||
DeviceMetadata,
|
||||
HorizonClient,
|
||||
HorizonResponseError,
|
||||
HorizonUnauthorizedError,
|
||||
HorizonUnavailableError,
|
||||
HorizonUser,
|
||||
)
|
||||
from fastmcp.cli.deploy.output import (
|
||||
CommandName,
|
||||
ErrorCategory,
|
||||
emit_device_challenge,
|
||||
emit_environment_logout,
|
||||
emit_error,
|
||||
emit_identity,
|
||||
emit_logout,
|
||||
start_device_approval_status,
|
||||
stop_device_approval_status,
|
||||
)
|
||||
from fastmcp.cli.deploy.state import StateFileError, state_lock
|
||||
|
||||
JsonOption = Annotated[
|
||||
bool,
|
||||
Parameter(
|
||||
name="--json",
|
||||
help="Write one final JSON result to stdout",
|
||||
negative=(),
|
||||
),
|
||||
]
|
||||
HostOption = Annotated[
|
||||
str | None,
|
||||
Parameter(
|
||||
name="--host",
|
||||
help="Use and save a different Horizon host URL",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _can_open_browser() -> bool:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
|
||||
def _device_metadata() -> DeviceMetadata:
|
||||
return DeviceMetadata(
|
||||
device_name=platform.node() or None,
|
||||
platform=platform.system().lower() or None,
|
||||
architecture=platform.machine().lower() or None,
|
||||
client_version=fastmcp.__version__,
|
||||
)
|
||||
|
||||
|
||||
def _load_session_snapshot(
|
||||
credentials: CredentialStore,
|
||||
) -> tuple[HorizonConfiguration, ResolvedCredential | None]:
|
||||
with state_lock(credentials.path.parent):
|
||||
configuration = ConfigurationStore(credentials.path.parent).load()
|
||||
environment_key = os.environ.get("HORIZON_API_KEY")
|
||||
if environment_key:
|
||||
credential = ResolvedCredential(
|
||||
api_key=SecretStr(environment_key),
|
||||
source="environment",
|
||||
)
|
||||
else:
|
||||
stored_key = credentials.load()
|
||||
credential = (
|
||||
ResolvedCredential(api_key=stored_key, source="stored")
|
||||
if stored_key is not None
|
||||
else None
|
||||
)
|
||||
return configuration, credential
|
||||
|
||||
|
||||
def _fail(
|
||||
command: CommandName,
|
||||
category: ErrorCategory,
|
||||
message: str,
|
||||
*,
|
||||
json_output: bool,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> NoReturn:
|
||||
emit_error(
|
||||
command,
|
||||
category,
|
||||
message,
|
||||
json_output=json_output,
|
||||
details=details,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _fail_for_expected_error(
|
||||
command: CommandName,
|
||||
error: Exception,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> NoReturn:
|
||||
if isinstance(error, AuthenticationRequiredError):
|
||||
_fail(
|
||||
command,
|
||||
"authentication_required",
|
||||
"Run `fastmcp login` to sign in to Prefect Horizon.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonUnauthorizedError):
|
||||
_fail(
|
||||
command,
|
||||
"authentication_invalid",
|
||||
"The Horizon credential is not valid. Run `fastmcp login` again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationDeniedError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_denied",
|
||||
"The device authorization request was denied.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationExpiredError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_expired",
|
||||
"The device authorization request expired. Run the command again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, DeviceAuthorizationError):
|
||||
_fail(
|
||||
command,
|
||||
"authorization_failed",
|
||||
"The device authorization request failed. Run the command again.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonUnavailableError):
|
||||
_fail(
|
||||
command,
|
||||
"horizon_unavailable",
|
||||
"The Horizon API is unavailable. Try again later.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, HorizonResponseError):
|
||||
_fail(
|
||||
command,
|
||||
"horizon_error",
|
||||
"Horizon returned an unexpected response. Try again later.",
|
||||
json_output=json_output,
|
||||
)
|
||||
if isinstance(error, StateFileError):
|
||||
_fail(
|
||||
command,
|
||||
"state_error",
|
||||
"The local Horizon state is invalid.",
|
||||
json_output=json_output,
|
||||
)
|
||||
raise error
|
||||
|
||||
|
||||
async def _get_user(
|
||||
api_origin: str,
|
||||
credential: ResolvedCredential,
|
||||
) -> HorizonUser:
|
||||
async with HorizonClient(api_origin, api_key=credential.api_key) as client:
|
||||
return await client.get_current_user()
|
||||
|
||||
|
||||
async def login(
|
||||
*,
|
||||
host: HostOption = None,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Sign in to Prefect Horizon."""
|
||||
credentials = CredentialStore()
|
||||
|
||||
try:
|
||||
configuration_store = ConfigurationStore()
|
||||
requested_configuration: HorizonConfiguration | None = None
|
||||
if host is not None:
|
||||
try:
|
||||
requested_configuration = configuration_store.set_api_origin(
|
||||
host,
|
||||
credentials=credentials,
|
||||
)
|
||||
except ValueError:
|
||||
_fail(
|
||||
"login",
|
||||
"invalid_host",
|
||||
"The Horizon host must be an HTTP origin.",
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if (
|
||||
requested_configuration is not None
|
||||
and configuration.api_origin != requested_configuration.api_origin
|
||||
):
|
||||
raise StateFileError("The Horizon host changed during login")
|
||||
|
||||
async def device_authorization():
|
||||
approval_status: Status | None = None
|
||||
|
||||
def show_challenge(challenge: DeviceAuthorization) -> None:
|
||||
nonlocal approval_status
|
||||
emit_device_challenge(challenge, json_output=json_output)
|
||||
approval_status = start_device_approval_status(json_output=json_output)
|
||||
|
||||
try:
|
||||
async with HorizonClient(configuration.api_origin) as client:
|
||||
return await authorize_device(
|
||||
client,
|
||||
metadata=_device_metadata(),
|
||||
on_challenge=show_challenge,
|
||||
open_browser=not json_output and _can_open_browser(),
|
||||
browser_opener=webbrowser.open,
|
||||
)
|
||||
finally:
|
||||
stop_device_approval_status(approval_status)
|
||||
|
||||
async def interactive_credential() -> ResolvedCredential:
|
||||
api_key = await device_authorization()
|
||||
credentials.save_for_origin(
|
||||
api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
return ResolvedCredential(api_key=api_key, source="interactive")
|
||||
|
||||
if credential is None:
|
||||
credential = await interactive_credential()
|
||||
|
||||
try:
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
if credential.source == "environment":
|
||||
raise
|
||||
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
if credential.source == "interactive":
|
||||
raise
|
||||
|
||||
credential = await interactive_credential()
|
||||
try:
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
raise
|
||||
except (
|
||||
AuthenticationRequiredError,
|
||||
DeviceAuthorizationError,
|
||||
HorizonResponseError,
|
||||
HorizonUnauthorizedError,
|
||||
HorizonUnavailableError,
|
||||
StateFileError,
|
||||
) as error:
|
||||
_fail_for_expected_error("login", error, json_output=json_output)
|
||||
|
||||
emit_identity(
|
||||
"login",
|
||||
user,
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
|
||||
async def whoami(
|
||||
*,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Show the current Prefect Horizon user."""
|
||||
credentials = CredentialStore()
|
||||
configuration: HorizonConfiguration | None = None
|
||||
credential: ResolvedCredential | None = None
|
||||
|
||||
try:
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if credential is None:
|
||||
raise AuthenticationRequiredError("Horizon authentication is required")
|
||||
user = await _get_user(
|
||||
configuration.api_origin,
|
||||
credential,
|
||||
)
|
||||
except HorizonUnauthorizedError as error:
|
||||
if (
|
||||
configuration is not None
|
||||
and credential is not None
|
||||
and credential.source == "stored"
|
||||
):
|
||||
try:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
except StateFileError as cleanup_error:
|
||||
_fail_for_expected_error(
|
||||
"whoami",
|
||||
cleanup_error,
|
||||
json_output=json_output,
|
||||
)
|
||||
_fail_for_expected_error("whoami", error, json_output=json_output)
|
||||
except (
|
||||
AuthenticationRequiredError,
|
||||
HorizonResponseError,
|
||||
HorizonUnavailableError,
|
||||
StateFileError,
|
||||
) as error:
|
||||
_fail_for_expected_error("whoami", error, json_output=json_output)
|
||||
|
||||
emit_identity(
|
||||
"whoami",
|
||||
user,
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
|
||||
async def logout(
|
||||
*,
|
||||
json_output: JsonOption = False,
|
||||
) -> None:
|
||||
"""Revoke the current Horizon key and remove the local credential."""
|
||||
credentials = CredentialStore()
|
||||
|
||||
if os.environ.get("HORIZON_API_KEY"):
|
||||
emit_environment_logout(json_output=json_output)
|
||||
return
|
||||
|
||||
try:
|
||||
configuration, credential = _load_session_snapshot(credentials)
|
||||
if credential is None:
|
||||
emit_logout(remote_revoked=False, json_output=json_output)
|
||||
return
|
||||
|
||||
async with HorizonClient(
|
||||
configuration.api_origin,
|
||||
api_key=credential.api_key,
|
||||
) as client:
|
||||
try:
|
||||
await client.revoke_current_api_key()
|
||||
finally:
|
||||
credentials.clear_if_matches(
|
||||
credential.api_key,
|
||||
expected_api_origin=configuration.api_origin,
|
||||
)
|
||||
except HorizonUnauthorizedError:
|
||||
emit_logout(remote_revoked=False, json_output=json_output)
|
||||
return
|
||||
except (HorizonResponseError, HorizonUnavailableError):
|
||||
_fail(
|
||||
"logout",
|
||||
"remote_revocation_failed",
|
||||
"The local credential was removed, but the remote key can remain active.",
|
||||
json_output=json_output,
|
||||
details={
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
},
|
||||
)
|
||||
except StateFileError as error:
|
||||
_fail_for_expected_error("logout", error, json_output=json_output)
|
||||
|
||||
emit_logout(remote_revoked=True, json_output=json_output)
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -96,6 +97,32 @@ class CredentialStore:
|
|||
raise StateFileError("The Horizon host changed during login")
|
||||
self.save(api_key)
|
||||
|
||||
def clear_if_matches(
|
||||
self,
|
||||
api_key: SecretStr | str,
|
||||
*,
|
||||
expected_api_origin: str,
|
||||
) -> None:
|
||||
"""Clear a key only while its Horizon origin and value are active."""
|
||||
from fastmcp.cli.deploy.configuration import ConfigurationStore
|
||||
|
||||
expected_api_origin = normalize_api_origin(expected_api_origin)
|
||||
expected_api_key = (
|
||||
api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
||||
)
|
||||
with state_lock(self.path.parent):
|
||||
active_api_origin = ConfigurationStore(self.path.parent).load().api_origin
|
||||
active_api_key = self.load()
|
||||
if (
|
||||
active_api_origin == expected_api_origin
|
||||
and active_api_key is not None
|
||||
and secrets.compare_digest(
|
||||
active_api_key.get_secret_value(),
|
||||
expected_api_key,
|
||||
)
|
||||
):
|
||||
self.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
remove_state(self.path)
|
||||
|
||||
|
|
|
|||
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal file
297
fastmcp_slim/fastmcp/cli/deploy/output.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""Stable terminal and JSON output for Horizon CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
from rich import box
|
||||
from rich.align import Align
|
||||
from rich.console import Console, Group
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.status import Status
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
|
||||
|
||||
CommandName = Literal["login", "logout", "whoami"]
|
||||
ErrorCategory = Literal[
|
||||
"authentication_invalid",
|
||||
"authentication_required",
|
||||
"authorization_denied",
|
||||
"authorization_expired",
|
||||
"authorization_failed",
|
||||
"horizon_error",
|
||||
"horizon_unavailable",
|
||||
"invalid_host",
|
||||
"remote_revocation_failed",
|
||||
"state_error",
|
||||
]
|
||||
|
||||
console = Console()
|
||||
error_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _write_json(payload: object, *, stderr: bool = False) -> None:
|
||||
stream = sys.stderr if stderr else sys.stdout
|
||||
print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True)
|
||||
|
||||
|
||||
def _banner(title: str, *, style: str) -> Panel:
|
||||
return Panel(
|
||||
Align.center(Text(title, style=f"bold {style}")),
|
||||
box=box.ROUNDED,
|
||||
border_style=style,
|
||||
padding=(0, 1),
|
||||
width=52,
|
||||
)
|
||||
|
||||
|
||||
def _account_panel(
|
||||
user: HorizonUser,
|
||||
*,
|
||||
title: str,
|
||||
message: str,
|
||||
) -> Panel:
|
||||
name = Text(user.name or user.email, style="bold")
|
||||
details: list[Text] = [name]
|
||||
if user.name:
|
||||
details.append(Text(user.email, style="cyan"))
|
||||
details.extend([Text(), Text(message, style="green")])
|
||||
return Panel(
|
||||
Group(*details),
|
||||
title=Text(title, style="bold green"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="green",
|
||||
padding=(1, 2),
|
||||
width=52,
|
||||
)
|
||||
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
if seconds % 60 == 0:
|
||||
minutes = seconds // 60
|
||||
unit = "minute" if minutes == 1 else "minutes"
|
||||
return f"{minutes} {unit}"
|
||||
unit = "second" if seconds == 1 else "seconds"
|
||||
return f"{seconds} {unit}"
|
||||
|
||||
|
||||
def emit_device_challenge(
|
||||
authorization: DeviceAuthorization,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show a device challenge before polling starts."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": authorization.verification_uri,
|
||||
"verificationUrlComplete": authorization.verification_uri_complete,
|
||||
"userCode": authorization.user_code,
|
||||
},
|
||||
stderr=True,
|
||||
)
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print(_banner("Deploy FastMCP on Horizon", style="magenta"))
|
||||
console.print()
|
||||
console.print(Text("✓ Device authorization started", style="bold green"))
|
||||
console.print()
|
||||
console.print(" Open this URL in your browser:")
|
||||
console.print()
|
||||
console.print(
|
||||
Padding(
|
||||
Text(authorization.verification_uri_complete, style="cyan underline"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
console.print(" Confirm this code:")
|
||||
console.print()
|
||||
code = Table.grid()
|
||||
code.add_column(justify="center", width=52)
|
||||
code.add_row(Text(authorization.user_code, style="bold"))
|
||||
console.print(code)
|
||||
console.print()
|
||||
expires_in = _format_duration(authorization.expires_in)
|
||||
console.print(Text(f"The request expires in {expires_in}.", style="dim"))
|
||||
console.print(Text("Press Ctrl-C to cancel.", style="dim"))
|
||||
console.print()
|
||||
|
||||
|
||||
def start_device_approval_status(*, json_output: bool) -> Status | None:
|
||||
"""Start the terminal spinner while the browser approval is pending."""
|
||||
if json_output:
|
||||
return None
|
||||
status = console.status(
|
||||
"[cyan]Waiting for approval in your browser[/cyan]",
|
||||
spinner="dots",
|
||||
spinner_style="cyan",
|
||||
)
|
||||
status.start()
|
||||
return status
|
||||
|
||||
|
||||
def stop_device_approval_status(status: Status | None) -> None:
|
||||
"""Stop a device approval spinner when one is active."""
|
||||
if status is not None:
|
||||
status.stop()
|
||||
|
||||
|
||||
def emit_identity(
|
||||
command: Literal["login", "whoami"],
|
||||
user: HorizonUser,
|
||||
*,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show the authenticated user."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": command,
|
||||
"user": user.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
console.print()
|
||||
if command == "login":
|
||||
panel = _account_panel(
|
||||
user,
|
||||
title="Logged into Horizon",
|
||||
message="You are signed in to FastMCP.",
|
||||
)
|
||||
else:
|
||||
panel = _account_panel(
|
||||
user,
|
||||
title="Horizon Account",
|
||||
message="● Signed in",
|
||||
)
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_environment_logout(*, json_output: bool) -> None:
|
||||
"""Explain why logout cannot change an environment credential."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
message = Group(
|
||||
Text("This session uses HORIZON_API_KEY.", style="bold"),
|
||||
Text("Remove it from your environment to sign out."),
|
||||
Text("No credential was revoked or removed.", style="dim"),
|
||||
)
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
message,
|
||||
title=Text("Horizon Account", style="bold cyan"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_logout(
|
||||
*,
|
||||
remote_revoked: bool,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
"""Show a successful local logout result."""
|
||||
if json_output:
|
||||
_write_json(
|
||||
{
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": remote_revoked,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if remote_revoked:
|
||||
title = "Logged out of Horizon"
|
||||
message = "The Horizon credential was revoked and removed from this device."
|
||||
style = "green"
|
||||
else:
|
||||
title = "Horizon Account"
|
||||
message = "No active Horizon credential remains on this device."
|
||||
style = "cyan"
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
Text(message),
|
||||
title=Text(title, style=f"bold {style}"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style=style,
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def emit_error(
|
||||
command: CommandName,
|
||||
category: ErrorCategory,
|
||||
message: str,
|
||||
*,
|
||||
json_output: bool,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
"""Show a stable expected command failure."""
|
||||
if json_output:
|
||||
payload: dict[str, object] = {
|
||||
"ok": False,
|
||||
"command": command,
|
||||
"error": {
|
||||
"category": category,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if details:
|
||||
payload.update(details)
|
||||
_write_json(payload)
|
||||
return
|
||||
|
||||
titles = {
|
||||
"login": "✗ Sign in failed",
|
||||
"logout": "✗ Sign out failed",
|
||||
"whoami": "✗ Account lookup failed",
|
||||
}
|
||||
error_console.print()
|
||||
error_console.print(
|
||||
Panel(
|
||||
Text(message),
|
||||
title=Text(titles[command], style="bold red"),
|
||||
title_align="left",
|
||||
box=box.ROUNDED,
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
width=60,
|
||||
)
|
||||
)
|
||||
error_console.print()
|
||||
|
|
@ -9,7 +9,7 @@ import ssl
|
|||
import uuid
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
|
||||
|
||||
|
|
@ -800,11 +800,23 @@ class Client(
|
|||
|
||||
@asynccontextmanager
|
||||
async def _context_manager(self):
|
||||
transport_options = self._transport_options
|
||||
if (
|
||||
isinstance(self.transport, MCPConfigTransport)
|
||||
and len(self.transport.config.mcpServers) > 1
|
||||
):
|
||||
# Resolve mutable connection policy here rather than at construction:
|
||||
# both `Client.mode` and an MCPConfig's server set may change before
|
||||
# the client connects. Preserve options contributed by other layers.
|
||||
transport_options = replace(
|
||||
transport_options or TransportOptions(), backend_mode=self.mode
|
||||
)
|
||||
|
||||
# Only passed when this client actually wants non-default settings, so an
|
||||
# ordinary client never sends an argument a transport might not accept.
|
||||
if self._transport_options is not None:
|
||||
if transport_options is not None:
|
||||
connection = self.transport.connect_session(
|
||||
transport_options=self._transport_options, **self._session_kwargs
|
||||
transport_options=transport_options, **self._session_kwargs
|
||||
)
|
||||
else:
|
||||
connection = self.transport.connect_session(**self._session_kwargs)
|
||||
|
|
@ -847,9 +859,10 @@ class Client(
|
|||
else:
|
||||
timeout = normalize_timeout_to_seconds(timeout)
|
||||
|
||||
# A legacy-only transport (SSE, a multi-server proxy config) cannot serve
|
||||
# the modern era; treat "auto" as "legacy" there rather than probing
|
||||
# server/discover, which some such servers answer but then cannot serve.
|
||||
# A legacy-only transport cannot serve the modern era; treat "auto" as
|
||||
# "legacy" there rather than probing server/discover. Multi-server config
|
||||
# transports resolve this flag from their connected backend set before
|
||||
# negotiation reaches this point.
|
||||
effective_mode = self.mode
|
||||
if effective_mode == "auto" and self.transport.legacy_only:
|
||||
effective_mode = "legacy"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
"""Client-side dependency helpers."""
|
||||
|
||||
|
||||
def _get_forwardable_http_headers() -> dict[str, str]:
|
||||
"""Return ambient headers safe to copy onto a new MCP connection.
|
||||
|
||||
MCP transport and routing headers describe one HTTP hop and must be
|
||||
regenerated for the new connection. `Last-Event-ID` likewise belongs to
|
||||
the inbound connection's event stream. Other headers, including
|
||||
authorization and custom proxy headers, are preserved.
|
||||
"""
|
||||
return {
|
||||
name: value
|
||||
for name, value in get_http_headers(include={"authorization"}).items()
|
||||
if not name.startswith("mcp-") and name != "last-event-id"
|
||||
}
|
||||
|
||||
|
||||
def get_http_headers(
|
||||
include_all: bool = False,
|
||||
include: set[str] | None = None,
|
||||
|
|
|
|||
|
|
@ -44,22 +44,27 @@ class TransportOptions:
|
|||
|
||||
These belong to the client rather than to the transport, so a transport
|
||||
shared between clients doesn't leak one client's settings to another.
|
||||
Different client layers may own different fields; a layer that adds its
|
||||
settings must preserve the existing options rather than replace the bundle.
|
||||
|
||||
Attributes:
|
||||
session_class: The ClientSession class to instantiate. Proxies supply a
|
||||
session that skips output-schema validation, since they relay
|
||||
results rather than consume them.
|
||||
forward_incoming_headers: Whether to forward the inbound request's
|
||||
authorization header upstream. Only appropriate for proxies, where
|
||||
the caller's credentials are meant to be propagated. Honored by the
|
||||
HTTP and SSE transports; ignored by the others.
|
||||
forward_incoming_headers: Whether to forward eligible inbound HTTP
|
||||
headers upstream, including authorization. Hop-specific HTTP headers
|
||||
and MCP transport, routing, and event-stream state are excluded
|
||||
because each backend connection owns that state. Only appropriate
|
||||
for proxies; honored by the HTTP and SSE transports and ignored by
|
||||
the others.
|
||||
backend_mode: The connect `mode` to give backend clients that a wrapping
|
||||
transport builds on this client's behalf, so a chain of connections
|
||||
speaks one protocol era end to end. `None` leaves each backend
|
||||
client at its own default. Honored by `MCPConfigTransport`, whose
|
||||
multi-server form mounts a proxy per configured server; ignored by
|
||||
transports that connect to a single backend directly, since those
|
||||
carry the connecting client's own session and era.
|
||||
multi-server form mounts a proxy per configured server and resolves
|
||||
one shared era for the aggregate; ignored by transports that connect
|
||||
to a single backend directly, since those carry the connecting
|
||||
client's own session and era.
|
||||
"""
|
||||
|
||||
session_class: type[ClientSession] = ClientSession
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from collections.abc import AsyncIterator
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from fastmcp import _install_hints
|
||||
|
|
@ -85,6 +86,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
self.name_as_prefix = name_as_prefix
|
||||
self._transports: list[ClientTransport] = []
|
||||
self._request_state_security: RequestStateSecurity | None = None
|
||||
self._resolved_legacy_only = False
|
||||
|
||||
if not self.config.mcpServers:
|
||||
raise ValueError("No MCP servers defined in the config")
|
||||
|
|
@ -112,18 +114,17 @@ class MCPConfigTransport(ClientTransport):
|
|||
|
||||
@property
|
||||
def legacy_only(self) -> bool:
|
||||
"""Whether this config can only carry the legacy protocol era.
|
||||
"""Whether the connected composite resolved to the legacy protocol era.
|
||||
|
||||
A single-server config delegates directly to the underlying transport
|
||||
(no proxy), so it inherits that transport's era capability — a modern
|
||||
Streamable HTTP backend must stay modern-capable under `mode="auto"`.
|
||||
A multi-server config mounts each backend behind a legacy-era
|
||||
`ProxyClient` on a composite server, so the composite it exposes is
|
||||
legacy-era and `mode="auto"` should negotiate the handshake.
|
||||
(no proxy), so it inherits that transport's era capability. A
|
||||
multi-server config resolves this value while connecting its backends:
|
||||
all-modern backends leave the composite modern-capable, while any
|
||||
legacy backend moves every leg to the handshake era.
|
||||
"""
|
||||
if len(self.config.mcpServers) == 1:
|
||||
return self.transport.legacy_only
|
||||
return True
|
||||
return self._resolved_legacy_only
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
|
|
@ -152,49 +153,96 @@ class MCPConfigTransport(ClientTransport):
|
|||
) from exc
|
||||
|
||||
timeout = session_kwargs.get("read_timeout_seconds")
|
||||
composite = FastMCP[Any](
|
||||
name="MCPRouter", request_state_security=self._request_state_security
|
||||
requested_mode = (
|
||||
transport_options.backend_mode
|
||||
if transport_options is not None
|
||||
and transport_options.backend_mode is not None
|
||||
else "legacy"
|
||||
)
|
||||
|
||||
# The composite is only a router: every real backend is reached through
|
||||
# one of the mounted proxies below, so the era the connecting client
|
||||
# negotiates with the composite means nothing unless those backend legs
|
||||
# negotiate it too. `backend_mode` carries the connecting client's era
|
||||
# down to them, keeping the whole chain on one era end to end.
|
||||
backend_mode = (
|
||||
transport_options.backend_mode if transport_options is not None else None
|
||||
)
|
||||
# Close transports retained from a previous connection before replacing
|
||||
# them. The active connection's exit stack owns their normal cleanup.
|
||||
for transport in self._transports:
|
||||
await transport.close()
|
||||
self._transports = []
|
||||
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
# Close any previous transports from prior connections to avoid leaking
|
||||
for t in self._transports:
|
||||
await t.close()
|
||||
self._transports = []
|
||||
stack = contextlib.AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
try:
|
||||
composite, backend_versions = await self._build_composite(
|
||||
FastMCP, timeout, stack, requested_mode
|
||||
)
|
||||
|
||||
for name, server_config in self.config.mcpServers.items():
|
||||
try:
|
||||
transport, _client, proxy = await self._create_proxy(
|
||||
name, server_config, timeout, stack, backend_mode
|
||||
)
|
||||
except Exception: # Broad catch is intentional: failure modes
|
||||
# are diverse (OSError, TimeoutError, RuntimeError, etc.)
|
||||
# and the whole point is to skip any server that can't connect.
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server %r, skipping",
|
||||
name,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
self._transports.append(transport)
|
||||
composite.mount(proxy, namespace=name if self.name_as_prefix else None)
|
||||
|
||||
if not self._transports:
|
||||
raise ConnectionError("All MCP servers failed to connect")
|
||||
# `auto` is an aggregate negotiation: the composite can only expose
|
||||
# one era to its caller, so a single legacy backend makes legacy the
|
||||
# best mutual era. Reconnect the modern backends under that era too;
|
||||
# otherwise push- and result-based interactions would meet halfway
|
||||
# through the proxy chain and fail despite the frontend fallback.
|
||||
legacy_backends = [
|
||||
version not in MODERN_PROTOCOL_VERSIONS for version in backend_versions
|
||||
]
|
||||
if (
|
||||
requested_mode == "auto"
|
||||
and any(legacy_backends)
|
||||
and not all(legacy_backends)
|
||||
):
|
||||
await stack.aclose()
|
||||
stack = contextlib.AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
composite, _ = await self._build_composite(
|
||||
FastMCP, timeout, stack, "legacy"
|
||||
)
|
||||
self._resolved_legacy_only = True
|
||||
else:
|
||||
self._resolved_legacy_only = requested_mode == "legacy" or all(
|
||||
legacy_backends
|
||||
)
|
||||
|
||||
async with FastMCPTransport(mcp=composite).connect_session(
|
||||
transport_options=transport_options, **session_kwargs
|
||||
) as session:
|
||||
yield session
|
||||
finally:
|
||||
await stack.aclose()
|
||||
self._resolved_legacy_only = False
|
||||
|
||||
async def _build_composite(
|
||||
self,
|
||||
fastmcp_type: type["FastMCP[Any]"],
|
||||
timeout: float | None,
|
||||
stack: contextlib.AsyncExitStack,
|
||||
backend_mode: str,
|
||||
) -> tuple["FastMCP[Any]", list[str]]:
|
||||
"""Connect configured backends and mount their proxies on one router."""
|
||||
composite = fastmcp_type(
|
||||
name="MCPRouter", request_state_security=self._request_state_security
|
||||
)
|
||||
self._transports = []
|
||||
backend_versions: list[str] = []
|
||||
|
||||
for name, server_config in self.config.mcpServers.items():
|
||||
try:
|
||||
transport, client, proxy = await self._create_proxy(
|
||||
name, server_config, timeout, stack, backend_mode
|
||||
)
|
||||
except Exception: # Broad catch is intentional: failure modes
|
||||
# are diverse (OSError, TimeoutError, RuntimeError, etc.) and
|
||||
# one unavailable server must not take down healthy siblings.
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server %r, skipping",
|
||||
name,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
self._transports.append(transport)
|
||||
assert client.protocol_version is not None
|
||||
backend_versions.append(client.protocol_version)
|
||||
composite.mount(proxy, namespace=name if self.name_as_prefix else None)
|
||||
|
||||
if not self._transports:
|
||||
raise ConnectionError("All MCP servers failed to connect")
|
||||
|
||||
return composite, backend_versions
|
||||
|
||||
async def _create_proxy(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from fastmcp.client.auth.client_credentials import (
|
|||
PrivateKeyJWTOAuthProvider,
|
||||
)
|
||||
from fastmcp.client.auth.oauth import OAuth
|
||||
from fastmcp.client.dependencies import get_http_headers
|
||||
from fastmcp.client.dependencies import _get_forwardable_http_headers
|
||||
from fastmcp.client.transports.base import (
|
||||
ClientTransport,
|
||||
SessionKwargs,
|
||||
|
|
@ -161,12 +161,12 @@ class StreamableHttpTransport(ClientTransport):
|
|||
) -> AsyncIterator[ClientSession]:
|
||||
options = transport_options or TransportOptions()
|
||||
|
||||
# When used in a proxy, forward the inbound request's authorization
|
||||
# header to the upstream server. This is off by default so that a
|
||||
# plain Client used inside a server tool handler doesn't accidentally
|
||||
# leak the caller's credentials to an unrelated remote server.
|
||||
# Proxies preserve eligible inbound headers while starting a distinct
|
||||
# MCP connection with its own transport state.
|
||||
# This is off by default so a plain Client used inside a server tool
|
||||
# handler cannot leak caller headers to an unrelated remote server.
|
||||
if options.forward_incoming_headers:
|
||||
headers = get_http_headers(include={"authorization"}) | self.headers
|
||||
headers = _get_forwardable_http_headers() | self.headers
|
||||
else:
|
||||
headers = dict(self.headers)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from fastmcp.client.auth.client_credentials import (
|
|||
PrivateKeyJWTOAuthProvider,
|
||||
)
|
||||
from fastmcp.client.auth.oauth import OAuth
|
||||
from fastmcp.client.dependencies import get_http_headers
|
||||
from fastmcp.client.dependencies import _get_forwardable_http_headers
|
||||
from fastmcp.client.transports.base import (
|
||||
ClientTransport,
|
||||
SessionKwargs,
|
||||
|
|
@ -138,14 +138,12 @@ class SSETransport(ClientTransport):
|
|||
options = transport_options or TransportOptions()
|
||||
client_kwargs: dict[str, Any] = {}
|
||||
|
||||
# When used in a proxy, forward the inbound request's authorization
|
||||
# header to the upstream server. This is off by default so that a
|
||||
# plain Client used inside a server tool handler doesn't accidentally
|
||||
# leak the caller's credentials to an unrelated remote server.
|
||||
# Proxies preserve eligible inbound headers while starting a distinct
|
||||
# MCP connection with its own transport state.
|
||||
# This is off by default so a plain Client used inside a server tool
|
||||
# handler cannot leak caller headers to an unrelated remote server.
|
||||
if options.forward_incoming_headers:
|
||||
client_kwargs["headers"] = (
|
||||
get_http_headers(include={"authorization"}) | self.headers
|
||||
)
|
||||
client_kwargs["headers"] = _get_forwardable_http_headers() | self.headers
|
||||
else:
|
||||
client_kwargs["headers"] = dict(self.headers)
|
||||
|
||||
|
|
|
|||
|
|
@ -547,9 +547,9 @@ def get_http_headers(
|
|||
Never raises an exception, even if there is no active HTTP request (in which case
|
||||
an empty dict is returned).
|
||||
|
||||
By default, strips problematic headers like `content-length` and `authorization`
|
||||
that cause issues if forwarded to downstream services. If `include_all` is True,
|
||||
all headers are returned.
|
||||
By default, strips problematic headers like `content-length`, and credential
|
||||
headers like `authorization` and `cookie`, that cause issues if forwarded to
|
||||
downstream services. If `include_all` is True, all headers are returned.
|
||||
|
||||
The `include` parameter allows specific headers to be included even if they would
|
||||
normally be excluded. This is useful for proxy transports that need to forward
|
||||
|
|
@ -570,6 +570,7 @@ def get_http_headers(
|
|||
"expect",
|
||||
"accept",
|
||||
"authorization",
|
||||
"cookie",
|
||||
# Proxy-related headers
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
|
|
@ -1068,7 +1069,10 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
|
|||
"""Async context manager for HTTP Headers dependency."""
|
||||
|
||||
async def __aenter__(self) -> dict[str, str]:
|
||||
return get_http_headers(include={"authorization"})
|
||||
# Credential headers are denied by default because most callers forward
|
||||
# what they get. This dependency only exposes the current request to the
|
||||
# handler, so it opts them back in.
|
||||
return get_http_headers(include={"authorization", "cookie"})
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
|
|
@ -1083,9 +1087,9 @@ def CurrentHeaders() -> dict[str, str]:
|
|||
"""Get the current HTTP request headers.
|
||||
|
||||
This dependency provides access to the HTTP headers for the current request,
|
||||
including the authorization header. Returns an empty dictionary when no HTTP
|
||||
request is available, making it safe to use in code that might run over any
|
||||
transport.
|
||||
including the `authorization` and `cookie` headers, which `get_http_headers()`
|
||||
withholds by default. Returns an empty dictionary when no HTTP request is
|
||||
available, making it safe to use in code that might run over any transport.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to a dictionary of header name -> value
|
||||
|
|
|
|||
|
|
@ -96,14 +96,25 @@ class _ForwardingClientSession(ClientSession):
|
|||
|
||||
|
||||
# Settings every proxy-backend connection uses: relay results without policing
|
||||
# the backend's output schema, and forward the caller's authorization header
|
||||
# upstream (appropriate for a proxy, where credentials are meant to propagate).
|
||||
# the backend's output schema, and forward eligible caller headers upstream
|
||||
# without inheriting frontend-owned MCP transport state.
|
||||
PROXY_TRANSPORT_OPTIONS = TransportOptions(
|
||||
session_class=_ForwardingClientSession,
|
||||
forward_incoming_headers=True,
|
||||
)
|
||||
|
||||
|
||||
def _with_proxy_transport_options(
|
||||
options: TransportOptions | None,
|
||||
) -> TransportOptions:
|
||||
"""Layer proxy-owned settings onto options supplied by another client layer."""
|
||||
return replace(
|
||||
options or TransportOptions(),
|
||||
session_class=PROXY_TRANSPORT_OPTIONS.session_class,
|
||||
forward_incoming_headers=PROXY_TRANSPORT_OPTIONS.forward_incoming_headers,
|
||||
)
|
||||
|
||||
|
||||
#: Transport-level failures that can escape a backend connection attempt.
|
||||
#: `Client._connect` wraps most connect failures in a ``RuntimeError("Client
|
||||
#: failed to connect: ...")``, but a transport can also surface an httpx or
|
||||
|
|
@ -1353,7 +1364,8 @@ def _create_client_factory(
|
|||
# stopping at the composite router (see
|
||||
# `TransportOptions.backend_mode`).
|
||||
fresh._transport_options = replace(
|
||||
PROXY_TRANSPORT_OPTIONS, backend_mode=fresh.mode
|
||||
_with_proxy_transport_options(fresh._transport_options),
|
||||
backend_mode=fresh.mode,
|
||||
)
|
||||
return fresh
|
||||
|
||||
|
|
@ -1423,7 +1435,8 @@ def _create_client_factory(
|
|||
# moment a client is built for this request — so it tracks the
|
||||
# front era rather than whatever was true at construction.
|
||||
fresh._transport_options = replace(
|
||||
PROXY_TRANSPORT_OPTIONS, backend_mode=backend_mode
|
||||
_with_proxy_transport_options(fresh._transport_options),
|
||||
backend_mode=backend_mode,
|
||||
)
|
||||
return fresh
|
||||
|
||||
|
|
@ -1744,7 +1757,7 @@ class ProxyClient(Client[ClientTransportT]):
|
|||
self._proxy_restoring_handler_keys.add(key)
|
||||
super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload]
|
||||
|
||||
self._transport_options = PROXY_TRANSPORT_OPTIONS
|
||||
self._transport_options = _with_proxy_transport_options(self._transport_options)
|
||||
|
||||
def _bind_restoring_handlers(self) -> None:
|
||||
if "roots" in self._proxy_restoring_handler_keys:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, Any
|
||||
|
||||
|
|
@ -16,8 +17,9 @@ from fastmcp.tools.base import Tool
|
|||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
"""Lowercase, split on non-alphanumeric, filter short tokens."""
|
||||
return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1]
|
||||
"""Normalize and extract Unicode alphanumeric tokens."""
|
||||
normalized = unicodedata.normalize("NFKC", text).casefold()
|
||||
return re.findall(r"[^\W_]{2,}", normalized)
|
||||
|
||||
|
||||
class _BM25Index:
|
||||
|
|
|
|||
|
|
@ -489,6 +489,7 @@ async def format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes:
|
|||
"mcp": importlib.metadata.version("mcp"), # MCP protocol version
|
||||
},
|
||||
"serverInfo": server_info,
|
||||
"instructions": client.initialize_result.instructions,
|
||||
"capabilities": {}, # MCP format doesn't include capabilities at top level
|
||||
"tools": tools_result.tools,
|
||||
"prompts": prompts_result.prompts,
|
||||
|
|
|
|||
462
tests/cli/deploy/test_command.py
Normal file
462
tests/cli/deploy/test_command.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
import fastmcp
|
||||
import fastmcp.cli.deploy.authentication as authentication_module
|
||||
import fastmcp.cli.deploy.command as command_module
|
||||
from fastmcp.cli.deploy.command import login, logout, whoami
|
||||
from fastmcp.cli.deploy.configuration import ConfigurationStore
|
||||
from fastmcp.cli.deploy.credentials import CredentialStore
|
||||
from fastmcp.cli.deploy.horizon_client import HorizonClient
|
||||
from fastmcp.cli.deploy.state import StateFileError
|
||||
|
||||
|
||||
class HorizonAuthAPI:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token_error: str | None = None,
|
||||
revoke_status: int = 204,
|
||||
invalid_api_key: str | None = None,
|
||||
on_request: Callable[[httpx2.Request], None] | None = None,
|
||||
) -> None:
|
||||
self.token_error = token_error
|
||||
self.revoke_status = revoke_status
|
||||
self.invalid_api_key = invalid_api_key
|
||||
self.on_request = on_request
|
||||
self.requests: list[httpx2.Request] = []
|
||||
|
||||
def __call__(self, request: httpx2.Request) -> httpx2.Response:
|
||||
self.requests.append(request)
|
||||
if self.on_request is not None:
|
||||
self.on_request(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v0/oauth/device/authorization":
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device-secret",
|
||||
"user_code": "ABCD-EFGH",
|
||||
"verification_uri": "https://horizon.prefect.io/oauth/device",
|
||||
"verification_uri_complete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"expires_in": 600,
|
||||
"interval": 1,
|
||||
},
|
||||
)
|
||||
if path == "/api/v0/oauth/device/token":
|
||||
if self.token_error is not None:
|
||||
return httpx2.Response(400, json={"error": self.token_error})
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
|
||||
)
|
||||
if path == "/api/v0/me":
|
||||
if request.headers.get("Authorization") == (
|
||||
f"Bearer {self.invalid_api_key}"
|
||||
):
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
}
|
||||
},
|
||||
)
|
||||
if path == "/api/v0/me/api-key":
|
||||
return httpx2.Response(self.revoke_status)
|
||||
raise AssertionError(f"Unexpected request: {request.method} {path}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_horizon_api(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Callable[[HorizonAuthAPI], None]:
|
||||
def use(api: HorizonAuthAPI) -> None:
|
||||
transport = httpx2.MockTransport(api)
|
||||
|
||||
def client(
|
||||
api_origin: str,
|
||||
*,
|
||||
api_key: SecretStr | str | None = None,
|
||||
) -> HorizonClient:
|
||||
return HorizonClient(
|
||||
api_origin,
|
||||
api_key=api_key,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(command_module, "HorizonClient", client)
|
||||
|
||||
return use
|
||||
|
||||
|
||||
def test_session_snapshot_reads_host_and_credential_under_one_lock(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
configuration_load = ConfigurationStore.load
|
||||
credential_load = CredentialStore.load
|
||||
|
||||
@contextmanager
|
||||
def lock(directory: Path) -> Iterator[None]:
|
||||
events.append("lock")
|
||||
yield
|
||||
events.append("unlock")
|
||||
|
||||
def load_configuration(store: ConfigurationStore):
|
||||
events.append("configuration")
|
||||
return configuration_load(store)
|
||||
|
||||
def load_credential(store: CredentialStore):
|
||||
events.append("credential")
|
||||
return credential_load(store)
|
||||
|
||||
monkeypatch.setattr(command_module, "state_lock", lock)
|
||||
monkeypatch.setattr(ConfigurationStore, "load", load_configuration)
|
||||
monkeypatch.setattr(CredentialStore, "load", load_credential)
|
||||
|
||||
configuration, credential = command_module._load_session_snapshot(CredentialStore())
|
||||
|
||||
assert configuration.api_origin == "https://horizon.prefect.io"
|
||||
assert credential is None
|
||||
assert events == ["lock", "configuration", "credential", "unlock"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
|
||||
|
||||
|
||||
async def test_json_login_writes_one_result_and_challenge_to_stderr(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
browser_open = Mock()
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
|
||||
monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
|
||||
monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
stdout_lines = captured.out.strip().splitlines()
|
||||
assert len(stdout_lines) == 1
|
||||
assert json.loads(stdout_lines[0]) == {
|
||||
"ok": True,
|
||||
"command": "login",
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
},
|
||||
}
|
||||
assert json.loads(captured.err) == {
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": "https://horizon.prefect.io/oauth/device",
|
||||
"verificationUrlComplete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"userCode": "ABCD-EFGH",
|
||||
}
|
||||
browser_open.assert_not_called()
|
||||
authorization_request = next(
|
||||
request
|
||||
for request in api.requests
|
||||
if request.url.path == "/api/v0/oauth/device/authorization"
|
||||
)
|
||||
assert parse_qs(authorization_request.content.decode()) == {
|
||||
"client_id": ["fastmcp-cli"],
|
||||
"device_name": ["Avery's laptop"],
|
||||
"platform": ["darwin"],
|
||||
"architecture": ["arm64"],
|
||||
"client_version": ["4.0.0"],
|
||||
}
|
||||
|
||||
state = json.loads(CredentialStore().path.read_text())
|
||||
assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
|
||||
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
|
||||
|
||||
|
||||
async def test_login_host_is_saved_before_device_authorization(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
|
||||
await login(host="https://dev.horizon.prefect.io/", json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out)["ok"] is True
|
||||
configuration_path = fastmcp.settings.home / "cli" / "config.json"
|
||||
assert json.loads(configuration_path.read_text()) == {
|
||||
"schemaVersion": 1,
|
||||
"apiOrigin": "https://dev.horizon.prefect.io",
|
||||
}
|
||||
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
|
||||
|
||||
|
||||
async def test_login_rejects_an_invalid_host(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await login(host="https://horizon.prefect.io/path", json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "invalid_host"
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
||||
|
||||
async def test_tty_login_survives_browser_open_failure(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI())
|
||||
browser_open = Mock(side_effect=OSError("No browser"))
|
||||
monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
|
||||
await login()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "https://horizon.prefect.io/oauth/device" in output
|
||||
assert "ABCD-EFGH" in output
|
||||
assert "Logged into Horizon" in output
|
||||
assert "Ada" in output
|
||||
assert "ada@example.com" in output
|
||||
assert "Organization" not in output
|
||||
browser_open.assert_called_once()
|
||||
|
||||
|
||||
async def test_whoami_uses_the_stored_key_after_a_restart(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
await login(json_output=True)
|
||||
capsys.readouterr()
|
||||
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["command"] == "whoami"
|
||||
assert result["user"]["email"] == "ada@example.com"
|
||||
assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
|
||||
|
||||
|
||||
async def test_login_replaces_an_invalid_stored_key(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
|
||||
CredentialStore().save("fmcp_stale_key")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out)["ok"] is True
|
||||
assert json.loads(captured.err)["event"] == "device_authorization"
|
||||
stored_key = CredentialStore().load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_device_key"
|
||||
|
||||
|
||||
async def test_login_never_persists_an_environment_key(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
|
||||
|
||||
await login(json_output=True)
|
||||
|
||||
assert CredentialStore().path.exists() is False
|
||||
assert not any(
|
||||
request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
|
||||
)
|
||||
|
||||
|
||||
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
|
||||
CredentialStore().save("fmcp_stale_key")
|
||||
|
||||
def fail_clear(store: CredentialStore) -> None:
|
||||
raise StateFileError("cleanup failed")
|
||||
|
||||
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "state_error"
|
||||
|
||||
|
||||
async def test_whoami_does_not_clear_a_newer_host_credential(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
credentials = CredentialStore()
|
||||
credentials.save("fmcp_stale_key")
|
||||
switched = False
|
||||
|
||||
def switch_host(request: httpx2.Request) -> None:
|
||||
nonlocal switched
|
||||
if request.url.path != "/api/v0/me" or switched:
|
||||
return
|
||||
switched = True
|
||||
ConfigurationStore().set_api_origin(
|
||||
"https://dev.horizon.prefect.io",
|
||||
credentials=credentials,
|
||||
)
|
||||
credentials.save_for_origin(
|
||||
"fmcp_new_key",
|
||||
expected_api_origin="https://dev.horizon.prefect.io",
|
||||
)
|
||||
|
||||
api = HorizonAuthAPI(
|
||||
invalid_api_key="fmcp_stale_key",
|
||||
on_request=switch_host,
|
||||
)
|
||||
use_horizon_api(api)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "authentication_invalid"
|
||||
assert ConfigurationStore().load().api_origin == ("https://dev.horizon.prefect.io")
|
||||
stored_key = credentials.load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_new_key"
|
||||
assert api.requests[0].url.host == "horizon.prefect.io"
|
||||
|
||||
|
||||
async def test_json_whoami_does_not_start_device_authorization(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
browser_open = Mock()
|
||||
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await whoami(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "authentication_required"
|
||||
browser_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("token_error", "category"),
|
||||
[
|
||||
("access_denied", "authorization_denied"),
|
||||
("expired_token", "authorization_expired"),
|
||||
],
|
||||
)
|
||||
async def test_json_login_reports_stable_device_failures(
|
||||
token_error: str,
|
||||
category: str,
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(token_error=token_error))
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await login(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == category
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
||||
|
||||
async def test_logout_does_not_modify_environment_or_stored_credentials(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
await logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
stored_key = CredentialStore().load()
|
||||
assert stored_key is not None
|
||||
assert stored_key.get_secret_value() == "fmcp_stored_key"
|
||||
assert api.requests == []
|
||||
|
||||
|
||||
async def test_logout_revokes_the_remote_key_and_clears_local_state(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
api = HorizonAuthAPI()
|
||||
use_horizon_api(api)
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
await logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": True,
|
||||
}
|
||||
assert CredentialStore().path.exists() is False
|
||||
assert any(
|
||||
request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
|
||||
for request in api.requests
|
||||
)
|
||||
|
||||
|
||||
async def test_logout_clears_local_state_when_remote_revocation_fails(
|
||||
use_horizon_api: Callable[[HorizonAuthAPI], None],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
use_horizon_api(HorizonAuthAPI(revoke_status=503))
|
||||
CredentialStore().save("fmcp_stored_key")
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await logout(json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["category"] == "remote_revocation_failed"
|
||||
assert result["localCredentialRemoved"] is True
|
||||
assert result["remoteCredentialMayRemain"] is True
|
||||
assert CredentialStore().path.exists() is False
|
||||
|
|
@ -183,6 +183,29 @@ async def test_interactive_credential_rejects_an_origin_change(
|
|||
assert store.load() is None
|
||||
|
||||
|
||||
def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_current")
|
||||
|
||||
store.clear_if_matches(
|
||||
"fmcp_different",
|
||||
expected_api_origin="https://horizon.prefect.io",
|
||||
)
|
||||
store.clear_if_matches(
|
||||
"fmcp_current",
|
||||
expected_api_origin="https://dev.horizon.prefect.io",
|
||||
)
|
||||
|
||||
assert load_secret(store).get_secret_value() == "fmcp_current"
|
||||
|
||||
store.clear_if_matches(
|
||||
"fmcp_current",
|
||||
expected_api_origin="https://horizon.prefect.io",
|
||||
)
|
||||
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
|
||||
with pytest.raises(AuthenticationRequiredError):
|
||||
await resolve_credential(CredentialStore(tmp_path), environ={})
|
||||
|
|
|
|||
167
tests/cli/deploy/test_output.py
Normal file
167
tests/cli/deploy/test_output.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
|
||||
from fastmcp.cli.deploy.output import (
|
||||
emit_device_challenge,
|
||||
emit_environment_logout,
|
||||
emit_error,
|
||||
emit_identity,
|
||||
emit_logout,
|
||||
)
|
||||
|
||||
|
||||
def authorization() -> DeviceAuthorization:
|
||||
return DeviceAuthorization(
|
||||
device_code="device-secret",
|
||||
user_code="ABCD-EFGH",
|
||||
verification_uri="https://horizon.prefect.io/oauth/device",
|
||||
verification_uri_complete=(
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
expires_in=600,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
|
||||
def user() -> HorizonUser:
|
||||
return HorizonUser(id="user-1", email="ada@example.com", name="Ada")
|
||||
|
||||
|
||||
def test_json_device_challenge_uses_only_stderr(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_device_challenge(authorization(), json_output=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert json.loads(captured.err) == {
|
||||
"event": "device_authorization",
|
||||
"verificationUrl": "https://horizon.prefect.io/oauth/device",
|
||||
"verificationUrlComplete": (
|
||||
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
|
||||
),
|
||||
"userCode": "ABCD-EFGH",
|
||||
}
|
||||
|
||||
|
||||
def test_tty_device_challenge_uses_the_sign_in_layout(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_device_challenge(authorization(), json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "│" in output
|
||||
assert "Deploy FastMCP on Horizon" in output
|
||||
assert "✓ Device authorization started" in output
|
||||
assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
|
||||
assert "ABCD-EFGH" in output
|
||||
assert "The request expires in 10 minutes." in output
|
||||
|
||||
|
||||
def test_json_identity_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_identity("login", user(), json_output=True)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"command": "login",
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "ada@example.com",
|
||||
"name": "Ada",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_tty_identity_uses_an_account_panel(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_identity("whoami", user(), json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "│" in output
|
||||
assert "Horizon Account" in output
|
||||
assert "Ada" in output
|
||||
assert "ada@example.com" in output
|
||||
assert "● Signed in" in output
|
||||
assert "Organization" not in output
|
||||
|
||||
|
||||
def test_json_error_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_error(
|
||||
"logout",
|
||||
"remote_revocation_failed",
|
||||
"The remote key can remain active.",
|
||||
json_output=True,
|
||||
details={
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
},
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result == {
|
||||
"ok": False,
|
||||
"command": "logout",
|
||||
"error": {
|
||||
"category": "remote_revocation_failed",
|
||||
"message": "The remote key can remain active.",
|
||||
},
|
||||
"localCredentialRemoved": True,
|
||||
"remoteCredentialMayRemain": True,
|
||||
}
|
||||
|
||||
|
||||
def test_tty_environment_logout_explains_that_no_action_was_taken(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_environment_logout(json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Horizon Account" in output
|
||||
assert "This session uses HORIZON_API_KEY." in output
|
||||
assert "Remove it from your environment to sign out." in output
|
||||
assert "No credential was revoked or removed." in output
|
||||
|
||||
|
||||
def test_json_environment_logout_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_environment_logout(json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"credentialSource": "environment",
|
||||
"localCredentialRemoved": False,
|
||||
"remoteRevoked": False,
|
||||
}
|
||||
|
||||
|
||||
def test_tty_logout_uses_the_horizon_header(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_logout(remote_revoked=True, json_output=False)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Logged out of Horizon" in output
|
||||
assert "│" in output
|
||||
|
||||
|
||||
def test_json_logout_has_stable_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
emit_logout(remote_revoked=True, json_output=True)
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"ok": True,
|
||||
"command": "logout",
|
||||
"localCredentialRemoved": True,
|
||||
"remoteRevoked": True,
|
||||
}
|
||||
|
|
@ -35,6 +35,20 @@ class TestMainCLI:
|
|||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@pytest.mark.parametrize("name", ["login", "logout", "whoami"])
|
||||
def test_horizon_account_commands_are_top_level(self, name: str):
|
||||
command, bound, _ = app.parse_args([name, "--json"])
|
||||
|
||||
assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
||||
assert bound.arguments == {"json_output": True}
|
||||
|
||||
def test_login_accepts_a_horizon_host(self):
|
||||
_, bound, _ = app.parse_args(
|
||||
["login", "--host", "https://dev.horizon.prefect.io"]
|
||||
)
|
||||
|
||||
assert bound.arguments == {"host": "https://dev.horizon.prefect.io"}
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the version command."""
|
||||
|
|
|
|||
|
|
@ -177,6 +177,66 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
|
|||
assert headers["x-custom-header"] == "should-be-included"
|
||||
|
||||
|
||||
async def test_get_http_headers_excludes_cookie(sse_server: ASGIServer):
|
||||
"""get_http_headers() must not leak the caller's Cookie to a backend.
|
||||
|
||||
The OpenAPI provider forwards this mapping to the upstream named in the
|
||||
spec, so a session cookie scoped to the MCP host would otherwise reach a
|
||||
separate origin on every tool call. Callers that genuinely need it can ask
|
||||
for it back with `include={"cookie"}`, the same escape hatch authorization
|
||||
uses.
|
||||
"""
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def default_headers() -> dict[str, str]:
|
||||
return get_http_headers()
|
||||
|
||||
@server.tool
|
||||
def opted_in_headers() -> dict[str, str]:
|
||||
return get_http_headers(include={"cookie"})
|
||||
|
||||
async with asgi_server(server, transport="sse") as running_server:
|
||||
async with running_server.client(
|
||||
headers={"Cookie": "session=alice-secret", "X-Keep": "yes"}
|
||||
) as client:
|
||||
default = (await client.call_tool("default_headers")).data
|
||||
assert "cookie" not in default
|
||||
assert default["x-keep"] == "yes"
|
||||
|
||||
opted_in = (await client.call_tool("opted_in_headers")).data
|
||||
assert opted_in["cookie"] == "session=alice-secret"
|
||||
|
||||
|
||||
async def test_current_headers_still_exposes_cookie(sse_server: ASGIServer):
|
||||
"""CurrentHeaders() reads the request, so credentials stay visible.
|
||||
|
||||
The default denylist protects call sites that forward headers upstream.
|
||||
A handler inspecting its own request needs the cookie, the same way it
|
||||
already needs authorization.
|
||||
"""
|
||||
from fastmcp.server.dependencies import CurrentHeaders
|
||||
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def read_request(headers: dict = CurrentHeaders()) -> dict[str, str]:
|
||||
return headers
|
||||
|
||||
async with asgi_server(server, transport="sse") as running_server:
|
||||
async with running_server.client(
|
||||
headers={
|
||||
"Cookie": "session=alice-secret",
|
||||
"Authorization": "Bearer alice-token",
|
||||
}
|
||||
) as client:
|
||||
headers = (await client.call_tool("read_request")).data
|
||||
assert headers["cookie"] == "session=alice-secret"
|
||||
assert headers["authorization"] == "Bearer alice-token"
|
||||
|
||||
|
||||
def _worker_snapshot_headers() -> dict[str, str]:
|
||||
"""Read the HTTP headers snapshotted at task submission from inside a worker."""
|
||||
task_info = get_task_context()
|
||||
|
|
|
|||
107
tests/server/providers/proxy/test_proxy_headers.py
Normal file
107
tests/server/providers/proxy/test_proxy_headers.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Header forwarding across ProxyProvider HTTP hops."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from mcp import MCPError
|
||||
from mcp_types import METHOD_NOT_FOUND
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.middleware import Middleware
|
||||
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
|
||||
from fastmcp.utilities.tests import asgi_server
|
||||
|
||||
|
||||
async def test_proxy_does_not_forward_frontend_mcp_headers_to_legacy_backend():
|
||||
"""A modern frontend's transport state does not contaminate a legacy backend."""
|
||||
captured_requests: list[httpx2.Request] = []
|
||||
|
||||
class RejectDiscovery(Middleware):
|
||||
async def on_discover(self, context, call_next):
|
||||
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
|
||||
|
||||
backend = FastMCP("Legacy Backend", middleware=[RejectDiscovery()])
|
||||
|
||||
@backend.tool
|
||||
def legacy_ping() -> str:
|
||||
return "pong"
|
||||
|
||||
async with asgi_server(backend) as running_backend:
|
||||
|
||||
async def capture_request(request: httpx2.Request) -> None:
|
||||
captured_requests.append(request)
|
||||
|
||||
def backend_http_client(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx2.Timeout | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx2.AsyncClient:
|
||||
return running_backend.http_client(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
auth=auth,
|
||||
event_hooks={"request": [capture_request]},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
backend_transport = running_backend.transport(
|
||||
httpx_client_factory=backend_http_client
|
||||
)
|
||||
proxy = FastMCP(
|
||||
"Proxy",
|
||||
providers=[
|
||||
ProxyProvider(lambda: ProxyClient(backend_transport, mode="auto"))
|
||||
],
|
||||
)
|
||||
|
||||
async with asgi_server(proxy) as running_proxy:
|
||||
async with running_proxy.client(
|
||||
mode="auto",
|
||||
headers={
|
||||
"Authorization": "Bearer frontend-token",
|
||||
"X-Proxy-Custom": "preserved",
|
||||
"Mcp-Name": "frontend-name",
|
||||
"Mcp-Param-Tenant": "frontend-tenant",
|
||||
"Mcp-Session-Id": "frontend-session",
|
||||
"Last-Event-ID": "frontend-event",
|
||||
},
|
||||
) as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
tools = await client.list_tools()
|
||||
|
||||
assert [tool.name for tool in tools] == ["legacy_ping"]
|
||||
|
||||
def request_for(method: str) -> httpx2.Request:
|
||||
return next(
|
||||
request
|
||||
for request in captured_requests
|
||||
if request.method == "POST"
|
||||
and json.loads(request.content).get("method") == method
|
||||
)
|
||||
|
||||
discover = request_for("server/discover")
|
||||
initialize = request_for("initialize")
|
||||
list_tools = request_for("tools/list")
|
||||
|
||||
assert discover.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
|
||||
assert discover.headers["mcp-method"] == "server/discover"
|
||||
|
||||
assert "mcp-protocol-version" not in initialize.headers
|
||||
assert "mcp-method" not in initialize.headers
|
||||
initialize_body = json.loads(initialize.content)
|
||||
assert initialize_body["params"]["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
|
||||
assert list_tools.headers["mcp-protocol-version"] == LATEST_HANDSHAKE_VERSION
|
||||
assert "mcp-method" not in list_tools.headers
|
||||
assert list_tools.headers["mcp-session-id"] != "frontend-session"
|
||||
|
||||
for request in (discover, initialize, list_tools):
|
||||
assert request.headers["authorization"] == "Bearer frontend-token"
|
||||
assert request.headers["x-proxy-custom"] == "preserved"
|
||||
assert "mcp-name" not in request.headers
|
||||
assert "mcp-param-tenant" not in request.headers
|
||||
assert request.headers.get("mcp-session-id") != "frontend-session"
|
||||
assert "last-event-id" not in request.headers
|
||||
|
|
@ -636,6 +636,22 @@ class TestMultiServerConfigEraMirroring:
|
|||
"""Two entries so the transport takes its multi-server composite path."""
|
||||
return {"mcpServers": {"a": {"url": url}, "b": {"url": url}}}
|
||||
|
||||
async def test_direct_multi_server_client_drives_modern_guard_round_trips(self):
|
||||
"""The config-based client itself stays modern through every backend leg."""
|
||||
async with run_server_async(_era_reporting_backend()) as url:
|
||||
asked: list[str] = []
|
||||
async with Client(
|
||||
self._config(url),
|
||||
elicitation_handler=_two_answer_handler(asked),
|
||||
) as client:
|
||||
era = await client.call_tool("a_backend_era", {})
|
||||
result = await client.call_tool("a_book_flight", {})
|
||||
|
||||
assert client.protocol_version is None
|
||||
assert era.data == "2026-07-28"
|
||||
assert result.data == "Booked Paris on 2026-08-01"
|
||||
assert len(asked) == 2
|
||||
|
||||
async def test_modern_front_reaches_modern_backends(self):
|
||||
"""A modern front reaches each real backend on a modern session, and a
|
||||
backend guard tool round-trips end to end across both proxy hops."""
|
||||
|
|
|
|||
|
|
@ -436,6 +436,18 @@ class TestBM25Index:
|
|||
index.build(["alpha beta gamma"])
|
||||
assert index.query("zzz", 5) == []
|
||||
|
||||
def test_unicode_tokens(self):
|
||||
index = _BM25Index()
|
||||
index.build(["show portfolio", "показать портфель"])
|
||||
assert index.query("портфель", 5) == [1]
|
||||
|
||||
def test_unicode_normalization(self):
|
||||
index = _BM25Index()
|
||||
index.build(["Straße PORTFOLIO cafe\u0301"])
|
||||
assert index.query("STRASSE", 5) == [0]
|
||||
assert index.query("portfolio", 5) == [0]
|
||||
assert index.query("café", 5) == [0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# call_tool self-reference guard
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, patch
|
|||
import psutil
|
||||
import pytest
|
||||
from mcp_types import TextContent
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
|
@ -39,6 +40,7 @@ from fastmcp.mcp_config import (
|
|||
TransformingStdioMCPServer,
|
||||
)
|
||||
from fastmcp.server.elicitation import AcceptedElicitation
|
||||
from fastmcp.server.providers.proxy import ProxyClient
|
||||
from fastmcp.tools.base import Tool as FastMCPTool
|
||||
|
||||
# Some tests in this module spawn subprocess servers via stdio, each paying a
|
||||
|
|
@ -100,13 +102,26 @@ class InMemoryStdioMCPServer(StdioMCPServer):
|
|||
return FastMCPTransport(mcp=self.mcp)
|
||||
|
||||
|
||||
class TestConfigTransportLegacyOnly:
|
||||
"""`MCPConfigTransport.legacy_only` gating (regression for the over-broad flag).
|
||||
class LegacyFastMCPTransport(FastMCPTransport):
|
||||
"""In-memory transport that requires the handshake protocol era."""
|
||||
|
||||
legacy_only = True
|
||||
|
||||
|
||||
class LegacyInMemoryStdioMCPServer(InMemoryStdioMCPServer):
|
||||
"""In-memory config entry that behaves like a legacy-only backend."""
|
||||
|
||||
def to_transport(self) -> FastMCPTransport:
|
||||
return LegacyFastMCPTransport(mcp=self.mcp)
|
||||
|
||||
|
||||
class TestConfigTransportEraNegotiation:
|
||||
"""`MCPConfigTransport` negotiates one era across every connection leg.
|
||||
|
||||
A single-server config delegates directly to the underlying transport with no
|
||||
proxy, so it must mirror that transport's era capability rather than being
|
||||
forced legacy. Only the multi-server composite (backed by legacy-era
|
||||
ProxyClients) is legacy-only.
|
||||
proxy. A multi-server config discovers its backends before the composite client
|
||||
negotiates, allowing an all-modern configuration to stay modern and a mixed
|
||||
configuration to fall back consistently to the handshake era.
|
||||
"""
|
||||
|
||||
def test_single_modern_capable_server_is_not_forced_legacy(self):
|
||||
|
|
@ -129,8 +144,7 @@ class TestConfigTransportLegacyOnly:
|
|||
assert isinstance(transport.transport, SSETransport)
|
||||
assert transport.legacy_only is True
|
||||
|
||||
def test_multi_server_config_is_legacy_only(self):
|
||||
"""A multi-server composite is legacy-only regardless of backend eras."""
|
||||
def test_multi_server_config_is_not_assumed_legacy_before_connect(self):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"a": {"url": "https://a.example.com/mcp"},
|
||||
|
|
@ -138,7 +152,7 @@ class TestConfigTransportLegacyOnly:
|
|||
},
|
||||
}
|
||||
transport = MCPConfigTransport(config)
|
||||
assert transport.legacy_only is True
|
||||
assert transport.legacy_only is False
|
||||
|
||||
def test_transforming_single_server_wrapper_is_legacy_only(self):
|
||||
"""A single-server config that uses tool transforms or tag filters wraps
|
||||
|
|
@ -158,6 +172,151 @@ class TestConfigTransportLegacyOnly:
|
|||
assert transport.legacy_only is True
|
||||
|
||||
|
||||
def _make_protocol_era_server(name: str) -> FastMCP:
|
||||
server = FastMCP(name)
|
||||
|
||||
@server.tool
|
||||
async def protocol_era(ctx: Context) -> str:
|
||||
assert ctx.request_context is not None
|
||||
return ctx.request_context.protocol_version
|
||||
|
||||
@server.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def test_multi_server_auto_negotiates_modern_end_to_end():
|
||||
"""Modern backends keep the default multi-server client modern end to end."""
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")),
|
||||
"beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")),
|
||||
}
|
||||
)
|
||||
|
||||
async with Client(config) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
|
||||
tools = await client.list_tools()
|
||||
assert {tool.name for tool in tools} == {
|
||||
"alpha_add",
|
||||
"alpha_protocol_era",
|
||||
"beta_add",
|
||||
"beta_protocol_era",
|
||||
}
|
||||
|
||||
alpha_era = await client.call_tool("alpha_protocol_era", {})
|
||||
beta_era = await client.call_tool("beta_protocol_era", {})
|
||||
result = await client.call_tool("alpha_add", {"a": 2, "b": 3})
|
||||
|
||||
assert alpha_era.data == "2026-07-28"
|
||||
assert beta_era.data == "2026-07-28"
|
||||
assert result.data == 5
|
||||
|
||||
|
||||
async def test_multi_server_auto_falls_back_all_legs_when_one_backend_is_legacy():
|
||||
"""A mixed config never leaves the composite and its backends on different eras."""
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"modern": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("modern")),
|
||||
"legacy": LegacyInMemoryStdioMCPServer(
|
||||
mcp=_make_protocol_era_server("legacy")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
async with Client(config) as client:
|
||||
assert client.protocol_version not in MODERN_PROTOCOL_VERSIONS
|
||||
modern_era = await client.call_tool("modern_protocol_era", {})
|
||||
legacy_era = await client.call_tool("legacy_protocol_era", {})
|
||||
|
||||
assert modern_era.data not in MODERN_PROTOCOL_VERSIONS
|
||||
assert legacy_era.data not in MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "is_modern"),
|
||||
[("legacy", False), ("2026-07-28", True)],
|
||||
)
|
||||
async def test_multi_server_explicit_mode_reaches_every_backend(mode, is_modern):
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")),
|
||||
"beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")),
|
||||
}
|
||||
)
|
||||
|
||||
async with Client(config, mode=mode) as client:
|
||||
alpha_era = await client.call_tool("alpha_protocol_era", {})
|
||||
beta_era = await client.call_tool("beta_protocol_era", {})
|
||||
|
||||
assert (client.protocol_version in MODERN_PROTOCOL_VERSIONS) is is_modern
|
||||
|
||||
assert (alpha_era.data in MODERN_PROTOCOL_VERSIONS) is is_modern
|
||||
assert (beta_era.data in MODERN_PROTOCOL_VERSIONS) is is_modern
|
||||
|
||||
|
||||
async def test_multi_server_proxy_client_auto_negotiates_modern_end_to_end():
|
||||
"""ProxyClient keeps its proxy options without losing the aggregate era."""
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")),
|
||||
"beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")),
|
||||
}
|
||||
)
|
||||
|
||||
async with ProxyClient(config, mode="auto") as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
alpha_era = await client.call_tool("alpha_protocol_era", {})
|
||||
beta_era = await client.call_tool("beta_protocol_era", {})
|
||||
|
||||
assert alpha_era.data == "2026-07-28"
|
||||
assert beta_era.data == "2026-07-28"
|
||||
|
||||
|
||||
async def test_multi_server_mode_is_resolved_when_proxy_client_connects():
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")),
|
||||
"beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")),
|
||||
}
|
||||
)
|
||||
client = ProxyClient(config, mode="legacy")
|
||||
client.mode = "auto"
|
||||
|
||||
async with client:
|
||||
alpha_era = await client.call_tool("alpha_protocol_era", {})
|
||||
beta_era = await client.call_tool("beta_protocol_era", {})
|
||||
|
||||
assert alpha_era.data == "2026-07-28"
|
||||
assert beta_era.data == "2026-07-28"
|
||||
|
||||
|
||||
async def test_multi_server_shape_is_resolved_when_client_connects():
|
||||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha"))
|
||||
}
|
||||
)
|
||||
client = Client(config)
|
||||
config.add_server(
|
||||
"beta", InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta"))
|
||||
)
|
||||
|
||||
async with client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
tools = await client.list_tools()
|
||||
|
||||
assert {tool.name for tool in tools} == {
|
||||
"alpha_add",
|
||||
"alpha_protocol_era",
|
||||
"beta_add",
|
||||
"beta_protocol_era",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_single_stdio_config():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
|
|
@ -927,7 +1086,9 @@ async def test_multi_client_with_elicitation():
|
|||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"test_server": InMemoryStdioMCPServer(mcp=_make_elicit_server()),
|
||||
"test_server_2": InMemoryStdioMCPServer(mcp=_make_elicit_server()),
|
||||
# One legacy-only backend makes the aggregate reconnect every leg
|
||||
# under the handshake era, where server-initiated elicitation works.
|
||||
"test_server_2": LegacyInMemoryStdioMCPServer(mcp=_make_elicit_server()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1046,7 +1207,9 @@ async def test_multi_server_session_persistence():
|
|||
config = MCPConfig(
|
||||
mcpServers={
|
||||
"server1": InMemoryStdioMCPServer(mcp=_make_session_server()),
|
||||
"server2": InMemoryStdioMCPServer(mcp=_make_session_server()),
|
||||
# Session identity is a handshake-era feature. A legacy-only sibling
|
||||
# verifies aggregate auto-negotiation preserves it on every backend.
|
||||
"server2": LegacyInMemoryStdioMCPServer(mcp=_make_session_server()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ class TestFormatFunctions:
|
|||
|
||||
# Check server version in MCP format
|
||||
assert data["serverInfo"]["version"] == "2.0.0"
|
||||
assert data["instructions"] == "Test instructions"
|
||||
|
||||
# MCP format SHOULD have environment fields
|
||||
assert "environment" in data
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue