Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6affe5c0d3 |
||
|
|
9972c21aa4 |
||
|
|
fd66270926 |
||
|
|
08a3bc8eaf |
||
|
|
7bb486a10f |
||
|
|
0c9136841f |
||
|
|
9771e6b59c |
||
|
|
a925e55893 |
||
|
|
e8b94867ac |
||
|
|
c6762e821c |
||
|
|
5fd3abc548 |
||
|
|
a2dab7e2dd |
||
|
|
2e0a18199a |
||
|
|
10dc173637 |
||
|
|
979208c115 |
||
|
|
d060b36e1d |
||
|
|
d2290ca343 |
||
|
|
6705402e24 |
||
|
|
e7284ed130 |
||
|
|
0921d94a74 |
||
|
|
4d6f87e8d2 |
||
|
|
c5d7d0b6d0 |
||
|
|
d8b638304e |
||
|
|
56af44727b |
||
|
|
f1c40b062f |
||
|
|
57394b0548 |
||
|
|
0cee6249ba |
||
|
|
0d92192765 |
||
|
|
b2025ab815 |
||
|
|
9057285683 |
||
|
|
2a1cc94493 |
||
|
|
e473cca6a1 |
||
|
|
fb443cc4b2 |
||
|
|
37b3cb1ef7 |
||
|
|
31b76cbeed |
||
|
|
959569ba15 |
||
|
|
fc41dd56c8 |
||
|
|
5285e936a9 |
||
|
|
52ad0a8876 |
||
|
|
14aa889992 |
||
|
|
2378e560fb |
||
|
|
6e304527a5 |
||
|
|
a4f4ccd091 |
||
|
|
fe47969fb9 |
||
|
|
d82ed88eb5 |
||
|
|
b31ddf37ed |
||
|
|
6f69a3758e |
||
|
|
78e6fbb7e4 |
||
|
|
af06330a31 |
||
|
|
68ca87e20b |
||
|
|
c9c431b71a |
||
|
|
528e366558 |
||
|
|
27f5cc7a46 |
||
|
|
89c5e700f2 |
||
|
|
b61ce388dd |
||
|
|
b7c9a916d6 |
||
|
|
923341c98a |
||
|
|
e8ef138153 |
||
|
|
d3ffe87960 |
||
|
|
b9422f1c9b |
||
|
|
45f2a88a9a |
||
|
|
333aca7ac8 |
||
|
|
dcd9c1ee9f |
||
|
|
f599cdfcf9 |
||
|
|
11934c90ae |
||
|
|
814072c94d |
||
|
|
47bfa85e83 |
||
|
|
3212591946 |
||
|
|
7163d8263f |
||
|
|
1963af52cc |
||
|
|
e90a66b554 |
||
|
|
5dd062d077 |
||
|
|
0cb920f126 |
||
|
|
629ca297d2 |
||
|
|
00a70148bc |
||
|
|
837ef904f8 |
||
|
|
3a6f2996cd |
||
|
|
ebcc4dc3fb |
||
|
|
497f7afa61 |
||
|
|
e464f72c12 |
||
|
|
03aaebd3aa |
||
|
|
2713b53b12 |
||
|
|
1216c53693 |
||
|
|
4fc8882c02 |
||
|
|
74a242ae7f |
||
|
|
148278e07f |
||
|
|
9bdc03d54e |
||
|
|
6d2e908f2b |
||
|
|
867bba6263 |
||
|
|
d287c9868f |
||
|
|
53117cb3a9 |
||
|
|
bf4402725d |
||
|
|
2359b40285 |
||
|
|
e4d95e0d44 |
||
|
|
220d362112 |
||
|
|
080f2a869d |
||
|
|
d39c68df23 |
||
|
|
0da9092037 |
||
|
|
e50fb5be19 |
||
|
|
dcf8a6a0b5 |
||
|
|
410cc0db31 |
||
|
|
ca10dade2c |
||
|
|
48ef569f7e |
||
|
|
4df609119f |
||
|
|
7322ca56f4 |
||
|
|
985652491a |
||
|
|
0b200ef03b |
||
|
|
b15b1d5f07 |
||
|
|
67d7593df1 |
||
|
|
8d0f928e40 |
||
|
|
8f2c97b769 |
||
|
|
533c6a8226 |
||
|
|
c85836a081 |
||
|
|
24fdd909ac |
||
|
|
f2e63c979a |
||
|
|
f664db8952 |
||
|
|
4b519782f1 |
||
|
|
fe9723853d |
||
|
|
e942d00b98 |
||
|
|
3b78f86886 |
||
|
|
24717cc8eb |
||
|
|
067f90578c |
||
|
|
c0ecb70e24 |
||
|
|
ecdf09d44f |
||
|
|
08b62308d4 |
||
|
|
3945bdde11 |
||
|
|
3caa445c6c |
||
|
|
b31d95a429 |
||
|
|
cc596195bb |
||
|
|
5b2713d40c |
||
|
|
3a8da8c0c3 |
||
|
|
1b92efedc8 |
||
|
|
411a6d3980 |
||
|
|
4caa41f6d5 |
||
|
|
9dc8c5f02d |
||
|
|
f41a5193f3 |
||
|
|
e9cd1692b0 |
||
|
|
587340279e |
||
|
|
603342f0fe |
||
|
|
0ee7f1b293 |
||
|
|
03681ed55e |
||
|
|
96bf22e57a |
||
|
|
1b1abf6ab6 |
||
|
|
f226d00d0a |
||
|
|
ae13ede143 |
||
|
|
a527142312 |
||
|
|
44ce901ce3 |
||
|
|
ad81ca234a |
||
|
|
5e013d9c54 |
||
|
|
2397319a68 |
||
|
|
44724284b3 |
||
|
|
3169922492 |
||
|
|
1331131650 |
||
|
|
4573e4ac33 |
||
|
|
5734acf18e |
||
|
|
cf41441e44 |
||
|
|
60f37e9d7c |
||
|
|
f5fe42f5a4 |
||
|
|
48cf4950dc |
||
|
|
b7a5bffed0 |
||
|
|
5a3412ddc1 |
||
|
|
fda4c54362 |
||
|
|
f253682393 |
||
|
|
0fa03ec69f |
||
|
|
84bf9bde05 |
||
|
|
1cec2d60f4 |
||
|
|
510832aa45 |
||
|
|
9e6d00318c |
||
|
|
364b762ef6 |
||
|
|
734746a3d9 |
||
|
|
c85ccf1c6f |
||
|
|
df81c06221 |
||
|
|
47bbab3bc3 |
||
|
|
7703df1539 |
||
|
|
65be5a7147 |
||
|
|
2693fb95ae |
||
|
|
ac36a395aa |
||
|
|
ddb7b78ef4 |
||
|
|
255650dcd1 |
||
|
|
a3689ab493 |
||
|
|
1012d60004 |
||
|
|
cf110e3214 |
||
|
|
535fc1d0fb |
||
|
|
7267818e44 |
||
|
|
1e21814ed5 |
||
|
|
e196857bc9 |
||
|
|
6d0c160113 |
||
|
|
5d826490b6 |
||
|
|
b478bff56d |
||
|
|
ac96f88abd |
||
|
|
bdc48e98b1 |
||
|
|
19fe9faec8 |
||
|
|
ed39e73c0b |
||
|
|
b3025f93b4 |
||
|
|
ed6adeee7e |
||
|
|
a938126327 |
||
|
|
4f6f0e8bf5 |
||
|
|
60c0420775 |
||
|
|
c91f406938 |
||
|
|
8cc187fac0 |
||
|
|
a5b2ebb660 |
||
|
|
453cafb9a9 |
||
|
|
2a3d065417 |
||
|
|
616476f692 |
||
|
|
24725633f1 |
||
|
|
3eb579948a |
||
|
|
e8e6484278 |
||
|
|
f4753440da |
||
|
|
161834d4ae |
||
|
|
bf3e0010b8 |
||
|
|
2b0da5631a |
||
|
|
3d7b311de0 |
||
|
|
437d15aa71 |
||
|
|
5cbd259c3b |
||
|
|
2dfb51a4d1 |
||
|
|
941089e06a |
||
|
|
8f806da611 |
||
|
|
c5f12ec1e9 |
||
|
|
f27d2aac05 |
||
|
|
cf4e435db0 |
||
|
|
d5b9155f14 |
||
|
|
37891f42a4 |
||
|
|
3ce0f76e6e |
||
|
|
e6235d1667 |
||
|
|
fb2276b95f |
||
|
|
3517a29c82 |
||
|
|
98f8ef295a |
||
|
|
7ba4fb881d |
||
|
|
92c693bb73 |
||
|
|
883d893097 |
||
|
|
5388bea53a |
||
|
|
20dd94632e |
||
|
|
67201a9bbd |
||
|
|
7826ade12b |
||
|
|
ff50351f9e |
||
|
|
1a2244f402 |
||
|
|
75a80b6f07 |
||
|
|
abfb482246 |
||
|
|
e1fd62e0f3 |
||
|
|
2c73a2a881 |
||
|
|
dd52713517 |
||
|
|
62eb08e5b2 |
||
|
|
31a38b5078 |
||
|
|
51c53f2c18 |
||
|
|
7ba41dcfae |
||
|
|
eaf971cf25 |
||
|
|
92f1b1500d |
||
|
|
b33c811675 |
||
|
|
7c0224828b |
||
|
|
528abfab86 |
||
|
|
b3149d2f33 |
||
|
|
cc22bf5464 |
||
|
|
62575edabd |
||
|
|
0fe16dd5fd |
||
|
|
cb07adeca3 |
||
|
|
c0328540c9 |
||
|
|
688c6e3ade |
||
|
|
43d709c976 |
||
|
|
0e96aecd1d |
||
|
|
b9431d483f |
||
|
|
e82203bfc4 |
||
|
|
fc57c2c4c5 |
||
|
|
92140e5086 |
||
|
|
be5bb7c4f2 |
||
|
|
705497a593 |
||
|
|
3b53fb9a00 |
||
|
|
2fe56e56de |
||
|
|
8f669a77e3 |
||
|
|
a287a40184 |
||
|
|
29a14ab9e5 |
||
|
|
1e0b5c0479 |
||
|
|
0a22a9dc33 |
||
|
|
d6d3ad9a7c |
||
|
|
f049c8e5c6 |
||
|
|
99edde1125 |
||
|
|
e39038d879 |
||
|
|
239d682b6f |
||
|
|
7c7c13b648 |
||
|
|
dda845aa12 |
||
|
|
4598ad9361 |
||
|
|
1a943ad085 |
||
|
|
d3133ae6ce |
||
|
|
1a8c14a5b8 |
||
|
|
4fc49c62bd | ||
|
|
74f41ff1ae |
||
|
|
b1f7eec3cd |
||
|
|
050aeb6456 |
||
|
|
71e78842cc |
||
|
|
6f8b791faf |
||
|
|
21822053df |
||
|
|
2137c8fbdb |
||
|
|
b38716e5e3 |
||
|
|
14744c66c5 |
||
|
|
6f0e8e7851 |
||
|
|
1b5287c727 |
||
|
|
acba5478a9 |
||
|
|
65c614e48e |
||
|
|
4a2d83a0cb |
||
|
|
ce75b2d7d2 |
||
|
|
a7ddfdae07 |
||
|
|
3cfdea0e97 |
||
|
|
a1d330de9f |
||
|
|
5301298225 |
||
|
|
f0ab53e194 |
||
|
|
d77292fb06 |
||
|
|
7b728a243e |
||
|
|
656f887aff |
||
|
|
213cf993ca |
||
|
|
3bcdc1763f |
||
|
|
ae7793efd6 |
||
|
|
6d463bb353 |
||
|
|
bcb07c2b25 |
||
|
|
3dc8b72041 |
||
|
|
34e66a3812 |
||
|
|
0f9a41d777 |
||
|
|
1c258ae203 |
||
|
|
5fdd48a0d3 |
||
|
|
f4672c5084 |
||
|
|
dcc9b4f8e0 |
||
|
|
d41d0c0128 |
||
|
|
df039bf97c |
||
|
|
8adb5bdce8 |
||
|
|
5d80f4efc8 |
||
|
|
c06362126b |
||
|
|
e94b386a13 |
||
|
|
7080fcf1e6 |
||
|
|
0622babbd9 |
||
|
|
edf0950a6a |
||
|
|
2b4c7ebc61 |
||
|
|
2cf7784a48 |
||
|
|
9f427f3292 |
||
|
|
d5edcb3e92 |
||
|
|
68cbabb9d7 |
||
|
|
b63776b14f |
||
|
|
2ce41a8347 |
||
|
|
cfb2909631 |
||
|
|
f2abefffaf |
||
|
|
c9e98e53a7 |
||
|
|
fc5cb6ab48 |
||
|
|
812a46ab97 |
||
|
|
72a8631f0a |
||
|
|
024d7597fd |
||
|
|
c251e728f4 |
||
|
|
f2b89ec83e |
||
|
|
32a4587da9 |
||
|
|
8893b022e8 |
||
|
|
b26e5b907f |
||
|
|
0da9a074d0 |
||
|
|
6b69f6354a |
||
|
|
3ffe142e9a |
||
|
|
1fd557afdc |
||
|
|
3863f203e9 |
||
|
|
f397783c39 |
||
|
|
201de920a0 |
||
|
|
adcc17b968 |
||
|
|
6149b63a44 |
||
|
|
37de50144f |
||
|
|
bb6cb029f0 |
||
|
|
d52937b39c |
||
|
|
c7cbfbb302 |
||
|
|
78a9504ec1 |
||
|
|
a9cc822a10 |
||
|
|
a4bf947540 |
||
|
|
3f6b0597f1 |
||
|
|
06748eb4c4 |
||
|
|
2aa1ad2a69 |
||
|
|
4807eb5a80 |
||
|
|
ef96a31671 |
||
|
|
8ac0cab98c |
||
|
|
65b36de4eb |
||
|
|
a3a4b8d11a |
||
|
|
cc8382ce3e |
||
|
|
0dedbd9831 |
||
|
|
779271ae38 |
||
|
|
2bf9b10f63 |
||
|
|
8ac11ec604 |
||
|
|
b7cc25493c |
||
|
|
8b984d93a3 |
||
|
|
89ff338174 |
||
|
|
9ed0b93ceb |
||
|
|
72a34002aa |
||
|
|
8e02fc17e1 |
||
|
|
d3a184119e |
||
|
|
fa851d93a2 |
||
|
|
f82b0c9371 |
||
|
|
281fd4765e |
||
|
|
2cd178a962 |
||
|
|
c92bb2f7ff |
||
|
|
5983a650cc |
||
|
|
02b7889929 |
||
|
|
27279bc157 |
||
|
|
f22501315e |
||
|
|
f2517fe14b |
||
|
|
091afb82dc |
||
|
|
998f0ee4db |
||
|
|
b19fa6f279 |
||
|
|
71c475588f |
||
|
|
c51936f61f |
||
|
|
397089a78e |
||
|
|
fcffa14b5b |
||
|
|
9c8f763aa8 |
||
|
|
5489e8b6fb |
||
|
|
a357380cfa |
||
|
|
9724ad1ce6 |
||
|
|
116c13e2c6 |
||
|
|
91ccdb3d65 |
||
|
|
5e1aa4072b |
||
|
|
7d12e83cf4 |
||
|
|
be730674ad |
||
|
|
720d7130e2 |
||
|
|
a864840825 |
||
|
|
89e9c43acf |
||
|
|
da4fce2195 |
||
|
|
9eae96a05e |
||
|
|
b7b0f8eebb |
||
|
|
3390e49c01 |
||
|
|
de2289dc93 |
||
|
|
3a9f118e34 |
||
|
|
202af49857 |
||
|
|
6f2cd0cef0 |
||
|
|
8cdac3d2f8 |
||
|
|
f161149680 |
||
|
|
db9e451551 |
||
|
|
c44e68f766 |
||
|
|
3e86edfb2f |
||
|
|
f97f7c4a7d |
||
|
|
7ac40af302 |
||
|
|
673423da0d |
||
|
|
31ae5f4dc4 |
||
|
|
6c26d087df |
||
|
|
340af403bd |
||
|
|
b7e4ae7542 |
||
|
|
bbc8b4904e |
||
|
|
9f9dbad416 |
||
|
|
35a9ccdb42 |
||
|
|
1200ba0082 |
||
|
|
40acbc596c |
||
|
|
c733eec5d9 |
||
|
|
98f82485bd |
||
|
|
dcc68ce56b |
||
|
|
de89457683 |
||
|
|
cd7253c593 |
||
|
|
eb34ab7c72 |
||
|
|
7a933fe5da |
||
|
|
b4e50aa9f9 |
||
|
|
0e29cc4130 |
||
|
|
61399b386c |
||
|
|
7d335efd82 |
||
|
|
b8e758b02b |
||
|
|
df3e428ee8 |
||
|
|
814c9c024a |
||
|
|
89619a8604 |
||
|
|
3e798bfc01 |
||
|
|
f676f6c0f0 |
||
|
|
d648a31359 |
||
|
|
c81ff64e94 |
||
|
|
8dd15ac71c |
||
|
|
80c0d23d48 |
||
|
|
60f4b2d10a |
||
|
|
9323efad99 |
||
|
|
1940040ac3 |
||
|
|
146d7efb0f |
||
|
|
71889d7387 |
||
|
|
b85e7bd1a9 |
||
|
|
c0f165776d |
||
|
|
7629fe6b97 |
||
|
|
4fb975c6dc |
||
|
|
03e19f1c90 |
||
|
|
20596e5f41 |
||
|
|
7e93a9fc19 |
||
|
|
ca34666663 |
||
|
|
b41b9175e8 |
||
|
|
ca5cb4cb67 |
||
|
|
c3717e7ad3 |
||
|
|
c47c767ff4 |
||
|
|
47d35f0b3c |
||
|
|
346e794d59 |
||
|
|
356dfa692b |
||
|
|
1644b822b3 |
||
|
|
07ae8c0d4e |
||
|
|
9c6fd15a88 |
||
|
|
eaf7cf41d5 |
||
|
|
9a8592e7d4 |
||
|
|
e750a06a99 |
||
|
|
f4b2957a20 |
||
|
|
09e3a05e13 |
||
|
|
ff02c596a4 |
||
|
|
824ef8a281 |
||
|
|
d1ac8d68eb |
||
|
|
0926613714 |
||
|
|
a82c69bfd9 |
||
|
|
c7671e470c |
||
|
|
ef4e167c1a |
||
|
|
e68e513b42 |
||
|
|
d4759288d0 |
||
|
|
34e3664e69 |
||
|
|
68e25d478b |
||
|
|
6c61058190 |
||
|
|
959d4e39ae |
||
|
|
b34e7207ad |
||
|
|
35777b9811 |
||
|
|
6d2e6d442d |
||
|
|
6a84a2f79f |
||
|
|
c260e29a92 |
||
|
|
7570ba30cf |
||
|
|
0b1b52ba45 |
||
|
|
ba58ebcad5 |
||
|
|
99c4f3c906 |
||
|
|
41184ba6c8 |
||
|
|
813da6a579 |
||
|
|
11162d7790 |
||
|
|
dd792977dd |
||
|
|
c0bc6668c4 |
||
|
|
a99711d43f |
||
|
|
e975d0550b |
||
|
|
0b4ce00be2 |
||
|
|
6566c08446 |
||
|
|
0130f5b12b |
||
|
|
eb5146dc8b |
||
|
|
bd9885feda |
||
|
|
95b44fb0d7 |
||
|
|
b8cb3675dc |
||
|
|
a10fec7fa6 |
||
|
|
9bf5160eea |
||
|
|
49991fd2c7 |
||
|
|
d28a1a69ee |
||
|
|
fd11eca766 |
||
|
|
0a4e8d4e17 |
||
|
|
81fe63a81d |
||
|
|
ee54acbfa3 |
||
|
|
b2d4322ca0 |
||
|
|
6d092af6fd |
||
|
|
f55831ee79 |
||
|
|
7b1078b515 |
||
|
|
3abefeeeaf |
||
|
|
9b4c2ddf93 |
||
|
|
c067ef291e |
||
|
|
4fee123e72 |
||
|
|
bd84329a08 |
||
|
|
b16c2a8518 |
||
|
|
beacd6e009 |
||
|
|
e0336213e8 |
||
|
|
cf72565d9a |
||
|
|
08d364d005 |
||
|
|
5c9cf290d8 |
||
|
|
906ceea388 |
||
|
|
3d67e723e5 |
||
|
|
3fa7bc7b61 |
||
|
|
adf327164a |
||
|
|
7bc190bae8 |
||
|
|
31076ab2d4 |
||
|
|
9301924f44 |
||
|
|
6f43d1f170 |
||
|
|
7942184336 |
||
|
|
3f0964dd14 |
||
|
|
dced22344d |
||
|
|
0323831b01 |
||
|
|
4b659639f7 |
||
|
|
43bb24f62b |
||
|
|
c8bbfc034d |
||
|
|
0b11bdb629 |
||
|
|
41f3bc35ce |
||
|
|
4d45bb85a8 |
||
|
|
674768802a |
||
|
|
ce007de66d |
||
|
|
ecc916516d |
||
|
|
679b22970e |
||
|
|
17f9c00c53 |
||
|
|
d0443a1832 |
||
|
|
86bb54cb28 |
||
|
|
a2f8766a8a |
||
|
|
029f434109 |
||
|
|
5429ac6153 |
||
|
|
69e6572d48 |
||
|
|
a3bcabdce2 |
||
|
|
7b420656de |
||
|
|
1eb1bba83c |
||
|
|
f3cd20c920 |
||
|
|
bac2705426 |
||
|
|
1bb16fa96e |
||
|
|
df15e9566d |
||
|
|
0bcecffc4c |
||
|
|
20dc0fbabb |
||
|
|
d69b290b65 |
||
|
|
185fa49fd1 |
||
|
|
ba149e34f2 |
||
|
|
d831cc49e1 |
||
|
|
543961968c |
||
|
|
f7265f7b91 |
||
|
|
29c69e6a47 |
||
|
|
1a9ead07f5 |
||
|
|
2cbc435c6c |
||
|
|
b0b44c2fe4 |
||
|
|
7123556a34 |
||
|
|
2bce10bdb1 |
||
|
|
8276632caa |
||
|
|
544176770b |
||
|
|
05b7156ea8 |
||
|
|
7f94bef85e |
||
|
|
d55cb2bf4e |
||
|
|
f5dd324354 |
||
|
|
70014a2bbb |
||
|
|
532b1176f9 |
||
|
|
7901552eba |
||
|
|
9dad26620f |
||
|
|
6e418e62f9 |
||
|
|
8a2359ffeb |
||
|
|
294fc6b4d3 |
||
|
|
f2f4dbdcbd |
||
|
|
d1876433af |
||
|
|
073471cdf1 |
||
|
|
10cf0f78a8 |
||
|
|
7c8ad510b7 |
||
|
|
4cd4fe006c |
||
|
|
0e2a34189c |
||
|
|
e80c0150e1 |
||
|
|
2ca2de767b |
||
|
|
43ded92633 |
||
|
|
5a9340af71 |
||
|
|
e33cd41c7a |
||
|
|
6353dd192c |
||
|
|
5d33861cad |
||
|
|
1bdeed33c2 |
||
|
|
c2f8730d6d |
||
|
|
13f018264c |
||
|
|
b3fbee9c48 |
||
|
|
0a388b642f |
||
|
|
a00b20a427 |
||
|
|
b8f7b027c3 |
||
|
|
9e66f7ce89 |
||
|
|
c5782129bc |
||
|
|
37b54b4c03 |
||
|
|
8b5838675c |
||
|
|
173e0ee3b8 |
||
|
|
7b6a903eb9 |
||
|
|
fdb538bc28 |
||
|
|
1cb740745d |
||
|
|
5289d0686a |
||
|
|
f745d69dcb |
||
|
|
76919cfaa4 |
||
|
|
c6fb822c86 |
||
|
|
ed25167fa5 |
||
|
|
72003d9cc0 |
||
|
|
e4e119b324 |
||
|
|
280bab36f4 |
||
|
|
a027d75f60 |
||
|
|
a1307abded |
||
|
|
9d99aee014 |
||
|
|
c8a14c9dba |
||
|
|
e0d443c95e |
||
|
|
3b1b213a96 |
||
|
|
58c5e7223c |
||
|
|
83968b5b2f |
||
|
|
5d8eaf77be |
||
|
|
74f5fcfa0d |
||
|
|
cf8b66b82f |
||
|
|
da0cf22355 |
||
|
|
3978c6e1b9 |
||
|
|
9dfc925090 |
||
|
|
46523afe30 |
||
|
|
72b66a58b1 |
||
|
|
78f0b11a09 |
||
|
|
2210c1be18 |
||
|
|
82bd8bc1d9 |
||
|
|
1a330ac672 |
||
|
|
017135434e |
||
|
|
96e5327110 |
||
|
|
697b6e8e05 |
||
|
|
b4c7db6a50 |
||
|
|
babb477dff |
||
|
|
8c9269c34b |
||
|
|
70115b99b3 |
||
|
|
c4beb3e8ef |
||
|
|
da54ea003e |
||
|
|
d6e611f83f |
||
|
|
58b989c0a3 |
||
|
|
c2ca8e03e0 |
||
|
|
321498ab5d |
||
|
|
a81b25ae8d |
||
|
|
2ea14958f0 |
||
|
|
2d6b3873ec |
||
|
|
302d8999ba |
||
|
|
f007323542 |
||
|
|
d788424caa |
||
|
|
4e3c196941 |
||
|
|
6d66ab8be0 |
||
|
|
e5ee279614 |
||
|
|
d3bf9cd325 |
||
|
|
9a2bb6a7e7 |
||
|
|
df2d3a57c2 |
||
|
|
dfbe56d2b2 |
||
|
|
4e11f2890b |
||
|
|
9ec6db201f |
||
|
|
fd2fd34357 |
||
|
|
568cbd1a66 |
||
|
|
9ae4df85fb |
||
|
|
e2a7338730 |
||
|
|
04586f8f86 |
||
|
|
e334f7e4b6 |
||
|
|
c3fd34a1a6 |
||
|
|
ae77772ea8 |
||
|
|
5a54d82459 |
||
|
|
805fb469f5 |
||
|
|
08f4e01b8f |
||
|
|
a9aca20205 |
||
|
|
6b6f34eaa6 |
||
|
|
c8978681f6 |
||
|
|
3775916c5c |
||
|
|
97201cce59 |
||
|
|
7196604468 |
||
|
|
ebb81d3b2b |
||
|
|
9d0f2daddb |
||
|
|
ad7f7a5473 |
||
|
|
1669a3af01 |
||
|
|
5c9f688d95 |
||
|
|
94d326dbf1 |
||
|
|
1691b905e2 |
||
|
|
fb7d0c8dac |
||
|
|
ba184a2667 |
||
|
|
2bcca39aae |
||
|
|
e756315dea |
||
|
|
78fc5c12c0 |
||
|
|
d51726a9e8 |
||
|
|
3e0ab1e7ee |
||
|
|
5cbea24ecb |
||
|
|
06e692ba5c |
||
|
|
dec28830b9 |
||
|
|
ea8a2dbd6d |
||
|
|
9d65e5ac03 |
||
|
|
fd826cc7a6 |
||
|
|
fc021eea76 |
||
|
|
f67c3ee6c0 |
||
|
|
775f879813 |
||
|
|
b1942b31c4 |
||
|
|
2628e01f4b |
||
|
|
a50cf92d3a |
||
|
|
10a85e452d |
||
|
|
ff22f48365 |
||
|
|
106619967b |
||
|
|
5480f67cf9 |
||
|
|
b53e090299 | ||
|
|
faf3a9d7d6 |
||
|
|
92ba4f41d0 |
||
|
|
fbf4acc679 |
||
|
|
da53a97ed9 |
||
|
|
9abfe775cc |
||
|
|
0b5644723b |
||
|
|
e4503a72c8 |
||
|
|
7c47d1f25f |
||
|
|
4d3e05f6f6 |
||
|
|
41af509bcb |
||
|
|
fddba00723 |
||
|
|
e5815bd162 |
||
|
|
e598750cba |
||
|
|
d3ea9009b0 |
||
|
|
2c7bd8343e |
||
|
|
f10665db4c |
||
|
|
c58adfe3f5 |
||
|
|
27bfde95a4 |
||
|
|
733db0c9cf |
||
|
|
08042c3307 |
||
|
|
08cfbe522a |
||
|
|
ca060014bb | ||
|
|
00a44692e7 | ||
|
|
51e65c8310 |
||
|
|
827e494df4 |
||
|
|
888bdd3c34 |
||
|
|
960b92355e |
||
|
|
a9ae38237d |
||
|
|
ce06b6291d |
||
|
|
0d3e02f6a2 |
||
|
|
444edf6760 |
||
|
|
5e19c7cf73 |
||
|
|
defd52b2d6 |
||
|
|
0d48a4494f |
||
|
|
1395607583 |
||
|
|
53bfac0cc2 |
||
|
|
44c0004e6c |
||
|
|
2dc5fbfa3d |
||
|
|
76325b7846 | ||
|
|
070e8412c0 |
||
|
|
f90cf6a2a5 |
||
|
|
8ff4b5e9d3 |
||
|
|
f10c2e3f1f |
||
|
|
978cfe3f92 |
||
|
|
43a30c1d00 |
||
|
|
91840fd9c0 |
||
|
|
7a64d8f9af | ||
|
|
57c3aa2f0d |
||
|
|
d64b0679b8 |
||
|
|
5f537a0f69 |
||
|
|
ba25ad6921 |
||
|
|
0a93799068 |
||
|
|
bd742272ab |
||
|
|
c14ec2e0d7 |
||
|
|
059e831b8d |
||
|
|
0cb3b26514 |
||
|
|
73c62c5ccf |
||
|
|
c184b7a8db | ||
|
|
f5f19b2361 | ||
|
|
4c71c6168f |
||
|
|
d01d49ea6e |
||
|
|
e798d20cbb |
||
|
|
aa7869a62f |
||
|
|
3fa26a5a97 |
||
|
|
07e721f63f |
||
|
|
5097bb7ef8 |
||
|
|
7b35ab844a |
||
|
|
8d90a3afa3 |
||
|
|
7ab1fc71aa |
||
|
|
8f81a85abe |
||
|
|
e79a56435a |
||
|
|
66ccd1c515 |
||
|
|
a0e2f7fab7 |
||
|
|
3f9f7c8311 |
||
|
|
b0a6aafaf6 |
||
|
|
f164291483 |
||
|
|
62a0af6f71 |
||
|
|
bc6746e89b | ||
|
|
135191403a | ||
|
|
53fe3417bb |
||
|
|
400dcda2eb |
||
|
|
8d17fd327b |
||
|
|
4770bcd0b1 |
||
|
|
b66c6759c3 |
||
|
|
a77aaa79a4 |
||
|
|
83efb1077a |
||
|
|
0c00fdc783 |
||
|
|
6a806549c8 |
||
|
|
e8dd39f839 |
||
|
|
2efa525a66 |
||
|
|
b8fd814f1c |
||
|
|
af940aeea7 |
||
|
|
435cdda43c |
||
|
|
1e52765052 |
||
|
|
58bc32e559 |
||
|
|
e32bf07433 |
||
|
|
e21e8c157d |
||
|
|
329f595f5f |
||
|
|
d06b393cdb |
||
|
|
118ddcc9ac |
||
|
|
1bbd27d4a5 |
||
|
|
2d8f08d648 |
||
|
|
79ec8dccdb |
||
|
|
ed993166e4 |
||
|
|
43796bfa24 |
||
|
|
d93b99e163 |
||
|
|
73c055f136 |
||
|
|
ae73191b3e |
||
|
|
bd5170e19e |
||
|
|
21ac4043d5 |
||
|
|
b89cabc7df |
||
|
|
312e9da2dc |
||
|
|
3de4dc1f13 |
||
|
|
34a257147b |
||
|
|
6f108f7f7c |
||
|
|
937c640f04 |
||
|
|
247aec9332 | ||
|
|
1134421a3a | ||
|
|
0970ef4ae0 | ||
|
|
99727a97ae | ||
|
|
4f36581a5c | ||
|
|
e65404aa05 | ||
|
|
6194d06f6c | ||
|
|
eba1ad2772 |
||
|
|
552f797ac3 |
||
|
|
59fff69c38 |
||
|
|
2c8ea49b6f |
||
|
|
e691c511ab |
||
|
|
52e69efa6f |
||
|
|
37f6a3229c |
||
|
|
44e1abb7f1 |
||
|
|
680afeeed6 |
||
|
|
5d9b544ce7 |
||
|
|
9d58fed2fd |
||
|
|
d2c3e21173 |
||
|
|
0b09fa5944 |
||
|
|
561397c409 |
||
|
|
a79f51f55f |
||
|
|
7bbf71e29a |
||
|
|
ca3121282e |
||
|
|
45cea71d90 |
||
|
|
f2128a7977 |
||
|
|
bed35f3737 |
||
|
|
b90da481eb |
||
|
|
aa3b550ec6 |
||
|
|
dad01945c7 |
||
|
|
87cee0ff33 |
||
|
|
15cd7df87f |
||
|
|
fe75f43ee6 |
||
|
|
557e90d2e7 |
||
|
|
80a39f2349 | ||
|
|
466e1e8eb7 | ||
|
|
07db4a3dd1 |
||
|
|
edb37c8dc0 |
||
|
|
e98291e001 |
||
|
|
dd1a069508 |
||
|
|
1d4a0d3589 |
||
|
|
93bce513a5 |
||
|
|
ef9b4cf6a2 |
||
|
|
21f2f82b7d |
||
|
|
ce25842d35 |
||
|
|
8425c9ce9b |
||
|
|
a0216c3e50 | ||
|
|
aaf32b5307 |
||
|
|
34f380c905 |
||
|
|
28ed9cba5a |
||
|
|
ed03a426c7 |
||
|
|
6391c9e004 |
||
|
|
e4c00741be | ||
|
|
44ec763f92 |
||
|
|
15ed7cb2dc |
||
|
|
e4e4954b66 |
||
|
|
c40009524c |
||
|
|
840ea48371 | ||
|
|
131902f09f |
||
|
|
08075ba5e4 |
||
|
|
587dbb739d |
||
|
|
ab2f502f4a |
||
|
|
c6d3bdce0f |
||
|
|
e41482821c |
||
|
|
569acf077d |
||
|
|
6063b09d04 |
1677 changed files with 264916 additions and 4917 deletions
118
.claude/commands/review-pr.md
Normal file
118
.claude/commands/review-pr.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
Review the pull request: $ARGUMENTS
|
||||
|
||||
Follow these steps carefully. Use the `gh` CLI for all GitHub interactions.
|
||||
|
||||
## Step 1: Resolve the PR
|
||||
|
||||
Parse `$ARGUMENTS` to determine the PR. It can be:
|
||||
|
||||
- A full URL like `https://github.com/owner/repo/pull/123`
|
||||
- A `owner/repo#123` reference
|
||||
- A bare number like `123` (use the current repo)
|
||||
- A description — search for it with `gh pr list --search "<description>" --limit 5` and pick the best match
|
||||
|
||||
Once resolved, fetch the PR metadata:
|
||||
|
||||
```bash
|
||||
gh pr view <PR> --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees
|
||||
```
|
||||
|
||||
## Step 2: Gather the diff
|
||||
|
||||
Get the full diff of the PR:
|
||||
|
||||
```bash
|
||||
gh pr diff <PR>
|
||||
```
|
||||
|
||||
If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest.
|
||||
|
||||
## Step 3: Collect PR discussion context
|
||||
|
||||
Fetch all comments and review threads:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate
|
||||
gh api repos/{owner}/{repo}/issues/{number}/comments --paginate
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate
|
||||
```
|
||||
|
||||
Pay attention to:
|
||||
|
||||
- Reviewer feedback and requested changes
|
||||
- Author responses and explanations
|
||||
- Any unresolved conversations
|
||||
- Approval or rejection status
|
||||
|
||||
## Step 4: Find and read linked issues
|
||||
|
||||
Look for issue references in:
|
||||
|
||||
- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`)
|
||||
- The PR branch name (patterns like `issue-123`, `fix/123`)
|
||||
- Commit messages
|
||||
|
||||
For each linked issue, fetch its content:
|
||||
|
||||
```bash
|
||||
gh issue view <number> --json title,body,comments,labels,state
|
||||
```
|
||||
|
||||
Read through issue comments to understand the original problem, user reports, and any discussed solutions.
|
||||
|
||||
## Step 5: Analyze and validate
|
||||
|
||||
With all context gathered, analyze the PR critically:
|
||||
|
||||
1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues?
|
||||
2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address?
|
||||
3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications?
|
||||
4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors?
|
||||
5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases?
|
||||
6. **Breaking changes**: Could this PR break existing functionality or APIs?
|
||||
7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed?
|
||||
|
||||
## Step 6: Produce the review summary
|
||||
|
||||
Present the summary in this format:
|
||||
|
||||
---
|
||||
|
||||
### PR Review: `<title>` (<url>)
|
||||
|
||||
**Author:** <author> | **Status:** <state> | **Review decision:** <decision>
|
||||
**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>**
|
||||
|
||||
#### Problem
|
||||
|
||||
<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues>
|
||||
|
||||
#### Solution
|
||||
|
||||
<1-3 sentences describing the approach taken in the code>
|
||||
|
||||
#### Key changes
|
||||
|
||||
<Bulleted list of the most important changes, grouped by theme. Include file paths.>
|
||||
|
||||
#### Linked issues
|
||||
|
||||
<List of linked issues with their title, state, and a one-line summary of the discussion>
|
||||
|
||||
#### Discussion highlights
|
||||
|
||||
<Summary of important comments from reviewers and the author. Flag any unresolved threads.>
|
||||
|
||||
#### Concerns
|
||||
|
||||
<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found.">
|
||||
|
||||
#### Verdict
|
||||
|
||||
<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification>
|
||||
|
||||
#### Suggested action
|
||||
|
||||
<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about>
|
||||
|
||||
---
|
||||
150
.claude/skills/test-quality/SKILL.md
Normal file
150
.claude/skills/test-quality/SKILL.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
---
|
||||
name: test-quality
|
||||
description: Test quality bar for this repo. Read when writing, reviewing, or designing tests — covers naming, abstraction level, assertions, determinism, and the process for agent-driven test work.
|
||||
---
|
||||
|
||||
# Test & code quality guide
|
||||
|
||||
What "best practice for new work" means in this repo. Each rule carries its recorded reasoning
|
||||
where one exists; a rule with no stated why is a convention — follow it anyway.
|
||||
|
||||
## Naming & shape
|
||||
|
||||
- **Test names are behaviour sentences** stating the observable outcome, not the feature being
|
||||
poked: `test_elicit_form_decline_returns_no_content`, never `test_elicit_form_decline`.
|
||||
- Plain top-level `test_*` functions; no `Test` classes (legacy files have them — don't copy).
|
||||
- **Docstrings: 1–2 sentences of behaviour, honest about provenance** — spec-mandated,
|
||||
SDK-defined, or pinning a known gap? Say which: provenance is the triage key when the test
|
||||
later fails. A pinned-gap assertion breaking usually means a change *fixed* the gap; a
|
||||
spec-mandated assertion breaking means a regression.
|
||||
- Define things in dependency order; nothing forward-references. For client↔server tests:
|
||||
handlers → server construction → client setup → act → assert — the test reads in the order
|
||||
the conversation happens.
|
||||
- Inline the server (or equivalent setup) in the test, so the whole observable behaviour fits
|
||||
on one screen. Lift to a file-level fixture only when several tests in *that file* genuinely
|
||||
share it; never share across files.
|
||||
- A big multi-step test is fine when the property is irreducibly multi-step (e.g. resumability).
|
||||
Split when a failure wouldn't tell you which claim broke — not for shortness. Compensate with
|
||||
a numbered "Steps:" docstring so a reader sees the choreography before the body.
|
||||
|
||||
## Level of abstraction
|
||||
|
||||
- **Drive through the highest-level public API that can observe the property.** Hand-built wire
|
||||
requests are brittle and don't prove the user-facing contract; tests that stay above the
|
||||
internals keep working when the internals change. Drop to raw HTTP only when the assertion is
|
||||
about something the high-level API *cannot* observe (status codes, headers, wire framing).
|
||||
- **Scripting a peer over raw streams is a last resort**, reserved for behaviour the typed API
|
||||
cannot *produce* (malformed input, an impossible peer response). First ask what it would take
|
||||
for the public API to express it — often a small helper suffices. Every such test's docstring
|
||||
states why the public API couldn't do it.
|
||||
- **In-memory / in-process first.** HTTP-, SSE-, and auth-shaped behaviour can all be driven
|
||||
through an in-process ASGI transport; threads only when necessary, subprocesses only when the
|
||||
process boundary is itself the thing under test. In-process isn't just faster — it surfaces
|
||||
bugs (a real stream leak was found this way) that subprocess indirection masks.
|
||||
- **Tests read like real user code**: no aliasing shims in conftest, no walls of suppressions,
|
||||
no private imports unless that is genuinely the documented way to do the thing.
|
||||
- The assertion must prove the round trip — no side-channel state. What the server saw comes
|
||||
back through the protocol, or via a closure-captured list asserted after the call. Handlers
|
||||
assert their dispatch identity first (`assert params.name == "add"`), proving the request
|
||||
that arrived is the request the test sent.
|
||||
|
||||
## Assertions
|
||||
|
||||
- **Transformations** (input → output the SDK produced) → full-object `snapshot(...)` equality,
|
||||
so an added or dropped field fails. Regenerate with `--inline-snapshot=fix` so intentional
|
||||
changes arrive as a reviewable diff; never hand-edit snapshot literals.
|
||||
- **Pass-through values** (opaque tokens, `_meta`, cursors) → identity against the same
|
||||
variable you sent. A snapshot of a pass-through value only "matches" because a human checked
|
||||
two literals correspond — it proves nothing.
|
||||
- **Errors** → `pytest.raises` + `.code` against the named constant; snapshot SDK-authored
|
||||
messages; never `match=` on message text. Third-party text (pydantic, jsonschema) → stable
|
||||
prefix only — never pin text that changes with a dependency upgrade — with a comment saying so.
|
||||
|
||||
## Determinism
|
||||
|
||||
- **No sleeps, ever.** A sleep guesses at timing instead of waiting on the condition, so it
|
||||
either flakes or pads the run — sleeps head the list of the older test code's failure modes.
|
||||
Coordinate with `anyio.Event` so the wait ends exactly when the condition holds (that
|
||||
discipline is why 529 e2e tests run in ~10 s). Sole exception: tests *of* time-based
|
||||
features, with a comment.
|
||||
- Bound every indefinite wait with `anyio.fail_after(5)`. **5 is the standard** — widening it
|
||||
needs an articulable reason; "10 to be safe" is covering up a flake, not fixing one, and
|
||||
unexplained widenings propagate (one agent used 10 and every later one copied it).
|
||||
- **Never assert wall-clock time, even with huge margins.** `elapsed < 0.9` on a ~0.01 s
|
||||
operation — a 90× margin — was still rejected in review. `fail_after` bounding a hang is the
|
||||
only timing primitive allowed.
|
||||
- **Concurrency tests must prove genuine interleaving.** Without barriers the scheduler is free
|
||||
to serialize — "a" can start, finish, and return before "b" even begins — so two `start_soon`
|
||||
calls prove nothing. Gate with events so all parties are mid-flight before any proceeds, emit
|
||||
interleaved, then assert the demux.
|
||||
- **Don't over-synchronize either.** When delivery ordering is guaranteed (notifications
|
||||
emitted during a request you're awaiting, over a single ordered in-memory stream), a plain
|
||||
collected list asserted after the call is correct; events are for messages not tied to an
|
||||
awaited operation. Verify the ordering guarantee actually holds for your transport first.
|
||||
- Async tests use anyio, not asyncio.
|
||||
|
||||
## Behaviour philosophy
|
||||
|
||||
- **Pin current behaviour; never xfail.** A green suite asserting what actually happens is a
|
||||
regression bar for any refactor; an xfail proves nothing about it. Where current behaviour
|
||||
falls short of spec, pin the divergent output and record the gap as data — a tracking issue,
|
||||
with the docstring naming the known gap (suites with a requirements manifest record it as a
|
||||
divergence entry). Not hidden, not skipped.
|
||||
- **Hollow-proof check**: before claiming a test covers a behaviour, re-read the claim and ask
|
||||
"which assertion proves *this*?" A passing test near a behaviour is not proof of it — a full
|
||||
review of the e2e suite found two such cases even under this discipline.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- No new `# pragma`, `# type: ignore`, `# noqa` by default — restructure first; a suppression
|
||||
usually means a test or a type is missing. The narrow sanctioned escape hatches (and the
|
||||
audit to run before pushing) are in AGENTS.md. In tests, narrow types with `assert
|
||||
isinstance`; never `Any`/`object` when a real type exists.
|
||||
- **Warnings raised during tests are findings, not noise** (the repo runs
|
||||
`filterwarnings = error`). Fix the cause; if the fix can't land in the same change, scope the
|
||||
suppression to the one fixture that needs it and track the real fix explicitly.
|
||||
- Registered-but-never-invoked handler bodies are `raise NotImplementedError`, so they cannot
|
||||
silently become load-bearing.
|
||||
- Comments live next to the line they explain, not in docstrings; single backticks for code
|
||||
refs; match the surrounding comment density (one-liners next to one-liners). No
|
||||
`from __future__ import annotations` (py310+ repo).
|
||||
- Test work doesn't change `src/` as a side effect — the one mechanical exception is deleting a
|
||||
pragma a new test now covers. If a test can't be written without a library change, raise it
|
||||
as a finding or defer the test; don't quietly edit `src/`.
|
||||
|
||||
## Process (for agent-driven work)
|
||||
|
||||
- **Small chunks.** ≤10 tests per human-reviewed batch; when fanning out to multiple agents,
|
||||
≤5 per agent. Review quality scales inversely with batch size: the observed shortcuts
|
||||
(over-stacked tests, a timing assertion, wrong abstraction level) all surfaced in one
|
||||
oversized 27-test batch.
|
||||
- **Design → review → implement, as separate steps.** The design deliverable is not just the
|
||||
plan — it's the judgement calls (abstraction level, deferrals, contested assertions) stated
|
||||
explicitly, so the reviewer vetoes them before implementation rather than discovering them
|
||||
in the diff.
|
||||
- **High-stakes areas get fresh adversarial reviewers** on both the design and the
|
||||
implementation — fresh, because whoever designed it (or saw the proposed fix) anchors on the
|
||||
same layer. SERIOUS findings (wrong assertion, missed MUST/SHOULD, an unrecorded known gap)
|
||||
re-loop; style doesn't. Reserve the full panel for areas where being wrong is worse than
|
||||
being slow.
|
||||
- **Investigations pair a full-context look with a fresh agent given only the problem** —
|
||||
never the proposed fix — and compare conclusions. The unbiased read regularly catches
|
||||
anchoring on the wrong layer.
|
||||
- **When review questions a decision, reconsider genuinely**: re-derive the tradeoff, state the
|
||||
options, recommend with reasons. Defending the original choice is a valid outcome;
|
||||
reflexively agreeing with every challenge is as bad as ignoring it.
|
||||
- **Validate the reference artifact before building on it.** Whatever you treat as ground truth
|
||||
(a spec import, a baseline, a generated list), check it *first* — discovering it was
|
||||
incomplete after five batches costs far more than before batch one.
|
||||
- **Verify as you go**: per-file `uv run --frozen pytest <file> -q` + pyright + ruff while
|
||||
iterating; full suite, coverage, and `./scripts/test` (when `src/` was touched) at
|
||||
integration.
|
||||
- **Every agent writes notes**: what it did, what broke, and — most importantly — what it
|
||||
couldn't decide, so open questions surface instead of being silently resolved by whoever hit
|
||||
them.
|
||||
- **Quality over speed is a stated goal, not a preference** ("rushing something out the door is
|
||||
not the goal here, explicitly so"). Don't quietly de-scope agreed work mid-stream;
|
||||
renegotiate scope explicitly.
|
||||
- **Don't copy patterns from existing test code by default** — much of the repo's older test
|
||||
code is below the current bar (sleeps, mocks, `Test` classes, raciness). These rules define
|
||||
the bar for new work.
|
||||
5
.git-blame-ignore-revs
Normal file
5
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856
|
||||
543961968c0634e93d919d509cce23a1d6a56c21
|
||||
|
||||
# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553
|
||||
89e9c43acf7e23cf766357d776ec1ce63ac2c58e
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Generated
|
||||
uv.lock linguist-generated=true
|
||||
68
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: 🐛 MCP Python SDK Bug
|
||||
description: Report a bug or unexpected behavior in the MCP Python SDK
|
||||
labels: ["need confirmation"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for contributing to the MCP Python SDK! ✊
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Initial Checks
|
||||
description: >
|
||||
Both the 2.x stable line and the 1.x maintenance line are supported, and only
|
||||
the newest release of each line receives fixes.
|
||||
options:
|
||||
- label: I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
|
||||
required: true
|
||||
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: release-line
|
||||
attributes:
|
||||
label: Release line
|
||||
description: Which major version of the SDK are you using?
|
||||
options:
|
||||
- 2.x (current stable)
|
||||
- 1.x (maintenance line, v1.x branch)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Please explain what you're seeing and what you would expect to see.
|
||||
|
||||
Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example
|
||||
attributes:
|
||||
label: Example Code
|
||||
description: >
|
||||
If applicable, please add a self-contained,
|
||||
[minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example)
|
||||
demonstrating the bug.
|
||||
|
||||
placeholder: |
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
...
|
||||
render: Python
|
||||
|
||||
- type: textarea
|
||||
id: version
|
||||
attributes:
|
||||
label: Python & MCP Python SDK
|
||||
description: |
|
||||
Which version of Python and MCP Python SDK are you using?
|
||||
render: Text
|
||||
validations:
|
||||
required: true
|
||||
1
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
29
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: 🚀 MCP Python SDK Feature Request
|
||||
description: "Suggest a new feature for the MCP Python SDK"
|
||||
labels: ["feature request"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for contributing to the MCP Python SDK! ✊
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Please give as much detail as possible about the feature you would like to suggest. 🙏
|
||||
|
||||
You might like to add:
|
||||
* A demo of how code might look when using the feature
|
||||
* Your use case(s) for the feature
|
||||
* Reference to other projects that have a similar feature
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Please add any links or references that might help us understand your feature request better. 📚
|
||||
33
.github/ISSUE_TEMPLATE/question.yaml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/question.yaml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: ❓ MCP Python SDK Question
|
||||
description: "Ask a question about the MCP Python SDK"
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for reaching out to the MCP Python SDK community! We're here to help! 🤝
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: |
|
||||
Please provide as much detail as possible about your question. 🙏
|
||||
|
||||
You might like to include:
|
||||
* Code snippets showing what you've tried
|
||||
* Error messages you're encountering (if any)
|
||||
* Expected vs actual behavior
|
||||
* Your use case and what you're trying to achieve
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: |
|
||||
Please provide any additional context that might help us better understand your question, such as:
|
||||
* Your MCP Python SDK version
|
||||
* Your Python version
|
||||
* Relevant configuration or environment details 📝
|
||||
59
.github/ISSUE_TEMPLATE/v2-feedback.yaml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/v2-feedback.yaml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: v2 feedback
|
||||
description: Bugs, API friction, or docs gaps in v2 of the SDK
|
||||
title: "[v2] "
|
||||
labels: ["v2"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Anything in v2 that broke, surprised you, or slowed you down is useful, including API friction and docs gaps.
|
||||
|
||||
Docs: https://py.sdk.modelcontextprotocol.io/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/migration/
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Code to reproduce
|
||||
description: The smallest snippet or repository that shows it. For docs feedback, link the page instead.
|
||||
render: Python
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SDK version
|
||||
description: The published version (`pip show mcp`) or commit.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Server
|
||||
- Client
|
||||
- Transports
|
||||
- Auth
|
||||
- Documentation
|
||||
- Migration
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
644
.github/actions/conformance/client.py
vendored
Normal file
644
.github/actions/conformance/client.py
vendored
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
"""MCP unified conformance test client.
|
||||
|
||||
This client is designed to work with the @modelcontextprotocol/conformance npm package.
|
||||
It handles all conformance test scenarios via environment variables and CLI arguments.
|
||||
|
||||
Contract:
|
||||
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
|
||||
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
|
||||
- MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock
|
||||
server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when
|
||||
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
|
||||
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
|
||||
- Server URL as last CLI argument (sys.argv[1])
|
||||
- Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s)
|
||||
|
||||
Scenarios:
|
||||
initialize - Connect, initialize, list tools, close
|
||||
tools_call - Connect, call add_numbers(a=5, b=3), close
|
||||
sse-retry - Connect, call test_reconnection, close
|
||||
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
|
||||
json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
|
||||
request-metadata - Connect with all callbacks; client stamps _meta
|
||||
http-standard-headers - Tool, resource and prompt round-trips (Mcp-* headers checked)
|
||||
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
|
||||
http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
|
||||
elicitation-sep1034-client-defaults - Elicitation with default accept callback
|
||||
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
|
||||
auth/client-credentials-jwt - Client credentials with private_key_jwt
|
||||
auth/client-credentials-basic - Client credentials with client_secret_basic
|
||||
auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer)
|
||||
auth/* - Authorization code flow (default for auth scenarios)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, cast
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.auth.extensions.client_credentials import (
|
||||
ClientCredentialsOAuthProvider,
|
||||
PrivateKeyJWTOAuthProvider,
|
||||
SignedJWTParameters,
|
||||
)
|
||||
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
|
||||
from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
# Set up logging to stderr (stdout is for conformance test output)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Spec version the harness is running this scenario at (e.g. "2025-11-25",
|
||||
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted
|
||||
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
|
||||
#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
|
||||
#: outside the harness.
|
||||
PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
|
||||
|
||||
|
||||
def client_mode() -> str:
|
||||
"""Pick the Client(mode=) for the harness leg.
|
||||
|
||||
On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
|
||||
_meta envelope + MCP-Protocol-Version header are stamped on every request.
|
||||
On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
|
||||
as before (no server/discover probe is sent against a mock that would 400 it).
|
||||
Outside the harness -> 'auto' (probe + fallback).
|
||||
"""
|
||||
if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS:
|
||||
return "auto"
|
||||
return "legacy"
|
||||
|
||||
|
||||
# Type for async scenario handler functions
|
||||
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
|
||||
|
||||
# Registry of scenario handlers
|
||||
HANDLERS: dict[str, ScenarioHandler] = {}
|
||||
|
||||
|
||||
def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]:
|
||||
"""Register a scenario handler."""
|
||||
|
||||
def decorator(fn: ScenarioHandler) -> ScenarioHandler:
|
||||
HANDLERS[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_conformance_context() -> dict[str, Any]:
|
||||
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
|
||||
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
|
||||
if not context_json:
|
||||
raise RuntimeError(
|
||||
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
|
||||
"Expected JSON with client_id, client_secret, and/or private_key_pem."
|
||||
)
|
||||
try:
|
||||
return json.loads(context_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
|
||||
|
||||
|
||||
class InMemoryTokenStorage(TokenStorage):
|
||||
"""Simple in-memory token storage for conformance testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tokens: OAuthToken | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self._tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self._tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self._client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self._client_info = client_info
|
||||
|
||||
|
||||
class ConformanceOAuthCallbackHandler:
|
||||
"""OAuth callback handler that automatically fetches the authorization URL
|
||||
and extracts the auth code, without requiring user interaction.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._auth_code: str | None = None
|
||||
self._state: str | None = None
|
||||
self._iss: str | None = None
|
||||
|
||||
async def handle_redirect(self, authorization_url: str) -> None:
|
||||
"""Fetch the authorization URL and extract the auth code from the redirect."""
|
||||
logger.debug(f"Fetching authorization URL: {authorization_url}")
|
||||
|
||||
async with httpx2.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
authorization_url,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = cast(str, response.headers.get("location"))
|
||||
if location:
|
||||
redirect_url = urlparse(location)
|
||||
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
|
||||
|
||||
if "code" in query_params:
|
||||
self._auth_code = query_params["code"][0]
|
||||
state_values = query_params.get("state")
|
||||
self._state = state_values[0] if state_values else None
|
||||
iss_values = query_params.get("iss")
|
||||
self._iss = iss_values[0] if iss_values else None
|
||||
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
|
||||
return
|
||||
else:
|
||||
raise RuntimeError(f"No auth code in redirect URL: {location}")
|
||||
else:
|
||||
raise RuntimeError(f"No redirect location received from {authorization_url}")
|
||||
else:
|
||||
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
|
||||
|
||||
async def handle_callback(self) -> AuthorizationCodeResult:
|
||||
"""Return the captured auth code, state, and iss."""
|
||||
if self._auth_code is None:
|
||||
raise RuntimeError("No authorization code available - was handle_redirect called?")
|
||||
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
|
||||
self._auth_code = None
|
||||
self._state = None
|
||||
self._iss = None
|
||||
return result
|
||||
|
||||
|
||||
# --- Stub callbacks (declare capabilities in _meta without doing real work) ---
|
||||
|
||||
|
||||
async def stub_sampling_callback(
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
content=types.TextContent(type="text", text=""),
|
||||
model="conformance-stub",
|
||||
)
|
||||
|
||||
|
||||
async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
|
||||
return types.ListRootsResult(roots=[])
|
||||
|
||||
|
||||
async def default_elicitation_callback(
|
||||
context: ClientRequestContext,
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
"""Accept elicitation and apply defaults from the schema (SEP-1034)."""
|
||||
content: dict[str, str | int | float | bool | list[str] | None] = {}
|
||||
|
||||
# For form mode, extract defaults from the requested_schema
|
||||
if isinstance(params, types.ElicitRequestFormParams):
|
||||
schema = params.requested_schema
|
||||
logger.debug(f"Elicitation schema: {schema}")
|
||||
properties = schema.get("properties", {})
|
||||
for prop_name, prop_schema in properties.items():
|
||||
if "default" in prop_schema:
|
||||
content[prop_name] = prop_schema["default"]
|
||||
logger.debug(f"Applied defaults: {content}")
|
||||
|
||||
return types.ElicitResult(action="accept", content=content)
|
||||
|
||||
|
||||
# --- Scenario Handlers ---
|
||||
|
||||
|
||||
@register("initialize")
|
||||
async def run_initialize(server_url: str) -> None:
|
||||
"""Connect, initialize, list tools, close."""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
logger.debug("Initialized successfully")
|
||||
await client.list_tools()
|
||||
logger.debug("Listed tools successfully")
|
||||
|
||||
|
||||
@register("json-schema-ref-no-deref")
|
||||
async def run_json_schema_ref_no_deref(server_url: str) -> None:
|
||||
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.
|
||||
|
||||
The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
|
||||
Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
|
||||
scenario but its mock server only speaks the handshake-era lifecycle and 400s a
|
||||
modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
|
||||
"""
|
||||
async with Client(server_url, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
|
||||
|
||||
@register("json-schema-2020-12-preservation")
|
||||
async def run_json_schema_2020_12_preservation(server_url: str) -> None:
|
||||
"""List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106).
|
||||
|
||||
The harness diffs what the client round-trips through `json_schema_echo` against its
|
||||
fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties,
|
||||
allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike
|
||||
json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies.
|
||||
"""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
listed = await client.list_tools()
|
||||
focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool")
|
||||
await client.call_tool("json_schema_echo", {"schema": focal.input_schema})
|
||||
|
||||
|
||||
@register("tools_call")
|
||||
async def run_tools_call(server_url: str) -> None:
|
||||
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
|
||||
logger.debug(f"add_numbers result: {result}")
|
||||
|
||||
|
||||
@register("sse-retry")
|
||||
async def run_sse_retry(server_url: str) -> None:
|
||||
"""Connect, list tools, call test_reconnection, close."""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("test_reconnection", {})
|
||||
logger.debug(f"test_reconnection result: {result}")
|
||||
|
||||
|
||||
@register("request-metadata")
|
||||
async def run_request_metadata(server_url: str) -> None:
|
||||
"""Connect on the modern path with every client capability declared.
|
||||
|
||||
The scenario inspects every request's `_meta` envelope (SEP-2575) for
|
||||
protocolVersion / clientInfo / clientCapabilities, and the matching
|
||||
MCP-Protocol-Version header. mode='auto' makes the SDK send
|
||||
server/discover (covering the unsupported-version retry check), then adopt
|
||||
and stamp the envelope on the follow-up requests.
|
||||
"""
|
||||
async with Client(
|
||||
server_url,
|
||||
mode=client_mode(),
|
||||
sampling_callback=stub_sampling_callback,
|
||||
list_roots_callback=stub_list_roots_callback,
|
||||
elicitation_callback=default_elicitation_callback,
|
||||
) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
|
||||
logger.debug(f"add_numbers result: {result}")
|
||||
|
||||
|
||||
@register("http-standard-headers")
|
||||
async def run_http_standard_headers(server_url: str) -> None:
|
||||
"""Touch tools, resources and prompts on the modern path so each standard header is checked (SEP-2243).
|
||||
|
||||
The scenario inspects Mcp-Method on each request and Mcp-Name on tools/call,
|
||||
resources/read and prompts/get, and reports methods the client never sent as
|
||||
SKIPPED rather than failed, so exercise one of each. initialize and
|
||||
notifications/initialized stay SKIPPED: the modern path discovers via
|
||||
server/discover and never sends them.
|
||||
"""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
tools = await client.list_tools()
|
||||
if tools.tools:
|
||||
await client.call_tool(tools.tools[0].name, _stub_required_args(tools.tools[0].input_schema))
|
||||
resources = await client.list_resources()
|
||||
if resources.resources:
|
||||
await client.read_resource(resources.resources[0].uri)
|
||||
prompts = await client.list_prompts()
|
||||
if prompts.prompts:
|
||||
await client.get_prompt(prompts.prompts[0].name)
|
||||
|
||||
|
||||
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Minimal arguments satisfying a tool inputSchema's required list."""
|
||||
by_type: dict[str, Any] = {
|
||||
"string": "x",
|
||||
"integer": 0,
|
||||
"number": 0,
|
||||
"boolean": False,
|
||||
"object": {},
|
||||
"array": [],
|
||||
"null": None,
|
||||
}
|
||||
properties = input_schema.get("properties", {})
|
||||
return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])}
|
||||
|
||||
|
||||
@register("http-invalid-tool-headers")
|
||||
async def run_http_invalid_tool_headers(server_url: str) -> None:
|
||||
"""List tools, then call every tool the SDK surfaces (SEP-2243).
|
||||
|
||||
The harness mock advertises one valid tool plus several with malformed
|
||||
x-mcp-header annotations (empty, non-primitive type, duplicate, invalid
|
||||
chars). The scenario passes if valid_tool is called and the malformed
|
||||
ones are not -- so a conforming client filters them out of the list_tools
|
||||
result and the loop below never sees them. The scenario sets
|
||||
allowClientError, so a per-call failure is logged and skipped rather
|
||||
than aborting the whole run.
|
||||
"""
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
listed = await client.list_tools()
|
||||
logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}")
|
||||
for tool in listed.tools:
|
||||
try:
|
||||
await client.call_tool(tool.name, _stub_required_args(tool.input_schema))
|
||||
except Exception:
|
||||
logger.exception(f"call_tool({tool.name!r}) failed")
|
||||
|
||||
|
||||
@register("http-custom-headers")
|
||||
async def run_http_custom_headers(server_url: str) -> None:
|
||||
"""List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243).
|
||||
|
||||
The scenario supplies the exact arguments to send (including the null/edge-case values that
|
||||
exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is
|
||||
what drives every per-parameter check. `list_tools` first so the SDK caches each tool's
|
||||
annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are
|
||||
logged and skipped rather than aborting the run.
|
||||
"""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
if os.environ.get("MCP_CONFORMANCE_CONTEXT"):
|
||||
tool_calls = get_conformance_context().get("toolCalls", [])
|
||||
async with Client(server_url, mode=client_mode()) as client:
|
||||
listed = await client.list_tools()
|
||||
surfaced = {tool.name for tool in listed.tools}
|
||||
logger.debug(f"Surfaced tools: {sorted(surfaced)}")
|
||||
for call in tool_calls:
|
||||
name = call["name"]
|
||||
if name not in surfaced:
|
||||
logger.debug(f"skipping {name!r}: not surfaced by list_tools")
|
||||
continue
|
||||
try:
|
||||
await client.call_tool(name, call.get("arguments") or {})
|
||||
except Exception:
|
||||
logger.exception(f"call_tool({name!r}) failed")
|
||||
|
||||
|
||||
@register("elicitation-sep1034-client-defaults")
|
||||
async def run_elicitation_defaults(server_url: str) -> None:
|
||||
"""Connect with elicitation callback that applies schema defaults."""
|
||||
async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("test_client_elicitation_defaults", {})
|
||||
logger.debug(f"test_client_elicitation_defaults result: {result}")
|
||||
|
||||
|
||||
@register("sep-2322-client-request-state")
|
||||
async def run_mrtr_client(server_url: str) -> None:
|
||||
"""Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop.
|
||||
|
||||
The mock inspects raw `tools/call` params, so registering an
|
||||
`elicitation_callback` and letting the driver run is enough to satisfy
|
||||
all five wire-shape checks: the driver echoes `request_state` byte-exact
|
||||
and omits it when the server sent none, every retry mints a fresh
|
||||
JSON-RPC id, the unrelated call between auto-loops carries no MRTR
|
||||
params, and the no-`resultType` response parses as a terminal
|
||||
`CallToolResult` so the driver never retries it.
|
||||
"""
|
||||
|
||||
async def confirm(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
return types.ElicitResult(action="accept", content={"confirmed": True})
|
||||
|
||||
async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client:
|
||||
await client.list_tools()
|
||||
|
||||
await client.call_tool("test_mrtr_echo_state", {})
|
||||
await client.call_tool("test_mrtr_unrelated", {})
|
||||
await client.call_tool("test_mrtr_no_state", {})
|
||||
|
||||
result = await client.call_tool("test_mrtr_no_result_type", {})
|
||||
assert isinstance(result, types.CallToolResult)
|
||||
|
||||
|
||||
@register("auth/client-credentials-jwt")
|
||||
async def run_client_credentials_jwt(server_url: str) -> None:
|
||||
"""Client credentials flow with private_key_jwt authentication."""
|
||||
context = get_conformance_context()
|
||||
client_id = context.get("client_id")
|
||||
private_key_pem = context.get("private_key_pem")
|
||||
signing_algorithm = context.get("signing_algorithm", "ES256")
|
||||
|
||||
if not client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
|
||||
if not private_key_pem:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
|
||||
|
||||
jwt_params = SignedJWTParameters(
|
||||
issuer=client_id,
|
||||
subject=client_id,
|
||||
signing_algorithm=signing_algorithm,
|
||||
signing_key=private_key_pem,
|
||||
)
|
||||
|
||||
oauth_auth = PrivateKeyJWTOAuthProvider(
|
||||
server_url=server_url,
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id=client_id,
|
||||
assertion_provider=jwt_params.create_assertion_provider(),
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
@register("auth/client-credentials-basic")
|
||||
async def run_client_credentials_basic(server_url: str) -> None:
|
||||
"""Client credentials flow with client_secret_basic authentication."""
|
||||
context = get_conformance_context()
|
||||
client_id = context.get("client_id")
|
||||
client_secret = context.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
|
||||
if not client_secret:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
|
||||
|
||||
oauth_auth = ClientCredentialsOAuthProvider(
|
||||
server_url=server_url,
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
@register("auth/enterprise-managed-authorization")
|
||||
async def run_enterprise_managed_authorization(server_url: str) -> None:
|
||||
"""SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the
|
||||
enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP
|
||||
authorization server."""
|
||||
context = get_conformance_context()
|
||||
client_id = context.get("client_id")
|
||||
client_secret = context.get("client_secret")
|
||||
idp_client_id = context.get("idp_client_id")
|
||||
idp_id_token = context.get("idp_id_token")
|
||||
idp_token_endpoint = context.get("idp_token_endpoint")
|
||||
|
||||
if not client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
|
||||
if not client_secret:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
|
||||
if not idp_client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'")
|
||||
if not idp_id_token:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'")
|
||||
if not idp_token_endpoint:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'")
|
||||
|
||||
# IdentityAssertionOAuthProvider takes the AS issuer as configuration (the
|
||||
# SEP-990 trust model: the resource server is never asked which AS to use).
|
||||
# The harness does not put the issuer in context, so for conformance we
|
||||
# learn it from the harness's PRM document (RFC 9728); production
|
||||
# deployments would supply it as static configuration instead.
|
||||
prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0]
|
||||
async with httpx2.AsyncClient(timeout=30.0) as http:
|
||||
prm = (await http.get(prm_url)).raise_for_status().json()
|
||||
as_issuer = prm["authorization_servers"][0]
|
||||
|
||||
async def fetch_id_jag(audience: str, resource: str) -> str:
|
||||
"""Leg 1 - RFC 8693 token-exchange at the enterprise IdP."""
|
||||
async with httpx2.AsyncClient(timeout=30.0) as http:
|
||||
resp = await http.post(
|
||||
idp_token_endpoint,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:id-jag",
|
||||
"subject_token": idp_id_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
"audience": audience,
|
||||
"resource": resource,
|
||||
"client_id": idp_client_id,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
oauth_auth = IdentityAssertionOAuthProvider(
|
||||
server_url=server_url,
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
issuer=as_issuer,
|
||||
assertion_provider=fetch_id_jag,
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
async def run_auth_code_client(server_url: str) -> None:
|
||||
"""Authorization code flow (default for auth/* scenarios)."""
|
||||
callback_handler = ConformanceOAuthCallbackHandler()
|
||||
storage = InMemoryTokenStorage()
|
||||
|
||||
# Check for pre-registered client credentials from context
|
||||
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
|
||||
if context_json:
|
||||
try:
|
||||
context = json.loads(context_json)
|
||||
client_id = context.get("client_id")
|
||||
client_secret = context.get("client_secret")
|
||||
if client_id:
|
||||
await storage.set_client_info(
|
||||
OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
||||
token_endpoint_auth_method="client_secret_basic" if client_secret else "none",
|
||||
)
|
||||
)
|
||||
logger.debug(f"Pre-loaded client credentials: client_id={client_id}")
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT")
|
||||
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url=server_url,
|
||||
client_metadata=OAuthClientMetadata(
|
||||
client_name="conformance-client",
|
||||
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
),
|
||||
storage=storage,
|
||||
redirect_handler=callback_handler.handle_redirect,
|
||||
callback_handler=callback_handler.handle_callback,
|
||||
client_metadata_url="https://conformance-test.local/client-metadata.json",
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None:
|
||||
"""Common session logic for all OAuth flows."""
|
||||
http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0)
|
||||
transport = streamable_http_client(url=server_url, http_client=http_client)
|
||||
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
|
||||
logger.debug("Initialized successfully")
|
||||
|
||||
tools_result = await client.list_tools()
|
||||
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
|
||||
|
||||
# Call the first available tool (different tests have different tools)
|
||||
if tools_result.tools:
|
||||
tool_name = tools_result.tools[0].name
|
||||
try:
|
||||
result = await client.call_tool(tool_name, {})
|
||||
logger.debug(f"Called {tool_name}, result: {result}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Tool call result/error: {e}")
|
||||
|
||||
logger.debug("Connection closed successfully")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the conformance client."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
server_url = sys.argv[1]
|
||||
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
|
||||
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}")
|
||||
|
||||
if scenario:
|
||||
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
|
||||
handler = HANDLERS.get(scenario)
|
||||
if handler:
|
||||
asyncio.run(handler(server_url))
|
||||
elif scenario.startswith("auth/"):
|
||||
asyncio.run(run_auth_code_client(server_url))
|
||||
else:
|
||||
print(f"Unknown scenario: {scenario}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.debug(f"Running default auth flow against {server_url}")
|
||||
asyncio.run(run_auth_code_client(server_url))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
26
.github/actions/conformance/expected-failures.2025-11-25.yml
vendored
Normal file
26
.github/actions/conformance/expected-failures.2025-11-25.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Expected failures at the 2025-11-25 wire, read by the `--suite all
|
||||
# --spec-version 2025-11-25` server and client legs: every scenario the pinned
|
||||
# harness marks applicable at 2025-11-25, run over the stateful handshake.
|
||||
#
|
||||
# That selection is a superset of the frozen 2025-11-25 requirement set (`npx
|
||||
# $CONFORMANCE_PKG list --requirements 2025-11-25`), so an entry here for a
|
||||
# scenario in that set means `conformance tier-check` scores the SDK below 100%
|
||||
# for 2025-11-25 even though CI is green; link the tracking issue next to any
|
||||
# such entry.
|
||||
#
|
||||
# Kept separate from expected-failures.yml even though the bare `--suite all`
|
||||
# legs currently run these same scenarios at 2025-11-25 too: the harness's
|
||||
# default wire is due to move (modelcontextprotocol/conformance#426) and this
|
||||
# file must keep meaning 2025-11-25. Until then a failure here also needs an
|
||||
# entry there. The file is passed even while empty: run-client.sh keys its solo
|
||||
# re-verification off the harness's baseline output.
|
||||
#
|
||||
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||
# deliberately bumping that pin and reconciling all three expected-failures
|
||||
# files in the same change. Entries MUST be removed as they start passing (the
|
||||
# runner fails on stale entries).
|
||||
|
||||
client: []
|
||||
|
||||
server: []
|
||||
29
.github/actions/conformance/expected-failures.2026-07-28.yml
vendored
Normal file
29
.github/actions/conformance/expected-failures.2026-07-28.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Expected failures at the 2026-07-28 wire, read by the `--suite all
|
||||
# --spec-version 2026-07-28` server and client legs: every scenario the pinned
|
||||
# harness marks applicable at 2026-07-28, run stateless with per-request _meta.
|
||||
#
|
||||
# That selection is a superset of the frozen 2026-07-28 requirement set (`npx
|
||||
# $CONFORMANCE_PKG list --requirements 2026-07-28`), so an entry here for a
|
||||
# scenario in that set means `conformance tier-check` scores the SDK below 100%
|
||||
# for 2026-07-28 even though CI is green; link the tracking issue next to any
|
||||
# such entry.
|
||||
#
|
||||
# This baseline is separate from expected-failures.yml because entries are
|
||||
# keyed by scenario name only: a scenario that passes at the harness default
|
||||
# wire in the bare `--suite all` legs but fails when forced to 2026-07-28 (or
|
||||
# vice versa) cannot be expressed in a shared file. Where the bare legs already
|
||||
# default a scenario to 2026-07-28, a failure here also needs an entry there.
|
||||
#
|
||||
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||
# deliberately bumping that pin and reconciling all three expected-failures
|
||||
# files in the same change.
|
||||
#
|
||||
# Entries are grouped by what unblocks them. As each gap closes the
|
||||
# corresponding scenarios start passing and MUST be removed from this list
|
||||
# (the runner fails on stale entries), so the baseline burns down per
|
||||
# milestone.
|
||||
|
||||
client: []
|
||||
|
||||
server: []
|
||||
55
.github/actions/conformance/expected-failures.yml
vendored
Normal file
55
.github/actions/conformance/expected-failures.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Conformance scenarios not yet passing against the Python SDK on main, read by
|
||||
# the bare `--suite all` server and client legs (every scenario shipped with the
|
||||
# pinned harness, each at the harness's default wire for it). CI exits 0 if only
|
||||
# these fail, exits 1 on unexpected failures or stale entries.
|
||||
#
|
||||
# The wire-pinned legs read expected-failures.2025-11-25.yml and
|
||||
# expected-failures.2026-07-28.yml instead; see the header of
|
||||
# .github/workflows/conformance.yml for the leg layout.
|
||||
#
|
||||
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||
# deliberately bumping that pin and reconciling all three expected-failures
|
||||
# files in the same change.
|
||||
#
|
||||
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
|
||||
# scenarios start passing and MUST be removed from this list (the runner fails
|
||||
# on stale entries), so the baseline burns down per milestone.
|
||||
|
||||
client:
|
||||
# SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs.
|
||||
# The entries are per-check (conformance #406) because both scenarios
|
||||
# pass their non-DPoP checks (discovery, token acquisition, request
|
||||
# flow) live.
|
||||
- auth/dpop:sep-1932-client-token-request-proof
|
||||
- auth/dpop:sep-1932-client-dpop-auth-scheme
|
||||
- auth/dpop:sep-1932-client-fresh-proof
|
||||
- auth/dpop-nonce:sep-1932-client-token-request-proof
|
||||
- auth/dpop-nonce:sep-1932-client-dpop-auth-scheme
|
||||
- auth/dpop-nonce:sep-1932-client-fresh-proof
|
||||
- auth/dpop-nonce:sep-1932-client-as-nonce
|
||||
- auth/dpop-nonce:sep-1932-client-rs-nonce
|
||||
# Workload identity federation: the OAuth client does not implement the
|
||||
# urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with
|
||||
# authorization_code). Per-check for the same reason.
|
||||
- auth/wif-jwt-bearer:wif-grant-type
|
||||
|
||||
server:
|
||||
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
|
||||
# tasks extension yet. The bare `--suite all` leg is the only one that
|
||||
# reaches these extension-tagged scenarios (they never match a
|
||||
# --spec-version filter), so these entries live here only.
|
||||
#
|
||||
# `tasks-status-notifications` is intentionally NOT listed: the harness
|
||||
# skips it unconditionally (pending its rewrite against subscriptions/
|
||||
# listen), and a baseline entry for a scenario with no failing checks is
|
||||
# flagged stale.
|
||||
- tasks-lifecycle
|
||||
- tasks-capability-negotiation
|
||||
- tasks-wire-fields
|
||||
- tasks-request-state-removal
|
||||
- tasks-mrtr-input
|
||||
- tasks-request-headers
|
||||
- tasks-dispatch-and-envelope
|
||||
- tasks-required-task-error
|
||||
- tasks-mrtr-composition
|
||||
108
.github/actions/conformance/run-client.sh
vendored
Executable file
108
.github/actions/conformance/run-client.sh
vendored
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/bin/bash
|
||||
# Run a client conformance suite, re-verifying unexpected failures solo.
|
||||
# Concurrent suite runs on a hosted runner can push scenarios with real-time
|
||||
# waits past tolerance; solo, a real failure fails again while a contention
|
||||
# artifact passes. Failures that only reproduce under concurrency are excused.
|
||||
set -uo pipefail
|
||||
|
||||
: "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}"
|
||||
# One attempt: a solo failure on the quiet runner disproves the contention
|
||||
# hypothesis; a second try would be the blind retry this script avoids.
|
||||
SOLO_ATTEMPTS="${CONFORMANCE_SOLO_ATTEMPTS:-1}"
|
||||
|
||||
# Relative args resolve from the repo root; same contract as run-server.sh.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR/../../.." || exit 1
|
||||
|
||||
log="$(mktemp)"
|
||||
trap 'rm -f "$log"' EXIT
|
||||
|
||||
npx --yes "$CONFORMANCE_PKG" client "$@" 2>&1 | tee "$log"
|
||||
rc=${PIPESTATUS[0]}
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
plain="$(sed 's/\x1b\[[0-9;]*m//g' "$log")"
|
||||
|
||||
# If the harness's summary wording changes, the list comes up empty and the
|
||||
# original exit code passes through - never a false green.
|
||||
mapfile -t scenarios < <(
|
||||
printf '%s\n' "$plain" |
|
||||
sed -n '/^Unexpected failures (not in baseline):$/,/^$/p' |
|
||||
sed -n 's/^ ✗ //p'
|
||||
)
|
||||
if [ "${#scenarios[@]}" -eq 0 ]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
for scenario in "${scenarios[@]}"; do
|
||||
if ! [[ "$scenario" =~ ^[A-Za-z0-9/_-]+$ ]]; then
|
||||
echo "Extracted unexpected-failure name '${scenario}' does not look like a scenario name; passing the suite failure through." >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
done
|
||||
|
||||
# A stale baseline entry is a configuration error a solo rerun cannot excuse.
|
||||
# Here-string, not a pipe: grep -q quitting early would SIGPIPE printf and,
|
||||
# under pipefail, skip this guard exactly when the pattern is present.
|
||||
if grep -q '^Stale baseline entries' <<<"$plain"; then
|
||||
echo "Suite also reported stale baseline entries; not retrying." >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
# Drop the suite-only flags: --scenario replaces --suite, and solo runs are
|
||||
# judged directly rather than against the baseline.
|
||||
rerun_args=()
|
||||
output_dir=""
|
||||
skip_next=0
|
||||
expect_output_dir=0
|
||||
for arg in "$@"; do
|
||||
if [ "$skip_next" -eq 1 ]; then
|
||||
if [ "$expect_output_dir" -eq 1 ]; then
|
||||
output_dir="$arg"
|
||||
fi
|
||||
skip_next=0
|
||||
expect_output_dir=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--output-dir)
|
||||
skip_next=1
|
||||
expect_output_dir=1
|
||||
;;
|
||||
--suite | --expected-failures) skip_next=1 ;;
|
||||
--output-dir=*) output_dir="${arg#--output-dir=}" ;;
|
||||
--suite=* | --expected-failures=*) ;;
|
||||
*) rerun_args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$output_dir" ]; then
|
||||
rerun_args+=(--output-dir "${output_dir}-solo")
|
||||
fi
|
||||
|
||||
for scenario in "${scenarios[@]}"; do
|
||||
passed=0
|
||||
for attempt in $(seq 1 "$SOLO_ATTEMPTS"); do
|
||||
echo ""
|
||||
echo "Re-running '${scenario}' solo (attempt ${attempt}/${SOLO_ATTEMPTS})..."
|
||||
if npx --yes "$CONFORMANCE_PKG" client --scenario "$scenario" "${rerun_args[@]}"; then
|
||||
passed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$passed" -ne 1 ]; then
|
||||
echo "'${scenario}' still fails when run alone: real failure, not suite contention." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$output_dir" ]; then
|
||||
mkdir -p "$output_dir"
|
||||
printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED"
|
||||
fi
|
||||
echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention."
|
||||
# Surface the rescue on the run summary; the log line above and the
|
||||
# FLAKE_RESCUED marker in the uploaded results are otherwise easy to miss on a
|
||||
# green run.
|
||||
echo "::warning title=Conformance flake rescued::${scenarios[*]} failed under suite concurrency and passed solo${output_dir:+ (see ${output_dir}-solo and FLAKE_RESCUED in the uploaded results)}."
|
||||
exit 0
|
||||
51
.github/actions/conformance/run-server.sh
vendored
Executable file
51
.github/actions/conformance/run-server.sh
vendored
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PORT="${PORT:-3001}"
|
||||
SERVER_URL="http://localhost:${PORT}/mcp"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR/../../.."
|
||||
|
||||
# Refuse to start if something is already listening on the port. The readiness
|
||||
# check below cannot tell our server apart from a stale one, so a leftover
|
||||
# listener would mean silently running conformance against old code.
|
||||
if (: > "/dev/tcp/localhost/${PORT}") 2>/dev/null; then
|
||||
echo "Error: port ${PORT} is already in use." >&2
|
||||
echo "Stop the stale process first (lsof -ti:${PORT} -sTCP:LISTEN | xargs kill) or set PORT to a free port." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting mcp-everything-server on port ${PORT}..."
|
||||
uv run --frozen mcp-everything-server --port "$PORT" &
|
||||
SERVER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping server (PID: ${SERVER_PID})..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for server to be ready. --max-time keeps a hung listener from wedging
|
||||
# the loop, and a dead server process fails fast instead of retrying.
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
while ! curl -s --max-time 2 "$SERVER_URL" > /dev/null 2>&1; do
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "Server process exited unexpectedly" >&2
|
||||
exit 1
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "Server failed to start after ${MAX_RETRIES} retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "Server ready at $SERVER_URL"
|
||||
|
||||
npx --yes "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" \
|
||||
server --url "$SERVER_URL" "$@"
|
||||
22
.github/dependabot.yml
vendored
Normal file
22
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
cooldown:
|
||||
default-days: 14
|
||||
groups:
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
cooldown:
|
||||
default-days: 14
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
38
.github/pull_request_template.md
vendored
Normal file
38
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
Pull requests from outside the maintainer team need to link an open issue that
|
||||
a maintainer has assigned to you (or one labeled `help wanted`); others are
|
||||
closed automatically until that's in place. See CONTRIBUTING.md for details.
|
||||
-->
|
||||
|
||||
Fixes #
|
||||
|
||||
<!-- Provide a brief summary of your changes -->
|
||||
|
||||
## Motivation and Context
|
||||
<!-- Why is this change needed? What problem does it solve? -->
|
||||
|
||||
## How Has This Been Tested?
|
||||
<!-- Have you tested this in a real application? Which scenarios were tested? -->
|
||||
|
||||
## Breaking Changes
|
||||
<!-- Will users need to update their code or configurations? -->
|
||||
|
||||
## Types of changes
|
||||
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
|
||||
- [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer)
|
||||
- [ ] I have disclosed any AI assistance and can explain the change in my own words
|
||||
- [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io)
|
||||
- [ ] My code follows the repository's style guidelines
|
||||
- [ ] New and existing tests pass locally
|
||||
- [ ] I have added appropriate error handling
|
||||
- [ ] I have added or updated documentation as needed
|
||||
|
||||
## Additional context
|
||||
<!-- Add any other context, implementation notes, or design decisions -->
|
||||
117
.github/scripts/docs_preview.js
vendored
Normal file
117
.github/scripts/docs_preview.js
vendored
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires
|
||||
// these up: `authorize` decides whether a run may build and deploy a preview
|
||||
// (and for which commit), `comment` reports the outcome on the pull request.
|
||||
// The security model is described in the workflow's header.
|
||||
'use strict';
|
||||
|
||||
const MARKER = '<!-- docs-preview -->';
|
||||
const BOT_LOGIN = 'github-actions[bot]';
|
||||
|
||||
// Sets the job outputs `authorized`, `pr_number`, `head_sha` and
|
||||
// `slash_attempt` for a pull_request_target or /preview-docs issue_comment run.
|
||||
async function authorize({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function permissionFor(username) {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
|
||||
return { level: data.permission, role: data.role_name };
|
||||
}
|
||||
|
||||
let authorized = false;
|
||||
let prNumber = '';
|
||||
let headSha = '';
|
||||
let slashAttempt = false;
|
||||
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
const pr = context.payload.pull_request;
|
||||
prNumber = String(pr.number);
|
||||
headSha = pr.head.sha;
|
||||
// No automatic preview for fork PRs: actions/checkout refuses to fetch a
|
||||
// fork's head in a pull_request_target run. A maintainer can still request
|
||||
// one with /preview-docs. (head.repo is null once the fork is deleted.)
|
||||
const headRepo = pr.head.repo;
|
||||
if (!headRepo || headRepo.id !== context.payload.repository.id) {
|
||||
core.info(`PR #${prNumber} head is on ${headRepo ? headRepo.full_name : 'a deleted fork'}; fork PRs are previewed via /preview-docs only.`);
|
||||
} else {
|
||||
// Gate on the *sender* (whoever caused this run — on synchronize that
|
||||
// is the pusher), not the PR author, so a non-admin pushing to an
|
||||
// admin-opened branch does not get an automatic build.
|
||||
const actor = context.payload.sender.login;
|
||||
const perm = await permissionFor(actor);
|
||||
authorized = perm.level === 'admin';
|
||||
core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
||||
}
|
||||
} else {
|
||||
// issue_comment: the job-level `if:` already guarantees this is a PR
|
||||
// comment starting with /preview-docs.
|
||||
slashAttempt = true;
|
||||
const actor = context.payload.comment.user.login;
|
||||
prNumber = String(context.payload.issue.number);
|
||||
const perm = await permissionFor(actor);
|
||||
authorized = perm.level === 'admin' || perm.role === 'maintain';
|
||||
if (authorized) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) });
|
||||
if (pr.state !== 'open') {
|
||||
authorized = false;
|
||||
core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`);
|
||||
} else {
|
||||
headSha = pr.head.sha;
|
||||
}
|
||||
}
|
||||
core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
||||
}
|
||||
|
||||
core.setOutput('authorized', String(authorized));
|
||||
core.setOutput('pr_number', prNumber);
|
||||
core.setOutput('head_sha', headSha);
|
||||
core.setOutput('slash_attempt', String(slashAttempt));
|
||||
}
|
||||
|
||||
// Posts or updates the preview comment on the PR. Reads the outcome of the
|
||||
// earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA,
|
||||
// DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL.
|
||||
async function comment({ github, context }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const env = process.env;
|
||||
const issue_number = Number(env.PR_NUMBER);
|
||||
|
||||
async function upsert(body) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||
const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||
}
|
||||
}
|
||||
|
||||
if (env.AUTHORIZED !== 'true') {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.DEPLOY_RESULT !== 'success') {
|
||||
await upsert(
|
||||
`${MARKER}\n### 📚 Documentation preview\n\n` +
|
||||
`❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL;
|
||||
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
||||
await upsert(
|
||||
`${MARKER}\n### 📚 Documentation preview\n\n` +
|
||||
`| | |\n|---|---|\n` +
|
||||
`| **Preview** | ${previewUrl} |\n` +
|
||||
`| **Deployment** | ${env.DEPLOYMENT_URL} |\n` +
|
||||
`| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` +
|
||||
`| **Triggered by** | @${context.actor} |\n` +
|
||||
`| **Updated** | ${ts} |\n`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { authorize, comment };
|
||||
241
.github/scripts/docs_preview.test.js
vendored
Normal file
241
.github/scripts/docs_preview.test.js
vendored
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// Scenario tests for docs_preview.js: who gets a preview build (`authorize`)
|
||||
// and what the pull request shows afterwards (`comment`).
|
||||
//
|
||||
// node --test .github/scripts/docs_preview.test.js
|
||||
//
|
||||
// No dependencies; the GitHub client is a small fake defined at the bottom.
|
||||
// CI runs it in the checks job (.github/workflows/shared.yml).
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { authorize, comment } = require('./docs_preview.js');
|
||||
|
||||
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
|
||||
const BASE_REPO = { id: 1, full_name: 'modelcontextprotocol/python-sdk' };
|
||||
const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42';
|
||||
const MARKER = '<!-- docs-preview -->';
|
||||
|
||||
// `permission` / `role_name` as the collaborators API reports them.
|
||||
const PEOPLE = {
|
||||
admin: { permission: 'admin', role_name: 'admin' },
|
||||
maintainer: { permission: 'write', role_name: 'maintain' },
|
||||
writer: { permission: 'write', role_name: 'write' },
|
||||
outsider: { permission: 'read', role_name: 'read' },
|
||||
};
|
||||
|
||||
// ── authorize ──────────────────────────────────────────────────────────────
|
||||
// `expect` is the full set of job outputs the step writes.
|
||||
|
||||
const authorizeScenarios = [
|
||||
{
|
||||
name: 'admin pushes to (or opens) a PR → automatic preview of that head',
|
||||
event: pushed(7, 'admin'),
|
||||
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||
},
|
||||
{
|
||||
name: 'someone with write but not admin pushes → no automatic preview',
|
||||
event: pushed(7, 'writer'),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||
},
|
||||
{
|
||||
// actions/checkout refuses a fork's head under pull_request_target, so
|
||||
// the run stops here instead of failing in `build`; /preview-docs still works.
|
||||
name: 'admin pushes to or reopens a fork PR → no automatic preview',
|
||||
event: pushed(7, 'admin', { fork: 'someone/python-sdk' }),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||
permissionLookups: 0,
|
||||
},
|
||||
{
|
||||
name: 'fork PR whose fork has since been deleted → no automatic preview',
|
||||
event: pushed(7, 'admin', { fork: null }),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||
permissionLookups: 0,
|
||||
},
|
||||
{
|
||||
name: 'maintainer comments /preview-docs on a fork PR → previewed like any other',
|
||||
pr: { fork: 'someone/python-sdk' },
|
||||
event: slash(7, 'maintainer'),
|
||||
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||
},
|
||||
{
|
||||
name: 'maintainer comments /preview-docs on an open PR → preview of its current head',
|
||||
event: slash(7, 'maintainer'),
|
||||
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||
},
|
||||
{
|
||||
name: 'admin comments /preview-docs → authorized as well',
|
||||
event: slash(7, 'admin'),
|
||||
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||
},
|
||||
{
|
||||
name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt',
|
||||
event: slash(7, 'writer'),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||
},
|
||||
{
|
||||
name: 'outsider comments /preview-docs → refused, recorded as an attempt',
|
||||
event: slash(7, 'outsider'),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||
},
|
||||
{
|
||||
name: '/preview-docs on a closed PR → refused even for a maintainer',
|
||||
pr: { state: 'closed' },
|
||||
event: slash(7, 'maintainer'),
|
||||
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const s of authorizeScenarios) {
|
||||
test(`authorize: ${s.name}`, async () => {
|
||||
const world = makeWorld({ pr: { number: 7, ...s.pr } });
|
||||
assert.deepEqual(await runAuthorize(world, s.event), s.expect);
|
||||
assert.equal(world.writes.length, 0);
|
||||
if (s.permissionLookups !== undefined) assert.equal(world.permissionLookups, s.permissionLookups);
|
||||
});
|
||||
}
|
||||
|
||||
test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 } });
|
||||
world.failPermissionLookup = true;
|
||||
await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/);
|
||||
});
|
||||
|
||||
// ── comment ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEPLOYED = {
|
||||
AUTHORIZED: 'true',
|
||||
PR_NUMBER: '7',
|
||||
HEAD_SHA: HEAD,
|
||||
DEPLOY_RESULT: 'success',
|
||||
DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev',
|
||||
ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev',
|
||||
RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1',
|
||||
};
|
||||
|
||||
test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 } });
|
||||
await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider');
|
||||
assert.equal(world.comments.length, 1);
|
||||
assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/);
|
||||
assert.ok(!world.comments[0].body.includes(MARKER));
|
||||
});
|
||||
|
||||
test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 } });
|
||||
await runComment(world, DEPLOYED, 'admin');
|
||||
assert.equal(world.comments.length, 1);
|
||||
const body = world.comments[0].body;
|
||||
assert.ok(body.startsWith(`${MARKER}\n### 📚 Documentation preview`));
|
||||
assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||
assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||
assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/);
|
||||
assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/);
|
||||
assert.deepEqual(world.writes, ['comment on #7']);
|
||||
});
|
||||
|
||||
test('comment: a later deploy edits the existing preview comment instead of adding another', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
|
||||
await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin');
|
||||
assert.equal(world.comments.length, 2);
|
||||
assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/);
|
||||
assert.deepEqual(world.writes, ['edit comment 101']);
|
||||
});
|
||||
|
||||
test("comment: someone else's comment that happens to contain the marker is left alone", async () => {
|
||||
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] });
|
||||
await runComment(world, DEPLOYED, 'admin');
|
||||
assert.equal(world.comments.length, 2);
|
||||
assert.equal(world.comments[0].body, `quoting ${MARKER} here`);
|
||||
assert.deepEqual(world.writes, ['comment on #7']);
|
||||
});
|
||||
|
||||
test('comment: with no alias URL the preview link falls back to the deployment URL', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 } });
|
||||
await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin');
|
||||
assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||
});
|
||||
|
||||
test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => {
|
||||
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
|
||||
await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin');
|
||||
assert.equal(world.comments.length, 1);
|
||||
assert.equal(
|
||||
world.comments[0].body,
|
||||
`${MARKER}\n### 📚 Documentation preview\n\n❌ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Harness ────────────────────────────────────────────────────────────────
|
||||
|
||||
// `fork`: full name of the fork the head lives on; null for a deleted fork; omitted for a same-repo branch.
|
||||
function pushed(number, sender, { fork } = {}) {
|
||||
const repo = fork === undefined ? BASE_REPO : fork === null ? null : { id: 2, full_name: fork };
|
||||
return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', repository: BASE_REPO, pull_request: { number, head: { sha: HEAD, repo } }, sender: { login: sender } } };
|
||||
}
|
||||
function slash(number, commenter) {
|
||||
return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } };
|
||||
}
|
||||
|
||||
async function runAuthorize(world, event) {
|
||||
const outputs = {};
|
||||
const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } };
|
||||
await authorize({ github: world.github, context: { repo: REPO, ...event }, core });
|
||||
return outputs;
|
||||
}
|
||||
|
||||
async function runComment(world, env, actor) {
|
||||
const saved = {};
|
||||
for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; }
|
||||
try {
|
||||
await comment({ github: world.github, context: { repo: REPO, actor }, core: {} });
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
|
||||
|
||||
function makeWorld({ pr, comments = [] }) {
|
||||
const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, permissionLookups: 0, nextCommentId: 100 };
|
||||
for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c });
|
||||
|
||||
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
|
||||
const write = (what) => world.writes.push(what);
|
||||
const checkPr = (n) => { if (n !== world.pr.number) throw err(404); };
|
||||
|
||||
const rest = {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async ({ username }) => {
|
||||
world.permissionLookups++;
|
||||
if (world.failPermissionLookup) throw err(500, 'boom');
|
||||
const person = PEOPLE[username];
|
||||
if (!person) throw err(404, 'not a user');
|
||||
return { data: { ...person, user: { login: username } } };
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async ({ pull_number }) => {
|
||||
checkPr(pull_number);
|
||||
const repo = world.pr.fork ? { id: 2, full_name: world.pr.fork } : BASE_REPO;
|
||||
return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD, repo } } };
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; },
|
||||
createComment: async ({ issue_number, body }) => {
|
||||
checkPr(issue_number);
|
||||
write(`comment on #${issue_number}`);
|
||||
world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
|
||||
},
|
||||
updateComment: async ({ comment_id, body }) => {
|
||||
write(`edit comment ${comment_id}`);
|
||||
const c = world.comments.find((x) => x.id === comment_id);
|
||||
if (!c) throw err(404);
|
||||
c.body = body;
|
||||
},
|
||||
},
|
||||
};
|
||||
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
|
||||
return world;
|
||||
}
|
||||
312
.github/scripts/pr_intake_gate.js
vendored
Normal file
312
.github/scripts/pr_intake_gate.js
vendored
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get
|
||||
// in"); .github/workflows/require-linked-issue.yml wires this up to events.
|
||||
//
|
||||
// A pull request from someone without triage rights stays open only if its
|
||||
// description links (Fixes/Closes/Resolves #N) an open issue in this repo that
|
||||
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
|
||||
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
|
||||
// re-evaluates — and reopens — the PR when the description is edited or the
|
||||
// author is assigned to the issue. A triage+ user reopening the PR, removing
|
||||
// the label, or adding `bypass-issue-check` overrides it, and the override
|
||||
// sticks.
|
||||
//
|
||||
// Everything that writes goes through mutate(); when the workflow passes
|
||||
// ENFORCE=false (its kill switch) the run only logs what it would have done.
|
||||
'use strict';
|
||||
|
||||
const LABEL = 'missing-issue-link'; // marks PRs the gate has closed
|
||||
const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override
|
||||
const OPEN_LABEL = 'help wanted'; // issue label that waives assignment
|
||||
const MARKER = '<!-- require-linked-issue -->';
|
||||
const BOT_LOGIN = 'github-actions[bot]';
|
||||
const MAX_ISSUES = 5;
|
||||
|
||||
module.exports = async function run({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const enforce = process.env.ENFORCE === 'true';
|
||||
const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`;
|
||||
|
||||
// ── Entry points ─────────────────────────────────────────────────────────
|
||||
|
||||
if (context.eventName === 'issues') {
|
||||
// Someone was assigned an issue: re-evaluate their gate-closed PRs that
|
||||
// reference it (they may pass now).
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const assignee = context.payload.assignee.login;
|
||||
const closed = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100,
|
||||
});
|
||||
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
|
||||
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
|
||||
// Evaluate each independently so one transient failure doesn't strand
|
||||
// the rest (this event won't fire again for the same assignment).
|
||||
const failures = [];
|
||||
for (const pr of prs) {
|
||||
try {
|
||||
await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
|
||||
} catch (e) {
|
||||
failures.push(`#${pr.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (failures.length) throw new Error(`Could not re-evaluate ${failures.join('; ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const n = parseInt(process.env.PR_NUMBER_INPUT, 10);
|
||||
if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`);
|
||||
await evaluate(n, 'dispatch', context.payload.sender?.login);
|
||||
return;
|
||||
}
|
||||
|
||||
await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login);
|
||||
|
||||
// ── The rules ────────────────────────────────────────────────────────────
|
||||
|
||||
async function evaluate(prNumber, action, sender, hintIssue = null) {
|
||||
// Always read the PR live; the event payload can be stale by the time a
|
||||
// queued run starts.
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const labels = pr.labels.map((l) => l.name);
|
||||
// An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the
|
||||
// event itself proves the label was there a moment ago.
|
||||
const gated = action === 'unlabeled' || labels.includes(LABEL);
|
||||
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`);
|
||||
|
||||
// 0. Scope: open PRs, plus closed PRs the gate closed itself. Merged PRs
|
||||
// and PRs someone closed for other reasons are left alone.
|
||||
if (pr.merged_at) return log('merged — nothing to do');
|
||||
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
|
||||
|
||||
// 1. Exempt authors: bots and anyone with triage or better. Drafts are
|
||||
// gated like any other PR.
|
||||
if (pr.user.type === 'Bot') return log('author is a bot — exempt');
|
||||
if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
|
||||
|
||||
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
|
||||
// it open. Anyone else doing so just triggers a re-check.
|
||||
if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) {
|
||||
return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true });
|
||||
}
|
||||
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
|
||||
|
||||
// 3. The rule: the description links an open issue in this repo that is
|
||||
// labeled `help wanted` or assigned to the author. Only the first few
|
||||
// references are fetched; a just-assigned issue is checked first.
|
||||
const author = pr.user.login.toLowerCase();
|
||||
const refs = closingRefs(pr.body);
|
||||
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
|
||||
const linked = [];
|
||||
for (const num of refs.slice(0, MAX_ISSUES)) {
|
||||
const issue = await getIssue(num);
|
||||
if (!issue) continue; // missing, a PR, closed, or transferred away
|
||||
linked.push(num);
|
||||
if (issue.labels.some((l) => l.name.toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`);
|
||||
if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`);
|
||||
}
|
||||
return fail(linked);
|
||||
|
||||
// ── Outcomes ─────────────────────────────────────────────────────────
|
||||
|
||||
async function pass(reason, { sticky = false } = {}) {
|
||||
console.log(`PASS: ${reason}`);
|
||||
if (sticky) await addLabel(prNumber, BYPASS_LABEL);
|
||||
if (pr.state === 'closed' && !(await reopen(pr, reason))) return;
|
||||
if (gated) {
|
||||
await removeLabel(prNumber, LABEL);
|
||||
await deleteGateComment(prNumber);
|
||||
}
|
||||
}
|
||||
|
||||
async function fail(linkedIssues) {
|
||||
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
|
||||
await addLabel(prNumber, LABEL);
|
||||
await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues));
|
||||
if (pr.state === 'open') {
|
||||
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comment text ─────────────────────────────────────────────────────────
|
||||
|
||||
function closedComment(draft, linkedIssues) {
|
||||
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
|
||||
const opener = draft
|
||||
? "This PR has been closed automatically. It's still a draft, but we close those early so you don't put in more time only to have it closed the moment you mark it ready.\n\n"
|
||||
: 'This PR has been closed automatically. ';
|
||||
const rule = `${opener}This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue`;
|
||||
const situation = linkedIssues.length
|
||||
? [
|
||||
`${rule}, and you aren't currently assigned to ${issues}.`,
|
||||
'',
|
||||
`If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`,
|
||||
]
|
||||
: [
|
||||
`${rule}, and this PR doesn't link an open issue yet.`,
|
||||
'',
|
||||
"- **If you're already assigned to an issue for this**, add `Fixes #<n>` to the description and the PR will reopen on its own.",
|
||||
`- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`,
|
||||
"- **If there's an issue but you're not assigned**, add `Fixes #<n>` anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens automatically.",
|
||||
];
|
||||
return [
|
||||
MARKER,
|
||||
...situation,
|
||||
'',
|
||||
"You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.",
|
||||
'',
|
||||
`[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`,
|
||||
'',
|
||||
"- We're a small team with very little capacity to review community PRs right now.",
|
||||
'- Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.',
|
||||
'',
|
||||
`*Maintainers: reopen, remove \`${LABEL}\`, or add \`${BYPASS_LABEL}\` to override.*`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function cannotReopenComment(pr, reason) {
|
||||
return [
|
||||
MARKER,
|
||||
`This PR now passes the intake check (${reason}), but GitHub won't let it be reopened — usually because the branch was force-pushed or deleted while the PR was closed, or because another open PR uses the same branch.`,
|
||||
'',
|
||||
`If you have another open PR from this branch, please continue there. Otherwise, either push the branch back to \`${pr.head.sha.slice(0, 7)}\` and edit this PR's description to retry, or open a new PR that links the same issue (if a maintainer had waved this one through, mention that so they can do the same there).`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function mutate(description, fn) {
|
||||
if (!enforce) {
|
||||
console.log(`[dry-run] would ${description}`);
|
||||
return undefined;
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
// Triage-or-better on this repo, from the permission endpoint's capability
|
||||
// flags (role names can be custom; author_association hides private org
|
||||
// members). Only a nonexistent user 404s; any other error must throw rather
|
||||
// than be read as "untrusted", or a maintainer's PR could be closed.
|
||||
async function isTrusted(username) {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
|
||||
const p = data.user?.permissions;
|
||||
if (!p) throw new Error(`permission response for ${username} has no capability flags`);
|
||||
const trusted = Boolean(p.triage || p.push || p.maintain || p.admin);
|
||||
console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`);
|
||||
return trusted;
|
||||
} catch (e) {
|
||||
if (e.status === 404) return false;
|
||||
throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Issue numbers referenced with a closing keyword, in the forms GitHub itself
|
||||
// honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`.
|
||||
function closingRefs(body) {
|
||||
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(
|
||||
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
|
||||
'gi',
|
||||
);
|
||||
return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))];
|
||||
}
|
||||
|
||||
// The linked issue, or null if it doesn't exist, is actually a PR, isn't
|
||||
// open, or has been transferred to another repository.
|
||||
async function getIssue(num) {
|
||||
let issue;
|
||||
try {
|
||||
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num }));
|
||||
} catch (e) {
|
||||
if (e.status === 404 || e.status === 410) return null;
|
||||
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`);
|
||||
}
|
||||
if (issue.pull_request || issue.state !== 'open') return null;
|
||||
if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null;
|
||||
return issue;
|
||||
}
|
||||
|
||||
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
|
||||
// or deleted while closed, or another open PR uses it. Explain that in the
|
||||
// comment and make sure the control label is (still) on, so the PR stays
|
||||
// gate-managed and a later edit or override retries the reopen.
|
||||
async function reopen(pr, reason) {
|
||||
try {
|
||||
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 422) throw e;
|
||||
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
|
||||
await addLabel(pr.number, LABEL);
|
||||
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabel(prNumber, name) {
|
||||
await mutate(`add "${name}" to PR #${prNumber}`, async () => {
|
||||
await ensureLabelExists(name);
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] });
|
||||
});
|
||||
}
|
||||
|
||||
async function removeLabel(prNumber, name) {
|
||||
await mutate(`remove "${name}" from PR #${prNumber}`, async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureLabelExists(name) {
|
||||
const meta = {
|
||||
[LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'],
|
||||
[BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'],
|
||||
}[name];
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] });
|
||||
} catch (createErr) {
|
||||
if (createErr.status !== 422) throw createErr; // created concurrently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate keeps at most one comment per PR: authored by the Actions bot and
|
||||
// carrying MARKER. It's created or updated on failure and deleted on pass.
|
||||
async function findGateComment(prNumber) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
|
||||
return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
|
||||
}
|
||||
|
||||
async function upsertGateComment(prNumber, body) {
|
||||
const existing = await findGateComment(prNumber);
|
||||
if (!existing) {
|
||||
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }));
|
||||
} else if (existing.body !== body) {
|
||||
await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGateComment(prNumber) {
|
||||
const existing = await findGateComment(prNumber);
|
||||
if (!existing) return;
|
||||
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
|
||||
try {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e; // already deleted by a concurrent run
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue