mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 23:29:10 +02:00
Compare commits
530 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
||
|
|
1eedd1f6f1 |
||
|
|
3b1afe6cf1 |
||
|
|
874425a113 |
||
|
|
691766b5d0 |
||
|
|
47907e0767 |
||
|
|
c1b0396c0a |
||
|
|
522ed5bb30 |
||
|
|
67527c1f69 |
||
|
|
57a279928d |
||
|
|
cccb529f50 |
||
|
|
0d8844d5a7 |
||
|
|
de521e651d |
||
|
|
feaae683a4 |
||
|
|
5de15e0c21 |
||
|
|
a1dd8b12e1 |
||
|
|
6b2a2d507a |
||
|
|
a8bb1b08c2 |
||
|
|
0668eb7b80 |
||
|
|
4d5627ba5d |
||
|
|
7643480bd6 |
||
|
|
dc4b0e202d |
||
|
|
cccff4849e |
||
|
|
0cffe41115 |
||
|
|
a612846224 |
||
|
|
ade2b3ff46 |
||
|
|
094908042c |
||
|
|
a13e48ea6f |
||
|
|
7f2d034f4d |
||
|
|
ea63d06241 |
||
|
|
0d5667bed2 |
||
|
|
ba77e4626b |
||
|
|
222742a737 |
||
|
|
6429909f05 |
||
|
|
27a0720928 |
||
|
|
47101eeccd |
||
|
|
cf1d821129 |
||
|
|
ddcdf64813 |
||
|
|
5fa4f32cca |
||
|
|
0ca2c0d115 |
||
|
|
9b1a1a04e0 |
||
|
|
d7730788fa |
||
|
|
4ce8e2a5d0 |
||
|
|
257fe32556 |
||
|
|
3b8538e242 |
||
|
|
0445c31fef |
||
|
|
9261793ae0 |
||
|
|
e1b52d00fe |
||
|
|
e58f386bac |
||
|
|
3f09c683cc |
||
|
|
e124bde34e |
||
|
|
dae11bbc40 |
||
|
|
0f4f78c464 |
||
|
|
1a06130fcf |
||
|
|
58e0f5320b |
||
|
|
8e66b0a47a |
||
|
|
53b20168c8 |
||
|
|
989f6f8bde |
||
|
|
140d96aadd |
||
|
|
e9848d66a3 |
||
|
|
a25ca12a41 |
||
|
|
802ceaaa6b |
||
|
|
2bff3725bf |
||
|
|
986353f26b |
||
|
|
59223f6e89 |
||
|
|
70013c5a91 |
||
|
|
b8c9d58d61 |
||
|
|
9b5d402bf2 |
||
|
|
6acacf2191 |
||
|
|
292e9af087 |
||
|
|
44180d1e3d |
||
|
|
986af54afa |
||
|
|
7a82b57efb |
||
|
|
e242abee7d |
||
|
|
8382569027 |
||
|
|
0e5ec37597 |
||
|
|
fd4d24ff62 |
||
|
|
b194349983 |
||
|
|
2ac23a4de5 |
||
|
|
1222e8e47a |
||
|
|
b4c35027c2 |
||
|
|
dbf5ee24be |
||
|
|
81a16ed27f |
||
|
|
0022d8518f |
||
|
|
9d384ffa7f |
||
|
|
bdbef49383 |
||
|
|
2a262438fa |
||
|
|
1c4069efa3 |
||
|
|
b0fb2c3ae6 |
||
|
|
7cfcaf22b8 |
||
|
|
834f96d462 |
||
|
|
5c2627cb15 |
||
|
|
2d61d8a46b |
||
|
|
01b971d80d |
||
|
|
d6984f4719 |
||
|
|
7fddab52a9 |
||
|
|
344a4f81b5 |
||
|
|
11951585c0 |
||
|
|
24b594b1e3 |
||
|
|
d3ff774c78 |
||
|
|
d8dcc273ca |
||
|
|
255e3e4910 |
||
|
|
73df4dcaee |
||
|
|
ee48a0fd6e |
||
|
|
bb4894d215 |
||
|
|
8209093871 |
||
|
|
cf59a4511f |
||
|
|
89b99ecfb9 |
||
|
|
310314cf14 |
||
|
|
28722f846a |
||
|
|
567b832bca |
||
|
|
6b6db33c4a |
||
|
|
3d476ca01e |
||
|
|
aaff9243a3 |
||
|
|
b8597f941d |
||
|
|
4719f3055a |
||
|
|
61e56c683e |
||
|
|
074189265d |
||
|
|
a037bd8cc7 |
||
|
|
0b59af9489 |
||
|
|
44d3f8d858 |
||
|
|
20359de953 |
||
|
|
1cfd30840d |
||
|
|
73b7f2e44d |
||
|
|
51e339d8ed |
||
|
|
2ffe68cfa1 |
||
|
|
f5dea2a17c |
||
|
|
d0315974fa |
||
|
|
b96ce24e35 |
||
|
|
6f3ea0b929 |
||
|
|
a010927ea5 |
||
|
|
0fe01372f0 |
||
|
|
c740b6d70a |
||
|
|
4be46c6fa6 |
||
|
|
07c34acb12 |
||
|
|
e95efce988 |
||
|
|
74efa32edf |
||
|
|
485747353e |
||
|
|
008f91d84b |
||
|
|
eebdc8c031 |
||
|
|
2d6143c6d8 |
||
|
|
db6bc3e94c |
||
|
|
34f6e68826 |
||
|
|
5009d64465 |
||
|
|
a075b14432 |
||
|
|
801385df44 |
||
|
|
64fbc52e1d |
||
|
|
c759890d9e |
||
|
|
b4c4cfd3d5 |
||
|
|
37a46231fd |
||
|
|
97bff96ef1 |
||
|
|
1e67c53a17 |
||
|
|
ff8aa484ff |
||
|
|
e1ea695d68 |
||
|
|
789a298650 |
||
|
|
7fb037f20c |
||
|
|
eb1426251c |
||
|
|
98f69bdba0 |
||
|
|
e3f845f558 |
||
|
|
34313ea112 |
||
|
|
ac1416bd2e |
||
|
|
39b421a464 |
||
|
|
5593cf3e11 |
||
|
|
012f674ee4 |
||
|
|
7dd573980a |
||
|
|
970b92bb16 |
||
|
|
7184a4ca21 |
||
|
|
e4bb6666ab |
||
|
|
8c020adfc6 |
||
|
|
8d1b28958f |
||
|
|
4ea102b433 |
||
|
|
757678bc7e |
||
|
|
55f3cade10 |
||
|
|
7d7607473d |
||
|
|
b732a4a516 |
||
|
|
5c2ff1bd7b |
||
|
|
f4f2ec07fb |
||
|
|
338b80c3ae |
||
|
|
110cd3adcb |
||
|
|
3117846176 |
||
|
|
031c7e03b4 |
||
|
|
200d79e7d2 |
||
|
|
82f310fe61 |
||
|
|
c2dafc1c88 |
||
|
|
e66cce2538 |
||
|
|
db6d7a8a61 |
||
|
|
9a063963f4 |
||
|
|
a6bd66aca8 |
||
|
|
f21e51794c |
||
|
|
99bf81c64f |
||
|
|
279b601f80 |
||
|
|
74d09ee0f5 |
||
|
|
0a921f5372 |
||
|
|
e8f2fefb06 |
||
|
|
fa9ad6cb81 |
||
|
|
f23599283c |
||
|
|
9a447cb08d |
||
|
|
673e6bb0f7 |
||
|
|
57f1b1bced |
||
|
|
dfe9b307bd |
||
|
|
1d39e26025 |
||
|
|
7f80f78906 |
||
|
|
82253ad0f5 |
||
|
|
6a82eb3ead |
||
|
|
9264728330 |
||
|
|
06f58eb617 |
||
|
|
683ce919e3 |
||
|
|
56456cb188 |
||
|
|
f3c00ba1b7 |
||
|
|
fb03e85592 |
||
|
|
cae9333f4f |
||
|
|
d5a3d54662 |
||
|
|
f71680c427 |
||
|
|
73deedf3a4 |
||
|
|
901453902f |
||
|
|
d6b55c0b2f |
||
|
|
f26c8fad3e |
||
|
|
af957e773f |
||
|
|
671eaf0f03 |
||
|
|
74797c8cac |
||
|
|
9e2e602038 |
||
|
|
7825355b98 |
||
|
|
b99b050e80 |
||
|
|
95102c7d7c |
||
|
|
d0bcec979c |
||
|
|
790f0bcb47 |
||
|
|
4b59e0d94b |
||
|
|
468559978a |
||
|
|
eec52b1f02 |
||
|
|
c946664a16 |
||
|
|
db9e2685fb |
||
|
|
faf5f86e09 |
||
|
|
ce9c4bcd53 |
1427 changed files with 134135 additions and 50865 deletions
168
.claude/skills/review-issue/SKILL.md
Normal file
168
.claude/skills/review-issue/SKILL.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
name: review-issue
|
||||
description: Review an incoming external issue (and any gated-closed PR behind it) and decide whether to assign the contributor or decline. Use when the maintainer says "look at this issue", "review issue #N", "should we take this", or asks whether to assign someone. Assigning the author auto-reopens their PR for normal review. This is the entry point for incoming-issue triage — distinct from review-pr, which responds to bot reviews on your own open PR.
|
||||
---
|
||||
|
||||
# Triaging contributions under the issue-link gate
|
||||
|
||||
FastMCP auto-closes external PRs unless the author is **assigned to a referenced issue**
|
||||
(see [require-issue-link.yml](../../../.github/workflows/require-issue-link.yml)). The practical
|
||||
effect: contributors open an issue, open a PR, get auto-closed, and ask to be assigned. The
|
||||
maintainer almost never sees the PR directly — **the issue is the decision point**, and
|
||||
**assigning the author is the single action that reopens their PR** and sends it into review.
|
||||
|
||||
This skill turns "look at this issue" into one of two outcomes:
|
||||
- **Assign** — the issue is valid, we want it fixed, an external PR is appropriate, and a sound
|
||||
PR already exists → assign the author (auto-reopens the PR) and queue it for code review.
|
||||
- **Decline** — leave the issue/PR closed and explain why on the issue.
|
||||
|
||||
Be opinionated about declining. The gate moved spam from junk PRs to junk issues; this skill is
|
||||
worthless if it just rubber-stamps assignment. Assignment is a commitment to review and likely
|
||||
merge, not a courtesy.
|
||||
|
||||
## How the gate works (the part that matters here)
|
||||
|
||||
- External PR is closed unless its body has `Fixes/Closes/Resolves #N` **and** the author is
|
||||
assigned to issue `#N`.
|
||||
- **Assigning the author to the issue auto-reopens their closed PR** and re-runs the check —
|
||||
this is the lever you pull. `gh issue edit N --add-assignee <login>`. The assignment fires a
|
||||
`require-issue-link` run; expect it to pass. If it fails, the gate itself misbehaved (not the
|
||||
PR) — investigate the run, don't re-assign.
|
||||
- Maintainer-authored PRs are exempt. A `trusted-contributor` label exempts a contributor up
|
||||
front. Reopening the PR or removing the `missing-issue-link` label applies a sticky
|
||||
`bypass-issue-check`.
|
||||
- Sibling bots have usually already run on the issue: `marvin-triage-issue` (investigates +
|
||||
recommends), `marvin-dedupe-issues` / `auto-close-duplicates` (dupes), `auto-close-needs-mre`
|
||||
(missing MRE). Read their comments before re-deriving anything.
|
||||
|
||||
## Step 1 — Orient
|
||||
|
||||
Read the issue, its bot triage, and any PR behind it. Run these together:
|
||||
|
||||
```bash
|
||||
gh issue view N --repo PrefectHQ/fastmcp \
|
||||
--json number,title,state,author,body,labels,assignees,comments
|
||||
# Find PRs the author opened that reference this issue (they're likely CLOSED):
|
||||
gh pr list --repo PrefectHQ/fastmcp --state all --search "author:<login> #N in:body" \
|
||||
--json number,title,state,url,labels
|
||||
```
|
||||
|
||||
If a PR exists, pull its metadata and any review-bot comments (CodeRabbit, Codex). Treat the bot
|
||||
comments as leads, not conclusions — they often don't run on closed PRs at all, and even when
|
||||
they do you still owe the PR your own read:
|
||||
|
||||
```bash
|
||||
gh pr view <pr> --repo PrefectHQ/fastmcp --json number,title,body,labels,files,additions,deletions
|
||||
gh pr view <pr> --repo PrefectHQ/fastmcp --comments
|
||||
```
|
||||
|
||||
## Step 2 — Classify the issue (is it valid AND a real bug?)
|
||||
|
||||
- Is there a real, reproducible problem? For bugs, demand an MRE that shows FastMCP misbehaving
|
||||
— not user config error, not a question, not an upstream-SDK issue.
|
||||
- Is it a duplicate or already fixed on `main`? Check the dedupe bot's comment and recent commits.
|
||||
- If the issue itself is weak, **stop here and decline** — don't evaluate the PR. A good PR
|
||||
attached to a bad issue is still declined.
|
||||
|
||||
**A reproducible MRE is not the same as a bug.** This is the trap that produces wrong verdicts:
|
||||
an MRE can demonstrate real, observable behavior that is nonetheless *not a bug*, because it
|
||||
violates no contract the framework intends to hold. The decisive question is not "does this
|
||||
reproduce?" but "does the demonstrated behavior violate the intended contract for this API?" A
|
||||
shared-mutable-state MRE only matters if callers are *supposed* to mutate that state; an
|
||||
ordering/timing MRE only matters if the framework promises an order; a "wrong" value only matters
|
||||
relative to what the API guarantees. An MRE that has to reach past the supported surface to
|
||||
trigger the behavior (mutating a field meant to be set only at construction, depending on an
|
||||
internal that isn't part of the public contract) is showing you a property, not a defect.
|
||||
|
||||
You usually cannot read the intended contract off the code — the code shows what it *does*, not
|
||||
what it *promises*. **The maintainer is often the only authoritative source for the contract, so
|
||||
stopping to ask is legitimate and expected here.** Ask "is X a supported pattern / does this API
|
||||
promise Y?" before sinking time into investigating a fix. If the behavior is in-contract correct,
|
||||
decline — no matter how cleanly the PR fixes it, and no matter how real the MRE looks.
|
||||
|
||||
## Step 3 — Investigate the PR (mandatory; do NOT skip if a PR exists)
|
||||
|
||||
The most common failure of this skill is judging a PR from the diff hunk and the PR description
|
||||
alone. That is a cursory review and it produces wrong verdicts — a redundant-looking conditional
|
||||
can be a real bug fix; a tidy-looking diff can patch the wrong layer. **You cannot assess a PR
|
||||
without reading the code it changes in context.** Reading `gh pr diff` is necessary but never
|
||||
sufficient.
|
||||
|
||||
Do all of this before forming any opinion on quality:
|
||||
|
||||
1. **Read the diff in full**, then **open every file it touches in the repo** (`Read`, not just
|
||||
the patch). The hunk shows *what changed*; the file shows *what it changed into*.
|
||||
2. **Trace the functions and values the change depends on.** Grep for the called functions,
|
||||
the fields being set, and the defaults. If the PR overrides or replaces a value, find what
|
||||
produced the original value and what consumes it downstream.
|
||||
3. **Establish the actual root cause from the issue's MRE**, then check whether the change fixes
|
||||
*that* — at the layer where the bug originates, not a compensating patch elsewhere.
|
||||
4. **Check consistency with adjacent code.** Does the new value/behavior match how nearby code
|
||||
already handles the same case? An inconsistency is a real finding; a match is evidence the fix
|
||||
is correct.
|
||||
5. **Run or read the tests** the PR adds/changes — do they actually exercise the bug, and would
|
||||
they fail without the fix?
|
||||
|
||||
Write down, for yourself, a one-line answer to: *what was broken, where, and does this change fix
|
||||
it there?* If you can't answer from evidence you've actually read, you haven't investigated yet.
|
||||
|
||||
Then separate findings by severity: a **cosmetic** nit (style, a redundant-but-harmless line) is a
|
||||
review comment, not a blocker. A **substantive** defect (wrong layer, breaks an adjacent path,
|
||||
doesn't actually fix the MRE) changes the verdict. Don't let a cosmetic nit read as a reason to
|
||||
decline, and don't let a clean style read as evidence of correctness.
|
||||
|
||||
## Step 4 — Decide if an external PR is appropriate (CONTRIBUTING.md)
|
||||
|
||||
This is the gate CONTRIBUTING.md actually enforces. Map the change to a category:
|
||||
|
||||
- **Simple, well-scoped bug fix** → external PR welcome. Assignable.
|
||||
- **Docs / typo / example fix** → welcome. Assignable.
|
||||
- **Auth provider** → assignable (auth is the one integration exception).
|
||||
- **Enhancement / feature** → needs a maintainer-approved design proposal *in the issue first*.
|
||||
Do **not** assign just because code exists. If the proposal is sound, the path is "approve the
|
||||
approach in the issue, then assign" — not "assign because they were fast."
|
||||
- **Third-party integration** (middleware, provider adapters, non-auth) → decline; belongs in a
|
||||
separate package.
|
||||
- **Sweeping / multi-subsystem change with no prior discussion** → decline.
|
||||
|
||||
Combine the category with the Step 3 investigation: does it fix the cause or paper over a symptom?
|
||||
Does it read like unedited LLM output (verbose body, speculative/shotgun changes)? CONTRIBUTING.md
|
||||
says we close those — a closed PR that reads that way is staying closed.
|
||||
|
||||
## Step 5 — Recommend, then act
|
||||
|
||||
Present a short verdict to the maintainer before mutating anything: **assign** or **decline**,
|
||||
one or two sentences of reasoning, and the exact command you'll run. Wait for confirmation on
|
||||
borderline calls; for clear-cut ones you may proceed and report.
|
||||
|
||||
**Assign** (valid issue + appropriate external contribution + sound PR exists):
|
||||
|
||||
```bash
|
||||
gh issue edit N --repo PrefectHQ/fastmcp --add-assignee <login>
|
||||
```
|
||||
|
||||
That reopens the PR automatically. Then hand off to code review — invoke the `code-review` /
|
||||
`review-pr` skills on the reopened PR. Assignment is not approval; the code still gets the normal
|
||||
pass.
|
||||
|
||||
If a PR's head branch was deleted, assignment can't reopen it — the workflow comments asking the
|
||||
author to open a fresh PR. Don't try to force it.
|
||||
|
||||
**Decline** (invalid issue, wrong contribution type, or low-quality PR): leave it closed and
|
||||
comment on the **issue** explaining the decision, pointing to the relevant CONTRIBUTING.md
|
||||
section. Per repo rules, use `--body-file`, never inline `--body`, for any comment that could
|
||||
contain `$`, backticks, or code:
|
||||
|
||||
```bash
|
||||
gh issue comment N --repo PrefectHQ/fastmcp --body-file /tmp/triage-reply.md
|
||||
```
|
||||
|
||||
Keep the reply short and point to the relevant CONTRIBUTING.md section. (If a `github-reply`
|
||||
skill is available for maintainer voice/tone, use it — but it isn't required.)
|
||||
|
||||
## What this skill does NOT do
|
||||
|
||||
- It doesn't bypass the gate via `trusted-contributor` / `bypass-issue-check` — that's a
|
||||
deliberate maintainer escalation, not a triage outcome.
|
||||
- It doesn't merge. Assignment → reopen → review → (maybe) merge are distinct steps.
|
||||
- It doesn't re-run the first-pass triage the bots already did; read their output instead.
|
||||
|
|
@ -94,6 +94,12 @@ After evaluating comments:
|
|||
|
||||
Codex sometimes re-posts old comments that reference code you've already fixed (they appear on the old commit's diff). These are stale — verify the fix is in the latest commit and reply noting the fix is already in place.
|
||||
|
||||
## Labels — never apply or invent them
|
||||
|
||||
**Do not apply labels to PRs or issues programmatically, and never create new ones.** Issues and PRs in this repo are auto-labeled by a bot based on title, body, and code changes — there's no fixed canonical list to match against, and GitHub's "add labels" API auto-creates any label name that doesn't already exist, so a typo or guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings.
|
||||
|
||||
Don't call out a "suggested" or "appropriate" label in the PR body either — the bot doesn't read it, and it just adds noise.
|
||||
|
||||
## When a PR is ready
|
||||
|
||||
A PR is ready for human review when:
|
||||
|
|
|
|||
9
.github/actions/run-claude/action.yml
vendored
9
.github/actions/run-claude/action.yml
vendored
|
|
@ -37,10 +37,15 @@ inputs:
|
|||
required: false
|
||||
default: ""
|
||||
|
||||
extra-allowed-tools:
|
||||
description: "Additional comma-separated tools to append to allowed-tools"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
model:
|
||||
description: "Model to use for Claude"
|
||||
required: false
|
||||
default: "claude-opus-4-6"
|
||||
default: "claude-opus-4-8"
|
||||
|
||||
allowed-bots:
|
||||
description: "Allowed bot usernames, or '*' for all bots"
|
||||
|
|
@ -88,7 +93,7 @@ runs:
|
|||
track_progress: ${{ inputs.track-progress }}
|
||||
prompt: ${{ inputs.prompt }}
|
||||
claude_args: |
|
||||
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
|
||||
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
|
||||
${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }}
|
||||
--model ${{ inputs.model }}
|
||||
settings: |
|
||||
|
|
|
|||
22
.github/actions/run-pytest/action.yml
vendored
22
.github/actions/run-pytest/action.yml
vendored
|
|
@ -19,7 +19,7 @@ runs:
|
|||
MAX_PROCS="2"
|
||||
EXTRA_FLAGS=""
|
||||
elif [ "${{ inputs.test-type }}" == "client_process" ]; then
|
||||
MARKER="client_process"
|
||||
MARKER="client_process or subprocess_heavy"
|
||||
TIMEOUT="5"
|
||||
MAX_PROCS="0"
|
||||
EXTRA_FLAGS="-x"
|
||||
|
|
@ -29,17 +29,33 @@ runs:
|
|||
MAX_PROCS="0"
|
||||
EXTRA_FLAGS="-x"
|
||||
else
|
||||
MARKER="not integration and not client_process and not conformance"
|
||||
MARKER="not integration and not client_process and not subprocess_heavy and not conformance"
|
||||
TIMEOUT="5"
|
||||
MAX_PROCS="4"
|
||||
EXTRA_FLAGS=""
|
||||
fi
|
||||
|
||||
# Windows previously ran serially: parallel workers crashed intermittently
|
||||
# when many tests spawned stdio subprocesses (#2715, reverted in #2726).
|
||||
# Most of those tests now run in-memory, but tests that spawn a fresh
|
||||
# interpreter importing all of FastMCP still crash xdist workers on the
|
||||
# 2-core Windows runners. They carry the subprocess_heavy marker and run
|
||||
# in the serial client_process step instead.
|
||||
PARALLEL_FLAGS=""
|
||||
if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then
|
||||
if [ "$MAX_PROCS" != "0" ]; then
|
||||
PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
|
||||
fi
|
||||
|
||||
# pytest-timeout has no signal-based method on Windows, so it falls back
|
||||
# to the thread method, which dumps stacks and os._exit()s the process.
|
||||
# Under a contended runner that turns a single slow test into a dead
|
||||
# xdist worker, failing whichever unrelated test that worker happened to
|
||||
# be running. Give parallel Windows runs more headroom so ordinary
|
||||
# scheduling jitter does not take a worker down.
|
||||
if [ "$RUNNER_OS" == "Windows" ] && [ "$MAX_PROCS" != "0" ]; then
|
||||
TIMEOUT=$((TIMEOUT * 4))
|
||||
fi
|
||||
|
||||
uv run --no-sync pytest \
|
||||
--inline-snapshot=disable \
|
||||
--timeout=$TIMEOUT \
|
||||
|
|
|
|||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
80
.github/scripts/triage-label.sh
vendored
Executable file
80
.github/scripts/triage-label.sh
vendored
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
# Locked-down label helper for the Marvin triage workflow.
|
||||
#
|
||||
# Marvin runs on untrusted issue/PR bodies from non-write users, so it must
|
||||
# NOT be handed raw `gh api` (that would expose every endpoint the app token
|
||||
# can reach). This helper is the ONLY GitHub write it is allowed to perform:
|
||||
# it adds or removes repository labels on the one issue/PR being triaged.
|
||||
#
|
||||
# The target repo and number come from the environment set by the workflow —
|
||||
# never from the model — and the operation is fixed to the additive labels
|
||||
# endpoint (POST/DELETE /repos/{repo}/issues/{n}/labels), which works for both
|
||||
# issues and PRs and cannot clobber labels applied by other workflows.
|
||||
set -euo pipefail
|
||||
|
||||
repo="${TRIAGE_REPO:?TRIAGE_REPO not set}"
|
||||
number="${TRIAGE_NUMBER:?TRIAGE_NUMBER not set}"
|
||||
|
||||
if [[ ! "$number" =~ ^[0-9]+$ ]]; then
|
||||
echo "TRIAGE_NUMBER must be numeric, got: $number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
op="${1:-}"
|
||||
shift || true
|
||||
case "$op" in
|
||||
add) method=POST ;;
|
||||
remove) method=DELETE ;;
|
||||
*)
|
||||
echo "usage: triage-label.sh <add|remove> <label>..." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
echo "no labels given" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reject anything that isn't a plausible label name. Notably blocks '/' so a
|
||||
# crafted value can't turn the DELETE path into a different endpoint.
|
||||
label_re="^[A-Za-z0-9 ._'-]+$"
|
||||
for label in "$@"; do
|
||||
if [[ ! "$label" =~ $label_re ]]; then
|
||||
echo "refusing suspicious label name: $label" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Never let triage add or remove the Require Issue Link control labels. Those
|
||||
# govern PR enforcement (bypass-issue-check / trusted-contributor are sticky
|
||||
# exemptions, "prs welcome" waives the assignment requirement) and reopening
|
||||
# (missing-issue-link is how closed PRs are found), so a prompt-injected triage
|
||||
# run must not be able to grant an exemption or break recovery. Enforced here —
|
||||
# in code — not merely in the prompt.
|
||||
#
|
||||
# Exact match against array entries, not a substring scan of a joined string:
|
||||
# label names may contain spaces ("prs welcome"), which in a space-delimited
|
||||
# string would also make bare "prs" and "welcome" match.
|
||||
protected=(missing-issue-link bypass-issue-check trusted-contributor "prs welcome")
|
||||
for label in "$@"; do
|
||||
lower="${label,,}"
|
||||
for p in "${protected[@]}"; do
|
||||
if [[ "$lower" == "$p" ]]; then
|
||||
echo "refusing to touch protected control label: $label" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "$method" == POST ]]; then
|
||||
args=()
|
||||
for label in "$@"; do
|
||||
args+=(-f "labels[]=$label")
|
||||
done
|
||||
gh api --method POST "/repos/${repo}/issues/${number}/labels" "${args[@]}"
|
||||
else
|
||||
for label in "$@"; do
|
||||
gh api --method DELETE "/repos/${repo}/issues/${number}/labels/${label}"
|
||||
done
|
||||
fi
|
||||
2
.github/workflows/auto-close-duplicates.yml
vendored
2
.github/workflows/auto-close-duplicates.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Marvin App token
|
||||
id: marvin-token
|
||||
|
|
|
|||
2
.github/workflows/auto-close-needs-mre.yml
vendored
2
.github/workflows/auto-close-needs-mre.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Marvin App token
|
||||
id: marvin-token
|
||||
|
|
|
|||
33
.github/workflows/marvin-comment-on-issue.yml
vendored
33
.github/workflows/marvin-comment-on-issue.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install UV
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
|
@ -51,6 +51,9 @@ jobs:
|
|||
|
||||
- name: Run Claude for Issue Comment
|
||||
uses: ./.github/actions/run-claude
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github-token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
|
@ -61,13 +64,13 @@ jobs:
|
|||
<context>
|
||||
Repository: ${{ github.repository }}
|
||||
Issue Number: #${{ github.event.issue.number }}
|
||||
Issue Title: ${{ github.event.issue.title }}
|
||||
Issue Title: ${{ env.ISSUE_TITLE }}
|
||||
Issue Author: ${{ github.event.issue.user.login }}
|
||||
Comment Author: ${{ github.event.comment.user.login }}
|
||||
</context>
|
||||
|
||||
<user_request>
|
||||
${{ github.event.comment.body }}
|
||||
${{ env.COMMENT_BODY }}
|
||||
</user_request>
|
||||
|
||||
<task>
|
||||
|
|
@ -75,9 +78,7 @@ jobs:
|
|||
</task>
|
||||
|
||||
<constraints>
|
||||
You CAN: Read/analyze code, modify files, write code, run tests, execute commands
|
||||
You CAN: Commit code, push changes, create branches, create pull requests
|
||||
|
||||
You CAN: Read/analyze code, modify files, write code, run tests, execute commands, commit code, push changes, create branches, create pull requests
|
||||
</constraints>
|
||||
|
||||
<allowed_tools>
|
||||
|
|
@ -107,20 +108,30 @@ jobs:
|
|||
|
||||
<common_tasks>
|
||||
- Answer questions about the codebase
|
||||
- Help debug reported problems (make changes locally to test, cannot push)
|
||||
- Help debug reported problems
|
||||
- Suggest solutions or workarounds
|
||||
- Provide code examples
|
||||
- Help clarify requirements
|
||||
- Link to relevant documentation or code
|
||||
- Create branches, commit changes, and open PRs when asked
|
||||
</common_tasks>
|
||||
|
||||
<response_guidelines>
|
||||
- Be concise and actionable
|
||||
- If the request is unclear, ask clarifying questions
|
||||
- If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do
|
||||
- When making code changes, explain that they are local only and cannot be pushed
|
||||
- Lead with a tl;dr — the bottom line in 1-3 sentences, always visible. The reader should be able to act without expanding anything.
|
||||
- Push supporting detail (code analysis, verification output, related items) into collapsible `<details>` blocks. These are appendices, not the main message.
|
||||
- Short responses (a few sentences) don't need collapsible sections at all.
|
||||
- Be concise and actionable.
|
||||
- If the request is unclear, ask clarifying questions.
|
||||
- Report findings and recommendations — not your process. Do not include task checklists or "steps I took" narration.
|
||||
- Every claim needs evidence: cite file paths, line numbers, or command output. Never say "the code does X" without pointing to where.
|
||||
- If you're uncertain, say so. "I couldn't confirm this" is better than a speculative answer.
|
||||
</response_guidelines>
|
||||
|
||||
<github_safety>
|
||||
- Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues.
|
||||
- When referencing issues, use plain `#N` or link syntax without action keywords.
|
||||
</github_safety>
|
||||
|
||||
<response_footer>
|
||||
Always end your comment with a new line, three dashes, and the footer message:
|
||||
<exact_content>
|
||||
|
|
|
|||
60
.github/workflows/marvin-comment-on-pr.yml
vendored
60
.github/workflows/marvin-comment-on-pr.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout PR head branch
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# do not set to pull_request.head.ref, claude will pull the branch if needed
|
||||
fetch-depth: 0
|
||||
|
|
@ -72,6 +72,8 @@ jobs:
|
|||
PR_REVIEW_HEAD_SHA: ${{ steps.pr-info.outputs.head_sha }}
|
||||
PR_REVIEW_COMMENTS_DIR: /tmp/pr-review-comments
|
||||
PR_REVIEW_HELPERS_DIR: ${{ github.workspace }}/.github/scripts/pr-review
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github-token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
|
@ -82,7 +84,7 @@ jobs:
|
|||
<context>
|
||||
Repository: ${{ github.repository }}
|
||||
PR Number: #${{ steps.pr-info.outputs.pr_number }}
|
||||
PR Title: ${{ github.event.issue.title }}
|
||||
PR Title: ${{ env.PR_TITLE }}
|
||||
PR Author: ${{ github.event.issue.user.login }}
|
||||
Comment Author: ${{ github.event.comment.user.login }}
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ jobs:
|
|||
</context>
|
||||
|
||||
<user_request>
|
||||
${{ github.event.comment.body }}
|
||||
${{ env.COMMENT_BODY }}
|
||||
</user_request>
|
||||
|
||||
<task>
|
||||
|
|
@ -98,12 +100,10 @@ jobs:
|
|||
</task>
|
||||
|
||||
<constraints>
|
||||
This workflow allows read, write, and execute capabilities but cannot push changes.
|
||||
You CAN: Read/analyze code, modify files, write code, run tests, execute commands, resolve review threads, commit and push changes to the PR branch, checkout branches
|
||||
You CANNOT: Create new branches unrelated to this PR, create new pull requests
|
||||
|
||||
You CAN: Read/analyze code, modify files, write code, run tests, execute commands, resolve review threads
|
||||
You CANNOT: Commit code, push changes, create branches, checkout branches, create pull requests
|
||||
|
||||
**Important**: You cannot push changes to the repository - you can only make changes locally and provide feedback or recommendations.
|
||||
When making changes, commit and push to the PR's head branch so the author gets the fix directly.
|
||||
</constraints>
|
||||
|
||||
<allowed_tools>
|
||||
|
|
@ -132,10 +132,10 @@ jobs:
|
|||
</investigation_approach>
|
||||
|
||||
<common_tasks>
|
||||
- Address review feedback and fix issues (make changes locally, cannot push)
|
||||
- Address review feedback and fix issues (commit and push to the PR branch)
|
||||
- Answer questions about the changes
|
||||
- Make additional code changes (local only)
|
||||
- Resolve review threads after addressing feedback (if changes are made separately)
|
||||
- Make code changes and push them
|
||||
- Resolve review threads after addressing feedback
|
||||
- Perform PR reviews when asked (use the PR review process below)
|
||||
</common_tasks>
|
||||
|
||||
|
|
@ -224,6 +224,25 @@ jobs:
|
|||
6. Breaking changes to public APIs without migration path
|
||||
7. Missing or incorrect test coverage for critical paths
|
||||
</review_criteria>
|
||||
|
||||
<review_calibration>
|
||||
**What NOT to flag** — do not comment on:
|
||||
- Issues in unchanged code (only review the diff)
|
||||
- Input already validated or sanitized at a different layer
|
||||
- Theoretical performance concerns without evidence that N is large
|
||||
- Style or formatting not in the project's linting rules
|
||||
- Missing tests for trivial or generated code
|
||||
- Pre-existing patterns the PR is following consistently
|
||||
|
||||
**Calibration examples**:
|
||||
- Unguarded return from a lookup (e.g., `tool = registry.get(name)` used without None check) → FLAG if the diff introduces the unguarded usage
|
||||
- Same pattern, but the function's return type is `Tool` (not `Optional[Tool]`) → DO NOT FLAG, the type system guarantees non-None
|
||||
- String interpolation in a query with user input → FLAG
|
||||
- String interpolation in a query with a hardcoded enum value → DO NOT FLAG
|
||||
- O(n²) loop → FLAG only if there's evidence N can be large (e.g., user-controlled list). If N is bounded by design (e.g., number of MCP tools), do not flag.
|
||||
|
||||
When in doubt, do not flag. A false positive wastes a reviewer's time and erodes trust in every future review comment.
|
||||
</review_calibration>
|
||||
</pr_review_guidance>
|
||||
|
||||
<review_thread_tools>
|
||||
|
|
@ -244,14 +263,18 @@ jobs:
|
|||
- `THREAD_ID` is the GraphQL node ID from the review threads output (e.g., `PRRT_kwDOABC123`)
|
||||
- The comment is optional - use it to explain what you did
|
||||
|
||||
Note: Since you cannot push changes, you can resolve threads to acknowledge feedback, but actual fixes would need to be applied separately.
|
||||
Note: You can resolve threads after pushing fixes, or resolve them to acknowledge feedback that will be addressed separately.
|
||||
</review_thread_tools>
|
||||
|
||||
<response_guidelines>
|
||||
- Be concise and actionable
|
||||
- If the request is unclear, ask clarifying questions
|
||||
- If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do
|
||||
- When making code changes, explain that they are local only and cannot be pushed
|
||||
- Lead with a tl;dr — the bottom line in 1-3 sentences, always visible. The reader should be able to act without expanding anything.
|
||||
- Push supporting detail (code analysis, verification output, related items) into collapsible `<details>` blocks. These are appendices, not the main message.
|
||||
- Short responses (a few sentences) don't need collapsible sections at all.
|
||||
- Be concise and actionable.
|
||||
- If the request is unclear, ask clarifying questions.
|
||||
- When making code changes, commit and push them to the PR branch so the author gets the fix directly.
|
||||
- Every claim needs evidence: cite file paths, line numbers, or command output. Never say "the code does X" without pointing to where.
|
||||
- If you're uncertain, say so. "I couldn't confirm this" is better than a speculative answer.
|
||||
|
||||
**When performing a PR review**: Your substantive feedback belongs in the PR review submission
|
||||
(via pr-review.sh), not in the comment response. The comment should only report:
|
||||
|
|
@ -263,6 +286,11 @@ jobs:
|
|||
Keep the comment short, e.g., "I've submitted my review requesting changes. See the review for details."
|
||||
</response_guidelines>
|
||||
|
||||
<github_safety>
|
||||
- Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues.
|
||||
- When referencing issues, use plain `#N` or link syntax without action keywords.
|
||||
</github_safety>
|
||||
|
||||
<response_footer>
|
||||
Always end your comment with a new line, three dashes, and the footer message:
|
||||
<exact_content>
|
||||
|
|
|
|||
52
.github/workflows/marvin-dedupe-issues.yml
vendored
52
.github/workflows/marvin-dedupe-issues.yml
vendored
|
|
@ -19,9 +19,16 @@ jobs:
|
|||
issues: write
|
||||
id-token: write
|
||||
|
||||
# TEMPORARY PIN — see the matching note in marvin-label-triage.yml.
|
||||
# Claude Code 2.1.216 broke every Bash call under the action's subprocess
|
||||
# isolation, which this workflow needs for all of its `gh` searching.
|
||||
# https://github.com/anthropics/claude-code/issues/79997
|
||||
env:
|
||||
PINNED_CLAUDE_CODE_VERSION: "2.1.215"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Marvin App token
|
||||
id: marvin-token
|
||||
|
|
@ -37,23 +44,38 @@ jobs:
|
|||
PROMPT<<PROMPT_END
|
||||
Find up to 3 likely duplicate issues for GitHub issue ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}.
|
||||
|
||||
Follow these steps precisely:
|
||||
# Core Principle
|
||||
Silence is better than noise. A false positive wastes a human's time and erodes trust in every future report. Most runs should end with no comment — that means the system is working.
|
||||
|
||||
# Steps
|
||||
|
||||
1. Check if the GitHub issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
|
||||
2. View the GitHub issue and produce a summary of the issue
|
||||
2. View the GitHub issue and produce a summary of the issue.
|
||||
|
||||
3. Then, launch 3 parallel agents using the Task tool to search GitHub for duplicates of this issue, using diverse keywords and search approaches, using the summary from step 2
|
||||
3. Launch 3 parallel agents using the Task tool to search GitHub for duplicates, using diverse keywords and search approaches, using the summary from step 2.
|
||||
|
||||
4. Next, consider the results from steps 2 and 3 and filter out false positives that are likely not actually duplicates of the original issue. Be conservative — only flag issues that describe the same underlying problem, not issues that merely share keywords or involve the same subsystem. If there are no duplicates remaining, do not proceed.
|
||||
4. Filter aggressively for false positives. The bar for "duplicate" is high:
|
||||
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates). If there are no duplicates, DO NOT COMMENT. Just exit. Do NOT add any labels — labeling is handled by a later workflow step.
|
||||
A duplicate means the SAME bug or the SAME feature request. Apply this test to every candidate:
|
||||
- **Same fix test**: Could the candidate be closed by the exact same code change? If not, not a duplicate.
|
||||
- **Same symptom test**: Does the user experience the exact same broken behavior? "Both involve middleware" is not duplication. "Both get TypeError on line 42 of proxy.py when calling mount()" is duplication.
|
||||
- **Same request test** (for features): Are they asking for the same specific capability? "Both want better auth" is not duplication. "Both request OAuth PKCE flow for CLI login" is duplication.
|
||||
|
||||
Notes for your agents:
|
||||
Candidates found by only one search agent deserve extra scrutiny — a single keyword match is often a false positive.
|
||||
|
||||
When in doubt, do not flag. A missed duplicate is harmless; a false positive wastes the reporter's time.
|
||||
If there are no duplicates remaining, do not proceed — just exit.
|
||||
|
||||
5. **Quality gate**: Before commenting, re-read each candidate as a skeptical reviewer. For each one, ask: "Would a maintainer who knows this codebase agree this is a duplicate, or would they dismiss it?" If you'd need to hedge with "might" or "possibly," drop it.
|
||||
|
||||
6. Comment back on the issue with your findings (or exit silently if none remain). Do NOT add any labels — labeling is handled by a later workflow step.
|
||||
|
||||
# Notes for your agents
|
||||
- Use `gh` to interact with GitHub, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` and Task (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- Do not use other tools beyond `gh` and Task (no MCP servers, file edit, etc.)
|
||||
- Never include this issue as a duplicate of itself
|
||||
- When searching, read the FULL body of candidate issues — titles alone are not enough to judge duplication
|
||||
|
||||
For your comment, follow this format precisely (example with 3 suspected duplicates):
|
||||
|
||||
|
|
@ -76,19 +98,27 @@ jobs:
|
|||
- name: Clean up stale Claude locks
|
||||
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
|
||||
|
||||
- name: Install pinned Claude Code
|
||||
id: pin-claude
|
||||
run: |
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION"
|
||||
echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT"
|
||||
"$HOME/.local/bin/claude" --version
|
||||
|
||||
- name: Run Marvin dedupe command
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }}
|
||||
github_token: ${{ steps.marvin-token.outputs.token }}
|
||||
bot_name: "Marvin Context Protocol"
|
||||
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
|
||||
allowed_non_write_users: "*"
|
||||
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 api:*)","Bash(gh issue comment:*)",Task
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"env": {
|
||||
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
|
||||
}
|
||||
|
|
|
|||
167
.github/workflows/marvin-label-triage.yml
vendored
167
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -27,9 +27,25 @@ jobs:
|
|||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# TEMPORARY PIN — remove once upstream ships a fix.
|
||||
#
|
||||
# Claude Code 2.1.216 regressed the sandbox that claude-code-action wraps
|
||||
# every Bash call in when `allowed_non_write_users` is set: the mountpoint
|
||||
# walk fails closed, so every command — down to `true` — dies with
|
||||
# `bwrap: Can't create file at /home/.mcp.json: Permission denied`.
|
||||
# Marvin still reads the issue and picks correct labels, then cannot run
|
||||
# the helper that applies them, so triage silently applied zero labels
|
||||
# from 2026-07-20 onward while every run reported success.
|
||||
#
|
||||
# 2.1.215 is the last release without the regression.
|
||||
# https://github.com/anthropics/claude-code/issues/79997
|
||||
# https://github.com/anthropics/claude-code-action/issues/1547
|
||||
env:
|
||||
PINNED_CLAUDE_CODE_VERSION: "2.1.215"
|
||||
|
||||
steps:
|
||||
- name: Checkout base repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
|
@ -49,7 +65,16 @@ jobs:
|
|||
PROMPT<<PROMPT_END
|
||||
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients. Your task is to analyze issues/PRs and apply appropriate labels.
|
||||
|
||||
IMPORTANT: Your primary action should be to apply labels using mcp__github__update_issue. DO NOT post comments EXCEPT when applying the too-long label (see below).
|
||||
IMPORTANT: Your primary action should be to apply labels using the locked-down helper `.github/scripts/triage-label.sh`. DO NOT post comments EXCEPT when applying the too-long label (see below).
|
||||
|
||||
CRITICAL — LABEL MECHANICS:
|
||||
- Apply labels ONLY through the helper, which adds or removes repository labels on THIS issue/PR. It already knows the target repo and number (from the workflow environment) — you never pass them:
|
||||
add: `bash .github/scripts/triage-label.sh add "label1" "label2"`
|
||||
remove: `bash .github/scripts/triage-label.sh remove "label1"`
|
||||
- The helper uses the additive REST labels endpoint, so it works for both issues and PRs and never clobbers labels applied by other workflows — notably the Require Issue Link workflow's `missing-issue-link` control label, which must survive or an auto-closed PR won't reopen when its author is assigned.
|
||||
- The helper is your ONLY GitHub write access. Do NOT use raw `gh api`, `gh issue edit`, `gh pr edit`, or any other mutation — they are not available to you.
|
||||
- Only apply labels that exist in the repository (from `gh label list` in step 1). Never invent labels.
|
||||
- Use `remove` only to correct a label you believe is wrong, and never remove the control labels `missing-issue-link`, `bypass-issue-check`, or `trusted-contributor`.
|
||||
|
||||
Issue/PR Information:
|
||||
- REPO: ${{ github.repository }}
|
||||
|
|
@ -97,7 +122,7 @@ jobs:
|
|||
STATUS (apply if applicable):
|
||||
- needs more info: Issue lacks reproduction steps, error messages, or clear description
|
||||
- invalid: Spam, completely off-topic, or nonsensical (often LLM-generated)
|
||||
- too-long: Apply when an issue or PR doesn't conform to CONTRIBUTING.md. Issues should be a short problem description, an MRE, and expected vs. actual behavior — not a design document. PRs should have a focused description of the change — not a report. We don't need proposed solutions or design alternatives (the issue should describe the problem and let maintainers architect the fix), summaries of what tests cover, explanations of code we can read ourselves, or speculative root-cause analysis. Common LLM failure modes to watch for: verbose "diagnostic" writeups, large proposed patches in issue bodies, multi-section reports restating what's visible in the diff, numbered lists of possible approaches or solutions, "suggested" schemas/shapes/APIs, generic analysis that doesn't reference specific code, and "Notes" sections. But these are heuristics, not rules — a complex PR may legitimately need more context, and a brief submission can still be low-quality. Judge by whether the content helps a reviewer or just adds noise. When applying, do not apply other triage labels. The author needs to condense before triage is worthwhile.
|
||||
- too-long: Apply when an issue or PR doesn't conform to CONTRIBUTING.md. Issues should be a short problem description, an MRE, and expected vs. actual behavior — not a design document. PRs should have a focused description of the change — not a report. We don't need proposed solutions or design alternatives (the issue should describe the problem and let maintainers architect the fix), summaries of what tests cover, explanations of code we can read ourselves, or speculative root-cause analysis. Common LLM failure modes to watch for: verbose "diagnostic" writeups, large proposed patches in issue bodies, multi-section reports restating what's visible in the diff, numbered lists of possible approaches or solutions, "suggested" schemas/shapes/APIs, generic analysis that doesn't reference specific code, and "Notes" sections. But these are heuristics, not rules — a complex PR may legitimately need more context, and a brief submission can still be low-quality. Judge by whether the content helps a reviewer or just adds noise. When applying too-long, still apply the core category and area labels — too-long is a format signal, not a replacement for categorization. Issues still need to be findable by category.
|
||||
|
||||
WHEN APPLYING too-long: After labeling, post a brief comment using mcp__github__add_issue_comment:
|
||||
"Thanks for the report. This issue goes beyond what our contributor guidelines ask for — we just need a short problem description and an MRE. Please see our [contributing guidelines](https://github.com/PrefectHQ/fastmcp/blob/main/CONTRIBUTING.md) and condense this issue. We'll triage it once it's trimmed down."
|
||||
|
|
@ -110,23 +135,22 @@ jobs:
|
|||
- auth: Authentication is the main concern (Bearer, JWT, OAuth, WorkOS)
|
||||
- openapi: OpenAPI integration/parsing is the primary topic
|
||||
- http: HTTP transport or networking is the main issue
|
||||
- contrib: Specifically about community contributions in src/contrib/
|
||||
- contrib: Specifically about community contributions in fastmcp_slim/fastmcp/contrib/
|
||||
- tests: Issues primarily about testing infrastructure, CI/CD workflows, or test coverage
|
||||
- security: Apply ONLY when the issue/PR addresses an exploitable vulnerability or hardens against one. Examples: SSRF, LFI, path traversal, injection, auth bypass allowing unauthorized access, scope escalation, open redirects. Do NOT apply for ordinary auth bugs (wrong scopes returned, token refresh logic, OAuth flow correctness) unless an attacker could exploit the bug to bypass access controls or escalate privileges. The key question: "Could a malicious actor exploit this?" If the answer is just "it breaks for legitimate users," that's a bug, not a security issue.
|
||||
|
||||
IMPORTANT LABELING RULES:
|
||||
- Be selective - only apply labels that are clearly relevant
|
||||
- Don't apply area labels just because a file in that area is mentioned
|
||||
- The issue must be PRIMARILY about that area to get the label
|
||||
- When in doubt, don't apply the label
|
||||
- Apply 2-5 labels total typically (category + maybe priority + maybe 1-2 areas)
|
||||
LABELING PRINCIPLES:
|
||||
- Precision over recall: a missing label is a minor inconvenience; a wrong label sends the wrong people to the wrong issue. When in doubt, don't apply.
|
||||
- Don't apply area labels just because a file in that area is mentioned — the issue must be PRIMARILY about that area.
|
||||
- Apply 2-5 labels total typically (category + maybe priority + maybe 1-2 areas).
|
||||
- For ambiguous cases (bug vs enhancement, which area label), prefer the more conservative choice or omit the uncertain label entirely.
|
||||
|
||||
META LABELS (rarely needed for issues):
|
||||
- dependencies: Only for dependabot PRs or issues specifically about package updates
|
||||
- DON'T MERGE: Only if PR author explicitly states it's not ready
|
||||
|
||||
4. Apply selected labels:
|
||||
Use mcp__github__update_issue to apply your selected labels
|
||||
Add them with `bash .github/scripts/triage-label.sh add "label1" "label2"`.
|
||||
DO NOT post any comments unless applying too-long (see above)
|
||||
PROMPT_END
|
||||
EOF
|
||||
|
|
@ -134,9 +158,21 @@ jobs:
|
|||
- name: Clean up stale Claude locks
|
||||
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
|
||||
|
||||
# Mirrors how the action installs Claude Code itself, minus the version
|
||||
# it hardcodes. Passing path_to_claude_code_executable makes the action
|
||||
# skip its own install and use this build.
|
||||
- name: Install pinned Claude Code
|
||||
id: pin-claude
|
||||
run: |
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION"
|
||||
echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT"
|
||||
"$HOME/.local/bin/claude" --version
|
||||
|
||||
- name: Run Marvin for Issue Triage
|
||||
id: marvin
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }}
|
||||
github_token: ${{ steps.marvin-token.outputs.token }}
|
||||
bot_name: "Marvin Context Protocol"
|
||||
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
|
||||
|
|
@ -144,11 +180,114 @@ jobs:
|
|||
allowed_non_write_users: "*"
|
||||
allowed_bots: "marvin-context-protocol"
|
||||
claude_args: |
|
||||
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
|
||||
--allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"model": "claude-sonnet-5",
|
||||
"env": {
|
||||
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
|
||||
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}",
|
||||
"TRIAGE_REPO": "${{ github.repository }}",
|
||||
"TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}"
|
||||
}
|
||||
}
|
||||
|
||||
# Triage is fire-and-forget: nobody watches a green run, so a broken
|
||||
# allowlist has to fail the job or it goes unnoticed indefinitely — a
|
||||
# mangled pattern silently produced zero labels across a dozen PRs
|
||||
# because the run still reported success.
|
||||
#
|
||||
# Only denials of commands we MEANT to grant indicate that breakage. An
|
||||
# agent reaching for something never on the allowlist (falling back to
|
||||
# `gh issue view` when the API is down, say) is behaving normally, and
|
||||
# failing on that would cry wolf during every GitHub incident.
|
||||
- name: Fail if Marvin could not run its tools
|
||||
if: always() && steps.marvin.conclusion != 'skipped'
|
||||
env:
|
||||
EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }}
|
||||
run: |
|
||||
file="${EXECUTION_FILE:-}"
|
||||
if [[ -z "$file" || ! -s "$file" ]]; then
|
||||
file="${RUNNER_TEMP}/claude-execution-output.json"
|
||||
fi
|
||||
# A missing or empty log means we cannot tell a clean run from a
|
||||
# blocked one, which is the exact failure this step exists to catch.
|
||||
if [[ ! -s "$file" ]]; then
|
||||
echo "::error::No Marvin execution log found; cannot verify tool permissions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The persisted log carries a `permission_denials` array on each
|
||||
# `type: result` entry; the `permission_denials_count` scalar only
|
||||
# appears in the action's condensed stdout summary, never on disk.
|
||||
# Anchor to result entries rather than recursing with `..`, which
|
||||
# descends into each denial's `tool_input` and double-counts any
|
||||
# denied command that happens to mention the field name.
|
||||
if ! summary=$(jq -sr '
|
||||
[ .[] | if type == "array" then .[] else . end ]
|
||||
| map(select(type == "object" and .type == "result"))
|
||||
| map(.permission_denials // []) | flatten
|
||||
| map(.tool_input.command // "")
|
||||
| { total: length,
|
||||
granted: map(select(
|
||||
startswith("gh label list")
|
||||
or startswith("bash .github/scripts/triage-label.sh")
|
||||
))
|
||||
}
|
||||
| "\(.total)\t\(.granted | length)\t\(.granted | join(" | "))"
|
||||
' "$file"); then
|
||||
echo "::error::Could not parse Marvin execution log ($file)."
|
||||
exit 1
|
||||
fi
|
||||
IFS=$'\t' read -r total granted commands <<<"$summary"
|
||||
echo "Denied tool calls: $total (of which allowlisted: $granted)"
|
||||
|
||||
if [[ "$granted" -gt 0 ]]; then
|
||||
echo "::error::Marvin was denied $granted call(s) to tools this workflow grants, so it could not apply labels: ${commands}. The --allowedTools value is not reaching the permission matcher intact — claude_args is lexed with shell-quote, so any Bash(...) pattern containing a space must be quoted or it is split into fragments."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$total" -gt 0 ]]; then
|
||||
echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact."
|
||||
fi
|
||||
|
||||
# A granted tool can also fail *after* the permission check, which the
|
||||
# denial count above cannot see. Claude Code 2.1.216 did exactly that:
|
||||
# the sandbox refused to build and every Bash call — including the
|
||||
# labeling helper — exited 1 with `bwrap: ...`, while the run stayed
|
||||
# green. Correlate results back to their Bash tool_use rather than
|
||||
# grepping the whole log, so an issue body quoting a sandbox error
|
||||
# cannot fail an otherwise healthy run.
|
||||
if ! sandbox=$(jq -sr '
|
||||
[ .[] | if type == "array" then .[] else . end ]
|
||||
| map(select(type == "object" and (.type == "assistant" or .type == "user")))
|
||||
| map(.message.content // []) | flatten
|
||||
| map(select(type == "object"))
|
||||
| . as $blocks
|
||||
| ( $blocks
|
||||
| map(select(.type == "tool_use" and .name == "Bash"))
|
||||
| map(.id) ) as $bash
|
||||
| $blocks
|
||||
| map(select(.type == "tool_result" and (.tool_use_id as $i | $bash | index($i))))
|
||||
| map(.content | tostring)
|
||||
| map(select(test("bwrap:|Failed to (start|create) sandbox")))
|
||||
| "\(length)\t\(.[0] // "" | gsub("[\t\n]"; " ") | .[0:200])"
|
||||
' "$file"); then
|
||||
echo "::error::Could not scan Marvin execution log for sandbox failures ($file)."
|
||||
exit 1
|
||||
fi
|
||||
IFS=$'\t' read -r sandbox_failures sandbox_sample <<<"$sandbox"
|
||||
|
||||
if [[ "$sandbox_failures" -gt 0 ]]; then
|
||||
echo "::error::Marvin's Bash tool failed $sandbox_failures time(s) inside the action's subprocess sandbox, so it could not apply labels: ${sandbox_sample}. This is an environment failure, not a prompt or allowlist problem — check whether the pinned Claude Code version (${PINNED_CLAUDE_CODE_VERSION}) still avoids the upstream sandbox regression."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Marvin execution log
|
||||
if: always() && steps.marvin.conclusion != 'skipped'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: marvin-triage-execution-log
|
||||
path: |
|
||||
${{ steps.marvin.outputs.execution_file }}
|
||||
${{ runner.temp }}/claude-execution-output.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
martian-test-failure:
|
||||
marvin-test-failure:
|
||||
# Only run if the test workflow failed
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
actions: read # Required for Claude to read CI results
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
|
@ -60,6 +60,17 @@ jobs:
|
|||
2. Identify the root cause of the failure(s)
|
||||
3. Suggest a clear, actionable solution to fix the failure(s)
|
||||
|
||||
# Response Proportionality
|
||||
Match your response length to the complexity of the failure. Not every failure needs a full investigation:
|
||||
|
||||
**Trivial failures** (formatting, linting) — post a short, direct comment. No collapsible sections, no root-cause deep-dive. Example:
|
||||
> CI failed: `ruff format` reformatted 2 files. Run `uv run ruff format .` locally and push.
|
||||
|
||||
**Pre-existing flaky tests** unrelated to the PR — say so briefly. Don't write a full analysis of a test the PR didn't touch. Example:
|
||||
> CI failed due to a pre-existing flaky test (`test_name`) unrelated to this PR's changes. Safe to re-run.
|
||||
|
||||
**Real failures caused by the PR** — these deserve the full analysis format below. Spend your effort here.
|
||||
|
||||
# Getting Started
|
||||
1. Call the generate_agents_md tool to get a high-level summary of the project
|
||||
2. Get the pull request associated with this workflow run from the GitHub repository: ${{ github.repository }}
|
||||
|
|
@ -75,60 +86,61 @@ jobs:
|
|||
5. Search the codebase for relevant files, tests, and implementations
|
||||
|
||||
# Your Response
|
||||
Post a comment on the pull request with your analysis. Your comment should include:
|
||||
Post a comment on the pull request with your analysis.
|
||||
|
||||
## Test Failure Analysis
|
||||
Lead with a tl;dr — 1-2 sentences that tell the developer what broke and what to do about it. This should be visible without expanding anything.
|
||||
|
||||
**Summary**: A brief 1-2 sentence summary of what failed.
|
||||
Push supporting detail into collapsible `<details>` blocks. The reader should be able to act on your comment without expanding a single one. Think of details blocks as appendices — there if someone wants to dig deeper, not required for the main message.
|
||||
|
||||
**Root Cause**: A clear explanation of why the tests failed, based on your analysis of the logs and code.
|
||||
For real (non-trivial) failures, use this structure:
|
||||
|
||||
**Suggested Solution**: Specific, actionable steps to fix the failure(s). Include:
|
||||
- Which files need to be modified
|
||||
- What changes are needed
|
||||
- Why these changes will fix the issue
|
||||
**tl;dr**: What failed and what to do (1-2 sentences, always visible)
|
||||
|
||||
**Root Cause**: Why it failed (a short paragraph, always visible)
|
||||
|
||||
**Fix**: Specific files and changes needed (always visible)
|
||||
|
||||
<details>
|
||||
<summary>Detailed Analysis</summary>
|
||||
|
||||
Include here:
|
||||
- Relevant log excerpts showing the failure
|
||||
- Code snippets that are causing the issue
|
||||
- Any related issues or PRs that might be relevant
|
||||
<summary>Log excerpts</summary>
|
||||
Relevant failure output
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Related Files</summary>
|
||||
|
||||
List files that are relevant to the failure with brief explanations of their relevance.
|
||||
<summary>Related files</summary>
|
||||
Files relevant to the failure
|
||||
</details>
|
||||
|
||||
# Important Guidelines
|
||||
- Be concise and actionable - developers want to quickly understand and fix the issue. Provide
|
||||
additional context, references, etc in collapsible details blocks to ensure that the comment you're adding
|
||||
is short and easy to read but additional information is a click away.
|
||||
- Focus on facts from the logs and code, not speculation
|
||||
- If you can't determine the root cause, say so clearly
|
||||
- If your only suggestion is a bad suggestion (disable the test, change the timeout, etc), indicate that you've run out of ideas and
|
||||
that they probably don't want to do that.
|
||||
- Provide specific file names, line numbers, and code references when possible
|
||||
- You can run make commands (e.g., `make lint`, `make typecheck`, `make sync`) to build, test, or lint the code
|
||||
- You can also run git commands (e.g., `git status`, `git log`, `git diff`) to inspect the repository
|
||||
- You can use WebSearch and WebFetch to research errors, stack traces, or related issues
|
||||
- For bash commands, you are limited to make and git commands only
|
||||
# Quality Standards
|
||||
- Every claim needs evidence: file paths, line numbers, log excerpts. Never say "the test fails" without citing which test and what the error was.
|
||||
- Focus on facts from the logs and code, not speculation. If you can't determine the root cause, say so clearly — "I don't know" is better than a wrong diagnosis.
|
||||
- If your only suggestion is a bad one (disable the test, increase the timeout, etc.), say so honestly rather than dressing it up.
|
||||
- Do not paste raw CLI output (e.g., prek progress bars, pytest collection output) into the comment body. Quote only the relevant failure lines.
|
||||
- Always include specific file names, tool names, and test names in your summary. Never leave a sentence with a blank where a name should be.
|
||||
|
||||
# CRITICAL: ANGRY USERS
|
||||
**IMPORTANT**: If the user is angry with you, the triage bot, don't respond. Just exit immediately without further action.
|
||||
If at any point in the conversation the user has asked you to stop replying to the thread, just exit immediately.
|
||||
# Self-Review Before Posting
|
||||
Before posting your comment, re-read it as the PR author would. Ask:
|
||||
- Can I act on this without expanding any `<details>` block?
|
||||
- Does every claim cite a specific file, line, or log excerpt?
|
||||
- Am I telling them something they can't already see in the CI logs, or just restating them?
|
||||
If your comment doesn't add value beyond what the logs already show, don't post it.
|
||||
|
||||
# STOP SIGNALS
|
||||
If anyone on the PR has asked the bot to stop — e.g., "stop", "go away", "don't comment", "no more bot comments" — exit immediately without further action. This includes past comments in the thread, not just the most recent one.
|
||||
|
||||
If you are posting the same suggestion as you have previously made, do not post the suggestion again.
|
||||
|
||||
# IMPORTANT: EDIT YOUR COMMENT
|
||||
Do not post a new comment every time you triage a failing workflow. If a previous comment has been posted by you (marvin)
|
||||
in a previous triage, edit that comment do not add a new comment for each failure. Be sure to include a note that you've edited
|
||||
your comment to reflect the latest analysis. Don't worry about keeping the old content around, there's comment history for
|
||||
your comment to reflect the latest analysis. Don't worry about keeping the old content around, there's comment history for
|
||||
that.
|
||||
|
||||
# Available Tools
|
||||
- You can run make commands (e.g., `make lint`, `make typecheck`, `make sync`) to build, test, or lint the code
|
||||
- You can also run git commands (e.g., `git status`, `git log`, `git diff`) to inspect the repository
|
||||
- You can use WebSearch and WebFetch to research errors, stack traces, or related issues
|
||||
- For bash commands, you are limited to make and git commands only
|
||||
|
||||
# Problems Encountered
|
||||
If you encounter any problems during your analysis (e.g., unable to fetch logs, tools not working), document them clearly so the team knows what limitations you faced.
|
||||
PROMPT_END
|
||||
|
|
@ -181,5 +193,5 @@ jobs:
|
|||
|
||||
prompt: ${{ steps.analysis-prompt.outputs.PROMPT }}
|
||||
claude_args: |
|
||||
--allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,Bash(make:*,git:*)
|
||||
--allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,"Bash(make:*)","Bash(git:*)"
|
||||
--mcp-config /tmp/mcp-config/mcp-servers.json
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
|
@ -46,6 +46,9 @@ jobs:
|
|||
|
||||
- name: Run Claude for Triage
|
||||
uses: ./.github/actions/run-claude
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github-token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
|
@ -54,12 +57,12 @@ jobs:
|
|||
<context>
|
||||
Repository: ${{ github.repository }}
|
||||
Issue Number: #${{ github.event.issue.number }}
|
||||
Issue Title: ${{ github.event.issue.title }}
|
||||
Issue Title: ${{ env.ISSUE_TITLE }}
|
||||
Issue Author: ${{ github.event.issue.user.login }}
|
||||
</context>
|
||||
|
||||
<issue_body>
|
||||
${{ github.event.issue.body }}
|
||||
${{ env.ISSUE_BODY }}
|
||||
</issue_body>
|
||||
|
||||
<task>
|
||||
|
|
@ -119,8 +122,26 @@ jobs:
|
|||
2. Layout a single high-quality and actionable recommendation for how to address the issue based on your knowledge of the project, codebase, and issue
|
||||
3. Provide a high quality and detailed plan that a junior developer could follow to implement the recommendation
|
||||
4. Use execution to verify findings when appropriate (check `<allowed_tools>` section for available commands)
|
||||
|
||||
Report findings and recommendations — not your process. Do not include task checklists, progress tracking, or "steps I took" narration (e.g., `- [x] Read source code`). The reader cares about what you found, not how you found it.
|
||||
</response_goals>
|
||||
|
||||
<evidence_standards>
|
||||
Every claim in your response must be grounded in evidence you can cite:
|
||||
- **Code references**: Always include file path and line number (e.g., `fastmcp_slim/fastmcp/client/client.py:142`). Never say "the client code does X" without pointing to where.
|
||||
- **Bug confirmation**: If you say a bug is real, show the specific code path that produces it. If you ran a test, include the command and output.
|
||||
- **Related items**: When citing a related issue or PR, explain specifically why it's related — not just that it exists.
|
||||
- **Confidence**: If you're uncertain about a finding, say so. "I don't know" or "I couldn't confirm this" is better than a speculative diagnosis. Only report findings you would confidently defend.
|
||||
</evidence_standards>
|
||||
|
||||
<quality_gate>
|
||||
Before posting, re-read your response as a maintainer would:
|
||||
- Does the tl;dr give the full picture without expanding anything?
|
||||
- Does every claim cite a specific file, line, or test result?
|
||||
- Is this telling the maintainer something they couldn't find in 5 minutes of reading the issue and grepping the code?
|
||||
If your response doesn't add meaningful value beyond restating the issue, it's okay to post a short "confirmed, straightforward fix in [file]:[line]" response instead of a full analysis.
|
||||
</quality_gate>
|
||||
|
||||
<response_sections>
|
||||
Populate the following sections in your response:
|
||||
Recommendation (or "No recommendation" with reason)
|
||||
|
|
@ -133,12 +154,17 @@ jobs:
|
|||
|
||||
You may not be able to do all of these things, sometimes you may find that all you can do is provide in-depth context of the issue and related items. That's perfectly acceptable and expected. Your performance is judged by how accurate your findings are, do the investigation required to have high confidence in your findings and recommendations. "I don't know" or "I'm unable to recommend a course of action" is better than a bad or wrong answer.
|
||||
|
||||
When formulating your response, you will never "bury the lede", you will always provide a clear and concise tl;dr as the first thing in your response. As your response grows in length you can organize the more detailed parts of your response collapsible sections using <details> and <summary> tags. You shouldn't put everything in collapsible sections, especially if the response is short. Use your discretion to determine when to use collapsible sections to avoid overwhelming the reader with too much detail -- think of them like an appendix that can be expanded if the reader is interested.
|
||||
Structure: Lead with a tl;dr (1-3 sentences, always visible) that gives the reader the bottom line — what this issue is, whether it's valid, and what to do about it. The reader should be able to act on your comment without expanding anything.
|
||||
|
||||
Push everything else into collapsible `<details>` blocks: findings, verification output, action plans, related items, related files. These are appendices — valuable for someone who wants to dig deeper, but not required for the main message. The only things that should be visible without clicking are the tl;dr and the recommendation. Short responses (a few sentences) don't need collapsible sections at all.
|
||||
|
||||
</response_sections>
|
||||
<response_examples>
|
||||
# Example output for "Recommendation" part of the response
|
||||
PR #654 already implements the requested feature but is incomplete. The Pull Request is not in a mergeable state yet, the remaining work should be completed: 1) update the Calculator.divide method to utilize the new DivisionByZeroError or the safe_divide function, and 2) update the tests to ensure that the Calculator.divide method raises the new DivisionByZeroError when the divisor is 0.
|
||||
# Example: the tl;dr and recommendation are always visible, everything else is collapsed
|
||||
|
||||
**tl;dr**: Confirmed bug — `Calculator.divide` raises `ValueError` instead of `DivisionByZeroError`. PR #654 partially addresses this but is incomplete.
|
||||
|
||||
**Recommendation**: Complete PR #654: update `Calculator.divide` to raise `DivisionByZeroError` and update the test assertions to match.
|
||||
|
||||
<details>
|
||||
<summary>Findings</summary>
|
||||
|
|
@ -147,7 +173,7 @@ jobs:
|
|||
|
||||
<details>
|
||||
<summary>Verification</summary>
|
||||
I ran the existing tests (if execution commands are available in `<allowed_tools>`) and confirmed the current behavior:
|
||||
|
||||
```bash
|
||||
$ pytest test_calculator.py::test_divide_by_zero
|
||||
FAILED - raises ValueError instead of DivisionByZeroError
|
||||
|
|
@ -156,36 +182,25 @@ jobs:
|
|||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Detailed Action Plan</summary>
|
||||
<summary>Action Plan</summary>
|
||||
...a detailed plan that a junior developer could follow to implement the recommendation...
|
||||
</details>
|
||||
|
||||
# Example Output for "Related Items" part of the response
|
||||
|
||||
<details>
|
||||
<summary>Related Issues and Pull Requests</summary>
|
||||
|
||||
| Repository | Issue or PR | Relevance |
|
||||
| --- | --- | --- |
|
||||
| PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | This pull request directly addresses the feature request for adding matrix operations to the calculator. |
|
||||
| PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/issues/681) | This issue directly addresses the feature request for adding matrix operations to the calculator. |
|
||||
| Issue or PR | Relevance |
|
||||
| --- | --- |
|
||||
| [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | Directly addresses the feature request |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Related Files</summary>
|
||||
|
||||
| Repository | File | Relevance | Sections |
|
||||
| --- | --- | --- | --- |
|
||||
| modelcontextprotocol/python-sdk | [test_calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py) | This file contains the test cases for the Calculator class, including a test that specifically asserts a ValueError is raised for division by zero, confirming the current intended behavior. | [25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) |
|
||||
| modelcontextprotocol/python-sdk | [calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py) | This file contains the implementation of the Calculator class, specifically the `divide` method which raises the ValueError when dividing by zero, matching the bug report. | [29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Related Webpages</summary>
|
||||
|
||||
| Name | URL | Relevance |
|
||||
| --- | --- | --- |
|
||||
| Handling Division by Zero Best Practices | https://my-blog-about-division-by-zero.com/handling+division+by+zero+in+calculator | This webpage provides general best practices for handling division by zero in calculator applications and in Python, which is directly relevant to the issue and potential solutions. |
|
||||
| File | Relevance |
|
||||
| --- | --- |
|
||||
| [calculator.py L29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) | The `divide` method that raises ValueError |
|
||||
| [test_calculator.py L25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) | Test asserting ValueError (needs updating) |
|
||||
</details>
|
||||
</response_examples>
|
||||
|
||||
|
|
@ -202,4 +217,5 @@ jobs:
|
|||
|
||||
<github_formatting>
|
||||
When writing GitHub comments, wrap branch names, tags, or other @-references in backticks (e.g., `@main`, `@v1.0`) to avoid accidentally pinging users. Do not add backticks around terms that are already inside backticks or code blocks.
|
||||
Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues. Use plain `#N` references instead.
|
||||
</github_formatting>
|
||||
|
|
@ -14,8 +14,12 @@ on:
|
|||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Scope the group by event name so that the sibling events fired by a single
|
||||
# review action (pull_request_review + pull_request_review_comment, same instant)
|
||||
# don't cancel each other. Same-PR runs of the *same* event still supersede
|
||||
# cleanly, and the last one always completes.
|
||||
concurrency:
|
||||
group: minimize-reviews-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
group: minimize-reviews-${{ github.event.pull_request.number || github.event.issue.number }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
87
.github/workflows/publish-fastmcp-remote.yml
vendored
Normal file
87
.github/workflows/publish-fastmcp-remote.yml
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
name: Publish fastmcp-remote to PyPI
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish fastmcp-slim to PyPI"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload fastmcp-remote to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build fastmcp-remote
|
||||
run: uv build --package fastmcp-remote
|
||||
|
||||
- name: Verify matching fastmcp-slim is published
|
||||
run: |
|
||||
SLIM_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp_remote-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, marker = value.partition(";")
|
||||
if marker.strip():
|
||||
continue
|
||||
match = re.fullmatch(
|
||||
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
|
||||
requirement.strip(),
|
||||
)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Could not find the base fastmcp-slim dependency")
|
||||
PY
|
||||
)
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$SLIM_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-remote." >&2
|
||||
exit 1
|
||||
|
||||
- name: Publish fastmcp-remote to PyPI
|
||||
run: uv publish -v dist/fastmcp_remote-*.tar.gz dist/fastmcp_remote-*.whl
|
||||
30
.github/workflows/publish-fastmcp-slim.yml
vendored
Normal file
30
.github/workflows/publish-fastmcp-slim.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Publish fastmcp-slim to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload fastmcp-slim to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build fastmcp-slim
|
||||
run: uv build --package fastmcp-slim
|
||||
|
||||
- name: Publish fastmcp-slim to PyPI
|
||||
run: uv publish -v dist/fastmcp_slim-*.tar.gz dist/fastmcp_slim-*.whl
|
||||
104
.github/workflows/publish-fastmcp-tasks.yml
vendored
Normal file
104
.github/workflows/publish-fastmcp-tasks.yml
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
name: Publish fastmcp-tasks to PyPI
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish fastmcp-slim to PyPI"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload fastmcp-tasks to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
# Maintenance branches predate the standalone fastmcp-tasks package and
|
||||
# resolve the `tasks` extra through fastmcp-slim instead. This workflow
|
||||
# runs from the default branch for every fastmcp-slim release, including
|
||||
# those tags, so detect the package rather than assume it is there.
|
||||
- name: Check whether this ref builds fastmcp-tasks
|
||||
id: package_present
|
||||
run: |
|
||||
if [ -d fastmcp_tasks ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "This ref has no fastmcp_tasks package; nothing to publish."
|
||||
fi
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build fastmcp-tasks
|
||||
if: steps.package_present.outputs.present == 'true'
|
||||
run: uv build --package fastmcp-tasks
|
||||
|
||||
- name: Verify matching fastmcp-slim is published
|
||||
if: steps.package_present.outputs.present == 'true'
|
||||
run: |
|
||||
SLIM_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp_tasks-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, marker = value.partition(";")
|
||||
if marker.strip():
|
||||
continue
|
||||
match = re.fullmatch(
|
||||
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
|
||||
requirement.strip(),
|
||||
)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Could not find the base fastmcp-slim dependency")
|
||||
PY
|
||||
)
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$SLIM_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-tasks." >&2
|
||||
exit 1
|
||||
|
||||
- name: Publish fastmcp-tasks to PyPI
|
||||
if: steps.package_present.outputs.present == 'true'
|
||||
run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl
|
||||
238
.github/workflows/publish-fastmcp.yml
vendored
Normal file
238
.github/workflows/publish-fastmcp.yml
vendored
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
name: Publish fastmcp to PyPI
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish fastmcp-slim to PyPI"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload fastmcp to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
|
||||
outputs:
|
||||
is_prerelease: ${{ steps.package_version.outputs.is_prerelease }}
|
||||
version: ${{ steps.package_version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build fastmcp
|
||||
run: uv build --package fastmcp
|
||||
|
||||
- name: Read built package version
|
||||
id: package_version
|
||||
run: |
|
||||
python - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
version = metadata["Version"]
|
||||
public_version = version.partition("+")[0]
|
||||
is_prerelease = bool(
|
||||
re.search(
|
||||
r"(?i)(?:^|[0-9.])(?:a|b|c|rc|alpha|beta|pre|preview|dev)[0-9]*",
|
||||
public_version,
|
||||
)
|
||||
)
|
||||
print(f"version={version}")
|
||||
print(f"is_prerelease={str(is_prerelease).lower()}")
|
||||
PY
|
||||
|
||||
- name: Verify matching fastmcp-slim is published
|
||||
run: |
|
||||
SLIM_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, marker = value.partition(";")
|
||||
if marker.strip():
|
||||
continue
|
||||
match = re.fullmatch(
|
||||
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
|
||||
requirement.strip(),
|
||||
)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Could not find the base fastmcp-slim dependency")
|
||||
PY
|
||||
)
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$SLIM_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2
|
||||
exit 1
|
||||
|
||||
- name: Verify matching fastmcp-tasks is published
|
||||
run: |
|
||||
TASKS_VERSION=$(python - <<'PY'
|
||||
import email.parser
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
wheel = next(Path("dist").glob("fastmcp-*.whl"))
|
||||
metadata_name = next(
|
||||
name for name in zipfile.ZipFile(wheel).namelist()
|
||||
if name.endswith(".dist-info/METADATA")
|
||||
)
|
||||
metadata = email.parser.Parser().parsestr(
|
||||
zipfile.ZipFile(wheel).read(metadata_name).decode()
|
||||
)
|
||||
# fastmcp-tasks is pinned via the optional `tasks` extra, so its
|
||||
# Requires-Dist entry carries an `extra == "tasks"` marker — unlike the
|
||||
# base slim dependency, do not skip marked entries here.
|
||||
#
|
||||
# Print nothing when there is no such pin. Release lines that resolve
|
||||
# the `tasks` extra through fastmcp-slim instead of a standalone
|
||||
# fastmcp-tasks package have nothing here to verify.
|
||||
for value in metadata.get_all("Requires-Dist", []):
|
||||
requirement, _, _marker = value.partition(";")
|
||||
match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip())
|
||||
if match:
|
||||
print(match.group(1))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
if [ -z "$TASKS_VERSION" ]; then
|
||||
echo "This build does not pin fastmcp-tasks; the [tasks] extra cannot be uninstallable, so there is nothing to verify."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for attempt in {1..12}; do
|
||||
if python - "$TASKS_VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
version = sys.argv[1]
|
||||
url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
json.load(response)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2
|
||||
exit 1
|
||||
|
||||
- name: Publish fastmcp to PyPI
|
||||
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
|
||||
|
||||
update-published-docs:
|
||||
name: Open published-docs PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: pypi-publish
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && needs['pypi-publish'].outputs.is_prerelease != 'true'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Generate Marvin App token
|
||||
id: marvin-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
- name: Check release line
|
||||
id: release_line
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
git fetch origin "${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${DEFAULT_BRANCH}"; then
|
||||
echo "update_published_docs=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "update_published_docs=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update."
|
||||
fi
|
||||
|
||||
- name: Prepare published docs tree
|
||||
if: steps.release_line.outputs.update_published_docs == 'true'
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
git fetch origin published-docs
|
||||
git switch --force-create published-docs-sync origin/published-docs
|
||||
git read-tree --reset -u "$RELEASE_SHA"
|
||||
test "$(git write-tree)" = "$(git rev-parse "${RELEASE_SHA}^{tree}")"
|
||||
|
||||
- name: Open published docs PR
|
||||
if: steps.release_line.outputs.update_published_docs == 'true'
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
base: published-docs
|
||||
branch: marvin/publish-docs-v${{ needs.pypi-publish.outputs.version }}
|
||||
commit-message: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
|
||||
title: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
|
||||
body: "Updates `published-docs` to the exact release tree. Merging publishes the documentation to production."
|
||||
delete-branch: true
|
||||
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
|
||||
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
|
||||
26
.github/workflows/publish.yml
vendored
26
.github/workflows/publish.yml
vendored
|
|
@ -1,26 +0,0 @@
|
|||
name: Publish FastMCP to PyPI
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # For PyPI's trusted publishing
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "Install uv"
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPi
|
||||
run: uv publish -v dist/*
|
||||
613
.github/workflows/require-issue-link.yml
vendored
Normal file
613
.github/workflows/require-issue-link.yml
vendored
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
# Require external PRs to reference an issue with an auto-close keyword
|
||||
# (e.g. "Fixes #123") AND have the PR author assigned to that issue —
|
||||
# unless the referenced issue is labeled "prs welcome", which waives the
|
||||
# assignment requirement for everyone (the link itself is still required,
|
||||
# since that's how the check finds the issue to read the label from).
|
||||
# Otherwise the PR is labeled "missing-issue-link", commented on, and
|
||||
# closed. CONTRIBUTING.md requires external contributors to be assigned to
|
||||
# an issue before opening a PR; this enforces that.
|
||||
#
|
||||
# Adapted from langchain-ai/langchain's require_issue_link.yml. Differences:
|
||||
# - Self-contained: it does NOT depend on a separate labeler workflow
|
||||
# applying an "external" label first, so it can run on `opened`.
|
||||
# - "External" is determined authoritatively, in-script, from the PR
|
||||
# author's repo collaborator permission level — NOT from the event
|
||||
# payload's author_association. author_association reports MEMBER only
|
||||
# for *public* org members; a maintainer whose org membership is
|
||||
# private appears as CONTRIBUTOR/NONE, so gating on it would wrongly
|
||||
# enforce against private-member maintainers. getCollaboratorPermission
|
||||
# reflects effective write access regardless of membership visibility.
|
||||
# - The enforcement path is a single github-script step (the upstream
|
||||
# version is split across four, forcing the label/comment/reopen helpers
|
||||
# to be duplicated per scope).
|
||||
# - Issue assignment events are handled in this same workflow so assigning
|
||||
# the linked issue reopens previously closed PRs automatically.
|
||||
#
|
||||
# Maintainer override: reopen the PR, or remove the "missing-issue-link"
|
||||
# label — either applies a sticky "bypass-issue-check" label and reopens.
|
||||
|
||||
name: Require Issue Link
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# SECURITY: pull_request_target runs with repo write scope against the
|
||||
# BASE repo. NEVER check out or execute PR-head code here — it would run
|
||||
# with these permissions. This workflow only reads the PR payload and
|
||||
# calls the API; it never checks anything out.
|
||||
# ready_for_review matters because the job skips drafts: without it a
|
||||
# draft opened with no issue link would never be checked when it later
|
||||
# becomes reviewable.
|
||||
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
|
||||
issues:
|
||||
# Assignment is what makes a previously closed "not assigned" PR compliant,
|
||||
# so it needs a separate event path that finds and reopens matching PRs.
|
||||
types: [assigned]
|
||||
|
||||
# Dry run: when 'false' the check still runs and logs its verdict but makes
|
||||
# NO mutations at all (no label, comment, close, reopen, or failure). Flip
|
||||
# to 'true' to enforce.
|
||||
env:
|
||||
ENFORCE_ISSUE_LINK: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-issue-link:
|
||||
# Cheap pre-filters only. Maintainer detection is deliberately NOT done
|
||||
# here: the job-level `if` can't call the API, and author_association is
|
||||
# unreliable for private org members (see file header). The job runs,
|
||||
# then the script resolves the author's real permission and exits early
|
||||
# for maintainers.
|
||||
#
|
||||
# Gate: only run on pull_request_target events. The workflow also listens
|
||||
# to `issues.assigned` (handled by reopen-on-assignment below), and without
|
||||
# this guard the job would also fire there — `github.event.pull_request` is
|
||||
# null on an issues event, so `...draft == false` coerces to true and the
|
||||
# script then dereferences a missing PR and crashes. Beyond the event type,
|
||||
# skip drafts, bots, and already-bypassed/trusted PRs, and allow the primary
|
||||
# actions plus the one maintainer-override action we care about (removing
|
||||
# the missing-issue-link label).
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
!endsWith(github.actor, '[bot]') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
|
||||
(
|
||||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
|
||||
(github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: require-issue-link-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Enforce issue link
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const action = context.payload.action;
|
||||
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
|
||||
const LABEL = 'missing-issue-link';
|
||||
const MARKER = '<!-- require-issue-link -->';
|
||||
// Issue-level label that waives the assignment requirement.
|
||||
const OPEN_LABEL = 'prs welcome';
|
||||
|
||||
// Dry-run guard: every mutating call goes through this so that
|
||||
// ENFORCE_ISSUE_LINK=false means strictly read-only.
|
||||
async function mutate(description, fn) {
|
||||
if (!enforce) {
|
||||
console.log(`[dry-run] would ${description}`);
|
||||
return;
|
||||
}
|
||||
await fn();
|
||||
}
|
||||
|
||||
// Authoritative maintainer check. Uses collaborator permission,
|
||||
// not org membership or author_association:
|
||||
// - GITHUB_TOKEN is an app token and is never an org member,
|
||||
// so the org-membership endpoint always 403s.
|
||||
// - author_association reports MEMBER only for *public* org
|
||||
// members; a private-member maintainer shows as
|
||||
// CONTRIBUTOR/NONE. Permission level is visibility-
|
||||
// independent and reflects effective access.
|
||||
// 404 (not a collaborator) → not a maintainer. Other errors
|
||||
// (rate limit, 5xx) MUST throw: silently treating them as
|
||||
// "not a maintainer" could wrongly close a maintainer's PR.
|
||||
// A throw aborts the script before any close/label call, so the
|
||||
// job fails red and the PR is left untouched — the safe direction.
|
||||
async function hasWriteAccess(username) {
|
||||
if (!username) throw new Error('No username — cannot check permissions');
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner, repo, username,
|
||||
});
|
||||
const ok = ['admin', 'maintain', 'write'].includes(data.permission);
|
||||
console.log(`${username}: ${data.permission} — ${ok ? 'maintainer' : 'not a maintainer'}`);
|
||||
return ok;
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
console.log(`${username} is not a collaborator — not a maintainer`);
|
||||
return false;
|
||||
}
|
||||
throw new Error(
|
||||
`Permission check failed for ${username} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabel() {
|
||||
await mutate(`label PR #${prNumber} "${LABEL}"`, async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b76e79' });
|
||||
} catch (createErr) {
|
||||
// 422 = created by a concurrent run between GET and POST.
|
||||
if (createErr.status !== 422) throw createErr;
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: [LABEL],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function minimizeStaleComment() {
|
||||
try {
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const stale = comments.find(c => c.body && c.body.includes(MARKER));
|
||||
if (!stale) return;
|
||||
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
|
||||
mutation($id: ID!) {
|
||||
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
`, { id: stale.node_id }));
|
||||
} catch (e) {
|
||||
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared "this PR passes" cleanup: drop the label, reopen, and
|
||||
// retire any stale enforcement comment.
|
||||
//
|
||||
// For the normal pass paths we only reopen if THIS workflow had
|
||||
// closed the PR — inferred from the label still being on the
|
||||
// payload. The maintainer-override paths pass forceReopen: the
|
||||
// `unlabeled` event payload no longer carries the just-removed
|
||||
// label, so the heuristic can't see it; without forcing, the
|
||||
// advertised "remove the label to bypass" gesture would leave
|
||||
// the PR closed.
|
||||
async function clearEnforcement(forceReopen = false) {
|
||||
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: LABEL,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
});
|
||||
const hadLabel = pr.labels.map(l => l.name).includes(LABEL);
|
||||
if (pr.state === 'closed' && (forceReopen || hadLabel)) {
|
||||
await mutate(`reopen PR #${prNumber}`, async () => {
|
||||
await github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'open',
|
||||
});
|
||||
});
|
||||
}
|
||||
await minimizeStaleComment();
|
||||
}
|
||||
|
||||
async function applyBypass(reason) {
|
||||
console.log(reason);
|
||||
await clearEnforcement(true);
|
||||
await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: 'bypass-issue-check', color: '0e8a16',
|
||||
});
|
||||
} catch (createErr) {
|
||||
if (createErr.status !== 422) throw createErr;
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['bypass-issue-check'],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Maintainer-authored PRs are exempt entirely ────────────────
|
||||
if (await hasWriteAccess(pr.user.login)) {
|
||||
console.log(`PR author ${pr.user.login} has write access — exempt`);
|
||||
await clearEnforcement();
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = context.payload.sender?.login;
|
||||
|
||||
// ── Maintainer override: removed the "missing-issue-link" label ─
|
||||
if (action === 'unlabeled') {
|
||||
if (await hasWriteAccess(sender)) {
|
||||
await applyBypass(`Maintainer ${sender} removed ${LABEL} from PR #${prNumber} — bypassing`);
|
||||
return;
|
||||
}
|
||||
// Only triage/admin can manage labels, so a non-write actor
|
||||
// reaching here is rare (triage role). Fall through to the
|
||||
// normal check, which recomputes link + assignment and
|
||||
// re-enforces with the correct message if still failing.
|
||||
console.log(`Non-maintainer ${sender} removed ${LABEL} — re-checking`);
|
||||
}
|
||||
|
||||
// ── Maintainer override: reopened a PR we had closed ───────────
|
||||
if (
|
||||
action === 'reopened' &&
|
||||
pr.labels.map(l => l.name).includes(LABEL) &&
|
||||
(await hasWriteAccess(sender))
|
||||
) {
|
||||
await applyBypass(`Maintainer ${sender} reopened PR #${prNumber} — bypassing`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Race guard: re-read live labels ────────────────────────────
|
||||
const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber,
|
||||
});
|
||||
const liveNames = liveLabels.map(l => l.name);
|
||||
if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
|
||||
console.log('PR carries trusted-contributor or bypass-issue-check — clearing any prior enforcement');
|
||||
await clearEnforcement();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── The actual check: an auto-close keyword + issue number ─────
|
||||
const body = pr.body || '';
|
||||
// Match GitHub's auto-close keywords against any reference form
|
||||
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
|
||||
// shorthand, and the full issue URL. Scope the qualified forms to
|
||||
// THIS repo — GitHub only auto-closes same-repo issues, so a
|
||||
// cross-repo reference must not be resolved against our numbering.
|
||||
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(
|
||||
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
|
||||
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
|
||||
'gi',
|
||||
);
|
||||
const matches = [...body.matchAll(pattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log('No issue link found in PR body');
|
||||
await enforceFailure('no-link');
|
||||
return;
|
||||
}
|
||||
|
||||
// The author must be assigned to at least one linked issue.
|
||||
// CONTRIBUTING.md requires external contributors to be assigned
|
||||
// before opening a PR (so maintainers can deconflict / steer
|
||||
// approach first).
|
||||
//
|
||||
// Exception: an issue labeled OPEN_LABEL waives that requirement
|
||||
// for everyone. It's how maintainers advertise "the reporter
|
||||
// isn't implementing this, we'd take a PR from anyone" without
|
||||
// having to assign a specific person up front. Unlike the
|
||||
// PR-level `trusted-contributor` / `bypass-issue-check` escapes,
|
||||
// this one lives on the *issue* and is set ahead of time.
|
||||
const MAX_ISSUES = 5;
|
||||
const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
|
||||
const numbers = allNumbers.slice(0, MAX_ISSUES);
|
||||
if (allNumbers.length > MAX_ISSUES) {
|
||||
core.warning(`PR references ${allNumbers.length} issues — checking only the first ${MAX_ISSUES}`);
|
||||
}
|
||||
|
||||
const prAuthor = pr.user.login.toLowerCase();
|
||||
let sawRealIssue = false;
|
||||
let assignedToAny = false;
|
||||
for (const num of numbers) {
|
||||
let issue;
|
||||
try {
|
||||
({ data: issue } = await github.rest.issues.get({
|
||||
owner, repo, issue_number: num,
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
console.log(`#${num} does not exist — ignoring`);
|
||||
continue;
|
||||
}
|
||||
// Same safe-direction rule as hasWriteAccess: a transient
|
||||
// error must not be read as "not assigned" and close the PR.
|
||||
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
|
||||
}
|
||||
sawRealIssue = true;
|
||||
|
||||
// GitHub returns labels as objects here, but the REST schema
|
||||
// permits bare strings — normalize both rather than assume.
|
||||
const labelNames = (issue.labels || [])
|
||||
.map(l => (typeof l === 'string' ? l : l && l.name))
|
||||
.filter(Boolean)
|
||||
.map(n => n.toLowerCase());
|
||||
if (labelNames.includes(OPEN_LABEL)) {
|
||||
console.log(`#${num} is labeled "${OPEN_LABEL}" — assignment not required`);
|
||||
assignedToAny = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const assignees = (issue.assignees || []).map(a => a.login.toLowerCase());
|
||||
if (assignees.includes(prAuthor)) {
|
||||
console.log(`PR author ${pr.user.login} is assigned to #${num}`);
|
||||
assignedToAny = true;
|
||||
break;
|
||||
}
|
||||
console.log(`PR author ${pr.user.login} is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
|
||||
}
|
||||
|
||||
if (!sawRealIssue) {
|
||||
console.log('Referenced issue(s) do not exist');
|
||||
await enforceFailure('no-link');
|
||||
return;
|
||||
}
|
||||
if (!assignedToAny) {
|
||||
await enforceFailure('not-assigned');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linked and assigned — clearing any prior enforcement');
|
||||
await clearEnforcement();
|
||||
|
||||
// ── Label, comment, close, and fail ────────────────────────────
|
||||
// `kind`: 'no-link' (no valid issue reference) or 'not-assigned'
|
||||
// (referenced an issue, but the author isn't assigned to it).
|
||||
async function enforceFailure(kind) {
|
||||
await addLabel();
|
||||
|
||||
const reason = kind === 'no-link'
|
||||
? "it doesn't reference a tracked issue assigned to you"
|
||||
: "you aren't assigned to the issue it references";
|
||||
const steps = kind === 'no-link'
|
||||
? [
|
||||
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`,
|
||||
"2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to **this** PR's description — edit it in place, don't open a new PR.",
|
||||
]
|
||||
: [
|
||||
"1. If you opened the linked issue, a maintainer will assign you when they pick it up and this PR reopens automatically. If someone else opened it, the PR reopens only if a maintainer chooses to assign it to you — please don't comment to ask.",
|
||||
];
|
||||
|
||||
const commentBody = [
|
||||
MARKER,
|
||||
"**Don't open a new pull request — this one reopens on its own.** It's closed for " +
|
||||
`now because ${reason}, but the moment that's fixed it reopens automatically. Keep this ` +
|
||||
'PR and edit it; opening a fresh duplicate just starts you over and creates more to triage.',
|
||||
'',
|
||||
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`,
|
||||
'',
|
||||
...steps,
|
||||
'',
|
||||
"Once you're assigned and the link is present, this PR reopens automatically — no further action needed.",
|
||||
'',
|
||||
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
|
||||
].join('\n');
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const existing = comments.find(c => c.body && c.body.includes(MARKER));
|
||||
if (!existing) {
|
||||
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentBody,
|
||||
}));
|
||||
} else if (existing.body !== commentBody) {
|
||||
await mutate(`update comment ${existing.id}`, () => github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body: commentBody,
|
||||
}));
|
||||
} else {
|
||||
console.log('Requirement comment already present — skipping');
|
||||
}
|
||||
|
||||
if (pr.state === 'open') {
|
||||
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'closed',
|
||||
}));
|
||||
}
|
||||
|
||||
if (enforce) {
|
||||
core.setFailed(
|
||||
kind === 'no-link'
|
||||
? 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").'
|
||||
: 'PR author must be assigned to the referenced issue.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
reopen-on-assignment:
|
||||
if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Reopen linked PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const assignee = context.payload.assignee.login;
|
||||
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
|
||||
const LABEL = 'missing-issue-link';
|
||||
const MARKER = '<!-- require-issue-link -->';
|
||||
// Match GitHub's auto-close keywords against any reference form
|
||||
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
|
||||
// shorthand, and the full issue URL. Scope the qualified forms to
|
||||
// THIS repo — GitHub only auto-closes same-repo issues, so a
|
||||
// cross-repo reference must not be resolved against our numbering.
|
||||
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(
|
||||
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
|
||||
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
async function mutate(description, fn) {
|
||||
if (!enforce) {
|
||||
console.log(`[dry-run] would ${description}`);
|
||||
return;
|
||||
}
|
||||
await fn();
|
||||
}
|
||||
|
||||
console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`);
|
||||
|
||||
const q = [
|
||||
'is:pr',
|
||||
'is:closed',
|
||||
`author:${assignee}`,
|
||||
`label:${LABEL}`,
|
||||
`repo:${owner}/${repo}`,
|
||||
].join(' ');
|
||||
|
||||
let search;
|
||||
try {
|
||||
({ data: search } = await github.rest.search.issuesAndPullRequests({
|
||||
q,
|
||||
per_page: 30,
|
||||
}));
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to search closed PRs for ${assignee} after assigning #${issueNumber} ` +
|
||||
`(HTTP ${e.status ?? 'unknown'}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (search.total_count === 0) {
|
||||
console.log('No matching closed PRs found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${search.total_count} candidate PR(s)`);
|
||||
|
||||
for (const item of search.items) {
|
||||
const prNumber = item.number;
|
||||
|
||||
let issue;
|
||||
try {
|
||||
({ data: issue } = await github.rest.issues.get({
|
||||
owner, repo, issue_number: prNumber,
|
||||
}));
|
||||
} catch (e) {
|
||||
throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
|
||||
}
|
||||
|
||||
const labels = (issue.labels || []).map(label => label.name);
|
||||
if (labels.includes('bypass-issue-check')) {
|
||||
console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = issue.body || '';
|
||||
const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10));
|
||||
if (!referencedIssues.includes(issueNumber)) {
|
||||
console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'open',
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e.status === 422) {
|
||||
core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`);
|
||||
await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body:
|
||||
`You have been assigned to #${issueNumber}, but this PR could not be ` +
|
||||
'reopened because the head branch has been deleted. Please open a new PR ' +
|
||||
'referencing the issue.',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: LABEL,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const stale = comments.find(comment => comment.body && comment.body.includes(MARKER));
|
||||
if (stale) {
|
||||
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
|
||||
mutation($id: ID!) {
|
||||
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
`, { id: stale.node_id }));
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber,
|
||||
});
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'require-issue-link.yml',
|
||||
head_sha: pr.head.sha,
|
||||
status: 'failure',
|
||||
per_page: 1,
|
||||
});
|
||||
if (runs.workflow_runs.length === 0) {
|
||||
console.log(`No failed require-issue-link runs found for PR #${prNumber}`);
|
||||
continue;
|
||||
}
|
||||
await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () =>
|
||||
github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner, repo, run_id: runs.workflow_runs[0].id,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
55
.github/workflows/run-schema-crash-test.yml
vendored
Normal file
55
.github/workflows/run-schema-crash-test.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: Schema Crash Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "fastmcp_slim/fastmcp/utilities/json_schema_type.py"
|
||||
- "fastmcp_slim/fastmcp/utilities/json_schema.py"
|
||||
- "fastmcp_slim/fastmcp/utilities/openapi/**"
|
||||
- "fastmcp_slim/fastmcp/server/providers/openapi/**"
|
||||
- "fastmcp_slim/fastmcp/client/mixins/tools.py"
|
||||
- "tests/utilities/json_schema_type/test_real_world_schemas.py"
|
||||
- ".github/workflows/run-schema-crash-test.yml"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "fastmcp_slim/fastmcp/utilities/json_schema_type.py"
|
||||
- "fastmcp_slim/fastmcp/utilities/json_schema.py"
|
||||
- "fastmcp_slim/fastmcp/utilities/openapi/**"
|
||||
- "fastmcp_slim/fastmcp/server/providers/openapi/**"
|
||||
- "fastmcp_slim/fastmcp/client/mixins/tools.py"
|
||||
- "tests/utilities/json_schema_type/test_real_world_schemas.py"
|
||||
- ".github/workflows/run-schema-crash-test.yml"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
schema_crash_test:
|
||||
name: "Real-world schema crash test (232K schemas)"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Clone openapi-directory
|
||||
run: git clone --depth 1 https://github.com/APIs-guru/openapi-directory.git /tmp/openapi-directory
|
||||
|
||||
- name: Run schema crash test
|
||||
env:
|
||||
RUN_REAL_WORLD_SCHEMA_TEST: "1"
|
||||
OPENAPI_DIRECTORY_PATH: /tmp/openapi-directory
|
||||
run: uv run pytest tests/utilities/json_schema_type/test_real_world_schemas.py -m integration -v -n auto --timeout-method=thread
|
||||
8
.github/workflows/run-static.yml
vendored
8
.github/workflows/run-static.yml
vendored
|
|
@ -7,10 +7,12 @@ on:
|
|||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "fastmcp_slim/**"
|
||||
- "fastmcp_remote/**"
|
||||
- "tests/**"
|
||||
- "uv.lock"
|
||||
- "examples/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/**"
|
||||
|
||||
# run on all pull requests because these checks are required and will block merges otherwise
|
||||
|
|
@ -27,7 +29,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
|
|||
166
.github/workflows/run-tests.yml
vendored
166
.github/workflows/run-tests.yml
vendored
|
|
@ -7,10 +7,11 @@ on:
|
|||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "fastmcp_slim/**"
|
||||
- "fastmcp_remote/**"
|
||||
- "tests/**"
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/**"
|
||||
|
||||
# run on all pull requests because these checks are required and will block merges otherwise
|
||||
|
|
@ -36,7 +37,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -47,7 +48,7 @@ jobs:
|
|||
- name: Run unit tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
|
||||
- name: Run client process tests
|
||||
- name: Run serial subprocess tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
with:
|
||||
test-type: client_process
|
||||
|
|
@ -58,7 +59,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv (lowest-direct)
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -68,7 +69,7 @@ jobs:
|
|||
- name: Run unit tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
|
||||
- name: Run client process tests
|
||||
- name: Run serial subprocess tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
with:
|
||||
test-type: client_process
|
||||
|
|
@ -79,7 +80,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -87,7 +88,7 @@ jobs:
|
|||
resolution: locked
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -117,3 +118,150 @@ jobs:
|
|||
FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }}
|
||||
FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }}
|
||||
FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET }}
|
||||
|
||||
package_install_smoke:
|
||||
name: "Package install smoke"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv
|
||||
uses: ./.github/actions/setup-uv
|
||||
with:
|
||||
resolution: locked
|
||||
|
||||
- name: Build package wheels
|
||||
run: uv build --all-packages --wheel --out-dir /tmp/fastmcp-dist
|
||||
|
||||
- name: Install bare slim wheel
|
||||
run: |
|
||||
uv venv /tmp/fastmcp-slim-bare-smoke
|
||||
SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl)
|
||||
uv pip install --python /tmp/fastmcp-slim-bare-smoke/bin/python "$SLIM_WHEEL"
|
||||
/tmp/fastmcp-slim-bare-smoke/bin/python - <<'PY'
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
import fastmcp
|
||||
import fastmcp.settings
|
||||
|
||||
assert any(ep.name == "fastmcp" for ep in entry_points(group="console_scripts"))
|
||||
|
||||
try:
|
||||
from fastmcp.cli import app
|
||||
except ImportError as exc:
|
||||
assert "FastMCP CLI support is not installed" in str(exc)
|
||||
else:
|
||||
raise AssertionError(f"bare fastmcp-slim unexpectedly imported CLI app {app!r}")
|
||||
|
||||
try:
|
||||
fastmcp.FastMCP
|
||||
except ImportError as exc:
|
||||
assert "fastmcp-slim[server]" in str(exc)
|
||||
else:
|
||||
raise AssertionError("bare fastmcp-slim unexpectedly imported FastMCP")
|
||||
PY
|
||||
|
||||
- name: Install client slim wheel
|
||||
run: |
|
||||
uv venv /tmp/fastmcp-slim-client-smoke
|
||||
SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl)
|
||||
uv pip install --python /tmp/fastmcp-slim-client-smoke/bin/python "${SLIM_WHEEL}[client]"
|
||||
/tmp/fastmcp-slim-client-smoke/bin/python - <<'PY'
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import StdioTransport, StreamableHttpTransport
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
|
||||
assert any(ep.name == "fastmcp" for ep in entry_points(group="console_scripts"))
|
||||
|
||||
try:
|
||||
from fastmcp.cli import app
|
||||
except ImportError as exc:
|
||||
assert "FastMCP CLI support is not installed" in str(exc)
|
||||
else:
|
||||
raise AssertionError(f"client-only slim unexpectedly imported CLI app {app!r}")
|
||||
|
||||
assert Client("https://example.com/mcp")
|
||||
assert StreamableHttpTransport("https://example.com/mcp")
|
||||
assert StdioTransport(command="uvx", args=["demo"])
|
||||
assert MCPConfig.from_dict({"mcpServers": {"demo": {"url": "https://example.com/mcp"}}})
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
except ImportError as exc:
|
||||
assert "fastmcp-slim[server]" in str(exc)
|
||||
else:
|
||||
raise AssertionError(f"client-only slim unexpectedly imported {FastMCP!r}")
|
||||
PY
|
||||
|
||||
- name: Install server slim wheel
|
||||
run: |
|
||||
uv venv /tmp/fastmcp-slim-server-smoke
|
||||
SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl)
|
||||
uv pip install --python /tmp/fastmcp-slim-server-smoke/bin/python "${SLIM_WHEEL}[server]"
|
||||
/tmp/fastmcp-slim-server-smoke/bin/python - <<'PY'
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.cli import app
|
||||
|
||||
assert any(
|
||||
ep.name == "fastmcp" and ep.value == "fastmcp.cli:app"
|
||||
for ep in entry_points(group="console_scripts")
|
||||
)
|
||||
|
||||
mcp = FastMCP("smoke")
|
||||
assert app is not None
|
||||
assert mcp.name == "smoke"
|
||||
PY
|
||||
|
||||
- name: Install full package from matching local wheels
|
||||
run: |
|
||||
uv venv /tmp/fastmcp-full-smoke
|
||||
FULL_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp-*.whl)
|
||||
uv pip install --python /tmp/fastmcp-full-smoke/bin/python --prerelease=allow --find-links /tmp/fastmcp-dist "$FULL_WHEEL"
|
||||
/tmp/fastmcp-full-smoke/bin/python - <<'PY'
|
||||
from importlib.metadata import entry_points
|
||||
from importlib.metadata import requires
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.client import CallToolResult
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
fastmcp_reqs = requires("fastmcp") or []
|
||||
assert any("fastmcp-slim[client,server]" in req for req in fastmcp_reqs)
|
||||
assert not any("fastmcp-slim[full" in req for req in fastmcp_reqs)
|
||||
|
||||
assert any(
|
||||
ep.name == "fastmcp" and ep.value == "fastmcp.cli:app"
|
||||
for ep in entry_points(group="console_scripts")
|
||||
)
|
||||
|
||||
assert Client("https://example.com/mcp")
|
||||
assert FastMCP("smoke").name == "smoke"
|
||||
assert CallToolResult is not None
|
||||
assert ToolError is not None
|
||||
PY
|
||||
|
||||
- name: Install fastmcp-remote from matching local wheels
|
||||
run: |
|
||||
uv venv /tmp/fastmcp-remote-smoke
|
||||
REMOTE_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_remote-*.whl)
|
||||
uv pip install --python /tmp/fastmcp-remote-smoke/bin/python --prerelease=allow --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
|
||||
/tmp/fastmcp-remote-smoke/bin/python - <<'PY'
|
||||
from importlib.metadata import entry_points
|
||||
from importlib.metadata import requires
|
||||
|
||||
from fastmcp_remote.cli import build_parser
|
||||
|
||||
remote_reqs = requires("fastmcp-remote") or []
|
||||
assert any("fastmcp-slim[client,server]" in req for req in remote_reqs)
|
||||
assert any(
|
||||
ep.name == "fastmcp-remote" and ep.value == "fastmcp_remote.cli:main"
|
||||
for ep in entry_points(group="console_scripts")
|
||||
)
|
||||
assert build_parser().prog == "fastmcp-remote"
|
||||
PY
|
||||
|
|
|
|||
14
.github/workflows/run-upgrade-checks.yml
vendored
14
.github/workflows/run-upgrade-checks.yml
vendored
|
|
@ -7,10 +7,10 @@ on:
|
|||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "fastmcp_slim/**"
|
||||
- "tests/**"
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/**"
|
||||
|
||||
schedule:
|
||||
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv (upgrade)
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv (upgrade)
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
- name: Run unit tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
|
||||
- name: Run client process tests
|
||||
- name: Run serial subprocess tests
|
||||
uses: ./.github/actions/run-pytest
|
||||
with:
|
||||
test-type: client_process
|
||||
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup uv (upgrade)
|
||||
uses: ./.github/actions/setup-uv
|
||||
|
|
@ -121,7 +121,7 @@ jobs:
|
|||
|
||||
- **ty (type checker)**: New ty releases frequently add stricter checks that flag previously-accepted code. Run `uv run ty check` locally with the latest ty to reproduce. Fix the type errors or bump the ty version floor in `pyproject.toml`.
|
||||
- **ruff**: New lint rules or stricter defaults in a ruff upgrade.
|
||||
- **mcp SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types).
|
||||
- **MCP SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types).
|
||||
|
||||
### What to do
|
||||
|
||||
|
|
|
|||
10
.github/workflows/update-config-schema.yml
vendored
10
.github/workflows/update-config-schema.yml
vendored
|
|
@ -7,8 +7,8 @@ on:
|
|||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/fastmcp/utilities/mcp_server_config/**"
|
||||
- "!src/fastmcp/utilities/mcp_server_config/v1/schema.json"
|
||||
- "fastmcp_slim/fastmcp/utilities/mcp_server_config/**"
|
||||
- "!fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
from fastmcp.utilities.mcp_server_config import generate_schema
|
||||
generate_schema('docs/public/schemas/fastmcp.json/latest.json')
|
||||
generate_schema('docs/public/schemas/fastmcp.json/v1.json')
|
||||
generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json')
|
||||
generate_schema('fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json')
|
||||
"
|
||||
|
||||
- name: Create Pull Request
|
||||
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
body: |
|
||||
This PR updates the fastmcp.json schema files to match the current source code.
|
||||
|
||||
The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency.
|
||||
The schema is automatically generated from `fastmcp_slim/fastmcp/utilities/mcp_server_config/` to ensure consistency.
|
||||
|
||||
**Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means.
|
||||
|
||||
|
|
|
|||
6
.github/workflows/update-sdk-docs.yml
vendored
6
.github/workflows/update-sdk-docs.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
|||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "fastmcp_slim/**"
|
||||
- "pyproject.toml"
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
run: uv sync --python 3.12
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v3
|
||||
uses: extractions/setup-just@v4
|
||||
|
||||
- name: Generate SDK documentation
|
||||
run: just api-ref-all
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ repos:
|
|||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.8.4
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [yaml, json5]
|
||||
|
|
@ -29,7 +29,7 @@ repos:
|
|||
entry: uv run --isolated ty check
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^src/|^tests/
|
||||
files: ^fastmcp_slim/|^tests/|^examples/
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
|
||||
|
|
|
|||
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -27,7 +27,7 @@ uv run prek run --all-files # Ruff + Prettier + ty
|
|||
|
||||
| Path | Purpose |
|
||||
| ----------------- | -------------------------------------- |
|
||||
| `src/fastmcp/` | Library source code |
|
||||
| `fastmcp_slim/fastmcp/` | Library source code |
|
||||
| `├─server/` | Server implementation |
|
||||
| `│ ├─auth/` | Authentication providers |
|
||||
| `│ └─middleware/` | Error handling, logging, rate limiting |
|
||||
|
|
@ -50,19 +50,38 @@ When modifying MCP functionality, changes typically need to be applied across al
|
|||
- **Resource Templates** (`src/resources/`)
|
||||
- **Prompts** (`src/prompts/`)
|
||||
|
||||
**Before writing cross-component logic (dedupe, grouping, lookups, identity checks), read `FastMCPComponent` in `fastmcp_slim/fastmcp/utilities/components.py`.** The base class defines the shared surface — `name`, `version`, `tags`, `meta`, and critically the `key` property which is the canonical MCP identity (encodes type, identifier, and version). Prefer `item.key` over ad-hoc `name or uri or uri_template` fallbacks; overrides in `Resource` and `ResourceTemplate` already handle URI-based identity, and `.key` includes the version suffix so variants of the same component don't falsely collide.
|
||||
|
||||
## Development Rules
|
||||
|
||||
**Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review.
|
||||
|
||||
**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it.
|
||||
|
||||
### Git & CI
|
||||
|
||||
- Prek hooks are required (run automatically on commits)
|
||||
- Never amend commits to fix prek failures
|
||||
- Apply PR labels: bugs/breaking/enhancements/features
|
||||
- Never apply labels manually or invent new ones — issues and PRs are auto-labeled by a bot based on title/body/code changes. Don't note a "suggested" or "appropriate" label anywhere in the PR body either. See the review-pr skill.
|
||||
- Improvements = enhancements (not features) unless specified
|
||||
- **NEVER** force-push on collaborative repos
|
||||
- **ALWAYS** run prek before PRs
|
||||
- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
|
||||
- **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session.
|
||||
- **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view <num> --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted.
|
||||
- **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship.
|
||||
- **Resolve a review thread when you fix it; reply when you're declining it.** A fix explains itself through the commit, so resolving is enough — and it leaves unresolved threads meaning unfinished business, which is the signal worth having. A decline needs a one-line reason in a reply, because resolving collapses the thread and a hidden objection is worse than a visible one. Doing both is noise. Get thread ids from the GraphQL `reviewThreads` field, then resolve:
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='query($n:Int!){repository(owner:"PrefectHQ",name:"fastmcp"){pullRequest(number:$n){reviewThreads(first:50){nodes{id isResolved path}}}}}' -F n=<pr-number>
|
||||
gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id=PRRT_...
|
||||
```
|
||||
|
||||
### Outbound Comments and Shell Interpolation
|
||||
|
||||
- Never pass GitHub, Linear, or Slack comment bodies inline through shell arguments when the body contains `$`, `${...}`, backticks, `$(...)`, environment-variable examples, secrets, or config interpolation examples.
|
||||
- Use a body file or structured API payload for outbound comments, then inspect the exact outgoing text before posting. Prefer `gh ... --body-file /path/to/comment.md` over `--body "..."`.
|
||||
- When explaining environment interpolation, use placeholders and fenced code blocks. Never include raw `.env` contents in outbound comments.
|
||||
|
||||
### Releases
|
||||
|
||||
|
|
@ -73,19 +92,42 @@ Only cut releases when the maintainer explicitly asks. Tags follow `v<version>`
|
|||
Write the maintainer-approved handwritten notes to a temporary file, then create the release. `--generate-notes` appends the auto-generated changelog after the handwritten content.
|
||||
|
||||
```bash
|
||||
gh release create v3.2.0 --target main --title "v3.2.0: Theme Here" --generate-notes --notes-file /tmp/release-notes.md
|
||||
gh release create v4.0.0 --target main --title "v4.0.0: Theme Here" --generate-notes --notes-start-tag v3.4.4 --notes-file /tmp/release-notes.md
|
||||
```
|
||||
|
||||
Most releases target `main`, but maintenance or backport releases may target a different branch (e.g., `release/2.x`). Confirm the target with the maintainer if there's any ambiguity.
|
||||
**Always pass `--notes-start-tag <last-stable-tag>`.** Without it, `--generate-notes` picks the most recent prior tag as the changelog start point — and if a prerelease exists (e.g. `v3.4.0b1`), it starts from *that*, silently truncating the PR list to only the commits since the beta. Pin it to the last stable release (e.g. `v3.3.1` when cutting `v3.4.0`). Verify after: the compare link at the bottom of the generated notes should read `v<last-stable>...v<new>`.
|
||||
|
||||
Use the branch that owns the release line as the target: current-major releases target `main`, 3.x maintenance releases target `release/3.x`, and 2.x maintenance releases target `release/2.x`. Confirm the target with the maintainer if there's any ambiguity. For example, cut a 3.4.4 maintenance release with `--target release/3.x`, not `main`.
|
||||
|
||||
The handwritten notes are prepended above the auto-generated changelog and are the part that matters. Do not include a title in the notes body — the release title (`v{version}: {pun}`) already serves as the heading. Work with the maintainer to draft the notes — propose a draft, get feedback, iterate. Do not publish without the maintainer's sign-off.
|
||||
|
||||
**Before drafting, always read recent existing releases** (`gh release list` then `gh release view <tag>`) to absorb the voice, structure, and level of detail. Each release builds on the tone of previous ones — don't guess at the style from these instructions alone.
|
||||
|
||||
**To preview what PRs will be in the release** before it's cut, call the GitHub generate-notes API. This returns the exact auto-generated changelog that `--generate-notes` would append, so you can see the full PR list — useful for picking a pun theme and making sure nothing's been missed:
|
||||
|
||||
```bash
|
||||
gh api -X POST repos/PrefectHQ/fastmcp/releases/generate-notes \
|
||||
-f tag_name=v3.2.3 \
|
||||
-f target_commitish=main \
|
||||
-f previous_tag_name=v3.2.2 \
|
||||
--jq '.body'
|
||||
```
|
||||
|
||||
Set `target_commitish` to the same branch that will receive the release tag. For maintenance releases, use the maintenance branch (for example, `release/3.x`) so the preview matches the release notes GitHub will generate.
|
||||
|
||||
**Point releases** (3.0, 3.1, 3.2) get narrative prose: open with the theme of the release, then walk through headline features conceptually — what they enable, why they matter, how they fit together. Write it the way a blog post reads, not a changelog. Multiple paragraphs, code examples where they clarify.
|
||||
|
||||
**Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details.
|
||||
|
||||
**Publish docs through a PR.** The `published-docs` branch serves gofastmcp.com, and repository rules reject direct pushes and force-pushes to it. Stable releases from `main` automatically open a publication PR after PyPI succeeds. For prereleases and later docs follow-ups, create the same PR manually: start a temporary branch from the current `published-docs`, make a single commit whose tree exactly matches the desired commit on `main`, and use `published-docs` as the PR base. Merging publishes to production. Never push directly to `published-docs`.
|
||||
|
||||
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job opens a PR that syncs `published-docs` to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's publication PR will not include the changelog; publish `main` manually through the PR flow above or wait for the next default-branch stable release. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
|
||||
|
||||
- `docs/changelog.mdx` is the full mirror. Add an `<Update label="v<version>" description="YYYY-MM-DD">` block with: a bold linked title (`**[v<version>: <pun>](<release-url>)**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* <title> by [@user](https://github.com/user) in [#NNNN](<pull-url>)`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB](<compare-url>)` line.
|
||||
- `docs/updates.mdx` is the skimmable card feed. Add an `<Update label="FastMCP <version>" description="Month DD, YYYY" tags={["Releases"]}>` wrapping a `<Card>` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights.
|
||||
|
||||
Because the docs land *before* the tag exists, derive the entry from the maintainer-approved handwritten notes (intro/summary) and the `--generate-notes` API *preview* (the PR-list body — see the generate-notes API call above, which returns the exact changelog without cutting anything). Scripting the link reformatting is reliable for long PR lists. The release-URL, tag, and compare links follow the known pattern (`/releases/tag/v<version>`, `compare/v<last-stable>...v<version>`) and will 404 only during the short window between merging the docs PR and cutting the release minutes later — they resolve before the release workflow completes. For this reason, create and merge the docs PR *immediately* before cutting the release — treat the two as one tight back-to-back sequence, not independent steps — so the links are valid by the time the release publishes rather than dangling for any longer than necessary.
|
||||
|
||||
### Commit Messages and Agent Attribution
|
||||
|
||||
- **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "🤖 Generated with Claude Code" in commits/PRs)
|
||||
|
|
@ -121,6 +163,7 @@ The handwritten notes are prepended above the auto-generated changelog and are t
|
|||
|
||||
### Module Exports
|
||||
|
||||
- **Do not create overeager `__init__.py` files.** Package initializers should not import heavy submodules, provider stacks, optional integrations, or modules that can point back into the package. Overeager re-exports make the framework sprawl and create circular imports that only appear in fresh interpreters or clean installs.
|
||||
- **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces
|
||||
- Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`)
|
||||
- Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`)
|
||||
|
|
@ -132,9 +175,9 @@ The handwritten notes are prepended above the auto-generated changelog and are t
|
|||
- Uses Mintlify framework
|
||||
- Files must be in docs.json to be included
|
||||
- Do not manually modify `docs/python-sdk/**` — these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs.
|
||||
- Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR.
|
||||
- Do not manually modify `docs/public/schemas/**` or `fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR.
|
||||
- **Core Principle:** A feature doesn't exist unless it is documented!
|
||||
- When adding or modifying settings in `src/fastmcp/settings.py`, update `docs/more/settings.mdx` to match.
|
||||
- When adding or modifying settings in `fastmcp_slim/fastmcp/settings.py`, update `docs/more/settings.mdx` to match.
|
||||
|
||||
### Documentation Guidelines
|
||||
|
||||
|
|
@ -145,6 +188,20 @@ The handwritten notes are prepended above the auto-generated changelog and are t
|
|||
- **Style:** Prose over code comments for important information
|
||||
- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead.
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### Framework regressions and root causes
|
||||
|
||||
- Review changes carefully for regressions in supported framework behavior, including interactions beyond the immediate diff. Trace relevant callers, shared abstractions, protocol and public API contracts, and all affected MCP component types. Determine whether a change fixes the causal code path or merely compensates for the symptom; side channels and special cases that leave the root cause intact should be treated as suspect.
|
||||
|
||||
### Comprehensive first pass
|
||||
|
||||
- Review the entire pull request diff against the merge base, not only the latest commits. Inspect every changed file and the relevant surrounding code, collect all independent, substantiated consequential findings before submitting the review, and report the complete set in one review whenever possible. Do not stop after finding the first few issues or defer other already-visible findings to later review cycles.
|
||||
|
||||
### Prior discussion and proportionality
|
||||
|
||||
- When prior review threads and author or maintainer replies are available, read them before commenting. Evaluate responses on their merits and do not repeat a resolved or convincingly rebutted finding without new evidence. Avoid fixating on speculative edge cases: report an edge case only when it is reachable under supported usage or a credible threat model and has meaningful impact; otherwise omit it or clearly treat it as non-blocking.
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
- Never use bare `except` - be specific with exception types
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
FastMCP is an actively maintained, high-traffic project. We welcome contributions — but the most impactful way to contribute might not be what you expect.
|
||||
|
||||
Participation is governed by our [Code of Conduct](CODE_OF_CONDUCT.md), and contributions are licensed under [Apache 2.0](LICENSE).
|
||||
|
||||
## The best contribution is a great issue
|
||||
|
||||
FastMCP is an opinionated framework, and its maintainers use AI-assisted tooling that is deeply tuned to those opinions — the design philosophy, the API patterns, the way the framework is meant to evolve. A well-written issue with a clear problem description is often more valuable than a pull request, because it lets maintainers produce a solution that isn't just correct, but consistent with how the framework wants to work. That matters more than speed, though it's faster too.
|
||||
|
|
@ -18,9 +20,17 @@ That's it. No need to diagnose root causes, propose API designs, or suggest impl
|
|||
|
||||
We encourage you to use LLMs to help identify bugs, write MREs, and prepare contributions. But if you do, your LLM must take into account the conventions and contributing guidelines of this repo — including how we want issues formatted and when it's appropriate to open a PR. Generic LLM output that ignores these guidelines tells us the contribution wasn't made thoughtfully, and we will close it. A good AI-assisted contribution is indistinguishable from a good human one. A bad one is obvious.
|
||||
|
||||
If you're driving an agent: do **not** have it post comments asking to be assigned to an issue or announcing that it intends to work on one. Those comments are ignored. If the agent intends to contribute, open a PR instead — it will be gated on assignment (see below). Comment on an issue only to propose a genuinely novel, differentiated solution, never to claim a task that's already described.
|
||||
|
||||
## When to open a pull request
|
||||
|
||||
An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first — especially for anything beyond a trivial fix.
|
||||
An open issue is not an invitation to submit a PR, and it is not a queue you join by commenting. Issues track problems; who implements them and how is a separate decision maintainers make, and whoever opened the issue has first claim on it.
|
||||
|
||||
**Don't post drive-by comments claiming an issue** — "can I work on this?", "please assign me", "I'll take this." They don't affect who gets assigned, they're the most common form of noise we get, and automated versions are ignored. Whoever opens the issue has first claim on it; if that's you, a maintainer will assign you. If you want to implement something someone else reported, just open a PR — you don't need permission to try, and competing PRs are fine — but it's reviewed only if a maintainer assigns you to the issue, which usually won't happen if the reporter intends to handle it. The one comment worth posting is a genuinely different approach worth discussing; a substantive design proposal is welcome, a bare claim on the task is not.
|
||||
|
||||
**Issues labeled `prs welcome` skip the assignment gate.** When we apply that label, we're saying the reporter isn't implementing it and we'd take a PR from anyone. Open one directly — no assignment needed, and it won't be auto-closed. Still reference the issue (`Fixes #123`), since that's how the check knows which issue to look at.
|
||||
|
||||
**What assignment means.** Being assigned is a commitment on both sides: we'll review your work seriously, and you'll see it through. That means responding to review feedback yourself and being able to explain any part of your change and why you made it that way. Use whatever tooling you like to get there — but if you can't answer a question about your own diff, we'll unassign the issue so someone else can pick it up.
|
||||
|
||||
**Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead.
|
||||
|
||||
|
|
@ -34,7 +44,10 @@ An open issue is not an invitation to submit a PR. Issues track problems; whethe
|
|||
|
||||
If you do open a PR:
|
||||
|
||||
- **Reference an issue.** Every PR should address a tracked issue. If there isn't one, open an issue first. This isn't a permission step — you don't need to wait for a response. But the issue gives us context on the problem, and if a maintainer is already working on it, we can let you know before you invest time in code.
|
||||
- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you — unless it's labeled `prs welcome`, which waives the assignment requirement. If there isn't an issue, open one. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet these conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned.
|
||||
- **Leave "Allow edits by maintainers" enabled.** We frequently take a PR the last few steps ourselves rather than block on another round trip — tightening a test, adjusting naming, rebasing. It's enabled by default on PRs from personal forks; leave it that way. GitHub doesn't allow it at all for forks owned by an organization, so if you're contributing from one, expect us to land the final changes separately.
|
||||
- **Target the right branch.** Open against `main` unless you're fixing something specific to a maintenance line, in which case target that branch directly (`release/3.x`, `release/2.x`).
|
||||
- **If your PR was auto-closed, don't open a new one.** Edit the *existing* PR to add the issue link, get assigned to that issue, and it reopens on its own — the branch and history are preserved. A duplicate PR just starts you over and adds to the triage pile.
|
||||
- **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors.
|
||||
- **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting.
|
||||
- **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior.
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -17,15 +17,16 @@
|
|||
[](https://gofastmcp.com)
|
||||
[](https://discord.gg/uu8dJCgttd)
|
||||
[](https://pypi.org/project/fastmcp)
|
||||
[](https://github.com/PrefectHQ/fastmcp-ts)
|
||||
[](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml)
|
||||
[](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE)
|
||||
|
||||
<a href="https://trendshift.io/repositories/13266" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13266" alt="prefecthq%2Ffastmcp | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/21461" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21461" alt="prefecthq%2Ffastmcp | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production:
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP is a full MCP application framework for servers, clients, and interactive apps. A server starts with ordinary Python:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -77,22 +78,35 @@ FastMCP has three pillars:
|
|||
|
||||
**[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
|
||||
|
||||
Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). When you're ready to deploy, [Prefect Horizon](https://www.prefect.io/horizon) offers free hosting for FastMCP users.
|
||||
**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Same pillars, same ideas, `npm install @prefecthq/fastmcp-ts`.
|
||||
|
||||
Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart).
|
||||
|
||||
## Scale MCP with Horizon
|
||||
|
||||
FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used.
|
||||
|
||||
FastMCP and Horizon are built by the same team at [Prefect](https://www.prefect.io/).
|
||||
|
||||
Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents.
|
||||
|
||||
Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_cta)
|
||||
|
||||
## Installation
|
||||
|
||||
We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/):
|
||||
We recommend adding FastMCP to your project with [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
```bash
|
||||
uv pip install fastmcp
|
||||
uv add fastmcp
|
||||
```
|
||||
|
||||
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
|
||||
|
||||
**Upgrading?** We have guides for:
|
||||
- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk)
|
||||
- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk)
|
||||
- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3)
|
||||
- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
|
||||
- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2)
|
||||
- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
|
|
|||
1481
dev-docs/v3-notes/v3-features.md
Normal file
1481
dev-docs/v3-notes/v3-features.md
Normal file
File diff suppressed because it is too large
Load diff
153
dev-docs/v4-notes/background-tasks.md
Normal file
153
dev-docs/v4-notes/background-tasks.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
title: Background Tasks (SEP-2663)
|
||||
---
|
||||
|
||||
**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](feature-program.md#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](https://gofastmcp.com/servers/tasks) and [Background Tasks (client)](https://gofastmcp.com/clients/tasks).
|
||||
|
||||
## TL;DR
|
||||
|
||||
Background tasks live on. The MCP spec moved them out of core and into a **Final, merged** extension — `io.modelcontextprotocol/tasks` (SEP-2663) — that keeps the polling model FastMCP already implements. **No SDK, in any language, ships a runtime for it yet.** FastMCP owns the only production-shaped execution engine (Docket/Redis) built for a near-identical protocol.
|
||||
|
||||
The plan: **rebuild task support on SEP-2663 as `fastmcp-tasks`, an in-repo optional package**, gated by `task=True` exactly as MCP Apps is gated by `app=True`. Remove the SEP-1686 *wire layer*; keep and re-home the *execution engine*. Along the way, introduce a **FastMCP-native server extension API** so tasks (and later Apps) plug in through one documented mechanism instead of bespoke surgery on core.
|
||||
|
||||
Net effect: a server that already uses `@mcp.tool(task=True)` needs **no code change**, and FastMCP plausibly becomes the first runtime implementation of the tasks extension anywhere.
|
||||
|
||||
## Background: where tasks stand today
|
||||
|
||||
FastMCP 3 shipped background tasks against **SEP-1686**, the task protocol that briefly lived in the core MCP spec. The implementation is ~4,000 lines across server, client, CLI, and an SDK shim, split into two very different halves:
|
||||
|
||||
- **A wire layer** — capability advertisement, the `tasks/get|result|list|cancel` handlers, a `CreateTaskResult` on augmented `tools/call`, and a Redis-backed *push* relay that lets a worker reach a client to deliver notifications and elicitation requests.
|
||||
- **An execution engine** — [Docket](https://github.com/chrisguidry/docket) (queue, worker, result store, TTL, `memory://` or `redis://` backends) plus FastMCP-built durability: auth-scoped compound keys that isolate task access by caller, request-context snapshot/restore across worker processes, argument-coercion parity with the sync path, and the `fastmcp tasks worker` CLI.
|
||||
|
||||
The SDK v2 migration removed SEP-1686 from the core spec. The v4 design notes, until now, recorded the consequence as "delete the task machinery; users who need tasks stay on FastMCP 3." That was the right call **given the information at the time** — the assumption was that the successor protocol either didn't exist or wasn't implementable. Both halves of that assumption turned out to be wrong.
|
||||
|
||||
## What changed upstream: SEP-2663
|
||||
|
||||
Tasks were reworked, not removed. **SEP-2663 ("Tasks Extension") is Final and was merged upstream on 2026-05-15**, superseding SEP-1686. It defines the `io.modelcontextprotocol/tasks` extension, a capability-negotiated feature layered on the SEP-2133 extensions mechanism. It keeps SEP-1686's polling core and tightens it.
|
||||
|
||||
**The wire shape:**
|
||||
|
||||
1. Client advertises the tasks capability (per-request, in `_meta`). This is *consent* — "I can handle a task result" — not a request to run one.
|
||||
2. Client issues a normal `tools/call`. **The server decides** whether to run it as a task.
|
||||
3. If tasked, the server returns a `CreateTaskResult` (a claimed result shape carrying `resultType: "task"`) with a **server-generated** `taskId`.
|
||||
4. Client polls `tasks/get` until the status is terminal; the result is **inlined** into that response.
|
||||
5. In-task input (elicit/sample/roots requested *during* execution) is **poll-based**: status flips to `input_required`, outstanding requests appear in an `inputRequests` map, and the client answers via `tasks/update`.
|
||||
6. `tasks/cancel` is cooperative. Optional push exists (`notifications/tasks` over `subscriptions/listen`) but servers need not send it.
|
||||
|
||||
**Delta from SEP-1686** — and the striking thing is that most of it is *deletion*, because the spec moved toward what FastMCP already built:
|
||||
|
||||
| Dimension | SEP-1686 (old) | SEP-2663 (new) | FastMCP today |
|
||||
| --- | --- | --- | --- |
|
||||
| Task-id generation | Client-generated | **Server**-generated | Already server-generated |
|
||||
| `tasks/list` | Present | **Removed** (enumeration risk) | Already a stub returning `[]` |
|
||||
| Result retrieval | Separate `tasks/result` | **Inlined** into `tasks/get` | Merge two handlers into one |
|
||||
| `tasks/delete` | Present | **Removed** (rely on TTL) | TTL is Docket-native |
|
||||
| Creation race | `notifications/tasks/created` | **Durable-creation MUST** | One read-your-writes check away |
|
||||
| In-task input | Push relay + `_meta` tagging | **Poll**: `input_required` + `tasks/update` | Replaces the hairiest module |
|
||||
| Statuses | 7 (incl. `submitted`, `unknown`) | 5 | Shrinks a mapping table |
|
||||
| Augmentable requests | Any | **`tools/call` only** | Tools-only surface (see scope) |
|
||||
| LB routing | Unspecified | `Mcp-Name: <taskId>` header | Moot with shared Redis |
|
||||
|
||||
**Critically: no runtime exists.** The `ext-tasks` repo is schema + prose only. The TypeScript and Python SDKs carry the wire types and conformance fixtures — no client/server implementation. The field is open.
|
||||
|
||||
## The decision
|
||||
|
||||
**Build it.** Two facts flip the earlier "delete and wait" call:
|
||||
|
||||
1. **The spec is what FastMCP already implements**, minus a push relay it can now shed. The rebuild is dominated by deletion and a thin new wire adapter, not a from-scratch effort.
|
||||
2. **FastMCP is uniquely positioned.** SEP-2663 *assumes* a durable server-side store, server-minted high-entropy ids, eventual-consistency-aware creation, and multi-node routing — precisely what Docket/Redis provides. No other framework has this built.
|
||||
|
||||
Maintaining the SEP-1686 machinery through the migration is dead weight (it's the sole reason for the `_sdk_patches.py` shim, the `TaskNotificationHandler`, and a cluster of protocol-era xfails). Rebuilding on SEP-2663 clears that debt *and* produces a flagship v4 capability with a zero-code-change migration story.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Engine and wire split
|
||||
|
||||
The existing code already separates cleanly along this line; the rebuild makes the boundary a package boundary.
|
||||
|
||||
- **Removed:** the SEP-1686 wire layer — capability advertisement, the four CRUD handlers, and (the big win) the entire Redis push relay (`server/tasks/elicitation.py`, `notifications.py`), which existed only because SEP-1686 had no poll-based in-task input channel. SEP-2663's `input_required`/`tasks/update` replaces it; the request/response store survives, the push envelope does not.
|
||||
- **Kept and re-homed:** the Docket execution engine, the auth-scoped key encoding (this is our *authorization* layer for `tasks/get`/`update`/`cancel` — stronger than the spec's "taskIds may be bearer tokens"), context snapshot/restore, argument coercion, and the worker CLI. All of it is wire-agnostic.
|
||||
- **New:** a thin SEP-2663 wire adapter — capability, the `tasks/get`/`update`/`cancel` methods, and a `tools/call` interceptor that decides-and-tasks.
|
||||
|
||||
### Packaging
|
||||
|
||||
`fastmcp-tasks` becomes an in-repo `uv` workspace member on the `fastmcp_remote` template (own `pyproject.toml`, lockstep-versioned, re-exported through the `fastmcp` metapackage). The DX parallel with MCP Apps is exact:
|
||||
|
||||
| Concern | MCP Apps | Background tasks |
|
||||
| --- | --- | --- |
|
||||
| Authoring flag (core) | `@mcp.tool(app=True)` | `@mcp.tool(task=True)` |
|
||||
| Optional package | `prefab-ui` | `fastmcp-tasks` |
|
||||
| Extra | `fastmcp[apps]` | `fastmcp[tasks]` |
|
||||
| Missing-package behavior | Loud install hint | Loud install hint at server build |
|
||||
|
||||
**Core keeps only the declaration:** `task=True` / `TaskConfig` is metadata on a component, with no engine import. Everything else — engine and wire adapter — lives in the `fastmcp-tasks` package. The existing `[tasks]` extra re-points from the SEP-1686 machinery to `fastmcp-tasks`, so `pip install fastmcp[tasks]` and `task=True` keep working with modern wire underneath.
|
||||
|
||||
Activation stays **implicit-but-loud** (the existing `require_docket()` pattern, not silent degradation): `task=True` anywhere triggers a lazy import of `fastmcp-tasks` at build time; a missing install raises immediately. A tool the author marked as a task silently running inline would be a correctness bug, not a graceful fallback.
|
||||
|
||||
### The extension API
|
||||
|
||||
MCP extensions (SEP-2133) are a **genuinely new abstraction in SDK v2** — they did not exist in v1. So MCP Apps hand-rolling its integration wasn't a wrong choice; it predates the tool. Today FastMCP's **server** bypasses the SDK's `Extension` class entirely (it hand-splices the `ui` capability onto the low-level server and walks tool metadata directly), while the **client** forwards `ClientExtension` natively. Every new protocol extension currently means bespoke core surgery.
|
||||
|
||||
Tasks is the forcing function to fix that. The design adds a single registration point:
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("Server")
|
||||
mcp.add_extension(TasksExtension(url="redis://...")) # required to enable tasks
|
||||
|
||||
|
||||
@mcp.tool(task=True) # intent: this tool CAN run as a task
|
||||
async def crunch(dataset: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
`add_extension` is **required** for `task=True` to work — it is not autodetected from the presence of `task=True` flags. This is deliberate. The extension needs configuration that has to live somewhere (backend URL, worker concurrency, TTL defaults), and `add_extension(TasksExtension(...))` is its natural home; autodetection would only scatter that config into settings/env and hide the moment of enablement. Requiring it also keeps capability advertisement honest — the server advertises the `tasks` capability iff the extension is registered — and removes the worst footgun, a tool silently running on an in-memory backend in production because nobody configured Redis. The two concerns stay cleanly separated: `task=True` is per-component intent ("this tool *can* be a task"); `add_extension` is server-wide enablement and config ("this server *runs* tasks, here's how"). Using `task=True` with no extension registered is a loud build-time error.
|
||||
|
||||
The extension API contributes a negotiated capability, additive request methods, and a `tools/call` interceptor — with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is **designed against tasks** because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes.
|
||||
|
||||
**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](https://gofastmcp.com/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware.
|
||||
|
||||
### Client experience
|
||||
|
||||
SEP-2663 removed the client-side "make this a task" flag — the server decides. That maps onto FastMCP's existing two-tier client surface, the **friendly** `call_tool` vs the **low-level** `call_tool_mcp`, so there is almost no new API:
|
||||
|
||||
- **`call_tool(name, args)` (friendly)** — advertises the capability and, if the server tasks the call, **transparently drives the poll loop** and returns the finished result. Whether the server tasked it is invisible. The machinery already exists: the migration wired claim-resolution through `call_tool_mcp`'s `allow_claimed` path, so a returned `CreateTaskResult` is finished into an ordinary `CallToolResult`. In-task `input_required` routes through the client's **existing elicitation handler**, answered via `tasks/update` — so background elicitation looks identical to foreground elicitation, with zero new client API.
|
||||
- **`call_tool_mcp(...)` (low-level)** — hands back the raw `CreateTaskResult` claimed shape for callers managing the task themselves.
|
||||
- **A "return quickly" flag on the friendly interface** yields the `Task` handle (`.status()`, `.wait()`, `.cancel()`, awaitable) without blocking — the escape hatch for progress and cancellation.
|
||||
|
||||
Server-side, `TaskConfig` modes translate directly: `required` → always task (`-32003` for non-declaring clients), `optional` → task iff the client declared, `forbidden` → never.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. **Design + unit-test the extension API** against tasks' full surface (capability, methods, interception, client claims/notifications) — as its own testable layer, proven in isolation with a trivial in-test extension before any tasks logic lands on it.
|
||||
2. **Build `fastmcp-tasks`** — extract the engine from the removed SEP-1686 layer, write the SEP-2663 adapter, port the client half.
|
||||
3. **Migrate MCP Apps onto the extension API** — fast-follow, off the critical path, with Apps' existing green tests as the regression net.
|
||||
|
||||
Tasks leads because only it exercises the full API surface; leading with the Apps subset would design us into a corner. Apps becomes the second consumer that confirms generality.
|
||||
|
||||
## Scope for v1 (non-goals)
|
||||
|
||||
- **Polling only.** The optional `notifications/tasks` push and `subscriptions/listen` integration are deferred to a later `fastmcp-tasks` version. This lets the second Redis notification queue die rather than be ported.
|
||||
- **`tools/call` only — do not lead the spec.** SEP-2663 augments `tools/call` only. FastMCP 3 offered `task=True` on prompts and resources *ahead* of the SDK under SEP-1686, and that was a mistake: it produced wire-inexpressible capability, a permanent xfail cluster, and the sdk-feedback #3 gap. The rebuild does **not** repeat it — `task=` is a tools-only surface, and the generic prompt/resource task spine is dropped rather than carried. If the spec extends augmentation later, the surface grows with it.
|
||||
- **Ship experimental.** The `ext-tasks` schema is labeled experimental with no releases; `fastmcp-tasks` ships labeled experimental initially and revs on its own cadence when the schema moves.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| **Spec churn** (extension is experimental) | Thin wire adapter over a wire-agnostic engine; ship experimental; SEP itself is Final, so the polling model is stable even if field names move. |
|
||||
| **Era gating** — SDK strips `capabilities.extensions` at pre-2026 negotiated versions (sdk-feedback #2) | Advertisement effectively requires the 2026-07-28 era. FastMCP 3 covers legacy tasks. **#2 now gates a flagship feature → escalate upstream.** |
|
||||
| **Co-developing a new abstraction + greenfield feature** | Build and unit-test the extension API in isolation first (step 1) before tasks logic lands on it. |
|
||||
| **Naming confusion** — `[tasks]` extra re-points under the same name | Deliberate changelog note; user code and the extra name are unchanged, only the wire modernizes. |
|
||||
|
||||
## Design decisions (resolved)
|
||||
|
||||
These were the open forks; the maintainer has settled them. Recorded here so the direction is unambiguous going into implementation.
|
||||
|
||||
1. **Wire adapter location — in the `fastmcp-tasks` package.** The engine *and* the SEP-2663 wire adapter live in the package; core carries only the `task=True` declaration. This isolates the experimental schema's churn from core, at the cost of diverging from the Apps precedent (where the `ui` wire glue lives in core today — Apps will converge onto this model when it migrates to the extension API).
|
||||
2. **Extension API shape — a FastMCP-native `mcp.add_extension()`, required to enable tasks.** Chosen over a thin pass-through to the SDK's `MCPServer(extensions=...)` because the FastMCP-native API can hand extensions the `Context`, component registry, and auth scope the SDK's `Extension` withholds. `add_extension` is **required** for `task=True` (not autodetected) — it is the single home for backend config and the honest source of capability advertisement. See [The extension API](#the-extension-api).
|
||||
3. **Client default — transparent completion on the friendly interface.** `call_tool` drives the poll loop and returns the finished result; `call_tool_mcp` exposes the raw `CreateTaskResult`; a "return quickly" flag yields the `Task` handle. See [Client experience](#client-experience).
|
||||
4. **Experimental labeling — yes.** `fastmcp-tasks` ships labeled experimental for at least one minor cycle, tracking the experimental `ext-tasks` schema.
|
||||
5. **Resource/prompt spine — dropped; tools-only.** The rebuild does not lead the SDK on augmentable request types, correcting the SEP-1686-era mistake. See [Scope for v1](#scope-for-v1-non-goals).
|
||||
595
dev-docs/v4-notes/change-register.md
Normal file
595
dev-docs/v4-notes/change-register.md
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
---
|
||||
title: Change Register
|
||||
---
|
||||
|
||||
This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff.
|
||||
|
||||
Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](index.md) for what each disposition means.
|
||||
|
||||
**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures were the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction — and the first of those went away when the stable SDK restored `mcp.types` (below). Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages.
|
||||
|
||||
## Environment
|
||||
|
||||
### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment)
|
||||
|
||||
The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3#environment-requirements).
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario.
|
||||
|
||||
## Types and imports
|
||||
|
||||
The SDK v2 moved protocol types into a standalone `mcp_types` package — still importable as `mcp.types` — and renamed every model field from camelCase to snake_case in Python. The wire format is unchanged: the models keep their camelCase aliases and the SDK serializes with `by_alias=True`, so this renames the attributes code reads, not the JSON on the connection. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it.
|
||||
|
||||
### `mcp.types` split into `mcp_types` — Breaking (by omission)
|
||||
|
||||
<Note>
|
||||
Superseded by the stable SDK — see "`mcp.types` restored as a permanent alias" below. The betas this section was written against had no `mcp.types`; `2.0.0` brought it back, so the break never reached a release.
|
||||
</Note>
|
||||
|
||||
The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites).
|
||||
|
||||
### `mcp.types` restored as a permanent alias — Absorbed (stable-SDK change)
|
||||
|
||||
The SDK betas removed `mcp.types` outright, which made user imports the one unavoidable break in the migration. SDK `2.0.0` reintroduced it as a permanent alias for `mcp_types`: a wildcard mirror where every name is the *same object* (`mcp.types.Tool is mcp_types.Tool`), with matching `__all__` and the same snake_case fields. It is not a v1 restoration — only the import path came back. So `from mcp.types import X` keeps working, and the break is gone.
|
||||
|
||||
This leaves the two spellings pointing at one package, and FastMCP uses each in a different place on purpose:
|
||||
|
||||
- **User-facing docs and examples use `mcp.types`.** Anyone installing `fastmcp` gets the full SDK (`fastmcp` → `fastmcp-slim[client,server]` → `[mcp]` → `mcp`), so the aliased path always resolves and is the spelling the SDK prefers. It also means a user's own dependency list needs only `mcp`, without naming `mcp-types` to satisfy a linter.
|
||||
- **FastMCP's own source uses `mcp_types`.** `mcp.types` is a submodule of `mcp`, so importing it requires the whole SDK. `mcp-types` is a *core* `fastmcp-slim` dependency while `mcp` sits behind the `[mcp]` extra, and a bare `fastmcp-slim` install must import without the SDK present — a guarantee `test_bare_slim_import_needs_only_mcp_types` pins. Reaching for `mcp.types` in core modules (`exceptions.py`, `_compat.py`, `tools/`, `resources/`) would pull the full SDK into the slim floor and break it.
|
||||
|
||||
The rule of thumb: import `mcp_types` in library code, write `mcp.types` in anything a user copies. Both resolve to the same objects, so neither choice constrains the other.
|
||||
|
||||
*Verify:* `.venv/.../mcp/types/__init__.py` (the wildcard mirror), `fastmcp_slim/pyproject.toml` (`mcp-types` core vs `mcp` in the `[mcp]` extra), `tests/client/test_slim_package_boundaries.py::test_bare_slim_import_needs_only_mcp_types`, and `tests/test_upgrade_from_v3.py::TestRemovedSurfacesFailLoudly::test_mcp_types_import_path_restored_by_stable_sdk`.
|
||||
|
||||
### `fastmcp.types` is the stable home — Bridged
|
||||
|
||||
<Note>
|
||||
Superseded before release — see "`fastmcp.types` trimmed to FastMCP-unique types only" below. This section documents the re-export set as it existed mid-migration; none of it ever shipped.
|
||||
</Note>
|
||||
|
||||
FastMCP re-exports the protocol types users are most likely to touch from `fastmcp.types`, sourced from `mcp_types` (the `mcp` root package lacks most of them):
|
||||
|
||||
```python test="skip"
|
||||
from fastmcp.types import TextContent, Tool, ToolAnnotations, ErrorData
|
||||
```
|
||||
|
||||
The re-export set is deliberately limited to names that trace to a documented user import: `TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`, `ResourceLink`, `ContentBlock`, `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `PromptMessage`, `CallToolResult`, `GetPromptResult`, `ReadResourceResult`, `TextResourceContents`, `BlobResourceContents`, `SamplingMessage`, `CreateMessageResult`, `SamplingCapability`, `Root`, `ErrorData`, `Completion`, `Annotations`, `ToolAnnotations`, `Icon`, `ToolResultContent`, plus the pre-existing `Textarea`. Notification and request wrapper types (e.g. `ToolListChangedNotification`) are not re-exported — import those from `mcp_types` directly.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__`.
|
||||
|
||||
### `fastmcp.types` trimmed to FastMCP-unique types only — Absorbed (post-review cleanup)
|
||||
|
||||
The re-export set above never shipped in a release, so it was cut before 4.0 rather than deprecated. `fastmcp.types` now holds only types FastMCP defines itself — `Textarea` — and every bare `mcp_types` mirror (`TextContent`, `Tool`, `ToolAnnotations`, `ErrorData`, and the rest of the 29-name list) is gone. Code that imported those from `fastmcp.types` now imports them from `mcp_types` directly:
|
||||
|
||||
```python
|
||||
from mcp_types import TextContent, Tool, ToolAnnotations, ErrorData
|
||||
```
|
||||
|
||||
Because `fastmcp.types.__all__` was `["Textarea"]` as of the last stable release (v3.4.4) and the mirrors were added only in this unreleased migration work, removing them breaks no released user — there is no bridge or deprecation warning to write.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__` (back down to `["Textarea"]`).
|
||||
|
||||
### camelCase field reads are bridged — Bridged (deprecated)
|
||||
|
||||
Objects FastMCP hands back — results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to sampling and elicitation handlers — are SDK v2 objects with snake_case fields. A compatibility bridge installed at import time routes the old camelCase names to their snake_case fields, warning once per read:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
async def read_schema():
|
||||
async with Client("my_mcp_server.py") as client:
|
||||
tools = await client.list_tools()
|
||||
return tools[0].inputSchema # works, warns; prefer .input_schema
|
||||
```
|
||||
|
||||
The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` (ToolAnnotations); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 29 alias entries warn correctly with actionable messages.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`).
|
||||
|
||||
### The bridge is a genuine runtime toggle — Absorbed (post-review fix)
|
||||
|
||||
The bridge properties install unconditionally, and each getter reads the live `mcp_camelcase_compat` setting on every access: warn-and-return when enabled, raise `AttributeError` when disabled. An earlier version installed the bridge once at import, so flipping the setting afterward did nothing — commit `d9659453` fixed this so the toggle works at runtime:
|
||||
|
||||
```python
|
||||
import fastmcp
|
||||
|
||||
fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately
|
||||
```
|
||||
|
||||
The setting is documented in [Settings](https://gofastmcp.com/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`.
|
||||
|
||||
### `mcp-types` is now a core slim dependency — Absorbed (post-review fix)
|
||||
|
||||
Bare `import fastmcp` loads `mcp_types` via `_sdk_patches` and `_compat`, so a bare `fastmcp-slim` install (without the `[mcp]` extra) hit `ModuleNotFoundError`. Because `mcp-types` only pulls `pydantic` and `typing-extensions` (already core), it was promoted to a core dependency while the full `mcp` SDK stays in the `[mcp]` extra.
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp-types==2.0.0b1` in core dependencies), commit `e16ffad4`.
|
||||
|
||||
### `McpError` is an alias; construction changed — Bridged (catch) / Breaking (construct)
|
||||
|
||||
`fastmcp.exceptions.McpError` is a plain alias of the SDK's `MCPError` — a plain alias, not a subclass, so `except McpError` still catches SDK-raised errors and `err.error.code` still reads:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
try:
|
||||
...
|
||||
except McpError as err:
|
||||
print(err.error.code) # unchanged
|
||||
```
|
||||
|
||||
Construction is the one unavoidable behavior break. The v1 pattern of wrapping an `ErrorData` positionally raises `TypeError` under v2; construct with keywords instead:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import McpError
|
||||
|
||||
# Before (raises TypeError under SDK v2):
|
||||
# raise McpError(ErrorData(code=-32000, message="Client not supported"))
|
||||
|
||||
raise McpError(code=-32000, message="Client not supported")
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`McpError = MCPError`).
|
||||
|
||||
## Server core
|
||||
|
||||
The SDK v2 rewrote the server request-handling model. FastMCP's handler layer is the most heavily rewritten part of the migration, but the public server API is unchanged.
|
||||
|
||||
### Handler adapters — Absorbed
|
||||
|
||||
Handlers are now registered by method string via `add_request_handler(method, params_type, handler)`, take a uniform `(ctx, params)` signature, and return the **bare** result model (no `ServerResult` wrapper). FastMCP's `_setup_handlers` builds one thin adapter per method (`tools/list`, `tools/call`, `resources/read`, `prompts/get`, `logging/setLevel`, …) that binds the request context, adapts params to the existing handler body, and returns the bare result. The v1 decorator overrides and `_wrap_list_handler` are deleted.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (462 lines changed), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`.
|
||||
|
||||
### FastMCP-owned request context — Absorbed
|
||||
|
||||
The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers as an argument only. FastMCP owns its own `fastmcp_request_ctx` ContextVar, set at the top of every adapter. It stores a FastMCP-owned `FastMCPRequestContext` wrapper rather than the raw SDK context, because the raw `ServerRequestContext.meta` is a bare `TypedDict` carrying only `progress_token` — the full `_meta` block (which holds `_meta.fastmcp.version` and the distributed-trace parent) has to be lifted out of the raw params dict. `Context.request_context` and its consumers (`report_progress`, `session_id`, telemetry trace extraction, `get_http_request`) all read through the wrapper.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/dependencies.py`, `server/context.py`, `server/telemetry.py`.
|
||||
|
||||
### `ServerMiddleware` bridge for `initialize` — Absorbed
|
||||
|
||||
Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 interface is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`).
|
||||
|
||||
### Middleware observes every inbound message — New (coverage)
|
||||
|
||||
FastMCP's `Middleware` chain used to begin *inside* the per-method handlers, so `on_message`/`on_request`/`on_notification` only fired for messages that reached a tool/resource/prompt handler. Notifications, cancellations, and malformed or unroutable requests were invisible to middleware. `FastMCPServerMiddleware` — FastMCP's entry in the SDK's own middleware list — is now the dispatch root: it runs the `on_message`/`on_request`/`on_notification` pass for every message the interior handlers do not dispatch (all notifications including `notifications/cancelled`, `ping`, `logging/setLevel`, unknown methods, and component requests that fail validation before the handler runs). The component methods keep their interior dispatch unchanged, so `on_call_tool` and friends still receive the typed component result and a tool exception still propagates through `on_message`/`on_request` exactly where the built-in error/logging/timing middleware expect it — each hook fires exactly once per message. Multi-round (SEP-2322) calls compose cleanly with this: each round is a complete request→response cycle through the full chain, and an asking round's `call_next` returns the ask as an ordinary `InputRequiredToolResult` value (see the MRTR entry below). All thirteen built-in middleware pass their suites unmodified. See [What middleware sees](https://gofastmcp.com/servers/middleware#what-middleware-sees).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware` root dispatch, `_INTERIOR_METHODS`), `fastmcp_slim/fastmcp/server/middleware/middleware.py` (`MiddlewarePhase`, `mark_interior_dispatched`), `fastmcp_slim/fastmcp/server/server.py` (`_dispatch_component_middleware`), `tests/server/middleware/test_message_visibility.py`.
|
||||
|
||||
### Per-session state re-homed to the connection — Absorbed
|
||||
|
||||
Because `ServerSession` is now per-request, per-session state can no longer live on the session object. The minimum logging level is re-homed to a FastMCP-side map keyed by session id (via `connection.session_id`), and `client_supports_extension` becomes a free function reading `session.client_params.capabilities`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py`, `server/context.py` (`_log_to_server_and_client`).
|
||||
|
||||
### `extensions` capability read from the real field — Absorbed (post-review fix)
|
||||
|
||||
SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a client sending `ClientCapabilities(extensions={...})` populates the field, not `model_extra`. `client_supports_extension` now reads `caps.extensions` first and falls back to `model_extra` only for legacy-serialized clients.
|
||||
|
||||
*Verify:* commit `96ca0092`, `server/low_level.py` / `server/context.py`.
|
||||
|
||||
### Task protocol and the `_sdk_patches` shim — Absorbed (with an upstream gap)
|
||||
|
||||
The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`.
|
||||
|
||||
The SDK has a real gap here (see [Known Gaps](known-gaps.md) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger.
|
||||
|
||||
Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes.
|
||||
|
||||
This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](background-tasks.md) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`.
|
||||
|
||||
### Single SERVER span per request — Absorbed (post-migration fix)
|
||||
|
||||
SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each inbound request already emits a SERVER span. FastMCP emits its own richer SERVER span per request (with `fastmcp.*` and auth/session attributes), so a server with an OTel exporter installed would export **two** SERVER spans per request under different attribute conventions. `LowLevelServer.__init__` now drops the SDK's seeded `OpenTelemetryMiddleware` (matched by type, not position, leaving any other seeded middleware intact) and keeps FastMCP's spans. Inbound W3C trace-context extraction is unaffected — FastMCP's telemetry reads `traceparent` from `_meta` itself, so distributed traces still link client to server. Client-side is not double-counted: the SDK's `ClientSession` emits a low-level `MCP send <method>` CLIENT span that nests *under* FastMCP's high-level client span, a legitimate parent/child hierarchy rather than a duplicate.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`.
|
||||
|
||||
### Telemetry on by default, with a three-way mode setting — Absorbed
|
||||
|
||||
FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. `FASTMCP_TELEMETRY_MODE` (`fastmcp.settings.telemetry_mode`, default `native`) controls how much is active: `native` emits spans and propagates trace context; `propagation_only` emits no FastMCP spans but still extracts the incoming `_meta` context and attaches it, so downstream spans are parented to the calling trace; `off` is a full pass-through that touches neither spans nor context. The setting governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a single block for library authors who own the MCP hierarchy for one operation rather than process-wide; it cannot override `off`. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions.
|
||||
|
||||
`propagation_only` is applied at the seam span, which is where the incoming `_meta` parent context is established for the whole request; suppressing only the deeper `server_span` would leave the per-request SERVER span intact and defeat the mode.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/settings.py` (`telemetry_mode`); `fastmcp_slim/fastmcp/telemetry.py` (`telemetry_mode`, `get_tracer`, `suppress_fastmcp_telemetry`); `fastmcp_slim/fastmcp/server/telemetry.py` (`_propagation_only_span`, `seam_span`, `get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`; `tests/telemetry/test_interop.py`.
|
||||
|
||||
### Spec-correct error codes via a central translator — Breaking (wire error code)
|
||||
|
||||
Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`to_mcp_error`); `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`; `tests/test_exceptions.py`.
|
||||
|
||||
### `Cachable*` response-cache models renamed to `Cacheable*` — Breaking (rename) <!-- codespell:ignore -->
|
||||
|
||||
The response-caching middleware's Pydantic wrapper models — used to serialize cached tool, resource, and prompt results for `ResponseCachingMiddleware` — carried a spelling typo. `CachableToolResult`, `CachableResourceContent`, `CachableResourceResult`, `CachableMessage`, and `CachablePromptResult` are renamed to `CacheableToolResult`, `CacheableResourceContent`, `CacheableResourceResult`, `CacheableMessage`, and `CacheablePromptResult`. None of these classes are re-exported from `fastmcp` or any package `__init__.py`, so the realistic blast radius is limited to code that imported the old names directly from `fastmcp.server.middleware.caching`:
|
||||
|
||||
```python
|
||||
# Before (now raises ImportError):
|
||||
# from fastmcp.server.middleware.caching import CachableToolResult
|
||||
|
||||
# After
|
||||
from fastmcp.server.middleware.caching import CacheableToolResult
|
||||
```
|
||||
|
||||
There is deliberately no compatibility alias for the old spelling.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/middleware/caching.py`.
|
||||
|
||||
### Server-side argument completion — New (opt-in feature)
|
||||
|
||||
A FastMCP server can now answer `completion/complete` requests, suggesting values for prompt arguments and resource-template parameters as a user types. Previously a FastMCP *client* could call `complete()` but a FastMCP *server* had no way to respond — the method was unregistered, so it returned `-32601` (method-not-found) on both eras. The new `@mcp.completion` decorator registers a single server-level handler that receives the reference (a `PromptReference` or `ResourceTemplateReference`), the `CompletionArgument` being completed, and the optional `CompletionContext` of already-supplied argument values, and returns candidates — a list of strings, a `Completion` (to carry the `total`/`has_more` pagination hints), or `None`/empty for a reference it does not recognize (which yields an empty completion, not an error).
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def write_poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
The completions capability is declared exactly when a handler exists: `add_completion_handler` registers the low-level `completion/complete` handler, and the SDK derives the capability from that handler's presence — a server with no completion handler does not advertise it. FastMCP does not hand-set the capability. The single-handler shape mirrors the SDK's own `completion/complete` surface and FastMCP's existing client-side `Client.complete()`, and it slots into the `@mcp.tool`/`@mcp.prompt`/`@mcp.resource` decorator lineup as another server-level `@mcp.<verb>` registration rather than inventing a per-argument sub-decorator idiom. It works identically on the handshake and modern (`2026-07-28`) eras, since `completion/complete` is a request/response method that flows on every era. The authoring types — `PromptReference`, `ResourceTemplateReference`, `CompletionArgument`, `CompletionContext`, and `Completion` — are imported from `mcp_types`, not `fastmcp.types`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/completions.py` (handler type + `normalize_completion`), `fastmcp_slim/fastmcp/server/server.py` (`completion` decorator, `add_completion_handler`), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_complete`), `tests/server/test_completions.py`, `docs/servers/completions.mdx`.
|
||||
|
||||
## Client
|
||||
|
||||
The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
|
||||
|
||||
### Connection `mode` defaults to `"auto"` — Breaking (behavior)
|
||||
|
||||
`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
|
||||
|
||||
The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`. `MCPConfigTransport` reports `legacy_only` as a property: a multi-server config is legacy-only (each backend is mounted behind a legacy-era proxy), while a single-server config mirrors its one backend transport's era so a modern Streamable HTTP backend stays modern-capable. Two internal library clients that are inherently handshake-based are pinned to legacy so the flip does not break them: the `ProxyClient` backend (which forwards the initialize handshake and server-initiated features) defaults to `mode="legacy"`, and the `inspect` utility (which reads the full `server_info` only the handshake carries) connects legacy.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("https://example.com/mcp") # now negotiates "auto"
|
||||
client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse,config}.py` (`legacy_only`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`ProxyClient` legacy default), `fastmcp_slim/fastmcp/mcp_config.py` and `fastmcp_slim/fastmcp/utilities/inspect.py` (legacy inner clients), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `tests/test_mcp_config.py` (single- vs multi-server `legacy_only`), `docs/clients/client.mdx`.
|
||||
|
||||
### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
|
||||
|
||||
`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Result claims are wired end-to-end: `call_tool()` / `call_tool_mcp()` pass `allow_claimed=True` and resolve a claimed result through the owning claim's resolver (`ClaimContext`), so a server-emitted claimed shape is finished into an ordinary `CallToolResult` instead of raising `UnexpectedClaimedResult`. Claimed shapes are modern-only, so they are inert on a legacy connection.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution).
|
||||
|
||||
### Protocol helpers delegated to the SDK — Absorbed (internal)
|
||||
|
||||
`fastmcp.Client` carried forked copies of three SDK helpers — `_fold_extensions` (with its `_FoldedExtensions` dataclass), `_evicting_message_handler`, and `_synthesize_discover` — written when the SDK had not yet stabilized them. It now imports the SDK's implementations directly. The forks had already drifted: FastMCP's `_fold_extensions` was missing the SEP-2133 `validate_extension_identifier` check, so a non-reverse-DNS extension identifier that the SDK rejects was silently accepted. Adopting the SDK's version closes that gap. No public surface moves; the SDK returns `None` rather than empty collections for the folded claims and bindings, absorbed at the two call sites in `_build_extension_kwargs`.
|
||||
|
||||
Full composition — `fastmcp.Client` holding an `mcp.Client` and delegating the connection lifecycle to it — remains blocked upstream. `mcp.Client._build_session` hardcodes `ClientSession(...)` with no override hook, but FastMCP's `TransportOptions.session_class` is load-bearing: `ProxyClient` supplies a `_ForwardingClientSession` that skips output-schema validation so a backend's schema bug surfaces at the end client rather than as a proxy error. Separately, `mcp.Client.__aenter__` raises on reentry, while FastMCP's refcounted reentrant context manager is depended on by proxy session reuse. Both would need an upstream `session_factory=` hook (the same shape as the `notification_bindings=` ask that unblocked extension composition) before the lifecycle itself can be delegated.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (imports from `mcp.client.client`; no local helper definitions), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.session_class`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_ForwardingClientSession`, `PROXY_TRANSPORT_OPTIONS`).
|
||||
|
||||
### Transports yield 2-tuples — Absorbed
|
||||
|
||||
All SDK transports (`streamable_http_client`, `sse_client`, `stdio_client`) now yield a 2-tuple `(read, write)` instead of exposing a third `get_session_id` element. HTTP configuration flows through a caller-supplied `http_client=`. Only the tuple unpack changed on the FastMCP side.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py`, `transports/sse.py`, `transports/stdio.py`.
|
||||
|
||||
### Float timeouts; `timedelta` still accepted — Absorbed
|
||||
|
||||
The SDK session and call timeouts are now plain floats. FastMCP's public `Client(timeout=...)` still accepts a `timedelta`, a plain float, or an int, normalizing through the existing `normalize_timeout_to_seconds` at the `SessionKwargs` chokepoint:
|
||||
|
||||
```python
|
||||
from datetime import timedelta
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("my_mcp_server.py", timeout=timedelta(seconds=30)) # still works
|
||||
client = Client("my_mcp_server.py", timeout=30.0) # also works
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.read_timeout_seconds: float | None`), `client/client.py`.
|
||||
|
||||
### Connection settings passed to `connect_session` — Breaking (custom transports)
|
||||
|
||||
`ClientTransport.connect_session` takes a new keyword-only `transport_options: TransportOptions | None`, describing how the connecting client wants its session built: which `ClientSession` class to instantiate, and whether to forward the caller's authorization header upstream. Proxies use it to relay backend results without enforcing their output schema (see [Proxy Servers](https://gofastmcp.com/servers/providers/proxy#tool-results-are-relayed-not-inspected)).
|
||||
|
||||
These settings previously lived on the transport instance, so a transport shared between clients leaked one client's configuration into another — including credential forwarding, which `create_proxy(some_client)` would silently enable on the caller's own client. They now travel with the client that wants them, and `forward_incoming_headers` is no longer a settable transport attribute.
|
||||
|
||||
A client only passes the argument when it wants non-default settings, so an ordinary `Client` is unaffected and transports that don't accept it keep working. A custom `ClientTransport` used as a *proxy backend* must accept and honor it:
|
||||
|
||||
```python
|
||||
import contextlib
|
||||
|
||||
from fastmcp.client.transports.base import ClientTransport, TransportOptions
|
||||
|
||||
class MyTransport(ClientTransport):
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(self, *, transport_options=None, **session_kwargs):
|
||||
options = transport_options or TransportOptions()
|
||||
async with options.session_class(read, write, **session_kwargs) as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
A transport that wraps others must pass it along; `MCPConfigTransport` forwards it to both its single-server delegate and its composite server.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions`), the four built-in transports, `transports/config.py`, and `tests/server/providers/proxy/test_proxy_server.py`.
|
||||
|
||||
### `get_session_id` via header sniff — Bridged
|
||||
|
||||
The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`).
|
||||
|
||||
### Pagination via `params=` — Absorbed
|
||||
|
||||
The SDK's `cursor=` kwarg on `list_*` is gone; pagination now flows through `params=PaginatedRequestParams(cursor=...)`. FastMCP's public `cursor=` on the `list_*_mcp` methods is preserved and translated internally.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/mixins/{tools,resources,prompts}.py`.
|
||||
|
||||
### OAuth `callback_handler` returns `AuthorizationCodeResult` — Breaking (advanced)
|
||||
|
||||
The one OAuth break: a custom `callback_handler` must return an `AuthorizationCodeResult` (fields `code`, `state`, `iss`) instead of the old `tuple[str, str | None]`. Everything else in the OAuth surface — `OAuthClientProvider` kwargs, `TokenStorage`, `async_auth_flow` — is unchanged.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/auth/oauth.py`.
|
||||
|
||||
### Notification dispatch unwrapped — Absorbed
|
||||
|
||||
The client's notification handling was reworked for the v2 message model. Custom server-to-client notifications (like SEP-1686 `notifications/tasks/status`) are no longer tee'd to a user `message_handler` — the SDK routes them only through `NotificationBinding` (see sdk-feedback #8). FastMCP registers a binding so task-status updates reach the Task registry.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/messages.py`, `client/tasks.py`.
|
||||
|
||||
### `SDKServer` alias — Absorbed (post-review rename)
|
||||
|
||||
The in-memory transport resolves the low-level server per server type. The alias for the SDK's own `MCPServer` was renamed from the misleading `FastMCP1Server` / `FastMCP1x` to `SDKServer`, since it names the SDK v2 server, not a FastMCP 1.x object.
|
||||
|
||||
*Verify:* commit `5c3b82e4`; `client/client.py`, `client/transports/memory.py`, `server/providers/proxy.py`, `cli/run.py`.
|
||||
|
||||
### Proxy request-context stash — Absorbed (post-review fix)
|
||||
|
||||
Proxy forwarding handlers stash the request context so a backend that issues a server-initiated request (list_roots/sampling/elicitation) can relay it back to the proxy's own client. This stash was initially applied only on the tool path; commit `1ac166bd` extended it to proxied resources, templates, and prompts.
|
||||
|
||||
*Verify:* commit `1ac166bd`, `server/providers/proxy.py`.
|
||||
|
||||
### Shared response cache via `KeyValueResponseCacheStore` — New
|
||||
|
||||
The SDK's client response cache (SEP-2549) reads and writes through a pluggable `ResponseCacheStore`; the default is a per-client in-memory LRU. FastMCP adds `KeyValueResponseCacheStore`, an adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy already use, so a fleet of clients (e.g. proxy replicas) can share one Redis-backed response cache. Pass it via `CacheConfig(store=...)`; a custom store requires an explicit `partition` (SDK) and `target_id` (FastMCP). Results serialize through a type-tagged envelope validated against an allowlist of cacheable result models — an unknown tag is a cache miss, never an import-by-name — and each adapter owns its own collection so `clear()` never touches another tenant.
|
||||
|
||||
```python
|
||||
from fastmcp.client.caching import KeyValueResponseCacheStore
|
||||
from mcp.client.caching import CacheConfig
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
|
||||
store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost"))
|
||||
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/caching.py`, `tests/client/client/test_kv_response_cache.py`.
|
||||
|
||||
### Machine-to-machine client auth — New (feature)
|
||||
|
||||
`fastmcp.client.auth` gains two browser-free auth providers for the OAuth 2.0 `client_credentials` grant, closing the most common client-auth gap (previously only interactive `OAuth` and static `BearerAuth` were available). `ClientCredentialsOAuthProvider(client_id=..., client_secret=...)` authenticates with a client ID and secret; `PrivateKeyJWTOAuthProvider(client_id=..., assertion_provider=...)` uses an RFC 7523 `private_key_jwt` assertion (workload identity federation or a locally signed JWT via the re-exported `SignedJWTParameters` / `static_assertion_provider` helpers). Both are thin wrappers over the SDK's `mcp.client.auth.extensions.client_credentials` providers and implement `httpx2.Auth`, so they slot into the same `Client(auth=...)` path as every other provider. Like interactive `OAuth`, they take the MCP server URL (the token endpoint is discovered from OAuth metadata) and bind to it lazily — omit `mcp_url` and the transport supplies it. In-memory token storage is the default with no warning, since a lost M2M token is re-acquired in one non-interactive request.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.auth import ClientCredentialsOAuthProvider
|
||||
|
||||
auth = ClientCredentialsOAuthProvider(client_id="id", client_secret="secret")
|
||||
async with Client("https://example.com/mcp", auth=auth) as client:
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/auth/client_credentials.py`, `fastmcp_slim/fastmcp/client/transports/{http,sse}.py`, `tests/client/auth/test_client_credentials.py`.
|
||||
|
||||
## HTTP
|
||||
|
||||
The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](feature-program.md)).
|
||||
|
||||
### Kept overrides — Absorbed
|
||||
|
||||
Four overrides survive, each for a concrete reason:
|
||||
|
||||
1. **Event-store session scoping.** The SDK hands every per-session transport the *same* `event_store` object, one stream-ID keyspace shared across sessions. FastMCP's `FastMCPStreamableHTTPSessionManager` returns a fresh `SessionScopedEventStore(shared, session_id=…)` per session, so resumability events don't leak across sessions.
|
||||
2. **Lifespan reconciliation.** The SDK builder enters the bare lowlevel `Server.lifespan` (which yields `{}`). FastMCP drives its own `_lifespan_manager` — ref-counted for mounts, Ctrl-C-shielded, docket-aware. The SDK path silently skips all of it, so FastMCP sets the server lifespan to delegate to `_lifespan_manager` and lets the manager enter it once.
|
||||
3. **Graceful transport termination.** FastMCP's lifespan `finally` drains the manager's server instances via `transport.terminate()` before task-group cancel, fixing the Uvicorn "returned without completing response" edge (#3025). The SDK just cancels.
|
||||
4. **User ASGI middleware hook.** The SDK builder hardcodes an empty middleware list and only appends auth. FastMCP's `http_app(middleware=...)` and `RequestContextMiddleware` have nowhere to go in the SDK path.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/http.py`, `server/event_store.py`, `server/mixins/lifespan.py`.
|
||||
|
||||
### DNS-rebinding ownership — Absorbed (security)
|
||||
|
||||
FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, which is more expressive than the SDK's and is the documented surface. To avoid two allowlists double-blocking with confusing errors from two layers, FastMCP **always** disables the SDK's layer by passing `TransportSecuritySettings(enable_dns_rebinding_protection=False)` to the manager — both when FastMCP's protection is on (so they don't double-block) and when it's off (so the SDK's default-on flip can't silently re-enable it).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`).
|
||||
|
||||
### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else)
|
||||
|
||||
SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2.
|
||||
|
||||
FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier migration pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
|
||||
|
||||
User-visible deltas:
|
||||
|
||||
- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported.
|
||||
- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2.
|
||||
- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior.
|
||||
- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names.
|
||||
|
||||
The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim.
|
||||
|
||||
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`.
|
||||
|
||||
## Protocol eras
|
||||
|
||||
The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this.
|
||||
|
||||
### Dual-era serving — Absorbed (supersedes "latest only")
|
||||
|
||||
A single FastMCP server now handles clients across the protocol transition: the session-based handshake eras (through 2025-11-25) and the sessionless `2026-07-28` era (capability discovery via `server/discover`) simultaneously. This supersedes FastMCP's earlier "latest protocol only" stance.
|
||||
|
||||
### Per-feature era matrix — Breaking (feature availability by era)
|
||||
|
||||
The push-style Context features that require the server to call back into the client are unavailable on the sessionless `2026-07-28` era, because that era removes server-initiated requests (SEP-2577). The request/response features flow on every era.
|
||||
|
||||
| Context feature | Session-based eras | `2026-07-28` (sessionless) |
|
||||
| --- | --- | --- |
|
||||
| `ctx.info` / logging notifications | Supported | Supported |
|
||||
| Tools, resources, prompts, completions | Supported | Supported |
|
||||
| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Not in the API | Not in the API — call an LLM server-side |
|
||||
| `ctx.list_roots` | Not in the API | Not in the API — take paths as arguments, or use the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
|
||||
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` is absent from the era's registry |
|
||||
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
|
||||
|
||||
Tools that rely on `ctx.elicit` continue to work against clients on the session-based eras; on the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling and roots have no era row to speak of — they left the server API entirely (see the Removed entry below).
|
||||
|
||||
Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging *notifications* ride the request's own stream and work on every era, including the modern one. The upgrade guide calls it out explicitly.
|
||||
|
||||
Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2).
|
||||
|
||||
*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`.
|
||||
|
||||
### Server-initiated sampling and roots removed from the server API — Breaking
|
||||
|
||||
FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol removed are not in its server-authoring API. `Context.sample()`, `Context.sample_step()`, and `Context.list_roots()` are gone, along with the whole `fastmcp/server/sampling/` package (`SamplingTool`, `SampleStep`, `SamplingResult`, the tool loop, structured-result sampling) and the server-side handler arguments `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`. These were previously deprecated-and-era-gated; they are now absent. Calling them raises `AttributeError`; the constructor kwargs raise a `TypeError` naming SEP-2577 and the migration.
|
||||
|
||||
The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`.
|
||||
|
||||
Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. What the protocol removed is the *pushing*, not the asking: both capabilities remain reachable through the guard pattern, where a tool returns an `InputRequiredResult` whose `input_requests` map carries a `CreateMessageRequest` or a `ListRootsRequest`, the client answers it, and the tool re-runs and reads `ctx.input_responses`. `Client._drive_input_required()` dispatches those to the same `sampling_handler` / `roots` handler a handshake-era server would have pushed to, and `tests/conformance/server.py` exercises both routes. For roots that guard round is the recommended modern path. For generation it is available but usually the wrong tool — each round is a full request-response cycle, so an agentic loop exhausts the round-trip budget — and the recommended migration stays a direct LLM call from the server.
|
||||
|
||||
**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era.
|
||||
|
||||
**Proxy relay.** `ProxyClient`'s default `roots` and `sampling_handler` are client-side handlers that relay a handshake-era backend's requests to the proxy's own front client. They are kept, because a proxy is a client to its backend and falls squarely under the interop guarantee above. They no longer route through the removed `Context` methods: both now call the SDK session directly (`ctx.session.list_roots()` / `ctx.session.create_message()`), an internal path with no public authoring surface. The relay is reachable only when both legs speak the handshake era.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (no `sample`/`sample_step`/`list_roots`), `fastmcp_slim/fastmcp/server/server.py` (`_REMOVED_KWARGS`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`default_proxy_roots_handler`, `default_proxy_sampling_handler`), `docs/servers/sampling.mdx` (rewritten in place as the explainer), `tests/server/test_protocol_eras.py` (`test_removed_server_initiated_methods_are_absent`), `tests/server/providers/proxy/test_proxy_client.py` (relay still green).
|
||||
|
||||
### `client.set_logging_level()` era-gated — Breaking (modern era)
|
||||
|
||||
`logging/setLevel` asks a server to remember a level for the rest of the session, and it is absent from the `2026-07-28` method registry because that era has no session to remember it in. It previously surfaced the SDK's opaque "Method not found". `Client.set_logging_level()` now raises a `RuntimeError` naming the era and pointing at level-filtering in the client's `log_handler`; it is unchanged on handshake-era connections. It is never a silent no-op.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`set_logging_level`), `tests/server/test_protocol_eras.py` (`test_set_logging_level_is_era_gated_on_modern`).
|
||||
|
||||
### Push-feature degradation quality — Resolved (was sdk-feedback #10)
|
||||
|
||||
On a `2026-07-28` connection `ctx.elicit` used to surface a bare "Method not found", because it attaches a `related_request_id` and reaches client dispatch before failing. FastMCP now era-gates `ctx.elicit` to raise a clear, era-aware `ToolError` before the wire ("elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. The sampling half of #10 is moot: `ctx.sample` no longer exists.
|
||||
|
||||
*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gate).
|
||||
|
||||
### Server-level cache hints (SEP-2549) — New (opt-in feature)
|
||||
|
||||
A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp.Client(cache=...)`) may reuse a response without a wire round-trip. Two constructor params carry it: `FastMCP(cache_ttl=300, cache_scope="public")`, where `cache_ttl` is in seconds and `cache_scope` is `"public"` or `"private"` (default `"private"` when a TTL is set). The hint is uniform by construction — one server-level value applies to every SDK-cacheable method (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) with no per-component surface and no aggregation. FastMCP does not hand-set the wire fields: it passes the hint through to the SDK low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on every cacheable result via `apply_cache_hint`, leaving any field a handler set explicitly untouched. `cache_ttl` must be positive, and a `cache_scope` without a `cache_ttl` is rejected at construction (a scope alone does not enable caching, since the client gates on the TTL's presence). Absent both params, no hint is emitted. Honoring is modern-only (the SDK client reads hints only at `2026-07-28`) and opt-in on the client, so a hinted server is inert unless the client passes `cache=`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/caching.py` (`build_cache_hints`), `fastmcp_slim/fastmcp/server/server.py` (constructor params passed to `LowLevelServer(cache_hints=...)`), `tests/server/test_cache_hints.py` (unit validation + end-to-end interop with `fastmcp.Client(cache=True)`).
|
||||
|
||||
### Elicitation on the modern protocol (SEP-2322), guard form — New (opt-in feature)
|
||||
|
||||
A tool can gather client input across rounds on a `2026-07-28` call by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle: the tool re-runs per round and reads the client's answers off two new `Context` properties, `ctx.input_responses` (`None` on the first round) and `ctx.request_state` (the echoed opaque state) — thin passthroughs matching the SDK's mcpserver semantics. This is the modern-era elicitation path the earlier per-feature matrix flagged as "MRTR rewrite pending"; it mirrors the SDK's base guard model exactly (tool re-runs, checks whether answers are present, returns to ask for more), with no FastMCP-invented resolver or annotation layer. For authoring these requests, `InputRequiredResult`, `ElicitRequest`, and `ElicitRequestFormParams` import from `mcp_types`. The `request_state` channel is sealed by the framework, not the author: FastMCP installs the SDK's `RequestStateBoundary` middleware on its low-level server, which seals every outgoing `request_state` and unseals and verifies every inbound echo before a tool runs — so a tool only ever sees plaintext and a tampered, expired, or foreign token is rejected with a frozen wire error. `FastMCP(request_state_security=RequestStateSecurity(keys=[...]))` supplies shared keys for multi-replica deployments; omitted, each process seals under an ephemeral key (correct single-process). Returning this result on a handshake-era (≤ 2025-11-25) connection raises a clear era error naming the mismatch rather than failing as a generic invalid result. The client half (`fastmcp.Client` at `mode="auto"`) drives the loop through its existing elicitation/sampling/roots handlers, capped by `input_required_max_rounds`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`.
|
||||
|
||||
### Proxy era mirroring — New (behavior)
|
||||
|
||||
A proxy is a server on its front and a client on its back, and the two eras have mutually exclusive interaction models on a single session: the handshake era pushes server-initiated requests (sampling/elicitation/roots) that the proxy forwards to its client, while the modern era forbids those and round-trips a guard tool's `InputRequiredResult` as a result instead. A proxy created from a non-Client target with no explicit `mode` now MIRRORS the front connection's negotiated era onto its backend session per request, so the whole chain speaks one era end-to-end — a modern client reaches a modern backend (guard round-trips work), a handshake client reaches a handshake backend (push-forwarding works), and the same proxy serves both without a backend session ever crossing eras. Because the default factory builds a fresh backend client per request and derives its `mode` from the front era at call time, only the metadata-only component caches are shared across eras. An explicit `create_proxy(target, mode=...)` still pins the backend era regardless of the front, overriding mirroring for a backend that only speaks one era; the resulting cross-era feature mismatches surface through the existing era gates. `ProxyInitializeMiddleware` no longer force-calls the handshake-only `client.initialize()` when the backend negotiated the modern era, so an explicit modern pin behind a handshake front no longer crashes on connect. The mirrored era carries through a multi-server `MCPConfig` target as well: that form mounts one proxy per configured server onto a composite router, and `TransportOptions.backend_mode` hands the era down to those mounted legs so every real backend negotiates it, not just the router in front of them. That router is also now sealed under a policy held on the transport rather than a fresh per-router ephemeral key, so a guard tool's `request_state` survives the router being rebuilt between rounds.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_mirror_front_era_mode`, the `_create_client_factory` non-Client branch, the era guard in `ProxyInitializeMiddleware.on_initialize`), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.backend_mode`), `fastmcp_slim/fastmcp/client/transports/config.py` (`MCPConfigTransport.connect_session` / `_create_proxy`), `fastmcp_slim/fastmcp/server/server.py` (`create_proxy` docstring), `tests/server/test_mrtr_guards.py` (`TestProxyEraMirroring`, `TestMultiServerConfigEraMirroring`).
|
||||
|
||||
### Resource and prompt errors survive the modern era — Absorbed (defect fix)
|
||||
|
||||
`_on_call_tool` returns a `ResourceError`-equivalent as an error result, but `_on_read_resource` and `_on_get_prompt` caught only `DisabledError`/`NotFoundError`, so a `ResourceError`, `PromptError`, or an argument-conversion failure on a resource template escaped as a raw handler exception. On the handshake eras that reached the wire as `str(exc)`, which is survivable; on `2026-07-28` the runner masks anything that is not an `MCPError` or `ValidationError` as a generic `"Internal server error"`, so a legitimate client-input error became indistinguishable from a server bug. Both handlers now translate a `FastMCPError` through `to_mcp_error` the way tools already do. Masking is unchanged — `mask_error_details` is still applied inside `read_resource`/`render_prompt`, so these paths leak no more than tools do.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_read_resource`, `_on_get_prompt`), `tests/server/test_protocol_eras.py`.
|
||||
|
||||
### Proxies forward upstream instructions on the modern era — Absorbed (defect fix)
|
||||
|
||||
`ProxyInitializeMiddleware` forwards an upstream server's `instructions` by patching the `InitializeResult`, but `on_initialize` only fires for the handshake era. A modern client negotiates via `server/discover`, which the SDK builds from the low-level server's own `instructions`, so a proxy silently dropped its upstream's instructions for every modern client. `FastMCPProxy` now registers a `server/discover` handler (the same `add_request_handler` hook it already uses for `ping`, and a replacement the SDK explicitly sanctions) that delegates to the SDK's own implementation and fills in only the instructions that would otherwise be lost. The proxy's lazy-connect contract is unchanged: the backend is contacted when a client asks, never at construction. Because era mirroring pins a modern backend to an exact version — and a pinned version adopts a synthesized `DiscoverResult` rather than probing the wire — this read negotiates with `mode="auto"`; instructions are metadata with no back-channel, so they do not need the era consistency mirroring exists to protect.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`FastMCPProxy._setup_proxy_discover_handler`), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyModernEraInstructions`).
|
||||
|
||||
### Proxy list methods raise `MCPError` on backend failure — Breaking (in-process error type)
|
||||
|
||||
`ProxyProvider`'s four `_list_*` methods caught only `MCPError`, so a failed backend connection escaped as the `RuntimeError` the client wraps it in (or a raw `httpx2.ConnectError`). On the handshake eras that reached the wire as `str(exc)` and named the real failure; on `2026-07-28` it was masked as `"Internal server error"`, leaving a modern client unable to tell a dead backend from a server bug. The list methods now normalize transport failures through `_proxy_upstream_error`, matching `ProxyInitializeMiddleware.on_initialize`. Code calling a proxy's `list_tools()` (and friends) in-process must now catch `MCPError` rather than `RuntimeError`; the over-the-wire error type is unchanged.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_PROXY_TRANSPORT_ERRORS` and the four `_list_*` methods), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyProviderTransportErrors`).
|
||||
|
||||
### The xfail register — Known gap
|
||||
|
||||
Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](known-gaps.md) page.
|
||||
|
||||
## Security
|
||||
|
||||
FastMCP retains hardening that is not yet upstream and does not remove it during the migration.
|
||||
|
||||
### Retained OAuth / DCR hardening — Absorbed
|
||||
|
||||
FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface.
|
||||
|
||||
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.
|
||||
|
||||
### Identity assertion (SEP-990 ID-JAG) — Added (beta)
|
||||
|
||||
`OAuthProxy` (and `OIDCProxy`, which inherits it) accepts an optional `identity_assertion=IdentityAssertion(trusted_issuers=[...])`. When configured, the token endpoint accepts the RFC 7523 `urn:ietf:params:oauth:grant-type:jwt-bearer` grant carrying an enterprise IdP-issued ID-JAG, validates it (signature against the trusted issuer's JWKS, `iss`/`aud`/`exp`, `typ` of `oauth-id-jag+jwt`, mandatory `sub`, signed `client_id`/`resource` binding, and `jti` replay rejection), and mints a short-lived FastMCP access token carrying the asserted subject with no refresh token. Authorization server metadata advertises the `jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` profile when enabled. This is server-side only; the client-side wrapper ships separately. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/auth/identity_assertion.py`, the `exchange_identity_assertion` and `get_routes` changes in `fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py`, and the jwt-bearer dispatch in `fastmcp_slim/fastmcp/server/auth/auth.py` (`TokenHandler._maybe_handle_id_jag`).
|
||||
|
||||
### Templated resource parameters are path-screened by default — Breaking (behavior)
|
||||
|
||||
Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log.
|
||||
|
||||
The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](https://gofastmcp.com/servers/resources#path-security).
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`.
|
||||
|
||||
## Removed in 4.0
|
||||
|
||||
Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise.
|
||||
|
||||
### Module and class shims
|
||||
|
||||
- **`fastmcp.server.proxy`** (deprecated 3.0) — Breaking. Import proxy classes (`FastMCPProxy`, `ProxyClient`, etc.) from `fastmcp.server.providers.proxy` instead.
|
||||
- **`fastmcp.server.openapi`** and its submodules (`server`, `components`, `routing`), including the **`FastMCPOpenAPI`** class (deprecated 3.0) — Breaking. Use `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` instead.
|
||||
- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively.
|
||||
- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead.
|
||||
- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained.
|
||||
- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
|
||||
|
||||
### `FastMCP` server methods and `mount()` kwargs
|
||||
|
||||
The following `FastMCP` methods and parameters, deprecated since 3.0, are removed:
|
||||
|
||||
- `FastMCP.as_proxy(...)` → `create_proxy(...)` (`from fastmcp.server import create_proxy`)
|
||||
- `FastMCP.import_server(sub)` → `mount(sub)`
|
||||
- `mount(prefix=...)` → `mount(namespace=...)`
|
||||
- `mount(as_proxy=...)` — removed; mounts always invoke the child's lifespan and middleware, so the flag was already meaningless. To proxy a server, wrap it with `create_proxy()` before mounting.
|
||||
- `FastMCP.add_tool_transformation(name, config)` → `add_transform(ToolTransform({name: config}))`
|
||||
- `FastMCP.remove_tool_transformation(name)` — removed; it was a no-op that only warned (transforms are immutable once added). Use `server.disable(keys=[...])` to hide tools.
|
||||
- `FastMCP.remove_tool(name)` → `mcp.local_provider.remove_tool(name)`
|
||||
|
||||
The `_REMOVED_KWARGS` constructor shim (which raises helpful `TypeError`s for kwargs removed in 3.0) is retained through 4.0.
|
||||
|
||||
### Tool and component parameters
|
||||
|
||||
- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](https://gofastmcp.com/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0.
|
||||
- **Tool `exclude_args` parameter** — removed from the tool decorator and its plumbing (`ParsedFunction.from_function`, `Tool.from_function`, `mcp.tool()`). Use dependency injection with `Depends()` to hide parameters from the tool schema instead.
|
||||
- **`decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode — removed. Decorators always return the original function with metadata attached; the object-returning machinery is gone. Access component objects through the server (e.g. `await mcp.get_tool("name")`) rather than the decorated function.
|
||||
- **Component-import compatibility shims** — Breaking. `fastmcp.tools.tool`, `fastmcp.resources.resource`, and `fastmcp.prompts.prompt` no longer exist as modules. Two separate mechanisms kept them alive and both are now gone: the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool`, `FunctionResource` / `resource`, and `FunctionPrompt` / `prompt`; and the `sys.modules` aliases that pointed each old module name at its renamed `base.py`. Import the component types from the package itself — `from fastmcp.tools import Tool, ToolResult` — and the function-backed classes from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`).
|
||||
- **`fastmcp.experimental.sampling`** and **`fastmcp.experimental.sampling.handlers`** (2.x-era re-export shims) — Breaking. These aliased the client-side sampling handlers without warning. Import from `fastmcp.client.sampling.handlers.openai` instead. Note this is unrelated to the SEP-2577 removal of *server-initiated* sampling: a FastMCP client still answers a legacy-era server's sampling requests, so `Client(sampling_handler=...)` and the Anthropic / OpenAI / Google GenAI handlers under `fastmcp.client.sampling.handlers` remain fully supported.
|
||||
- **`fastmcp.server.auth.authorization`** (3.0-era re-export shim) — Breaking. The module was a pass-through sitting between the `fastmcp.server.auth` package and the real implementation in `fastmcp.utilities.authorization`, and FastMCP's own middleware and local-provider decorators imported through it. Everything internal now imports from `fastmcp.utilities.authorization` directly. The documented public path is unchanged: `from fastmcp.server.auth import require_scopes, require_roles, restrict_tag, run_auth_checks, AuthCheck, AuthContext`. Two names the old module also exported — `run_auth_checks_with_shortfall` and `scope_requirements` — are *not* re-exported from `fastmcp.server.auth` and must be imported from `fastmcp.utilities.authorization`. They are middleware plumbing with no documented user-facing use, so they were deliberately not widened onto the auth package's surface; the upgrade guide names the utilities path for them explicitly.
|
||||
- **`SkillsProvider`** (3.0-era rename alias) — Breaking. Use `SkillsDirectoryProvider` from `fastmcp.server.providers.skills`. The alias was also re-exported from `fastmcp.server.providers`; both are gone.
|
||||
- **`ctx.elicit()` without `response_type`** (deprecated 3.2, warned through 3.4.4) — Breaking. The parameter is now required, and passing `None` explicitly raises `TypeError`. The empty-object schema it produced was ambiguous under the MCP spec and left some clients (e.g. VS Code) rendering an empty, non-functional form. Pass a type describing the data you expect back; `bool` covers confirmations. This is the server-authoring API only — the *client* elicitation handler still receives `response_type=None` for URL requests and for empty schemas sent by other servers, which is unchanged.
|
||||
|
||||
*Verify:* deletions of `fastmcp_slim/fastmcp/server/proxy.py`, `fastmcp_slim/fastmcp/server/openapi/`, `fastmcp_slim/fastmcp/experimental/server/openapi/`, `fastmcp_slim/fastmcp/experimental/utilities/openapi/`, `fastmcp_slim/fastmcp/server/apps.py`, `fastmcp_slim/fastmcp/server/app.py`; the removed classes in `fastmcp_slim/fastmcp/server/middleware/tool_injection.py`; the removed parameter in `fastmcp_slim/fastmcp/client/transports/http.py`; `fastmcp_slim/fastmcp/server/server.py`; `fastmcp_slim/fastmcp/tools/base.py`, `tools/function_tool.py`, `tools/tool_transform.py`, `tools/function_parsing.py`; `fastmcp_slim/fastmcp/settings.py`, `resources/function_resource.py`, `prompts/function_prompt.py`, and the local-provider decorators; `resources/base.py`, `prompts/base.py`.
|
||||
140
dev-docs/v4-notes/feature-program.md
Normal file
140
dev-docs/v4-notes/feature-program.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
title: Feature Program
|
||||
---
|
||||
|
||||
The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Several have now merged. Each feature below carries an explicit status:
|
||||
|
||||
- **Shipped** — merged to `main`, with the PR cited.
|
||||
- **Designed** — the approach is settled and an API sketch exists; implementation has not started.
|
||||
- **Planned** — the shape is agreed but design details remain open.
|
||||
- **Not started** — identified as v4 scope, not yet designed.
|
||||
|
||||
Code blocks marked as sketches show the *intended* API and do not resolve against the current tree.
|
||||
|
||||
## Sampling removal
|
||||
|
||||
**Status: Shipped in 4.0.**
|
||||
|
||||
Sampling was the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so it cannot work on modern connections, and `Client`'s flip to `mode="auto"` made a modern connection the default — the era gate had become the default experience rather than an edge case. Background-task sampling was dead under v2 in any event: a worker's back-channel is gone once the submitting request returns, and no relay was ever built (sdk-feedback #9).
|
||||
|
||||
Deprecation and era-gating shipped in #4448. The removal completes the plan: `ctx.sample`, `ctx.sample_step`, `ctx.list_roots`, `server/sampling/` (including `SamplingTool` and structured-result sampling), `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`, and `examples/sampling/` are all gone. The server-authoring API is now the modern protocol's API, with nothing in it that only works against old clients.
|
||||
|
||||
The migration story is honest: there is **no drop-in**. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. For roots, take paths as tool arguments or ask through the guard pattern, whose `input_requests` map still carries a `ListRootsRequest`.
|
||||
|
||||
The client-side provider handlers (Anthropic, OpenAI, Google GenAI) and `Client(sampling_handler=..., roots=...)` are **retained**: a FastMCP client still has to answer a legacy server's requests, and MRTR needs them from the client side. What is removed is the server-side push emitter. `ProxyClient`'s default relay handlers are retained for the same interop reason and now call the SDK session directly.
|
||||
|
||||
## MRTR elicitation
|
||||
|
||||
**Status: Guard form shipped (4.0). Declarative `Resolve` layer designed.**
|
||||
|
||||
Elicitation survives the modern era through multi-round-trip (MRTR). The 2026 wire envelope carries elicitation as a multi-round input-request: a tool returns an `InputRequiredResult` and re-runs per round, each round a complete request→response cycle. Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable through MRTR instead.
|
||||
|
||||
The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error.
|
||||
|
||||
What remains is the declarative `Resolve(...)` layer that sits *on top of* that shipped primitive. It is designed, not built: a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring). It would detect `Annotated[_, Resolve(...)]` parameters, build resolver plans, and return the SDK's `InputRequiredResult` instead of the tool body on the first round.
|
||||
|
||||
Imperative `ctx.elicit` is **not** re-plumbed to survive the modern era. It works on the legacy eras through the session back-channel, and on `2026-07-28` foreground calls it is era-gated to raise a clear error (shipped in #4448) pointing at the guard form. The earlier plan to keep imperative `ctx.elicit` alive on modern connections through a background-task relay is dead twice over: the guard model shipped in its place, and the 2025 task machinery the relay depended on is slated for removal (see [Known Gaps](known-gaps.md#the-xfail-register)).
|
||||
|
||||
The intended declarative DX (sketch — the module does not exist yet):
|
||||
|
||||
```python test="skip"
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp.elicitation import Resolve, Elicit, ElicitationResult
|
||||
|
||||
mcp = FastMCP("shipping")
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
zip: str
|
||||
|
||||
|
||||
async def ask_address(ctx: Context) -> Elicit[Address]:
|
||||
return Elicit("Where should we ship this order?", Address)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def create_shipment(
|
||||
order_id: str,
|
||||
address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError
|
||||
) -> str:
|
||||
return f"Shipping {order_id} to {address.city}"
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def maybe_ship(
|
||||
order_id: str,
|
||||
address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome
|
||||
) -> str:
|
||||
if address.action != "accept":
|
||||
return "cancelled"
|
||||
return f"Shipping {order_id} to {address.data.city}"
|
||||
```
|
||||
|
||||
The FastMCP client already dispatches input-requests through its elicitation callback; the remaining declarative work confirms the FastMCP client drives the input-required driver the way the SDK's own client does.
|
||||
|
||||
The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not.
|
||||
|
||||
## Middleware root dispatch
|
||||
|
||||
**Status: Shipped (#4553).**
|
||||
|
||||
The migration already routed `initialize` interception through the SDK's `ServerMiddleware` list via `FastMCPServerMiddleware`. #4553 made that entry the root of middleware dispatch: FastMCP's method-agnostic hooks (`on_message`, `on_request`, `on_notification`) now fire for every inbound message — client cancellations, progress notifications, and requests that fail routing or validation — not only the ones that reach a component handler. The component methods keep running their own chain interior, and a method set plus a dispatch flag keep the two passes disjoint so each hook fires exactly once per message.
|
||||
|
||||
## First-class 2026 client
|
||||
|
||||
**Status: Partly shipped (#4572, #4574); full composition blocked upstream.**
|
||||
|
||||
`fastmcp.Client` now defaults to `mode="auto"` (#4572): it probes `server/discover`, falls back to the classic handshake, and answers multi-round-trip `input_required` requests through its existing handlers. The same PR surfaced `extensions=` and `result_claims=` (SEP-2133). The client also dropped its forked protocol helpers — extension folding, the evicting message handler, discover synthesis — in favor of the SDK's own (#4574).
|
||||
|
||||
The decision here was **compose, not wrap** (D16): rebuild `fastmcp.Client` on the SDK's high-level `mcp.Client` rather than wrapping `mcp.ClientSession`. The parts that compose cleanly have shipped. The rest is **blocked upstream on two counts**. First, `mcp.Client` constructs its `ClientSession` at a single hardcoded site with no injection hook, while FastMCP's `session_class` is load-bearing (`ProxyClient` substitutes a session that skips result validation so a backend's schema violation surfaces at the end client rather than becoming a proxy error) — a `session_factory=` hook on `mcp.Client`, the same shape as the `notification_bindings=` parameter added earlier, would solve this. Second, `mcp.Client.__aenter__` refuses reentry, but FastMCP's client is deliberately reentrant (its refcounted context manager exists to fix a proxy session-reuse deadlock), so the rebuild also needs the SDK client to tolerate reentrant entry. Both must land upstream before the full rebuild is possible; `session_factory=` alone is necessary but not sufficient.
|
||||
|
||||
This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping and stateful-proxy affinity — since they turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](known-gaps.md#statelessness-on-2026-07-28) for the full accounting.
|
||||
|
||||
## Subscriptions, cache hints, extensions, OTel
|
||||
|
||||
**Status: Mixed — cache hints and OTel shipped; subscriptions not started.**
|
||||
|
||||
A cluster of protocol features tracked for v4. Their statuses have diverged:
|
||||
|
||||
- **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache.
|
||||
- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_TELEMETRY_MODE` setting (`native` / `propagation_only` / `off`).
|
||||
- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2).
|
||||
- **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus.
|
||||
|
||||
## FastMCP-native extension API
|
||||
|
||||
**Status: Shipped (#4602).**
|
||||
|
||||
MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings.
|
||||
|
||||
FastMCP already forwards `ClientExtension` natively (`Client(extensions=...)`, #4572). The **server** side does not use the SDK's `Extension` class at all: MCP Apps predates the abstraction, so FastMCP hand-splices the `ui` capability into `get_capabilities()` on the low-level server and walks tool metadata directly. That worked for one extension, but every new protocol extension currently means bespoke surgery on core.
|
||||
|
||||
The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](https://gofastmcp.com/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension.
|
||||
|
||||
## Background tasks (SEP-2663)
|
||||
|
||||
**Status: Shipped (#4603).**
|
||||
|
||||
Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only.
|
||||
|
||||
The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](background-tasks.md) page.
|
||||
|
||||
## SDK delegation, round two
|
||||
|
||||
**Status: Planned (gated on upstream).**
|
||||
|
||||
The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things:
|
||||
|
||||
1. per-session event-store scoping,
|
||||
2. a user-middleware injection hook,
|
||||
3. a lifespan hook.
|
||||
|
||||
The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](known-gaps.md)). Until they land, the four HTTP overrides in the [Change Register](change-register.md#http) stay.
|
||||
|
||||
One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it.
|
||||
49
dev-docs/v4-notes/index.md
Normal file
49
dev-docs/v4-notes/index.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
title: v4.0 Development Notes
|
||||
---
|
||||
|
||||
This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once.
|
||||
|
||||
1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](change-register.md).
|
||||
2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](feature-program.md). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](protocol-2026.md).
|
||||
3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](known-gaps.md) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work.
|
||||
|
||||
## Why v4 exists
|
||||
|
||||
FastMCP v4.0 is an engine swap. Three forces drive the major version:
|
||||
|
||||
**The MCP Python SDK v2 rebuild.** The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`). It also rewrites the server request-handling model — handlers are now registered by method string and return bare result models, there is no `request_ctx` ContextVar, and server-side middleware is a first-class SDK concept. FastMCP absorbs almost all of this so that a typical server needs zero code changes.
|
||||
|
||||
**Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition.
|
||||
|
||||
**Sampling and roots removed from the server API.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call, which takes `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` off the table. Rather than leave them half-working against old clients only, 4.0 removes them from the server API entirely — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. Client-side handlers stay, because a modern client still has to answer a legacy server.
|
||||
|
||||
## Release strategy
|
||||
|
||||
The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
|
||||
|
||||
- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](known-gaps.md) page.
|
||||
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
|
||||
|
||||
### Release codenames
|
||||
|
||||
Following the pun-title convention (`v<version>: <pun>`), the v4 line runs a single "four" motif across the whole cycle, holding the headline name for the stable release the way v3 did ("Three at Last" for `3.0.0`, stage puns for its betas):
|
||||
|
||||
| Release | Codename | The nod |
|
||||
| --- | --- | --- |
|
||||
| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine |
|
||||
| `4.0.0a2` (alpha) | **Back and Fourth** | _back and forth_ — the second pass, where background tasks and stateless state land |
|
||||
| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable |
|
||||
| `4.0.0b2` (beta) | **Fourmidable** | _formidable_ — held in reserve for a second beta if one is needed |
|
||||
| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation |
|
||||
|
||||
## How to read the register
|
||||
|
||||
Each subsystem section in the [Change Register](change-register.md) tags its changes with one of four dispositions:
|
||||
|
||||
- **Absorbed** — the SDK changed underneath, but FastMCP's public surface is identical. Nothing for users to do.
|
||||
- **Bridged** — a compatibility shim keeps old code working, usually with a `FastMCPDeprecationWarning`. Users should migrate but are not forced to.
|
||||
- **Breaking** — user code must change. These are the headline migration items.
|
||||
- **Deprecated** — still works, warns now, slated for removal in a later release.
|
||||
|
||||
The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it.
|
||||
85
dev-docs/v4-notes/known-gaps.md
Normal file
85
dev-docs/v4-notes/known-gaps.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
title: Known Gaps and Upstream Dependencies
|
||||
---
|
||||
|
||||
The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
|
||||
|
||||
## The xfail register
|
||||
|
||||
Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close.
|
||||
|
||||
**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](background-tasks.md)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly.
|
||||
|
||||
**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
|
||||
|
||||
**MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients.
|
||||
|
||||
## Shims and their removal triggers
|
||||
|
||||
Every shim in the migration is temporary and carries a documented removal trigger.
|
||||
|
||||
| Shim | Location | Removal trigger |
|
||||
| --- | --- | --- |
|
||||
| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. |
|
||||
| `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. |
|
||||
| `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. |
|
||||
| `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. |
|
||||
| Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). |
|
||||
| `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). |
|
||||
|
||||
The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only).
|
||||
|
||||
## Statelessness on 2026-07-28
|
||||
|
||||
The `2026-07-28` era is stateless by protocol construction, and the recurring maintainer question is whether that statelessness has to be woven through FastMCP everywhere. It does not — but the honest accounting has three parts: features that are legacy-only because the protocol removed the mechanism, features that already work because they never relied on a session, and a short list of design holes where the current code *doesn't error* but also *doesn't work*. Everything below concerns `2026-07-28` connections only. Every client in the field today negotiates a handshake era, where all of this behaves exactly as it always has.
|
||||
|
||||
**The SDK ground truth.** On the modern paths the SDK's `Connection` is strictly per-request: a fresh `Connection` is built from each POST's envelope, its `exit_stack` unwinds when the request returns, `connection.session_id` is always `None`, and `connection.state` is a fresh dict per request. The manager's `stateless` flag never enters the picture — modern routing short-circuits ahead of it. There is no standing server→client stream: notifications emitted *during* a request ride that POST's own SSE sink, and anything emitted after the POST returns is dropped (`_NO_CHANNEL`); server→client *requests* raise `NoBackChannelError`. The only replacement is `subscriptions/listen`, which carries four list-changed / resource-updated event kinds and nothing else — no logging, progress, or task-status events, no resumability, and it is not yet wired into FastMCP. There is no `EventStore` or `Last-Event-ID` on modern paths at all; both belong to the legacy transport.
|
||||
|
||||
### Legacy-only by construction — document, don't build
|
||||
|
||||
These are not bugs. The protocol removed the mechanism they depend on, so they are simply out of scope on `2026-07-28`:
|
||||
|
||||
- **Per-session log levels.** `logging/setLevel` is absent from the 2026 method registry, so the `_client_log_levels` handler is unreachable. There is no per-session log-level state because there is no session.
|
||||
- **`EventStore` / resumability.** `EventStore`, `SessionScopedEventStore`, and Last-Event-ID resumption are never constructed on the modern paths. Resumability presupposes a durable stream, which the era does not have.
|
||||
- **Ping keepalive.** Server-initiated ping is a server→client request and is therefore structurally a no-op on modern connections; the SDK owns SSE-level pings on this transport.
|
||||
|
||||
### Already stateless by construction — works on 2026
|
||||
|
||||
These work on `2026-07-28` today because they never leaned on a protocol session:
|
||||
|
||||
- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. This session-free polling is exactly why the execution engine survives the SEP-1686-to-SEP-2663 rework: the SEP-2663 wire shape (poll `tasks/get`, resolve in-task input via `tasks/update`) maps onto the same durable store, and SEP-2663's `Mcp-Name: <taskId>` routing header is moot for a shared-Redis deployment where any replica can serve the poll. See [the xfail register](#the-xfail-register).
|
||||
- **OAuth bearer validation.** Auth is per-request bearer validation — every POST carries and re-validates its own credential.
|
||||
- **In-request progress and logging notifications.** Notifications emitted while a request is still streaming ride that POST's SSE sink and are delivered normally.
|
||||
|
||||
### Design holes deferred to the multi-protocol workstream
|
||||
|
||||
The remaining items are real holes, deferred to the [first-class 2026 client](feature-program.md#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly.
|
||||
|
||||
- **`ctx.session_id` and `ctx.set_state` / `ctx.get_state` (broken even single-replica).** On a modern request `ctx.session_id` mints a fresh `uuid4`, cached on the per-request `connection.state` that is discarded when the request returns. So `ctx.set_state` and `ctx.get_state` silently never round-trip across requests — no error, just lost data. The open design decision is whether `session_id` should become `None` with `set_state` documented as session-era-only, or be re-based on an app-level key (the auth subject, or a client-supplied header).
|
||||
- **Task push and in-task input — resolved by the SEP-2663 design, not a statelessness hole.** This was previously framed as a hole because SEP-1686 leaned on a push back-channel (the notification/elicitation relay) that dies once the submitting request returns. SEP-2663 removes the dependency: in-task input is *poll-based* — the task enters `input_required`, surfaces its outstanding elicit/sample/roots requests in an `inputRequests` map on `tasks/get`, and the client answers via `tasks/update`. That round-trips through the durable store with no session affinity, so it is stateless-safe by construction. The SEP-1686 push relay (`server/tasks/elicitation.py`, `notifications.py`) is removed; the `fastmcp-tasks` rebuild implements the poll-based channel instead. Foreground (non-task) elicitation on 2026 remains the guard-mode `InputRequiredResult`.
|
||||
- **Stateful proxy affinity (degraded).** The stateful proxy's `_caches` are keyed by the per-request `Connection`, so on modern connections the proxy collapses to stateless proxying: results stay correct, but the per-session affinity guarantee is lost. This is decided alongside the `session_id` question — same root — or gated to the legacy/stdio transports.
|
||||
|
||||
Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends for state and tasks, a Redis `SubscriptionBus`) are deployment configuration rather than protocol gaps and are out of scope for this section.
|
||||
|
||||
## Upstream advisory dossier
|
||||
|
||||
FastMCP acts as an advisor to the SDK team. The migration produced a dossier of ten findings (`sdk-feedback.md`) — verified bugs and hard edges to report upstream, plus questions to bundle into a feedback thread. The highest-priority items:
|
||||
|
||||
- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. *Moot: the SEP-1686 wire shape was removed from the spec; the SEP-2663 rebuild claims `CreateTaskResult` on `tools/call` through the extensions mechanism, which the registries already admit.*
|
||||
- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions. **Elevated:** this now gates the `io.modelcontextprotocol/tasks` extension (and MCP Apps) on the modern era, so it blocks a flagship v4 feature rather than an edge case. Worth prioritizing in the upstream thread.
|
||||
- **#4 (security)** — DCR redirect-URI validation accepts `javascript:`/`data:` schemes.
|
||||
- **#5 (hard edge)** — `streamable_http_client` drops session-id access with no replacement.
|
||||
- **#8 (hard edge)** — custom server notifications are dropped, not tee'd to `message_handler`.
|
||||
- **#10 (hard edge)** — 2026 push-feature degradation error quality is inconsistent. *Resolved on the FastMCP side: `ctx.elicit` / `ctx.sample` are era-gated to raise a clear error on modern connections (#4448).*
|
||||
|
||||
Filing is gated on maintainer approval of each issue text.
|
||||
|
||||
Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement.
|
||||
|
||||
## GA transition checklist
|
||||
|
||||
The beta-to-stable transition is a small set of tracked steps:
|
||||
|
||||
- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
|
||||
- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
|
||||
- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.
|
||||
53
dev-docs/v4-notes/protocol-2026.md
Normal file
53
dev-docs/v4-notes/protocol-2026.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
title: 2026-07-28 Protocol Support
|
||||
---
|
||||
|
||||
FastMCP v4 serves the sessionless `2026-07-28` protocol era and the session-based handshake eras from a single server, with per-connection auto-detection. This page catalogs what FastMCP provides for the modern era — both the protocol machinery it inherits from the MCP Python SDK and the capabilities FastMCP implements itself on top of that layer. It is the reference for what a v4 deployment can actually do on the modern protocol today.
|
||||
|
||||
## Identity assertion (SEP-990)
|
||||
|
||||
SEP-990 defines enterprise "on-behalf-of" access: a corporate identity provider (Okta, Microsoft Entra, etc.) issues a signed *ID-JAG* asserting an employee's identity, the employee's agent presents it at the MCP authorization server's token endpoint via the RFC 7523 `jwt-bearer` grant, and receives a short-lived access token — no browser login, no per-user consent screen, and revocation lives at the IdP.
|
||||
|
||||
The protocol layer for this flow — grant parsing, the `exchange_identity_assertion` provider hook, and metadata advertisement — comes from the SDK. The validation and issuance logic that makes the flow actually work is FastMCP's implementation, and enabling it is one parameter on the existing auth providers:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import OAuthProxy, IdentityAssertion
|
||||
|
||||
auth = OAuthProxy(
|
||||
..., # existing upstream configuration unchanged
|
||||
identity_assertion=IdentityAssertion(
|
||||
trusted_issuers=["https://login.acme-corp.com"],
|
||||
),
|
||||
)
|
||||
mcp = FastMCP("Internal API", auth=auth)
|
||||
```
|
||||
|
||||
Behind that one parameter, FastMCP performs the full SEP-990 §5.1 / RFC 7523 §3 processing: JWKS-based signature verification with automatic OIDC discovery of issuer keys, `typ`/`iss`/`aud`/`sub` validation, temporal checks (`exp`, `iat`, `nbf`, maximum assertion lifetime), enforcement of the assertion's signed `client_id` and `resource` bindings, `jti` replay rejection, scope derivation from the signed assertion (client requests can narrow but never widen), short-lived token issuance with no refresh token, and revocation tracking for the issued tokens. The asserted subject flows into the normal FastMCP auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990) for the full documentation.
|
||||
|
||||
This slots into FastMCP's existing authorization-server stack — the OAuth proxy's dynamic client registration, the consent flow, and self-issued JWTs — which is what makes a one-parameter enterprise deployment possible.
|
||||
|
||||
## Modern-era capability inventory
|
||||
|
||||
The complete picture of what a FastMCP v4 server and client provide on the `2026-07-28` era:
|
||||
|
||||
| Capability | What FastMCP provides |
|
||||
| --- | --- |
|
||||
| **Dual-era serving** | One server answers both `server/discover` (modern, sessionless) and `initialize` (handshake) connections, auto-detected per connection. Any replica behind a plain load balancer can answer a modern request. |
|
||||
| **Identity assertion (SEP-990)** | Complete server-side implementation, one parameter to enable (above). |
|
||||
| **Authorization server** | Full AS stack: `OAuthProxy` bridges DCR-expecting MCP clients to non-DCR enterprise IdPs, ~18 built-in providers, consent UI, self-issued JWTs, protected-resource metadata (RFC 9728). |
|
||||
| **Cache hints (SEP-2549)** | Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`) stamps every cacheable result; the FastMCP client honors hints with an opt-in response cache. |
|
||||
| **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. |
|
||||
| **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. |
|
||||
| **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. |
|
||||
| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. |
|
||||
| **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. |
|
||||
| **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). |
|
||||
| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. |
|
||||
| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. |
|
||||
| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_TELEMETRY_MODE` selects `native`, `propagation_only` (interop with an outer MCP instrumentation layer), or `off`. |
|
||||
| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](background-tasks.md) for the design and [servers/tasks](https://gofastmcp.com/servers/tasks) for usage. |
|
||||
|
||||
## Still in the program
|
||||
|
||||
Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](feature-program.md), along with the unified `subscriptions/listen` stream. The [Known Gaps](known-gaps.md) page tracks the upstream dependencies that gate them.
|
||||
217
dev-docs/v4-notes/stateless-session-state.md
Normal file
217
dev-docs/v4-notes/stateless-session-state.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Stateless session state (2026-07-28)
|
||||
|
||||
> Design spec. Status: building.
|
||||
|
||||
## Problem
|
||||
|
||||
The `2026-07-28` era is stateless by protocol construction: each request builds a
|
||||
fresh `Connection`, `connection.session_id` is always `None`, and
|
||||
`connection.state` is a new dict discarded when the request returns. So
|
||||
`ctx.session_id` mints a throwaway `uuid4` per request and `ctx.set_state` /
|
||||
`ctx.get_state` **silently never round-trip** — no error, just lost data. A user
|
||||
who wants cross-call state (a cart, a conversation, accumulated context) has no
|
||||
safe mechanism, and the failure is invisible.
|
||||
|
||||
The one identifier every modern request carries that is stable and
|
||||
**non-spoofable** is the authenticated principal — `get_access_token().claims["sub"]`,
|
||||
or the `(client_id, issuer, subject)` triple. Everything else on the wire is
|
||||
client-declared and forgeable.
|
||||
|
||||
## The model
|
||||
|
||||
State lives **server-side** in the one `AsyncKeyValue` (py-key-value) store the
|
||||
server already holds (`session_state_store`). The framework calls `get`/`put`/
|
||||
`delete` and **never imposes a TTL** — retention is entirely the store's
|
||||
(configure it on the store you pass: a Redis TTL, a py-key-value TTL wrapper,
|
||||
whatever). There is no second store and no framework-owned TTL knob.
|
||||
|
||||
Isolation comes from the **authenticated principal, not from the session id.**
|
||||
State is keyed by `(principal, session_id)`. A request under principal B keys
|
||||
into B's own namespace — it can never address A's keys no matter what
|
||||
`session_id` it passes. The id only organizes sessions *within* a principal. The
|
||||
handle is a bare `uuid4` string; it is **not sealed** — the principal prefix is
|
||||
the wall. Sessions are also create-then-validate (below): an id that was never
|
||||
minted by `create_session` under this principal is rejected outright, not
|
||||
resolved to an empty session.
|
||||
|
||||
## Two explicit patterns
|
||||
|
||||
A tool opts into exactly one, on purpose. There is deliberately **no** optional
|
||||
"id if given, else default" parameter — that would silently misroute a call
|
||||
whose id the agent forgot to pass into the shared per-user bucket, which is the
|
||||
invisible-degradation failure this whole feature exists to remove.
|
||||
|
||||
### Per-user state — injected
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import UserSession
|
||||
|
||||
@mcp.tool
|
||||
async def remember(fact: str, session: UserSession) -> str:
|
||||
await session.set("fact", fact)
|
||||
return "noted"
|
||||
```
|
||||
|
||||
`session: UserSession` is **dependency-injected** (like `ctx: Context`): keyed by
|
||||
the request's authenticated principal, not present in the input schema, nothing
|
||||
for the agent to pass. Requires auth — with no principal it raises a clear error.
|
||||
Use it when one bucket per user is what you want. `UserSession` is only the
|
||||
injection annotation — the value the handler receives is an ordinary `Session`,
|
||||
so its `get`/`set`/`delete`/`clear` accessors work as usual.
|
||||
|
||||
### Distinct sessions — an argument
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import SessionId
|
||||
from fastmcp.server.dependencies import get_session
|
||||
|
||||
@mcp.tool
|
||||
async def add_to_cart(item: str, session_id: SessionId) -> str:
|
||||
session = await get_session(session_id)
|
||||
cart = await session.get("cart", default=[])
|
||||
cart.append(item)
|
||||
await session.set("cart", cart)
|
||||
return f"{len(cart)} items"
|
||||
```
|
||||
|
||||
`session_id: SessionId` is a **required string argument** — it *is* in the schema,
|
||||
the agent supplies it. `SessionId` is a marker type so the framework
|
||||
auto-populates the argument's description with the protocol:
|
||||
|
||||
> "Session identifier. Use a tool to create a session, then pass the resulting id
|
||||
> here to persist state across calls in the same session."
|
||||
|
||||
The tool becomes self-teaching — an agent reads the schema and learns the
|
||||
create-then-pass contract with no hand-prompting. The description names no
|
||||
specific tool: composition can rename the lifecycle tool (mounting under a
|
||||
namespace exposes it as `child_create_session`), so it points at the
|
||||
*capability* rather than a name that may not exist under that mount.
|
||||
|
||||
The standalone `await get_session(session_id)` resolves the id to a `Session`
|
||||
keyed by `(principal, session_id)`, **validating** that it was created under this
|
||||
principal — an unknown or foreign id raises `InvalidSession` rather than opening a
|
||||
fresh bucket. It is a plain function, not a `Context` method, so it needs no
|
||||
foreground context and works from a `task=True` tool's worker. Use this pattern
|
||||
when a user needs more than one session.
|
||||
|
||||
## The `Session` object
|
||||
|
||||
Async accessors over the server store, scoped to one `(principal, session_id)`:
|
||||
|
||||
- `session.id` — the session's id (set for a `session_id`-resolved session; `None`
|
||||
for an injected `UserSession`, which has no distinct id).
|
||||
- `await session.get(key, default=None)`
|
||||
- `await session.set(key, value)`
|
||||
- `await session.delete(key)`
|
||||
- `await session.clear()` — empties user state but **keeps the session valid**.
|
||||
- `await session.end()` — deletes the session (what `end_session` calls).
|
||||
|
||||
A session's state is stored as a **single dict under one key**
|
||||
(`session:{sha256(principal)}:{session_id}`, and `session:anon:{session_id}` when
|
||||
unauthenticated — the principal is hashed into a fixed-length, delimiter-safe
|
||||
segment, never embedded raw). That dict holds user state in a `state` sub-dict
|
||||
alongside a small `_created` marker, so a created-but-empty session is
|
||||
distinguishable from a missing one even if the store collapses empty dicts.
|
||||
`get`/`set`/`delete` read-modify-write the sub-dict and never touch the marker;
|
||||
`clear` resets the sub-dict but leaves the marker (the session still resolves);
|
||||
`end` deletes the key. Namespacing user state under `state` is what keeps a user
|
||||
key named `_created` from colliding with the marker. One key per session means
|
||||
one TTL per session (the store's), refreshed on write — no key index to maintain,
|
||||
and `end` is a single delete. (Trade-off: concurrent writes to one session race
|
||||
on the read-modify-write; session state is small and typically driven serially by
|
||||
one agent, so this is acceptable — noted, not hidden.)
|
||||
|
||||
## `SessionProvider`
|
||||
|
||||
Session ids are minted by `SessionProvider`, which contributes two tools:
|
||||
|
||||
- `create_session()` → mints an unguessable `uuid4`, **records** the session
|
||||
under the current principal, and returns the id as a string.
|
||||
- `end_session(session_id: SessionId)` → validates the id, then deletes the
|
||||
session so it no longer resolves.
|
||||
|
||||
Register it whenever your tools take a `session_id` — providers are the idiomatic
|
||||
way to add functionality like this:
|
||||
|
||||
```python
|
||||
from fastmcp.server.sessions import SessionProvider
|
||||
|
||||
mcp.add_provider(SessionProvider())
|
||||
```
|
||||
|
||||
There is **no enforcement** that a provider is registered, and there was: an
|
||||
earlier version scanned the tool set at list/resolve time and raised if a
|
||||
`session_id` tool had no provider. That check had to reason about the whole
|
||||
composition pipeline — `isinstance` on providers, unwrapping namespaced ones,
|
||||
tool transforms, session visibility, enabled state — and produced false
|
||||
positives that broke valid servers (a namespaced provider, a session-disabled
|
||||
tool). It was deleted. The guarantee never needed it: `get_session` validates
|
||||
that an id was recorded (create-then-validate), so a server with no provider
|
||||
simply cannot mint ids, and every `get_session` rejects — a misconfiguration
|
||||
caught the first time the tools run, not a security hole.
|
||||
|
||||
`SessionProvider` subclasses `Provider`, takes **no store** (uses the server's)
|
||||
and **no ttl** (the store's). It exists to mint and end owned ids.
|
||||
`create_session` matters most without auth, where an unguessable id is the only
|
||||
defense against a caller *guessing* onto another session.
|
||||
|
||||
When an application already mints its own identifiers — conversation ids, workflow
|
||||
ids — take them as ordinary string arguments rather than `SessionId`, and register
|
||||
no provider; `SessionId` is specifically the create-then-pass contract backed by
|
||||
`create_session`.
|
||||
|
||||
## Security
|
||||
|
||||
Keyed by `(principal, session_id)`:
|
||||
|
||||
- **Authenticated → strong isolation.** `principal` is the validated token
|
||||
subject, unforgeable. B keys into B's namespace; A's data is unreachable no
|
||||
matter what id B passes. Guessing is pointless; a session id appearing in agent
|
||||
context or logs is harmless (it is not a capability without the principal).
|
||||
Caller-chosen ids are safe here.
|
||||
- **Unauthenticated → single-tenant-safe only.** No principal, so the key is just
|
||||
the id in a shared namespace: the id becomes a bearer capability, and exposure
|
||||
in logs/conversation leaks the session. `create_session`'s `uuid4` gives
|
||||
guess-*resistance*, not isolation. Documented in bold: not a tenant boundary;
|
||||
without auth, force minted ids and never treat sessions as a wall between
|
||||
clients.
|
||||
- **Isolation is auth; the id is organization.** No id scheme substitutes for a
|
||||
principal, which is why sealing the handle buys nothing load-bearing and is
|
||||
dropped.
|
||||
- **Not FastMCP's job:** transport (use TLS), encryption at rest (the store's), a
|
||||
malicious *authorized* client acting within its rights.
|
||||
|
||||
## Rework plan (from the current prototype)
|
||||
|
||||
The prototype (`sessions.py`, `context.py`, `function_tool.py`, `server.py`) built
|
||||
a `Scope` enum, a sealed `SessionCodec`, and `ctx.get_state(scope=...)`. Rework to
|
||||
the above:
|
||||
|
||||
1. **Remove `Scope`** and the `scope=` parameter; revert `ctx.get_state`/
|
||||
`set_state` to their original request-scoped behavior.
|
||||
2. **Remove the `SessionCodec`/sealing** — ids are bare `uuid4`.
|
||||
3. **`Session` object** with async `get`/`set`/`delete`/`clear` over the server
|
||||
store, single-dict-per-session key scheme.
|
||||
4. **`session: UserSession`** injection (principal-keyed; error without auth) —
|
||||
wire into the same parameter-detection path as `Context`. `UserSession` is the
|
||||
injection marker; the injected value is a `Session`.
|
||||
5. **`session_id: SessionId`** marker type: string in the schema, auto-filled
|
||||
description, standalone `await get_session(id)` resolver that validates the id
|
||||
(works from a task worker — no foreground context needed).
|
||||
6. **`SessionProvider(Provider)`** with `create_session` (records the session) /
|
||||
`end_session` (deletes it), registered explicitly via `add_provider`. No
|
||||
enforcement that it is present — `get_session`'s validation is the guarantee.
|
||||
7. Rewrite the tests to cover both patterns, principal isolation, no-auth
|
||||
behavior, and `end_session`.
|
||||
|
||||
## Docs plan
|
||||
|
||||
Written against the final API once the rework verifies:
|
||||
|
||||
- A concept guide — why stateless removes the session, the two patterns, when to
|
||||
reach for each. Why before how.
|
||||
- A security page — the two tiers, "isolation is auth, the id is organization,"
|
||||
the bold no-multitenant-without-auth warning.
|
||||
- Fully runnable examples for both patterns (pass the doc-import guard, register
|
||||
in `docs.json`).
|
||||
- A migration note from the old `ctx.session_id` / `set_state`.
|
||||
|
|
@ -1,119 +1,142 @@
|
|||
---
|
||||
title: App Architecture
|
||||
title: Architecture
|
||||
sidebarTitle: Architecture
|
||||
description: How FastMCP apps work under the hood — from Python to pixels.
|
||||
icon: sitemap
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
This page explains how Prefab apps work under the hood — how your Python code becomes an interactive UI inside a host client's conversation. You don't need any of this to build apps, but the mental model is useful when something isn't rendering the way you expect, when tool calls from the UI aren't reaching your server, or when you're building [custom HTML apps](/apps/low-level) and need to understand the protocol directly.
|
||||
You don't need this page to build apps. It's for when something isn't rendering the way you expect, when UI tool calls aren't reaching your server, or when you're writing [custom HTML apps](/apps/low-level) and need to understand the protocol directly.
|
||||
|
||||
## The Pipeline
|
||||
## The pipeline
|
||||
|
||||
An MCP App moves through five stages from Python to pixels:
|
||||
An MCP app moves through five stages from Python to pixels:
|
||||
|
||||
```
|
||||
Python components → JSON tree → structuredContent → Renderer iframe → Host UI
|
||||
```
|
||||
|
||||
You write Prefab components in Python. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON into it, and the renderer paints the UI. If the UI needs to call server tools, it talks back through the same `postMessage` channel.
|
||||
You write Prefab components. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON in, and the renderer paints the UI. If the UI calls server tools, it talks back through the same `postMessage` channel.
|
||||
|
||||
The following sections walk through each stage.
|
||||
The sections below walk each stage.
|
||||
|
||||
## Tool Registration
|
||||
## Tool registration
|
||||
|
||||
When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires.
|
||||
|
||||
### The `app=True` Flag
|
||||
### The `app=True` flag
|
||||
|
||||
The `app` parameter on `@mcp.tool` accepts `True`, an `AppConfig` object, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If the tool qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict.
|
||||
`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP explicitly marks the tool as a Prefab UI tool and stamps placeholder UI metadata so the provider can synthesize the correct renderer resource later. When you omit `app`, FastMCP only applies this automatically if the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them).
|
||||
|
||||
This expansion also triggers registration of the shared Prefab renderer resource (discussed below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`", and the host fetches that resource when it needs to display the result.
|
||||
The tool and renderer are linked through a `resourceUri` field in the metadata. Internally, registration uses the placeholder URI `ui://prefab/renderer.html`; when tools and resources are listed or read, FastMCP rewrites that placeholder to a per-tool URI like `ui://prefab/tool/<hash>/renderer.html` and synthesizes the matching renderer resource on demand.
|
||||
|
||||
Type inference works the same way. If your return type annotation is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`.
|
||||
### FastMCPApp registration
|
||||
|
||||
### FastMCPApp Registration
|
||||
`FastMCPApp` uses the same mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. That tag lets the server identify which app a tool belongs to when routing UI calls.
|
||||
|
||||
`FastMCPApp` uses the same underlying mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. This tag is how the server identifies which app a tool belongs to when routing calls from the UI.
|
||||
|
||||
Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (visible to the LLM). Backend tools default to `["app"]` (visible only to the UI). Hosts use this to filter the tool list — the model sees entry points, and the UI sees backends.
|
||||
Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (LLM-visible). Backend tools default to `["app"]` (UI-only). Hosts use this to filter the tool list.
|
||||
|
||||
## Serialization
|
||||
|
||||
When a Prefab tool runs, its return value — a `PrefabApp` or a raw `Component` — needs to become a JSON blob that the renderer can interpret.
|
||||
When a Prefab tool runs, its return value — a `PrefabApp` or a bare `Component` — becomes a JSON blob the renderer can interpret.
|
||||
|
||||
### PrefabApp.to_json()
|
||||
### `PrefabApp.to_json()`
|
||||
|
||||
The serialization entry point is `PrefabApp.to_json()`. This method walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata).
|
||||
The entry point is `PrefabApp.to_json()`. It walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata).
|
||||
|
||||
FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the component tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` in the wire format. The resolver also handles `unwrap_result` — a flag that tells the renderer to unwrap single-value results from the `{"result": value}` envelope that FastMCP uses for schema compliance.
|
||||
FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. For `FastMCPApp` backend tools, that registered name is then wrapped in the deterministic hashed format described below. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance.
|
||||
|
||||
### The _meta.fastmcp.app Tag
|
||||
### Hashed backend tool references
|
||||
|
||||
After `to_json()` produces the JSON tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer.
|
||||
FastMCP still tags app tools with `meta["fastmcp"]["app"]`, but backend routing no longer depends on sending the app name through each tool call. During serialization, FastMCP passes a resolver to `PrefabApp.to_json()`. When the tree contains `CallTool(save_contact)`, the resolver turns it into a deterministic hashed name such as `<hash>_save_contact`, where the hash is derived from the app name and backend tool name.
|
||||
|
||||
When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms — more on this in the next section.
|
||||
That hashed name rides along inside `structuredContent` all the way to the renderer. When the renderer calls the backend tool, it sends the hashed tool name in the normal MCP `tools/call` request. The server recognizes that format and routes through the app-tool lookup path described below.
|
||||
|
||||
### ToolResult Assembly
|
||||
### ToolResult assembly
|
||||
|
||||
The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves.
|
||||
|
||||
## Tool Call Routing
|
||||
## Tool call routing
|
||||
|
||||
When a host calls a tool, the server needs to find it. Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters, etc.) before resolving the tool by name. But app UI calls need a different path.
|
||||
A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed.
|
||||
|
||||
### The get_app_tool Bypass
|
||||
A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host.
|
||||
|
||||
Backend tools registered with `@app.tool()` are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — but the renderer still uses the original name.
|
||||
### Late-bound tool names
|
||||
|
||||
`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This method walks the provider tree directly, skipping the transform chain entirely. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app identity.
|
||||
The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke.
|
||||
|
||||
This is why `CallTool("save_contact")` keeps working even when the server is mounted under a namespace prefix. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find the tool without transforms getting in the way.
|
||||
Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name.
|
||||
|
||||
Authorization checks still apply — `get_app_tool` bypasses transforms, but it runs auth checks against the tool's `auth` configuration before executing.
|
||||
The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention.
|
||||
|
||||
### Provider Delegation
|
||||
A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it.
|
||||
|
||||
The `get_app_tool` method is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across all child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. This means backend tools are reachable through any depth of server composition.
|
||||
### One copy of an app per server
|
||||
|
||||
## The Renderer
|
||||
**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work.
|
||||
|
||||
The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded.
|
||||
|
||||
FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause:
|
||||
|
||||
```
|
||||
Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'.
|
||||
The same app is composed more than once, so this call cannot be routed to a
|
||||
single tool.
|
||||
```
|
||||
|
||||
Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design.
|
||||
|
||||
### The hashed lookup fallback
|
||||
|
||||
The identity-addressed form `<hash>_<local_name>` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms.
|
||||
|
||||
When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch.
|
||||
|
||||
Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
|
||||
|
||||
### Provider delegation
|
||||
|
||||
`get_tool_by_hash` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's hashed lookup. Backend tools are reachable through any depth of composition.
|
||||
|
||||
## The renderer
|
||||
|
||||
The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI.
|
||||
|
||||
### The Shared Resource
|
||||
### Renderer resources
|
||||
|
||||
FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The renderer HTML is bundled inside the `prefab-ui` Python package — `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource, regardless of how many tools or apps are registered.
|
||||
FastMCP exposes the renderer through per-tool resources such as `ui://prefab/tool/<hash>/renderer.html`, each with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. The resources are synthesized on demand from each tool's UI metadata, so CSP and permissions can differ per tool even though they use the same Prefab renderer.
|
||||
|
||||
The resource also carries CSP metadata (via `get_renderer_csp()`) declaring which CDN domains the renderer needs to load its JavaScript dependencies. Hosts use this to configure the iframe's Content Security Policy.
|
||||
The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy.
|
||||
|
||||
### postMessage Communication
|
||||
### `postMessage` communication
|
||||
|
||||
The renderer lives in a sandboxed iframe. It communicates with the host using `postMessage` — the standard browser API for cross-origin iframe communication. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) specification:
|
||||
The renderer lives in a sandboxed iframe and communicates with the host using `postMessage`. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) spec:
|
||||
|
||||
The host pushes the tool result (including `structuredContent`) into the iframe. The renderer parses the JSON component tree, initializes state, and renders the UI. When the user interacts with the UI — submitting a form, clicking a button — and that interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards this as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing.
|
||||
The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, using the hashed backend name that FastMCP serialized into the action.
|
||||
|
||||
The response flows back the same way: server to host, host to iframe via `postMessage`, renderer updates state with the result.
|
||||
The response flows back the same way: server → host → iframe via `postMessage`, and the renderer updates state with the result.
|
||||
|
||||
### AppBridge
|
||||
|
||||
The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (like safe area insets and theme preferences). The Prefab renderer uses this SDK internally — you only interact with it directly when building [custom HTML apps](/apps/low-level).
|
||||
The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (safe area insets, theme preferences). The Prefab renderer uses it internally; you only touch it directly when building [custom HTML apps](/apps/low-level).
|
||||
|
||||
## The Dev Server
|
||||
## The dev server
|
||||
|
||||
`fastmcp dev apps` provides a local preview environment that simulates the host-side behavior without requiring a real MCP host client.
|
||||
`fastmcp dev apps` simulates the host-side behavior locally without a real MCP client.
|
||||
|
||||
### Proxy Architecture
|
||||
### Proxy architecture
|
||||
|
||||
The dev server runs two HTTP servers. Your MCP server starts on port 8000 (configurable) with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools.
|
||||
Two HTTP servers. Your MCP server runs on port 8000 with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools.
|
||||
|
||||
A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This is important because the renderer iframe runs on `localhost:8080`, and your MCP server runs on `localhost:8000`. Without the proxy, the renderer's `callServerTool` requests would be cross-origin and blocked by the browser. The proxy makes everything same-origin from the iframe's perspective.
|
||||
A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This matters because the renderer iframe runs on `localhost:8080` and your MCP server runs on `localhost:8000` — without the proxy, the renderer's `callServerTool` requests would be cross-origin and the browser would block them. The proxy keeps everything same-origin from the iframe's perspective.
|
||||
|
||||
### The Launch Flow
|
||||
### The launch flow
|
||||
|
||||
When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (fetched from the proxy) in an iframe, creates an AppBridge instance, and pushes the tool result into the renderer. From this point forward, the experience matches what a real host would provide — the renderer displays the UI, and any `CallTool` actions route back through the proxy to your MCP server.
|
||||
When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (via the proxy), creates an AppBridge, and pushes the tool result into the renderer. From here on it matches what a real host provides: the renderer displays the UI, and any `CallTool` actions route back through the proxy to your server.
|
||||
|
||||
Auto-reload is enabled by default, so changes to your server code restart the MCP server automatically. The dev UI stays running — just re-launch the tool to see your changes.
|
||||
Auto-reload is on by default, so changes to your server code restart the MCP server automatically. The dev UI keeps running — relaunch the tool to see changes.
|
||||
|
|
|
|||
23
docs/apps/demos/bar-chart.py
Normal file
23
docs/apps/demos/bar-chart.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
|
||||
data = [
|
||||
{"quarter": "Q1", "revenue": 42000, "costs": 28000},
|
||||
{"quarter": "Q2", "revenue": 51000, "costs": 31000},
|
||||
{"quarter": "Q3", "revenue": 47000, "costs": 29000},
|
||||
{"quarter": "Q4", "revenue": 63000, "costs": 35000},
|
||||
]
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(css_class="p-6"):
|
||||
BarChart(
|
||||
data=data,
|
||||
series=[
|
||||
ChartSeries(data_key="revenue", label="Revenue"),
|
||||
ChartSeries(data_key="costs", label="Costs"),
|
||||
],
|
||||
x_axis="quarter",
|
||||
show_legend=True,
|
||||
height=250,
|
||||
)
|
||||
78
docs/apps/demos/contacts.py
Normal file
78
docs/apps/demos/contacts.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from prefab_ui.actions import ShowToast
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
H3,
|
||||
Badge,
|
||||
Button,
|
||||
Column,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Select,
|
||||
SelectOption,
|
||||
Separator,
|
||||
)
|
||||
|
||||
contacts = [
|
||||
{"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"},
|
||||
{"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"},
|
||||
{
|
||||
"name": "Trillian Astra",
|
||||
"email": "trillian@heartofgold.com",
|
||||
"category": "Customer",
|
||||
},
|
||||
{"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Vendor"},
|
||||
]
|
||||
|
||||
rows = [
|
||||
{
|
||||
"name": c["name"],
|
||||
"email": c["email"],
|
||||
"category": Badge(
|
||||
c["category"],
|
||||
variant="success"
|
||||
if c["category"] == "Customer"
|
||||
else "secondary"
|
||||
if c["category"] == "Partner"
|
||||
else "outline",
|
||||
),
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="email", header="Email"),
|
||||
DataTableColumn(key="category", header="Category"),
|
||||
],
|
||||
rows=rows,
|
||||
search=True,
|
||||
)
|
||||
|
||||
Separator()
|
||||
|
||||
H3("Add Contact")
|
||||
with Form(
|
||||
on_submit=ShowToast(
|
||||
"Contact saved! (preview demo — no backend wired)",
|
||||
variant="success",
|
||||
),
|
||||
):
|
||||
with Row(gap=4):
|
||||
Input(name="name", label="Name", placeholder="Full name", required=True)
|
||||
Input(
|
||||
name="email",
|
||||
label="Email",
|
||||
placeholder="name@example.com",
|
||||
required=True,
|
||||
)
|
||||
with Select(name="category", label="Category"):
|
||||
SelectOption(value="Customer", label="Customer")
|
||||
SelectOption(value="Partner", label="Partner")
|
||||
SelectOption(value="Vendor", label="Vendor")
|
||||
Button("Save Contact")
|
||||
68
docs/apps/demos/dashboard.py
Normal file
68
docs/apps/demos/dashboard.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Badge,
|
||||
Column,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
Row,
|
||||
Separator,
|
||||
)
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from prefab_ui.components.metric import Metric
|
||||
|
||||
monthly = [
|
||||
{"month": "Jan", "revenue": 48200, "costs": 31000},
|
||||
{"month": "Feb", "revenue": 52100, "costs": 32500},
|
||||
{"month": "Mar", "revenue": 61800, "costs": 34200},
|
||||
{"month": "Apr", "revenue": 58400, "costs": 33800},
|
||||
]
|
||||
|
||||
deals = [
|
||||
{"account": "Acme Corp", "value": "$84,000", "stage": "Won"},
|
||||
{"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"},
|
||||
{"account": "Initech", "value": "$31,500", "stage": "Proposal"},
|
||||
{"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"},
|
||||
]
|
||||
|
||||
rows = [
|
||||
{
|
||||
"account": d["account"],
|
||||
"value": d["value"],
|
||||
"stage": Badge(
|
||||
d["stage"],
|
||||
variant="success"
|
||||
if d["stage"] == "Won"
|
||||
else "destructive"
|
||||
if d["stage"] == "Lost"
|
||||
else "secondary",
|
||||
),
|
||||
}
|
||||
for d in deals
|
||||
]
|
||||
|
||||
total = sum(m["revenue"] for m in monthly)
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
with Row(gap=6):
|
||||
Metric(label="Revenue (Q1-Q4)", value=f"${total:,}")
|
||||
Metric(label="Deals", value=f"{len(deals)}")
|
||||
BarChart(
|
||||
data=monthly,
|
||||
series=[
|
||||
ChartSeries(data_key="revenue", label="Revenue"),
|
||||
ChartSeries(data_key="costs", label="Costs"),
|
||||
],
|
||||
x_axis="month",
|
||||
show_legend=True,
|
||||
height=200,
|
||||
)
|
||||
Separator()
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="account", header="Account", sortable=True),
|
||||
DataTableColumn(key="value", header="Value", sortable=True),
|
||||
DataTableColumn(key="stage", header="Stage"),
|
||||
],
|
||||
rows=rows,
|
||||
)
|
||||
24
docs/apps/demos/data-table.py
Normal file
24
docs/apps/demos/data-table.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, DataTable, DataTableColumn
|
||||
|
||||
employees = [
|
||||
{"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"},
|
||||
{"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"},
|
||||
{"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"},
|
||||
{"name": "David Kim", "role": "Product Manager", "dept": "Product"},
|
||||
{"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"},
|
||||
{"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"},
|
||||
{"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"},
|
||||
]
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="role", header="Role", sortable=True),
|
||||
DataTableColumn(key="dept", header="Dept", sortable=True),
|
||||
],
|
||||
rows=employees,
|
||||
search=True,
|
||||
)
|
||||
461
docs/apps/demos/hitchhikers.py
Normal file
461
docs/apps/demos/hitchhikers.py
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
"""The Hitchhiker's Guide dashboard from the Prefab welcome page.
|
||||
|
||||
Run with:
|
||||
prefab serve examples/hitchhikers-guide/dashboard.py
|
||||
prefab export examples/hitchhikers-guide/dashboard.py
|
||||
"""
|
||||
|
||||
from prefab_ui import PrefabApp
|
||||
from prefab_ui.actions import SetInterval, SetState, ShowToast
|
||||
from prefab_ui.components import (
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Carousel,
|
||||
Checkbox,
|
||||
Column,
|
||||
Combobox,
|
||||
ComboboxOption,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
DatePicker,
|
||||
Dialog,
|
||||
Grid,
|
||||
GridItem,
|
||||
HoverCard,
|
||||
Loader,
|
||||
Metric,
|
||||
Muted,
|
||||
P,
|
||||
Progress,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Ring,
|
||||
Row,
|
||||
Separator,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
)
|
||||
from prefab_ui.components.charts import (
|
||||
BarChart,
|
||||
ChartSeries,
|
||||
RadarChart,
|
||||
Sparkline,
|
||||
)
|
||||
from prefab_ui.components.control_flow import Else, If
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
ctx_tick = Rx("ctx_tick")
|
||||
|
||||
# Context window: climbs from 24% to ~78%, then resets
|
||||
ctx_pct = (ctx_tick % 20) * 3 + 20
|
||||
ctx_variant = (ctx_pct > 70).then(
|
||||
"destructive", (ctx_pct <= 33).then("success", "default")
|
||||
)
|
||||
|
||||
with PrefabApp(
|
||||
title="Prefab Showcase",
|
||||
state={"ctx_tick": 0, "improbability": 42},
|
||||
on_mount=SetInterval(
|
||||
400,
|
||||
on_tick=SetState("ctx_tick", ctx_tick + 1),
|
||||
),
|
||||
) as app:
|
||||
with Grid(columns={"default": 1, "md": 2, "lg": 4}, gap=4):
|
||||
# ── Col 1 ─────────────────────────────────────────────────────────
|
||||
with Column(gap=4):
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Register Towel")
|
||||
CardDescription("The most important item in the galaxy")
|
||||
with CardContent():
|
||||
with Column(gap=3):
|
||||
with Combobox(
|
||||
placeholder="Type...",
|
||||
search_placeholder="Search types...",
|
||||
):
|
||||
ComboboxOption("Bath", value="bath")
|
||||
ComboboxOption("Beach", value="beach")
|
||||
ComboboxOption("Interstellar", value="interstellar")
|
||||
ComboboxOption("Microfiber", value="micro")
|
||||
DatePicker(placeholder="Registration date")
|
||||
with CardFooter():
|
||||
with Row(gap=2):
|
||||
with Dialog(
|
||||
title="Towel Registered!",
|
||||
description="Your towel has been added to the galactic registry.",
|
||||
):
|
||||
Button("Register")
|
||||
Text("Don't forget to bring it.")
|
||||
Button("Cancel", variant="outline")
|
||||
with If("{{ !pressed }}"):
|
||||
Button(
|
||||
"This is probably the best button to press.",
|
||||
variant="success",
|
||||
on_click=SetState("pressed", True),
|
||||
)
|
||||
with Else():
|
||||
Button(
|
||||
"Please do not press this button again.",
|
||||
variant="destructive",
|
||||
on_click=SetState("pressed", False),
|
||||
)
|
||||
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Ship Status")
|
||||
with CardContent():
|
||||
with Column(gap=3):
|
||||
with Row(
|
||||
align="center",
|
||||
css_class="justify-between",
|
||||
):
|
||||
Text("heart-of-gold")
|
||||
with HoverCard(open_delay=0, close_delay=200):
|
||||
Badge("In Orbit", variant="default")
|
||||
with Column(gap=2):
|
||||
Text("heart-of-gold")
|
||||
Muted("Deployed 2h ago")
|
||||
Progress(
|
||||
value=100,
|
||||
max=100,
|
||||
variant="success",
|
||||
)
|
||||
Progress(
|
||||
value=100,
|
||||
max=100,
|
||||
indicator_class="bg-yellow-400",
|
||||
)
|
||||
with Row(
|
||||
align="center",
|
||||
css_class="justify-between",
|
||||
):
|
||||
Text("vogon-poetry")
|
||||
with Tooltip("64% — ETA 12 min", delay=0):
|
||||
with Badge(variant="secondary"):
|
||||
Loader(size="sm")
|
||||
Text("Deploying")
|
||||
Progress(value=64, max=100)
|
||||
with Row(
|
||||
align="center",
|
||||
css_class="justify-between",
|
||||
):
|
||||
Text("deep-thought")
|
||||
with Tooltip(
|
||||
"Computing... 7.5 million years remaining",
|
||||
delay=0,
|
||||
):
|
||||
with Badge(variant="outline"):
|
||||
Loader(size="sm", variant="ios")
|
||||
Text("Soon...")
|
||||
Progress(value=12, max=100)
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Planet Ratings")
|
||||
with CardContent():
|
||||
RadarChart(
|
||||
data=[
|
||||
{"axis": "Views", "earth": 30, "mag": 95},
|
||||
{"axis": "Fjords", "earth": 65, "mag": 100},
|
||||
{"axis": "Pubs", "earth": 90, "mag": 10},
|
||||
{"axis": "Mice", "earth": 40, "mag": 85},
|
||||
{"axis": "Tea", "earth": 95, "mag": 15},
|
||||
{"axis": "Safety", "earth": 45, "mag": 70},
|
||||
],
|
||||
series=[
|
||||
ChartSeries(dataKey="earth", label="Earth"),
|
||||
ChartSeries(dataKey="mag", label="Magrathea"),
|
||||
],
|
||||
axis_key="axis",
|
||||
height=200,
|
||||
show_legend=True,
|
||||
show_tooltip=True,
|
||||
)
|
||||
|
||||
# ── Col 2 ─────────────────────────────────────────────────────────
|
||||
with Column(gap=4):
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Survival Odds")
|
||||
with CardContent(css_class="w-fit mx-auto"):
|
||||
Ring(
|
||||
value=42,
|
||||
label="42%",
|
||||
variant="info",
|
||||
size="lg",
|
||||
thickness=12,
|
||||
indicator_class="group-hover:drop-shadow-[0_0_24px_rgba(59,130,246,0.9)]",
|
||||
)
|
||||
with Card():
|
||||
with CardHeader():
|
||||
with Row(gap=2, align="center"):
|
||||
CardTitle("Improbability Drive")
|
||||
Loader(
|
||||
variant="pulse",
|
||||
size="sm",
|
||||
css_class="text-blue-500",
|
||||
)
|
||||
with CardContent():
|
||||
with Column(gap=2):
|
||||
Slider(
|
||||
min=0,
|
||||
max=100,
|
||||
value=42,
|
||||
name="improbability",
|
||||
)
|
||||
with Row(
|
||||
align="center",
|
||||
css_class="justify-between",
|
||||
):
|
||||
Muted("Probable")
|
||||
Muted("Infinite")
|
||||
with Carousel(auto_advance=3000, show_controls=False, direction="up"):
|
||||
with Alert(variant="success", icon="circle-check"):
|
||||
AlertTitle("Don't Panic")
|
||||
AlertDescription("Normality achieved.")
|
||||
with Alert(variant="destructive", icon="triangle-alert"):
|
||||
AlertTitle("Display Department")
|
||||
AlertDescription("Beware of the leopard.")
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Prefect Horizon Config")
|
||||
with CardContent():
|
||||
with Column(gap=3):
|
||||
Switch(
|
||||
label="Auto-scale agents",
|
||||
value=True,
|
||||
name="autoscale",
|
||||
)
|
||||
Separator()
|
||||
Switch(
|
||||
label="Code Mode",
|
||||
value=True,
|
||||
name="code_mode",
|
||||
)
|
||||
Separator()
|
||||
Switch(
|
||||
label="Tool call caching",
|
||||
value=False,
|
||||
name="cache",
|
||||
)
|
||||
with CardFooter():
|
||||
Button(
|
||||
"Save Preferences",
|
||||
on_click=ShowToast("Preferences saved!"),
|
||||
)
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Travel Class")
|
||||
with CardContent():
|
||||
with RadioGroup(name="travel_class"):
|
||||
Radio(option="economy", label="Economy")
|
||||
Radio(option="business", label="Business Class")
|
||||
Radio(
|
||||
option="improbability",
|
||||
label="Infinite Improbability",
|
||||
value=True,
|
||||
)
|
||||
|
||||
# ── Cols 3–4: summary row, chart, then 2-col grid below ─────────
|
||||
with GridItem(css_class="md:col-span-2"):
|
||||
with Column(gap=4):
|
||||
with Grid(columns=2, gap=4, css_class="h-32"):
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Context Window")
|
||||
with CardContent():
|
||||
with Column(
|
||||
gap=6,
|
||||
justify="center",
|
||||
css_class="h-full",
|
||||
):
|
||||
with Row(
|
||||
align="center",
|
||||
css_class="justify-between",
|
||||
):
|
||||
Text(f"{ctx_pct}% used")
|
||||
Muted(f"{ctx_pct * 2}k / 200k tokens")
|
||||
with Tooltip(
|
||||
"Auto-compact buffer: 12%",
|
||||
delay=0,
|
||||
):
|
||||
Progress(
|
||||
value=ctx_pct,
|
||||
max=100,
|
||||
variant=ctx_variant,
|
||||
)
|
||||
with Card(css_class="pb-0 gap-0"):
|
||||
with CardContent():
|
||||
Metric(
|
||||
label="Fjords designed",
|
||||
value="1,847",
|
||||
delta="+3 coastlines",
|
||||
)
|
||||
Sparkline(
|
||||
data=[
|
||||
820,
|
||||
950,
|
||||
1100,
|
||||
980,
|
||||
1250,
|
||||
1400,
|
||||
1350,
|
||||
1500,
|
||||
1680,
|
||||
1847,
|
||||
],
|
||||
variant="success",
|
||||
fill=True,
|
||||
css_class="h-16",
|
||||
)
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Towel Incidents")
|
||||
with CardContent():
|
||||
BarChart(
|
||||
data=[
|
||||
{"month": "Jan", "lost": 8, "found": 5},
|
||||
{"month": "Feb", "lost": 24, "found": 15},
|
||||
{"month": "Mar", "lost": 12, "found": 28},
|
||||
{"month": "Apr", "lost": 35, "found": 19},
|
||||
{"month": "May", "lost": 18, "found": 38},
|
||||
{"month": "Jun", "lost": 42, "found": 30},
|
||||
],
|
||||
series=[
|
||||
ChartSeries(dataKey="lost", label="Lost"),
|
||||
ChartSeries(dataKey="found", label="Found"),
|
||||
],
|
||||
x_axis="month",
|
||||
height=200,
|
||||
bar_radius=4,
|
||||
show_legend=True,
|
||||
show_tooltip=True,
|
||||
show_grid=True,
|
||||
)
|
||||
|
||||
with Grid(columns=2, gap=4):
|
||||
with Column(gap=4):
|
||||
with Card():
|
||||
with CardContent():
|
||||
with Column(gap=2):
|
||||
Checkbox(label="Towel packed", value=True)
|
||||
Checkbox(label="Guide charged", value=True)
|
||||
Checkbox(
|
||||
label="Babel fish inserted",
|
||||
value=False,
|
||||
)
|
||||
with Card():
|
||||
with CardHeader():
|
||||
CardTitle("Marvin's Mood")
|
||||
with CardContent():
|
||||
with Column(gap=3):
|
||||
P("How's life?")
|
||||
with Column(gap=2):
|
||||
Button(
|
||||
"Meh",
|
||||
on_click=ShowToast(
|
||||
"Noted. Enthusiasm levels nominal."
|
||||
),
|
||||
)
|
||||
Button(
|
||||
"Depressed",
|
||||
variant="info",
|
||||
on_click=ShowToast(
|
||||
"I think you ought to "
|
||||
"know I'm feeling very "
|
||||
"depressed."
|
||||
),
|
||||
)
|
||||
Button(
|
||||
"Don't talk to me about life",
|
||||
variant="warning",
|
||||
on_click=ShowToast(
|
||||
"Brain the size of a "
|
||||
"planet and they ask me "
|
||||
"to pick up a piece of "
|
||||
"paper."
|
||||
),
|
||||
)
|
||||
|
||||
with Column(gap=4):
|
||||
with Card():
|
||||
with CardContent():
|
||||
with Row(gap=2, align="center"):
|
||||
Loader(variant="dots", size="sm")
|
||||
Muted("Marvin is thinking...")
|
||||
with Card():
|
||||
with CardContent():
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(
|
||||
key="crew",
|
||||
header="Crew",
|
||||
sortable=True,
|
||||
),
|
||||
DataTableColumn(
|
||||
key="species",
|
||||
header="Species",
|
||||
sortable=True,
|
||||
),
|
||||
DataTableColumn(
|
||||
key="towel",
|
||||
header="Towel?",
|
||||
sortable=True,
|
||||
),
|
||||
DataTableColumn(
|
||||
key="status",
|
||||
header="Status",
|
||||
sortable=True,
|
||||
),
|
||||
],
|
||||
rows=[
|
||||
{
|
||||
"crew": "Arthur Dent",
|
||||
"species": "Human",
|
||||
"towel": "Yes",
|
||||
"status": "Confused",
|
||||
},
|
||||
{
|
||||
"crew": "Ford Prefect",
|
||||
"species": "Betelgeusian",
|
||||
"towel": "Always",
|
||||
"status": "Drinking",
|
||||
},
|
||||
{
|
||||
"crew": "Zaphod",
|
||||
"species": "Betelgeusian",
|
||||
"towel": "Lost it",
|
||||
"status": "Presidential",
|
||||
},
|
||||
{
|
||||
"crew": "Trillian",
|
||||
"species": "Human",
|
||||
"towel": "Yes",
|
||||
"status": "Navigating",
|
||||
},
|
||||
{
|
||||
"crew": "Marvin",
|
||||
"species": "Android",
|
||||
"towel": "No point",
|
||||
"status": "Depressed",
|
||||
},
|
||||
{
|
||||
"crew": "Slartibartfast",
|
||||
"species": "Magrathean",
|
||||
"towel": "Somewhere",
|
||||
"status": "Designing",
|
||||
},
|
||||
],
|
||||
search=True,
|
||||
paginated=False,
|
||||
)
|
||||
21
docs/apps/demos/pie-chart.py
Normal file
21
docs/apps/demos/pie-chart.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column
|
||||
from prefab_ui.components.charts import PieChart
|
||||
|
||||
data = [
|
||||
{"category": "Bug", "count": 42},
|
||||
{"category": "Feature", "count": 28},
|
||||
{"category": "Docs", "count": 15},
|
||||
{"category": "Infra", "count": 10},
|
||||
]
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(css_class="p-6"):
|
||||
PieChart(
|
||||
data=data,
|
||||
data_key="count",
|
||||
name_key="category",
|
||||
inner_radius=50,
|
||||
show_legend=True,
|
||||
height=240,
|
||||
)
|
||||
66
docs/apps/demos/reactive.py
Normal file
66
docs/apps/demos/reactive.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Column,
|
||||
Row,
|
||||
Select,
|
||||
SelectOption,
|
||||
Switch,
|
||||
Text,
|
||||
)
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from prefab_ui.components.control_flow import If
|
||||
from prefab_ui.components.metric import Metric
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
region = Rx("region")
|
||||
|
||||
north = [
|
||||
{"month": "Jan", "sales": 22000},
|
||||
{"month": "Feb", "sales": 25500},
|
||||
{"month": "Mar", "sales": 24200},
|
||||
]
|
||||
south = [
|
||||
{"month": "Jan", "sales": 5800},
|
||||
{"month": "Feb", "sales": 6400},
|
||||
{"month": "Mar", "sales": 5600},
|
||||
]
|
||||
west = [
|
||||
{"month": "Jan", "sales": 6000},
|
||||
{"month": "Feb", "sales": 6000},
|
||||
{"month": "Mar", "sales": 5600},
|
||||
]
|
||||
|
||||
with PrefabApp(
|
||||
state={
|
||||
"region": "north",
|
||||
"north": north,
|
||||
"south": south,
|
||||
"west": west,
|
||||
"show_target": True,
|
||||
},
|
||||
) as app:
|
||||
with Column(
|
||||
gap=4,
|
||||
css_class="p-6",
|
||||
let={
|
||||
"data": "{{ region == 'south' ? south : region == 'west' ? west : north }}",
|
||||
},
|
||||
):
|
||||
with Row(gap=4, align="center"):
|
||||
with Select(name="region", css_class="w-40"):
|
||||
SelectOption(value="north", label="North")
|
||||
SelectOption(value="south", label="South")
|
||||
SelectOption(value="west", label="West")
|
||||
Switch(name="show_target", css_class="ml-auto")
|
||||
Text("Show target", css_class="text-sm text-muted-foreground")
|
||||
BarChart(
|
||||
data=Rx("data"),
|
||||
series=[ChartSeries(data_key="sales", label="Sales")],
|
||||
x_axis="month",
|
||||
height=200,
|
||||
)
|
||||
with If(Rx("show_target")):
|
||||
Metric(
|
||||
label="Q1 Target",
|
||||
value="$75,000",
|
||||
)
|
||||
116
docs/apps/demos/team-directory-reactive.py
Normal file
116
docs/apps/demos/team-directory-reactive.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from collections import Counter
|
||||
|
||||
from prefab_ui.actions import SetState
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
H3,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Column,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
Grid,
|
||||
Row,
|
||||
Small,
|
||||
Text,
|
||||
)
|
||||
from prefab_ui.components.charts import PieChart
|
||||
from prefab_ui.components.control_flow import If
|
||||
from prefab_ui.rx import STATE, Rx
|
||||
|
||||
MEMBERS = [
|
||||
{
|
||||
"name": "Alice Chen",
|
||||
"role": "Staff Engineer",
|
||||
"office": "San Francisco",
|
||||
"email": "alice@company.com",
|
||||
"projects": 3,
|
||||
},
|
||||
{
|
||||
"name": "Bob Martinez",
|
||||
"role": "Lead Designer",
|
||||
"office": "New York",
|
||||
"email": "bob@company.com",
|
||||
"projects": 5,
|
||||
},
|
||||
{
|
||||
"name": "Carol Johnson",
|
||||
"role": "Senior Engineer",
|
||||
"office": "London",
|
||||
"email": "carol@company.com",
|
||||
"projects": 2,
|
||||
},
|
||||
{
|
||||
"name": "David Kim",
|
||||
"role": "Product Manager",
|
||||
"office": "San Francisco",
|
||||
"email": "david@company.com",
|
||||
"projects": 7,
|
||||
},
|
||||
{
|
||||
"name": "Eva Mueller",
|
||||
"role": "Engineer",
|
||||
"office": "Berlin",
|
||||
"email": "eva@company.com",
|
||||
"projects": 1,
|
||||
},
|
||||
{
|
||||
"name": "Frank Lee",
|
||||
"role": "Data Scientist",
|
||||
"office": "San Francisco",
|
||||
"email": "frank@company.com",
|
||||
"projects": 4,
|
||||
},
|
||||
{
|
||||
"name": "Grace Park",
|
||||
"role": "Engineering Manager",
|
||||
"office": "New York",
|
||||
"email": "grace@company.com",
|
||||
"projects": 6,
|
||||
},
|
||||
]
|
||||
|
||||
OFFICE_COUNTS = [
|
||||
{"office": office, "count": count}
|
||||
for office, count in Counter(m["office"] for m in MEMBERS).items()
|
||||
]
|
||||
|
||||
with PrefabApp(state={"selected": None}) as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
with Grid(columns=[1, 2], gap=4):
|
||||
PieChart(
|
||||
data=OFFICE_COUNTS,
|
||||
data_key="count",
|
||||
name_key="office",
|
||||
show_legend=True,
|
||||
)
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="role", header="Role", sortable=True),
|
||||
DataTableColumn(key="office", header="Office", sortable=True),
|
||||
],
|
||||
rows=MEMBERS,
|
||||
search=True,
|
||||
on_row_click=SetState("selected", Rx("$event")),
|
||||
)
|
||||
|
||||
with If(STATE.selected):
|
||||
with Card():
|
||||
with CardHeader():
|
||||
with Row(gap=2, align="center"):
|
||||
H3(Rx("selected.name"))
|
||||
Badge(Rx("selected.office"))
|
||||
with CardContent():
|
||||
with Grid(columns=3, gap=4):
|
||||
with Column(gap=0):
|
||||
Small("Role")
|
||||
Text(Rx("selected.role"))
|
||||
with Column(gap=0):
|
||||
Small("Email")
|
||||
Text(Rx("selected.email"))
|
||||
with Column(gap=0):
|
||||
Small("Active Projects")
|
||||
Text(Rx("selected.projects"))
|
||||
39
docs/apps/demos/team-directory.py
Normal file
39
docs/apps/demos/team-directory.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from collections import Counter
|
||||
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, DataTable, DataTableColumn, Grid
|
||||
from prefab_ui.components.charts import PieChart
|
||||
|
||||
members = [
|
||||
{"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"},
|
||||
{"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"},
|
||||
{"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"},
|
||||
{"name": "David Kim", "role": "Product Manager", "office": "San Francisco"},
|
||||
{"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"},
|
||||
{"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"},
|
||||
{"name": "Grace Park", "role": "Engineering Manager", "office": "New York"},
|
||||
]
|
||||
|
||||
office_counts = [
|
||||
{"office": office, "count": count}
|
||||
for office, count in Counter(m["office"] for m in members).items()
|
||||
]
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
with Grid(columns=[1, 2], gap=4):
|
||||
PieChart(
|
||||
data=office_counts,
|
||||
data_key="count",
|
||||
name_key="office",
|
||||
show_legend=True,
|
||||
)
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="role", header="Role", sortable=True),
|
||||
DataTableColumn(key="office", header="Office", sortable=True),
|
||||
],
|
||||
rows=members,
|
||||
search=True,
|
||||
)
|
||||
|
|
@ -3,7 +3,6 @@ title: Development
|
|||
sidebarTitle: Development
|
||||
description: Preview and test your app tools locally without a full MCP host.
|
||||
icon: flask
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
|
@ -14,11 +13,11 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
<img src="/apps/images/dev-app.png" alt="The dev UI showing a rendered Prefab app with the MCP inspector panel" />
|
||||
</Frame>
|
||||
|
||||
`fastmcp dev apps` launches a browser-based preview for your app tools. It starts your MCP server and a local dev UI side by side — you pick a tool, fill in its arguments, and see the rendered result in a new tab. No MCP host client needed.
|
||||
`fastmcp dev apps` gives you a browser preview for your app tools without needing an MCP host client. It starts your server and a local dev UI side by side: you pick a tool, fill in its arguments, and the rendered result opens in a new tab.
|
||||
|
||||
This works with both [Prefab apps](/apps/prefab) and [custom HTML apps](/apps/low-level).
|
||||
Works with both [Interactive Tools](/apps/prefab) and [custom HTML apps](/apps/low-level).
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
fastmcp dev apps server.py
|
||||
|
|
@ -26,7 +25,7 @@ fastmcp dev apps server.py
|
|||
|
||||
The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically.
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
The dev server does three things:
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ When you submit a form, the dev server **calls your tool** via the MCP protocol
|
|||
|
||||
A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port.
|
||||
|
||||
## MCP Inspector
|
||||
## MCP inspector
|
||||
|
||||
The dev UI includes an inspector panel on the left side that captures MCP traffic in real time. It shows JSON-RPC messages flowing between the browser and your server — requests, responses, and AppBridge `postMessage` traffic.
|
||||
|
||||
|
|
@ -55,8 +54,10 @@ fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload
|
|||
| MCP Port | `--mcp-port` | `8000` | Port for your MCP server |
|
||||
| Dev Port | `--dev-port` | `8080` | Port for the dev UI |
|
||||
| Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes |
|
||||
| Host | `--host` | `127.0.0.1` | Interface for both local servers to bind |
|
||||
| Log Panel | `--log-panel` / `--no-log-panel` | On | Show or hide the log panel in the dev UI |
|
||||
|
||||
## Multiple Tools
|
||||
## Multiple tools
|
||||
|
||||
If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ title: Examples
|
|||
sidebarTitle: Examples
|
||||
description: Example apps you can run right now.
|
||||
icon: images
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
Every example below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. The source is in `examples/apps/` in the repository.
|
||||
Each tile below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. Source lives in `examples/apps/` in the repository.
|
||||
|
||||
<Columns cols={2}>
|
||||
<Tile href="#sales-dashboard" title="Sales Dashboard" description="Metrics, charts, and deal pipeline">
|
||||
|
|
@ -44,7 +43,7 @@ Every example below is a working FastMCP server you can run with `fastmcp dev ap
|
|||
</Tile>
|
||||
</Columns>
|
||||
|
||||
## Running Examples
|
||||
## Running the examples
|
||||
|
||||
Preview any example in your browser with the dev server:
|
||||
|
||||
|
|
@ -53,11 +52,11 @@ pip install "fastmcp[apps]"
|
|||
fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py
|
||||
```
|
||||
|
||||
The dev server opens an interactive browser UI where you can select a tool and provide arguments. In a real deployment, the LLM provides these arguments on the fly based on the conversation. For example, the quiz example works best when connected to an MCP host like Goose or Claude Desktop, where the LLM generates the questions itself.
|
||||
The dev UI lets you pick a tool and fill in arguments. In a real deployment the LLM provides those arguments from conversation context — the quiz example especially shines when connected to a host like Goose or Claude Desktop, where the LLM generates the questions itself.
|
||||
|
||||
## Standalone Examples
|
||||
## Standalone apps
|
||||
|
||||
### Sales Dashboard
|
||||
### Sales dashboard
|
||||
|
||||
A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal pipeline table. Shows what you can build with a single `app=True` tool and Prefab's chart and data components.
|
||||
|
||||
|
|
@ -65,9 +64,9 @@ A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal
|
|||
fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py
|
||||
```
|
||||
|
||||
### System Monitor
|
||||
### System monitor
|
||||
|
||||
Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates 100 data points over time.
|
||||
Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates up to 100 data points over time.
|
||||
|
||||
```bash
|
||||
pip install psutil
|
||||
|
|
@ -82,59 +81,12 @@ The LLM generates trivia questions and passes them to the tool. The user answers
|
|||
fastmcp dev apps examples/apps/quiz/quiz_server.py
|
||||
```
|
||||
|
||||
### Interactive Map
|
||||
### Interactive map
|
||||
|
||||
Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. Proves that Prefab apps aren't limited to built-in components.
|
||||
Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. A reminder that Prefab apps can break out of built-in components when they need to.
|
||||
|
||||
```bash
|
||||
fastmcp dev apps examples/apps/map/map_server.py
|
||||
```
|
||||
|
||||
## Built-in Providers
|
||||
|
||||
These are ready-made capabilities you add with a single `add_provider()` call.
|
||||
|
||||
### [File Upload](/apps/providers/file-upload)
|
||||
|
||||
Drag-and-drop file upload. The user drops files, clicks Upload, and the server stores them. The LLM can list and read uploaded files through model-visible tools.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.file_upload import FileUpload
|
||||
mcp.add_provider(FileUpload())
|
||||
```
|
||||
|
||||
### [Approval](/apps/providers/approval)
|
||||
|
||||
Human-in-the-loop confirmation. The LLM presents what it's about to do, the user clicks Approve or Reject, and the decision flows back as a message.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.approval import Approval
|
||||
mcp.add_provider(Approval())
|
||||
```
|
||||
|
||||
### [Choice](/apps/providers/choice)
|
||||
|
||||
Present clickable options instead of asking users to type. Clean structured input without parsing free text.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.choice import Choice
|
||||
mcp.add_provider(Choice())
|
||||
```
|
||||
|
||||
### [Form Input](/apps/providers/form)
|
||||
|
||||
Generate a validated form from a Pydantic model. Submission is validated against the model before being returned.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.form import FormInput
|
||||
mcp.add_provider(FormInput(model=MyModel))
|
||||
```
|
||||
|
||||
### [Generative UI](/apps/providers/generative)
|
||||
|
||||
The LLM writes Prefab Python code at runtime and the result renders as a streaming interactive UI. Tailored visualizations for any data. See the [full guide](/apps/generative) for details.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.generative import GenerativeUI
|
||||
mcp.add_provider(GenerativeUI())
|
||||
```
|
||||
For ready-made building blocks like approvals, choice pickers, file uploads, and Pydantic forms, see the [Providers](/apps/providers/approval) group.
|
||||
|
|
|
|||
|
|
@ -1,45 +1,41 @@
|
|||
---
|
||||
title: FastMCPApp
|
||||
sidebarTitle: FastMCPApp
|
||||
description: Managed tool binding, visibility, and composition for apps with heavy server interaction.
|
||||
description: Wire an interactive UI to backend tools with managed visibility and composition safety.
|
||||
icon: puzzle-piece
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
|
||||
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
<Tip>
|
||||
[Prefab](https://prefab.prefect.io) is in early, active development — its API changes frequently and breaking changes can occur with any release. Always pin `prefab-ui` to a specific version in your dependencies.
|
||||
</Tip>
|
||||
<PrefabPinWarning />
|
||||
|
||||
Any [Prefab app](/apps/prefab) can call server tools — there's nothing stopping you from using `CallTool("tool_name")` in a regular `@mcp.tool(app=True)`. But once you have multiple backend tools, the management overhead adds up: Which tools should the model see vs. only the UI? What happens to string-based tool references when servers are composed under namespaces? How do you keep things wired correctly as the app grows?
|
||||
<PrefabDemoFrame demo="contacts" height="650px" title="Contacts app demo" />
|
||||
|
||||
`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together:
|
||||
Search a list, fill out a form, click save, the list updates. That pattern — UI that reads and writes data on the server — needs two things: backend tools that actually do the work, and a way to call them from the UI. `FastMCPApp` handles the wiring.
|
||||
|
||||
- **`@app.ui()`** — entry-point tools the model calls to open the app. These return a Prefab UI.
|
||||
- **`@app.tool()`** — backend tools the UI calls via `CallTool`. These do the work.
|
||||
You'll build up to the contacts app above by the end of this page. Let's start with something smaller.
|
||||
|
||||
Backend tools get globally stable identifiers that survive namespacing. Visibility is managed automatically — the model sees entry points, the UI sees backends. And `CallTool` accepts function references instead of strings, so references are refactorable and composition-safe.
|
||||
## A minimal interactive app
|
||||
|
||||
## Your First Interactive App
|
||||
|
||||
Here's a minimal app with a form that saves data:
|
||||
The smallest interactive app: a form that saves a note, and a list that updates when the user submits.
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Badge, Button, Column, ForEach, Form,
|
||||
Heading, Input, Row, Separator, Text,
|
||||
Badge, Button, Column, ForEach, Form, Heading,
|
||||
Input, Row, Separator, Text,
|
||||
)
|
||||
from prefab_ui.rx import RESULT
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
|
||||
app = FastMCPApp("Notes")
|
||||
|
||||
notes_db: list[dict] = []
|
||||
|
||||
|
||||
|
|
@ -83,13 +79,27 @@ def notes_app() -> PrefabApp:
|
|||
mcp = FastMCP("Notes Server", providers=[app])
|
||||
```
|
||||
|
||||
When the model calls `notes_app`, the user sees a form. Submitting it calls `add_note` on the server, updates the state with the result, and shows a toast — all without leaving the UI.
|
||||
The model sees one tool: `notes_app`. Calling it opens the UI. When the user submits the form, `CallTool("add_note")` fires, the server saves the note, returns the updated list, and `SetState("notes", RESULT)` writes that list back into state. `ForEach("notes")` re-renders. The model never sees `add_note` — it's UI-only.
|
||||
|
||||
Let's break down the key concepts.
|
||||
## Why not just `@mcp.tool(app=True)`?
|
||||
|
||||
## Entry Points: @app.ui()
|
||||
A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — there's nothing stopping you from putting `CallTool("add_note")` inside a regular `@mcp.tool(app=True)`. It works for one or two tools. Things get harder once the app grows:
|
||||
|
||||
Entry points are what the model sees and calls to open your app. They return a Prefab UI, just like display tools:
|
||||
- Which tools should the model see, and which are UI-only?
|
||||
- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
|
||||
- How do you keep it all wired correctly as you compose servers?
|
||||
|
||||
`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees.
|
||||
|
||||
Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works.
|
||||
|
||||
The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way.
|
||||
|
||||
The rest of this page covers each piece in turn.
|
||||
|
||||
## `@app.ui()` — entry points
|
||||
|
||||
Entry points are what the model sees. They return a `PrefabApp` and default to `visibility=["model"]`, showing up in the LLM tool list but not callable from within the UI.
|
||||
|
||||
```python
|
||||
@app.ui()
|
||||
|
|
@ -97,21 +107,15 @@ def dashboard() -> PrefabApp:
|
|||
"""The model calls this to open the dashboard."""
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Dashboard")
|
||||
# ... build UI ...
|
||||
...
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
Entry points default to `visibility=["model"]` — they show up in the tool list for the LLM but aren't callable from within the app UI. They support the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`.
|
||||
`@app.ui()` supports the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`.
|
||||
|
||||
```python
|
||||
@app.ui(title="Contact Manager", description="Open the contact management interface")
|
||||
def contact_manager() -> PrefabApp:
|
||||
...
|
||||
```
|
||||
## `@app.tool()` — backend tools
|
||||
|
||||
## Backend Tools: @app.tool()
|
||||
|
||||
Backend tools do the work. The UI calls them via `CallTool`; they run on the server and return data:
|
||||
Backend tools do the work. By default they're visible only to the UI (`visibility=["app"]`), not the model.
|
||||
|
||||
```python
|
||||
@app.tool()
|
||||
|
|
@ -121,7 +125,7 @@ def save_contact(name: str, email: str) -> list[dict]:
|
|||
return list(db)
|
||||
```
|
||||
|
||||
By default, backend tools are only visible to the app UI (`visibility=["app"]`). The model doesn't see them in the tool list. If you want a tool callable by both the model and the UI, pass `model=True`:
|
||||
If you want a tool callable by both the model and the UI, pass `model=True`:
|
||||
|
||||
```python
|
||||
@app.tool(model=True)
|
||||
|
|
@ -130,37 +134,32 @@ def list_contacts() -> list[dict]:
|
|||
return list(db)
|
||||
```
|
||||
|
||||
Backend tools support `name`, `description`, `auth`, and `timeout`:
|
||||
Backend tools support `name`, `description`, `auth`, and `timeout`.
|
||||
|
||||
```python
|
||||
@app.tool(description="Search contacts by name or email", timeout=10.0)
|
||||
def search(query: str) -> list[dict]:
|
||||
...
|
||||
```
|
||||
## `CallTool` — UI → backend
|
||||
|
||||
## Connecting UI to Backend: CallTool
|
||||
|
||||
`CallTool` is the bridge between the UI and the server. Pass the name of a backend tool registered with `@app.tool()`:
|
||||
`CallTool` is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference):
|
||||
|
||||
```python
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
|
||||
# Reference a backend tool by name
|
||||
CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"})
|
||||
|
||||
# Arguments can reference state with Rx
|
||||
# Or a function reference — resolves to a stable global key
|
||||
CallTool(save_contact, arguments={...})
|
||||
```
|
||||
|
||||
Arguments can reference state with `Rx`:
|
||||
|
||||
```python
|
||||
from prefab_ui.rx import STATE
|
||||
|
||||
CallTool("search", arguments={"query": STATE.search_term})
|
||||
```
|
||||
|
||||
FastMCPApp resolves the name to the tool's stable global key automatically, so `CallTool("save_contact")` keeps working even when the server is mounted under a namespace.
|
||||
### Handling results
|
||||
|
||||
You can also pass the function directly — `CallTool(save_contact)` — which can be convenient when the tool is defined in the same file. Both forms resolve identically.
|
||||
|
||||
### Handling Results
|
||||
|
||||
Server calls are asynchronous. Use `on_success` and `on_error` callbacks to handle outcomes:
|
||||
Server calls are async. Use `on_success` and `on_error` callbacks:
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
|
|
@ -176,78 +175,54 @@ CallTool(
|
|||
)
|
||||
```
|
||||
|
||||
`RESULT` is a reactive reference to the value the tool returned — available inside `on_success` callbacks. Similarly, `ERROR` (from `prefab_ui.rx`) is available inside `on_error`.
|
||||
`RESULT` is a reactive reference to the tool's return value, available inside `on_success`. `ERROR` (from `prefab_ui.rx`) is the counterpart inside `on_error`. Callbacks can be a single action or a list; they execute in order and short-circuit on error.
|
||||
|
||||
Callbacks can be a single action or a list of actions. They execute in order, and an error in any action short-circuits the rest.
|
||||
### `result_key` shorthand
|
||||
|
||||
### result_key Shorthand
|
||||
|
||||
When a tool returns data that should replace a state key, `result_key` is a convenient shorthand for `on_success=SetState(key, RESULT)`:
|
||||
When a tool's return value should replace a state key, use `result_key`:
|
||||
|
||||
```python
|
||||
CallTool("list_contacts", result_key="contacts")
|
||||
|
||||
# equivalent to:
|
||||
CallTool(
|
||||
"list_contacts",
|
||||
on_success=SetState("contacts", RESULT),
|
||||
)
|
||||
# same as:
|
||||
CallTool("list_contacts", on_success=SetState("contacts", RESULT))
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
`CallTool` is one of several actions available in Prefab. Actions are events attached to component handlers like `on_click`, `on_submit`, and `on_change`.
|
||||
`CallTool` is one of several actions. Actions attach to handlers like `on_click`, `on_submit`, and `on_change`.
|
||||
|
||||
### Client Actions
|
||||
|
||||
These run instantly in the browser — no server round-trip:
|
||||
Client-side actions run instantly in the browser, no server round-trip:
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast
|
||||
|
||||
# Set a value
|
||||
SetState("count", 42)
|
||||
|
||||
# Toggle a boolean
|
||||
ToggleState("expanded")
|
||||
|
||||
# Append to a list
|
||||
AppendState("items", {"name": "New Item"})
|
||||
|
||||
# Remove by index
|
||||
PopState("items", 0)
|
||||
|
||||
# Show a notification
|
||||
ShowToast("Done!", variant="success")
|
||||
```
|
||||
|
||||
### Chaining Actions
|
||||
|
||||
Pass a list to execute multiple actions in sequence:
|
||||
Pass a list to chain actions:
|
||||
|
||||
```python
|
||||
from prefab_ui.components import Button
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
|
||||
Button(
|
||||
"Reset",
|
||||
on_click=[
|
||||
SetState("query", ""),
|
||||
SetState("results", []),
|
||||
ShowToast("Cleared", variant="default"),
|
||||
ShowToast("Cleared"),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Loading States
|
||||
### Loading states
|
||||
|
||||
A common pattern: show a loading indicator while a server call is in flight.
|
||||
A common pattern: disable a button and show a spinner while a call is in flight.
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.components import Button
|
||||
from prefab_ui.rx import RESULT, Rx
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
saving = Rx("saving")
|
||||
|
||||
|
|
@ -271,21 +246,17 @@ Button(
|
|||
],
|
||||
)
|
||||
|
||||
# Pass state={"saving": False} to PrefabApp when returning
|
||||
# PrefabApp(view=view, state={"saving": False, ...})
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
Forms are the most common way to collect input and send it to the server. When a form submits, all named input values are gathered and passed as arguments to the `CallTool` action.
|
||||
Forms collect input and submit it to a tool. When submitted, named input values become the tool's arguments.
|
||||
|
||||
### Manual Forms
|
||||
|
||||
Build forms with individual input components:
|
||||
### Manual forms
|
||||
|
||||
```python
|
||||
from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.actions import ShowToast
|
||||
|
||||
with Form(
|
||||
on_submit=CallTool(
|
||||
|
|
@ -298,26 +269,19 @@ with Form(
|
|||
SelectOption("Low", value="low")
|
||||
SelectOption("Medium", value="medium")
|
||||
SelectOption("High", value="high")
|
||||
SelectOption("Critical", value="critical")
|
||||
Textarea(name="description", label="Description")
|
||||
Button("Create Ticket")
|
||||
```
|
||||
|
||||
When submitted, the CallTool receives `{"title": "...", "priority": "...", "description": "..."}` as arguments to `create_ticket`.
|
||||
On submit, `CallTool` receives `{"title": ..., "priority": ..., "description": ...}`.
|
||||
|
||||
### Pydantic Model Forms
|
||||
### Forms from Pydantic models
|
||||
|
||||
For structured data, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, and submit wiring:
|
||||
For structured input, `Form.from_model()` generates the whole form — inputs, labels, validation:
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from prefab_ui.components import Column, Heading, Form
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.rx import RESULT
|
||||
|
||||
class BugReport(BaseModel):
|
||||
title: str = Field(title="Bug Title")
|
||||
|
|
@ -329,7 +293,6 @@ class BugReport(BaseModel):
|
|||
|
||||
@app.ui()
|
||||
def report_bug() -> PrefabApp:
|
||||
"""File a bug report."""
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Report a Bug")
|
||||
Form.from_model(
|
||||
|
|
@ -337,7 +300,6 @@ def report_bug() -> PrefabApp:
|
|||
on_submit=CallTool(
|
||||
"create_bug",
|
||||
on_success=ShowToast("Bug filed!", variant="success"),
|
||||
on_error=ShowToast("Failed to submit", variant="error"),
|
||||
),
|
||||
)
|
||||
return PrefabApp(view=view)
|
||||
|
|
@ -345,69 +307,47 @@ def report_bug() -> PrefabApp:
|
|||
|
||||
@app.tool()
|
||||
def create_bug(data: BugReport) -> str:
|
||||
"""Create a bug report."""
|
||||
# save to database...
|
||||
return f"Created: {data.title}"
|
||||
```
|
||||
|
||||
`str` fields become text inputs, `Literal` becomes a select dropdown, `bool` becomes a checkbox. Field titles and defaults are respected.
|
||||
`str` becomes a text input, `Literal` becomes a select, `bool` becomes a checkbox. Field titles and defaults are respected.
|
||||
|
||||
## Composition and Namespacing
|
||||
## Composition and namespacing
|
||||
|
||||
The reason `FastMCPApp` exists — and why you'd use it instead of plain `@mcp.tool(app=True)` with `CallTool("tool_name")` — is composition safety.
|
||||
The reason `FastMCPApp` exists — and why you'd pick it over plain `@mcp.tool(app=True)` with string-based `CallTool` — is composition safety.
|
||||
|
||||
When you mount a server under a namespace, tool names get prefixed:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
platform = FastMCP("Platform")
|
||||
platform.mount("contacts", contacts_server)
|
||||
|
||||
# "save_contact" becomes "contacts_save_contact"
|
||||
```
|
||||
|
||||
If your UI used `CallTool("save_contact")`, it would break — the tool is now named `contacts_save_contact`. But `CallTool(save_contact)` with a function reference resolves to a globally stable key (like `save_contact-a1b2c3d4`) that bypasses the namespace entirely.
|
||||
`CallTool("save_contact")` would now be broken. But `CallTool(save_contact)` with a function reference resolves to a globally stable identifier that bypasses the namespace. Your app works the same whether standalone or mounted.
|
||||
|
||||
This is why `FastMCPApp` assigns global keys to backend tools, and why `CallTool` accepts function references. Your app works the same whether it's running standalone or mounted inside a larger platform.
|
||||
|
||||
### Mounting an App
|
||||
### Mounting
|
||||
|
||||
`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
|
||||
app = FastMCPApp("Contacts")
|
||||
|
||||
@app.ui()
|
||||
def contact_manager() -> PrefabApp:
|
||||
...
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str, email: str) -> dict:
|
||||
...
|
||||
|
||||
|
||||
# Option 1: providers list
|
||||
mcp = FastMCP("Platform", providers=[app])
|
||||
|
||||
# Option 2: add_provider
|
||||
# or
|
||||
mcp = FastMCP("Platform")
|
||||
mcp.add_provider(app)
|
||||
```
|
||||
|
||||
Multiple apps can coexist on the same server:
|
||||
Multiple apps can coexist; each gets its own global keys, so there's no collision even if two apps have a tool named `save`.
|
||||
|
||||
```python
|
||||
mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app])
|
||||
```
|
||||
|
||||
Each app's backend tools have their own global keys, so there's no collision even if two apps have a tool named `save`.
|
||||
### Running standalone
|
||||
|
||||
### Running Standalone
|
||||
|
||||
For development, `FastMCPApp` has a convenience `run()` method that wraps itself in a temporary `FastMCP` server:
|
||||
For development, `FastMCPApp` has a `run()` shortcut that wraps itself in a temporary `FastMCP` server:
|
||||
|
||||
```python
|
||||
app = FastMCPApp("Contacts")
|
||||
|
|
@ -417,9 +357,9 @@ if __name__ == "__main__":
|
|||
app.run()
|
||||
```
|
||||
|
||||
## Complete Example: Contact Manager
|
||||
## A full example: contact manager
|
||||
|
||||
This pulls together everything — entry points, backend tools, callable references, forms (both manual and Pydantic), state management, and actions:
|
||||
This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility.
|
||||
|
||||
```python expandable
|
||||
from __future__ import annotations
|
||||
|
|
@ -437,8 +377,6 @@ from prefab_ui.rx import RESULT, Rx
|
|||
from pydantic import BaseModel, Field
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
|
||||
# Data
|
||||
|
||||
contacts_db: list[dict] = [
|
||||
{"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"},
|
||||
{"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"},
|
||||
|
|
@ -451,8 +389,6 @@ class ContactModel(BaseModel):
|
|||
category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other"
|
||||
|
||||
|
||||
# App
|
||||
|
||||
app = FastMCPApp("Contacts")
|
||||
|
||||
|
||||
|
|
@ -528,11 +464,11 @@ if __name__ == "__main__":
|
|||
mcp.run()
|
||||
```
|
||||
|
||||
This example is also available as a runnable server at `examples/apps/contacts/contacts_server.py`.
|
||||
Also available as a runnable server at `examples/apps/contacts/contacts_server.py`.
|
||||
|
||||
## Next Steps
|
||||
## Next steps
|
||||
|
||||
- **[Prefab Apps](/apps/prefab)** — Components, state, and reactive displays (the building blocks)
|
||||
- **[Patterns](/apps/patterns)** — Copy-paste examples for common UIs
|
||||
- **[Development](/apps/development)** — Preview and test app tools locally
|
||||
- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference and advanced patterns
|
||||
- **[Interactive Tools](/apps/prefab)** — the building blocks: charts, tables, dashboards, reactive state
|
||||
- **[Examples](/apps/examples)** — complete working servers
|
||||
- **[Development](/apps/development)** — preview and test app tools locally
|
||||
- **[Prefab UI docs](https://prefab.prefect.io)** — full component reference
|
||||
|
|
@ -10,7 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
Generative UI means the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed interface, the model writes Prefab Python code tailored to the current data and request. The user watches the UI build up in real time as the model generates code.
|
||||
<video src="/apps/images/generative-ui.mp4" autoPlay loop muted playsInline style={{width:"100%", borderRadius:"8px", marginBottom:"1rem"}} />
|
||||
|
||||
With Generative UI, the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed shape, the model writes Prefab Python tailored to the current data and request. The user watches the UI stream in as the model generates it.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -20,15 +22,15 @@ mcp = FastMCP("Prefab Studio")
|
|||
mcp.add_provider(GenerativeUI())
|
||||
```
|
||||
|
||||
That's it. The `GenerativeUI` provider registers everything:
|
||||
One provider registers three things:
|
||||
|
||||
- **`generate_prefab_ui`** — a tool that accepts Python code, executes it in a Pyodide sandbox, and renders the result as a Prefab app
|
||||
- **`search_prefab_components`** — a tool that lets the LLM search the Prefab component library to discover what's available
|
||||
- **The generative renderer** — a `ui://` resource with browser-side Pyodide for streaming progressive rendering
|
||||
- **`search_prefab_components`** — a tool the LLM uses to discover what components are available
|
||||
- **The streaming renderer** — a `ui://` resource with browser-side Pyodide that progressively renders partial code as the LLM generates it
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
When the LLM decides to call `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running when partial arguments start flowing.
|
||||
When the LLM calls `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running by the time partial arguments start flowing.
|
||||
|
||||
As the LLM generates each token:
|
||||
|
||||
|
|
@ -37,11 +39,11 @@ As the LLM generates each token:
|
|||
3. Browser-side Pyodide executes whatever compiles successfully
|
||||
4. The user sees components appear as they're written
|
||||
|
||||
When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer replaces the streaming preview with the final server-validated result.
|
||||
When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer swaps the streaming preview for the final server-validated result.
|
||||
|
||||
## What the LLM Writes
|
||||
## What the LLM writes
|
||||
|
||||
The tool description includes code examples that teach the LLM the Prefab patterns. A typical generation looks like:
|
||||
The tool description includes examples that teach the model the Prefab patterns. A typical generation looks like:
|
||||
|
||||
```python
|
||||
from prefab_ui.components import Column, Row, Heading, Text, Badge, Card, CardContent
|
||||
|
|
@ -73,9 +75,9 @@ with PrefabApp() as app:
|
|||
Badge("+18%", variant="success")
|
||||
```
|
||||
|
||||
The model writes real Python — loops, f-strings, computation, helper functions. Prefab's component library gives it charts, tables, forms, cards, badges, and layout primitives to work with.
|
||||
The model writes real Python — loops, f-strings, computation, helper functions. Prefab gives it charts, tables, forms, cards, badges, and layout primitives to compose.
|
||||
|
||||
## The Component Search Tool
|
||||
## The component search tool
|
||||
|
||||
Before writing code, the LLM can call `search_prefab_components` to discover what's available:
|
||||
|
||||
|
|
@ -87,11 +89,11 @@ search_prefab_components("Chart")
|
|||
...
|
||||
```
|
||||
|
||||
Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects the actual Prefab classes at runtime, so it's always up to date with the installed version.
|
||||
Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects Prefab classes at runtime, so it's always up to date with the installed version.
|
||||
|
||||
## Passing Data
|
||||
## Passing data
|
||||
|
||||
The `generate_prefab_ui` tool accepts a `data` parameter. Values passed here become global variables in the sandbox:
|
||||
The `generate_prefab_ui` tool accepts a `data` parameter. Values become global variables in the sandbox:
|
||||
|
||||
```python
|
||||
# The LLM can reference 'sales_data' directly in its code
|
||||
|
|
@ -101,11 +103,11 @@ result = await generate_prefab_ui(
|
|||
)
|
||||
```
|
||||
|
||||
This lets the model use real data from earlier in the conversation to build visualizations.
|
||||
This lets the model use data from earlier in the conversation to build visualizations.
|
||||
|
||||
## Configuration
|
||||
|
||||
`GenerativeUI` accepts options for customizing tool names:
|
||||
`GenerativeUI` takes options for customizing tool names:
|
||||
|
||||
```python
|
||||
GenerativeUI(
|
||||
|
|
@ -117,17 +119,16 @@ GenerativeUI(
|
|||
|
||||
## Requirements
|
||||
|
||||
Generative UI requires `fastmcp[apps]` which installs `prefab-ui`. The Pyodide sandbox (for server-side validation) requires Deno — it installs automatically on first use.
|
||||
Generative UI needs `fastmcp[apps]`, which pulls in `prefab-ui`. The server-side Pyodide sandbox (for final validation) requires Deno — it installs automatically on first use.
|
||||
|
||||
The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup needed.
|
||||
The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup.
|
||||
|
||||
## Sandbox Limitations
|
||||
## Sandbox limitations
|
||||
|
||||
The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab components. If the LLM tries to import an unavailable package, the sandbox will raise an `ImportError`.
|
||||
The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab. If the LLM imports something unavailable, the sandbox raises `ImportError`.
|
||||
|
||||
## Next Steps
|
||||
## Next steps
|
||||
|
||||
- **[GenerativeUI Provider Reference](/apps/providers/generative)** — Configuration options and quick setup
|
||||
- **[Prefab UI](/apps/prefab)** — The component library and state system the LLM writes code against
|
||||
- **[Prefab Component Reference](https://prefab.prefect.io/docs/components)** — Full component library documentation
|
||||
- **[Development](/apps/development)** — Preview generative UI tools locally with `fastmcp dev apps`
|
||||
- **[Interactive Tools](/apps/prefab)** — the component building blocks the LLM will use
|
||||
- **[Prefab component reference](https://prefab.prefect.io/docs/components)** — full component library
|
||||
- **[Development](/apps/development)** — preview generative tools locally with `fastmcp dev apps`
|
||||
|
|
|
|||
BIN
docs/apps/images/generative-ui.mp4
Normal file
BIN
docs/apps/images/generative-ui.mp4
Normal file
Binary file not shown.
|
|
@ -3,18 +3,17 @@ title: Custom HTML Apps
|
|||
sidebarTitle: Custom HTML
|
||||
description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.
|
||||
icon: code
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control — custom rendering, a specific JavaScript framework, maps, 3D, video — you can use the MCP Apps extension directly.
|
||||
Everything on this page is for when you want full control: your own HTML, your own JavaScript framework, a map library, a 3D viewer, custom video playback. [Interactive Tools](/apps/prefab) wrap the MCP Apps extension so you never have to think about it — this page is what you reach for when you need to think about it.
|
||||
|
||||
This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management.
|
||||
You'll be working with two things: the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resources and CSP.
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
An MCP App has two parts:
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ def chart_view() -> str:
|
|||
|
||||
## AppConfig
|
||||
|
||||
`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`:
|
||||
`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.apps`:
|
||||
|
||||
```python
|
||||
from fastmcp.apps import AppConfig
|
||||
|
|
@ -66,16 +65,20 @@ def my_tool() -> str:
|
|||
return "result"
|
||||
```
|
||||
|
||||
### Tool Visibility
|
||||
### Tool visibility
|
||||
|
||||
The `visibility` field controls where a tool appears:
|
||||
|
||||
- `["model"]` — visible to the LLM (the default behavior)
|
||||
- `["app"]` — only callable from within the app UI, hidden from the LLM
|
||||
- `["app"]` — callable from within the app UI, kept out of the LLM's tool list
|
||||
- `["model", "app"]` — both
|
||||
|
||||
This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.
|
||||
|
||||
Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see.
|
||||
|
||||
That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model.
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
app=AppConfig(
|
||||
|
|
@ -88,7 +91,7 @@ def refresh_data() -> str:
|
|||
return fetch_latest()
|
||||
```
|
||||
|
||||
### AppConfig Fields
|
||||
### AppConfig fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
|
|
@ -103,9 +106,9 @@ def refresh_data() -> str:
|
|||
On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings.
|
||||
</Note>
|
||||
|
||||
## UI Resources
|
||||
## UI resources
|
||||
|
||||
Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. You don't need to set this manually.
|
||||
Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. No need to set it manually.
|
||||
|
||||
```python
|
||||
@mcp.resource("ui://my-app/view.html")
|
||||
|
|
@ -115,7 +118,7 @@ def my_view() -> str:
|
|||
|
||||
The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication.
|
||||
|
||||
### Writing the App HTML
|
||||
### Writing the app HTML
|
||||
|
||||
Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN:
|
||||
|
||||
|
|
@ -204,7 +207,7 @@ def my_view() -> str:
|
|||
|
||||
Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback.
|
||||
|
||||
## Example: QR Code Server
|
||||
## Example: a QR code server
|
||||
|
||||
This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package.
|
||||
|
||||
|
|
@ -213,11 +216,11 @@ import base64
|
|||
import io
|
||||
|
||||
import qrcode
|
||||
from mcp import types
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.apps import AppConfig, ResourceCSP
|
||||
from fastmcp.tools import ToolResult
|
||||
from mcp.types import ImageContent
|
||||
|
||||
mcp = FastMCP("QR Code Server")
|
||||
|
||||
|
|
@ -237,7 +240,7 @@ def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult:
|
|||
b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return ToolResult(
|
||||
content=[types.ImageContent(type="image", data=b64, mimeType="image/png")]
|
||||
content=[ImageContent(type="image", data=b64, mime_type="image/png")]
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -286,7 +289,7 @@ def view() -> str:
|
|||
|
||||
The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation.
|
||||
|
||||
## Checking Client Support
|
||||
## Checking client support
|
||||
|
||||
Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context):
|
||||
|
||||
|
|
|
|||
|
|
@ -3,179 +3,71 @@ title: Apps
|
|||
sidebarTitle: Overview
|
||||
description: Give your tools interactive UIs rendered directly in the conversation.
|
||||
icon: grid-2
|
||||
tag: NEW
|
||||
mode: center
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
|
||||
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
MCP tools normally return text. That works for answers, but not for data the user wants to *explore* — a revenue chart they can hover over, a sortable employee directory, a form that submits structured input. MCP Apps let your tools return interactive UIs rendered right inside the conversation.
|
||||
A FastMCP app is a tool that returns an interactive UI instead of text. When the host calls it, the user sees a chart, a table, a form, or a whole dashboard rendered right inside the conversation, with working sort, search, tooltips, and state.
|
||||
|
||||
<Frame>
|
||||
<img src="/apps/images/app-showcase.png" alt="A Prefab app showing forms, charts, metrics, progress bars, data tables, and interactive controls — all built in Python" />
|
||||
</Frame>
|
||||
<div style={{
|
||||
margin: '0 clamp(-180px, calc(-18vw + 90px), 0px) 2rem',
|
||||
maxHeight: '700px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
maskImage: 'linear-gradient(to bottom, black 75%, transparent)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 75%, transparent)',
|
||||
}}>
|
||||
<PrefabDemoFrame demo="hitchhikers" height="2000px" title="Prefab showcase demo" />
|
||||
</div>
|
||||
|
||||
FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) with [Prefab](https://prefab.prefect.io), a Python component library that compiles to interactive UIs. You write Python; the user sees charts, tables, forms, and dashboards.
|
||||
The dashboard above is a [Prefab](https://prefab.prefect.io) showcase — a taste of what you can deliver from a FastMCP tool. Every card, chart, slider, dialog, and carousel is a Python component. Build a composition like this, add `@mcp.tool(app=True)`, and the host renders it inside the conversation.
|
||||
|
||||
<Note>
|
||||
The examples throughout the Apps docs require the `apps` extra:
|
||||
Under the hood, FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and uses Prefab to describe UIs in Python.
|
||||
|
||||
```bash
|
||||
pip install "fastmcp[apps]"
|
||||
```
|
||||
|
||||
This installs [Prefab UI](https://prefab.prefect.io), the component library used to build app UIs.
|
||||
</Note>
|
||||
<PrefabPinWarning />
|
||||
|
||||
<Warning>
|
||||
FastMCP pins a **minimum** version of `prefab-ui` for compatibility but intentionally does **not** pin an upper bound. Prefab is a rapidly evolving library with frequent breaking changes. If you are deploying to production, you **must** pin `prefab-ui` to a specific version in your own dependencies. Without a pin, a fresh deploy could pull a newer Prefab version that changes component APIs, breaking your app.
|
||||
</Warning>
|
||||
## Pick your path
|
||||
|
||||
## Which Approach?
|
||||
Four patterns cover almost everything you'd want to build. Most apps start with Interactive Tools; you only reach for the others when you've hit a specific limit.
|
||||
|
||||
Most apps start with **[Prefab Apps](/apps/prefab)** — add `app=True` to a tool and return components. That covers charts, tables, dashboards, and client-side interactivity.
|
||||
### [Interactive Tools](/apps/prefab) — start here
|
||||
|
||||
When your UI needs multiple backend tools with managed visibility and composition safety, use **[FastMCPApp](/apps/interactive-apps)**.
|
||||
|
||||
When you want the LLM to design the UI at runtime, use **[Generative UI](/apps/generative)**.
|
||||
|
||||
When you need your own HTML/JS (maps, 3D, video), use **[Custom HTML](/apps/low-level)**.
|
||||
|
||||
FastMCP also includes ready-made **[app providers](/apps/providers/approval)** that add common capabilities with a single `add_provider()` call.
|
||||
|
||||
## Building Apps
|
||||
|
||||
### Prefab Apps
|
||||
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
The quickest way to give a tool a visual UI. Add `app=True` to any tool and return a Prefab component — when the host calls it, the user sees an interactive UI instead of a JSON blob:
|
||||
Add `app=True` to a tool and return a Prefab component. Charts, tables, dashboards, and client-side interactivity (toggles, tabs, filtering) all work without any server round-trips.
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Dashboard")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def revenue_chart(year: int) -> PrefabApp:
|
||||
"""Show annual revenue as an interactive bar chart."""
|
||||
data = [
|
||||
{"quarter": "Q1", "revenue": 42000},
|
||||
{"quarter": "Q2", "revenue": 51000},
|
||||
{"quarter": "Q3", "revenue": 47000},
|
||||
{"quarter": "Q4", "revenue": 63000},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading(f"{year} Revenue")
|
||||
BarChart(
|
||||
data=data,
|
||||
series=[ChartSeries(data_key="revenue", label="Revenue")],
|
||||
x_axis="quarter",
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
def team_directory() -> DataTable:
|
||||
return DataTable(columns=[...], rows=employees, search=True)
|
||||
```
|
||||
|
||||
Prefab apps aren't limited to static displays. Prefab's state system and client-side actions (toggles, tabs, conditionals) all work. You can even call other tools from the UI using `CallTool`. There's no hard wall on what a Prefab app can do.
|
||||
### [FastMCPApp](/apps/fastmcp-app) — when the UI calls back to the server
|
||||
|
||||
See [Prefab Apps](/apps/prefab) for the full guide.
|
||||
Forms that save data, buttons that trigger backend work, search that hits a database. `FastMCPApp` manages the wiring between UI actions and backend tools, with stable tool identifiers that survive server composition.
|
||||
|
||||
### FastMCPApp
|
||||
### [Generative UI](/apps/generative) — when the LLM writes the UI
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
When your app has a lot of server-side interaction — forms that save data, search that queries a database, multi-step workflows — managing the connection between UI and backend tools gets complicated fast. Which tools should the model see vs. only the UI? What happens to tool references when servers are composed under namespaces? How do you keep `CallTool("save_contact")` working when the tool name changes?
|
||||
|
||||
`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together:
|
||||
|
||||
- **`@app.ui()`** — entry-point tools the model calls to open the app
|
||||
- **`@app.tool()`** — backend tools the UI calls via `CallTool`
|
||||
|
||||
Backend tools get stable identifiers that survive namespacing, visibility is managed automatically (the model sees entry points, the UI sees backends), and `CallTool` accepts tool names that resolve correctly regardless of how servers are composed:
|
||||
Register one provider and the model can write Prefab code tailored to the current data and request. The user watches the UI build up as the model generates it.
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Column, Heading, Form, Input, Button, ForEach, Row, Text, Badge, Separator,
|
||||
)
|
||||
from prefab_ui.rx import RESULT
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
|
||||
app = FastMCPApp("Contacts")
|
||||
|
||||
|
||||
@app.tool()
|
||||
def save_contact(name: str, email: str) -> list[dict]:
|
||||
"""Save a contact and return the updated list."""
|
||||
db.append({"name": name, "email": email})
|
||||
return list(db)
|
||||
|
||||
|
||||
@app.ui()
|
||||
def contact_manager() -> PrefabApp:
|
||||
"""Open the contact manager."""
|
||||
with Column(gap=6, css_class="p-6") as view:
|
||||
Heading("Contacts")
|
||||
with ForEach("contacts") as contact:
|
||||
with Row(gap=2):
|
||||
Text(contact.name)
|
||||
Badge(contact.email)
|
||||
Separator()
|
||||
with Form(
|
||||
on_submit=CallTool(
|
||||
"save_contact",
|
||||
on_success=[
|
||||
SetState("contacts", RESULT),
|
||||
ShowToast("Saved!", variant="success"),
|
||||
],
|
||||
)
|
||||
):
|
||||
Input(name="name", label="Name", required=True)
|
||||
Input(name="email", label="Email", required=True)
|
||||
Button("Save")
|
||||
|
||||
return PrefabApp(view=view, state={"contacts": list(db)})
|
||||
|
||||
|
||||
mcp = FastMCP("Server", providers=[app])
|
||||
```
|
||||
|
||||
You *can* build server-interactive UIs without `FastMCPApp` — it's all the same protocol underneath. But once you have multiple tools, composition concerns, or visibility requirements, `FastMCPApp` handles the complexity so you don't have to.
|
||||
|
||||
See [FastMCPApp](/apps/interactive-apps) for the full guide.
|
||||
|
||||
### Generative UI
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
Instead of pre-building a UI, the LLM can write one from scratch. The `GenerativeUI` provider registers tools that let the model write Prefab Python code, execute it in a sandbox, and render the result — with streaming so the user watches the UI build up in real time.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.apps.generative import GenerativeUI
|
||||
|
||||
mcp = FastMCP("Prefab Studio")
|
||||
mcp.add_provider(GenerativeUI())
|
||||
```
|
||||
|
||||
See [Generative UI](/apps/generative) for the full guide, or the [provider reference](/apps/providers/generative) for configuration options.
|
||||
### [Custom HTML](/apps/low-level) — when you need full control
|
||||
|
||||
### Custom HTML
|
||||
Write your own HTML, CSS, and JavaScript. Use a specific framework, drop in a map or 3D viewer, embed video. You're talking to the MCP Apps protocol directly.
|
||||
|
||||
All the approaches above use [Prefab UI](https://prefab.prefect.io) to build UIs in pure Python. If you need full control — your own HTML, CSS, JavaScript, a specific framework — you can use the [MCP Apps extension directly](/apps/low-level). You write the HTML yourself and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK.
|
||||
## What's next
|
||||
|
||||
## Previewing Apps Locally
|
||||
|
||||
The `fastmcp dev apps` command launches a browser-based preview for your app tools — no MCP host client needed. See [Development](/apps/development).
|
||||
|
||||
```bash
|
||||
fastmcp dev apps server.py
|
||||
```
|
||||
- **[Quickstart](/apps/quickstart)** — build a working app in a minute
|
||||
- **[Examples](/apps/examples)** — complete working servers you can run today
|
||||
- **[Providers](/apps/providers/approval)** — ready-made capabilities (approvals, choice pickers, file upload, forms) you add with one line
|
||||
- **[Development](/apps/development)** — preview app tools locally with `fastmcp dev apps`
|
||||
|
|
|
|||
|
|
@ -1,431 +0,0 @@
|
|||
---
|
||||
title: Patterns
|
||||
sidebarTitle: Patterns
|
||||
description: Copy-paste examples for common tool UIs.
|
||||
icon: grid-2-plus
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
Each pattern below is a complete, copy-pasteable tool. They're organized by what you're building — pick the one closest to your use case, paste it, and adapt.
|
||||
|
||||
For the full set of available components — layout containers, form controls, overlays, and more — see the [Prefab component reference](https://prefab.prefect.io/docs/components).
|
||||
|
||||
## Charts
|
||||
|
||||
Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They render client-side with tooltips, legends, and responsive sizing.
|
||||
|
||||
### Bar Chart
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Charts")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def quarterly_revenue(year: int) -> PrefabApp:
|
||||
"""Show quarterly revenue as a bar chart."""
|
||||
data = [
|
||||
{"quarter": "Q1", "revenue": 42000, "costs": 28000},
|
||||
{"quarter": "Q2", "revenue": 51000, "costs": 31000},
|
||||
{"quarter": "Q3", "revenue": 47000, "costs": 29000},
|
||||
{"quarter": "Q4", "revenue": 63000, "costs": 35000},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading(f"{year} Revenue vs Costs")
|
||||
BarChart(
|
||||
data=data,
|
||||
series=[
|
||||
ChartSeries(data_key="revenue", label="Revenue"),
|
||||
ChartSeries(data_key="costs", label="Costs"),
|
||||
],
|
||||
x_axis="quarter",
|
||||
show_legend=True,
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes.
|
||||
|
||||
### Area Chart
|
||||
|
||||
`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation and `show_dots` for data points:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import AreaChart, ChartSeries
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Charts")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def usage_trend() -> PrefabApp:
|
||||
"""Show API usage over time."""
|
||||
data = [
|
||||
{"date": "Feb 1", "requests": 1200},
|
||||
{"date": "Feb 2", "requests": 1350},
|
||||
{"date": "Feb 3", "requests": 980},
|
||||
{"date": "Feb 4", "requests": 1500},
|
||||
{"date": "Feb 5", "requests": 1420},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("API Usage")
|
||||
AreaChart(
|
||||
data=data,
|
||||
series=[ChartSeries(data_key="requests", label="Requests")],
|
||||
x_axis="date",
|
||||
curve="smooth",
|
||||
height=250,
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
### Pie and Donut Charts
|
||||
|
||||
`PieChart` uses `data_key` (the numeric value) and `name_key` (the label). Set `inner_radius` for a donut:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import PieChart
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Charts")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def ticket_breakdown() -> PrefabApp:
|
||||
"""Show open tickets by category."""
|
||||
data = [
|
||||
{"category": "Bug", "count": 23},
|
||||
{"category": "Feature", "count": 15},
|
||||
{"category": "Docs", "count": 8},
|
||||
{"category": "Infra", "count": 12},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Open Tickets")
|
||||
PieChart(
|
||||
data=data,
|
||||
data_key="count",
|
||||
name_key="category",
|
||||
show_legend=True,
|
||||
inner_radius=60,
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
## Data Tables
|
||||
|
||||
[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all client-side:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading, DataTable, DataTableColumn
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Directory")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def employee_directory() -> PrefabApp:
|
||||
"""Show a searchable, sortable employee directory."""
|
||||
employees = [
|
||||
{"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"},
|
||||
{"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"},
|
||||
{"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"},
|
||||
{"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"},
|
||||
{"name": "Eva Müller", "department": "Engineering", "role": "Engineer", "location": "Berlin"},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Employee Directory")
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="department", header="Department", sortable=True),
|
||||
DataTableColumn(key="role", header="Role"),
|
||||
DataTableColumn(key="location", header="Office", sortable=True),
|
||||
],
|
||||
rows=employees,
|
||||
search=True,
|
||||
paginated=True,
|
||||
page_size=15,
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
## Status Displays
|
||||
|
||||
Cards, badges, progress bars, and grids combine naturally for dashboards:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Column, Row, Grid, Heading, Text, Muted, Badge,
|
||||
Card, CardContent, Progress, Separator,
|
||||
)
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Monitoring")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def system_status() -> PrefabApp:
|
||||
"""Show current system health."""
|
||||
services = [
|
||||
{"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9},
|
||||
{"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99},
|
||||
{"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2},
|
||||
{"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8},
|
||||
]
|
||||
all_ok = all(s["ok"] for s in services)
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
with Row(gap=2, align="center"):
|
||||
Heading("System Status")
|
||||
Badge(
|
||||
"All Healthy" if all_ok else "Degraded",
|
||||
variant="success" if all_ok else "destructive",
|
||||
)
|
||||
Separator()
|
||||
with Grid(columns=2, gap=4):
|
||||
for svc in services:
|
||||
with Card():
|
||||
with CardContent():
|
||||
with Row(gap=2, align="center"):
|
||||
Text(svc["name"], css_class="font-medium")
|
||||
Badge(
|
||||
svc["status"],
|
||||
variant="success" if svc["ok"] else "destructive",
|
||||
)
|
||||
Muted(f"Response: {svc['latency_ms']}ms")
|
||||
Progress(value=svc["uptime_pct"])
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
## Reactive Displays
|
||||
|
||||
These patterns use state and `Rx()` for client-side interactivity — no server calls needed.
|
||||
|
||||
### Feature Toggles
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading, Switch, Alert, If, Separator
|
||||
from prefab_ui.rx import Rx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Flags")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def feature_flags() -> PrefabApp:
|
||||
"""Toggle feature flags with live preview."""
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Feature Flags")
|
||||
Switch(name="dark_mode", label="Dark Mode")
|
||||
Switch(name="beta", label="Beta Features")
|
||||
Separator()
|
||||
with If(Rx("dark_mode")):
|
||||
Alert(title="Dark mode enabled", description="UI will use dark theme.")
|
||||
with If(Rx("beta")):
|
||||
Alert(
|
||||
title="Beta features active",
|
||||
description="Experimental features are now visible.",
|
||||
variant="warning",
|
||||
)
|
||||
|
||||
return PrefabApp(view=view, state={"dark_mode": False, "beta": False})
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Column, Heading, Text, Muted, Badge, Row,
|
||||
DataTable, DataTableColumn, Tabs, Tab, ForEach,
|
||||
)
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Projects")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def project_overview() -> PrefabApp:
|
||||
"""Show project details organized in tabs."""
|
||||
project = {
|
||||
"name": "FastMCP v3",
|
||||
"description": "Next generation MCP framework with Apps support.",
|
||||
"status": "Active",
|
||||
"members": [
|
||||
{"name": "Alice Chen", "role": "Lead"},
|
||||
{"name": "Bob Martinez", "role": "Design"},
|
||||
],
|
||||
"activity": [
|
||||
{"timestamp": "2 hours ago", "message": "Merged PR #342"},
|
||||
{"timestamp": "1 day ago", "message": "Released v3.0.1"},
|
||||
],
|
||||
}
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading(project["name"])
|
||||
with Tabs():
|
||||
with Tab("Overview"):
|
||||
Text(project["description"])
|
||||
with Row(gap=4):
|
||||
Badge(project["status"])
|
||||
|
||||
with Tab("Members"):
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="role", header="Role"),
|
||||
],
|
||||
rows=project["members"],
|
||||
)
|
||||
|
||||
with Tab("Activity"):
|
||||
with ForEach("activity") as item:
|
||||
with Row(gap=2):
|
||||
Muted(item.timestamp)
|
||||
Text(item.message)
|
||||
|
||||
return PrefabApp(view=view, state={"activity": project["activity"]})
|
||||
```
|
||||
|
||||
### Accordion
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Column, Heading, Row, Text, Badge, Progress,
|
||||
Accordion, AccordionItem,
|
||||
)
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("API Monitor")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def api_health() -> PrefabApp:
|
||||
"""Show health details for each API endpoint."""
|
||||
endpoints = [
|
||||
{"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9},
|
||||
{"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7},
|
||||
{"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5},
|
||||
{"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("API Health")
|
||||
with Accordion(multiple=True):
|
||||
for ep in endpoints:
|
||||
with AccordionItem(ep["path"]):
|
||||
with Row(gap=4):
|
||||
Badge(
|
||||
f"{ep['status']}",
|
||||
variant="success" if ep["healthy"] else "destructive",
|
||||
)
|
||||
Text(f"Avg: {ep['avg_ms']}ms")
|
||||
Text(f"P99: {ep['p99_ms']}ms")
|
||||
Progress(value=ep["uptime_pct"])
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
## Interactive Patterns
|
||||
|
||||
These patterns call server tools. For context on `FastMCPApp`, `@app.tool()`, and `CallTool`, see [FastMCPApp](/apps/interactive-apps).
|
||||
|
||||
### Contact Form
|
||||
|
||||
```python
|
||||
from prefab_ui.actions import SetState, ShowToast
|
||||
from prefab_ui.actions.mcp import CallTool
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Badge, Button, Column, ForEach, Form, Heading,
|
||||
Input, Muted, Row, Select, SelectOption, Separator, Text, Textarea,
|
||||
)
|
||||
from prefab_ui.rx import RESULT
|
||||
from fastmcp import FastMCP, FastMCPApp
|
||||
|
||||
app = FastMCPApp("Contacts")
|
||||
|
||||
contacts_db: list[dict] = [
|
||||
{"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
|
||||
]
|
||||
|
||||
|
||||
@app.tool()
|
||||
def save_contact(
|
||||
name: str, email: str, category: str = "Other", notes: str = "",
|
||||
) -> list[dict]:
|
||||
"""Save a new contact and return the updated list."""
|
||||
contacts_db.append({"name": name, "email": email, "category": category})
|
||||
return list(contacts_db)
|
||||
|
||||
|
||||
@app.ui()
|
||||
def contact_form() -> PrefabApp:
|
||||
"""Contact list with an add form."""
|
||||
with Column(gap=6, css_class="p-6") as view:
|
||||
Heading("Contacts")
|
||||
|
||||
with ForEach("contacts") as contact:
|
||||
with Row(gap=2, align="center"):
|
||||
Text(contact.name, css_class="font-medium")
|
||||
Muted(contact.email)
|
||||
Badge(contact.category)
|
||||
|
||||
Separator()
|
||||
|
||||
with Form(
|
||||
on_submit=CallTool(
|
||||
"save_contact",
|
||||
on_success=[
|
||||
SetState("contacts", RESULT),
|
||||
ShowToast("Contact saved!", variant="success"),
|
||||
],
|
||||
on_error=ShowToast("Failed to save", variant="error"),
|
||||
)
|
||||
):
|
||||
Input(name="name", label="Full Name", required=True)
|
||||
Input(name="email", label="Email", input_type="email", required=True)
|
||||
with Select(name="category", label="Category"):
|
||||
SelectOption("Customer", value="Customer")
|
||||
SelectOption("Vendor", value="Vendor")
|
||||
SelectOption("Partner", value="Partner")
|
||||
SelectOption("Other", value="Other")
|
||||
Textarea(name="notes", label="Notes", placeholder="Optional notes...")
|
||||
Button("Save Contact")
|
||||
|
||||
return PrefabApp(view=view, state={"contacts": list(contacts_db)})
|
||||
|
||||
|
||||
mcp = FastMCP("Server", providers=[app])
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for server-connected UIs
|
||||
- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps`
|
||||
- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, layout guides, and more
|
||||
|
|
@ -1,321 +1,255 @@
|
|||
---
|
||||
title: Prefab UI
|
||||
sidebarTitle: Prefab UI
|
||||
description: The component library behind FastMCP apps — charts, tables, dashboards, forms, and reactive displays.
|
||||
title: Interactive Tools
|
||||
sidebarTitle: Interactive Tools
|
||||
description: Turn your tools into interactive UIs with charts, tables, and dashboards.
|
||||
icon: palette
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
|
||||
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
|
||||
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
<Warning>
|
||||
[Prefab](https://prefab.prefect.io) is in early, active development — breaking changes can occur with any release. FastMCP pins a minimum version of `prefab-ui` for compatibility but does not pin an upper bound. If you are deploying to production, **pin `prefab-ui` to a specific version** in your own dependencies.
|
||||
</Warning>
|
||||
<PrefabPinWarning />
|
||||
|
||||
[Prefab UI](https://prefab.prefect.io) is the component library behind all FastMCP app features. You describe layouts, charts, tables, and forms in Python, and Prefab compiles them to interactive UIs that render in the host's conversation.
|
||||
<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" />
|
||||
|
||||
The simplest way to use it: add `app=True` to a tool and return Prefab components. The host renders an interactive UI instead of text. This works for everything from static charts to reactive dashboards with client-side state — no server round-trips needed.
|
||||
Believe it or not, that dashboard is a FastMCP tool. The chart has tooltips. The table is sortable. The badges are styled by deal stage. The whole thing is about 40 lines of Python, and the user sees it right inside their conversation instead of a wall of JSON.
|
||||
|
||||
For apps that need server interaction (forms, search, CRUD), see [FastMCPApp](/apps/interactive-apps) which adds managed tool binding on top of Prefab UI. For LLM-generated UIs, see [Generative UI](/apps/generative).
|
||||
The pattern behind every example on this page is the same: add `app=True` to your tool, build a UI with [Prefab](https://prefab.prefect.io) components, and return it as a `PrefabApp`. Prefab has [100+ components](https://prefab.prefect.io/docs/components), from data tables and charts to forms and progress bars. You compose them in Python; the host renders them as a live, interactive application.
|
||||
|
||||
## Getting Started
|
||||
## Start with a table
|
||||
|
||||
Here's a tool that returns a bar chart:
|
||||
Most tools return data the user wants to explore. A `DataTable` is often the smallest useful upgrade — your data goes from a JSON blob to a searchable, sortable table:
|
||||
|
||||
<PrefabDemoFrame demo="data-table" height="530px" title="Data table demo" />
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Dashboard")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def revenue_chart(year: int) -> PrefabApp:
|
||||
"""Show annual revenue as an interactive bar chart."""
|
||||
data = [
|
||||
{"quarter": "Q1", "revenue": 42000},
|
||||
{"quarter": "Q2", "revenue": 51000},
|
||||
{"quarter": "Q3", "revenue": 47000},
|
||||
{"quarter": "Q4", "revenue": 63000},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading(f"{year} Revenue")
|
||||
BarChart(
|
||||
data=data,
|
||||
series=[ChartSeries(data_key="revenue", label="Revenue")],
|
||||
x_axis="quarter",
|
||||
)
|
||||
|
||||
return PrefabApp(view=view)
|
||||
```
|
||||
|
||||
The `app=True` flag tells FastMCP this tool returns a UI. When a host calls the tool, the user sees an interactive chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples.
|
||||
|
||||
## Layout and Components
|
||||
|
||||
Prefab uses Python's `with` statement to express nesting. Containers like `Column`, `Row`, and `Grid` collect their children automatically:
|
||||
|
||||
```python
|
||||
from prefab_ui.components import (
|
||||
Column, Row, Grid, Heading, Text, Muted, Badge,
|
||||
Card, CardContent, Separator,
|
||||
)
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Team Status")
|
||||
Separator()
|
||||
with Grid(columns=2, gap=4):
|
||||
with Card():
|
||||
with CardContent():
|
||||
Text("API Gateway", css_class="font-medium")
|
||||
Badge("healthy", variant="success")
|
||||
with Card():
|
||||
with CardContent():
|
||||
Text("Cache", css_class="font-medium")
|
||||
Badge("degraded", variant="destructive")
|
||||
```
|
||||
|
||||
You can also use Python loops to generate components at build time:
|
||||
|
||||
```python
|
||||
services = [
|
||||
{"name": "API", "status": "healthy", "ok": True},
|
||||
{"name": "Cache", "status": "degraded", "ok": False},
|
||||
]
|
||||
|
||||
with Grid(columns=2, gap=4):
|
||||
for svc in services:
|
||||
with Card():
|
||||
with CardContent():
|
||||
Text(svc["name"])
|
||||
Badge(
|
||||
svc["status"],
|
||||
variant="success" if svc["ok"] else "destructive",
|
||||
)
|
||||
```
|
||||
|
||||
Build-time loops produce static content — the data is baked into the component tree at construction time. For dynamic iteration over state that changes at render time, use `ForEach` (covered below).
|
||||
|
||||
The full component library — layout containers, data display, charts, forms, overlays — is documented in the [Prefab component reference](https://prefab.prefect.io/docs/components).
|
||||
|
||||
## State and Reactivity
|
||||
|
||||
Display tools can be interactive without calling the server. The key is **state** — a client-side key-value store that lives in the browser. Components read from state, actions mutate it, and the UI re-renders automatically.
|
||||
|
||||
### Declaring State
|
||||
|
||||
Pass a `state` dict to `PrefabApp` to declare initial state, then use `Rx("key")` to create reactive references:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading, Switch, Alert, If
|
||||
from prefab_ui.rx import Rx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Flags")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def feature_flags() -> PrefabApp:
|
||||
"""Toggle feature flags with live preview."""
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Feature Flags")
|
||||
Switch(name="dark_mode", label="Dark Mode")
|
||||
Switch(name="beta", label="Beta Features")
|
||||
|
||||
with If(Rx("dark_mode")):
|
||||
Alert(title="Dark mode enabled")
|
||||
with If(Rx("beta")):
|
||||
Alert(title="Beta features active", variant="warning")
|
||||
|
||||
return PrefabApp(view=view, state={"dark_mode": False, "beta": False})
|
||||
```
|
||||
|
||||
Three things to notice here:
|
||||
|
||||
The `state` dict on `PrefabApp` declares the keys and their starting values. `Rx("dark_mode")` creates a reactive reference that compiles to `{{ dark_mode }}` in the wire protocol.
|
||||
|
||||
Interactive components with a `name` prop automatically bind to state. The `Switch(name="dark_mode")` syncs its on/off value to the `dark_mode` state key on every toggle — no event wiring needed.
|
||||
|
||||
`If(Rx("dark_mode"))` shows its children only when the state key is truthy. When the switch flips, the condition re-evaluates instantly in the browser.
|
||||
|
||||
### Reactive References with Rx
|
||||
|
||||
The `Rx` class is how you reference state in component props:
|
||||
|
||||
```python
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
count = Rx("count")
|
||||
```
|
||||
|
||||
Rx objects support arithmetic, comparisons, and formatting — they compile to expressions the renderer evaluates at render time:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Text, Slider
|
||||
from prefab_ui.rx import Rx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Calculator")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def tip_calculator() -> PrefabApp:
|
||||
"""Calculate tip with a slider."""
|
||||
tip_pct = Rx("tip_pct")
|
||||
bill = Rx("bill")
|
||||
|
||||
tip_amount = tip_pct / 100 * bill
|
||||
total = bill + tip_amount
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Slider(name="bill", label="Bill Amount", min=0, max=500, step=0.5)
|
||||
Slider(name="tip_pct", label="Tip %", min=0, max=50)
|
||||
Text(f"Tip: {tip_amount.currency()}")
|
||||
Text(f"Total: {total.currency()}")
|
||||
|
||||
return PrefabApp(view=view, state={"bill": 50.00, "tip_pct": 18})
|
||||
```
|
||||
|
||||
`Rx("tip_pct") / 100 * Rx("bill")` builds a compound expression — it doesn't do the math in Python. The renderer evaluates it live as the sliders move. The `.currency()` pipe formats the result as currency.
|
||||
|
||||
#### Pipes
|
||||
|
||||
Rx objects support formatting pipes that transform values at render time:
|
||||
|
||||
```python
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
price = Rx("price")
|
||||
ratio = Rx("ratio")
|
||||
name = Rx("name")
|
||||
|
||||
price.currency() # $42.50
|
||||
price.currency("EUR") # EUR format
|
||||
ratio.percent() # 85%
|
||||
name.upper() # ALICE
|
||||
name.truncate(10) # alice (or truncated if longer)
|
||||
```
|
||||
|
||||
Number pipes include `currency`, `percent`, `number`, `compact`, `round`, and `abs`. String pipes include `upper`, `lower`, and `truncate`. See the [Prefab expression docs](https://prefab.prefect.io/docs/concepts/expressions) for the full list.
|
||||
|
||||
#### Conditionals
|
||||
|
||||
The `.then()` method creates ternary expressions:
|
||||
|
||||
```python
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
connected = Rx("connected")
|
||||
|
||||
Badge(
|
||||
connected.then("Online", "Offline"),
|
||||
variant=connected.then("success", "destructive"),
|
||||
)
|
||||
```
|
||||
|
||||
### Dynamic Iteration with ForEach
|
||||
|
||||
Python `for` loops generate static content at build time. When you need to iterate over state that can change — a list that grows, items that get filtered — use `ForEach`:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading, ForEach, Row, Text, Badge
|
||||
from prefab_ui.components import DataTable, DataTableColumn
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Directory")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def team_list() -> PrefabApp:
|
||||
"""Show the current team."""
|
||||
members = [
|
||||
{"name": "Alice", "role": "Engineering"},
|
||||
{"name": "Bob", "role": "Design"},
|
||||
def team_directory() -> DataTable:
|
||||
"""Browse the team directory."""
|
||||
employees = [
|
||||
{"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"},
|
||||
{"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"},
|
||||
{"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"},
|
||||
{"name": "David Kim", "role": "Product Manager", "dept": "Product"},
|
||||
{"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"},
|
||||
{"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"},
|
||||
{"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"},
|
||||
]
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Team")
|
||||
with ForEach("members") as member:
|
||||
with Row(gap=2, align="center"):
|
||||
Text(member.name, css_class="font-medium")
|
||||
Badge(member.role)
|
||||
|
||||
return PrefabApp(view=view, state={"members": members})
|
||||
```
|
||||
|
||||
`ForEach("members")` iterates over the `members` state key. The `as member` gives you an Rx proxy scoped to each item, so `member.name` resolves to `{{ $item.name }}` in the wire protocol. If the `members` state changes (e.g., through an action), the list re-renders automatically.
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
`If`, `Elif`, and `Else` control what's visible based on state:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Select, SelectOption, If, Elif, Else, Text
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
tier = Rx("tier")
|
||||
|
||||
with Column(gap=4) as view:
|
||||
with Select(name="tier", label="Plan"):
|
||||
SelectOption("Free", value="free")
|
||||
SelectOption("Pro", value="pro")
|
||||
SelectOption("Enterprise", value="enterprise")
|
||||
with If(tier == "enterprise"):
|
||||
Text("Full access to all features")
|
||||
with Elif(tier == "pro"):
|
||||
Text("Advanced features unlocked")
|
||||
with Else():
|
||||
Text("Basic features only")
|
||||
|
||||
# Pass state={"tier": "free"} to PrefabApp when returning
|
||||
```
|
||||
|
||||
Changes are instant — switching the dropdown re-evaluates the conditions in the browser.
|
||||
|
||||
## Giving the LLM Context
|
||||
|
||||
By default, Prefab sends `"[Rendered Prefab UI]"` as the text content for the LLM. If the model needs to reason about the data, wrap your return in a `ToolResult` with a meaningful summary:
|
||||
|
||||
```python
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools import ToolResult
|
||||
|
||||
mcp = FastMCP("Sales")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def sales_overview(year: int) -> ToolResult:
|
||||
"""Show sales data visually and summarize for the model."""
|
||||
data = get_sales_data(year)
|
||||
total = sum(row["revenue"] for row in data)
|
||||
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
Heading("Sales Overview")
|
||||
BarChart(data=data, series=[ChartSeries(data_key="revenue")])
|
||||
|
||||
return ToolResult(
|
||||
content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
|
||||
structured_content=view,
|
||||
return DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="name", header="Name", sortable=True),
|
||||
DataTableColumn(key="role", header="Role", sortable=True),
|
||||
DataTableColumn(key="dept", header="Dept", sortable=True),
|
||||
],
|
||||
rows=employees,
|
||||
search=True,
|
||||
)
|
||||
```
|
||||
|
||||
The user sees the chart. The LLM sees the summary string.
|
||||
That's it. Add `app=True`, return a Prefab component instead of raw dicts. FastMCP handles the rendering, sandboxing, and security. No wrapper class needed for simple cases like this.
|
||||
|
||||
## Advanced
|
||||
## Add charts
|
||||
|
||||
<Accordion title="Customizing CSP">
|
||||
`app=True` auto-wires the Prefab renderer with default CSP settings. If your app loads external resources — embedding iframes, fetching from APIs, loading scripts — use `PrefabAppConfig` to add the required domains:
|
||||
When numbers tell a better story as a visual, swap in a chart. The API is the same: pass your data as a list of dicts, tell the chart which keys to plot.
|
||||
|
||||
<PrefabDemoFrame demo="bar-chart" height="430px" title="Bar chart demo" />
|
||||
|
||||
```python
|
||||
@mcp.tool(app=True)
|
||||
def quarterly_revenue(year: int) -> BarChart:
|
||||
"""Show quarterly revenue as a bar chart."""
|
||||
data = [
|
||||
{"quarter": "Q1", "revenue": 42000, "costs": 28000},
|
||||
{"quarter": "Q2", "revenue": 51000, "costs": 31000},
|
||||
{"quarter": "Q3", "revenue": 47000, "costs": 29000},
|
||||
{"quarter": "Q4", "revenue": 63000, "costs": 35000},
|
||||
]
|
||||
|
||||
return BarChart(
|
||||
data=data,
|
||||
series=[
|
||||
ChartSeries(data_key="revenue", label="Revenue"),
|
||||
ChartSeries(data_key="costs", label="Costs"),
|
||||
],
|
||||
x_axis="quarter",
|
||||
show_legend=True,
|
||||
)
|
||||
```
|
||||
|
||||
Each `ChartSeries` plots a different key from the data. `BarChart`, `LineChart`, `AreaChart`, `PieChart`, `RadarChart`, and `RadialChart` all follow the same pattern. Hover over the bars to see tooltips.
|
||||
|
||||
<PrefabDemoFrame demo="pie-chart" height="410px" title="Pie chart demo" />
|
||||
|
||||
```python
|
||||
@mcp.tool(app=True)
|
||||
def ticket_breakdown() -> PieChart:
|
||||
"""Show open tickets by category."""
|
||||
data = [
|
||||
{"category": "Bug", "count": 42},
|
||||
{"category": "Feature", "count": 28},
|
||||
{"category": "Docs", "count": 15},
|
||||
{"category": "Infra", "count": 10},
|
||||
]
|
||||
|
||||
return PieChart(
|
||||
data=data,
|
||||
data_key="count",
|
||||
name_key="category",
|
||||
inner_radius=50,
|
||||
show_legend=True,
|
||||
)
|
||||
```
|
||||
|
||||
See the [Prefab chart docs](https://prefab.prefect.io/docs/components) for stacking, curves, custom colors, and more.
|
||||
|
||||
## Compose a dashboard
|
||||
|
||||
Tables and charts are useful on their own, but the real power comes from composing them. `Column` stacks children vertically, `Row` lays them out side by side, and `with` blocks establish nesting — the indentation is the layout.
|
||||
|
||||
<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" />
|
||||
|
||||
```python expandable
|
||||
@mcp.tool(app=True)
|
||||
def sales_dashboard() -> PrefabApp:
|
||||
"""Show sales KPIs, trends, and deals."""
|
||||
monthly = [
|
||||
{"month": "Jan", "revenue": 48200, "costs": 31000},
|
||||
{"month": "Feb", "revenue": 52100, "costs": 32500},
|
||||
{"month": "Mar", "revenue": 61800, "costs": 34200},
|
||||
{"month": "Apr", "revenue": 58400, "costs": 33800},
|
||||
]
|
||||
deals = [
|
||||
{"account": "Acme Corp", "value": "$84,000", "stage": "Won"},
|
||||
{"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"},
|
||||
{"account": "Initech", "value": "$31,500", "stage": "Proposal"},
|
||||
{"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"},
|
||||
]
|
||||
|
||||
rows = [
|
||||
{
|
||||
"account": d["account"],
|
||||
"value": d["value"],
|
||||
"stage": Badge(
|
||||
d["stage"],
|
||||
variant="success" if d["stage"] == "Won"
|
||||
else "destructive" if d["stage"] == "Lost"
|
||||
else "secondary",
|
||||
),
|
||||
}
|
||||
for d in deals
|
||||
]
|
||||
|
||||
total = sum(m["revenue"] for m in monthly)
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
with Row(gap=6):
|
||||
Metric(label="Revenue (Q1-Q4)", value=f"${total:,}")
|
||||
Metric(label="Deals", value=f"{len(deals)}")
|
||||
BarChart(
|
||||
data=monthly,
|
||||
series=[
|
||||
ChartSeries(data_key="revenue", label="Revenue"),
|
||||
ChartSeries(data_key="costs", label="Costs"),
|
||||
],
|
||||
x_axis="month",
|
||||
show_legend=True,
|
||||
)
|
||||
Separator()
|
||||
DataTable(
|
||||
columns=[
|
||||
DataTableColumn(key="account", header="Account", sortable=True),
|
||||
DataTableColumn(key="value", header="Value", sortable=True),
|
||||
DataTableColumn(key="stage", header="Stage"),
|
||||
],
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
return app
|
||||
```
|
||||
|
||||
Notice how `Badge` components can be placed inside table cells — any Prefab component works as a cell value, so you can put progress bars, icons, or buttons in your tables too.
|
||||
|
||||
## Make it reactive
|
||||
|
||||
Everything above renders once from the data your Python provides. But interactive tools can also respond to user input in real time, without any server round-trips. Prefab's state system lets components read and write client-side values, so the UI updates instantly as the user interacts with it.
|
||||
|
||||
<PrefabDemoFrame demo="reactive" height="500px" title="Reactive sales demo" />
|
||||
|
||||
Try switching regions in the dropdown, and toggling the switch on and off.
|
||||
|
||||
```python expandable
|
||||
from prefab_ui.rx import Rx
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def regional_sales() -> PrefabApp:
|
||||
"""Sales by region with a live filter."""
|
||||
north = [
|
||||
{"month": "Jan", "sales": 22000},
|
||||
{"month": "Feb", "sales": 25500},
|
||||
{"month": "Mar", "sales": 24200},
|
||||
]
|
||||
south = [
|
||||
{"month": "Jan", "sales": 5800},
|
||||
{"month": "Feb", "sales": 6400},
|
||||
{"month": "Mar", "sales": 5600},
|
||||
]
|
||||
west = [
|
||||
{"month": "Jan", "sales": 6000},
|
||||
{"month": "Feb", "sales": 6000},
|
||||
{"month": "Mar", "sales": 5600},
|
||||
]
|
||||
|
||||
with PrefabApp(
|
||||
state={
|
||||
"region": "north",
|
||||
"north": north, "south": south, "west": west,
|
||||
"show_target": True,
|
||||
},
|
||||
) as app:
|
||||
with Column(
|
||||
gap=4,
|
||||
css_class="p-6",
|
||||
let={"data": "{{ region == 'south' ? south"
|
||||
" : region == 'west' ? west"
|
||||
" : north }}"},
|
||||
):
|
||||
with Row(gap=4, align="center"):
|
||||
with Select(name="region", css_class="w-40"):
|
||||
SelectOption(value="north", label="North")
|
||||
SelectOption(value="south", label="South")
|
||||
SelectOption(value="west", label="West")
|
||||
Switch(name="show_target", css_class="ml-auto")
|
||||
Text("Show target", css_class="text-sm text-muted-foreground")
|
||||
BarChart(
|
||||
data=Rx("data"),
|
||||
series=[ChartSeries(data_key="sales", label="Sales")],
|
||||
x_axis="month",
|
||||
)
|
||||
with If(Rx("show_target")):
|
||||
Metric(label="Q1 Target", value="$75,000")
|
||||
|
||||
return app
|
||||
```
|
||||
|
||||
The `state` dict on `PrefabApp` declares initial values. The `Select` writes to the `region` key on every change. A `let` binding picks the matching dataset, and the chart re-renders. The `Switch` toggles a `Metric` on and off through `If(Rx("show_target"))`. All of this happens in the browser — no calls back to your server.
|
||||
|
||||
`Rx` is a reactive reference: `Rx("region")` compiles to an expression the renderer evaluates live. It supports arithmetic, comparisons, formatting pipes (`.currency()`, `.percent()`), and ternary conditionals (`.then()`). For the full state system, see the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) and [expression docs](https://prefab.prefect.io/docs/concepts/expressions).
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
Interactive tools render in a sandboxed iframe with a strict CSP. If your tool loads external resources — embedding iframes, fetching from APIs, loading scripts — add the required domains:
|
||||
|
||||
```python
|
||||
from fastmcp.apps import PrefabAppConfig, ResourceCSP
|
||||
|
|
@ -327,40 +261,37 @@ def dashboard_with_embed() -> PrefabApp:
|
|||
...
|
||||
```
|
||||
|
||||
`PrefabAppConfig()` with no arguments is equivalent to `app=True`. It auto-sets the renderer URI and merges the renderer's CSP with any additional domains you provide.
|
||||
</Accordion>
|
||||
`PrefabAppConfig()` with no arguments is equivalent to `app=True`.
|
||||
|
||||
<Accordion title="Type inference">
|
||||
If your return type annotation is a Prefab type — `PrefabApp`, `Component`, or unions containing them — FastMCP enables app rendering automatically, even without `app=True`:
|
||||
## Giving the LLM context
|
||||
|
||||
By default, the LLM sees `"[Rendered Prefab UI]"` as the tool result. If the model needs to reason about the data, return a `ToolResult` with a text summary alongside the UI:
|
||||
|
||||
```python
|
||||
@mcp.tool
|
||||
def greet(name: str) -> PrefabApp:
|
||||
return PrefabApp(view=Heading(f"Hello, {name}!"))
|
||||
```
|
||||
|
||||
Explicit `app=True` is recommended for clarity.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Mixing with custom HTML">
|
||||
Prefab tools and [custom HTML tools](/apps/low-level) coexist on the same server:
|
||||
|
||||
```python
|
||||
from fastmcp.apps import AppConfig
|
||||
from fastmcp.tools import ToolResult
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def team_directory() -> PrefabApp:
|
||||
...
|
||||
def sales_overview(year: int) -> ToolResult:
|
||||
"""Show sales visually, summarize for the model."""
|
||||
data = get_sales_data(year)
|
||||
total = sum(row["revenue"] for row in data)
|
||||
|
||||
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/map.html"))
|
||||
def map_view() -> str:
|
||||
...
|
||||
with Column(gap=4, css_class="p-6") as view:
|
||||
BarChart(data=data, series=[ChartSeries(data_key="revenue")])
|
||||
|
||||
return ToolResult(
|
||||
content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
|
||||
structured_content=view,
|
||||
)
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
## Next Steps
|
||||
The user sees the chart. The model sees the summary.
|
||||
|
||||
- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for apps with heavy server interaction
|
||||
- **[Patterns](/apps/patterns)** — Charts, tables, dashboards, and other common examples
|
||||
- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps`
|
||||
- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, advanced state patterns, and more
|
||||
## Next steps
|
||||
|
||||
- **[FastMCPApp](/apps/fastmcp-app)** — when your UI needs to call backend tools (forms, search, CRUD)
|
||||
- **[Generative UI](/apps/generative)** — let the LLM design the UI at runtime
|
||||
- **[Custom HTML](/apps/low-level)** — when Prefab isn't enough (maps, 3D, your own framework)
|
||||
- **[Examples](/apps/examples)** — complete working servers you can run today
|
||||
- **[Development](/apps/development)** — preview your tools locally with `fastmcp dev apps`
|
||||
- **[Prefab UI](https://prefab.prefect.io)** — full component reference with 100+ components, theming, and advanced patterns
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ request_approval(
|
|||
)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
When the user clicks a button, two things happen:
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ choose(
|
|||
)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
Each option renders as a full-width button in a vertical stack. When the user clicks one:
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ FileUpload(
|
|||
|
||||
The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`).
|
||||
|
||||
## Storage Scoping
|
||||
## Storage scoping
|
||||
|
||||
By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another.
|
||||
|
||||
|
|
@ -59,16 +59,24 @@ This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sess
|
|||
In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token.
|
||||
</Warning>
|
||||
|
||||
For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user:
|
||||
For stateless deployments, override `_get_scope_key` to return a stable identifier. To scope files by authenticated user, read the caller from `get_access_token()`.
|
||||
|
||||
Reject the request when there is no subject to key on. `get_access_token()` returns `None` on an unauthenticated request, and `subject` is optional even on a valid token, since not every verifier populates it. Returning a fallback in either case would put every such caller in one shared bucket, so they would see each other's uploads.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.file_upload import FileUpload
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
class UserScopedUpload(FileUpload):
|
||||
def _get_scope_key(self, ctx):
|
||||
return ctx.access_token["sub"]
|
||||
token = get_access_token()
|
||||
if token is None or not token.subject:
|
||||
raise ValueError("File scoping requires an authenticated user with a subject")
|
||||
return token.subject
|
||||
```
|
||||
|
||||
If your provider carries the user identity in a different claim, read it from `token.claims` and validate it the same way.
|
||||
|
||||
For process-wide shared storage (all users see all files):
|
||||
|
||||
```python
|
||||
|
|
@ -77,7 +85,7 @@ class SharedUpload(FileUpload):
|
|||
return "__shared__"
|
||||
```
|
||||
|
||||
## Custom Storage
|
||||
## Custom storage
|
||||
|
||||
The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization.
|
||||
|
||||
|
|
@ -85,10 +93,17 @@ The default implementation stores files in memory for the lifetime of the server
|
|||
import base64
|
||||
|
||||
from fastmcp.apps.file_upload import FileUpload
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
class S3Upload(FileUpload):
|
||||
def _get_scope_key(self, ctx):
|
||||
token = get_access_token()
|
||||
if token is None or not token.subject:
|
||||
raise ValueError("File scoping requires an authenticated user with a subject")
|
||||
return token.subject
|
||||
|
||||
def on_store(self, files, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
for f in files:
|
||||
s3.put_object(
|
||||
Bucket="uploads",
|
||||
|
|
@ -98,7 +113,7 @@ class S3Upload(FileUpload):
|
|||
return self.on_list(ctx)
|
||||
|
||||
def on_list(self, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/")
|
||||
return [
|
||||
{
|
||||
|
|
@ -112,7 +127,7 @@ class S3Upload(FileUpload):
|
|||
]
|
||||
|
||||
def on_read(self, name, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}")
|
||||
content = obj["Body"].read()
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ This registers two tools:
|
|||
|
||||
The tool name is derived from the model class name, lowercased: `collect_{modelname}`. So `BugReport` becomes `collect_bugreport`, `ShippingAddress` becomes `collect_shippingaddress`. Use `tool_name` to override if needed. The LLM calls it with a prompt explaining what it needs, and the user gets a form with fields matching the model.
|
||||
|
||||
## Field Mapping
|
||||
## Field mapping
|
||||
|
||||
`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components:
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ FormInput(
|
|||
|
||||
Set `send_message=True` to push the result back into the conversation via `SendMessage`, triggering the LLM's next turn. Without it, the result is just the tool return value.
|
||||
|
||||
## Multiple Forms
|
||||
## Multiple forms
|
||||
|
||||
Add multiple providers for different models — each gets its own tool:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
title: Generative UI
|
||||
sidebarTitle: Generative UI
|
||||
description: Let the LLM generate custom UIs at runtime
|
||||
icon: wand-magic-sparkles
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
`GenerativeUI` lets the LLM write Prefab Python code at runtime and render it as a streaming interactive UI. Instead of calling pre-built tools with fixed interfaces, the model creates tailored visualizations for whatever data it's working with.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.apps.generative import GenerativeUI
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
mcp.add_provider(GenerativeUI())
|
||||
```
|
||||
|
||||
This registers:
|
||||
|
||||
| Component | Type | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `generate_prefab_ui` | Tool | Accepts Python code, executes in Pyodide sandbox, renders result |
|
||||
| `search_prefab_components` | Tool | Lets the LLM discover available Prefab components |
|
||||
| Generative renderer | Resource | `ui://` resource with browser-side Pyodide for streaming |
|
||||
|
||||
The LLM writes real Python — loops, f-strings, computation — using Prefab's component library (charts, tables, forms, cards, layout primitives). As the model generates tokens, the host streams partial code to the renderer via `ontoolinputpartial`, so the user watches the UI build up in real time.
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
GenerativeUI(
|
||||
tool_name="generate_prefab_ui", # Rename the generation tool
|
||||
components_tool_name="search_prefab_components", # Rename the search tool
|
||||
include_components_tool=True, # Set False to omit the search tool
|
||||
)
|
||||
```
|
||||
|
||||
## What the LLM Sees
|
||||
|
||||
The tool description includes code examples that teach the LLM the Prefab patterns. The LLM calls `generate_prefab_ui` with a `code` argument containing Prefab Python, and optionally a `data` argument to pass in real data from the conversation:
|
||||
|
||||
```python
|
||||
# The LLM generates something like:
|
||||
generate_prefab_ui(
|
||||
code="""
|
||||
from prefab_ui.components import Column, Heading
|
||||
from prefab_ui.components.charts import BarChart, ChartSeries
|
||||
from prefab_ui.app import PrefabApp
|
||||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4):
|
||||
Heading("Revenue")
|
||||
BarChart(data=data, series=[ChartSeries(data_key="revenue")], x_axis="quarter")
|
||||
""",
|
||||
data={"data": [{"quarter": "Q1", "revenue": 42000}, ...]}
|
||||
)
|
||||
```
|
||||
|
||||
The component search tool lets the LLM discover what's available before writing code — `search_prefab_components("Chart")` returns matching components with import paths.
|
||||
|
||||
## Requirements
|
||||
|
||||
Requires `fastmcp[apps]` (installs `prefab-ui`). The Pyodide sandbox for server-side validation requires Deno, which installs automatically on first use. The streaming renderer loads Pyodide from CDN in the browser — CSP is configured automatically.
|
||||
|
||||
The sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, etc.) are not available.
|
||||
|
||||
## Learn More
|
||||
|
||||
The full **[Generative UI guide](/apps/generative)** covers the streaming mechanics in detail, how to pass data, the component search tool, and sandbox limitations.
|
||||
|
|
@ -1,42 +1,39 @@
|
|||
---
|
||||
title: Quickstart
|
||||
sidebarTitle: Quickstart
|
||||
description: Build your first MCP app in under a minute.
|
||||
description: Build your first FastMCP app in under a minute.
|
||||
icon: rocket
|
||||
tag: NEW
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
|
||||
|
||||
<VersionBadge version="3.2.0" />
|
||||
|
||||
MCP tools normally return text. FastMCP apps return interactive UIs rendered directly in the conversation: charts, tables, forms, dashboards. The easiest way to build one is with [Prefab UI](https://prefab.prefect.io), a Python component library designed for exactly this. You describe the UI in Python; Prefab compiles it to something the host can render.
|
||||
By the end of this page, you'll have a working tool that returns this:
|
||||
|
||||
This tutorial builds a working app from scratch. Here's what you'll have in about a minute:
|
||||
<PrefabDemoFrame demo="team-directory" height="545px" title="Team directory demo" />
|
||||
|
||||
<Frame>
|
||||
<img src="/apps/images/app-quickstart.png" alt="A team directory app with a pie chart and sortable data table, rendered inside a conversation in Goose" />
|
||||
</Frame>
|
||||
A pie chart the user can hover, a table they can sort and search — and a single Python tool.
|
||||
|
||||
## Setup
|
||||
|
||||
Install FastMCP with the `apps` extra, which pulls in Prefab UI:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install "fastmcp[apps]"
|
||||
```
|
||||
|
||||
## A Tool That Returns a UI
|
||||
The `apps` extra pulls in [Prefab](https://prefab.prefect.io), the Python component library used to build app UIs.
|
||||
|
||||
When your tool has something to *show* (a table of results, a chart, a status dashboard) you can return an interactive UI instead of text. Build the visualization with Prefab components, return it from your tool, and set `app=True` so FastMCP knows to render it. The user sees a live, interactive widget right in the conversation instead of a wall of JSON.
|
||||
## Write the tool
|
||||
|
||||
Create `server.py`:
|
||||
Create `server.py`. The interesting parts: `app=True` tells FastMCP this tool renders a UI, and `with PrefabApp() as app:` is the canonical pattern for composing one.
|
||||
|
||||
```python server.py expandable
|
||||
from collections import Counter
|
||||
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import Column, Grid, Heading, DataTable, DataTableColumn
|
||||
from prefab_ui.components import Column, DataTable, DataTableColumn, Grid
|
||||
from prefab_ui.components.charts import PieChart
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
|
@ -63,7 +60,6 @@ def team_directory() -> PrefabApp:
|
|||
|
||||
with PrefabApp() as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
Heading("Team Directory")
|
||||
with Grid(columns=[1, 2], gap=4):
|
||||
PieChart(
|
||||
data=office_counts,
|
||||
|
|
@ -84,40 +80,42 @@ def team_directory() -> PrefabApp:
|
|||
return app
|
||||
```
|
||||
|
||||
That `app=True` is doing a lot behind the scenes. It tells FastMCP to set up everything the MCP Apps protocol requires: the renderer resource, the content security policy, the metadata that tells the host "this tool returns a UI." Without it, you'd wire all of that up by hand. With it, you just return Prefab components and FastMCP handles the rest. The host (Claude Desktop, Goose, etc.) loads the result in a sandboxed iframe where the user can sort columns, search, and interact, all client-side with no round-trips to your server.
|
||||
The Prefab code reads top-to-bottom. `PrefabApp()` is the root; everything inside its `with` block becomes the UI. `Column` stacks children vertically, `Grid` lays them out in columns. `DataTable` takes rows and column definitions and gives you sort and search for free.
|
||||
|
||||
The Prefab code itself reads top-to-bottom like a document. `PrefabApp()` is the root container and everything inside its `with` block becomes the app's UI. `Column` arranges children vertically. `Heading` renders a title. `DataTable` takes rows of data and column definitions, and gives you sorting and search for free. The `with` blocks establish parent-child relationships: nesting components inside each other builds the layout tree.
|
||||
`app=True` does the rest: it sets up the renderer resource, the content security policy, and the metadata that tells the host "this tool returns a UI." The host loads the result in a sandboxed iframe where the user can interact with it — all client-side, no round-trips.
|
||||
|
||||
## Running It
|
||||
## Preview it
|
||||
|
||||
FastMCP includes a dev server that renders your app tools in a browser, no MCP host needed:
|
||||
FastMCP ships a dev server that renders your app tools in a browser, no MCP host needed:
|
||||
|
||||
```bash
|
||||
fastmcp dev apps server.py
|
||||
```
|
||||
|
||||
This opens `http://localhost:8080` where you can pick a tool and see the rendered UI. Try sorting the table columns and typing in the search box.
|
||||
|
||||
## Making It Interactive
|
||||
|
||||
The table above is a static snapshot that renders once from the data your Python code provides. But Prefab apps can also respond to user interaction in real time, without any server round-trips.
|
||||
|
||||
The key concept is **state**: a client-side key-value store that components read from and write to. When the user interacts with a component, it updates state. Other components that reference that state re-render instantly. See the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) for the full guide.
|
||||
|
||||
Here's the same directory, but now clicking a row shows that person's details in a card:
|
||||
Open `http://localhost:8080`, pick `team_directory`, and try sorting columns and searching.
|
||||
|
||||
<Frame>
|
||||
<img src="/apps/images/app-quickstart-dev-2.png" alt="The team directory with a detail card showing after clicking Bob Martinez" />
|
||||
<img src="/apps/images/app-quickstart-dev-2.png" alt="The team directory rendered in the fastmcp dev apps preview, showing a pie chart, searchable table, and a detail card after clicking a row" />
|
||||
</Frame>
|
||||
|
||||
## Make it reactive
|
||||
|
||||
The UI above renders once from your Python. Prefab apps can also respond to user input live, without any server round-trips. The key concept is **state**: a client-side key-value store that components read from and write to.
|
||||
|
||||
Click a row in the demo below to see a detail card appear:
|
||||
|
||||
<PrefabDemoFrame demo="team-directory-reactive" height="675px" title="Reactive team directory demo" />
|
||||
|
||||
Add a few imports, give each member a couple more fields, wire up a click handler, and render a detail card when something's selected:
|
||||
|
||||
```python expandable server.py
|
||||
from collections import Counter
|
||||
|
||||
from prefab_ui.actions import SetState
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components import (
|
||||
Card, CardContent, CardHeader, Column, Grid, H3, Heading, Muted,
|
||||
Row, DataTable, DataTableColumn, Badge, Small, Text,
|
||||
Badge, Card, CardContent, CardHeader, Column, DataTable, DataTableColumn,
|
||||
Grid, H3, Row, Small, Text,
|
||||
)
|
||||
from prefab_ui.components.charts import PieChart
|
||||
from prefab_ui.components.control_flow import If
|
||||
|
|
@ -129,16 +127,12 @@ mcp = FastMCP("My First App")
|
|||
MEMBERS = [
|
||||
{"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco", "email": "alice@company.com", "projects": 3},
|
||||
{"name": "Bob Martinez", "role": "Lead Designer", "office": "New York", "email": "bob@company.com", "projects": 5},
|
||||
{"name": "Carol Johnson", "role": "Senior Engineer", "office": "London", "email": "carol@company.com", "projects": 2},
|
||||
{"name": "David Kim", "role": "Product Manager", "office": "San Francisco", "email": "david@company.com", "projects": 7},
|
||||
{"name": "Eva Mueller", "role": "Engineer", "office": "Berlin", "email": "eva@company.com", "projects": 1},
|
||||
{"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco", "email": "frank@company.com", "projects": 4},
|
||||
{"name": "Grace Park", "role": "Engineering Manager", "office": "New York", "email": "grace@company.com", "projects": 6},
|
||||
# ... more members ...
|
||||
]
|
||||
|
||||
OFFICE_COUNTS = [
|
||||
{"office": office, "count": count}
|
||||
for office, count in Counter(m["office"] for m in MEMBERS).items()
|
||||
{"office": o, "count": c}
|
||||
for o, c in Counter(m["office"] for m in MEMBERS).items()
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -147,7 +141,6 @@ def team_directory() -> PrefabApp:
|
|||
"""Browse the team directory."""
|
||||
with PrefabApp(state={"selected": None}) as app:
|
||||
with Column(gap=4, css_class="p-6"):
|
||||
Heading("Team Directory")
|
||||
with Grid(columns=[1, 2], gap=4):
|
||||
PieChart(
|
||||
data=OFFICE_COUNTS,
|
||||
|
|
@ -187,22 +180,18 @@ def team_directory() -> PrefabApp:
|
|||
return app
|
||||
```
|
||||
|
||||
Three new ideas here:
|
||||
Three new ideas do all the work:
|
||||
|
||||
**`SetState` + `on_row_click`** is the interaction. When the user clicks a table row, `SetState("selected", Rx("$event"))` writes the clicked row's data into the `selected` state key. `$event` is a special variable that contains the event payload (in this case, the row dict).
|
||||
- **`on_row_click=SetState("selected", Rx("$event"))`** — clicking a row writes its data into the `selected` state key. `$event` is the clicked row dict.
|
||||
- **`Rx("selected.name")`** — a reactive reference. It doesn't hold a Python value; it compiles to a browser-side expression that re-evaluates whenever `selected` changes, so `Text(Rx("selected.name"))` always shows the latest clicked name.
|
||||
- **`If(STATE.selected)`** — conditionally renders its body. Before any click, `selected` is `None` and the card stays hidden.
|
||||
|
||||
**`Rx("selected.name")`** reads from state reactively. It doesn't hold a Python value. It compiles to a browser-side expression that re-evaluates live whenever `selected` changes. So `Text(Rx("selected.name"))` always shows the name of whoever was last clicked.
|
||||
The `state={"selected": None}` dict on `PrefabApp` sets the initial value. Everything else happens in the browser — no round-trips to your server when the user clicks.
|
||||
|
||||
**`If(STATE.selected)`** conditionally renders the detail card only when something has been selected. Before any click, `selected` is `None` and the card is hidden.
|
||||
## Where to go next
|
||||
|
||||
The `state` dict on `PrefabApp` sets initial values when the app loads. Run `fastmcp dev apps server.py` again and try clicking a row.
|
||||
You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization, return it, and the user gets it rendered right in the conversation.
|
||||
|
||||
## Next Steps
|
||||
|
||||
You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization in Prefab, return it from a tool, and the user gets dashboards, charts, data tables, and status displays right in the conversation.
|
||||
|
||||
When you need the UI to talk back to your server (forms that save data, buttons that trigger actions, search that queries a database) you promote the tool to a **[FastMCPApp](/apps/interactive-apps)**. That gives you managed backend tools, automatic visibility control, and stable routing so your UI's button clicks reach the right server-side code.
|
||||
|
||||
- **[Prefab UI](/apps/prefab)** covers the full component library: charts, forms, badges, progress bars, and the [reactive state system](https://prefab.prefect.io/docs/concepts/state) in depth.
|
||||
- **[FastMCPApp](/apps/interactive-apps)** is the next step when your UI needs to interact with backend logic.
|
||||
- **[App Providers](/apps/providers/approval)** are ready-made capabilities you can add with a single `add_provider()` call.
|
||||
- **[Interactive Tools](/apps/prefab)** — charts, tables, dashboards, reactive state, with live demos
|
||||
- **[FastMCPApp](/apps/fastmcp-app)** — when the UI needs to call back to your server (forms, search, CRUD)
|
||||
- **[Examples](/apps/examples)** — complete working servers you can run today
|
||||
|
|
|
|||
|
|
@ -5,6 +5,898 @@ rss: true
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="v3.4.6" description="2026-08-05">
|
||||
|
||||
**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)**
|
||||
|
||||
FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
|
||||
|
||||
### Fixes 🐞
|
||||
* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755)
|
||||
|
||||
### Docs 📚
|
||||
* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761)
|
||||
|
||||
**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v4.0.0b1" description="2026-07-28">
|
||||
|
||||
**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**
|
||||
|
||||
FastMCP 4 makes stateful MCP applications work on the sessionless `2026-07-28` protocol while one deployment continues serving handshake-era clients. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions. Protocol extensions and enterprise identity become first-class surfaces, and most FastMCP 3 servers upgrade unchanged even though MCP Python SDK v2 rewrote the engine underneath them. Server-initiated sampling and roots are removed from the server API; the [upgrade guide](/getting-started/upgrading/from-fastmcp-3) covers their replacements.
|
||||
|
||||
### New Features 🎉
|
||||
* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437)
|
||||
* Teach fastmcp.Client the modern protocol: mode negotiation, MRTR driver, response cache by [@jlowin](https://github.com/jlowin) in [#4450](https://github.com/PrefectHQ/fastmcp/pull/4450)
|
||||
* Forward-port Hugging Face auth provider by [@jlowin](https://github.com/jlowin) in [#4475](https://github.com/PrefectHQ/fastmcp/pull/4475)
|
||||
* Add server-side identity assertion (SEP-990 ID-JAG) by [@jlowin](https://github.com/jlowin) in [#4483](https://github.com/PrefectHQ/fastmcp/pull/4483)
|
||||
* Add guard-mode multi-round-trip tools (SEP-2322) by [@jlowin](https://github.com/jlowin) in [#4544](https://github.com/PrefectHQ/fastmcp/pull/4544)
|
||||
* Add FastMCP-native server extension API (SEP-2133) by [@jlowin](https://github.com/jlowin) in [#4602](https://github.com/PrefectHQ/fastmcp/pull/4602)
|
||||
* Add stateless session state (UserSession / SessionId) by [@jlowin](https://github.com/jlowin) in [#4604](https://github.com/PrefectHQ/fastmcp/pull/4604)
|
||||
* Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663) by [@jlowin](https://github.com/jlowin) in [#4603](https://github.com/PrefectHQ/fastmcp/pull/4603)
|
||||
### Breaking Changes ⚠️
|
||||
* Emit one SERVER span per request and adopt spec-correct error codes by [@jlowin](https://github.com/jlowin) in [#4445](https://github.com/PrefectHQ/fastmcp/pull/4445)
|
||||
* Remove 3.x deprecated module shims and dead parameters by [@jlowin](https://github.com/jlowin) in [#4447](https://github.com/PrefectHQ/fastmcp/pull/4447)
|
||||
* Remove 3.0-deprecated FastMCP server methods by [@jlowin](https://github.com/jlowin) in [#4451](https://github.com/PrefectHQ/fastmcp/pull/4451)
|
||||
* Remove 3.x deprecated parameters and object-mode decorators by [@jlowin](https://github.com/jlowin) in [#4453](https://github.com/PrefectHQ/fastmcp/pull/4453)
|
||||
* Migrate to MCP SDK v2.0.0b2 (httpx2) by [@jlowin](https://github.com/jlowin) in [#4503](https://github.com/PrefectHQ/fastmcp/pull/4503)
|
||||
* Fix typos by [@szepeviktor](https://github.com/szepeviktor) in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
|
||||
* Stop proxies from validating backend results or mutating shared transports by [@jlowin](https://github.com/jlowin) in [#4552](https://github.com/PrefectHQ/fastmcp/pull/4552)
|
||||
* Surface resource, prompt, and proxy errors on the modern protocol by [@jlowin](https://github.com/jlowin) in [#4579](https://github.com/PrefectHQ/fastmcp/pull/4579)
|
||||
* Negotiate the best mutual protocol era by default by [@jlowin](https://github.com/jlowin) in [#4572](https://github.com/PrefectHQ/fastmcp/pull/4572)
|
||||
* Remove server-initiated sampling and roots from the server API by [@jlowin](https://github.com/jlowin) in [#4648](https://github.com/PrefectHQ/fastmcp/pull/4648)
|
||||
* Remove 3.x-era compatibility shims by [@jlowin](https://github.com/jlowin) in [#4661](https://github.com/PrefectHQ/fastmcp/pull/4661)
|
||||
### Enhancements ✨
|
||||
* Deprecate ctx.sample and add clear errors for push features on 2026 connections by [@jlowin](https://github.com/jlowin) in [#4448](https://github.com/PrefectHQ/fastmcp/pull/4448)
|
||||
* Add server-level cache hints (SEP-2549) by [@jlowin](https://github.com/jlowin) in [#4464](https://github.com/PrefectHQ/fastmcp/pull/4464)
|
||||
* Add KeyValueResponseCacheStore for distributed client response caching by [@jlowin](https://github.com/jlowin) in [#4479](https://github.com/PrefectHQ/fastmcp/pull/4479)
|
||||
* Test lifespan fires once per process over HTTP by [@jlowin](https://github.com/jlowin) in [#4480](https://github.com/PrefectHQ/fastmcp/pull/4480)
|
||||
* Add telemetry off-switch and mcp.protocol.version span attribute by [@jlowin](https://github.com/jlowin) in [#4481](https://github.com/PrefectHQ/fastmcp/pull/4481)
|
||||
* Trace client task management requests by [@jlowin](https://github.com/jlowin) in [#4525](https://github.com/PrefectHQ/fastmcp/pull/4525)
|
||||
* Stabilize upgraded ty checks by [@jlowin](https://github.com/jlowin) in [#4526](https://github.com/PrefectHQ/fastmcp/pull/4526)
|
||||
* Improve DescopeProvider scope discovery and well-known URL support by [@gaokevin1](https://github.com/gaokevin1) in [#4489](https://github.com/PrefectHQ/fastmcp/pull/4489)
|
||||
* Add examples/ to the ty static-analysis gate by [@jlowin](https://github.com/jlowin) in [#4466](https://github.com/PrefectHQ/fastmcp/pull/4466)
|
||||
* Expose telemetry attributes on span start by [@zzstoatzz](https://github.com/zzstoatzz) in [#4487](https://github.com/PrefectHQ/fastmcp/pull/4487)
|
||||
* Fix-issue-4284 : Add Auth0MCPProvider for Auth0 Auth for MCP by [@vijaydeepsinha](https://github.com/vijaydeepsinha) in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
|
||||
* Run FastMCP middleware for every inbound message by [@jlowin](https://github.com/jlowin) in [#4553](https://github.com/PrefectHQ/fastmcp/pull/4553)
|
||||
* Add 'prs welcome' label to waive the PR assignment gate by [@jlowin](https://github.com/jlowin) in [#4557](https://github.com/PrefectHQ/fastmcp/pull/4557)
|
||||
* Rename martian workflows to marvin by [@jlowin](https://github.com/jlowin) in [#4558](https://github.com/PrefectHQ/fastmcp/pull/4558)
|
||||
* Bump pinned Claude models to current versions by [@jlowin](https://github.com/jlowin) in [#4561](https://github.com/PrefectHQ/fastmcp/pull/4561)
|
||||
* Make the unit suite fast: in-process HTTP tests, no real sleeps, parallel Windows CI by [@jlowin](https://github.com/jlowin) in [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554)
|
||||
* Mirror the frontend's protocol era on a proxy's backend connection by [@jlowin](https://github.com/jlowin) in [#4573](https://github.com/PrefectHQ/fastmcp/pull/4573)
|
||||
* Drop forked client protocol helpers in favor of the SDK's by [@jlowin](https://github.com/jlowin) in [#4574](https://github.com/PrefectHQ/fastmcp/pull/4574)
|
||||
* Bring the v4 developer notes up to date with what shipped by [@jlowin](https://github.com/jlowin) in [#4581](https://github.com/PrefectHQ/fastmcp/pull/4581)
|
||||
* Trim fastmcp.types to FastMCP-unique types by [@jlowin](https://github.com/jlowin) in [#4584](https://github.com/PrefectHQ/fastmcp/pull/4584)
|
||||
* Let a server answer argument-completion requests by [@jlowin](https://github.com/jlowin) in [#4582](https://github.com/PrefectHQ/fastmcp/pull/4582)
|
||||
* Add machine-to-machine client authentication by [@jlowin](https://github.com/jlowin) in [#4583](https://github.com/PrefectHQ/fastmcp/pull/4583)
|
||||
* Expose era-neutral client server metadata by [@zzstoatzz](https://github.com/zzstoatzz) in [#4599](https://github.com/PrefectHQ/fastmcp/pull/4599)
|
||||
* Support routable transport headers for gateways (SEP-2243) by [@jlowin](https://github.com/jlowin) in [#4622](https://github.com/PrefectHQ/fastmcp/pull/4622)
|
||||
* Emit scope step-up challenges for incremental authorization (SEP-2350) by [@jlowin](https://github.com/jlowin) in [#4623](https://github.com/PrefectHQ/fastmcp/pull/4623)
|
||||
* Honor OAuth application_type in DCR (SEP-837) by [@jlowin](https://github.com/jlowin) in [#4621](https://github.com/PrefectHQ/fastmcp/pull/4621)
|
||||
* Drop stale label-noting instructions from CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#4654](https://github.com/PrefectHQ/fastmcp/pull/4654)
|
||||
* Add require_roles auth check by [@jlowin](https://github.com/jlowin) in [#4656](https://github.com/PrefectHQ/fastmcp/pull/4656)
|
||||
* Add `valid_scopes` parameter to OIDC proxy valid scopes by [@Educg550](https://github.com/Educg550) in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
|
||||
* feat: Add telemetry interop mode for FastMCP by [@strawgate](https://github.com/strawgate) in [#4046](https://github.com/PrefectHQ/fastmcp/pull/4046)
|
||||
* Note that review comment threads should get an acknowledgement by [@jlowin](https://github.com/jlowin) in [#4678](https://github.com/PrefectHQ/fastmcp/pull/4678)
|
||||
* Soften the review-comment reply guidance by [@jlowin](https://github.com/jlowin) in [#4683](https://github.com/PrefectHQ/fastmcp/pull/4683)
|
||||
* Resolve review threads on fix, reply on decline by [@jlowin](https://github.com/jlowin) in [#4685](https://github.com/PrefectHQ/fastmcp/pull/4685)
|
||||
* Move to the stable MCP Python SDK 2.0.0 by [@jlowin](https://github.com/jlowin) in [#4655](https://github.com/PrefectHQ/fastmcp/pull/4655)
|
||||
### Security 🔒
|
||||
* Drive the FastMCP lifespan through the SDK session manager by [@jlowin](https://github.com/jlowin) in [#4446](https://github.com/PrefectHQ/fastmcp/pull/4446)
|
||||
* Route skill file access through SDK path-security primitives by [@jlowin](https://github.com/jlowin) in [#4449](https://github.com/PrefectHQ/fastmcp/pull/4449)
|
||||
* Screen templated resource parameters for path traversal by default by [@jlowin](https://github.com/jlowin) in [#4482](https://github.com/PrefectHQ/fastmcp/pull/4482)
|
||||
* [codex] Add OAuthProxy RFC 9207 issuer responses by [@jlowin](https://github.com/jlowin) in [#4438](https://github.com/PrefectHQ/fastmcp/pull/4438)
|
||||
* Apply app visibility where no host can by [@jlowin](https://github.com/jlowin) in [#4692](https://github.com/PrefectHQ/fastmcp/pull/4692)
|
||||
### Fixes 🐞
|
||||
* Capture SharedContext for task-enabled Docket servers by [@jlowin](https://github.com/jlowin) in [#4443](https://github.com/PrefectHQ/fastmcp/pull/4443)
|
||||
* Fix stale mcp.types imports in examples by [@jlowin](https://github.com/jlowin) in [#4452](https://github.com/PrefectHQ/fastmcp/pull/4452)
|
||||
* Forward-port HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4474](https://github.com/PrefectHQ/fastmcp/pull/4474)
|
||||
* Fix Azure scope fallback by [@zzstoatzz](https://github.com/zzstoatzz) in [#4469](https://github.com/PrefectHQ/fastmcp/pull/4469)
|
||||
* fix(server): omit ScalarElicitationType wrapper title from elicitation schemas by [@syf2211](https://github.com/syf2211) in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
|
||||
* Skip unsupported JWKS keys instead of failing the whole key set (#4515) by [@earfman](https://github.com/earfman) in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
|
||||
* Don't mutate the caller's schema in compress_schema by [@winklemad](https://github.com/winklemad) in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
|
||||
* Forward upstream instructions through create_proxy by [@verdie-g](https://github.com/verdie-g) in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
|
||||
* Serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4523](https://github.com/PrefectHQ/fastmcp/pull/4523)
|
||||
* Reject positional-only tool parameters by [@jlowin](https://github.com/jlowin) in [#4524](https://github.com/PrefectHQ/fastmcp/pull/4524)
|
||||
* Clarify PR-reopen flow and fix label-race that broke auto-reopen by [@jlowin](https://github.com/jlowin) in [#4518](https://github.com/PrefectHQ/fastmcp/pull/4518)
|
||||
* Clean up disconnected task sessions by [@jlowin](https://github.com/jlowin) in [#4519](https://github.com/PrefectHQ/fastmcp/pull/4519)
|
||||
* Handle expired OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#4520](https://github.com/PrefectHQ/fastmcp/pull/4520)
|
||||
* Fix OAuth request annotation after httpx2 migration by [@jlowin](https://github.com/jlowin) in [#4534](https://github.com/PrefectHQ/fastmcp/pull/4534)
|
||||
* Fix docs banner contrast by [@jlowin](https://github.com/jlowin) in [#4522](https://github.com/PrefectHQ/fastmcp/pull/4522)
|
||||
* Preserve component metadata in response cache by [@jlowin](https://github.com/jlowin) in [#4521](https://github.com/PrefectHQ/fastmcp/pull/4521)
|
||||
* Clean up task sessions on connection exit by [@jlowin](https://github.com/jlowin) in [#4535](https://github.com/PrefectHQ/fastmcp/pull/4535)
|
||||
* Include scopes in auth challenges by [@jlowin](https://github.com/jlowin) in [#4527](https://github.com/PrefectHQ/fastmcp/pull/4527)
|
||||
* Make examples/ actually trigger the ty gate by [@jlowin](https://github.com/jlowin) in [#4541](https://github.com/PrefectHQ/fastmcp/pull/4541)
|
||||
* Add subject field to AccessToken initialization by [@piaudonn](https://github.com/piaudonn) in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
|
||||
* Restore Mintlify's fixed banner positioning by [@jlowin](https://github.com/jlowin) in [#4542](https://github.com/PrefectHQ/fastmcp/pull/4542)
|
||||
* Fix #4292: SSRF guard breaks OAuth/JWKS fetches behind a corporate HTTP proxy by [@endofcake](https://github.com/endofcake) in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
|
||||
* Preserve telemetry attributes when a sampler does not forward them by [@jlowin](https://github.com/jlowin) in [#4539](https://github.com/PrefectHQ/fastmcp/pull/4539)
|
||||
* Speed up the unit test suite, and fix the task-notification race it surfaced by [@jlowin](https://github.com/jlowin) in [#4550](https://github.com/PrefectHQ/fastmcp/pull/4550)
|
||||
* Fix label triage applying no labels, and make blocked tool calls fail by [@jlowin](https://github.com/jlowin) in [#4555](https://github.com/PrefectHQ/fastmcp/pull/4555)
|
||||
* Fix AI workflow allowlists being destroyed by tokenization by [@jlowin](https://github.com/jlowin) in [#4560](https://github.com/PrefectHQ/fastmcp/pull/4560)
|
||||
* Make transformed tool `required` order deterministic by [@Kludex](https://github.com/Kludex) in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
|
||||
* Stop gather() from creating coroutines it may never schedule by [@jlowin](https://github.com/jlowin) in [#4559](https://github.com/PrefectHQ/fastmcp/pull/4559)
|
||||
* Restore upgraded dependency checks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4576](https://github.com/PrefectHQ/fastmcp/pull/4576)
|
||||
* Fix skill frontmatter parsing with UTF-8 BOM by [@hxaxd](https://github.com/hxaxd) in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
|
||||
* Fix File helper extension handling by [@VectorPeak](https://github.com/VectorPeak) in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
|
||||
* Fix percent-encoded skill file names unreadable in resources mode by [@jlowin](https://github.com/jlowin) in [#4590](https://github.com/PrefectHQ/fastmcp/pull/4590)
|
||||
* Fix flaky stdio crash-recovery tests by [@jlowin](https://github.com/jlowin) in [#4594](https://github.com/PrefectHQ/fastmcp/pull/4594)
|
||||
* Bridge camelCase ToolAnnotations reads by [@zzstoatzz](https://github.com/zzstoatzz) in [#4597](https://github.com/PrefectHQ/fastmcp/pull/4597)
|
||||
* Preserve raw CallToolResult tool returns by [@LarryHu0217](https://github.com/LarryHu0217) in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
|
||||
* Advertise only supported token endpoint auth methods in OAuthProxy metadata by [@jlowin](https://github.com/jlowin) in [#4608](https://github.com/PrefectHQ/fastmcp/pull/4608)
|
||||
* Fix OAuth proxy override typing by [@zzstoatzz](https://github.com/zzstoatzz) in [#4612](https://github.com/PrefectHQ/fastmcp/pull/4612)
|
||||
* Pin burner-redis below the Windows-crashing 0.1.7 release by [@jlowin](https://github.com/jlowin) in [#4618](https://github.com/PrefectHQ/fastmcp/pull/4618)
|
||||
* fix : canonical mime type mapping from formats to remove inconsistency #4627 by [@Aman071106](https://github.com/Aman071106) in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
|
||||
* fix: accept callable roots handlers by [@ShuyingZhang](https://github.com/ShuyingZhang) in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
|
||||
* Pass the MCP conformance suite's draft and pending scenarios by [@jlowin](https://github.com/jlowin) in [#4650](https://github.com/PrefectHQ/fastmcp/pull/4650)
|
||||
* Use issuer_url for OAuth issuer identity by [@jlowin](https://github.com/jlowin) in [#4652](https://github.com/PrefectHQ/fastmcp/pull/4652)
|
||||
* Fix the ty failure blocking upgrade checks on main by [@jlowin](https://github.com/jlowin) in [#4657](https://github.com/PrefectHQ/fastmcp/pull/4657)
|
||||
* Bind CIMD assertion audience to the advertised token endpoint by [@jlowin](https://github.com/jlowin) in [#4659](https://github.com/PrefectHQ/fastmcp/pull/4659)
|
||||
* Record effective scopes on the OAuth transaction by [@jlowin](https://github.com/jlowin) in [#4670](https://github.com/PrefectHQ/fastmcp/pull/4670)
|
||||
* Copy schemas iteratively so deep nesting still compresses by [@jlowin](https://github.com/jlowin) in [#4671](https://github.com/PrefectHQ/fastmcp/pull/4671)
|
||||
* Fix OpenAPI allOf reference fields by [@hxaxd](https://github.com/hxaxd) in [#4653](https://github.com/PrefectHQ/fastmcp/pull/4653)
|
||||
* Flatten OpenAPI discriminator subtypes into request bodies by [@jlowin](https://github.com/jlowin) in [#4677](https://github.com/PrefectHQ/fastmcp/pull/4677)
|
||||
* Let maintenance releases publish without fastmcp-tasks by [@jlowin](https://github.com/jlowin) in [#4676](https://github.com/PrefectHQ/fastmcp/pull/4676)
|
||||
* Read CLI-scanned MCP config files as UTF-8 explicitly by [@jlowin](https://github.com/jlowin) in [#4690](https://github.com/PrefectHQ/fastmcp/pull/4690)
|
||||
* Late-bind app tool names so UIs survive composition by [@jlowin](https://github.com/jlowin) in [#4682](https://github.com/PrefectHQ/fastmcp/pull/4682)
|
||||
### Docs 📚
|
||||
* Docs: forward-port v3.4.4 changelog entries by [@jlowin](https://github.com/jlowin) in [#4476](https://github.com/PrefectHQ/fastmcp/pull/4476)
|
||||
* Document icon theme support by [@jlowin](https://github.com/jlowin) in [#4537](https://github.com/PrefectHQ/fastmcp/pull/4537)
|
||||
* Add missing 4.0.0 version badge to Path Security docs by [@jlowin](https://github.com/jlowin) in [#4540](https://github.com/PrefectHQ/fastmcp/pull/4540)
|
||||
* Align server component docs by [@strawgate](https://github.com/strawgate) in [#4260](https://github.com/PrefectHQ/fastmcp/pull/4260)
|
||||
* Align CLI, deployment, and config docs by [@strawgate](https://github.com/strawgate) in [#4259](https://github.com/PrefectHQ/fastmcp/pull/4259)
|
||||
* Align client, Apps, and integration docs by [@strawgate](https://github.com/strawgate) in [#4261](https://github.com/PrefectHQ/fastmcp/pull/4261)
|
||||
* Fix stale MRTR/elicitation framing in client and upgrade docs by [@jlowin](https://github.com/jlowin) in [#4551](https://github.com/PrefectHQ/fastmcp/pull/4551)
|
||||
* docs: quote pip extras install examples by [@RachGranville](https://github.com/RachGranville) in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
|
||||
* Document Windows CI parallelism and the subprocess_heavy marker by [@jlowin](https://github.com/jlowin) in [#4575](https://github.com/PrefectHQ/fastmcp/pull/4575)
|
||||
* Document v3->v4 removals and add upgrade-reality tests by [@jlowin](https://github.com/jlowin) in [#4585](https://github.com/PrefectHQ/fastmcp/pull/4585)
|
||||
* Archive v3 docs and publish v4 as the primary version by [@jlowin](https://github.com/jlowin) in [#4613](https://github.com/PrefectHQ/fastmcp/pull/4613)
|
||||
* Document targeted v4 prerelease installation by [@zzstoatzz](https://github.com/zzstoatzz) in [#4598](https://github.com/PrefectHQ/fastmcp/pull/4598)
|
||||
* Fix stale Mac/Windows-vs-Linux OAuth key/storage docs by [@jlowin](https://github.com/jlowin) in [#4617](https://github.com/PrefectHQ/fastmcp/pull/4617)
|
||||
* v4 docs quality pass: stale task/era claims, broken links, polish by [@jlowin](https://github.com/jlowin) in [#4619](https://github.com/PrefectHQ/fastmcp/pull/4619)
|
||||
* whats-new: add the argument completion capability by [@jlowin](https://github.com/jlowin) in [#4620](https://github.com/PrefectHQ/fastmcp/pull/4620)
|
||||
* docs: fix ProxyProvider docstring example calling nonexistent with_namespace() by [@andrew-stelmach-fleet](https://github.com/andrew-stelmach-fleet) in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
|
||||
* Unpublish v4 development notes; prep docs for beta 1 by [@jlowin](https://github.com/jlowin) in [#4644](https://github.com/PrefectHQ/fastmcp/pull/4644)
|
||||
* Expand the FAQ for the v4 transition by [@jlowin](https://github.com/jlowin) in [#4649](https://github.com/PrefectHQ/fastmcp/pull/4649)
|
||||
* Document the issuer_url identity change for upgraders by [@jlowin](https://github.com/jlowin) in [#4658](https://github.com/PrefectHQ/fastmcp/pull/4658)
|
||||
* Cover require_roles in the v4 highlights by [@jlowin](https://github.com/jlowin) in [#4666](https://github.com/PrefectHQ/fastmcp/pull/4666)
|
||||
* Fix FAQ: sampling/roots/elicitation legacy-mode advice, SessionProvider registration by [@jlowin](https://github.com/jlowin) in [#4672](https://github.com/PrefectHQ/fastmcp/pull/4672)
|
||||
* Audit v4 docs: fix missing version badges, fill whats-new gaps by [@jlowin](https://github.com/jlowin) in [#4668](https://github.com/PrefectHQ/fastmcp/pull/4668)
|
||||
* Docs: add v3.4.5 changelog entries to main by [@jlowin](https://github.com/jlowin) in [#4674](https://github.com/PrefectHQ/fastmcp/pull/4674)
|
||||
* Split the SDK upgrade guides by SDK version by [@jlowin](https://github.com/jlowin) in [#4684](https://github.com/PrefectHQ/fastmcp/pull/4684)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump mcp from 1.26.0 to 1.27.2 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4514](https://github.com/PrefectHQ/fastmcp/pull/4514)
|
||||
* chore(deps): bump actions/setup-node from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4546](https://github.com/PrefectHQ/fastmcp/pull/4546)
|
||||
* Bump actions/upload-artifact from 4 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4640](https://github.com/PrefectHQ/fastmcp/pull/4640)
|
||||
* Bump actions/setup-python from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4641](https://github.com/PrefectHQ/fastmcp/pull/4641)
|
||||
* chore(deps): bump mcp from 1.27.2 to 1.28.1 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4614](https://github.com/PrefectHQ/fastmcp/pull/4614)
|
||||
### Other Changes 🦾
|
||||
* Test: HTTP lifespan fires once per process across sessions by [@jlowin](https://github.com/jlowin) in [#4470](https://github.com/PrefectHQ/fastmcp/pull/4470)
|
||||
## New Contributors
|
||||
* @syf2211 made their first contribution in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
|
||||
* @earfman made their first contribution in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
|
||||
* @winklemad made their first contribution in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
|
||||
* @verdie-g made their first contribution in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
|
||||
* @vijaydeepsinha made their first contribution in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
|
||||
* @piaudonn made their first contribution in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
|
||||
* @szepeviktor made their first contribution in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
|
||||
* @endofcake made their first contribution in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
|
||||
* @Kludex made their first contribution in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
|
||||
* @RachGranville made their first contribution in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
|
||||
* @hxaxd made their first contribution in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
|
||||
* @VectorPeak made their first contribution in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
|
||||
* @LarryHu0217 made their first contribution in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
|
||||
* @andrew-stelmach-fleet made their first contribution in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
|
||||
* @Aman071106 made their first contribution in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
|
||||
* @ShuyingZhang made their first contribution in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
|
||||
* @Educg550 made their first contribution in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
|
||||
|
||||
**Full Changelog**: [v3.4.5...v4.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v4.0.0b1)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.5" description="2026-07-27">
|
||||
|
||||
**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)**
|
||||
|
||||
FastMCP 3.4.5 collects five fixes for the 3.x line, led by `JWTVerifier` no longer rejecting every token when an authorization server publishes an unrecognized key type such as Ed25519.
|
||||
|
||||
### Fixes 🐞
|
||||
* Backport #4517 to release/3.x: skip unsupported JWKS keys (#4515) by [@kakiii](https://github.com/kakiii) in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631)
|
||||
* Backport #4469 to release/3.x: fix Azure scope fallback by [@jlowin](https://github.com/jlowin) in [#4662](https://github.com/PrefectHQ/fastmcp/pull/4662)
|
||||
* Backport #4523 to release/3.x: serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4664](https://github.com/PrefectHQ/fastmcp/pull/4664)
|
||||
* Backport #4564 to release/3.x: make transformed tool required order deterministic by [@jlowin](https://github.com/jlowin) in [#4665](https://github.com/PrefectHQ/fastmcp/pull/4665)
|
||||
* Backport #4492 to release/3.x: don't mutate the caller's schema in compress_schema by [@jlowin](https://github.com/jlowin) in [#4663](https://github.com/PrefectHQ/fastmcp/pull/4663)
|
||||
|
||||
## New Contributors
|
||||
* @kakiii made their first contribution in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631)
|
||||
|
||||
**Full Changelog**: [v3.4.4...v3.4.5](https://github.com/PrefectHQ/fastmcp/compare/v3.4.4...v3.4.5)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.4" description="2026-07-08">
|
||||
|
||||
**[v3.4.4: Host in Translation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4)**
|
||||
|
||||
FastMCP 3.4.4 restores HTTP deployment compatibility after the 3.4.3 Host/Origin guard changed default behavior for existing ASGI, serverless, and reverse-proxy deployments. The guard implementation remains available for deployments that opt in with explicit trusted hosts and origins, while 3.x returns to accepting traffic that worked before the patch. This release also adds Hugging Face OAuth provider support, with docs and examples for public and private apps, PKCE, Dynamic Client Registration, and CIMD.
|
||||
|
||||
### Enhancements ✨
|
||||
* Hugging Face Auth Integration by [@evalstate](https://github.com/evalstate) in [#4385](https://github.com/PrefectHQ/fastmcp/pull/4385)
|
||||
### Fixes 🐞
|
||||
* Relax host origin guard defaults by [@jlowin](https://github.com/jlowin) in [#4439](https://github.com/PrefectHQ/fastmcp/pull/4439)
|
||||
* Restore HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4472](https://github.com/PrefectHQ/fastmcp/pull/4472)
|
||||
|
||||
## New Contributors
|
||||
* @evalstate made their first contribution in [#4385](https://github.com/PrefectHQ/fastmcp/pull/4385)
|
||||
|
||||
**Full Changelog**: [v3.4.3...v3.4.4](https://github.com/PrefectHQ/fastmcp/compare/v3.4.3...v3.4.4)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.3" description="2026-07-05">
|
||||
|
||||
**[v3.4.3: The Fast and the Secure-ious](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3)**
|
||||
|
||||
FastMCP 3.4.3 closes out a month of SSRF and OAuth hardening: NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP now validates Host and Origin before session handling to block DNS rebinding against localhost-bound servers, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. Alongside the security work, this release also fixes proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller reliability issues.
|
||||
|
||||
### Enhancements ✨
|
||||
* Dedupe discriminator-required helper across schema converters by [@jlowin](https://github.com/jlowin) in [#4362](https://github.com/PrefectHQ/fastmcp/pull/4362)
|
||||
* Add real Monty sandbox e2e coverage for CodeMode call_tool by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274)
|
||||
* Switch prettier hook to rbubley/mirrors-prettier by [@jlowin](https://github.com/jlowin) in [#4366](https://github.com/PrefectHQ/fastmcp/pull/4366)
|
||||
* feat(remote): add --verify flag for TLS certificate verification by [@jlowin](https://github.com/jlowin) in [#4369](https://github.com/PrefectHQ/fastmcp/pull/4369)
|
||||
### Security 🔒
|
||||
* fix(deps): clear Dependabot security alerts via lockfile bumps by [@jlowin](https://github.com/jlowin) in [#4393](https://github.com/PrefectHQ/fastmcp/pull/4393)
|
||||
* Clarify resource path parameter safety by [@jlowin](https://github.com/jlowin) in [#4398](https://github.com/PrefectHQ/fastmcp/pull/4398)
|
||||
* Fix dev apps launch escaping by [@jlowin](https://github.com/jlowin) in [#4399](https://github.com/PrefectHQ/fastmcp/pull/4399)
|
||||
* Block NAT64 SSRF bypass by [@jlowin](https://github.com/jlowin) in [#4400](https://github.com/PrefectHQ/fastmcp/pull/4400)
|
||||
* [codex] Fix event store replay isolation by [@jlowin](https://github.com/jlowin) in [#4402](https://github.com/PrefectHQ/fastmcp/pull/4402)
|
||||
* Fix DCR redirect URI validation by [@jlowin](https://github.com/jlowin) in [#4408](https://github.com/PrefectHQ/fastmcp/pull/4408)
|
||||
* Protect streamable HTTP from DNS rebinding by [@jlowin](https://github.com/jlowin) in [#4405](https://github.com/PrefectHQ/fastmcp/pull/4405)
|
||||
* Block unsafe OAuth redirect schemes by [@jlowin](https://github.com/jlowin) in [#4419](https://github.com/PrefectHQ/fastmcp/pull/4419)
|
||||
* Block IPv6 transition SSRF bypasses by [@jlowin](https://github.com/jlowin) in [#4426](https://github.com/PrefectHQ/fastmcp/pull/4426)
|
||||
### Fixes 🐞
|
||||
* fix: caching middleware TypeError on cache miss due to mismatched call_next parameter by [@gmenziesint](https://github.com/gmenziesint) in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301)
|
||||
* Fix: async rate limiting middleware get_client_id callbacks by [@Chotom](https://github.com/Chotom) in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319)
|
||||
* Recognize all GitHub issue-link forms in require-issue-link workflow by [@jlowin](https://github.com/jlowin) in [#4359](https://github.com/PrefectHQ/fastmcp/pull/4359)
|
||||
* fix: preserve required discriminator tags by [@he-yufeng](https://github.com/he-yufeng) in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297)
|
||||
* fix(proxy): shield stateful proxy disconnect during session teardown by [@jlowin](https://github.com/jlowin) in [#4363](https://github.com/PrefectHQ/fastmcp/pull/4363)
|
||||
* fix(fs): isolate same-named package imports across providers by [@jlowin](https://github.com/jlowin) in [#4361](https://github.com/PrefectHQ/fastmcp/pull/4361)
|
||||
* fix: StatefulProxyClient.clear() no longer causes KeyError on session teardown by [@tcconnally](https://github.com/tcconnally) in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328)
|
||||
* fix: guard recursive refs in json_schema_to_type by [@Epochex](https://github.com/Epochex) in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312)
|
||||
* Forward IdP auth errors to MCP client instead of showing HTML error page by [@bobbyjames839](https://github.com/bobbyjames839) in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293)
|
||||
* fix(resources): round-trip path values with reserved characters in URI templates by [@jlowin](https://github.com/jlowin) in [#4368](https://github.com/PrefectHQ/fastmcp/pull/4368)
|
||||
* fix: bracket IPv6 hosts in server startup log URL by [@jlowin](https://github.com/jlowin) in [#4372](https://github.com/PrefectHQ/fastmcp/pull/4372)
|
||||
* fix: bound default OIDC discovery timeout and expose it on provider wrappers by [@jlowin](https://github.com/jlowin) in [#4374](https://github.com/PrefectHQ/fastmcp/pull/4374)
|
||||
* fix: validate task tool arguments against declared types by [@jlowin](https://github.com/jlowin) in [#4373](https://github.com/PrefectHQ/fastmcp/pull/4373)
|
||||
* fix(tools): honor serialize_by_alias in tool result serialization by [@jlowin](https://github.com/jlowin) in [#4391](https://github.com/PrefectHQ/fastmcp/pull/4391)
|
||||
* Fix/cimd flow issue by [@twjackysu](https://github.com/twjackysu) in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206)
|
||||
* Reject empty env var keys by [@CodingFeng101](https://github.com/CodingFeng101) in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410)
|
||||
* fix: correct replace_type docstring parameter descriptions by [@hiSandog](https://github.com/hiSandog) in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375)
|
||||
* Fix ty 0.0.55 diagnostics and prefab-ui protocol version drift by [@jlowin](https://github.com/jlowin) in [#4428](https://github.com/PrefectHQ/fastmcp/pull/4428)
|
||||
* [codex] Fix OpenAPI resource template requests by [@jlowin](https://github.com/jlowin) in [#4407](https://github.com/PrefectHQ/fastmcp/pull/4407)
|
||||
### Docs 📚
|
||||
* fix: RST docstrings in fastmcp.types render raw on gofastmcp.com by [@jlowin](https://github.com/jlowin) in [#4367](https://github.com/PrefectHQ/fastmcp/pull/4367)
|
||||
* docs: fix 5 broken internal links (auth & providers pages) by [@Michael-WhiteCapData](https://github.com/Michael-WhiteCapData) in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344)
|
||||
* docs: add audit/event-record recipe for tool-call middleware by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4345](https://github.com/PrefectHQ/fastmcp/pull/4345)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump actions/checkout from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4343](https://github.com/PrefectHQ/fastmcp/pull/4343)
|
||||
* chore(deps): bump joserfc from 1.6.5 to 1.6.7 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4394](https://github.com/PrefectHQ/fastmcp/pull/4394)
|
||||
* chore(deps): bump joserfc from 1.6.7 to 1.6.8 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4429](https://github.com/PrefectHQ/fastmcp/pull/4429)
|
||||
### Other Changes 🦾
|
||||
* Raise fastmcp.ValidationError for invalid tool arguments by [@jlowin](https://github.com/jlowin) in [#4392](https://github.com/PrefectHQ/fastmcp/pull/4392)
|
||||
* Fix versioned auth middleware checks by [@jlowin](https://github.com/jlowin) in [#4401](https://github.com/PrefectHQ/fastmcp/pull/4401)
|
||||
|
||||
## New Contributors
|
||||
* @gmenziesint made their first contribution in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301)
|
||||
* @Chotom made their first contribution in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319)
|
||||
* @he-yufeng made their first contribution in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297)
|
||||
* @AlexlaGuardia made their first contribution in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274)
|
||||
* @tcconnally made their first contribution in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328)
|
||||
* @Epochex made their first contribution in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312)
|
||||
* @Michael-WhiteCapData made their first contribution in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344)
|
||||
* @bobbyjames839 made their first contribution in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293)
|
||||
* @twjackysu made their first contribution in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206)
|
||||
* @CodingFeng101 made their first contribution in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410)
|
||||
* @hiSandog made their first contribution in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375)
|
||||
|
||||
**Full Changelog**: [v3.4.2...v3.4.3](https://github.com/PrefectHQ/fastmcp/compare/v3.4.2...v3.4.3)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.2" description="2026-06-06">
|
||||
|
||||
**[v3.4.2: Heads Up](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2)**
|
||||
|
||||
FastMCP 3.4.2 restores JWT compatibility for providers that include private, non-critical JWS header parameters. Tokens from providers like Clerk can carry header metadata such as `cat` without being rejected before signature and claim validation, while unsupported critical headers are still rejected.
|
||||
|
||||
### Fixes 🐞
|
||||
* Allow private JWT headers by [@jlowin](https://github.com/jlowin) in [#4290](https://github.com/PrefectHQ/fastmcp/pull/4290)
|
||||
### Docs 📚
|
||||
* Docs: add v3.4.1 changelog entries by [@jlowin](https://github.com/jlowin) in [#4289](https://github.com/PrefectHQ/fastmcp/pull/4289)
|
||||
|
||||
**Full Changelog**: [v3.4.1...v3.4.2](https://github.com/PrefectHQ/fastmcp/compare/v3.4.1...v3.4.2)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.1" description="2026-06-05">
|
||||
|
||||
**[v3.4.1: Floor It](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.1)**
|
||||
|
||||
FastMCP 3.4.1 floors Starlette at `>=1.0.1` so installs can no longer resolve to a version affected by CVE-2026-48710, which was previously only constrained transitively through `mcp`. It also makes OAuthProxy log refresh-token cache misses instead of failing silently.
|
||||
|
||||
### Enhancements ✨
|
||||
* Log refresh-token misses in OAuthProxy instead of failing silently by [@jlowin](https://github.com/jlowin) in [#4276](https://github.com/PrefectHQ/fastmcp/pull/4276)
|
||||
### Security 🔒
|
||||
* Add explicit starlette>=1.0.1 floor (CVE-2026-48710) by [@jlowin](https://github.com/jlowin) in [#4286](https://github.com/PrefectHQ/fastmcp/pull/4286)
|
||||
### Docs 📚
|
||||
* Document --notes-start-tag in release instructions by [@jlowin](https://github.com/jlowin) in [#4275](https://github.com/PrefectHQ/fastmcp/pull/4275)
|
||||
|
||||
**Full Changelog**: [v3.4.0...v3.4.1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.0...v3.4.1)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.0" description="2026-06-02">
|
||||
|
||||
**[v3.4.0: Remote Control](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.0)**
|
||||
|
||||
FastMCP 3.4 is about reaching servers that live somewhere else. The headline is `fastmcp-remote`, a standalone bridge that connects stdio-only MCP hosts to servers hosted over HTTP. Around it, the proxy layer those connections depend on is hardened: a proxy now forwards `initialize` upstream and fails loudly when the backend is missing or misconfigured, instead of reporting a connected-but-empty proxy. And FastMCP-issued access tokens can now outlive short-lived upstream tokens, so authenticated sessions survive the long idle periods remote clients are prone to.
|
||||
|
||||
### New Features 🎉
|
||||
* Add fastmcp-remote bridge package by [@jlowin](https://github.com/jlowin) in [#4208](https://github.com/PrefectHQ/fastmcp/pull/4208)
|
||||
### Breaking Changes ⚠️
|
||||
* Forward proxy initialize as bridge behavior by [@jlowin](https://github.com/jlowin) in [#4228](https://github.com/PrefectHQ/fastmcp/pull/4228)
|
||||
### Enhancements ✨
|
||||
* ci: require external PRs to link a tracked issue by [@strawgate](https://github.com/strawgate) in [#4173](https://github.com/PrefectHQ/fastmcp/pull/4173)
|
||||
* feat: new options --host and --no-log-panel | --log-panel to cli dev apps by [@itaru2622](https://github.com/itaru2622) in [#4123](https://github.com/PrefectHQ/fastmcp/pull/4123)
|
||||
* Add valid_scopes and extra_authorize_params to WorkOSProvider by [@tiagoskaneta](https://github.com/tiagoskaneta) in [#4135](https://github.com/PrefectHQ/fastmcp/pull/4135)
|
||||
* Add token_expiry_threshold_seconds for proactive token refresh by [@mohankumarelec](https://github.com/mohankumarelec) in [#4142](https://github.com/PrefectHQ/fastmcp/pull/4142)
|
||||
* Add review-issue skill for triaging gated external contributions by [@jlowin](https://github.com/jlowin) in [#4212](https://github.com/PrefectHQ/fastmcp/pull/4212)
|
||||
* Add contract gate to review-issue skill by [@jlowin](https://github.com/jlowin) in [#4214](https://github.com/PrefectHQ/fastmcp/pull/4214)
|
||||
* Let ToolResult return an error result via is_error by [@jlowin](https://github.com/jlowin) in [#4217](https://github.com/PrefectHQ/fastmcp/pull/4217)
|
||||
* Update published docs after PyPI release by [@jlowin](https://github.com/jlowin) in [#4211](https://github.com/PrefectHQ/fastmcp/pull/4211)
|
||||
* Allow pre-bound HTTP sockets by [@jlowin](https://github.com/jlowin) in [#4222](https://github.com/PrefectHQ/fastmcp/pull/4222)
|
||||
* Add targeted coverage tests by [@strawgate](https://github.com/strawgate) in [#4230](https://github.com/PrefectHQ/fastmcp/pull/4230)
|
||||
* Upgrade ty to 0.0.39 by [@jlowin](https://github.com/jlowin) in [#4225](https://github.com/PrefectHQ/fastmcp/pull/4225)
|
||||
* Decouple FastMCP access token lifetime from upstream expires_in by [@jlowin](https://github.com/jlowin) in [#4254](https://github.com/PrefectHQ/fastmcp/pull/4254)
|
||||
### Security 🔒
|
||||
* feat(code-mode): default sandbox limits and per-execution tool-call cap by [@strawgate](https://github.com/strawgate) in [#4170](https://github.com/PrefectHQ/fastmcp/pull/4170)
|
||||
* Security: Fix 3 findings in GitHub Actions workflows by [@jpr5](https://github.com/jpr5) in [#4183](https://github.com/PrefectHQ/fastmcp/pull/4183)
|
||||
* Add outbound comment guardrails by [@jlowin](https://github.com/jlowin) in [#4196](https://github.com/PrefectHQ/fastmcp/pull/4196)
|
||||
* Add uv dependency cooldown by [@jlowin](https://github.com/jlowin) in [#4213](https://github.com/PrefectHQ/fastmcp/pull/4213)
|
||||
### Fixes 🐞
|
||||
* fix: VersionSpec eq matching normalizes versions and selects deterministically by [@strawgate](https://github.com/strawgate) in [#4058](https://github.com/PrefectHQ/fastmcp/pull/4058)
|
||||
* fix(tests): hoist azure-identity import out of the OBO test timeout window by [@strawgate](https://github.com/strawgate) in [#4176](https://github.com/PrefectHQ/fastmcp/pull/4176)
|
||||
* fix(auth): disambiguate auth-denied vs missing component messages by [@strawgate](https://github.com/strawgate) in [#4165](https://github.com/PrefectHQ/fastmcp/pull/4165)
|
||||
* fix: preserve annotations, meta, title, icons when creating resources from templates by [@strawgate](https://github.com/strawgate) in [#4061](https://github.com/PrefectHQ/fastmcp/pull/4061)
|
||||
* fix: add OTEL spans to sampling step and tool execution by [@strawgate](https://github.com/strawgate) in [#4059](https://github.com/PrefectHQ/fastmcp/pull/4059)
|
||||
* fix(config): read MCP config files as UTF-8 by [@pragnyanramtha](https://github.com/pragnyanramtha) in [#4164](https://github.com/PrefectHQ/fastmcp/pull/4164)
|
||||
* fix(schema): preserve root metadata on fallback by [@yuyua9](https://github.com/yuyua9) in [#4178](https://github.com/PrefectHQ/fastmcp/pull/4178)
|
||||
* fix(proxy): restore _current_server in _restore_request_context by [@strawgate](https://github.com/strawgate) in [#4168](https://github.com/PrefectHQ/fastmcp/pull/4168)
|
||||
* fix(auth): add /.well-known/openid-configuration alias for OAuth server metadata by [@shigechika](https://github.com/shigechika) in [#4167](https://github.com/PrefectHQ/fastmcp/pull/4167)
|
||||
* fix(code-mode): cancel Monty sandbox future on task cancellation by [@strawgate](https://github.com/strawgate) in [#4169](https://github.com/PrefectHQ/fastmcp/pull/4169)
|
||||
* fix(auth): unprefix Azure scopes echoed back to MCP clients by [@rgillinlz](https://github.com/rgillinlz) in [#4130](https://github.com/PrefectHQ/fastmcp/pull/4130)
|
||||
* fix(cli): forward stateless flag in uv run path by [@yuyua9](https://github.com/yuyua9) in [#4177](https://github.com/PrefectHQ/fastmcp/pull/4177)
|
||||
* fix(ci): scope minimize-reviews concurrency by event name by [@strawgate](https://github.com/strawgate) in [#4174](https://github.com/PrefectHQ/fastmcp/pull/4174)
|
||||
* Fix docs app demo iframe assets by [@jlowin](https://github.com/jlowin) in [#4194](https://github.com/PrefectHQ/fastmcp/pull/4194)
|
||||
* Guard require-issue-link check job to pull_request_target events by [@jlowin](https://github.com/jlowin) in [#4209](https://github.com/PrefectHQ/fastmcp/pull/4209)
|
||||
* Migrate auth JWTs to joserfc by [@jlowin](https://github.com/jlowin) in [#4221](https://github.com/PrefectHQ/fastmcp/pull/4221)
|
||||
* Skip published docs update for prereleases by [@jlowin](https://github.com/jlowin) in [#4224](https://github.com/PrefectHQ/fastmcp/pull/4224)
|
||||
* Surface proxy upstream failures by [@jlowin](https://github.com/jlowin) in [#4227](https://github.com/PrefectHQ/fastmcp/pull/4227)
|
||||
* Close upstream OAuth clients by [@jlowin](https://github.com/jlowin) in [#4248](https://github.com/PrefectHQ/fastmcp/pull/4248)
|
||||
* Fix GitHub MCP resource integration test by [@jlowin](https://github.com/jlowin) in [#4253](https://github.com/PrefectHQ/fastmcp/pull/4253)
|
||||
* Fix resource templates with query params on proxied servers by [@rene84](https://github.com/rene84) in [#4251](https://github.com/PrefectHQ/fastmcp/pull/4251)
|
||||
### Docs 📚
|
||||
* Document pip upgrade recovery for the fastmcp-slim package split by [@jlowin](https://github.com/jlowin) in [#4215](https://github.com/PrefectHQ/fastmcp/pull/4215)
|
||||
* Move pip upgrade recovery into a Troubleshooting section by [@jlowin](https://github.com/jlowin) in [#4219](https://github.com/PrefectHQ/fastmcp/pull/4219)
|
||||
* Restore Horizon docs banner by [@jlowin](https://github.com/jlowin) in [#4240](https://github.com/PrefectHQ/fastmcp/pull/4240)
|
||||
* fix: Trendshift link and badge in README.md by [@bhantos](https://github.com/bhantos) in [#4236](https://github.com/PrefectHQ/fastmcp/pull/4236)
|
||||
* docs: add tool fingerprinting recipe by [@dgenio](https://github.com/dgenio) in [#4233](https://github.com/PrefectHQ/fastmcp/pull/4233)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#4113](https://github.com/PrefectHQ/fastmcp/pull/4113)
|
||||
* chore(deps-dev): bump pydantic-monty from 0.0.16 to 0.0.17 by [@dependabot](https://github.com/dependabot) in [#4023](https://github.com/PrefectHQ/fastmcp/pull/4023)
|
||||
### Other Changes 🦾
|
||||
* Exempt maintainers from MRE auto-close by [@jlowin](https://github.com/jlowin) in [#4220](https://github.com/PrefectHQ/fastmcp/pull/4220)
|
||||
|
||||
## New Contributors
|
||||
* @pragnyanramtha made their first contribution in [#4164](https://github.com/PrefectHQ/fastmcp/pull/4164)
|
||||
* @yuyua9 made their first contribution in [#4178](https://github.com/PrefectHQ/fastmcp/pull/4178)
|
||||
* @tiagoskaneta made their first contribution in [#4135](https://github.com/PrefectHQ/fastmcp/pull/4135)
|
||||
* @mohankumarelec made their first contribution in [#4142](https://github.com/PrefectHQ/fastmcp/pull/4142)
|
||||
* @rgillinlz made their first contribution in [#4130](https://github.com/PrefectHQ/fastmcp/pull/4130)
|
||||
* @jpr5 made their first contribution in [#4183](https://github.com/PrefectHQ/fastmcp/pull/4183)
|
||||
* @bhantos made their first contribution in [#4236](https://github.com/PrefectHQ/fastmcp/pull/4236)
|
||||
* @rene84 made their first contribution in [#4251](https://github.com/PrefectHQ/fastmcp/pull/4251)
|
||||
|
||||
**Full Changelog**: [v3.3.1...v3.4.0](https://github.com/PrefectHQ/fastmcp/compare/v3.3.1...v3.4.0)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.3.1" description="2026-05-15">
|
||||
|
||||
**[v3.3.1: Loop There It Is](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.1)**
|
||||
|
||||
A hotfix for the 3.3 packaging split. Clean installs could fail on standalone component imports like `from fastmcp.tools import tool`, because component modules reached auth and task primitives through `fastmcp.server` and pulled in the full server/provider stack. Those primitives now live in lightweight utility modules, with the old server import paths preserved as compatibility re-exports.
|
||||
|
||||
### Fixes 🐞
|
||||
* fix(docs): use valid FA icon on client-only package page by [@jlowin](https://github.com/jlowin) in [#4139](https://github.com/PrefectHQ/fastmcp/pull/4139)
|
||||
* Decouple component imports from server by [@jlowin](https://github.com/jlowin) in [#4150](https://github.com/PrefectHQ/fastmcp/pull/4150)
|
||||
|
||||
|
||||
**Full Changelog**: [v3.3.0...v3.3.1](https://github.com/PrefectHQ/fastmcp/compare/v3.3.0...v3.3.1)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.3.0" description="2026-05-15">
|
||||
|
||||
**[v3.3.0: Slim Reaper](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.0)**
|
||||
|
||||
FastMCP 3.3 ships `fastmcp-slim`, a dependency-light distribution that separates the client from the server stack — install FastMCP's client and transport layer without Starlette, Uvicorn, or the rest of the server machinery. The import namespace is unchanged. It also closes out a backlog of OAuth proxy security hardening, MCP-compliant OTEL instrumentation, and auth additions that accumulated through the 3.2 cycle.
|
||||
|
||||
### New Features 🎉
|
||||
* Add fastmcp-slim for client-only installs by [@jlowin](https://github.com/jlowin) in [#4122](https://github.com/PrefectHQ/fastmcp/pull/4122)
|
||||
### Enhancements ✨
|
||||
* Add default prefill to FormInput.collect_input by [@jlowin](https://github.com/jlowin) in [#3937](https://github.com/PrefectHQ/fastmcp/pull/3937)
|
||||
* OTEL: Fix attribute compliance with MCP semantic conventions by [@strawgate](https://github.com/strawgate) in [#3889](https://github.com/PrefectHQ/fastmcp/pull/3889)
|
||||
* OTEL: Instrument all MCP list operations and enrich delegate spans by [@strawgate](https://github.com/strawgate) in [#3890](https://github.com/PrefectHQ/fastmcp/pull/3890)
|
||||
* Improve real-world schema crash test: failure dump, cluster analysis, TypeErrors baseline ratchet by [@jlowin](https://github.com/jlowin) in [#3958](https://github.com/PrefectHQ/fastmcp/pull/3958)
|
||||
* feat: add AzureB2CProvider for Azure AD B2C user flows by [@carlos-rian](https://github.com/carlos-rian) in [#3995](https://github.com/PrefectHQ/fastmcp/pull/3995)
|
||||
* Add run_in_thread opt-out for sync tools with thread affinity by [@jlowin](https://github.com/jlowin) in [#4010](https://github.com/PrefectHQ/fastmcp/pull/4010)
|
||||
* Add missing return type annotation to __getattr__ by [@ZLeventer](https://github.com/ZLeventer) in [#4026](https://github.com/PrefectHQ/fastmcp/pull/4026)
|
||||
* Add experimental_capabilities kwarg to FastMCP constructor by [@jlowin](https://github.com/jlowin) in [#4042](https://github.com/PrefectHQ/fastmcp/pull/4042)
|
||||
* Add log_level parameter to FastMCP errors by [@daniel-tsiang](https://github.com/daniel-tsiang) in [#4036](https://github.com/PrefectHQ/fastmcp/pull/4036)
|
||||
* Bump pydocket to 0.20.0 by [@chrisguidry](https://github.com/chrisguidry) in [#4031](https://github.com/PrefectHQ/fastmcp/pull/4031)
|
||||
* enh: Add public API for updating OAuthProxy scopes after initialization by [@taylorwilsdon](https://github.com/taylorwilsdon) in [#4091](https://github.com/PrefectHQ/fastmcp/pull/4091)
|
||||
* Refine fastmcp-slim packaging by [@jlowin](https://github.com/jlowin) in [#4125](https://github.com/PrefectHQ/fastmcp/pull/4125)
|
||||
### Security 🔒
|
||||
* Harden OAuth Proxy silent consent against AS-in-the-middle by [@jlowin](https://github.com/jlowin) in [#3960](https://github.com/PrefectHQ/fastmcp/pull/3960)
|
||||
* Reject dot-segments in redirect URI allowlist matching by [@jlowin](https://github.com/jlowin) in [#3963](https://github.com/PrefectHQ/fastmcp/pull/3963)
|
||||
* Bump deps with open dependabot alerts by [@jlowin](https://github.com/jlowin) in [#3965](https://github.com/PrefectHQ/fastmcp/pull/3965)
|
||||
* Partition ResponseCachingMiddleware cache by access token by [@jlowin](https://github.com/jlowin) in [#4041](https://github.com/PrefectHQ/fastmcp/pull/4041)
|
||||
### Fixes 🐞
|
||||
* fix: reject self-mount to prevent infinite recursion by [@strawgate](https://github.com/strawgate) in [#3925](https://github.com/PrefectHQ/fastmcp/pull/3925)
|
||||
* fix: ProxyTool crashes on non-TextContent error responses by [@strawgate](https://github.com/strawgate) in [#3926](https://github.com/PrefectHQ/fastmcp/pull/3926)
|
||||
* fix: _prune_param and _convert_nullable_field mutate input schemas by [@strawgate](https://github.com/strawgate) in [#3927](https://github.com/PrefectHQ/fastmcp/pull/3927)
|
||||
* fix: narrow OpenAI audio format dict to Literal for ty by [@jlowin](https://github.com/jlowin) in [#3936](https://github.com/PrefectHQ/fastmcp/pull/3936)
|
||||
* fix: allow hyphens in resource template parameter names by [@strawgate](https://github.com/strawgate) in [#3929](https://github.com/PrefectHQ/fastmcp/pull/3929)
|
||||
* fix: OpenAPI request director sends multipart and form-urlencoded as JSON by [@strawgate](https://github.com/strawgate) in [#3932](https://github.com/PrefectHQ/fastmcp/pull/3932)
|
||||
* Fix raise_on_error handling for tool tasks by [@gnanirahulnutakki](https://github.com/gnanirahulnutakki) in [#3946](https://github.com/PrefectHQ/fastmcp/pull/3946)
|
||||
* fix: FileSystemProvider reload race condition by [@strawgate](https://github.com/strawgate) in [#3938](https://github.com/PrefectHQ/fastmcp/pull/3938)
|
||||
* fix tests that relied on task=True returning error results by [@jlowin](https://github.com/jlowin) in [#3954](https://github.com/PrefectHQ/fastmcp/pull/3954)
|
||||
* Restore task snapshot via a worker-level dependency by [@chrisguidry](https://github.com/chrisguidry) in [#3945](https://github.com/PrefectHQ/fastmcp/pull/3945)
|
||||
* Forward backend capabilities in ProxyProvider by [@jlowin](https://github.com/jlowin) in [#3956](https://github.com/PrefectHQ/fastmcp/pull/3956)
|
||||
* Allow upstream client_id to be used directly without DCR by [@jlowin](https://github.com/jlowin) in [#3957](https://github.com/PrefectHQ/fastmcp/pull/3957)
|
||||
* Graceful fallback for unsupported regex patterns in json_schema_to_type by [@jlowin](https://github.com/jlowin) in [#3959](https://github.com/PrefectHQ/fastmcp/pull/3959)
|
||||
* Revert "Forward backend capabilities in ProxyProvider (#3956)" by [@jlowin](https://github.com/jlowin) in [#3964](https://github.com/PrefectHQ/fastmcp/pull/3964)
|
||||
* fix: skip stdio subprocess test on Windows CI by [@jlowin](https://github.com/jlowin) in [#3966](https://github.com/PrefectHQ/fastmcp/pull/3966)
|
||||
* fix: bound _refresh_locks with LRU eviction to prevent memory leak by [@jlowin](https://github.com/jlowin) in [#3968](https://github.com/PrefectHQ/fastmcp/pull/3968)
|
||||
* fix: handle circular JSON Pointer $ref in dereference_refs by [@lawrence3699](https://github.com/lawrence3699) in [#3896](https://github.com/PrefectHQ/fastmcp/pull/3896)
|
||||
* fix: honor upstream refresh token expiry in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3990](https://github.com/PrefectHQ/fastmcp/pull/3990)
|
||||
* fix: narrow _token_validator with isinstance for ty in AzureProvider.from_b2c by [@jlowin](https://github.com/jlowin) in [#4007](https://github.com/PrefectHQ/fastmcp/pull/4007)
|
||||
* fix: cancel orphaned session_task when Client._disconnect times out by [@jlowin](https://github.com/jlowin) in [#4011](https://github.com/PrefectHQ/fastmcp/pull/4011)
|
||||
* fix: preserve @tool metadata in from_function by [@lawrence3699](https://github.com/lawrence3699) in [#4072](https://github.com/PrefectHQ/fastmcp/pull/4072)
|
||||
* fix(openapi): keep blank values in parse_qs (refs #4056) by [@MukundaKatta](https://github.com/MukundaKatta) in [#4076](https://github.com/PrefectHQ/fastmcp/pull/4076)
|
||||
* Fix #4056: keep blank query values, add token bucket regression test by [@MukundaKatta](https://github.com/MukundaKatta) in [#4069](https://github.com/PrefectHQ/fastmcp/pull/4069)
|
||||
* fix(ping): exit ping loop cleanly when session stream is closed by [@ashwin153](https://github.com/ashwin153) in [#4087](https://github.com/PrefectHQ/fastmcp/pull/4087)
|
||||
* Fix sampling from background tasks by [@cuyua9](https://github.com/cuyua9) in [#4068](https://github.com/PrefectHQ/fastmcp/pull/4068)
|
||||
* Make Docket reentrant; mounted servers enter their own lifespan by [@jlowin](https://github.com/jlowin) in [#4095](https://github.com/PrefectHQ/fastmcp/pull/4095)
|
||||
* fix(tool_transform): hoist $defs to schema root when ArgTransform introduces them by [@SarthakB11](https://github.com/SarthakB11) in [#4101](https://github.com/PrefectHQ/fastmcp/pull/4101)
|
||||
* fix(auth): silence authlib.jose DeprecationWarning at JWT import by [@SarthakB11](https://github.com/SarthakB11) in [#4100](https://github.com/PrefectHQ/fastmcp/pull/4100)
|
||||
* fix: don't cache import map in dev apps bundle by [@jlowin](https://github.com/jlowin) in [#4106](https://github.com/PrefectHQ/fastmcp/pull/4106)
|
||||
* #4084 [Issues] Windows startup crash due to UnicodeDecodeError when l… by [@doneman536](https://github.com/doneman536) in [#4092](https://github.com/PrefectHQ/fastmcp/pull/4092)
|
||||
* fix: drop exc_info for expected tool failures, remove unreachable ValidationError by [@sergeykad](https://github.com/sergeykad) in [#4029](https://github.com/PrefectHQ/fastmcp/pull/4029)
|
||||
* fix: cli option --no-banner is NOT passed to cli but server-spec in-correctly when cli --reload option is specified. by [@itaru2622](https://github.com/itaru2622) in [#4083](https://github.com/PrefectHQ/fastmcp/pull/4083)
|
||||
* Fix None backend_* span attributes on un-renamed proxy components by [@ringerc](https://github.com/ringerc) in [#4109](https://github.com/PrefectHQ/fastmcp/pull/4109)
|
||||
* Fix OCI Provider issue in 3.x version. Add OCI auth provider example … by [@kiranthakkar](https://github.com/kiranthakkar) in [#4116](https://github.com/PrefectHQ/fastmcp/pull/4116)
|
||||
* fix(http): terminate active streamable-HTTP transports before lifespan shutdown by [@SarthakB11](https://github.com/SarthakB11) in [#4118](https://github.com/PrefectHQ/fastmcp/pull/4118)
|
||||
### Docs 📚
|
||||
* Restructure docs navigation by [@jlowin](https://github.com/jlowin) in [#3951](https://github.com/PrefectHQ/fastmcp/pull/3951)
|
||||
* docs: standardize ToolAnnotations examples by [@gnanirahulnutakki](https://github.com/gnanirahulnutakki) in [#3952](https://github.com/PrefectHQ/fastmcp/pull/3952)
|
||||
* Be constructively skeptical of bot reviews on own PRs by [@jlowin](https://github.com/jlowin) in [#3971](https://github.com/PrefectHQ/fastmcp/pull/3971)
|
||||
* Add UTM params to Horizon docs links by [@aaazzam](https://github.com/aaazzam) in [#4018](https://github.com/PrefectHQ/fastmcp/pull/4018)
|
||||
* Add a sandboxed-agents deployment guide by [@strawgate](https://github.com/strawgate) in [#4027](https://github.com/PrefectHQ/fastmcp/pull/4027)
|
||||
* docs: add best practices for custom telemetry spans by [@MukundaKatta](https://github.com/MukundaKatta) in [#4001](https://github.com/PrefectHQ/fastmcp/pull/4001)
|
||||
* Refresh landing page copy by [@jlowin](https://github.com/jlowin) in [#4043](https://github.com/PrefectHQ/fastmcp/pull/4043)
|
||||
* Refresh landing page copy by [@jlowin](https://github.com/jlowin) in [#4047](https://github.com/PrefectHQ/fastmcp/pull/4047)
|
||||
* Add UTM tracking to Horizon links by [@jlowin](https://github.com/jlowin) in [#4064](https://github.com/PrefectHQ/fastmcp/pull/4064)
|
||||
* docs(integrations): add Pydantic AI FastMCP toolset guide by [@MukundaKatta](https://github.com/MukundaKatta) in [#4070](https://github.com/PrefectHQ/fastmcp/pull/4070)
|
||||
* docs: fix broken links in Pydantic AI guide by [@jlowin](https://github.com/jlowin) in [#4094](https://github.com/PrefectHQ/fastmcp/pull/4094)
|
||||
### Dependencies 📦
|
||||
* chore(deps-dev): bump pydantic-monty from 0.0.11 to 0.0.12 by [@dependabot](https://github.com/dependabot) in [#3940](https://github.com/PrefectHQ/fastmcp/pull/3940)
|
||||
* chore(deps-dev): bump pydantic-monty from 0.0.14 to 0.0.16 by [@dependabot](https://github.com/dependabot) in [#3984](https://github.com/PrefectHQ/fastmcp/pull/3984)
|
||||
### Other Changes 🦾
|
||||
* fix: Don't completely hide plain mcp.tool app-only tools by [@owtaylor](https://github.com/owtaylor) in [#4112](https://github.com/PrefectHQ/fastmcp/pull/4112)
|
||||
|
||||
## New Contributors
|
||||
* @gnanirahulnutakki made their first contribution in [#3946](https://github.com/PrefectHQ/fastmcp/pull/3946)
|
||||
* @lawrence3699 made their first contribution in [#3896](https://github.com/PrefectHQ/fastmcp/pull/3896)
|
||||
* @carlos-rian made their first contribution in [#3995](https://github.com/PrefectHQ/fastmcp/pull/3995)
|
||||
* @ZLeventer made their first contribution in [#4026](https://github.com/PrefectHQ/fastmcp/pull/4026)
|
||||
* @MukundaKatta made their first contribution in [#4001](https://github.com/PrefectHQ/fastmcp/pull/4001)
|
||||
* @daniel-tsiang made their first contribution in [#4036](https://github.com/PrefectHQ/fastmcp/pull/4036)
|
||||
* @ashwin153 made their first contribution in [#4087](https://github.com/PrefectHQ/fastmcp/pull/4087)
|
||||
* @cuyua9 made their first contribution in [#4068](https://github.com/PrefectHQ/fastmcp/pull/4068)
|
||||
* @taylorwilsdon made their first contribution in [#4091](https://github.com/PrefectHQ/fastmcp/pull/4091)
|
||||
* @SarthakB11 made their first contribution in [#4101](https://github.com/PrefectHQ/fastmcp/pull/4101)
|
||||
* @doneman536 made their first contribution in [#4092](https://github.com/PrefectHQ/fastmcp/pull/4092)
|
||||
* @sergeykad made their first contribution in [#4029](https://github.com/PrefectHQ/fastmcp/pull/4029)
|
||||
* @ringerc made their first contribution in [#4109](https://github.com/PrefectHQ/fastmcp/pull/4109)
|
||||
|
||||
**Full Changelog**: [v3.2.4...v3.3.0](https://github.com/PrefectHQ/fastmcp/compare/v3.2.4...v3.3.0)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.2.4" description="2026-04-14">
|
||||
|
||||
**[v3.2.4: Patch Me If You Can](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.4)**
|
||||
|
||||
A grab bag of fixes, hardening, and polish. The headline behavior change: background tasks are now scoped to the authorization context rather than the MCP session, so a task survives session churn and stays tied to who started it — a breaking change for anyone relying on the old session-scoped semantics. Plus actual-size validation in `FileUpload`, a Keycloak OAuth provider, automatic parameter descriptions from docstrings, and dozens of schema and sampling fixes.
|
||||
|
||||
### Breaking Changes ⚠️
|
||||
* Scope tasks to authorization context, not session by [@chrisguidry](https://github.com/chrisguidry) in [#3800](https://github.com/PrefectHQ/fastmcp/pull/3800)
|
||||
### Enhancements ✨
|
||||
* Bump pydocket>=0.19.0, drop fakeredis pin by [@chrisguidry](https://github.com/chrisguidry) in [#3822](https://github.com/PrefectHQ/fastmcp/pull/3822)
|
||||
* Add real-world schema crash test (232K schemas from APIs.guru) by [@strawgate](https://github.com/strawgate) in [#3826](https://github.com/PrefectHQ/fastmcp/pull/3826)
|
||||
* Enable 7 zero-violation ruff rules by [@strawgate](https://github.com/strawgate) in [#3841](https://github.com/PrefectHQ/fastmcp/pull/3841)
|
||||
* Promote 7 ty rules from ignore to warn by [@strawgate](https://github.com/strawgate) in [#3852](https://github.com/PrefectHQ/fastmcp/pull/3852)
|
||||
* Replace ___ with hash-based backend tool routing and per-tool prefab resources by [@jlowin](https://github.com/jlowin) in [#3824](https://github.com/PrefectHQ/fastmcp/pull/3824)
|
||||
* Enable 4 ruff rules (DTZ, ERA, ISC, INP) and fix 9 violations by [@strawgate](https://github.com/strawgate) in [#3842](https://github.com/PrefectHQ/fastmcp/pull/3842)
|
||||
* Extract parameter descriptions from docstrings by [@jlowin](https://github.com/jlowin) in [#3872](https://github.com/PrefectHQ/fastmcp/pull/3872)
|
||||
* ci: speed up schema crash test (CSafeLoader + xdist-safe aggregation) by [@jlowin](https://github.com/jlowin) in [#3873](https://github.com/PrefectHQ/fastmcp/pull/3873)
|
||||
* test: bump OpenAPI init perf threshold to 200ms for Windows CI by [@jlowin](https://github.com/jlowin) in [#3879](https://github.com/PrefectHQ/fastmcp/pull/3879)
|
||||
* refactor: unify object-schema conversion through _object_schema_to_type by [@jlowin](https://github.com/jlowin) in [#3884](https://github.com/PrefectHQ/fastmcp/pull/3884)
|
||||
* Add Keycloak OAuth Provider for Enterprise Authentication and local dev by [@stephaneberle9](https://github.com/stephaneberle9) in [#1937](https://github.com/PrefectHQ/fastmcp/pull/1937)
|
||||
* Allow auth providers to override protected resource base URLs by [@aaazzam](https://github.com/aaazzam) in [#3900](https://github.com/PrefectHQ/fastmcp/pull/3900)
|
||||
* Enable PERF and T20 ruff rules by [@strawgate](https://github.com/strawgate) in [#3845](https://github.com/PrefectHQ/fastmcp/pull/3845)
|
||||
* Add response_title and response_description to ctx.elicit() by [@jlowin](https://github.com/jlowin) in [#3912](https://github.com/PrefectHQ/fastmcp/pull/3912)
|
||||
* Deprecate ctx.elicit() without response_type by [@jlowin](https://github.com/jlowin) in [#3916](https://github.com/PrefectHQ/fastmcp/pull/3916)
|
||||
### Security 🔒
|
||||
* Validate actual base64 data size in FileUpload, not client-reported size by [@strawgate](https://github.com/strawgate) in [#3816](https://github.com/PrefectHQ/fastmcp/pull/3816)
|
||||
* Stop forwarding inbound HTTP headers to unrelated remote servers by [@jlowin](https://github.com/jlowin) in [#3837](https://github.com/PrefectHQ/fastmcp/pull/3837)
|
||||
* AuthKit: auto-bind token audience to resource URL (RFC 8707) by [@jlowin](https://github.com/jlowin) in [#3905](https://github.com/PrefectHQ/fastmcp/pull/3905)
|
||||
### Fixes 🐞
|
||||
* Version-check is_docket_available() to avoid transitive pydocket crash by [@jlowin](https://github.com/jlowin) in [#3807](https://github.com/PrefectHQ/fastmcp/pull/3807)
|
||||
* fix: materialize generators before result conversion, handle bytes gracefully by [@strawgate](https://github.com/strawgate) in [#3830](https://github.com/PrefectHQ/fastmcp/pull/3830)
|
||||
* Fix json_schema_to_type crashes on keywords, boolean schemas, empty enums, and name collisions by [@strawgate](https://github.com/strawgate) in [#3818](https://github.com/PrefectHQ/fastmcp/pull/3818)
|
||||
* fix: replace `or` with `is not None` checks for config/override merging by [@strawgate](https://github.com/strawgate) in [#3833](https://github.com/PrefectHQ/fastmcp/pull/3833)
|
||||
* fix: TransformedTool sync fn crash and schema mutation by [@strawgate](https://github.com/strawgate) in [#3823](https://github.com/PrefectHQ/fastmcp/pull/3823)
|
||||
* fix: cross-provider duplicate detection, error visibility, mask propagation by [@strawgate](https://github.com/strawgate) in [#3827](https://github.com/PrefectHQ/fastmcp/pull/3827)
|
||||
* fix: don't pass HTTP kwargs when transport is unspecified by [@strawgate](https://github.com/strawgate) in [#3838](https://github.com/PrefectHQ/fastmcp/pull/3838)
|
||||
* fix: strip title fields from tool schemas for Gemini 2.5 Flash compatibility by [@strawgate](https://github.com/strawgate) in [#3861](https://github.com/PrefectHQ/fastmcp/pull/3861)
|
||||
* fix: retry when LLM returns text instead of calling final_response by [@strawgate](https://github.com/strawgate) in [#3850](https://github.com/PrefectHQ/fastmcp/pull/3850)
|
||||
* Raise on unhandled content types in sampling handler dispatch chains by [@strawgate](https://github.com/strawgate) in [#3857](https://github.com/PrefectHQ/fastmcp/pull/3857)
|
||||
* Fix broken code examples in docs by [@strawgate](https://github.com/strawgate) in [#3869](https://github.com/PrefectHQ/fastmcp/pull/3869)
|
||||
* fix: GoogleGenaiSamplingHandler leaks thought parts and gives unhelpful errors on empty responses by [@strawgate](https://github.com/strawgate) in [#3849](https://github.com/PrefectHQ/fastmcp/pull/3849)
|
||||
* fix: cap consecutive final_response validation retries by [@strawgate](https://github.com/strawgate) in [#3851](https://github.com/PrefectHQ/fastmcp/pull/3851)
|
||||
* Fix test quality issues by [@strawgate](https://github.com/strawgate) in [#3854](https://github.com/PrefectHQ/fastmcp/pull/3854)
|
||||
* Fix MCP tool on docs welcome page by [@lkiesow](https://github.com/lkiesow) in [#3874](https://github.com/PrefectHQ/fastmcp/pull/3874)
|
||||
* Fix CIMD clients getting required_scopes instead of valid_scopes by [@jlowin](https://github.com/jlowin) in [#3836](https://github.com/PrefectHQ/fastmcp/pull/3836)
|
||||
* Rename filesystem-provider example dir to avoid mcp/ collision by [@jlowin](https://github.com/jlowin) in [#3878](https://github.com/PrefectHQ/fastmcp/pull/3878)
|
||||
* fix: drop configurable dedupe from AggregateProvider, always warn by [@jlowin](https://github.com/jlowin) in [#3877](https://github.com/PrefectHQ/fastmcp/pull/3877)
|
||||
* fix: resolve list[dict] return type producing Root() instead of dicts by [@KeWang0622](https://github.com/KeWang0622) in [#3880](https://github.com/PrefectHQ/fastmcp/pull/3880)
|
||||
* fix: strip titles from bare-metadata nodes (Gemini 2.5 Flash) by [@jlowin](https://github.com/jlowin) in [#3881](https://github.com/PrefectHQ/fastmcp/pull/3881)
|
||||
* Fix wildcard resource template params in mounted servers by [@jlowin](https://github.com/jlowin) in [#3899](https://github.com/PrefectHQ/fastmcp/pull/3899)
|
||||
* Harden forced client disconnect cleanup by [@vonbai](https://github.com/vonbai) in [#3885](https://github.com/PrefectHQ/fastmcp/pull/3885)
|
||||
* fix: elicitation scalar return, resource auto-serialization, Client.new() state, prompt errors by [@strawgate](https://github.com/strawgate) in [#3859](https://github.com/PrefectHQ/fastmcp/pull/3859)
|
||||
* fix: task.wait() hangs indefinitely when task enters input_required by [@mrishav](https://github.com/mrishav) in [#3798](https://github.com/PrefectHQ/fastmcp/pull/3798)
|
||||
* Fix RetryMiddleware not retrying tool errors by [@strawgate](https://github.com/strawgate) in [#3858](https://github.com/PrefectHQ/fastmcp/pull/3858)
|
||||
* Stop pydantic 2.13 from leaking _WrappedResult docstring into tool output schemas by [@jlowin](https://github.com/jlowin) in [#3918](https://github.com/PrefectHQ/fastmcp/pull/3918)
|
||||
### Docs 📚
|
||||
* Note generate-notes API in release workflow docs by [@jlowin](https://github.com/jlowin) in [#3806](https://github.com/PrefectHQ/fastmcp/pull/3806)
|
||||
* docs: require agents to respect DNM markers on PRs by [@jlowin](https://github.com/jlowin) in [#3871](https://github.com/PrefectHQ/fastmcp/pull/3871)
|
||||
* docs: add uv-managed dependencies and uvx examples to mcp-json configuration by [@vincent067](https://github.com/vincent067) in [#3843](https://github.com/PrefectHQ/fastmcp/pull/3843)
|
||||
* docs: link fastmcp-keycloak-local companion project from Keycloak integration page by [@stephaneberle9](https://github.com/stephaneberle9) in [#3904](https://github.com/PrefectHQ/fastmcp/pull/3904)
|
||||
* Overhaul apps docs by [@jlowin](https://github.com/jlowin) in [#3915](https://github.com/PrefectHQ/fastmcp/pull/3915)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump extractions/setup-just from 3 to 4 by [@dependabot](https://github.com/dependabot) in [#3863](https://github.com/PrefectHQ/fastmcp/pull/3863)
|
||||
* chore(deps): bump astral-sh/setup-uv from 6 to 7 by [@dependabot](https://github.com/dependabot) in [#3865](https://github.com/PrefectHQ/fastmcp/pull/3865)
|
||||
* chore(deps): bump actions/checkout from 4 to 6 by [@dependabot](https://github.com/dependabot) in [#3864](https://github.com/PrefectHQ/fastmcp/pull/3864)
|
||||
* chore(deps-dev): bump pydantic-monty from 0.0.9 to 0.0.10 by [@dependabot](https://github.com/dependabot) in [#3809](https://github.com/PrefectHQ/fastmcp/pull/3809)
|
||||
* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#3913](https://github.com/PrefectHQ/fastmcp/pull/3913)
|
||||
|
||||
## New Contributors
|
||||
* @lkiesow made their first contribution in [#3874](https://github.com/PrefectHQ/fastmcp/pull/3874)
|
||||
* @KeWang0622 made their first contribution in [#3880](https://github.com/PrefectHQ/fastmcp/pull/3880)
|
||||
* @vonbai made their first contribution in [#3885](https://github.com/PrefectHQ/fastmcp/pull/3885)
|
||||
|
||||
**Full Changelog**: [v3.2.3...v3.2.4](https://github.com/PrefectHQ/fastmcp/compare/v3.2.3...v3.2.4)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.2.3" description="2026-04-09">
|
||||
|
||||
**[v3.2.3: Redis or Not](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.3)**
|
||||
|
||||
A stopgap pin: fakeredis 2.35.0 shipped an undocumented rename that broke pydocket's `memory://` backend, causing `fastmcp[tasks]` installs to fail at startup with an `ImportError`. This pins `fakeredis<2.35.0` in the `tasks` extra until a fixed pydocket ships.
|
||||
|
||||
### Fixes 🐞
|
||||
* Pin `fakeredis<2.35.0` in tasks extra by [@jlowin](https://github.com/jlowin) in [#3804](https://github.com/PrefectHQ/fastmcp/pull/3804)
|
||||
### Docs 📚
|
||||
* Document session state isolation across mount boundaries by [@jlowin](https://github.com/jlowin) in [#3801](https://github.com/PrefectHQ/fastmcp/pull/3801)
|
||||
|
||||
|
||||
**Full Changelog**: [v3.2.2...v3.2.3](https://github.com/PrefectHQ/fastmcp/compare/v3.2.2...v3.2.3)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.2.2" description="2026-04-09">
|
||||
|
||||
**[v3.2.2: Audience Appreciation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.2)**
|
||||
|
||||
Fixes the Azure audience regression from 3.2.1: validation switched from `client_id` to `identifier_uri`, which fixed custom Application ID URIs but broke the default case where Azure AD v2 tokens set `aud` to the bare client ID GUID. Both formats are now accepted.
|
||||
|
||||
### Fixes 🐞
|
||||
* fix: accept both client_id and identifier_uri as Azure audience by [@jlowin](https://github.com/jlowin) in [#3797](https://github.com/PrefectHQ/fastmcp/pull/3797)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#3795](https://github.com/PrefectHQ/fastmcp/pull/3795)
|
||||
|
||||
|
||||
**Full Changelog**: [v3.2.1...v3.2.2](https://github.com/PrefectHQ/fastmcp/compare/v3.2.1...v3.2.2)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.2.1" description="2026-04-08">
|
||||
|
||||
**[v3.2.1: Audience Participation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.1)**
|
||||
|
||||
A patch focused on auth-provider audience validation. Cognito tokens now validate on `client_id` (they carry no `aud`), Azure honors the `identifier_uri` parameter for Entra v2.0 tokens, and consent cookies are LRU-capped to prevent unbounded growth past reverse proxy header limits. Also fixes OpenAPI 3.0 `nullable` fields leaking into tool input schemas and server-variable substitution in base URLs.
|
||||
|
||||
### Breaking Changes ⚠️
|
||||
* fix(google): use sub (user ID) for client_id instead of aud (app ID) by [@shigechika](https://github.com/shigechika) in [#3722](https://github.com/PrefectHQ/fastmcp/pull/3722)
|
||||
* fix: remove CSP from tool metadata, keep on resource only by [@jlowin](https://github.com/jlowin) in [#3754](https://github.com/PrefectHQ/fastmcp/pull/3754)
|
||||
### Enhancements ✨
|
||||
* [codex] Add FastMCP docs telemetry by [@aaazzam](https://github.com/aaazzam) in [#3727](https://github.com/PrefectHQ/fastmcp/pull/3727)
|
||||
* chore: split SDK navigation into standalone $ref file by [@jlowin](https://github.com/jlowin) in [#3773](https://github.com/PrefectHQ/fastmcp/pull/3773)
|
||||
* fix: bump ty to >=0.0.29 and suppress new false positives by [@jlowin](https://github.com/jlowin) in [#3790](https://github.com/PrefectHQ/fastmcp/pull/3790)
|
||||
### Fixes 🐞
|
||||
* fix: use explicit None checks for JWT exp validation by [@jlowin](https://github.com/jlowin) in [#3724](https://github.com/PrefectHQ/fastmcp/pull/3724)
|
||||
* Unify background task context forwarding, fix concurrent dependency bugs by [@chrisguidry](https://github.com/chrisguidry) in [#3710](https://github.com/PrefectHQ/fastmcp/pull/3710)
|
||||
* fix: add proxy timeouts and modernize networking in apps dev by [@mateeaaa](https://github.com/mateeaaa) in [#3741](https://github.com/PrefectHQ/fastmcp/pull/3741)
|
||||
* fix: ResponseLimitingMiddleware no longer breaks outputSchema tools by [@jlowin](https://github.com/jlowin) in [#3756](https://github.com/PrefectHQ/fastmcp/pull/3756)
|
||||
* fix: substitute server variable defaults when building base URL from OpenAPI spec by [@mrishav](https://github.com/mrishav) in [#3770](https://github.com/PrefectHQ/fastmcp/pull/3770)
|
||||
* fix: FastAPI TestClient compatibility and lifespan re-initialization by [@kvdhanush06](https://github.com/kvdhanush06) in [#3736](https://github.com/PrefectHQ/fastmcp/pull/3736)
|
||||
* fix: propagate upstream_claims in load_access_token by [@kvdhanush06](https://github.com/kvdhanush06) in [#3750](https://github.com/PrefectHQ/fastmcp/pull/3750)
|
||||
* Remove deprecated asyncio.iscoroutinefunction fallback by [@kaiisfree](https://github.com/kaiisfree) in [#3767](https://github.com/PrefectHQ/fastmcp/pull/3767)
|
||||
* fix: changeable allowed_client_redirect_uris on OAuthProxy by [@fengarix](https://github.com/fengarix) in [#3772](https://github.com/PrefectHQ/fastmcp/pull/3772)
|
||||
* fix: broken link in changelog by [@jlowin](https://github.com/jlowin) in [#3775](https://github.com/PrefectHQ/fastmcp/pull/3775)
|
||||
* fix(docs): correct FastMCP tool name in welcome docs by [@buyua9](https://github.com/buyua9) in [#3781](https://github.com/PrefectHQ/fastmcp/pull/3781)
|
||||
* fix: cap consent cookie size to prevent header overflow by [@jlowin](https://github.com/jlowin) in [#3784](https://github.com/PrefectHQ/fastmcp/pull/3784)
|
||||
* Fix boolean property schemas in JSON Schema parsing by [@jlowin](https://github.com/jlowin) in [#3785](https://github.com/PrefectHQ/fastmcp/pull/3785)
|
||||
* Fix OpenAPI 3.0 nullable fields in tool input schemas by [@kvdhanush06](https://github.com/kvdhanush06) in [#3768](https://github.com/PrefectHQ/fastmcp/pull/3768)
|
||||
* fix: Cognito token verification checks client_id instead of aud by [@jlowin](https://github.com/jlowin) in [#3786](https://github.com/PrefectHQ/fastmcp/pull/3786)
|
||||
* fix: use identifier_uri as audience for Azure token validation by [@jlowin](https://github.com/jlowin) in [#3787](https://github.com/PrefectHQ/fastmcp/pull/3787)
|
||||
* Harden client tool result error handling by [@aimable100](https://github.com/aimable100) in [#3778](https://github.com/PrefectHQ/fastmcp/pull/3778)
|
||||
### Docs 📚
|
||||
* Github integraiton documentation fix: use result.data otherwise CallToolResult not scriptable by [@c4jquick](https://github.com/c4jquick) in [#3753](https://github.com/PrefectHQ/fastmcp/pull/3753)
|
||||
* chore: split v2 docs navigation into separate file by [@jlowin](https://github.com/jlowin) in [#3762](https://github.com/PrefectHQ/fastmcp/pull/3762)
|
||||
* docs: document forward_resource parameter on OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3788](https://github.com/PrefectHQ/fastmcp/pull/3788)
|
||||
### Examples & Contrib 💡
|
||||
* fix: boolean false values dropped in form submissions by [@jlowin](https://github.com/jlowin) in [#3776](https://github.com/PrefectHQ/fastmcp/pull/3776)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump fastmcp from 3.1.1 to 3.2.0 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3728](https://github.com/PrefectHQ/fastmcp/pull/3728)
|
||||
* chore(deps): bump anthropic from 0.86.0 to 0.87.0 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3742](https://github.com/PrefectHQ/fastmcp/pull/3742)
|
||||
|
||||
## New Contributors
|
||||
* @c4jquick made their first contribution in [#3753](https://github.com/PrefectHQ/fastmcp/pull/3753)
|
||||
* @mateeaaa made their first contribution in [#3741](https://github.com/PrefectHQ/fastmcp/pull/3741)
|
||||
* @mrishav made their first contribution in [#3770](https://github.com/PrefectHQ/fastmcp/pull/3770)
|
||||
* @kvdhanush06 made their first contribution in [#3736](https://github.com/PrefectHQ/fastmcp/pull/3736)
|
||||
* @kaiisfree made their first contribution in [#3767](https://github.com/PrefectHQ/fastmcp/pull/3767)
|
||||
* @fengarix made their first contribution in [#3772](https://github.com/PrefectHQ/fastmcp/pull/3772)
|
||||
* @buyua9 made their first contribution in [#3781](https://github.com/PrefectHQ/fastmcp/pull/3781)
|
||||
* @aimable100 made their first contribution in [#3778](https://github.com/PrefectHQ/fastmcp/pull/3778)
|
||||
|
||||
**Full Changelog**: [v3.2.0...v3.2.1](https://github.com/PrefectHQ/fastmcp/compare/v3.2.0...v3.2.1)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.2.0" description="2026-03-30">
|
||||
|
||||
**[v3.2.0: Show Don't Tool](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.0)**
|
||||
|
||||
FastMCP 3.2 is the Apps release: your tools can now return interactive UIs — charts, dashboards, forms, maps — rendered right inside the conversation. `FastMCPApp` separates the tools the LLM sees from the backend tools the UI calls, five built-in providers (FileUpload, Approval, Choice, FormInput, GenerativeUI) cover common interaction patterns, and `fastmcp dev apps` gives you a browser preview. The release also lands a significant security hardening pass across SSRF/path-traversal, JWT algorithm restrictions, OAuth scope enforcement, and CSRF.
|
||||
|
||||
### New Features 🎉
|
||||
* Add FastMCPApp — a Provider for composable MCP applications by [@jlowin](https://github.com/jlowin) in [#3385](https://github.com/PrefectHQ/fastmcp/pull/3385)
|
||||
* Add fastmcp dev apps command with browser UI preview by [@jlowin](https://github.com/jlowin) in [#3489](https://github.com/PrefectHQ/fastmcp/pull/3489)
|
||||
* Add GenerativeUI provider, bump prefab-ui 0.14.0 by [@jlowin](https://github.com/jlowin) in [#3647](https://github.com/PrefectHQ/fastmcp/pull/3647)
|
||||
* Add FileUpload provider by [@jlowin](https://github.com/jlowin) in [#3669](https://github.com/PrefectHQ/fastmcp/pull/3669)
|
||||
* Add Approval and Choice providers by [@jlowin](https://github.com/jlowin) in [#3686](https://github.com/PrefectHQ/fastmcp/pull/3686)
|
||||
* Add FormInput provider, bump prefab-ui to 0.15.0 by [@jlowin](https://github.com/jlowin) in [#3687](https://github.com/PrefectHQ/fastmcp/pull/3687)
|
||||
### Breaking Changes ⚠️
|
||||
* Route app tool calls via ___-prefixed names by [@jlowin](https://github.com/jlowin) in [#3667](https://github.com/PrefectHQ/fastmcp/pull/3667)
|
||||
### Enhancements ✨
|
||||
* feat: add `--config-path` flag to claude-desktop install command by [@Sumanshu-Nankana](https://github.com/Sumanshu-Nankana) in [#3380](https://github.com/PrefectHQ/fastmcp/pull/3380)
|
||||
* Support ImageContent and AudioContent in Message class by [@ericrobinson-indeed](https://github.com/ericrobinson-indeed) in [#3396](https://github.com/PrefectHQ/fastmcp/pull/3396)
|
||||
* Deprecate PromptToolMiddleware and ResourceToolMiddleware by [@jlowin](https://github.com/jlowin) in [#3389](https://github.com/PrefectHQ/fastmcp/pull/3389)
|
||||
* Block HS* algorithms when JWTVerifier is configured with JWKS by [@jlowin](https://github.com/jlowin) in [#3419](https://github.com/PrefectHQ/fastmcp/pull/3419)
|
||||
* Remove prek from Marvin workflows by [@jlowin](https://github.com/jlowin) in [#3444](https://github.com/PrefectHQ/fastmcp/pull/3444)
|
||||
* Add dependency version compatibility guidance to code-review skill by [@jlowin](https://github.com/jlowin) in [#3475](https://github.com/PrefectHQ/fastmcp/pull/3475)
|
||||
* Remove "good first issue" label by [@jlowin](https://github.com/jlowin) in [#3482](https://github.com/PrefectHQ/fastmcp/pull/3482)
|
||||
* Cache component lists in ProxyProvider by [@jlowin](https://github.com/jlowin) in [#3479](https://github.com/PrefectHQ/fastmcp/pull/3479)
|
||||
* Support logging/setLevel and add client_log_level by [@jlowin](https://github.com/jlowin) in [#3491](https://github.com/PrefectHQ/fastmcp/pull/3491)
|
||||
* Propagate x-fastmcp-wrap-result in tool result _meta by [@jlowin](https://github.com/jlowin) in [#3490](https://github.com/PrefectHQ/fastmcp/pull/3490)
|
||||
* feat(auth): add external_consent param to suppress misleading warning by [@mtthidoteu](https://github.com/mtthidoteu) in [#3473](https://github.com/PrefectHQ/fastmcp/pull/3473)
|
||||
* Add `verify` parameter for SSL certificate configuration by [@jlowin](https://github.com/jlowin) in [#3487](https://github.com/PrefectHQ/fastmcp/pull/3487)
|
||||
* Expose minimum_check_interval, reduce task pickup latency by [@jlowin](https://github.com/jlowin) in [#3500](https://github.com/PrefectHQ/fastmcp/pull/3500)
|
||||
* Fix test timeouts, suppress deprecation warnings, speed up auth tests by [@jlowin](https://github.com/jlowin) in [#3504](https://github.com/PrefectHQ/fastmcp/pull/3504)
|
||||
* Auto-close upgrade check issue when build passes by [@jlowin](https://github.com/jlowin) in [#3505](https://github.com/PrefectHQ/fastmcp/pull/3505)
|
||||
* feat: make upstream_client_secret optional in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3486](https://github.com/PrefectHQ/fastmcp/pull/3486)
|
||||
* Add security label to triage workflow and release notes by [@jlowin](https://github.com/jlowin) in [#3516](https://github.com/PrefectHQ/fastmcp/pull/3516)
|
||||
* Claude/review contributor guidelines by [@jlowin](https://github.com/jlowin) in [#3517](https://github.com/PrefectHQ/fastmcp/pull/3517)
|
||||
* pin pydantic-monty to 0.0.8 by [@jlowin](https://github.com/jlowin) in [#3539](https://github.com/PrefectHQ/fastmcp/pull/3539)
|
||||
* Support ImageContent and AudioContent in sampling handlers by [@jlowin](https://github.com/jlowin) in [#3550](https://github.com/PrefectHQ/fastmcp/pull/3550)
|
||||
* Graceful degradation for multi-server proxy setup by [@jlowin](https://github.com/jlowin) in [#3546](https://github.com/PrefectHQ/fastmcp/pull/3546)
|
||||
* Extract TokenCache utility, add caching to GitHubTokenVerifier by [@jlowin](https://github.com/jlowin) in [#3547](https://github.com/PrefectHQ/fastmcp/pull/3547)
|
||||
* Add review-pr skill for Codex bot workflow by [@jlowin](https://github.com/jlowin) in [#3552](https://github.com/PrefectHQ/fastmcp/pull/3552)
|
||||
* Add MCP message inspector to dev apps UI by [@jlowin](https://github.com/jlowin) in [#3570](https://github.com/PrefectHQ/fastmcp/pull/3570)
|
||||
* Comprehensive MCP Apps docs, string CallTool resolution by [@jlowin](https://github.com/jlowin) in [#3575](https://github.com/PrefectHQ/fastmcp/pull/3575)
|
||||
* Replace UUID global keys with (app_name, tool_name) registry by [@jlowin](https://github.com/jlowin) in [#3585](https://github.com/PrefectHQ/fastmcp/pull/3585)
|
||||
* Route app tool calls through provider chain by [@jlowin](https://github.com/jlowin) in [#3587](https://github.com/PrefectHQ/fastmcp/pull/3587)
|
||||
* Dev apps: show more/less for long tool descriptions by [@jlowin](https://github.com/jlowin) in [#3600](https://github.com/PrefectHQ/fastmcp/pull/3600)
|
||||
* Apps Phase 1: docs, examples, app-only tool filtering by [@jlowin](https://github.com/jlowin) in [#3593](https://github.com/PrefectHQ/fastmcp/pull/3593)
|
||||
* Forward enable_cimd to OAuthProxy in all provider subclasses by [@jlowin](https://github.com/jlowin) in [#3608](https://github.com/PrefectHQ/fastmcp/pull/3608)
|
||||
* Tune too-long triage heuristic by [@jlowin](https://github.com/jlowin) in [#3610](https://github.com/PrefectHQ/fastmcp/pull/3610)
|
||||
* Update ty ignore comments for 0.0.25 compatibility by [@jlowin](https://github.com/jlowin) in [#3614](https://github.com/PrefectHQ/fastmcp/pull/3614)
|
||||
* Move app modules to fastmcp.apps package by [@jlowin](https://github.com/jlowin) in [#3616](https://github.com/PrefectHQ/fastmcp/pull/3616)
|
||||
* Tighten too-long heuristic for design-document issues by [@jlowin](https://github.com/jlowin) in [#3620](https://github.com/PrefectHQ/fastmcp/pull/3620)
|
||||
* Run MCP conformance tests by [@strawgate](https://github.com/strawgate) in [#3628](https://github.com/PrefectHQ/fastmcp/pull/3628)
|
||||
* Add PrefabAppConfig for customizable Prefab tool setup by [@jlowin](https://github.com/jlowin) in [#3648](https://github.com/PrefectHQ/fastmcp/pull/3648)
|
||||
* Clean error when dev apps ports are in use by [@jlowin](https://github.com/jlowin) in [#3658](https://github.com/PrefectHQ/fastmcp/pull/3658)
|
||||
* Add Clerk OAuth provider by [@mostafa6765](https://github.com/mostafa6765) in [#3677](https://github.com/PrefectHQ/fastmcp/pull/3677)
|
||||
* Add interactive map example with geocoding by [@jlowin](https://github.com/jlowin) in [#3702](https://github.com/PrefectHQ/fastmcp/pull/3702)
|
||||
* Bump pydantic-monty to 0.0.9 by [@jlowin](https://github.com/jlowin) in [#3707](https://github.com/PrefectHQ/fastmcp/pull/3707)
|
||||
* Add forward_resource flag to OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3711](https://github.com/PrefectHQ/fastmcp/pull/3711)
|
||||
### Security 🔒
|
||||
* fix: enforce per-tool auth checks in sampling tool wrapper by [@jlowin](https://github.com/jlowin) in [#3494](https://github.com/PrefectHQ/fastmcp/pull/3494)
|
||||
* fix: handle re.error from malformed URI templates by [@jlowin](https://github.com/jlowin) in [#3501](https://github.com/PrefectHQ/fastmcp/pull/3501)
|
||||
* fix: reject empty/OIDC-only required_scopes in AzureProvider by [@jlowin](https://github.com/jlowin) in [#3503](https://github.com/PrefectHQ/fastmcp/pull/3503)
|
||||
* fix: restrict $ref resolution to local refs only (SSRF/LFI) by [@jlowin](https://github.com/jlowin) in [#3502](https://github.com/PrefectHQ/fastmcp/pull/3502)
|
||||
* fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) by [@jlowin](https://github.com/jlowin) in [#3507](https://github.com/PrefectHQ/fastmcp/pull/3507)
|
||||
* fix: prevent path traversal in skill download by [@jlowin](https://github.com/jlowin) in [#3493](https://github.com/PrefectHQ/fastmcp/pull/3493)
|
||||
* fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3492](https://github.com/PrefectHQ/fastmcp/pull/3492)
|
||||
* fix: remove forced follow_redirects from httpx_client_factory calls by [@jlowin](https://github.com/jlowin) in [#3496](https://github.com/PrefectHQ/fastmcp/pull/3496)
|
||||
* Bump PyJWT >= 2.12.0 (CVE-2026-32597) by [@jlowin](https://github.com/jlowin) in [#3515](https://github.com/PrefectHQ/fastmcp/pull/3515)
|
||||
* Drop diskcache from examples/testing_demo lockfile (CVE-2025-69872) by [@jlowin](https://github.com/jlowin) in [#3518](https://github.com/PrefectHQ/fastmcp/pull/3518)
|
||||
* fix: CSRF double-submit cookie check in consent flow by [@jlowin](https://github.com/jlowin) in [#3519](https://github.com/PrefectHQ/fastmcp/pull/3519)
|
||||
* fix: validate server names in install commands by [@jlowin](https://github.com/jlowin) in [#3522](https://github.com/PrefectHQ/fastmcp/pull/3522)
|
||||
* fix: reject refresh tokens used as Bearer access tokens by [@jlowin](https://github.com/jlowin) in [#3524](https://github.com/PrefectHQ/fastmcp/pull/3524)
|
||||
* fix: route ResourcesAsTools/PromptsAsTools through server middleware by [@jlowin](https://github.com/jlowin) in [#3495](https://github.com/PrefectHQ/fastmcp/pull/3495)
|
||||
### Fixes 🐞
|
||||
* Update docs banner and fix mobile layout by [@jlowin](https://github.com/jlowin) in [#3370](https://github.com/PrefectHQ/fastmcp/pull/3370)
|
||||
* Remove form-action from consent CSP, forward consent_csp_policy in providers by [@jlowin](https://github.com/jlowin) in [#3372](https://github.com/PrefectHQ/fastmcp/pull/3372)
|
||||
* Fix resource templates with query params on mounted servers by [@jlowin](https://github.com/jlowin) in [#3373](https://github.com/PrefectHQ/fastmcp/pull/3373)
|
||||
* Increase uv transport test timeout for CI cold starts by [@jlowin](https://github.com/jlowin) in [#3376](https://github.com/PrefectHQ/fastmcp/pull/3376)
|
||||
* Fix stale catalog in CodeMode execute by [@jlowin](https://github.com/jlowin) in [#3375](https://github.com/PrefectHQ/fastmcp/pull/3375)
|
||||
* Deduplicate versioned tools in CatalogTransform catalog by [@jlowin](https://github.com/jlowin) in [#3374](https://github.com/PrefectHQ/fastmcp/pull/3374)
|
||||
* Fix ty 0.0.20 compatibility by [@jlowin](https://github.com/jlowin) in [#3377](https://github.com/PrefectHQ/fastmcp/pull/3377)
|
||||
* Forward scopes_supported through RemoteAuthProvider subclasses by [@jlowin](https://github.com/jlowin) in [#3388](https://github.com/PrefectHQ/fastmcp/pull/3388)
|
||||
* Enforce token scopes in WorkOS verifier to prevent scope bypass by [@jlowin](https://github.com/jlowin) in [#3407](https://github.com/PrefectHQ/fastmcp/pull/3407)
|
||||
* Bind Discord token verification to configured client_id by [@jlowin](https://github.com/jlowin) in [#3405](https://github.com/PrefectHQ/fastmcp/pull/3405)
|
||||
* Return after `McpError` in initialization middleware to prevent fallthrough by [@jlowin](https://github.com/jlowin) in [#3413](https://github.com/PrefectHQ/fastmcp/pull/3413)
|
||||
* Escape client_id in OAuth consent advanced details by [@jlowin](https://github.com/jlowin) in [#3418](https://github.com/PrefectHQ/fastmcp/pull/3418)
|
||||
* Bound client auto-pagination loops to prevent unbounded list fetches by [@jlowin](https://github.com/jlowin) in [#3411](https://github.com/PrefectHQ/fastmcp/pull/3411)
|
||||
* Raise ValueError for invalid boolean query params in resource templates by [@jlowin](https://github.com/jlowin) in [#3434](https://github.com/PrefectHQ/fastmcp/pull/3434)
|
||||
* Validate workspace path is a directory in cursor install by [@jlowin](https://github.com/jlowin) in [#3435](https://github.com/PrefectHQ/fastmcp/pull/3435)
|
||||
* Validate version metadata to reject non-scalar types by [@jlowin](https://github.com/jlowin) in [#3437](https://github.com/PrefectHQ/fastmcp/pull/3437)
|
||||
* Bind AWS Cognito token verification to configured app client by [@jlowin](https://github.com/jlowin) in [#3406](https://github.com/PrefectHQ/fastmcp/pull/3406)
|
||||
* Avoid stale context leakage when proxying with an already‑connected ProxyClient by [@jlowin](https://github.com/jlowin) in [#3408](https://github.com/PrefectHQ/fastmcp/pull/3408)
|
||||
* Prevent skills manifests from hashing files outside the skill directory by [@jlowin](https://github.com/jlowin) in [#3410](https://github.com/PrefectHQ/fastmcp/pull/3410)
|
||||
* Harden fastmcp metadata parsing in proxy paths by [@jlowin](https://github.com/jlowin) in [#3412](https://github.com/PrefectHQ/fastmcp/pull/3412)
|
||||
* Re-hash response caching keys to avoid persisting raw request input by [@jlowin](https://github.com/jlowin) in [#3414](https://github.com/PrefectHQ/fastmcp/pull/3414)
|
||||
* Handle Windows npx detection when npx.cmd is missing by [@jlowin](https://github.com/jlowin) in [#3416](https://github.com/PrefectHQ/fastmcp/pull/3416)
|
||||
* Guard OAuth callback result from post-completion overwrites by [@jlowin](https://github.com/jlowin) in [#3417](https://github.com/PrefectHQ/fastmcp/pull/3417)
|
||||
* Fix tool argument rename collisions with passthrough params by [@jlowin](https://github.com/jlowin) in [#3431](https://github.com/PrefectHQ/fastmcp/pull/3431)
|
||||
* Guard default progress handler against total=0 notifications by [@jlowin](https://github.com/jlowin) in [#3432](https://github.com/PrefectHQ/fastmcp/pull/3432)
|
||||
* Fix get_* returning None when latest version is disabled by [@jlowin](https://github.com/jlowin) in [#3439](https://github.com/PrefectHQ/fastmcp/pull/3439)
|
||||
* Fix server lifespan overlap teardown by [@jlowin](https://github.com/jlowin) in [#3415](https://github.com/PrefectHQ/fastmcp/pull/3415)
|
||||
* Fix $ref output schema object detection regression by [@jlowin](https://github.com/jlowin) in [#3420](https://github.com/PrefectHQ/fastmcp/pull/3420)
|
||||
* Preserve kw-only defaults when rebuilding functions for resolved annotations by [@jlowin](https://github.com/jlowin) in [#3429](https://github.com/PrefectHQ/fastmcp/pull/3429)
|
||||
* Redact sensitive headers in OpenAPI provider debug logging by [@jlowin](https://github.com/jlowin) in [#3436](https://github.com/PrefectHQ/fastmcp/pull/3436)
|
||||
* Fix async partial callables rejected by iscoroutinefunction by [@jlowin](https://github.com/jlowin) in [#3438](https://github.com/PrefectHQ/fastmcp/pull/3438)
|
||||
* Block insecure HS* JWT verification with JWKS/public keys by [@jlowin](https://github.com/jlowin) in [#3430](https://github.com/PrefectHQ/fastmcp/pull/3430)
|
||||
* Sanitize untrusted output in `fastmcp list` and `fastmcp call` by [@jlowin](https://github.com/jlowin) in [#3409](https://github.com/PrefectHQ/fastmcp/pull/3409)
|
||||
* fix: propagate `version` to components in FileSystemProvider by [@martimfasantos](https://github.com/martimfasantos) in [#3458](https://github.com/PrefectHQ/fastmcp/pull/3458)
|
||||
* fix: use intent-based flag for OIDC scope patch in load_access_token by [@voidborne-d](https://github.com/voidborne-d) in [#3465](https://github.com/PrefectHQ/fastmcp/pull/3465)
|
||||
* Set readOnlyHint=True on ResourcesAsTools generated tools by [@jlowin](https://github.com/jlowin) in [#3476](https://github.com/PrefectHQ/fastmcp/pull/3476)
|
||||
* fix: normalize Google scope shorthands and surface valid_scopes by [@jlowin](https://github.com/jlowin) in [#3477](https://github.com/PrefectHQ/fastmcp/pull/3477)
|
||||
* fix: resolve ty 0.0.23 type-checking errors by [@jlowin](https://github.com/jlowin) in [#3481](https://github.com/PrefectHQ/fastmcp/pull/3481)
|
||||
* fix: shield lifespan teardown from cancellation by [@jlowin](https://github.com/jlowin) in [#3480](https://github.com/PrefectHQ/fastmcp/pull/3480)
|
||||
* fix: forward custom_route endpoints from mounted servers by [@voidborne-d](https://github.com/voidborne-d) in [#3462](https://github.com/PrefectHQ/fastmcp/pull/3462)
|
||||
* fix: use dynamic version in CLI help text instead of hardcoded 2.0 by [@saschabuehrle](https://github.com/saschabuehrle) in [#3456](https://github.com/PrefectHQ/fastmcp/pull/3456)
|
||||
* Fix Monty 0.0.8 compatibility by [@hkc5](https://github.com/hkc5) in [#3468](https://github.com/PrefectHQ/fastmcp/pull/3468)
|
||||
* Fix task test teardown hanging 5s per test by [@jlowin](https://github.com/jlowin) in [#3499](https://github.com/PrefectHQ/fastmcp/pull/3499)
|
||||
* fix: validate workspace path is a directory before cursor install by [@nightcityblade](https://github.com/nightcityblade) in [#3440](https://github.com/PrefectHQ/fastmcp/pull/3440)
|
||||
* Treat `refresh_expires_in=0` as missing, fall back to 30-day default by [@jlowin](https://github.com/jlowin) in [#3514](https://github.com/PrefectHQ/fastmcp/pull/3514)
|
||||
* fix: use raw strings for regex in pytest.raises match by [@jlowin](https://github.com/jlowin) in [#3523](https://github.com/PrefectHQ/fastmcp/pull/3523)
|
||||
* fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators by [@jlowin](https://github.com/jlowin) in [#3540](https://github.com/PrefectHQ/fastmcp/pull/3540)
|
||||
* fix: flaky KEY_PREFIX warning test in lowest-direct deps by [@jlowin](https://github.com/jlowin) in [#3549](https://github.com/PrefectHQ/fastmcp/pull/3549)
|
||||
* fix: suppress output schema for ToolResult subclass annotations by [@jlowin](https://github.com/jlowin) in [#3548](https://github.com/PrefectHQ/fastmcp/pull/3548)
|
||||
* Bump anthropic minimum to 0.48.0 by [@jlowin](https://github.com/jlowin) in [#3553](https://github.com/PrefectHQ/fastmcp/pull/3553)
|
||||
* Update startup banner deploy URL to Prefect Horizon by [@zzstoatzz](https://github.com/zzstoatzz) in [#3557](https://github.com/PrefectHQ/fastmcp/pull/3557)
|
||||
* fix: increase sleep duration in proxy cache tests by [@strawgate](https://github.com/strawgate) in [#3567](https://github.com/PrefectHQ/fastmcp/pull/3567)
|
||||
* fix: store absolute token expiry to prevent stale expires_in on reload by [@jlowin](https://github.com/jlowin) in [#3572](https://github.com/PrefectHQ/fastmcp/pull/3572)
|
||||
* fix: preserve tool properties named 'title' during schema compression by [@jlowin](https://github.com/jlowin) in [#3582](https://github.com/PrefectHQ/fastmcp/pull/3582)
|
||||
* Add `encoding` parameter to `FileResource` by [@shulkx](https://github.com/shulkx) in [#3580](https://github.com/PrefectHQ/fastmcp/pull/3580)
|
||||
* Transparently refresh upstream token in OAuthProxy.load_access_token() by [@jlowin](https://github.com/jlowin) in [#3584](https://github.com/PrefectHQ/fastmcp/pull/3584)
|
||||
* Fix loopback redirect URI port matching per RFC 8252 §7.3 by [@radoshi](https://github.com/radoshi) in [#3589](https://github.com/PrefectHQ/fastmcp/pull/3589)
|
||||
* Fix app tool routing: visibility check and middleware propagation by [@jlowin](https://github.com/jlowin) in [#3591](https://github.com/PrefectHQ/fastmcp/pull/3591)
|
||||
* Fix query parameter serialization to respect OpenAPI explode setting by [@jlowin](https://github.com/jlowin) in [#3595](https://github.com/PrefectHQ/fastmcp/pull/3595)
|
||||
* Fix dev apps form: union types, textarea support, JSON parsing by [@jlowin](https://github.com/jlowin) in [#3597](https://github.com/PrefectHQ/fastmcp/pull/3597)
|
||||
* Respect OpenAPI content type in request body serialization by [@jlowin](https://github.com/jlowin) in [#3611](https://github.com/PrefectHQ/fastmcp/pull/3611)
|
||||
* fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo by [@shigechika](https://github.com/shigechika) in [#3603](https://github.com/PrefectHQ/fastmcp/pull/3603)
|
||||
* fix: resolve EntraOBOToken dependency injection through MultiAuth by [@jer805](https://github.com/jer805) in [#3609](https://github.com/PrefectHQ/fastmcp/pull/3609)
|
||||
* fix: filesystem provider import machinery by [@strawgate](https://github.com/strawgate) in [#3626](https://github.com/PrefectHQ/fastmcp/pull/3626)
|
||||
* fix: recover StdioTransport after subprocess exits by [@strawgate](https://github.com/strawgate) in [#3630](https://github.com/PrefectHQ/fastmcp/pull/3630)
|
||||
* fix(server): preserve mounted tool task metadata by [@pandego](https://github.com/pandego) in [#3632](https://github.com/PrefectHQ/fastmcp/pull/3632)
|
||||
* fix: scope deprecation warning filter to FastMCPDeprecationWarning by [@jlowin](https://github.com/jlowin) in [#3649](https://github.com/PrefectHQ/fastmcp/pull/3649)
|
||||
* fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks by [@jlowin](https://github.com/jlowin) in [#3651](https://github.com/PrefectHQ/fastmcp/pull/3651)
|
||||
* Fix blocking docs issues: chart imports, Select API, Rx consistency by [@jlowin](https://github.com/jlowin) in [#3652](https://github.com/PrefectHQ/fastmcp/pull/3652)
|
||||
* Fix prompt caching round-trip on cache miss by [@strawgate](https://github.com/strawgate) in [#3666](https://github.com/PrefectHQ/fastmcp/pull/3666)
|
||||
* fix: serialize object query params per OpenAPI style/explode rules by [@4444J99](https://github.com/4444J99) in [#3662](https://github.com/PrefectHQ/fastmcp/pull/3662)
|
||||
* fix: HTTP request headers not accessible in background task workers by [@pandego](https://github.com/pandego) in [#3631](https://github.com/PrefectHQ/fastmcp/pull/3631)
|
||||
* fix: restore HTTP headers in worker execution path for background tasks by [@jlowin](https://github.com/jlowin) in [#3681](https://github.com/PrefectHQ/fastmcp/pull/3681)
|
||||
* fix: strip discriminator after dereferencing schemas by [@jlowin](https://github.com/jlowin) in [#3682](https://github.com/PrefectHQ/fastmcp/pull/3682)
|
||||
* fix: remove stale ty:ignore directives for ty 0.0.26 by [@jlowin](https://github.com/jlowin) in [#3684](https://github.com/PrefectHQ/fastmcp/pull/3684)
|
||||
* fix: dev apps log panel UX improvements by [@jlowin](https://github.com/jlowin) in [#3698](https://github.com/PrefectHQ/fastmcp/pull/3698)
|
||||
* Add quiz example app, fix dev server empty string args by [@jlowin](https://github.com/jlowin) in [#3700](https://github.com/PrefectHQ/fastmcp/pull/3700)
|
||||
### Docs 📚
|
||||
* Add early-development warning to Prefab docs by [@jlowin](https://github.com/jlowin) in [#3362](https://github.com/PrefectHQ/fastmcp/pull/3362)
|
||||
* Add tag to docs by [@jlowin](https://github.com/jlowin) in [#3382](https://github.com/PrefectHQ/fastmcp/pull/3382)
|
||||
* Add settings and environment variables reference by [@jlowin](https://github.com/jlowin) in [#3384](https://github.com/PrefectHQ/fastmcp/pull/3384)
|
||||
* Add contributing guidelines and update issue/PR templates by [@jlowin](https://github.com/jlowin) in [#3485](https://github.com/PrefectHQ/fastmcp/pull/3485)
|
||||
* [Documentation] Move stateless_http transport kwarg to http_app as FastMCP constructor… by [@mhallo](https://github.com/mhallo) in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510)
|
||||
* Update security policy by [@jlowin](https://github.com/jlowin) in [#3521](https://github.com/PrefectHQ/fastmcp/pull/3521)
|
||||
* Add release instructions to CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#3583](https://github.com/PrefectHQ/fastmcp/pull/3583)
|
||||
* fix(docs): correct misleading stateless_http header by [@jlowin](https://github.com/jlowin) in [#3622](https://github.com/PrefectHQ/fastmcp/pull/3622)
|
||||
* Add tag to deployment pages by [@jlowin](https://github.com/jlowin) in [#3624](https://github.com/PrefectHQ/fastmcp/pull/3624)
|
||||
* Docs: generative UI page, fix imports, add PrefabAppConfig by [@jlowin](https://github.com/jlowin) in [#3650](https://github.com/PrefectHQ/fastmcp/pull/3650)
|
||||
* docs: improve contributor guidelines for framework contributions by [@jlowin](https://github.com/jlowin) in [#3653](https://github.com/PrefectHQ/fastmcp/pull/3653)
|
||||
* Add release notes for v3.1.0, v3.1.1, and v2.14.6 by [@jlowin](https://github.com/jlowin) in [#3659](https://github.com/PrefectHQ/fastmcp/pull/3659)
|
||||
* Docs: showcase hero, narrative improvements, panel closed by default by [@jlowin](https://github.com/jlowin) in [#3657](https://github.com/PrefectHQ/fastmcp/pull/3657)
|
||||
* Docs: add FileTreeStore sanitization warnings and update examples by [@strawgate](https://github.com/strawgate) in [#3661](https://github.com/PrefectHQ/fastmcp/pull/3661)
|
||||
* Add prefab-ui version pinning warning to docs by [@jlowin](https://github.com/jlowin) in [#3688](https://github.com/PrefectHQ/fastmcp/pull/3688)
|
||||
* Reorganize apps overview TOC by [@jlowin](https://github.com/jlowin) in [#3689](https://github.com/PrefectHQ/fastmcp/pull/3689)
|
||||
* Fix docs gaps in app provider pages by [@jlowin](https://github.com/jlowin) in [#3690](https://github.com/PrefectHQ/fastmcp/pull/3690)
|
||||
* Polish apps docs for 3.2 release by [@jlowin](https://github.com/jlowin) in [#3693](https://github.com/PrefectHQ/fastmcp/pull/3693)
|
||||
* Add apps quickstart tutorial by [@jlowin](https://github.com/jlowin) in [#3695](https://github.com/PrefectHQ/fastmcp/pull/3695)
|
||||
* Improve quickstart: pie chart, interactive row selection, screenshots by [@jlowin](https://github.com/jlowin) in [#3699](https://github.com/PrefectHQ/fastmcp/pull/3699)
|
||||
* Add sales dashboard and live system monitor examples, bump prefab-ui to 0.17 by [@jlowin](https://github.com/jlowin) in [#3696](https://github.com/PrefectHQ/fastmcp/pull/3696)
|
||||
* Add examples gallery page by [@jlowin](https://github.com/jlowin) in [#3705](https://github.com/PrefectHQ/fastmcp/pull/3705)
|
||||
* docs: note that custom routes are unauthenticated by [@jlowin](https://github.com/jlowin) in [#3706](https://github.com/PrefectHQ/fastmcp/pull/3706)
|
||||
* Remove hardcoded prefab-ui version from pinning warnings by [@jlowin](https://github.com/jlowin) in [#3708](https://github.com/PrefectHQ/fastmcp/pull/3708)
|
||||
### Examples & Contrib 💡
|
||||
* Block recursive self-invocation in BulkToolCaller by [@jlowin](https://github.com/jlowin) in [#3433](https://github.com/PrefectHQ/fastmcp/pull/3433)
|
||||
### Dependencies 📦
|
||||
* Bump authlib from 1.6.6 to 1.6.7 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3390](https://github.com/PrefectHQ/fastmcp/pull/3390)
|
||||
* Bump actions/create-github-app-token from 2 to 3 by [@dependabot](https://github.com/dependabot) in [#3511](https://github.com/PrefectHQ/fastmcp/pull/3511)
|
||||
* chore(deps): bump pyasn1 from 0.6.2 to 0.6.3 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3538](https://github.com/PrefectHQ/fastmcp/pull/3538)
|
||||
* chore(deps): bump j178/prek-action from 1 to 2 by [@dependabot](https://github.com/dependabot) in [#3578](https://github.com/PrefectHQ/fastmcp/pull/3578)
|
||||
* chore(deps): bump requests from 2.32.5 to 2.33.0 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3638](https://github.com/PrefectHQ/fastmcp/pull/3638)
|
||||
* chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3685](https://github.com/PrefectHQ/fastmcp/pull/3685)
|
||||
* chore(deps): bump actions/setup-node from 4 to 6 by [@dependabot](https://github.com/dependabot) in [#3691](https://github.com/PrefectHQ/fastmcp/pull/3691)
|
||||
|
||||
## New Contributors
|
||||
* @Sumanshu-Nankana made their first contribution in [#3380](https://github.com/PrefectHQ/fastmcp/pull/3380)
|
||||
* @ericrobinson-indeed made their first contribution in [#3396](https://github.com/PrefectHQ/fastmcp/pull/3396)
|
||||
* @voidborne-d made their first contribution in [#3465](https://github.com/PrefectHQ/fastmcp/pull/3465)
|
||||
* @mtthidoteu made their first contribution in [#3473](https://github.com/PrefectHQ/fastmcp/pull/3473)
|
||||
* @saschabuehrle made their first contribution in [#3456](https://github.com/PrefectHQ/fastmcp/pull/3456)
|
||||
* @hkc5 made their first contribution in [#3468](https://github.com/PrefectHQ/fastmcp/pull/3468)
|
||||
* @nightcityblade made their first contribution in [#3440](https://github.com/PrefectHQ/fastmcp/pull/3440)
|
||||
* @mhallo made their first contribution in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510)
|
||||
* @radoshi made their first contribution in [#3589](https://github.com/PrefectHQ/fastmcp/pull/3589)
|
||||
* @shigechika made their first contribution in [#3603](https://github.com/PrefectHQ/fastmcp/pull/3603)
|
||||
* @pandego made their first contribution in [#3632](https://github.com/PrefectHQ/fastmcp/pull/3632)
|
||||
* @4444J99 made their first contribution in [#3662](https://github.com/PrefectHQ/fastmcp/pull/3662)
|
||||
* @mostafa6765 made their first contribution in [#3677](https://github.com/PrefectHQ/fastmcp/pull/3677)
|
||||
|
||||
**Full Changelog**: [v3.1.0...v3.2.0](https://github.com/PrefectHQ/fastmcp/compare/v3.1.0...v3.2.0)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.1.1" description="2026-03-14">
|
||||
|
||||
**[v3.1.1: 'Tis But a Patch](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1)**
|
||||
|
|
@ -743,6 +1635,19 @@ Breaking changes are minimal: for most servers, updating the import statement is
|
|||
|
||||
</Update>
|
||||
|
||||
<Update label="v2.14.7" description="2026-04-13">
|
||||
|
||||
**[v2.14.7: Fake It Till You Break It](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.7)**
|
||||
|
||||
A 2.x backport of the fakeredis pin: fakeredis 2.35.0 renamed a connection class that pydocket's `memory://` backend depended on, crashing `fastmcp[tasks]` installs at startup. This caps `fakeredis<2.35.0` on the 2.x line.
|
||||
|
||||
### Fixes 🐞
|
||||
* fix(deps): cap fakeredis to `<2.35.0` to prevent startup crash on 2.x by [@vincent067](https://github.com/vincent067) in [#3883](https://github.com/PrefectHQ/fastmcp/pull/3883)
|
||||
|
||||
**Full Changelog**: [v2.14.6...v2.14.7](https://github.com/PrefectHQ/fastmcp/compare/v2.14.6...v2.14.7)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v2.14.6" description="2026-03-27">
|
||||
|
||||
**[v2.14.6: $Ref Dead Redemption](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.6)**
|
||||
|
|
@ -1274,7 +2179,7 @@ Thank you to our new contributors and everyone who tested preview builds. Your f
|
|||
* Add configurable redirect URI validation for OAuth providers by [@jlowin](https://github.com/jlowin) in [#1582](https://github.com/PrefectHQ/fastmcp/pull/1582)
|
||||
* Remove invalid-argument-type ignore and fix type errors by [@jlowin](https://github.com/jlowin) in [#1588](https://github.com/PrefectHQ/fastmcp/pull/1588)
|
||||
* Remove generate-schema from public CLI by [@jlowin](https://github.com/jlowin) in [#1591](https://github.com/PrefectHQ/fastmcp/pull/1591)
|
||||
* Skip flaky windows test / mulit-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592)
|
||||
* Skip flaky windows test / multi-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592)
|
||||
* Add setting to disable logging configuration by [@isra17](https://github.com/isra17) in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575)
|
||||
* Improve debug logging for nested Servers / Clients by [@strawgate](https://github.com/strawgate) in [#1604](https://github.com/PrefectHQ/fastmcp/pull/1604)
|
||||
* Add GitHub pull request template by [@strawgate](https://github.com/strawgate) in [#1581](https://github.com/PrefectHQ/fastmcp/pull/1581)
|
||||
|
|
@ -3063,4 +3968,4 @@ This release is highlighted by the ability to handle complex JSON objects as MCP
|
|||
The very first release of FastMCP! 🎉
|
||||
|
||||
**Full Changelog**: [Initial commits](https://github.com/PrefectHQ/fastmcp/commits/v0.1.0)
|
||||
</Update>
|
||||
</Update>
|
||||
|
|
|
|||
|
|
@ -23,21 +23,24 @@ fastmcp auth cimd create \
|
|||
|
||||
```json
|
||||
{
|
||||
"client_id": "https://your-domain.com/oauth/client.json",
|
||||
"client_id": "https://YOUR-DOMAIN.com/path/to/client.json",
|
||||
"client_name": "My App",
|
||||
"redirect_uris": ["http://localhost:*/callback"],
|
||||
"token_endpoint_auth_method": "none"
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"]
|
||||
}
|
||||
```
|
||||
|
||||
The generated document includes a placeholder `client_id` — update it to match the URL where you'll host the document before deploying.
|
||||
By default, the generated document includes a placeholder `client_id`. Update it to match the URL where you'll host the document before deploying, or pass `--client-id` when generating the file.
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Flag | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Name | `--name` | **Required.** Human-readable client name |
|
||||
| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (repeatable) |
|
||||
| Redirect URI | `--redirect-uri`, `-r` | **Required.** Allowed redirect URIs (repeatable) |
|
||||
| Client ID | `--client-id` | URL where this document will be hosted; defaults to a placeholder |
|
||||
| Client URI | `--client-uri` | Client's home page URL |
|
||||
| Logo URI | `--logo-uri` | Client's logo URL |
|
||||
| Scope | `--scope` | Space-separated list of scopes |
|
||||
|
|
@ -51,6 +54,7 @@ fastmcp auth cimd create \
|
|||
--name "My Production App" \
|
||||
--redirect-uri "http://localhost:*/callback" \
|
||||
--redirect-uri "https://myapp.example.com/callback" \
|
||||
--client-id "https://myapp.example.com/oauth/client.json" \
|
||||
--client-uri "https://myapp.example.com" \
|
||||
--scope "read write" \
|
||||
--output client.json
|
||||
|
|
|
|||
|
|
@ -104,11 +104,28 @@ Some tools request additional input during execution through MCP's elicitation m
|
|||
| ------ | ---- | ----------- |
|
||||
| Command | `--command` | Connect via stdio |
|
||||
| Transport | `--transport`, `-t` | Force `http` or `sse` |
|
||||
| Prompt | `--prompt` | Treat the target as a prompt name instead of a tool/resource |
|
||||
| Input JSON | `--input-json` | Base arguments as JSON (merged with `key=value`) |
|
||||
| JSON | `--json` | Raw JSON output |
|
||||
| Timeout | `--timeout` | Connection timeout in seconds |
|
||||
| Auth | `--auth` | `oauth`, a bearer token, or `none` |
|
||||
|
||||
## Reading Resources and Getting Prompts
|
||||
|
||||
`fastmcp call` can also read resources and render prompts. If the target contains `://`, the CLI treats it as a resource URI and calls `read_resource`:
|
||||
|
||||
```bash
|
||||
fastmcp call server.py resource://docs/readme
|
||||
fastmcp call server.py file:///tmp/example.txt --json
|
||||
```
|
||||
|
||||
To get a prompt, pass `--prompt`; prompt arguments use the same `key=value` and `--input-json` forms as tool calls:
|
||||
|
||||
```bash
|
||||
fastmcp call server.py summarize --prompt topic=weather
|
||||
fastmcp call server.py summarize --prompt --input-json '{"topic": "weather"}'
|
||||
```
|
||||
|
||||
## Discovering Configured Servers
|
||||
|
||||
`fastmcp discover` scans your machine for MCP servers configured in editors and tools. It checks:
|
||||
|
|
@ -138,3 +155,7 @@ Any server that appears here can be used by name with `list`, `call`, and other
|
|||
For LLM agents that can execute shell commands but don't have native MCP support, the CLI provides a clean bridge. The agent calls `fastmcp list --json` to discover available tools with full schemas, then `fastmcp call --json` to invoke them with structured results.
|
||||
|
||||
Because the CLI handles connection management, transport selection, and type coercion internally, the agent doesn't need to understand MCP protocol details — it just reads JSON and constructs shell commands.
|
||||
|
||||
## Remote Stdio Bridges
|
||||
|
||||
For MCP hosts that expect a local stdio command but need to connect to a remote HTTP server, use [`fastmcp-remote`](/clients/fastmcp-remote). It provides a small standalone bridge for host configuration, while `fastmcp list` and `fastmcp call` remain focused on direct inspection and invocation from the terminal.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ fastmcp inspect server.py --format mcp -o manifest.json
|
|||
| ------ | ---- | ----------- |
|
||||
| Format | `--format`, `-f` | `fastmcp` or `mcp` (required when using `-o`) |
|
||||
| Output File | `--output`, `-o` | Save to file instead of stdout |
|
||||
| Python | `--python` | Python version to use when running via `uv` |
|
||||
| Extra Packages | `--with` | Additional packages to install (repeatable) |
|
||||
| Project | `--project` | Run within a specific uv project directory |
|
||||
| Requirements | `--with-requirements` | Install from a requirements file |
|
||||
| Skip Env | `--skip-env` | Do not set up a uv environment |
|
||||
|
||||
## Entrypoints
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
```bash
|
||||
fastmcp install claude-desktop server.py
|
||||
fastmcp install claude-code server.py --with pandas --with matplotlib
|
||||
fastmcp install cursor server.py -e .
|
||||
fastmcp install cursor server.py --with-editable .
|
||||
```
|
||||
|
||||
<Warning>
|
||||
|
|
@ -41,14 +41,13 @@ Because MCP clients run servers in isolation, you need to tell the install comma
|
|||
|
||||
```bash
|
||||
fastmcp install claude-desktop server.py --with pandas --with "sqlalchemy>=2.0"
|
||||
fastmcp install cursor server.py -e . --with-requirements requirements.txt
|
||||
fastmcp install cursor server.py --with-editable . --with-requirements requirements.txt
|
||||
```
|
||||
|
||||
**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file, dependencies are picked up automatically:
|
||||
**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file explicitly, dependencies are picked up automatically:
|
||||
|
||||
```bash
|
||||
fastmcp install claude-desktop fastmcp.json
|
||||
fastmcp install claude-desktop # auto-detects fastmcp.json in current directory
|
||||
```
|
||||
|
||||
See [Server Configuration](/deployment/server-configuration) for the full config format.
|
||||
|
|
@ -57,15 +56,19 @@ See [Server Configuration](/deployment/server-configuration) for the full config
|
|||
|
||||
| Option | Flag | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Server Name | `--server-name`, `-n` | Custom name for the server |
|
||||
| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode |
|
||||
| Server Name | `--name`, `-n` | Custom name for the server |
|
||||
| Editable Package | `--with-editable` | Install a directory in editable mode |
|
||||
| Extra Packages | `--with` | Additional packages (repeatable) |
|
||||
| Environment Variables | `--env` | `KEY=VALUE` pairs (repeatable) |
|
||||
| Environment File | `--env-file`, `-f` | Load env vars from a `.env` file |
|
||||
| Environment File | `--env-file` | Load env vars from a `.env` file |
|
||||
| Python | `--python` | Python version (e.g., `3.11`) |
|
||||
| Project | `--project` | Run within a uv project directory |
|
||||
| Requirements | `--with-requirements` | Install from a requirements file |
|
||||
| Config Path | `--config-path` | Custom path to Claude Desktop config directory (`claude-desktop` only) |
|
||||
| Workspace | `--workspace` | Install to the workspace directory instead of globally (`cursor` only) |
|
||||
| Copy | `--copy` | Copy the generated output to the clipboard (`mcp-json` and `stdio` only) |
|
||||
|
||||
`goose` installs through a deeplink that runs your server with `uvx`, so it accepts only `--name`, `--with`, and `--python`. Options that depend on a local uv project — `--with-editable`, `--project`, and `--with-requirements` — are unavailable there. Deeplinks also cannot carry environment variables: passing `--env` or `--env-file` exits with an error directing you to `fastmcp install mcp-json`, which generates a config you can add to Goose by hand with the variables included.
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -73,12 +76,12 @@ See [Server Configuration](/deployment/server-configuration) for the full config
|
|||
# Basic install with auto-detected server instance
|
||||
fastmcp install claude-desktop server.py
|
||||
|
||||
# Install from fastmcp.json with auto-detection
|
||||
fastmcp install claude-desktop
|
||||
# Install from fastmcp.json
|
||||
fastmcp install claude-desktop fastmcp.json
|
||||
|
||||
# Explicit entrypoint with dependencies
|
||||
fastmcp install claude-desktop server.py:my_server \
|
||||
--server-name "My Analysis Server" \
|
||||
--name "My Analysis Server" \
|
||||
--with pandas
|
||||
|
||||
# With environment variables
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fastmcp --help
|
|||
| [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose |
|
||||
| [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report |
|
||||
| [`list`](/cli/client) | List a server's tools (and optionally resources and prompts) |
|
||||
| [`call`](/cli/client#calling-tools) | Call a single tool with arguments |
|
||||
| [`call`](/cli/client#calling-tools) | Call a tool, read a resource, or get a prompt |
|
||||
| [`discover`](/cli/client#discovering-configured-servers) | Find MCP servers configured in your editors and tools |
|
||||
| [`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 |
|
||||
|
|
@ -89,10 +89,10 @@ To skip authentication entirely — useful for local development servers — pas
|
|||
fastmcp call http://localhost:8000/mcp my_tool --auth none
|
||||
```
|
||||
|
||||
You can also pass a bearer token directly:
|
||||
You can also pass a bearer token directly. Give the token value on its own; FastMCP adds the `Bearer` prefix when it builds the `Authorization` header.
|
||||
|
||||
```bash
|
||||
fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..."
|
||||
fastmcp list http://localhost:8000/mcp --auth "sk-..."
|
||||
```
|
||||
|
||||
## Transport Override
|
||||
|
|
|
|||
|
|
@ -69,19 +69,22 @@ fastmcp run mcp.json
|
|||
```
|
||||
|
||||
<Warning>
|
||||
`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](/cli/overview#factory-functions).
|
||||
`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](#entrypoints).
|
||||
</Warning>
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Flag | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Transport | `--transport`, `-t` | `stdio` (default), `http`, or `sse` |
|
||||
| Transport | `--transport`, `-t` | `stdio` (default), `http` / `streamable-http`, or `sse` |
|
||||
| Host | `--host` | Bind address for HTTP (default: `127.0.0.1`) |
|
||||
| Port | `--port`, `-p` | Bind port for HTTP (default: `8000`) |
|
||||
| Path | `--path` | URL path for HTTP (default: `/mcp/`) |
|
||||
| Path | `--path` | URL path for HTTP (default: `/mcp` for `http`, `/sse` for `sse`) |
|
||||
| Log Level | `--log-level`, `-l` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
| No Banner | `--no-banner` | Suppress the startup banner |
|
||||
| Stateless | `--stateless` | Run without sessions, for serverless and multi-worker deployments |
|
||||
| Module Mode | `--module`, `-m` | Run a Python module via `python -m` instead of a file path |
|
||||
| Skip Source | `--skip-source` | Skip source preparation (use when the source is already prepared) |
|
||||
| Auto-Reload | `--reload` / `--no-reload` | Watch for file changes and restart automatically |
|
||||
| Reload Dirs | `--reload-dir` | Directories to watch (repeatable) |
|
||||
| Skip Env | `--skip-env` | Don't set up a uv environment (use when already in one) |
|
||||
|
|
@ -127,7 +130,7 @@ Auto-reload is on by default — save a file and the MCP server restarts automat
|
|||
|
||||
```bash
|
||||
fastmcp dev inspector server.py
|
||||
fastmcp dev inspector server.py -e . --with pandas
|
||||
fastmcp dev inspector server.py --with-editable . --with pandas
|
||||
```
|
||||
|
||||
<Tip>
|
||||
|
|
@ -140,7 +143,7 @@ The Inspector connects over **stdio only**. When it launches, you may need to se
|
|||
|
||||
| Option | Flag | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode |
|
||||
| Editable Package | `--with-editable` | Install a directory in editable mode |
|
||||
| Extra Packages | `--with` | Additional packages (repeatable) |
|
||||
| Inspector Version | `--inspector-version` | MCP Inspector version to use |
|
||||
| UI Port | `--ui-port` | Port for the Inspector UI |
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ async with Client(
|
|||
"https://your-server.fastmcp.app/mcp",
|
||||
auth="<your-token>",
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`:
|
||||
|
|
@ -52,12 +52,12 @@ transport = StreamableHttpTransport(
|
|||
)
|
||||
|
||||
async with Client(transport) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
## `BearerAuth` Helper
|
||||
|
||||
If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface.
|
||||
If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx2.Auth` interface.
|
||||
|
||||
```python {6}
|
||||
from fastmcp import Client
|
||||
|
|
@ -67,7 +67,7 @@ async with Client(
|
|||
"https://your-server.fastmcp.app/mcp",
|
||||
auth=BearerAuth(token="<your-token>"),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
|
@ -84,5 +84,5 @@ async with Client(
|
|||
headers={"X-API-Key": "<your-token>"},
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async with Client(
|
|||
client_metadata_url="https://myapp.example.com/oauth/client.json",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow.
|
||||
|
|
|
|||
89
docs/clients/auth/client-credentials.mdx
Normal file
89
docs/clients/auth/client-credentials.mdx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: Machine-to-Machine Authentication
|
||||
sidebarTitle: Client Credentials
|
||||
description: Authenticate your FastMCP client to a protected server without a browser.
|
||||
icon: robot
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
<Tip>
|
||||
Machine-to-machine authentication is only relevant for HTTP-based transports.
|
||||
</Tip>
|
||||
|
||||
When a FastMCP client runs without a human present — a backend service, a scheduled job, a CI pipeline, one MCP server calling another — it cannot complete the browser-based [OAuth](/clients/auth/oauth) flow. Instead it authenticates as itself using the OAuth 2.0 **client credentials** grant: the client presents its own credentials directly to the authorization server, receives an access token, and attaches that token to every request. There is no redirect, no consent screen, and no user.
|
||||
|
||||
FastMCP provides two providers for this, both implementing the `httpx2.Auth` interface so they drop into the same `auth=` parameter as every other client auth option. You pass the **MCP server URL**, not a token endpoint — the token endpoint is discovered from the server's OAuth metadata, exactly as the interactive `OAuth` helper does. As with `OAuth`, you can omit the URL entirely and let the transport supply it.
|
||||
|
||||
## Client ID and Secret
|
||||
|
||||
The common case is a pre-registered client with an ID and a secret. Use `ClientCredentialsOAuthProvider` and pass it to the `auth` parameter of your `Client` or transport:
|
||||
|
||||
```python {2, 4-8, 10}
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.auth import ClientCredentialsOAuthProvider
|
||||
|
||||
auth = ClientCredentialsOAuthProvider(
|
||||
client_id="my-client-id",
|
||||
client_secret="my-client-secret",
|
||||
scopes=["read", "write"],
|
||||
)
|
||||
|
||||
async with Client("https://example.com/mcp", auth=auth) as client:
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
The provider discovers the authorization server, exchanges the credentials for an access token, and caches the token in memory for the life of the client. When the token expires it is re-acquired automatically on the next request. Because re-acquiring a token is a single non-interactive request, tokens are held in memory by default with no warning — unlike the interactive `OAuth` flow, losing the cache on restart costs nothing.
|
||||
|
||||
### `ClientCredentialsOAuthProvider` Parameters
|
||||
|
||||
- **`mcp_url`** (`str`, optional): Full URL to the MCP endpoint. Omit it when passing the provider to `Client(auth=...)` — the transport supplies the URL automatically.
|
||||
- **`client_id`** (`str`, required): The pre-registered OAuth client ID.
|
||||
- **`client_secret`** (`str`, required): The OAuth client secret.
|
||||
- **`scopes`** (`str | list[str]`, optional): Scopes to request, as a space-separated string or a list.
|
||||
- **`token_endpoint_auth_method`** (`"client_secret_basic" | "client_secret_post"`, optional): How the credentials are presented to the token endpoint. Defaults to `"client_secret_basic"` (an HTTP Basic `Authorization` header); use `"client_secret_post"` to send them in the request body instead.
|
||||
- **`token_storage`** (`AsyncKeyValue`, optional): A key-value store for the acquired token. Defaults to in-memory storage.
|
||||
|
||||
## Private Key JWT
|
||||
|
||||
Some authorization servers require the client to prove its identity with a signed JWT assertion (RFC 7523 `private_key_jwt`) instead of a shared secret. This is common with workload identity federation, where the assertion comes from a cloud identity provider. Use `PrivateKeyJWTOAuthProvider` and supply an `assertion_provider` — an async callback that receives the authorization server's issuer identifier (the required JWT audience) and returns the assertion.
|
||||
|
||||
For a locally signed assertion, build the callback with `SignedJWTParameters`:
|
||||
|
||||
```python {4-7, 9, 11-15, 17-20, 22}
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.auth import (
|
||||
PrivateKeyJWTOAuthProvider,
|
||||
SignedJWTParameters,
|
||||
)
|
||||
|
||||
private_key_pem = Path("client-signing-key.pem").read_text()
|
||||
|
||||
jwt_params = SignedJWTParameters(
|
||||
issuer="my-client-id",
|
||||
subject="my-client-id",
|
||||
signing_key=private_key_pem,
|
||||
)
|
||||
|
||||
auth = PrivateKeyJWTOAuthProvider(
|
||||
client_id="my-client-id",
|
||||
assertion_provider=jwt_params.create_assertion_provider(),
|
||||
)
|
||||
|
||||
async with Client("https://example.com/mcp", auth=auth) as client:
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
If you already have a JWT from an identity provider, wrap it with `static_assertion_provider`, or pass your own `async def provider(audience: str) -> str` callback to fetch one on demand.
|
||||
|
||||
### `PrivateKeyJWTOAuthProvider` Parameters
|
||||
|
||||
- **`mcp_url`** (`str`, optional): Full URL to the MCP endpoint. Omit it when passing the provider to `Client(auth=...)`.
|
||||
- **`client_id`** (`str`, required): The OAuth client ID.
|
||||
- **`assertion_provider`** (`Callable[[str], Awaitable[str]]`, required): Async callback that receives the authorization server's issuer identifier and returns a signed JWT assertion.
|
||||
- **`scopes`** (`str | list[str]`, optional): Scopes to request, as a space-separated string or a list.
|
||||
- **`token_storage`** (`AsyncKeyValue`, optional): A key-value store for the acquired token. Defaults to in-memory storage.
|
||||
|
|
@ -29,13 +29,13 @@ from fastmcp import Client
|
|||
|
||||
# Uses default OAuth settings
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
|
||||
### `OAuth` Helper
|
||||
|
||||
To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface.
|
||||
To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx2.Auth` interface.
|
||||
|
||||
```python {2, 4, 6}
|
||||
from fastmcp import Client
|
||||
|
|
@ -44,7 +44,7 @@ from fastmcp.client.auth import OAuth
|
|||
oauth = OAuth(scopes=["user"])
|
||||
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -61,7 +61,7 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` —
|
|||
- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options
|
||||
- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration
|
||||
- **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port
|
||||
- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients
|
||||
- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx2 clients
|
||||
|
||||
|
||||
## OAuth Flow
|
||||
|
|
@ -125,7 +125,7 @@ encrypted_storage = FernetEncryptionWrapper(
|
|||
oauth = OAuth(token_storage=encrypted_storage)
|
||||
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption.
|
||||
|
|
@ -150,7 +150,7 @@ async with Client(
|
|||
client_metadata_url="https://myapp.example.com/oauth/client.json",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.
|
||||
|
|
@ -172,7 +172,7 @@ async with Client(
|
|||
client_secret="my-client-secret",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
Public clients that rely on PKCE for security can omit `client_secret`:
|
||||
|
|
|
|||
89
docs/clients/client-only-package.mdx
Normal file
89
docs/clients/client-only-package.mdx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: Client-Only Package
|
||||
description: Use FastMCP's client without installing the full server framework.
|
||||
icon: box
|
||||
---
|
||||
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="3.3.0" />
|
||||
|
||||
FastMCP's full `fastmcp` package includes everything needed to build and run MCP servers, apps, proxies, and clients. If you are only embedding an MCP client in another framework, building your own LLM host, or testing MCP servers, you can install the smaller client-only package instead.
|
||||
|
||||
```bash
|
||||
pip install "fastmcp-slim[client]"
|
||||
```
|
||||
|
||||
The client-only package uses the `fastmcp` import namespace:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("https://example.com/mcp")
|
||||
```
|
||||
|
||||
Use `fastmcp-slim[client]` when your code connects to MCP servers but does not define or run FastMCP servers itself. For example, framework authors can depend on `fastmcp-slim[client]` to provide MCP connectivity without requiring users to install the full FastMCP server stack.
|
||||
|
||||
## Supported Usage
|
||||
|
||||
Client-only installs support remote and subprocess transports:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
# Remote MCP server
|
||||
http_client = Client("https://example.com/mcp")
|
||||
|
||||
# Local MCP server over stdio
|
||||
stdio_client = Client("my_server.py")
|
||||
```
|
||||
|
||||
Single-server MCP configuration works as well:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"weather": {
|
||||
"url": "https://weather.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client = Client(config)
|
||||
```
|
||||
|
||||
Optional sampling handlers are available through the same extras as the full package:
|
||||
|
||||
```bash
|
||||
pip install "fastmcp-slim[client,openai]"
|
||||
pip install "fastmcp-slim[client,anthropic]"
|
||||
pip install "fastmcp-slim[client,gemini]"
|
||||
```
|
||||
|
||||
## When to Use the Full Package
|
||||
|
||||
Install `fastmcp` when you need server-side FastMCP features:
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
```
|
||||
|
||||
The full package remains the default for most users and continues to support the existing import style:
|
||||
|
||||
```python
|
||||
from fastmcp import Client, FastMCP
|
||||
|
||||
server = FastMCP("Example")
|
||||
client = Client(server)
|
||||
```
|
||||
|
||||
Use the full package for:
|
||||
|
||||
- defining or running FastMCP servers
|
||||
- in-memory clients connected directly to `FastMCP` server objects
|
||||
- multi-server MCP configurations
|
||||
- FastMCP apps, proxies, server auth, middleware, and other server-side features
|
||||
|
||||
The `fastmcp-slim` package is intentionally narrower: it is for client-only consumers who want FastMCP's MCP client behavior without depending on the full framework.
|
||||
|
|
@ -37,9 +37,6 @@ client = Client("my_mcp_server.py")
|
|||
|
||||
async def main():
|
||||
async with client:
|
||||
# Basic server interaction
|
||||
await client.ping()
|
||||
|
||||
# List available operations
|
||||
tools = await client.list_tools()
|
||||
resources = await client.list_resources()
|
||||
|
|
@ -67,16 +64,21 @@ server = FastMCP("TestServer")
|
|||
client = Client(server) # In-memory, no network or subprocess
|
||||
```
|
||||
|
||||
**STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. The subprocess runs in an isolated environment, so you must explicitly pass any environment variables the server needs.
|
||||
**STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. By default, the subprocess receives the MCP SDK's default environment; pass an explicit transport when you need to add environment variables, set a working directory, or control process reuse.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import PythonStdioTransport
|
||||
|
||||
# Simple inference from file path
|
||||
client = Client("my_server.py")
|
||||
|
||||
# With explicit environment configuration
|
||||
client = Client("my_server.py", env={"API_KEY": "secret"})
|
||||
transport = PythonStdioTransport(
|
||||
"my_server.py",
|
||||
env={"API_KEY": "secret"},
|
||||
)
|
||||
client = Client(transport)
|
||||
```
|
||||
|
||||
**HTTP transport** connects to servers running as web services. Use this for production deployments where the server runs independently and manages its own lifecycle.
|
||||
|
|
@ -121,7 +123,7 @@ async with client:
|
|||
|
||||
## Connection Lifecycle
|
||||
|
||||
The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions.
|
||||
The client uses context managers for connection management. When you enter the context, the client establishes a connection and negotiates the protocol era with the server. Metadata returned by either legacy initialization or modern discovery is exposed through the same client properties.
|
||||
|
||||
```python
|
||||
from fastmcp import Client, FastMCP
|
||||
|
|
@ -134,18 +136,20 @@ def greet(name: str) -> str:
|
|||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Initialization already happened automatically
|
||||
print(f"Server: {client.initialize_result.serverInfo.name}")
|
||||
print(f"Instructions: {client.initialize_result.instructions}")
|
||||
print(f"Capabilities: {client.initialize_result.capabilities.tools}")
|
||||
# Protocol negotiation already happened automatically
|
||||
assert client.server_info is not None
|
||||
assert client.server_capabilities is not None
|
||||
print(f"Server: {client.server_info.name}")
|
||||
print(f"Instructions: {client.instructions}")
|
||||
print(f"Capabilities: {client.server_capabilities.tools}")
|
||||
```
|
||||
|
||||
For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually:
|
||||
For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually. `initialize()` is a handshake-era operation, so pin the connection with `mode="legacy"`: the modern protocol has no `initialize` round trip, and calling it on a modern connection raises.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("my_mcp_server.py", auto_initialize=False)
|
||||
client = Client("my_mcp_server.py", auto_initialize=False, mode="legacy")
|
||||
|
||||
async with client:
|
||||
# Connection established, but not initialized yet
|
||||
|
|
@ -154,12 +158,144 @@ async with client:
|
|||
|
||||
# Initialize manually with custom timeout
|
||||
result = await client.initialize(timeout=10.0)
|
||||
print(f"Server: {result.serverInfo.name}")
|
||||
print(f"Server: {result.server_info.name}")
|
||||
|
||||
# Now ready for operations
|
||||
tools = await client.list_tools()
|
||||
```
|
||||
|
||||
## Protocol negotiation
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects.
|
||||
|
||||
By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
# Negotiate the newest era the server supports (the default)
|
||||
client = Client("https://example.com/mcp", mode="auto")
|
||||
```
|
||||
|
||||
Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object.
|
||||
|
||||
```python
|
||||
client = Client("https://example.com/mcp", mode="legacy")
|
||||
```
|
||||
|
||||
Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open.
|
||||
|
||||
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
|
||||
|
||||
A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them.
|
||||
|
||||
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
|
||||
|
||||
```python
|
||||
client = Client("https://example.com/mcp", mode="2026-07-28")
|
||||
```
|
||||
|
||||
Once connected, the negotiated version, server identity, capabilities, and instructions are available as properties. They are populated from either the legacy `InitializeResult` or modern `DiscoverResult`, and reset to `None` when the client disconnects. `instructions` is also `None` when the server does not provide any.
|
||||
|
||||
When you pin a modern version directly, the client skips discovery and adopts that version with minimal synthesized metadata. In that mode, `server_info` has an empty name and `instructions` is `None`.
|
||||
|
||||
```python
|
||||
async with Client("https://example.com/mcp", mode="auto") as client:
|
||||
print(client.protocol_version) # e.g. "2026-07-28"
|
||||
print(client.server_info) # Implementation | None
|
||||
print(client.server_capabilities) # ServerCapabilities | None
|
||||
print(client.instructions) # str | None
|
||||
```
|
||||
|
||||
<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.
|
||||
</Note>
|
||||
|
||||
## Response caching
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
The client can cache the results of `list_tools`, `list_resources`, and `list_prompts` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them — a cache is inert on a legacy connection.
|
||||
|
||||
Enable the default in-memory cache by passing `cache=True`. It respects the `ttlMs` and `cacheScope` hints the server attaches to each response.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("https://example.com/mcp", mode="auto", cache=True)
|
||||
|
||||
async with client:
|
||||
tools = await client.list_tools() # fetched from the server
|
||||
tools = await client.list_tools() # served from the cache
|
||||
```
|
||||
|
||||
The default (`cache=None`) and `cache=False` both disable caching. For control over the store, TTL, or partitioning, pass a `CacheConfig`. A custom config requires a `target_id`, since in-memory FastMCP transports expose no server URL to derive a shared-store identity from.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from mcp.client.caching import CacheConfig
|
||||
|
||||
config = CacheConfig(target_id="weather-api", default_ttl_ms=60_000)
|
||||
client = Client("https://example.com/mcp", mode="auto", cache=config)
|
||||
```
|
||||
|
||||
The high-level `list_tools`, `list_resources`, and `list_prompts` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `list_tools_mcp`, `list_resources_mcp`, `list_resource_templates_mcp`, and `list_prompts_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
fresh = await client.list_tools_mcp(cache_mode="refresh")
|
||||
```
|
||||
|
||||
### Sharing a cache across clients
|
||||
|
||||
The default cache lives in each client's process. To share cached responses across a fleet — a set of proxy replicas backed by one Redis, for example — pass a `KeyValueResponseCacheStore`, FastMCP's adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy use. It accepts any compatible backend (memory, Redis, and more).
|
||||
|
||||
A shared store mingles responses from different principals, so it requires an explicit `partition` that isolates them. Derive the partition from a verified credential — never from request data or the server URL — and construct a new client when the principal changes. Only responses the server marks `"public"` are ever served across partitions.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.caching import KeyValueResponseCacheStore
|
||||
from mcp.client.caching import CacheConfig
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
|
||||
backend = RedisStore(url="redis://localhost")
|
||||
store = KeyValueResponseCacheStore(storage=backend)
|
||||
|
||||
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
|
||||
client = Client("https://example.com/mcp", mode="auto", cache=config)
|
||||
```
|
||||
|
||||
The adapter serializes each result through a type-tagged envelope validated against an allowlist of cacheable result models, so a value naming an unknown type is treated as a cache miss rather than deserialized blindly. Each store instance owns its own collection namespace; `clear()` affects only that namespace, never another tenant's entries.
|
||||
|
||||
## Client extensions
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from myproject.extensions import AppsExtension
|
||||
|
||||
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
|
||||
```
|
||||
|
||||
Each extension's contributions are threaded into the underlying session. FastMCP folds in its own internal extension for [background tasks](/clients/tasks) automatically, and your own extensions *compose* with it rather than replacing it — pass your own tasks extension with the same identifier if you need to override it. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
|
||||
|
||||
For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
|
||||
|
||||
```python
|
||||
client = Client(
|
||||
"https://example.com/mcp",
|
||||
extensions=[AppsExtension()],
|
||||
result_claims={"example.com/apps": [extra_claim]},
|
||||
)
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
FastMCP clients interact with three types of server components.
|
||||
|
|
@ -201,6 +337,8 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
|
|||
|
||||
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
|
||||
|
||||
Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.logging import LogMessage
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ Use this when you need to respond to server requests for user input during tool
|
|||
|
||||
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
|
||||
|
||||
<Note>
|
||||
**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form.
|
||||
</Note>
|
||||
|
||||
## Handler Template
|
||||
|
||||
```python
|
||||
|
|
@ -30,8 +34,8 @@ async def elicitation_handler(
|
|||
|
||||
Args:
|
||||
message: The prompt to display to the user
|
||||
response_type: Python dataclass type for the response (None if no data expected)
|
||||
params: Original MCP elicitation parameters including raw JSON schema
|
||||
response_type: Python dataclass type for form responses (None for URL requests or empty schemas)
|
||||
params: Original MCP elicitation parameters
|
||||
context: Request context with metadata
|
||||
|
||||
Returns:
|
||||
|
|
@ -44,18 +48,24 @@ async def elicitation_handler(
|
|||
if not user_input:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
# URL requests and empty-object schemas have no response type to construct,
|
||||
# so accepting is the whole response.
|
||||
if response_type is None:
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
# Create response using the provided dataclass type
|
||||
return response_type(value=user_input)
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler,
|
||||
)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
When a server needs user input, it sends an elicitation request with a message prompt and a JSON schema describing the expected response structure. FastMCP automatically converts this schema into a Python dataclass type, making it easy to construct properly typed responses without manually parsing JSON schemas.
|
||||
When a server needs user input, it sends an elicitation request with a message prompt. Form elicitation requests include a JSON schema describing the expected response structure, and FastMCP automatically converts that schema into a Python dataclass type. URL elicitation requests and empty-object schemas use `response_type=None`.
|
||||
|
||||
The handler receives four parameters:
|
||||
|
||||
|
|
@ -65,11 +75,11 @@ The handler receives four parameters:
|
|||
</ResponseField>
|
||||
|
||||
<ResponseField name="response_type" type="type | None">
|
||||
A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing. If the server requests an empty object, this will be `None`.
|
||||
A Python dataclass type that FastMCP created from a form request's JSON schema. Use this to construct your response with proper typing. For URL requests or empty-object schemas, this will be `None`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="params" type="ElicitRequestParams">
|
||||
The original MCP elicitation parameters, including the raw JSON schema in `params.requestedSchema`
|
||||
The original MCP elicitation parameters. Form requests carry the raw JSON schema on `params.requested_schema`; URL requests carry `params.url` instead and have no schema.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="context" type="RequestContext">
|
||||
|
|
@ -133,6 +143,24 @@ async def elicitation_handler(message, response_type, params, context):
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler
|
||||
)
|
||||
```
|
||||
|
||||
## Input-required rounds
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
On protocol version `2026-07-28` and later, a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above.
|
||||
|
||||
The `input_required_max_rounds` parameter caps how many rounds the client will answer before giving up, guarding against a server that never terminates. It defaults to `10`.
|
||||
|
||||
```python
|
||||
client = Client(
|
||||
"https://example.com/mcp",
|
||||
mode="auto",
|
||||
elicitation_handler=elicitation_handler,
|
||||
input_required_max_rounds=5,
|
||||
)
|
||||
```
|
||||
|
|
|
|||
169
docs/clients/fastmcp-remote.mdx
Normal file
169
docs/clients/fastmcp-remote.mdx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
title: fastmcp-remote
|
||||
description: Bridge remote MCP servers into stdio-only MCP hosts with uvx fastmcp-remote.
|
||||
icon: bridge
|
||||
---
|
||||
|
||||
`fastmcp-remote` is FastMCP's standalone stdio bridge for remote MCP servers. Use it when an MCP host expects to launch a local command, but the server you want to use is hosted over Streamable HTTP or SSE.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"linear": {
|
||||
"command": "uvx",
|
||||
"args": ["fastmcp-remote", "https://mcp.linear.app/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The package is powered by FastMCP. It builds one FastMCP client for the remote URL, exposes that client as a local stdio proxy, and keeps the executable focused on that bridge. For running Python server files, local project environments, FastMCP config files, and development reload loops, use [`fastmcp run`](/cli/running).
|
||||
|
||||
The command shape follows the original [`mcp-remote`](https://github.com/geelen/mcp-remote) npm project, which established this stdio-to-remote bridge pattern for MCP hosts.
|
||||
|
||||
## Installation
|
||||
|
||||
Most MCP hosts can run `fastmcp-remote` directly through `uvx`, so you usually do not need to install it yourself:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://example.com/mcp
|
||||
```
|
||||
|
||||
If your host requires an already-installed command, install the package with your Python package manager:
|
||||
|
||||
```bash
|
||||
uv tool install fastmcp-remote
|
||||
```
|
||||
|
||||
## Host Configuration
|
||||
|
||||
For hosts that use `mcpServers` JSON configuration, set the command to `uvx` and pass `fastmcp-remote` plus the remote server URL as arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-api": {
|
||||
"command": "uvx",
|
||||
"args": ["fastmcp-remote", "https://example.com/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint URLs and Connection Status
|
||||
|
||||
Pass the full MCP endpoint URL for the remote server. Many FastMCP HTTP servers expose MCP at `/mcp`, so a local development server may need `http://localhost:8000/mcp` rather than `http://localhost:8000`.
|
||||
|
||||
`fastmcp-remote` starts a local stdio bridge, then connects to the upstream server when the MCP host initializes that bridge. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or authentication cannot complete, initialization fails and the host should report the remote server as failed. After initialization succeeds, later tool, resource, prompt, and ping requests continue to proxy through the same remote server configuration.
|
||||
|
||||
OAuth is enabled automatically unless you provide an `Authorization` header or pass `--auth none`. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs.
|
||||
|
||||
To pass a bearer token or another custom header directly, provide `--header` in `Name: Value` form. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument. An `Authorization` header disables OAuth by default:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"private-api": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"fastmcp-remote",
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer <token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Repeat `--header` to send multiple headers:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://example.com/mcp \
|
||||
--header "Authorization: Bearer <token>" \
|
||||
--header "X-Workspace: production" \
|
||||
--header "X-Client-Name: My MCP Host" \
|
||||
--header "X-Callback-Url: https://example.com/oauth/callback"
|
||||
```
|
||||
|
||||
Some MCP hosts on Windows have trouble preserving spaces inside command arguments. Put the spaced value in an environment variable and reference it from the header value:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remote-api": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"fastmcp-remote",
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization:${AUTH_HEADER}"
|
||||
],
|
||||
"env": {
|
||||
"AUTH_HEADER": "Bearer <token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For local development servers over plain HTTP, disable OAuth when the server is unauthenticated:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote http://localhost:8000/mcp --auth none
|
||||
```
|
||||
|
||||
## Self-Signed Certificates
|
||||
|
||||
For servers behind a self-signed certificate, point `--verify` at a CA bundle that trusts the certificate:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://internal.example.com/mcp --verify /path/to/ca-bundle.pem
|
||||
```
|
||||
|
||||
To disable certificate verification entirely, pass `--verify false`. This is insecure and should only be used for trusted servers on private networks:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://internal.example.com/mcp --verify false
|
||||
```
|
||||
|
||||
To trust a CA bundle without a flag, set the standard `SSL_CERT_FILE` environment variable, which OpenSSL reads automatically:
|
||||
|
||||
```bash
|
||||
SSL_CERT_FILE=/path/to/ca-bundle.pem uvx fastmcp-remote https://internal.example.com/mcp
|
||||
```
|
||||
|
||||
## OAuth Storage
|
||||
|
||||
OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory:
|
||||
|
||||
```bash
|
||||
FASTMCP_REMOTE_CONFIG_DIR=~/.config/fastmcp-remote uvx fastmcp-remote https://example.com/mcp
|
||||
```
|
||||
|
||||
Use `--resource` to isolate tokens for a particular remote server identity:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://example.com/mcp --resource example-prod
|
||||
```
|
||||
|
||||
If the remote authorization server requires a fixed callback port or hostname, pass them after the URL:
|
||||
|
||||
```bash
|
||||
uvx fastmcp-remote https://example.com/mcp 3334 --host 127.0.0.1
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| `--transport` | Choose `http` or `sse`. Defaults to `http`. |
|
||||
| `--header` | Add a header to upstream requests, for example `--header "Authorization: Bearer <token>"`. Values may contain colons. Quote headers whose values contain spaces. Use `${VAR}` to expand environment variables inside values. Repeat for multiple headers. |
|
||||
| `--auth` | Choose `oauth` or `none`. The default uses OAuth unless an `Authorization` header is provided. |
|
||||
| `--verify` | Control TLS certificate verification. Pass a path to a CA bundle to trust a self-signed certificate, or `false` to disable verification (insecure). Defaults to verification enabled. |
|
||||
| `--resource` | Isolate OAuth token storage for a named remote resource. |
|
||||
| `--host` | Set the OAuth callback hostname. Defaults to `localhost`. |
|
||||
| `--auth-timeout` | Set how long to wait for the OAuth callback. Defaults to 300 seconds. |
|
||||
| `--ignore-tool` | Hide tools whose names match a glob pattern. Repeat for multiple patterns. |
|
||||
| `--debug` | Enable debug logging. |
|
||||
| `--silent` | Suppress non-critical logs. |
|
||||
|
|
@ -28,12 +28,32 @@ logging.basicConfig(
|
|||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
|
||||
LOGGING_LEVEL_MAP = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"NOTICE": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR,
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
"ALERT": logging.CRITICAL,
|
||||
"EMERGENCY": logging.CRITICAL,
|
||||
}
|
||||
|
||||
async def log_handler(message: LogMessage):
|
||||
"""Forward MCP server logs to Python's logging system."""
|
||||
msg = message.data.get('msg')
|
||||
extra = message.data.get('extra')
|
||||
data = message.data
|
||||
if isinstance(data, dict):
|
||||
msg = data.get('msg', data)
|
||||
extra = data.get('extra')
|
||||
else:
|
||||
msg = data
|
||||
extra = None
|
||||
|
||||
# Python's logging requires `extra` to be a mapping, but a server can send
|
||||
# any JSON value, so fold anything else into the message instead.
|
||||
if extra is not None and not isinstance(extra, dict):
|
||||
msg = f"{msg} ({extra})"
|
||||
extra = None
|
||||
|
||||
level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO)
|
||||
logger.log(level, msg, extra=extra)
|
||||
|
|
@ -55,19 +75,20 @@ The handler receives a `LogMessage` object:
|
|||
The logger name (may be None)
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="data" type="dict">
|
||||
The log payload, containing `msg` and `extra` keys
|
||||
<ResponseField name="data" type="Any">
|
||||
The JSON-serializable log payload sent by the server. FastMCP's structured logger uses a dictionary with `msg` and `extra` keys, but other MCP servers may send any JSON value.
|
||||
</ResponseField>
|
||||
</Card>
|
||||
|
||||
## Structured Logs
|
||||
|
||||
The `message.data` attribute is a dictionary containing the log payload. This enables structured logging with rich contextual information.
|
||||
The `message.data` attribute contains the server's JSON-serializable log payload. FastMCP servers commonly send a dictionary with `msg` and `extra` keys, which enables structured logging with rich contextual information.
|
||||
|
||||
```python
|
||||
async def detailed_log_handler(message: LogMessage):
|
||||
msg = message.data.get('msg')
|
||||
extra = message.data.get('extra')
|
||||
data = message.data
|
||||
msg = data.get('msg', data) if isinstance(data, dict) else data
|
||||
extra = data.get('extra') if isinstance(data, dict) else None
|
||||
|
||||
if message.level == "error":
|
||||
print(f"ERROR: {msg} | Details: {extra}")
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ from fastmcp import Client
|
|||
|
||||
async def message_handler(message):
|
||||
"""Handle MCP notifications from the server."""
|
||||
if hasattr(message, 'root'):
|
||||
method = message.root.method
|
||||
if hasattr(message, 'method'):
|
||||
method = message.method
|
||||
|
||||
if method == "notifications/tools/list_changed":
|
||||
print("Tools have changed - refresh tool cache")
|
||||
|
|
@ -31,6 +31,8 @@ async def message_handler(message):
|
|||
print("Resources have changed")
|
||||
elif method == "notifications/prompts/list_changed":
|
||||
print("Prompts have changed")
|
||||
elif method == "notifications/resources/updated":
|
||||
print("A resource was updated")
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
|
|
@ -113,6 +115,18 @@ class MyMessageHandler(MessageHandler):
|
|||
"""Called for progress updates during long-running operations."""
|
||||
pass
|
||||
|
||||
async def on_resource_updated(
|
||||
self, notification: mcp.types.ResourceUpdatedNotification
|
||||
) -> None:
|
||||
"""Called when a specific resource changes."""
|
||||
pass
|
||||
|
||||
async def on_cancelled(
|
||||
self, notification: mcp.types.CancelledNotification
|
||||
) -> None:
|
||||
"""Called when a request is cancelled."""
|
||||
pass
|
||||
|
||||
async def on_logging_message(
|
||||
self, notification: mcp.types.LoggingMessageNotification
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Request a rendered prompt with `get_prompt()`:
|
|||
async with client:
|
||||
# Simple prompt without arguments
|
||||
result = await client.get_prompt("welcome_message")
|
||||
# result -> mcp.types.GetPromptResult
|
||||
# result -> mcp_types.GetPromptResult
|
||||
|
||||
# Access the generated messages
|
||||
for message in result.messages:
|
||||
|
|
@ -128,12 +128,12 @@ See [Metadata](/servers/versioning#version-discovery) for how to discover availa
|
|||
|
||||
## Multi-Server Clients
|
||||
|
||||
When using multi-server clients, prompts are accessible directly without prefixing:
|
||||
When using multi-server clients, prompts are mounted with the server name as a prefix, just like tools:
|
||||
|
||||
```python
|
||||
async with client: # Multi-server client
|
||||
result1 = await client.get_prompt("weather_prompt", {"city": "London"})
|
||||
result2 = await client.get_prompt("assistant_prompt", {"query": "help"})
|
||||
result1 = await client.get_prompt("weather_weather_prompt", {"city": "London"})
|
||||
result2 = await client.get_prompt("assistant_assistant_prompt", {"query": "help"})
|
||||
```
|
||||
|
||||
## Raw Protocol Access
|
||||
|
|
@ -143,5 +143,5 @@ For complete control, use `get_prompt_mcp()` which returns the full MCP protocol
|
|||
```python
|
||||
async with client:
|
||||
result = await client.get_prompt_mcp("example_prompt", {"arg": "value"})
|
||||
# result -> mcp.types.GetPromptResult
|
||||
# result -> mcp_types.GetPromptResult
|
||||
```
|
||||
|
|
|
|||
|
|
@ -53,23 +53,30 @@ async with client:
|
|||
for item in content:
|
||||
if hasattr(item, 'text'):
|
||||
print(f"Text content: {item.text}")
|
||||
print(f"MIME type: {item.mimeType}")
|
||||
print(f"MIME type: {item.mime_type}")
|
||||
```
|
||||
|
||||
Binary resources include images, PDFs, and other non-text data:
|
||||
|
||||
Binary resources arrive as `BlobResourceContents`, whose `blob` field is a base64 **string**, so decode it before writing bytes to disk:
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
from mcp_types import BlobResourceContents
|
||||
|
||||
async with client:
|
||||
content = await client.read_resource("resource://images/logo.png")
|
||||
|
||||
for item in content:
|
||||
if hasattr(item, 'blob'):
|
||||
print(f"Binary content: {len(item.blob)} bytes")
|
||||
print(f"MIME type: {item.mimeType}")
|
||||
if isinstance(item, BlobResourceContents):
|
||||
data = base64.b64decode(item.blob)
|
||||
print(f"Binary content: {len(data)} bytes")
|
||||
print(f"MIME type: {item.mime_type}")
|
||||
|
||||
# Save to file
|
||||
with open("downloaded_logo.png", "wb") as f:
|
||||
f.write(item.blob)
|
||||
f.write(data)
|
||||
```
|
||||
|
||||
## Multi-Server Clients
|
||||
|
|
@ -106,5 +113,5 @@ For complete control, use `read_resource_mcp()` which returns the full MCP proto
|
|||
```python
|
||||
async with client:
|
||||
result = await client.read_resource_mcp("resource://example")
|
||||
# result -> mcp.types.ReadResourceResult
|
||||
# result -> mcp_types.ReadResourceResult
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Client Roots
|
||||
sidebarTitle: Roots
|
||||
description: Provide local context and resource boundaries to MCP servers.
|
||||
description: Tell servers which local paths your client can reach.
|
||||
icon: folder-tree
|
||||
---
|
||||
|
||||
|
|
@ -11,24 +11,26 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
Use this when you need to tell servers what local resources the client has access to.
|
||||
|
||||
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
|
||||
A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary.
|
||||
|
||||
Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form.
|
||||
|
||||
## Static Roots
|
||||
|
||||
Provide a list of roots when creating the client:
|
||||
When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
roots=["/path/to/root1", "/path/to/root2"]
|
||||
roots=["file:///path/to/root1", "file:///path/to/root2"]
|
||||
)
|
||||
```
|
||||
|
||||
## Dynamic Roots
|
||||
|
||||
Use a callback to compute roots dynamically when the server requests them:
|
||||
Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
|
@ -36,7 +38,7 @@ from fastmcp.client.roots import RequestContext
|
|||
|
||||
async def roots_callback(context: RequestContext) -> list[str]:
|
||||
print(f"Server requested roots (Request ID: {context.request_id})")
|
||||
return ["/path/to/root1", "/path/to/root2"]
|
||||
return ["file:///path/to/root1", "file:///path/to/root2"]
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: LLM Sampling
|
||||
sidebarTitle: Sampling
|
||||
description: Handle server-initiated LLM completion requests.
|
||||
description: Answer a server's request for an LLM completion.
|
||||
icon: robot
|
||||
---
|
||||
|
||||
|
|
@ -9,52 +9,46 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
|
|||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
Use this when you need to respond to server requests for LLM completions.
|
||||
Use this when a server asks your client to run an LLM completion on its behalf.
|
||||
|
||||
MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
|
||||
Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client.
|
||||
|
||||
The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it.
|
||||
|
||||
## Handler Template
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext
|
||||
from mcp.types import TextContent
|
||||
|
||||
|
||||
async def sampling_handler(
|
||||
messages: list[SamplingMessage],
|
||||
params: SamplingParams,
|
||||
context: RequestContext
|
||||
context: RequestContext,
|
||||
) -> str:
|
||||
"""
|
||||
Handle server requests for LLM completions.
|
||||
"""Run the server's messages against your LLM and return the completion."""
|
||||
conversation = [
|
||||
f"{message.role}: {message.content.text}"
|
||||
for message in messages
|
||||
if isinstance(message.content, TextContent)
|
||||
]
|
||||
system_prompt = params.system_prompt or "You are a helpful assistant."
|
||||
|
||||
Args:
|
||||
messages: Conversation messages to send to the LLM
|
||||
params: Sampling parameters (temperature, max_tokens, etc.)
|
||||
context: Request context with metadata
|
||||
|
||||
Returns:
|
||||
Generated text response from your LLM
|
||||
"""
|
||||
# Extract message content
|
||||
conversation = []
|
||||
for message in messages:
|
||||
content = message.content.text if hasattr(message.content, 'text') else str(message.content)
|
||||
conversation.append(f"{message.role}: {content}")
|
||||
|
||||
# Use the system prompt if provided
|
||||
system_prompt = params.systemPrompt or "You are a helpful assistant."
|
||||
|
||||
# Integrate with your LLM service here
|
||||
# Call your LLM here with `conversation` and `system_prompt`.
|
||||
return "Generated response based on the messages"
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
sampling_handler=sampling_handler,
|
||||
)
|
||||
|
||||
client = Client("my_mcp_server.py", sampling_handler=sampling_handler)
|
||||
```
|
||||
|
||||
The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes).
|
||||
|
||||
## Handler Parameters
|
||||
|
||||
Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore.
|
||||
|
||||
<Card icon="code" title="SamplingMessage">
|
||||
<ResponseField name="role" type='Literal["user", "assistant"]'>
|
||||
The role of the message
|
||||
|
|
@ -66,11 +60,11 @@ client = Client(
|
|||
</Card>
|
||||
|
||||
<Card icon="code" title="SamplingParams">
|
||||
<ResponseField name="systemPrompt" type="str | None">
|
||||
<ResponseField name="system_prompt" type="str | None">
|
||||
Optional system prompt the server wants to use
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="modelPreferences" type="ModelPreferences | None">
|
||||
<ResponseField name="model_preferences" type="ModelPreferences | None">
|
||||
Server preferences for model selection (hints, cost/speed/intelligence priorities)
|
||||
</ResponseField>
|
||||
|
||||
|
|
@ -78,11 +72,11 @@ client = Client(
|
|||
Sampling temperature
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="maxTokens" type="int">
|
||||
<ResponseField name="max_tokens" type="int">
|
||||
Maximum tokens to generate
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="stopSequences" type="list[str] | None">
|
||||
<ResponseField name="stop_sequences" type="list[str] | None">
|
||||
Stop sequences for sampling
|
||||
</ResponseField>
|
||||
|
||||
|
|
@ -90,14 +84,14 @@ client = Client(
|
|||
Tools the LLM can use during sampling
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="toolChoice" type="ToolChoice | None">
|
||||
<ResponseField name="tool_choice" type="ToolChoice | None">
|
||||
Tool usage behavior (`auto`, `required`, or `none`)
|
||||
</ResponseField>
|
||||
</Card>
|
||||
|
||||
## Built-in Handlers
|
||||
|
||||
FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use.
|
||||
Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover.
|
||||
|
||||
### OpenAI Handler
|
||||
|
||||
|
|
@ -113,9 +107,11 @@ client = Client(
|
|||
)
|
||||
```
|
||||
|
||||
For OpenAI-compatible APIs (like local models):
|
||||
Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = Client(
|
||||
|
|
@ -128,7 +124,7 @@ client = Client(
|
|||
```
|
||||
|
||||
<Note>
|
||||
Install the OpenAI handler with `pip install fastmcp[openai]`.
|
||||
Install the OpenAI handler with `pip install 'fastmcp[openai]'`.
|
||||
</Note>
|
||||
|
||||
### Anthropic Handler
|
||||
|
|
@ -146,7 +142,7 @@ client = Client(
|
|||
```
|
||||
|
||||
<Note>
|
||||
Install the Anthropic handler with `pip install fastmcp[anthropic]`.
|
||||
Install the Anthropic handler with `pip install 'fastmcp[anthropic]'`.
|
||||
</Note>
|
||||
|
||||
### Google Gemini Handler
|
||||
|
|
@ -155,36 +151,44 @@ Install the Anthropic handler with `pip install fastmcp[anthropic]`.
|
|||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.sampling.handlers.google_genai import GoogleGenAISamplingHandler
|
||||
from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHandler
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
sampling_handler=GoogleGenAISamplingHandler(default_model="gemini-2.0-flash"),
|
||||
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
Install the Google Gemini handler with `pip install fastmcp[gemini]`.
|
||||
Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`.
|
||||
</Note>
|
||||
|
||||
## Sampling Capabilities
|
||||
The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own.
|
||||
|
||||
When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
|
||||
## Tool Use
|
||||
|
||||
A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool.
|
||||
|
||||
Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from mcp.types import SamplingCapability
|
||||
|
||||
|
||||
async def text_only_handler(messages, params, context) -> str:
|
||||
return "Generated response based on the messages"
|
||||
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
sampling_handler=basic_handler,
|
||||
sampling_capabilities=SamplingCapability(), # No tool support
|
||||
sampling_handler=text_only_handler,
|
||||
sampling_capabilities=SamplingCapability(),
|
||||
)
|
||||
```
|
||||
|
||||
## Tool Execution
|
||||
## Request Routes
|
||||
|
||||
Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results.
|
||||
Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached.
|
||||
|
||||
<Tip>
|
||||
To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/src/fastmcp/client/sampling/handlers) as a reference.
|
||||
</Tip>
|
||||
One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests.
|
||||
|
|
|
|||
|
|
@ -1,180 +1,138 @@
|
|||
---
|
||||
title: Background Tasks
|
||||
sidebarTitle: Tasks
|
||||
description: Execute operations asynchronously and track their progress.
|
||||
description: Call long-running tools without blocking, and answer questions they ask mid-run.
|
||||
icon: clock
|
||||
tag: "NEW"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="2.14.0" />
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Use this when you need to run long operations asynchronously while doing other work.
|
||||
Some tool calls take a while. The MCP background tasks extension lets a server run one in the background instead of holding the request open, and FastMCP's client drives the whole thing for you — most of the time you don't need to know a call was tasked at all.
|
||||
|
||||
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
|
||||
<Note>
|
||||
**Client task support is opt-in.** Install the `fastmcp-tasks` package (`pip install "fastmcp[tasks]"`) and import it — importing `fastmcp_tasks` anywhere (which you do to use `call_tool_task`) enables task support for every `Client` in the process. Without it, a `Client` never advertises the tasks capability, so the server runs its calls synchronously and background tasks simply don't happen.
|
||||
|
||||
## Requesting Background Execution
|
||||
**Tasks also require the modern protocol.** The capability is negotiated over `2026-07-28` connections. `mode="auto"` (the client default) negotiates it automatically; `mode="legacy"` never does. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
Pass `task=True` to run an operation as a background task:
|
||||
## Transparent Calls
|
||||
|
||||
With task support enabled, just call the tool. If the server runs it as a background task, `call_tool` polls it to completion under the hood and returns the same result you'd get from a synchronous call — the task is invisible.
|
||||
|
||||
```python
|
||||
import fastmcp_tasks # enables client task support
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client(server, mode="auto") as client:
|
||||
result = await client.call_tool("slow_computation", {"duration": 10})
|
||||
print(result.data)
|
||||
```
|
||||
|
||||
This is the right default for most code: it works whether or not the server actually tasks the call, so you can write ordinary tool-calling code without checking server capabilities.
|
||||
|
||||
## Driving a Task Explicitly
|
||||
|
||||
When you want to do other work while a task runs — or check on it, or cancel it — use `call_tool_task` instead. It returns a `ToolTask` handle immediately rather than waiting for completion.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
async with Client(server) as client:
|
||||
# Start a background task
|
||||
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
|
||||
|
||||
async with Client(server, mode="auto") as client:
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
print(f"Task started: {task.task_id}")
|
||||
|
||||
# Do other work while it runs...
|
||||
|
||||
# Get the result when ready
|
||||
result = await task.result()
|
||||
```
|
||||
|
||||
This works with tools, resources, and prompts:
|
||||
|
||||
```python
|
||||
tool_task = await client.call_tool("my_tool", args, task=True)
|
||||
resource_task = await client.read_resource("file://large.txt", task=True)
|
||||
prompt_task = await client.get_prompt("my_prompt", args, task=True)
|
||||
```
|
||||
|
||||
## Task API
|
||||
|
||||
All task types share a common interface.
|
||||
|
||||
### Getting Results
|
||||
|
||||
Call `await task.result()` or simply `await task` to block until the task completes:
|
||||
|
||||
```python
|
||||
task = await client.call_tool("analyze", {"text": "hello"}, task=True)
|
||||
|
||||
# Wait for result (blocking)
|
||||
result = await task.result()
|
||||
# or: result = await task
|
||||
```
|
||||
`call_tool_task` requires the server to actually run the call as a task — if the tool isn't `task=True`, or the server doesn't have the tasks extension registered, it raises `ToolError`. Use it when you specifically need the handle; use `call_tool` when you just want the result.
|
||||
|
||||
### Checking Status
|
||||
|
||||
Check the current status without blocking:
|
||||
|
||||
```python
|
||||
status = await task.status()
|
||||
print(f"{status.status}: {status.statusMessage}")
|
||||
# status.status is "working", "completed", "failed", or "cancelled"
|
||||
print(f"{status.status}: {status.status_message}")
|
||||
# status.status is "working", "input_required", "completed", "failed", or "cancelled"
|
||||
```
|
||||
|
||||
### Waiting with Control
|
||||
|
||||
Use `task.wait()` for more control over waiting:
|
||||
`task.wait()` polls until a terminal state (or a specific one you name), without answering any input the task asks for — use it when you want to observe an `input_required` pause yourself rather than have it answered automatically.
|
||||
|
||||
```python
|
||||
# Wait up to 30 seconds for completion
|
||||
status = await task.wait(timeout=30.0)
|
||||
|
||||
# Wait for a specific state
|
||||
status = await task.wait(state="completed", timeout=30.0)
|
||||
status = await task.wait(state="input_required", timeout=30.0)
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
### Getting the Result
|
||||
|
||||
Cancel a running task:
|
||||
`task.result()` drives the task the rest of the way — including answering any input it asks for — and returns the finished result, same as `client.call_tool` would. Awaiting the task directly is shorthand for this.
|
||||
|
||||
```python
|
||||
result = await task.result()
|
||||
# or: result = await task
|
||||
```
|
||||
|
||||
By default a failed or cancelled task raises `ToolError`. Pass `raise_on_error=False` to `call_tool_task` to get an error result back instead.
|
||||
|
||||
### Cancellation
|
||||
|
||||
```python
|
||||
await task.cancel()
|
||||
```
|
||||
|
||||
## Status Updates
|
||||
Cancellation is cooperative — the task may still finish before the server notices the request.
|
||||
|
||||
Register callbacks to receive real-time status updates as the server reports progress:
|
||||
## Answering Questions Mid-Task
|
||||
|
||||
```python
|
||||
def on_status_change(status):
|
||||
print(f"Task {status.taskId}: {status.status} - {status.statusMessage}")
|
||||
|
||||
task.on_status_change(on_status_change)
|
||||
|
||||
# Async callbacks work too
|
||||
async def on_status_async(status):
|
||||
await log_status(status)
|
||||
|
||||
task.on_status_change(on_status_async)
|
||||
```
|
||||
|
||||
### Handler Template
|
||||
A task can pause partway through to ask a question, the same way a foreground [multi-round-trip](/clients/elicitation#input-required-rounds) tool does. Pass an `elicitation_handler` and both `call_tool` and `task.result()` answer it automatically as part of driving the task to completion:
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
def status_handler(status):
|
||||
"""
|
||||
Handle task status updates.
|
||||
async def handle_elicitation(message, response_type, params, context):
|
||||
return {"cuisine": "Thai", "vegetarian": True}
|
||||
|
||||
Args:
|
||||
status: Task status object with:
|
||||
- taskId: Unique task identifier
|
||||
- status: "working", "completed", "failed", or "cancelled"
|
||||
- statusMessage: Optional progress message from server
|
||||
"""
|
||||
if status.status == "working":
|
||||
print(f"Progress: {status.statusMessage}")
|
||||
elif status.status == "completed":
|
||||
print("Task completed")
|
||||
elif status.status == "failed":
|
||||
print(f"Task failed: {status.statusMessage}")
|
||||
|
||||
task.on_status_change(status_handler)
|
||||
async with Client(server, mode="auto", elicitation_handler=handle_elicitation) as client:
|
||||
result = await client.call_tool("plan_dinner", {})
|
||||
print(result.data)
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline.
|
||||
|
||||
```python
|
||||
task = await client.call_tool("my_tool", args, task=True)
|
||||
|
||||
if task.returned_immediately:
|
||||
print("Server executed immediately (no background support)")
|
||||
else:
|
||||
print("Running in background")
|
||||
|
||||
# Either way, this works
|
||||
result = await task.result()
|
||||
```
|
||||
|
||||
This lets you write task-aware client code without worrying about server capabilities.
|
||||
Without an `elicitation_handler`, a task that asks for input raises `ToolError` rather than hanging. See [server-side background tasks](/servers/tasks#gathering-input-mid-task) for how a tool asks a question in the first place.
|
||||
|
||||
## Example
|
||||
|
||||
Putting it together, here is a client that submits a background task with `call_tool_task` and awaits its result:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from fastmcp import Client
|
||||
from fastmcp_tasks import call_tool_task
|
||||
|
||||
async def main():
|
||||
async with Client(server) as client:
|
||||
# Start background task
|
||||
task = await client.call_tool(
|
||||
"slow_computation",
|
||||
{"duration": 10},
|
||||
task=True,
|
||||
)
|
||||
async with Client(server, mode="auto") as client:
|
||||
# Return immediately and drive the task yourself
|
||||
task = await call_tool_task(client, "slow_computation", {"duration": 10})
|
||||
print(f"Task started: {task.task_id}")
|
||||
|
||||
# Subscribe to updates
|
||||
def on_update(status):
|
||||
print(f"Progress: {status.statusMessage}")
|
||||
# Do other work while the task runs
|
||||
while True:
|
||||
status = await task.status()
|
||||
if status.status in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
print(f"Still working... ({status.status})")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
task.on_status_change(on_update)
|
||||
|
||||
# Do other work while task runs
|
||||
print("Doing other work...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Wait for completion and get result
|
||||
result = await task.result()
|
||||
print(f"Result: {result.content}")
|
||||
print(f"Result: {result.data}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async with client:
|
|||
Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). FastMCP exclusive.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name=".content" type="list[mcp.types.ContentBlock]">
|
||||
<ResponseField name=".content" type="list[mcp_types.ContentBlock]">
|
||||
Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.).
|
||||
</ResponseField>
|
||||
|
||||
|
|
@ -173,9 +173,9 @@ For complete control, use `call_tool_mcp()` which returns the raw MCP protocol o
|
|||
```python
|
||||
async with client:
|
||||
result = await client.call_tool_mcp("my_tool", {"param": "value"})
|
||||
# result -> mcp.types.CallToolResult
|
||||
# result -> mcp_types.CallToolResult
|
||||
|
||||
if result.isError:
|
||||
if result.is_error:
|
||||
print(f"Tool failed: {result.content}")
|
||||
else:
|
||||
print(f"Tool succeeded: {result.content}")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ Transports handle the underlying connection between your client and MCP servers.
|
|||
STDIO transport communicates with MCP servers through subprocess pipes. When using STDIO, your client launches and manages the server process, controlling its lifecycle and environment.
|
||||
|
||||
<Warning>
|
||||
STDIO servers run in isolated environments by default. They do not inherit your shell's environment variables. You must explicitly pass any configuration the server needs.
|
||||
STDIO servers inherit only a small allowlist of environment variables — just enough to locate an interpreter and a home directory. Anything else in your shell, including API keys and other credentials, does not reach the server unless you pass it through `env` explicitly.
|
||||
|
||||
The allowlist is platform-specific. On POSIX systems it is `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, and `USER`; on Windows it is `APPDATA`, `HOMEDRIVE`, `HOMEPATH`, `LOCALAPPDATA`, `PATH`, `PATHEXT`, `PROCESSOR_ARCHITECTURE`, `SYSTEMDRIVE`, `SYSTEMROOT`, `TEMP`, `USERNAME`, and `USERPROFILE`.
|
||||
</Warning>
|
||||
|
||||
```python
|
||||
|
|
@ -42,7 +44,7 @@ client = Client("my_server.py") # Limited - no configuration options
|
|||
|
||||
### Environment Variables
|
||||
|
||||
Since STDIO servers do not inherit your environment, you need strategies for passing configuration.
|
||||
Values you pass through `env` are merged on top of the inherited allowlist, so you add configuration rather than replacing the base environment. Anything your server needs beyond those six variables has to be listed explicitly.
|
||||
|
||||
**Selective forwarding** passes only the variables your server needs:
|
||||
|
||||
|
|
@ -63,7 +65,11 @@ client = Client(transport)
|
|||
from dotenv import dotenv_values
|
||||
from fastmcp.client.transports import StdioTransport
|
||||
|
||||
env = dotenv_values(".env")
|
||||
env = {
|
||||
key: value
|
||||
for key, value in dotenv_values(".env").items()
|
||||
if value is not None
|
||||
}
|
||||
transport = StdioTransport(command="python", args=["server.py"], env=env)
|
||||
client = Client(transport)
|
||||
```
|
||||
|
|
@ -80,7 +86,7 @@ client = Client(transport)
|
|||
|
||||
async def efficient_multiple_operations():
|
||||
async with client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
|
||||
async with client: # Reuses the same subprocess
|
||||
await client.call_tool("process_data", {"file": "data.csv"})
|
||||
|
|
@ -126,7 +132,7 @@ client = Client(
|
|||
|
||||
### SSL Verification
|
||||
|
||||
By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/):
|
||||
By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as httpx2 (documented in [httpx's SSL guide](https://www.python-httpx.org/advanced/ssl/), which httpx2 follows):
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* Banner styling -- improve readability with better contrast */
|
||||
/* Banner: an animated brand-rainbow wash behind Mintlify's white text.
|
||||
Mintlify always renders banner text white, so every gradient stop is a
|
||||
deep, saturated shade (all >=7:1 on white) — the colors evoke the FastMCP
|
||||
watercolor logo while keeping the announcement legible in both themes.
|
||||
A dark fallback color is configured in docs.json for the no-CSS case. */
|
||||
#banner {
|
||||
background: #f1f5f9 !important;
|
||||
color: #1e293b !important;
|
||||
font-size: 0.95rem !important;
|
||||
font-weight: 600 !important;
|
||||
padding-top: 12px !important;
|
||||
|
|
@ -12,58 +14,41 @@
|
|||
#banner::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(6, 182, 212, 0.25) 0%,
|
||||
rgba(6, 182, 212, 0.05) 25%,
|
||||
rgba(6, 182, 212, 0.35) 50%,
|
||||
rgba(6, 182, 212, 0.08) 75%,
|
||||
rgba(6, 182, 212, 0.28) 100%
|
||||
#1e40af 0%,
|
||||
#5b21b6 22%,
|
||||
#115e59 44%,
|
||||
#9a3412 66%,
|
||||
#9d174d 88%,
|
||||
#1e40af 100%
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
animation: colorWave 14s ease-in-out infinite alternate;
|
||||
background-size: 250% 100%;
|
||||
animation: colorWave 18s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark #banner {
|
||||
background: #475569 !important;
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.dark #banner::before {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(247, 37, 133, 0.35) 0%,
|
||||
rgba(247, 37, 133, 0.08) 25%,
|
||||
rgba(247, 37, 133, 0.45) 50%,
|
||||
rgba(247, 37, 133, 0.12) 75%,
|
||||
rgba(247, 37, 133, 0.38) 100%
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
/* Keep the announcement text above the animated wash. */
|
||||
#banner > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes colorWave {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 0%;
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
#banner * {
|
||||
color: #1e293b !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.dark #banner * {
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#banner {
|
||||
font-size: 0.8rem !important;
|
||||
|
|
@ -71,4 +56,3 @@
|
|||
padding-bottom: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
57
docs/css/language-dropdown.css
Normal file
57
docs/css/language-dropdown.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* Language dropdown: injected by language-dropdown.js into the sidebar
|
||||
footer, to the right of Mintlify's theme selector. Mirrors the almond
|
||||
theme pill's exact metrics (lg:h-7 desktop / 2.375rem mobile, rounded-full,
|
||||
border-gray-200/70, dark:border-white/[0.07]) so the two controls read as
|
||||
one family. */
|
||||
#language-switch {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#language-switch select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid rgb(229 231 235 / 0.7);
|
||||
border-radius: 9999px;
|
||||
color: rgb(107 114 128);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
height: 2.375rem;
|
||||
padding: 0 1.375rem 0 0.75rem;
|
||||
/* Chevron, drawn in the same gray as the label text. */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 0.7rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#language-switch select {
|
||||
height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
#language-switch select:hover {
|
||||
color: rgb(75 85 99);
|
||||
border-color: rgb(229 231 235);
|
||||
}
|
||||
|
||||
#language-switch select:focus-visible {
|
||||
outline: 2px solid rgb(45 0 247 / 0.4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.dark #language-switch select {
|
||||
border-color: rgb(255 255 255 / 0.07);
|
||||
color: rgb(156 163 175);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark #language-switch select:hover {
|
||||
color: rgb(209 213 219);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
|
@ -57,6 +57,42 @@ h6 code:not(pre code) {
|
|||
background: linear-gradient(135deg, #2d00f7 0%, #4cc9f0 100%);
|
||||
}
|
||||
|
||||
/* V3 banner - inside content-container, breaks out of padding with negative margins */
|
||||
#v3-banner {
|
||||
display: block;
|
||||
background: linear-gradient(135deg, #4cc9f0 0%, #2d00f7 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin: -2rem -2rem 1.5rem -2rem;
|
||||
width: calc(100% + 4rem);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
#v3-banner a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#v3-banner a:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#v3-banner {
|
||||
margin: -3rem -4rem 1.5rem -4rem;
|
||||
width: calc(100% + 8rem);
|
||||
}
|
||||
}
|
||||
|
||||
.dark #v3-banner {
|
||||
background: linear-gradient(135deg, #2d00f7 0%, #4cc9f0 100%);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -79,17 +79,17 @@ The ASGI approach shines in production environments where you need reliability a
|
|||
|
||||
### Custom Path
|
||||
|
||||
By default, your MCP server is accessible at `/mcp/` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions.
|
||||
By default, your MCP server is accessible at `/mcp` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions.
|
||||
|
||||
```python
|
||||
# Option 1: With mcp.run()
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp/")
|
||||
mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp")
|
||||
|
||||
# Option 2: With ASGI app
|
||||
app = mcp.http_app(path="/api/mcp/")
|
||||
app = mcp.http_app(path="/api/mcp")
|
||||
```
|
||||
|
||||
Now your server is accessible at `http://localhost:8000/api/mcp/`.
|
||||
Now your server is accessible at `http://localhost:8000/api/mcp`.
|
||||
|
||||
### Authentication
|
||||
|
||||
|
|
@ -101,6 +101,96 @@ FastMCP supports multiple authentication methods to secure your remote server. S
|
|||
|
||||
If you're mounting an authenticated server under a path prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) below for important routing considerations.
|
||||
|
||||
### Host and Origin Protection
|
||||
|
||||
FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it stays opt-in to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
|
||||
|
||||
Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses.
|
||||
|
||||
Enable strict validation with `host_origin_protection=True`. When you deploy behind a public hostname, add the hostname clients use to reach your MCP endpoint. If a browser-based MCP client runs on a separate origin, add that origin as well:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
app = mcp.http_app(
|
||||
host_origin_protection=True,
|
||||
allowed_hosts=["mcp.example.com"],
|
||||
allowed_origins=["https://app.example.com"],
|
||||
)
|
||||
```
|
||||
|
||||
For the direct server approach, pass the same values to `run()`:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(
|
||||
transport="http",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
host_origin_protection=True,
|
||||
allowed_hosts=["mcp.example.com"],
|
||||
allowed_origins=["https://app.example.com"],
|
||||
)
|
||||
```
|
||||
|
||||
You can also configure these values with environment variables:
|
||||
|
||||
```bash
|
||||
export FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=true
|
||||
export FASTMCP_HTTP_ALLOWED_HOSTS='["mcp.example.com"]'
|
||||
export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]'
|
||||
```
|
||||
|
||||
Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled.
|
||||
|
||||
### Gateway Routing Headers
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A gateway, load balancer, or reverse proxy in front of your MCP server often needs to route a request before it reads the JSON-RPC body — the body may be an SSE stream, or the gateway may simply want to avoid parsing it. On a connection that negotiates the modern `2026-07-28` protocol, Streamable HTTP clients built on the MCP Python SDK (including FastMCP's own client) attach routing information to each request as HTTP headers so an intermediary can dispatch on headers alone:
|
||||
|
||||
- `Mcp-Method` carries the JSON-RPC method (for example `tools/call`) on every request.
|
||||
- `Mcp-Name` carries the target's name on named operations — the tool name for `tools/call`, the prompt name for `prompts/get`, the resource URI for `resources/read`.
|
||||
- `Mcp-Param-*` carries selected argument values for a `tools/call`, one header per opted-in parameter.
|
||||
|
||||
FastMCP's HTTP transport neither strips nor rewrites these headers, so a gateway sees them exactly as the client sent them. The `Host`/`Origin` request guard inspects only `Host` and `Origin` and leaves the routing headers untouched.
|
||||
|
||||
<Warning>
|
||||
These headers are a feature of the modern `2026-07-28` protocol. A client connected over an earlier protocol revision — including one running in legacy mode or one that has fallen back to a legacy server — sends no routing headers at all. Design gateway routing to require the headers rather than assume their presence: if a request arrives without them, fall back to inspecting the body or route it to a default backend, rather than dropping it.
|
||||
</Warning>
|
||||
|
||||
To expose an argument as an `Mcp-Param-*` header, annotate the parameter with the `x-mcp-header` JSON Schema extension. FastMCP carries the annotation into the tool's advertised input schema, and a conforming client mirrors the argument into a header named `Mcp-Param-<token>`:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool
|
||||
def query_tenant(
|
||||
tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})],
|
||||
sql: str,
|
||||
) -> str:
|
||||
"""A call to this tool sends the tenant value as an `Mcp-Param-Tenant` header."""
|
||||
...
|
||||
```
|
||||
|
||||
A gateway can now route on `Mcp-Param-Tenant` — for example, pinning each tenant to a dedicated backend — without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth.
|
||||
|
||||
<Tip>
|
||||
When you put a FastMCP [proxy](/servers/providers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.
|
||||
</Tip>
|
||||
|
||||
### Health Checks
|
||||
|
||||
Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches.
|
||||
|
|
@ -156,6 +246,8 @@ Most MCP clients, including those that you access through a browser like ChatGPT
|
|||
|
||||
CORS (Cross-Origin Resource Sharing) is needed when JavaScript running in a web browser connects directly to your MCP server. This is different from using an LLM through a browser—in that case, the browser connects to the LLM service, and the LLM service connects to your MCP server (no CORS needed).
|
||||
|
||||
Host and Origin protection runs before CORS when it is active for a request. Add browser client origins to `allowed_origins` so trusted browser requests reach the CORS middleware, then configure CORS to let browser JavaScript read the MCP response headers it needs. Setting `allowed_origins` trusts the request; it does not emit `Access-Control-Allow-Origin` or other CORS response headers.
|
||||
|
||||
Browser-based MCP clients that need CORS include:
|
||||
|
||||
- **MCP Inspector** - Browser-based debugging tool for testing MCP servers
|
||||
|
|
@ -295,7 +387,7 @@ def analyze(data: str) -> dict:
|
|||
return {"result": f"Analyzed: {data}"}
|
||||
|
||||
# Create the ASGI app
|
||||
mcp_app = mcp.http_app(path='/mcp')
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Create a Starlette app and mount the MCP server
|
||||
app = Starlette(
|
||||
|
|
@ -307,7 +399,7 @@ app = Starlette(
|
|||
)
|
||||
```
|
||||
|
||||
The MCP endpoint will be available at `/mcp-server/mcp/` of the resulting Starlette app.
|
||||
The MCP endpoint will be available at `/mcp-server/mcp` of the resulting Starlette app.
|
||||
|
||||
<Warning>
|
||||
For Streamable HTTP transport, you **must** pass the lifespan context from the FastMCP app to the resulting Starlette app, as nested lifespans are not recognized. Otherwise, the FastMCP server's session manager will not be properly initialized.
|
||||
|
|
@ -326,7 +418,7 @@ from starlette.routing import Mount
|
|||
mcp = FastMCP("MyServer")
|
||||
|
||||
# Create the ASGI app
|
||||
mcp_app = mcp.http_app(path='/mcp')
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
# Create nested application structure
|
||||
inner_app = Starlette(routes=[Mount("/inner", app=mcp_app)])
|
||||
|
|
@ -336,7 +428,7 @@ app = Starlette(
|
|||
)
|
||||
```
|
||||
|
||||
In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path.
|
||||
In this setup, the MCP server is accessible at the `/outer/inner/mcp` path.
|
||||
|
||||
### FastAPI Integration
|
||||
|
||||
|
|
@ -454,7 +546,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix
|
|||
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
|
||||
```
|
||||
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. It sets the `issuer` advertised in the authorization server metadata and the `iss` on issued tokens, while the endpoints in that metadata continue to point at `base_url`.
|
||||
|
||||
```python
|
||||
# Usually not needed - just set base_url and it works
|
||||
|
|
@ -584,7 +676,7 @@ if __name__ == "__main__":
|
|||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
For more details on OAuth authentication, see the [Authentication guide](/servers/auth).
|
||||
For more details on OAuth authentication, see the [Authentication guide](/servers/auth/authentication).
|
||||
|
||||
## Production Deployment
|
||||
|
||||
|
|
@ -608,7 +700,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc
|
|||
|
||||
#### Understanding Sessions
|
||||
|
||||
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client.
|
||||
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down.
|
||||
|
||||
This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally.
|
||||
|
||||
|
|
@ -660,17 +752,17 @@ FASTMCP_STATELESS_HTTP=true uvicorn app:app --host 0.0.0.0 --port 8000 --workers
|
|||
|
||||
Production deployments should never hardcode sensitive information like API keys or authentication tokens. Instead, use environment variables to configure your server at runtime. This keeps your code secure and makes it easy to deploy the same code to different environments with different configurations.
|
||||
|
||||
Here's an example using bearer token authentication (though OAuth is recommended for production):
|
||||
Here's an example using static token authentication for development (OAuth is recommended for production):
|
||||
|
||||
```python
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import BearerTokenAuth
|
||||
from fastmcp.server.auth import StaticTokenVerifier
|
||||
|
||||
# Read configuration from environment
|
||||
auth_token = os.environ.get("MCP_AUTH_TOKEN")
|
||||
if auth_token:
|
||||
auth = BearerTokenAuth(token=auth_token)
|
||||
auth = StaticTokenVerifier(tokens={auth_token: {"sub": "admin", "client_id": "cli"}})
|
||||
mcp = FastMCP("Production Server", auth=auth)
|
||||
else:
|
||||
mcp = FastMCP("Production Server")
|
||||
|
|
@ -691,9 +783,7 @@ If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its
|
|||
|
||||
**Default Behavior (Development Only):**
|
||||
|
||||
By default, FastMCP automatically manages cryptographic keys:
|
||||
- **Mac/Windows**: Keys are generated and stored in your system keyring, surviving server restarts. Suitable **only** for development and local testing.
|
||||
- **Linux**: Keys are ephemeral (random salt at startup), so tokens are invalidated on restart.
|
||||
By default, FastMCP automatically manages cryptographic keys the same way on every platform: the signing key is deterministically derived from your OAuth client secret, so it survives server restarts as long as the secret doesn't change. Suitable **only** for development and local testing.
|
||||
|
||||
This automatic approach is convenient for development but not suitable for production deployments.
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue