rmcp-server-kit 3.10.1

Reusable MCP server framework with auth, RBAC, and Streamable HTTP transport (built on the rmcp SDK)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
# Changelog

All notable changes to `rmcp-server-kit` are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
**API-breaking** changes bump the **major** version, which tracks the `rmcp` SDK
major. Security-hardening changes that alter a runtime *default* (without
removing or retyping public API) ship in a **minor** release with a documented
migration note and a config opt-out - see the 3.1.0 notes below.

## [Unreleased]

## [3.10.1] - 2026-09-08

### Changed

- **Dependency refresh: 5 semver-compatible lockfile updates**, notably `rustls`
  0.23.43 -> 0.23.44 and `ipnet` 2.12.1 -> 2.12.2, plus `async-compression`,
  `compression-codecs` and `crossbeam-utils`. No `Cargo.toml` requirement
  changed, and `cargo semver-checks` reports no API change.

  **No security urgency.** No rustls advisory affects 0.23.43. rustls 0.23.44
  does raise its `rustls-webpki` floor to 0.103.14, which carries the fix for a
  high-severity CRL-parsing denial of service (RUSTSEC-2026-0104) whose
  advisory names mTLS servers using CRLs -- directly this crate's territory --
  but the committed lockfile already resolved `rustls-webpki` 0.103.15, so that
  fix was already in place and `cargo audit` was clean before and after.

  Everything else in rustls 0.23.44 is inapplicable or benign here: ML-DSA
  support lands in the `aws-lc-rs` provider (this crate uses `ring`), the ECH
  certificate-name fix is client-side, and `SSLKEYLOGFILE` files are now created
  owner-only on Unix. One change did warrant testing rather than assumption --
  TLS 1.2 client-certificate requests now filter the verifier's advertised
  signature schemes -- and the mTLS end-to-end suites pass unchanged.

## [3.10.0] - 2026-09-05

### Documentation

- **Documented that MRTR `requestState` is not bound by this crate, and what to
  do instead.** SEP-2322 multi-round-trip requests hand the client a third
  long-lived echoed value alongside `Mcp-Session-Id` and `taskId`. Those two
  have crate-level identity-binding support; `requestState` deliberately has
  none, because sealing it correctly depends on business context only the
  consumer has. A consumer could reasonably have assumed otherwise, so
  `docs/GUIDE.md` now states the non-coverage plainly and gives concrete
  identity-binding guidance for `RequestStateCodec` -- which principal accessor
  to bind, that the type needs rmcp's non-default `request-state` feature, how
  to scope it to the originating request, TTL and single-use handling, and the
  reminder that the sealed payload is authenticated rather than encrypted.

  No behaviour change.

### Added

- **Identity-bound MCP task IDs (`task_binding`, opt-in, default `false`).**
  MCP tasks (SEP-2663) hand the client a long-lived `taskId` that is later
  presented to `tasks/get`, `tasks/update`, and `tasks/cancel`. Upstream `rmcp`
  resolves those calls **by task ID alone** -- `TaskManager` takes no principal
  argument -- and this crate's RBAC layer inspects only `tools/call`. Any
  *authenticated* identity holding another identity's task ID could therefore
  read, update, or cancel that task: the same
  leaked-identifier-as-bearer-capability problem session binding already solves
  for `Mcp-Session-Id`.

  Enable via `McpServerConfig::with_task_binding(true)` or TOML
  `server.task_binding = true`. The `taskId` returned to the client becomes a
  signed wrapper bound to the authenticated identity, verified and rewritten
  back to the raw ID before the consumer's handler runs -- **handlers continue
  to see raw task IDs and need no changes.** A wrapper that fails verification
  (malformed, unwrapped, signed for another identity, or signed under a rotated
  secret) is rejected with exactly the error `rmcp` returns for a genuinely
  unknown task, so the check cannot be used to probe whether another identity's
  task exists.

  Reuses `server.session_binding_secret`, domain-separated so a session token
  can never verify as a task token or vice versa; rotating it now also
  invalidates outstanding external task IDs. Multi-replica deployments must
  share the secret. With authentication disabled the feature degrades to a
  no-op rather than failing requests.

  **Off by default** because it changes the wire format of `taskId` values. The
  only consumer affected is one that persists the *client-visible* ID as its own
  key; the ownership contract is that handlers own raw task IDs and
  `rmcp-server-kit` owns the external wrapped ID.

  This compatibility decision is not a staged promise to flip the default later.
  Future releases may revisit the default only with a fresh compatibility review
  and migration note; consumers that need cross-identity task isolation should
  enable `task_binding` explicitly.

  Affects only consumers that implement tasks -- the `ServerHandler` task
  methods otherwise default to method-not-found.

### Fixed

- **Corrected an inaccurate `ToolHooks.after` documentation contract.** The
  field previously documented the after-hook as "invoked once per call,
  regardless of how the call resolved". That holds only on the normal
  Deny / Replace / Ok / Err resolution paths. If the `call_tool` future is
  dropped after a before-hook has run but before the call resolves, the
  paired after-hook is never spawned -- a caveat the implementation already
  recorded internally but the public docs contradicted. Consumers who built
  audit trails or resource-release guards on before/after *pairing* could
  therefore see silently unpaired records under cancellation. The field docs
  now state the cancellation limitation and direct such consumers to make the
  after-hook idempotent or tolerant of missing closes.

  **Documentation only -- no behaviour change.** The runtime has always
  behaved this way; only the description was wrong.

### Changed

- **Updated `rmcp` and `rmcp-macros` 3.1.4 -> 3.2.0, plus 20 other
  semver-compatible lockfile bumps** (`tower-http` 0.7.1, `tokio-rustls`
  0.26.5, `toml` 1.1.5, `sse-stream` 0.2.6, `mio`, `indexmap`, `syn`, `cc`,
  `smallvec`, the `wasm-bindgen` family, and others). No `Cargo.toml` version
  requirement changed -- this is a lockfile-only move.

  **Behaviour note for consumers.** rmcp 3.2.0 routes *every* `initialize`
  request through the legacy/session lifecycle regardless of the protocol
  version in the request body, and `negotiate_protocol_version` now returns a
  `Result` rather than passing an unsupported version through. A client that
  previously negotiated `2026-07-28` via `initialize` may therefore now
  negotiate a legacy version instead, or receive `unsupported_protocol_version`
  if its handler advertises no legacy version through
  `ServerHandler::supported_protocol_versions()`. Servers that accept the
  default version set are unaffected.

  Identity-bound MCP sessions are unaffected by that transition: a post-legacy
  `initialize` now mints a session, and that session is wrapped and bound to
  the initiating identity exactly as a legacy one is. This is locked by the new
  `initialize_2026_protocol_is_session_bound_to_identity` end-to-end test,
  which was verified to fail against rmcp 3.1.4 and pass against 3.2.0 -- the
  pre-existing suite could not have caught the change, because every other
  end-to-end `initialize` uses a pre-`2026-07-28` (legacy) version.

  `rmcp` 3.1.4 -> 3.2.0 and `rmcp-macros` 3.1.4 -> 3.2.0 were **hand-audited**
  as `safe-to-deploy` delta audits rather than exempted; the remaining 20
  bumps refreshed their `cargo vet` exemptions. `cargo audit` and
  `cargo deny check` are clean.

- **`resolve_allowed_algorithms` now accepts `Option<&[String]>` instead of
  `Option<&Vec<String>>`** (`RUST_GUIDELINES.md` §1, "DO: Accept borrowed types
  in function arguments"). Internal (`pub(crate)`) with no semver impact. The
  `None` / `Some(empty)` / `Some(non-empty)` trichotomy is unchanged: omitting
  the field still yields the default accepted set, and an explicitly empty list
  is still a hard configuration error.

- **Documented cancel safety for the `tool_hooks`, `rbac_context`, and `admin`
  modules** (`RUST_GUIDELINES.md` §5, "DO: Annotate every async fn with cancel
  safety"). These modules' async functions are `ServerHandler` delegations and
  Axum handlers that hold no lock, permit, guard, or task-local across an
  `.await`, so they are cancel-safe with respect to their own state and inherit
  the wrapped handler's contract; `HookedHandler::call_tool` remains the
  documented **NOT cancel-safe** exception. `rbac_context` had no module
  documentation at all and now does.

- **Withdrawn: the promise that `ArgumentAllowlist.required` would default to
  `true` in 4.0.** It will not. `required` defaults to `false` permanently, and
  presence enforcement stays opt-in via `required = true` /
  `ArgumentAllowlist::new_required`. Flipping the default would silently convert
  previously-allowed traffic into `403`s with no compile error and nothing
  `cargo semver-checks` could detect, and an allowlist that constrains a supplied
  value is a legitimate configuration rather than a defect. The crate therefore
  provides the control and leaves the policy to the operator.

  This reverses forward-looking statements in earlier releases (3.8.0 and 3.9.1
  notes, and `docs/MIGRATION.md`). Those release entries are left as written —
  they were accurate at the time and rewriting shipped notes would falsify the
  record — but `docs/MIGRATION.md`, the `ArgumentAllowlist` rustdoc, the README
  and the guide have all been corrected, since they are live guidance rather
  than history.

  **No behaviour change.** Anything relying on the current default is
  unaffected; the startup warning that names fail-open allowlists is now a
  permanent control rather than an interim one, and is not going away.

- **`ArgumentAllowlist` documentation now states where argument validation
  actually belongs.** It is in the tool -- its input schema and handler -- not
  in this crate, which sees an untyped JSON object at an authorization boundary
  and cannot know a tool's parameter types, value ranges, or which option
  combinations are meaningful. An allowlist is a coarse role-scoped gate layered
  on top of that validation, never a replacement for it. The rustdoc and guide
  now say so explicitly and record the limits that follow: only the first
  `shlex::split` word is checked (so an allowlist of `["ls"]` accepts
  `"ls -la; id"`), object- and array-valued arguments are denied rather than
  inspected wherever an allowlist applies, and basename matching is POSIX-only.
  Documentation only; no behaviour change.

## [3.9.1] - 2026-09-04

### Changed

- **The optional-argument-allowlist startup warning now names the remedy.** It
  previously said only "optional argument allowlist may fail open", which
  identified the role, tool and argument but gave an operator nothing to act on.
  It now explains the failure mode — the allowed-value list is enforced only
  when the caller supplies the argument, so a tool substituting its own default
  bypasses it — and names the fix: `required = true` in TOML, or
  `ArgumentAllowlist::new_required` in code. Warning text only; no behaviour
  change. This remains the only mitigation available in 3.x, because flipping
  the `required` default is a breaking change reserved for 4.0.

### Documentation

- **The README Cargo-features table listed only two of the four declared
  features.** `oauth-mtls-client` and `test-helpers` were both absent, so a
  reader arriving from crates.io had no signal that `test-helpers` exists — let
  alone that it must never be enabled in a production build, since some of its
  helpers deliberately bypass SSRF screening, the JWKS refresh cooldown, the CDP
  discovery rate limiter, and CRL verifier publication. That hazard was already
  documented in `src/lib.rs`, `Cargo.toml` and `docs/GUIDE.md`, but not on the
  landing page most consumers read first.
- **Operator guidance for `key_eviction_policy`.** The guide now explains the
  trade-off rather than only listing the accepted values: `evict_lru` (the
  default) favours admitting new clients at the cost of letting an evicted quiet
  tenant return with a fresh quota, while `reject_new` favours isolating
  established tenants at the cost of turning away genuinely new ones. It records
  why `evict_lru` is the default — under a high-cardinality spray attack
  `reject_new` would let an attacker fill the table and deny every new
  legitimate client — and states that a `reject_new` rejection returns **503,
  not 429**, because it signals server-side admission capacity rather than a
  per-client quota breach.

## [3.9.0] - 2026-09-04

### Security

- **Closed a cross-principal session-binding collision (CWE-384) caused by a
  blank `AuthIdentity` stable id.** The session-binding fingerprint keys on a
  per-identity stable id (`method || 0x00 || stable_id`,
  `src/session_binding.rs`). When that stable id was blank (empty or
  whitespace-only), two distinct principals produced byte-identical
  fingerprints, so one caller's identity-bound session became usable by another
  — reopening the CWE-384 hole previously closed for session binding.

  The five first-party producers that could emit a blank stable id are now
  closed — mTLS extraction, configured API keys, hot-reloaded API keys,
  `verify_bearer_token`, and OAuth claims:

  - **mTLS** (`extract_mtls_identity`): a present-but-blank Subject CN no longer
    yields a blank identity, and no longer shadows a usable DNS SAN. Selection
    now takes the first non-blank CN, else the first non-blank DNS SAN, then
    applies the existing character guard; a certificate with no non-blank CN or
    SAN is rejected. Live E2E coverage confirms rustls/webpki accepts an
    in-test-CA-signed mTLS client certificate whose Subject CN is explicitly
    empty, so the blank-identity path was reachable through a real handshake,
    not only by direct DER parsing. Post-fix, empty-CN-plus-DNS-SAN authenticates
    under the SAN name; empty-CN-without-SAN completes the TLS handshake but is
    rejected as unauthenticated before MCP handling.
  - **API-key configuration**: a blank API-key `name` is now rejected during
    config validation — via both `validate_server_config` and
    `McpServerConfig::validate()` — naming the offending key index. The check is
    exposed as `AuthConfig::validate_api_key_names`, so it can also be called
    directly when a configuration is assembled programmatically.
  - **API-key hot reload**: added `ReloadHandle::try_reload_auth_keys` (fallible)
    which validates before swapping; the existing `reload_auth_keys` now rejects
    a blank-named batch, logging the error and leaving the previously installed
    keys untouched instead of silently installing colliding keys.
  - **Bearer verification** (`verify_bearer_token`): the public verifier now
    returns `None` (no identity) when the matched API key has a blank name. The
    check runs after the constant-time match loop resolves, so the "one Argon2
    verification per configured key" timing guarantee is unchanged.
  - **OAuth claims**: blank `preferred_username` / `sub` / `azp` / `client_id`
    are treated as absent so the identity-name fallback chain no longer
    short-circuits into a blank name; a blank `sub` is stored as `None` (so the
    OAuth fingerprint never keys on a blank stable id) and is rejected when
    `require_subject` is set. **Correction:** an earlier draft of this change
    stated OAuth was unaffected. That was wrong — OAuth carried the same
    collision and is fixed here.

  `fingerprint()` additionally carries a `debug_assert!` that the selected
  stable id is non-blank — an in-crate breadcrumb that the first-party
  producers above are validated upstream, not a runtime guard.

  **Compatibility:** API-additive — the new `ReloadHandle::try_reload_auth_keys`
  and `AuthConfig::validate_api_key_names` — with a **runtime behavioural
  change**: `reload_auth_keys` now refuses a blank-named batch and retains the
  previously installed keys rather than swapping them in. `cargo semver-checks`
  reports the API surface as additive, but this behaviour change is why the fix
  ships as a **minor**, not a patch.

### Fixed

- **`current_identity()` and `current_role()` now treat an empty value as
  absent**, matching `current_token()` and `current_sub()`, which already did.
  Previously two of the four task-local accessors returned `Some("")` where the
  other two returned `None`.

  The asymmetry defeated a reasonable guard. `current_sub().or_else(current_identity)`
  is a natural way to resolve a stable per-user key: `current_sub()` correctly
  yielded `None`, evaluation fell through to `current_identity()`, and the
  `Some("")` it returned satisfied the caller's `ok_or_else`. A downstream
  consumer was using that value as a per-user OAuth token-store key, so
  identity-less callers collapsed into one shared bucket. The call site looked
  correctly guarded.

  Reachable through an API key whose `name` is empty paired with a non-empty
  role: `ApiKeyEntry::new` does not validate `name`, and the RBAC middleware
  gates task-local installation only on the role being non-empty.

  **This is a behaviour change.** Any caller receiving `Some("")` today will now
  receive `None`. In this crate the change can only narrow: no internal path
  treats `None` as more permissive. Downstream code should audit `unwrap_or`,
  `unwrap_or_else` and `unwrap_or_default` fallbacks on these two accessors,
  where substituting a default for `None` could widen behaviour.

  **Scope.** This normalises the accessors only. A configured `AuthIdentity`
  may still carry an empty `name`, which remains significant for session-binding
  fingerprints, admin summaries and audit logs.

  Reported by a downstream consumer running per-user OAuth across ~145
  product-scoped tools.

### Added

- Added opt-in `McpServerConfig::with_event_store` for supplying an external
  rmcp `EventStore`. Omitting it leaves behaviour unchanged: `Last-Event-ID`
  resume already worked best-effort in-process for live legacy sessions whose
  events remained in rmcp's bounded channel cache. A configured store adds
  durable, cross-instance, and stateless replay. Implementations own globally
  unique event IDs and stream isolation, because rmcp calls
  `replay_events_after` with only the last event ID.

## [3.8.3] - 2026-09-03

### Added

- Added `McpServerConfig::with_session_store` for supplying an external rmcp
  `SessionStore`, enabling Streamable HTTP session recovery across replicas.
- Added `McpServerConfig::with_session_binding_secret`, TOML
  `server.session_binding_secret`, and the
  `RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET(_FILE)` environment override
  pair for sharing the session-binding HMAC key across replicas.

### Changed

- Config validation now rejects authenticated deployments that combine an
  external `session_store` with enabled session binding but omit a shared
  `session_binding_secret`, preventing cross-instance session verification
  failures at runtime.

### Security

- **`tools/list` responses are filtered by RBAC by default.** When RBAC is
  enabled and the request has an authenticated role, the transport hides tools
  that `RbacPolicy::check_operation` would deny and forces the list response's
  cache scope to `Private` to avoid cross-role cache leaks. Invocation remains
  independently enforced on `tools/call`. Set
  `McpServerConfig::with_tool_list_filtering(false)` or TOML
  `server.tool_list_filtering = false` to deliberately advertise a superset.

- **MCP sessions are now bound to the authenticated identity by default.**
  The transport wraps newly minted `Mcp-Session-Id` values in a stateless
  HMAC-SHA256 token tied to the stable API-key, mTLS, or OAuth identity
  fingerprint. A leaked session ID from user A therefore cannot be reused by
  user B, even when B presents valid credentials and has the same RBAC role.

  A new `McpServerConfig::with_session_binding(bool)` builder and TOML
  `server.session_binding` field control the behaviour. The default is
  `true`; setting it to `false` is an escape hatch for trusted gateways that
  deliberately re-authenticate each request under different labels, and it
  reinstates the CWE-384 session-replay risk.

### Fixed

- **`HookedHandler` now transparently delegates every `ServerHandler` method.**
  Handlers wrapped with `with_hooks` no longer fall through to rmcp trait
  defaults for `ping`, completion, logging level, legacy subscription, custom
  request, cancellation/progress/initialized/root-change, or custom
  notification methods. Hook behaviour remains exclusive to `tools/call`.

- **RBAC task-local context now reaches real `ServerHandler` methods.** The
  Streamable HTTP transport executes handler calls in rmcp-owned spawned tasks,
  so the middleware-only task-local scope was lost before consumer code ran.
  `current_role()`, `current_identity()`, `current_sub()`, and the
  `ToolCallContext` role / identity / sub fields therefore always observed
  `None` in real HTTP tool handlers, and `current_token()` token passthrough was
  non-functional for OAuth callers. The transport now internally re-enters the
  RBAC scope from the authenticated HTTP request identity before delegating to
  every `ServerHandler` method.

### Documentation

- **Documented the single-role authorization contract.** rmcp-server-kit evaluates exactly one
  role string per identity and never unions several matched roles; for OAuth that role is the
  **first matching `role_mappings` / `scopes` entry in configuration order**, so mapping order is
  significant and most-specific entries belong first. This was already the behaviour -- it was
  simply never written down, which is how a downstream consumer came to discover it by reading
  source. Now covered in the `current_role()` rustdoc and in `docs/GUIDE.md` (new
  "One role per identity" section, plus the OAuth `role_claim` notes), together with the two
  supported ways to grant a caller the combined capability of several claims: define one RBAC role
  that grants the union, or normalise the claim at the IdP.

  The docs also record that `current_role()` is **not** an authorization decision -- tool
  authorization is enforced in middleware before the task-local is installed, and admin gating
  reads the request's `AuthIdentity` directly.

  **No behaviour change.** Documentation only; no code, API, or configuration was modified.

## [3.8.2] - 2026-09-02 [NOT PUBLISHED]

> Version 3.8.2 was prepared but never tagged or published to crates.io. Its
> changes ship to users as part of 3.8.3; the section is retained for history.

### Security

- **RBAC operation `deny` entries are now glob-matched.** Previously
  `RoleConfig.deny` was compared by exact string equality, so a pattern such as
  `deny = ["jira_delete_*"]` matched **nothing**. Combined with the idiomatic
  `allow = ["*"]`, this failed **open**: a policy that looked locked down
  permitted every delete tool. Deny entries now use the same `glob_match`
  matcher already applied to `hosts` and `argument_allowlists.tool`.

  **Migration.** `glob_match` treats `*` as its only metacharacter, so an entry
  containing no `*` still reduces to exact equality - every glob-free `deny`
  list behaves exactly as before. Audit any existing `deny` entry that *does*
  contain a `*`: it was previously inert and will now start denying. That is
  almost always the behaviour the operator originally intended, but it can
  block tool calls that succeed today.

  Matching remains case-sensitive (host matching is case-insensitive only
  because `host_matches` lowercases both sides before calling `glob_match`).

### Added

- **`RbacConfig.global_deny`** - a server-wide operation kill switch evaluated
  before any role is consulted. Entries are always glob-matched and veto even a
  role's `allow = ["*"]`. This list can only ever remove capability, so it is
  safe to enable in any deployment. Defaults to empty (no behaviour change).
  Two scope limits: it is gated on `rbac.enabled`, and like the rest of the
  engine it governs *invocation* (`tools/call`) only -- a denied tool may still
  appear in a `tools/list` response unless the handler filters it.
- **`RbacConfig.allow_operation_matching`** (`AllowOperationMatching::Legacy` |
  `Glob`, TOML `"legacy"` | `"glob"`, default `Legacy`) - opt into glob matching
  for `RoleConfig.allow`. This is **not** enabled by default: widening an
  `allow` grants access, so a previously inert entry like `allow = ["jira_*"]`
  must not silently start granting it. Widening a `deny` can only subtract
  capability, which is why `deny` globs unconditionally.
- **Config-load warning** for `allow` entries that contain a `*` (other than the
  literal allow-all `"*"`) while `allow_operation_matching` is `legacy`. Under
  legacy matching the `*` is an ordinary character, so such an entry grants only
  an operation whose name contains that literal `*` -- almost never what the
  operator meant. A warning is also emitted when `global_deny` is non-empty
  while `rbac.enabled = false`, since every check short-circuits to allow.
- **`RoleConfig::with_deny`**, **`RbacConfig::with_global_deny`**,
  **`RbacConfig::with_allow_operation_matching`** builder methods.
- **`RbacPolicySummary.global_deny`** - count of configured `global_deny`
  patterns, surfaced via the admin diagnostics endpoint.

Reported downstream by a consumer with ~130 product-scoped tools that had been
hand-enumerating every destructive tool name to work around the exact-match
behaviour.

## [3.8.1] - 2026-08-30

### Changed

- **`argon2` upgraded from 0.5 to 0.6.** **No action is required and no
  re-hashing is needed: every existing stored API-key hash remains valid.**
  Compatibility is pinned by a regression test carrying a PHC string produced
  by argon2 0.5.3 through this crate's own key-generation path; it must keep
  verifying, so a future upgrade cannot silently invalidate deployed
  credentials.

  Default cost parameters are unchanged (`Argon2id`, `v=19`, `m=19456`, `t=2`,
  `p=1`), so newly issued keys have the same security posture as before.

  Internally this pulls `password-hash` 0.6.1 (0.6.0 was yanked upstream) and
  the new `phc` crate. `SaltString` moved out of `password-hash`, and
  `hash_password` no longer takes an explicit salt - the crate now supplies its
  own salt bytes directly via `hash_password_with_salt`, which removed a
  base64-encoding step and one `expect()` from the constant-time placeholder
  hash. `getrandom` and `rand_core` version counts are unchanged.

## [3.8.0] - 2026-08-29

Hardening pass from a full review against `RUST_GUIDELINES.md`, plus the fixes
for [#17](https://github.com/andrico21/rmcp-server-kit/issues/17) (Microsoft
Entra compatibility) and RFC 8693 token-exchange conformance.

> **Read first if you use the `oauth` feature.** OAuth *discovery metadata* is
> now RFC 9728 / RFC 8414 conformant, which changes two published values.
> **Token validation is unchanged.** Both changes have a legacy escape hatch,
> and exactly one topology needs action: an application that mounts its own
> `/authorize` + `/token` via `with_extra_router` *without* `oauth.proxy` must
> set `oauth.authorization_servers = ["<its public URL>"]`. See **Changed**.

> **Read first if you use `oauth.token_exchange`.** This release contains one
> **source-breaking API change** (`TokenExchangeConfig`), described under
> **Breaking** below. **Existing TOML configuration files continue to work
> unchanged, and the request sent on the wire is byte-identical.**

### Breaking

- **`TokenExchangeConfig::new` now takes `impl Into<String>` for `token_url`
  and `client_id`**, matching its own `with_*` setters.

  Passing a `String` variable still compiles. **Passing `"literal".into()` does
  not** - the target type is now generic, so inference fails with `E0283`. Drop
  the `.into()`:

  ```rust
  // before
  TokenExchangeConfig::new("https://idp/token".into(), "client".into(), None, None)
  // after
  TokenExchangeConfig::new("https://idp/token", "client", None, None)
  ```

  `cargo-semver-checks` does **not** report this change, so it is documented
  here rather than caught by tooling.

- **`TokenExchangeConfig::audience` is now `Option<String>`, and
  `TokenExchangeConfig::new` no longer takes an `audience` argument.**

  RFC 8693 §2.1 marks `audience` **OPTIONAL** (only `grant_type`,
  `subject_token`, and `subject_token_type` are REQUIRED), but the crate made it
  mandatory and always emitted it. Omission was unrepresentable, so an
  authorization server that rejects `audience` - or that expects RFC 8707
  `resource` instead - could not be configured at all.

  Migration is mechanical:

  ```rust
  // before
  TokenExchangeConfig::new(token_url, client_id, secret, cert, "downstream".into())
  // after
  TokenExchangeConfig::new(token_url, client_id, secret, cert)
      .with_audience("downstream")
  ```

  **TOML configs need no change**; `audience` remains an accepted key and
  behaves identically. Setting `audience = ""` is now rejected at startup as a
  malformed parameter - omit the key to leave the parameter out.

  This break deliberately ships in a **minor** release rather than 4.0.0, as a
  documented exception to the versioning policy stated at the top of this file.
  The `semver-checks` CI job carries `--release-type major` for this release
  only; the flag is removed once 3.8.0 is published and the break is part of the
  crates.io baseline.

### Added

- `oauth.token_exchange` now supports the remaining RFC 8693 §2.1 OPTIONAL
  request parameters, each omitted by default:
  - `resource` - an RFC 8707 resource indicator, validated at startup as an
    absolute URI with no fragment, using only RFC 3986 characters and
    well-formed percent-encoding. (The `url` crate implements the WHATWG URL
    Standard, which would otherwise silently trim or re-encode a value that is
    then sent to the authorization server verbatim.) **Unrelated to
    `oauth.proxy.strip_resource_param`**, which governs the OAuth proxy
    endpoints rather than token exchange.
  - `scope` - space-delimited scopes for the exchanged token.
  - `requested_token_type` - `"access_token"` (default, unchanged behaviour),
    `"omit"` to let the authorization server choose per RFC 8693 §2.1, or any
    token-type URI sent verbatim.

  Builder methods `with_audience`, `with_resource`, `with_scope`, and
  `with_requested_token_type` set them, matching the existing `McpServerConfig`
  constructor-plus-setters convention.

- **`observability.log_upstream_error_bodies`** (default `false`, env
  `RMCP_SERVER_KIT__OBSERVABILITY__LOG_UPSTREAM_ERROR_BODIES`). Opt-in switch
  that surfaces the authorization server's `error_description` on a failed
  token exchange. Off by default because the value is free-form upstream text
  that may echo request parameters back.

- **`rbac.roles.argument_allowlists.deny_unknown_arguments`** (default `false`).
  Confines a tool to only the arguments its allowlists name. By default an
  allowlist constrains only the argument it names, so with just `cmd`
  allowlisted a call carrying `{"cmd": "ls", "danger": true}` is admitted and
  `danger` reaches the handler unreviewed. Setting the flag on **any** allowlist
  matching a `(role, tool)` pair makes the permitted names the union of every
  matching entry's `argument`, rejecting any other top-level key - and rejecting
  object/array values, which have no nested-path allowlist to constrain them.
  Default-off preserves existing behaviour exactly.

### Fixed

- **The two config validators can no longer drift in ordering.** The three
  invariants both enforce - admin-requires-auth, TLS cert/key pairing, and
  mTLS-requires-TLS - are now evaluated by one shared helper, so a config
  invalid in more than one way reports the same first error whichever validator
  a consumer reaches for. Each validator keeps its own wording: the builder
  names which TLS half is missing, the TOML validator emits one combined
  message. Nothing else was folded in; the remaining checks are specific to one
  type or the other.
- **A scheduler-dependent CRL test is now deterministic.**
  `discovery_does_not_send_before_pending_marker_exists` spun a contending
  thread across 200 attempts and could pass without ever producing the
  interleaving it claimed to test. It now asserts the marker-before-publish
  ordering directly, at the one instant it can be violated, via a test-only
  probe behind the existing `test-helpers` feature.
- **Every config field is now provably classified for env overrides.** A new
  test fails unless each `ServerConfig` / `ObservabilityConfig` field is either
  present in `ENV_OVERRIDE_SPECS` or listed in `ENV_OVERRIDE_EXCLUDED_FIELDS`,
  so a newly added field can no longer default silently to "no override".
- **The hand-written `Debug` impls cannot silently drop a field.** Companion
  tests parse the struct definitions and fail if a field is added without being
  rendered (redacted or otherwise).
- **CI: the `--release-type major` fallback now removes itself.** The semver
  step runs unflagged first and treats *success* as a failure condition, so once
  3.8.0 is the published baseline the job fails loudly demanding the flag be
  dropped - instead of lingering and silently accepting every future breaking
  change.

### Deprecated

- `CrlSet::cache` is deprecated. Reading it remains safe; **mutating** it
  bypasses the atomic commit path and is now detected and denied. The field
  becomes private in 4.0.
- `CrlSet::__test_with_prepopulated_crls` and `CrlSet::__test_with_kept_receiver`
  are deprecated. Both are test-only constructors that are ungated in 3.x by
  accident; they become feature-gated behind `test-helpers` in 4.0.

  Note for downstream: these deprecations are **not** semver-breaking, but a
  crate building with `-D warnings` will fail until it adds
  `#[allow(deprecated)]` at the call sites. See
  [`docs/MIGRATION.md`]docs/MIGRATION.md#6-crlsetcache-and-the-ungated-test-constructors-are-deprecated.

### Security

- **The audit log is now protected on both Unix and Windows.**

  On **Unix**, owner-only permissions are applied **at file creation**
  (`OpenOptionsExt::mode(0o600)`). Previously the file was opened with
  `create(true)` and only then `chmod`'d to `0o600`, leaving a window in which
  it existed with umask-derived permissions and any local principal could open
  it. Pre-existing files are still corrected afterwards, because `mode` applies
  only at creation.

  On **Windows**, the file previously received no permission hardening at all
  and no warning was reported, so an audit log under a location such as
  `C:\ProgramData` inherited broad read ACLs while the operator believed it was
  protected. The file is now created and a protected owner-only DACL applied
  immediately afterwards.

  **These are not equivalent.** Rust std cannot pass `SECURITY_ATTRIBUTES` to
  file creation (rust-lang/libs-team#324), so Windows has no safe creation-time
  equivalent of `mode(0o600)`. The Windows path therefore leaves a small
  create-then-harden race that Unix does not have: it removes the *persistent*
  exposure, not the momentary one.

  If Windows ACL hardening fails, startup fails and the crate attempts to delete
  the unprotected file; the error states whether that deletion succeeded, so an
  operator knows whether an unprotected audit log may remain on disk. Hardening
  uses the `windows-permissions` crate, target-gated to `cfg(windows)` and used
  only to resolve the current process SID and apply the DACL - this crate is
  `#![forbid(unsafe_code)]` and so cannot call the Win32 ACL APIs directly.

- **Attacker-controlled intermediate CDP URLs no longer bypass the
  per-handshake cap.** `MAX_RELEVANT_CDP_URLS_PER_HANDSHAKE` is evaluated
  against the *relevant* URL set, but discovery candidates were collected from
  the *full* set. With `crl_end_entity_only = true` a peer could therefore drive
  CRL discovery, pending-set growth, and rate-limiter consumption from an
  uncapped number of intermediate CDPs. Discovery now reads the same set the cap
  governs.
- **Upstream OAuth `error_description` is redacted by default.** The value is
  free-form text chosen by the authorization server and may echo request
  parameters back. It is now logged only when
  `observability.log_upstream_error_bodies` is enabled. The `error` code itself
  is an enumerated RFC 6749 §5.2 / RFC 8693 value and is still logged
  unconditionally.
- **Discovery-metadata URLs are validated at startup.**
  `oauth.authorization_servers[]` and
  `oauth.authorization_server_metadata_issuer` are published verbatim by the
  unauthenticated `/.well-known/oauth-*` endpoints but were never checked. They
  are now held to the same policy as every other OAuth URL: parseable, no
  userinfo, scheme honouring `allow_http_oauth_urls`, and no literal-IP target.
  `Some(vec![])` remains valid and still omits the claim.
- **A custom `requested_token_type` must now be a URI.** A typo such as
  `"acess_token"` previously became a custom token type and was forwarded to the
  authorization server verbatim; it is now rejected at config validation.
  Fragments are permitted here - the no-fragment rule is RFC 8707 §2's
  constraint on `resource`, not a property of RFC 8693 §3 token-type
  identifiers.
- **`bootstrap_fetch` now bounds its startup fan-out.** It is a public helper
  taking a raw `MtlsConfig`, so it is reachable without
  `McpServerConfig::validate`; a broad CA bundle previously spawned one fetch
  task and one cache entry per distinct CDP URL, unbounded by
  `crl_max_cache_entries`. The URL set is now truncated to that cap, with a
  warning when truncation occurs.
- **Forwarding headers are no longer written to request logs.** `forwarded`,
  `x-forwarded-for`, and `x-real-ip` are attacker-controlled on any untrusted
  hop, so logging them verbatim let a caller plant misleading provenance in an
  incident-response trail. They join `authorization`, `cookie`, and
  `proxy-authorization` in the redaction set; the resolved client IP is still
  reported separately.
- **`tls_key_path` and `audit_log_path` are redacted from `Debug` output.**
  Both structs previously derived `Debug`, so a single `tracing::debug!(?config)`
  or panic message disclosed the private-key and audit-trail locations.
  Presence is still reported (`Some("[REDACTED]")`) so diagnostics remain
  useful.
- **SSRF: the whole `0.0.0.0/8` "this network" prefix is now blocked, not just
  `0.0.0.0`.** `Ipv4Addr::is_unspecified()` matches only the single unspecified
  address, so literals such as `0.1.2.3` previously passed outbound screening
  for JWKS, CRL, and OAuth fetches. Linux (>= 5.3) treats nonzero `0/8` as valid
  unicast and will route it, so the prefix cannot be assumed
  unreachable-by-construction. Blocked addresses now report the reason
  `this_network` (previously `unspecified` for `0.0.0.0` only).
- **JWKS keys that declare a non-verification intent are no longer accepted as
  JWT signature-verification keys.** `DecodingKey::from_jwk` does not enforce
  the JWK `use` (RFC 7517 4.2) or `key_ops` (4.3) parameters, so an issuer
  publishing signing and encryption keys in one JWKS previously had its
  encryption keys silently installed as verification keys. Keys with
  `"use":"enc"` (or any non-`sig` value), or with a `key_ops` list that omits
  `verify`, are now skipped. Absent `use`/`key_ops` remains accepted, since both
  parameters are optional per RFC 7517.
- **Plaintext diagnostic exposure is now armed only after tracing
  initialization fully succeeds.** `init_tracing_from_config_strict` previously
  set the process-global `log_plaintext_oauth_tokens` /
  `log_oauth_claim_values` / `log_tool_call_arguments` switches *before* the
  fallible audit-log setup, so a failed strict init returned `Err` with secret
  logging left enabled process-wide.
- **The audience-mismatch rejection log now honours `log_oauth_claim_values`.**
  `aud` and `azp` were logged verbatim at DEBUG regardless of the diagnostic
  switch that gates every other claim-value log site. `expected` and `mode` are
  local configuration and remain visible.

### Fixed

- **JWKS keys that omit the OPTIONAL `alg` member are no longer dropped
  ([#17]https://github.com/andrico21/rmcp-server-kit/issues/17).** RFC 7517
  §4.4 makes `alg` optional, but the key cache required it, so every key was
  discarded and authentication failed with "no matching JWKS key found".
  This broke **Microsoft Entra ID (Azure AD) v2.0 completely**: all nine keys at
  `login.microsoftonline.com/common/discovery/v2.0/keys` are published as
  `kty=RSA`, `use=sig`, with no `alg`.

  When `alg` is absent the permitted algorithms are now inferred from the key
  material - `RSA``RS256`/`RS384`/`RS512`/`PS256`/`PS384`/`PS512`,
  `P-256``ES256`, `P-384``ES384`, `Ed25519``EdDSA`. An explicit `alg`
  continues to pin exactly one algorithm.

  The inference reads only the JWK's key type, never the token header, so it
  cannot be steered by an attacker; symmetric (`oct`) keys are never inferred,
  so an `HS*` secret can never become a verification key; and the inferred set
  is always a subset of the algorithms accepted before key lookup. `P-521`
  remains unsupported because `jsonwebtoken`'s `Algorithm` enum defines no
  `ES512` variant (see [`docs/GUIDE.md`]docs/GUIDE.md "JWKS keys without an
  `alg` member").

- **Bumped the yanked `chacha20` 0.10.1 to 0.10.2 in `Cargo.lock`.** The yanked
  release was reached transitively via `rand` 0.10.2 (from both this crate and
  `rmcp`). `deny.toml` sets `yanked = "deny"`, so the `cargo-deny` CI job was
  failing on `error[yanked]`; `cargo audit` only warned, because
  `.cargo/audit.toml` does not escalate yanked crates. The `chacha20`
  `safe-to-deploy` exemption in `supply-chain/config.toml` was regenerated in
  the same change (`cargo vet regenerate exemptions`) so `cargo vet --locked`
  stays green - the regeneration touched only that one version line.
- **Restored the default-feature and `--features oauth` builds.**
  `post_failure_rate_limit_response` binds `extensions` but reads it only inside
  a `#[cfg(feature = "metrics")]` block, so any build without `metrics` tripped
  `unused_variables` under the workspace's `-D warnings`. This broke the
  `features-matrix` CI job for the `""` and `--features oauth` cells while
  `--all-features` stayed green. Annotated with the same
  `#[cfg_attr(not(feature = ...), allow(unused_variables, reason = ...))]` idiom
  already used by `unauthorized_response`.

### Added

- **`OAuthConfig::allowed_algorithms` - operator-configurable JWT signing
  algorithm allowlist.** Previously the accepted set was a compile-time
  constant, so a deployment could not pin validation to exactly what its
  identity provider signs with (for example `["RS256"]` for Microsoft Entra).

  When unset (the default) the built-in set applies: `RS256`, `RS384`, `RS512`,
  `ES256`, `ES384`, `PS256`, `PS384`, `PS512`, `EdDSA`.

  **The knob can only NARROW that set, never widen it.** `HS256`/`HS384`/
  `HS512` and `none` are not resolvable names, so an operator cannot re-enable
  a symmetric or unsigned algorithm and open an algorithm-confusion hole; a
  regression test asserts this directly. An empty list is rejected because it
  would silently reject every token. Names are matched case-insensitively and
  de-duplicated. Invalid values fail `OAuthConfig::validate` with the list of
  permitted names.

  Configurable via TOML (`[server.auth.oauth] allowed_algorithms`), the builder
  (`OAuthConfigBuilder::allowed_algorithms`), or the environment
  (`RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS`, comma-separated).
  This is the repository's first list-valued environment override; the value is
  resolved eagerly at override time so an unusable list is reported against the
  variable that set it.

- **`OAuthProxyConfig::strip_resource_param` - opt-in workaround for Microsoft
  Entra ID ([#17]https://github.com/andrico21/rmcp-server-kit/issues/17).**
  Entra v2.0 rejects an RFC 8707 `resource` parameter carried alongside a
  differing `api://` scope with `AADSTS9010010`, but MCP clients send `resource`
  because the MCP specification requires it - so an Entra-backed proxy could not
  complete an authorization-code flow without hand-rolling a replacement proxy.

  Set `strip_resource_param = true` to drop `resource` when forwarding
  `/authorize` and `/token` upstream. **Default `false`**, which preserves spec
  behaviour.

  Deliberately a narrow boolean rather than a general parameter strip-list: a
  free-form list would let an operator strip `code_challenge`/`code_verifier`
  (silently disabling PKCE) or `state` (enabling CSRF). Only `resource` is ever
  dropped; `response_type`, `redirect_uri`, `state`, `code_challenge`,
  `code_challenge_method`, `nonce`, `scope`, `grant_type`, `code`, and
  `refresh_token` are always forwarded. The comparison happens post-decode, so
  `%72esource` cannot smuggle the parameter through. The `/introspect` and
  `/revoke` proxy path is unaffected - `resource` is not a parameter of RFC 7662
  or RFC 7009 requests.

  Configurable via TOML (`[server.auth.oauth.proxy] strip_resource_param`), the
  builder (`OAuthProxyConfigBuilder::strip_resource_param`), or the environment
  (`RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM`). The env
  override fails closed when `[server.auth.oauth.proxy]` is not declared, since
  the three required proxy fields have no env source.

### Changed

- **BREAKING (behaviour): OAuth discovery metadata now conforms to RFC 9728 and
  RFC 8414. Token validation is unchanged; two legacy escape hatches are
  provided.**

  1. **Authorization Server Metadata `issuer`** now publishes this server's own
     public URL instead of the upstream `oauth.issuer`. RFC 8414 3.3 requires
     the published `issuer` to be identical to the identifier the metadata URL
     was built from, and this document is served from the local origin;
     RFC 8414 6.2 requires *clients* to reject a mismatch, so the old value made
     the document unusable to any conformant client.

     *Legacy opt-out:* `oauth.authorization_server_metadata_issuer = "<upstream
     issuer>"`. Needed only when the upstream IdP emits RFC 9207 `iss` in the
     authorization response **and** clients validate it - the proxy cannot
     reconcile that, because the upstream redirects straight to the client's
     `redirect_uri` without passing through this process.

  2. **Protected Resource Metadata `authorization_servers`** is now resolved
     from topology instead of always advertising this server: the upstream
     `oauth.issuer` when `oauth.proxy` is absent, this server's public URL when
     it is present. Protected Resource Metadata is served unconditionally, but
     `/authorize`, `/token`, and `/.well-known/oauth-authorization-server` are
     mounted only under `oauth.proxy` - so the old default pointed RFC 9728
     discovery at a URL that returns 404.

     *Action required for one topology:* an application that mounts its **own**
     `/authorize` and `/token` via `with_extra_router` **without** configuring
     `oauth.proxy` must now declare itself explicitly:

     ```toml
     [server.auth.oauth]
     authorization_servers = ["https://mcp.example.com"]
     ```

     The crate cannot detect that case - "no proxy" is indistinguishable from
     "the application supplied its own facade".

  Token validation is untouched in both cases: inbound JWT `iss` is always
  validated against `oauth.issuer`.

- **Zero-valued metadata claims are now omitted** rather than emitted as `[]`,
  per RFC 8414 3.2 ("Claims with zero elements MUST be omitted from the
  response") and RFC 9728 3.2. Affects `scopes_supported` in both documents and
  `authorization_servers`.
- **Protected Resource Metadata is also served at the RFC 9728 3.1
  path-inserted location** `/.well-known/oauth-protected-resource/mcp` for the
  `/mcp` resource. The root path is retained as an alias, so no client breaks.
  Like every framework route, an `extra_router` entry that *exactly* overlaps
  this new path panics at startup - see the collision caveat on
  `McpServerConfig::with_extra_router`.
- The `WWW-Authenticate` challenge advertises an **absolute**
  `resource_metadata` URL when `public_url` is configured, and keeps the
  previous relative path otherwise. Without `public_url` the only derivable
  origin is the bind address, which behind a TLS-terminating proxy is an
  internal address - a wrong absolute URL is not more conformant than a
  relative one.

- `tool_hooks::with_hooks` is now `#[must_use]`. Dropping the returned
  `HookedHandler` silently disables every supplied hook, so ignoring it is
  always a bug. This was deferred from the 1.7.x patch line to avoid downstream
  `-D warnings` churn and lands here at a minor boundary. Downstream code that
  discards the return value (rather than binding or wiring it) will now warn.
- `allow_http_oauth_urls` documentation corrected: HTTPS -> HTTP redirect
  *downgrades* are always rejected, but HTTP -> HTTP redirects are permitted
  when the flag is `true` (the target must still pass SSRF screening). The
  previous wording claimed all non-HTTPS redirect targets were rejected, which
  did not match `evaluate_oauth_redirect`.
- Examples and cookbook snippets that present a *restricted* RBAC role now use
  `ArgumentAllowlist::new_required` instead of `ArgumentAllowlist::new`. With
  the compatibility default (`required = false`) an allowlist constrains an
  argument only when it is supplied, so a caller omitting the key passes
  unchecked and the handler's default value is used - the shipped examples were
  demonstrating a fail-open policy. The default itself is unchanged and still
  flips in 4.0.
- Cancel-safety annotations added to the async request-path functions that were
  missing them (`RUST_GUIDELINES.md` §5), including `readyz`, `call_tool`,
  `handle_token`, `select_jwks_key`, and the CRL refresh/commit helpers. No
  behaviour change; `handle_token`, `readyz`, `call_tool`, and `select_jwks_key`
  are documented as **NOT** cancel-safe with the specific consequence.

- **Out-of-band mutation of the public `CrlSet::cache` field is now detected and
  fails the handshake closed.** Every legitimate commit records a constant-cost
  identity for each cached CRL and publishes it atomically with the verifier and
  the coverage hint; the synchronous mTLS precheck compares the live cache
  against that index before trusting `cached_urls`. A same-key replacement or a
  direct removal performed through the `pub` field previously left the coverage
  hint claiming revocation coverage the live verifier could not enforce. This
  detects **API misuse, not a same-process adversary** - code able to take the
  cache write lock is already inside the trust boundary. **Opt out with
  `crl_deny_on_unavailable = false`.**
- **A client certificate advertising more than 64 distinct CRL distribution
  point URLs is now rejected as malformed, in BOTH fail-open and fail-closed
  modes, with no opt-out.** Every step of CDP handling is linear in this
  peer-chosen count, so leaving it unbounded is an amplification primitive on
  the unauthenticated TLS handshake path. RFC 5280 4.2.1.13 treats multiple URIs
  in one distribution point as mirrors of the same CRL, so a conforming
  certificate needs far fewer. `crl_deny_on_unavailable = false` opts out of
  *revocation-unavailability* denials, not out of malformed-certificate
  rejection. See [`docs/MIGRATION.md`]docs/MIGRATION.md#5-certificates-advertising-more-than-64-cdp-urls-are-rejected.
- Documented the hazards of the `test-helpers` feature at the crate, guide, and
  manifest level: the helpers are for downstream integration tests only, are not
  stable API, and can deliberately bypass SSRF screening, the JWKS refresh
  cooldown, the CDP discovery rate limiter, and CRL verifier publication.
- **CRL revocation now fails closed by default.** `auth.mtls.crl_deny_on_unavailable`
  defaults to `true` (was `false`). A client certificate advertising CRL
  distribution points is rejected when *every* relevant CDP is uncached and
  unfetchable, per RFC 5280 §6.3. Denial deliberately requires all relevant
  CDPs to be unavailable rather than any single one, so blocking one mirror
  cannot be used to deny service. **Opt out with
  `crl_deny_on_unavailable = false`.** See [`docs/MIGRATION.md`]docs/MIGRATION.md#migrating-to-38-crl-fail-closed-by-default.
- **A CRL distribution point can no longer be stranded by a fast-settling fetch.**
  `note_discovered_urls` published the pending marker *after* handing the URL
  to the refresher, so a fetch that completed first left a stale marker that
  permanently suppressed rediscovery of that CDP. Under the new fail-closed
  default this would have become a persistent handshake failure.
- **OAuth access tokens, JWT claim values, and tool-call arguments are now
  redacted in `Debug` and log output by default.** `ExchangedToken` and
  `ToolCallContext` previously derived `Debug` over live secrets. Plaintext
  can be re-enabled per category for local debugging via the new
  `observability.log_plaintext_oauth_tokens`, `observability.log_oauth_claim_values`,
  and `observability.log_tool_call_arguments` knobs (and their
  `RMCP_SERVER_KIT__OBSERVABILITY__*` environment equivalents). These switches
  are **process-wide**, not per-server.
- **mTLS configured without TLS is now rejected at validation.** `auth.mtls`
  without both `tls_cert_path` and `tls_key_path` silently disabled client
  certificate authentication, because a plaintext listener never performs a
  handshake and so never extracts an identity.
- **Startup failures no longer leak background listeners.** A failure after
  `build_app_router` - a main-bind `AddrInUse`, an unreadable TLS key - left
  the Prometheus metrics listener and the CRL refresher running with their
  ports bound. The external-shutdown bridge additionally parked forever on a
  caller token that might never be cancelled.
- **Bootstrap CRL fetches now respect `crl_max_cache_entries`**, which they
  previously bypassed.
- An `ArgumentAllowlist` with a non-empty `allowed` list but `required = false`
  now emits a startup warning. Such an allowlist constrains the argument only
  when it is present, so a caller who omits it bypasses the check entirely if
  the tool substitutes a default. The new `ArgumentAllowlist::new_required`
  constructor is the recommended form; `required` will default to `true` in
  4.0.

- **A hard-aborted CRL refresher no longer strands a CDP URL, silently narrowing
  revocation coverage.** `mtls_revocation::run_crl_refresher`'s discovery arm
  marks a URL in-flight in `pending_urls`, then awaits `fetch_and_store_url`.
  Every normal exit promoted or cleared that marker, but `JoinHandle::abort`
  drops the future mid-await so neither ran, and a stale `pending_urls` entry
  suppresses re-enqueue permanently -- that CDP was then never retried until
  process restart. The arm now holds an owned RAII guard across the await whose
  `Drop` clears the marker on every path, including abort. Cooperative shutdown
  via the cancellation token was already safe and is unchanged.

- **Requests with an unresolvable source address are no longer exempt from rate
  limiting.** All four built-in per-IP limiters previously skipped enforcement
  entirely when no client address could be determined. They now fall back to a
  single bounded shared bucket via an internal `RateLimitKey::Unattributed`
  key, and log once per process. This is unreachable for a server started by
  `serve()` (the peer-address normalisation layer always inserts `ConnectInfo`),
  but was reachable when this crate's middleware was composed into a
  externally-built router.

  A typed key is used rather than a sentinel address: `limiter_client_ip`
  consults the trusted-forwarder-derived `ClientIp` first, and `crate::forwarded`
  does not filter unspecified addresses, so a forwarded `0.0.0.0` would have
  collided with a `0.0.0.0` sentinel.

- **Tool results that fail to serialize now fail closed against
  `max_result_bytes`.** `serialized_size` previously reported a serialization
  failure as `0` bytes, so the size cap silently did not fire and the result was
  recorded as zero-length. An unmeasurable result is now replaced when a cap is
  configured. When no cap is configured the result still passes through, since
  there is no policy to enforce; the failure is logged either way.

  The client-visible `actual_bytes` field renders `"unknown"` for unmeasurable
  results rather than a fabricated number.

- **Bearer credentials containing embedded whitespace are rejected.** RFC 7235
  §2.1 defines the credential as `token68`, which excludes whitespace; accepting
  it created a parser differential against a fronting proxy that splits on any
  whitespace. Only whitespace is rejected -- the full `token68` character class
  is deliberately **not** enforced, because `ApiKeyEntry::new` accepts an
  arbitrary caller-supplied hash and consumers may have hashed externally-issued
  opaque tokens containing other punctuation. Those continue to authenticate.

- **Zero-valued capacity knobs now fail config validation instead of being
  silently clamped or defaulted.** `oauth.max_jwks_keys`,
  `oauth.jwks_max_response_bytes`, the mTLS CRL capacity knobs
  (`crl_max_concurrent_fetches`, `crl_discovery_rate_per_min`,
  `crl_max_host_semaphores`, `crl_max_seen_urls`, `crl_max_cache_entries`,
  `crl_max_response_bytes`), and `auth.rate_limit.max_attempts_per_minute` now
  return `RmcpServerKitError::Config` with a `must be nonzero` message during
  startup validation.

  `auth.rate_limit.pre_auth_max_per_minute` is also rejected when explicitly set
  to `0`. That value never meant "unlimited": the limiter fell back to the
  built-in pre-auth default, so a `0` *raised* the quota rather than tightening
  it (`max_attempts_per_minute = 1` plus `pre_auth_max_per_minute = 0` yielded
  300/min instead of the derived 10/min), weakening the gate that shields Argon2
  verification from CPU-spray. Leaving the key unset still derives the quota as
  before.

  `auth.mtls.crl_max_response_bytes = 0` was likewise accepted and made every
  non-empty CRL body exceed the streaming cap, so CRL fetching could never
  succeed; under the new default `crl_deny_on_unavailable = true` that fails
  every CDP-bearing handshake instead of reporting the misconfiguration.

- **The internal bounded-limiter hard cap now uses `NonZeroUsize`.** This makes
  a zero tracked-key cap unrepresentable in the crate-internal constructor;
  public `with_per_minute` and `with_per_second` constructor signatures are
  unchanged and continue to clamp a direct `0` cap to `1` as defense-in-depth.

- **Configured audit logs now fail closed through
  `init_tracing_from_config_strict`.** When `audit_log_path` is set but the
  parent directory cannot be created or the file cannot be opened, strict
  tracing initialization returns `RmcpServerKitError::Startup` instead of
  silently running without an audit trail.

- **BREAKING: operator TOML config now rejects unknown keys.** The reusable
  TOML schemas now derive `serde(deny_unknown_fields)` for `[server]`,
  `[server.security_headers]`, `[server.auth]` (including `api_keys`, `mtls`,
  `rate_limit`, and OAuth sub-tables), `[rbac]` (including `roles` and
  `argument_allowlists`), and `[observability]`. Previously ignored stray or
  misspelled keys such as `tls_keypath` or `typo_content_security_policy` now
  abort deserialization/startup so hardened defaults are not accidentally used
  in place of the operator's intended setting.

### Added

- `oauth::exchange_token_with_cancel` -- a cancel-aware wrapper around
  `exchange_token` (feature `oauth`). It pre-checks the cancellation token so an
  already-abandoned request never reaches the wire, detaches the in-flight
  exchange instead of dropping it, and returns `cancel::DetachOutcome`. If the
  caller goes away and the exchange later succeeds, the detached task emits one
  `warn` recording that a downstream token was minted and discarded -- metadata
  only (`expires_in`, truncated `issued_token_type`); no token material, subject
  token, form body, client secret, or endpoint details are logged on that
  abandoned-success path. Additive; `exchange_token` is unchanged.

  Abandoned failures may log the error at `debug`, but outbound OAuth request
  failures sanitize the configured endpoint down to scheme/host/port and strip
  reqwest's embedded URL before formatting, so userinfo, path, query, and
  fragment are not logged.

  **This is a mitigation, not a guarantee.** Once the RFC 8693 request is on the
  wire, nothing local can un-send it: if the process dies, the runtime shuts
  down, or the response is lost after the authorization server minted a token,
  an orphaned downstream credential can still exist and this crate cannot know
  about it or revoke it. Outbound revocation of orphaned tokens is deliberately
  not implemented.

  Note also that detaching is unbounded in *count*: each detached exchange is
  time-bounded by the HTTP client's connect/total timeouts, but nothing caps how
  many can be in flight during a cancel storm. Use it behind the crate's
  existing authentication, rate-limit, and concurrency controls.

- `RmcpServerKitError::Internal` -- an internal-failure variant whose detail is
  logged server-side and collapsed to `"internal server error"` on the wire.
  Additive: the enum is `#[non_exhaustive]`.

- `McpServerConfig::with_trusted_forwarder_max_entries` and the
  `server.trusted_forwarder_max_entries` TOML key expose the
  forwarding-chain scan cap used in trusted-forwarder mode. Default `16`
  (unchanged). Validated to `1..=64`: `0` would pin every client to the proxy
  address, and an unbounded value would re-open the header-bomb vector the cap
  exists to close.

- `observability::TracingGuard` and
  `observability::init_tracing_from_config_strict` provide fail-closed audit-log
  setup with best-effort, time-bounded (5s) audit writer drain/flush on guard
  drop. The legacy
  `init_tracing_from_config` entry point is deprecated but remains fail-open for
  source compatibility.

### Changed

- **RBAC host matching is now ASCII-case-insensitive.** `RbacPolicy::check` and
  `RbacPolicy::host_visible` previously compared hosts byte-exactly, so
  `Example.COM` did not match a `hosts = ["example.com"]` pattern. Since DNS
  names and IP literals are case-insensitive by specification, this corrects
  false denials. Operation names and tool-name patterns remain **case-sensitive**
  -- normalization is scoped to host matching only.

- **`generate_api_key` now returns `RmcpServerKitError::Internal` instead of
  `Auth` when salt encoding or Argon2 hashing fails.** The `Auth` variant is
  declared client-facing and is rendered verbatim to HTTP clients, so embedding
  an upstream `password_hash::Error` chain in it violated that contract; it
  also mapped a server-side fault to `401` rather than `500`. Not a compile
  break -- the enum is `#[non_exhaustive]`, so downstream matches already carry
  a wildcard arm -- but the observable variant and HTTP status change.

- Servers started with `max_concurrent_requests` unset now log one startup
  warning. No default is imposed; behaviour is unchanged.

- Audit-file logging now uses a bounded non-blocking channel drained by a
  dedicated writer thread, so tracing call sites on tokio worker threads no
  longer perform synchronous file writes. If the channel is full, newest audit
  entries are dropped and the writer records the aggregate dropped count when it
  catches up. Runtime audit-file write or flush failures remain non-fatal but
  now increment an internal failure counter and emit a throttled warning directly
  to process stderr, avoiding recursive re-entry through the tracing subscriber.

- OAuth `audience_validation_mode = "permissive"` now logs once per process
  when it accepts a token via the `azp`-only fallback. Acceptance behaviour is
  unchanged; previously this mode was entirely silent, so a deployment running
  wider-than-spec audience validation left no trace. The `"warn"` mode's
  existing once-per-process warning is untouched.

- The JWKS "refresh skipped (cooldown active)" message moved from `debug` to
  `info`, so a key rotation stalled behind the refresh cooldown is visible at
  default log levels.

- A duplicate `kid` in a JWKS document now logs a warning (last entry wins, as
  before). The logged `kid` is truncated, since it is issuer-controlled text of
  unbounded length.

- A duplicate `kid` warning now carries a structured `kid_truncated` boolean
  alongside the truncated value, so log pipelines can detect truncation without
  substring-matching.

### Documentation

- **`docs/GUIDE.md`'s `RmcpServerKitError` reference was stale and is now
  accurate.** It listed an `Other(anyhow::Error)` variant that does not exist,
  and omitted `RateLimitedFor`, `Tls`, `Startup`, `Metrics`, and the new
  `Internal`. The variants are now grouped by exposure -- client-facing
  (rendered verbatim) versus internal-only (collapsed to
  `"internal server error"`) -- and the `#[non_exhaustive]` contract is stated.

- `RbacPolicy::argument_allowed` now documents that token comparison is
  byte-exact with no Unicode normalization, and the consequence on
  normalizing filesystems.

- `McpServerConfig::with_extra_router` now documents how path collisions are
  handled. A route that **exactly overlaps** a framework route panics at
  startup inside `axum::Router::merge`; a path merely *under* a framework
  prefix (`/admin/custom` alongside `/admin/status`) does **not** panic and is
  **not** validated. `axum::Router` exposes no route-enumeration API, so the
  framework cannot inspect these paths -- avoiding such collisions is the
  caller's responsibility. Both behaviours are now pinned by tests.

### Internal

- Every production async fn that can run inside `select!`, `timeout`, or an
  abortable task now carries a `// cancel-safe:` or `// NOT cancel-safe:`
  annotation, per `RUST_GUIDELINES.md` 5. Documentation only -- no behaviour
  change. Auditing the bodies surfaced two paths that were genuinely **not**
  cancel-safe; both are now addressed -- `mtls_revocation::run_crl_refresher`
  under *Security*, and `oauth::exchange_token` under *Added*.

- The client-facing-message invariant on `RmcpServerKitError` now has a
  regression guard. A test walks `src/` and fails when `Auth`, `Rbac`,
  `RateLimited`, or `RateLimitedFor.message` is constructed with an
  interpolated error-shaped binding (`{e}`, `{err}`, `{error}`, `{source}`, or
  the positional form) -- the exact shape of the defect fixed above.

  It is a **tripwire, not a proof**: it does not catch an error bound to a
  differently-named variable, an error stringified before interpolation, or a
  non-error internal detail such as a file path. The invariant still rests on
  review; the guard exists so the known failure mode cannot recur silently. It
  carries its own positive and negative self-tests so a broken matcher cannot
  degrade into a no-op that always passes.

- `host_matches` lowercases the host once per call rather than once per
  pattern, and only when a wildcard pattern is present -- an all-exact host
  pattern list stays allocation-free.

- Numeric IPv4 literal forms (decimal, hex, octal) and IPv4-mapped IPv6 are now
  pinned by tests as unreachable-as-hostnames through the OAuth literal-IP
  guard. No behaviour change: the `url` crate already normalizes these to
  `Host::Ipv4` before the guard sees them, but nothing asserted it.

## [3.7.0] - 2026-08-25

Retires the `mcpx` naming that outlived the crate rename.

### Changed

- **BREAKING (wire, not API): the `/version` response field `mcpx_version` is
  renamed to `rmcp_server_kit_version`.** The old key is **removed**, not
  duplicated. Anything scraping `/version` for `mcpx_version` must be updated.
  No Rust API is removed or retyped, so per this file's versioning policy - and
  matching the 3.1.0 precedent, which also altered the `/version` response
  shape - this ships in a minor release with a migration note.

  The name is deliberate: it matches the crate name and the convention already
  set by `RMCP_SERVER_KIT_BUILD_SHA` (1.8.1) and the `RMCP_SERVER_KIT__*` env
  overrides (3.5.0). It is **not** `rmcp_version`, which would read as the
  version of the `rmcp` SDK - a real, separate dependency of this crate.

- **`McpxError` is renamed to `RmcpServerKitError`.** A deprecated type alias
  keeps the old name compiling:

  ```rust
  #[deprecated(since = "3.7.0", note = "renamed to `RmcpServerKitError`; ...")]
  pub type McpxError = RmcpServerKitError;
  ```

  Downstream code using `McpxError` continues to build and gets a deprecation
  warning naming the replacement; verified against an external test crate.
  `cargo-semver-checks` confirms the change is additive against the published
  3.6.0 baseline (223 checks, "no semver update required").

  An alias rather than a hard rename because the major version **tracks the
  `rmcp` SDK major** (see the header of this file). Bumping to 4.0.0 for a
  rename would falsely signal an `rmcp` 4 upgrade. The alias is removed
  whenever an `rmcp` major forces the next 4.0.0.

### Migration

- Replace `McpxError` with `RmcpServerKitError`. The old name still works for
  now; the compiler will point at each site.
- If you parse `/version`, read `rmcp_server_kit_version` instead of
  `mcpx_version`.

## [3.6.0] - 2026-08-25

> **First published release since 3.3.1.** The `3.4.0` and `3.5.0` tags exist in
> git but were never published to crates.io: `release.yml` is gated on `CI` via
> `workflow_run`, CI was red on both tags, so the publish job was skipped and
> nobody noticed. 3.6.0 carries everything from both, plus the fixes that
> unblocked the pipeline. Consumers upgrading from 3.3.1 get the 3.4.0 and
> 3.5.0 sections below as well as this one. A minor bump rather than a patch,
> because relative to the last *published* version this adds public API.

### Fixed

- **Clippy failures on every feature combination except `--all-features`.**
  `apply_oauth_env_overrides` took `&mut self` and `&mut Vec<EnvOverride>` that
  the `#[cfg(not(feature = "oauth"))]` path never used mutably, tripping
  `needless_pass_by_ref_mut` twice and `needless_return` once. Invisible under
  `--all-features`, which cannot see inside `cfg(not(...))`. Reading the three
  OAuth variables now lives in an ungated helper so detection stays
  feature-independent - a set variable on a non-`oauth` build is still a hard
  error, never a silent no-op - while only the mutating half is cfg-gated. No
  behaviour or error-message change.

- **`t10_every_server_config_field_is_classified_for_bridge` failing on
  `windows-latest`.** It split on a literal `"\n}\n\nimpl ServerConfig"`; with
  `core.autocrlf` the Windows runner checks out CRLF and the marker could not
  match. Normalized before splitting. The rest of the suite was swept for the
  same class; the two `docs/GUIDE.md` parsers already use `.lines()`, which
  strips `\r`.

- **`cargo vet` exemptions refreshed** for `crc32fast`, `h2`, `log`, `syn` and
  `uuid`. `Cargo.lock` is gitignored, so CI resolves dependencies fresh while
  exemptions pin exact versions; any transitive release fails the blocking vet
  job until regenerated.

### Changed

- [`docs/RELEASING.md`]docs/RELEASING.md pre-flight now matches what CI
  actually enforces: clippy across the whole feature matrix, `cargo test` with
  and without default features, and `cargo vet --locked` against a freshly
  generated lockfile. It also requires confirming CI is green *before* tagging
  and verifying `max_version` on crates.io afterwards, since a pushed tag is
  not a release.

### Documentation

- **Every TOML-configurable parameter is now documented.** An exhaustive diff of
  the `Deserialize` structs against [`docs/GUIDE.md`]docs/GUIDE.md found five
  keys with no coverage anywhere in the guide: `oauth.require_subject`,
  `oauth.role_mappings`, the `claim_value` field of `RoleMapping`, and
  `rate_limit.{max_tracked_keys, idle_eviction}`. All are now documented with
  type and default, `RateLimitConfig` gained a full parameter table, and
  `role_claim` / `role_mappings` gained a worked example - `[[server.auth.oauth.role_mappings]]`
  was previously undiscoverable. `forwarded_header` now documents its accepted
  values (`"x-forwarded-for"`, `"forwarded"`) and `trusted_proxies` pairing.

- **The complete TOML example now names the environment variable that overrides
  each key**, as trailing `# env: RMCP_SERVER_KIT__...` comments, so the mapping
  is visible at the point of use rather than only in the reference table.

  A second drift guard covers these annotations: it asserts each one names a
  real variable, is attached to the TOML key that variable actually targets,
  appears at most once, and that every variable is annotated exactly once apart
  from a documented exemption for `..._REDACTION_SALT_FILE`, which shares a key
  with its non-`_FILE` sibling. Verified non-vacuous against a wrong-key
  attachment, a typo, and a deletion.

- **New runnable example `examples/config_file_server.rs`** covering the whole
  config pipeline: a downstream-owned root struct, TOML deserialization,
  `apply_env_overrides` with the audit report, `validate_server_config`,
  `apply_to_mcp_config`, the runtime-only wiring the bridge cannot carry
  (`RbacPolicy`, metrics), and a builder override chained after the bridge.
  The TOML-file path shipped in 3.4.0/3.5.0 previously had no runnable
  example - only a `rust,ignore` guide snippet, which nothing compiles.
  An example is covered by `cargo build --examples` and
  `cargo clippy --all-targets`, so it cannot rot silently.

- **Every config-pipeline code sample is now compiled.** The four public config
  entry points - `ServerConfig::{apply_env_overrides, apply_to_mcp_config}`,
  `ObservabilityConfig::apply_env_overrides` and
  `RbacConfig::apply_env_overrides` - gained rustdoc examples, so they are
  covered by `cargo test --doc` (doctest count 7 -> 11) and render on docs.rs.
  The two long `rust,ignore` pipeline snippets in the guide, which nothing
  compiled and which had already drifted to referencing a nonexistent
  `YourRootConfig` type, are replaced by a call-order outline plus pointers to
  the compiled rustdoc and to `examples/config_file_server.rs`. Also repaired
  two rustdoc-style intra-doc links in the guide that plain Markdown cannot
  resolve, so they rendered as literal bracketed text rather than links.

## [3.5.0] - 2026-08-24 (tagged, never published)

### Added

- **Three opt-in `apply_env_overrides` methods** for environment-driven configuration overlay.
  `ServerConfig::apply_env_overrides`, `ObservabilityConfig::apply_env_overrides`, and
  `RbacConfig::apply_env_overrides` each apply a named set of `RMCP_SERVER_KIT__*` env vars
  onto the corresponding struct and return a `Vec<EnvOverride>` audit report. No existing
  code path calls these methods automatically: `serve()`, `validate()`, and every config
  constructor are unaffected. This release is purely additive.

- **`EnvOverride` and `EnvOverrideSource`** -- public, `#[non_exhaustive]` types that
  describe each applied override. `EnvOverride::value` is `None` for secret-typed targets
  so secrets are never exposed in report output or `Debug` formatting.

- **14 curated environment variables** across three config structs:
  - `ServerConfig`: listen address and port, public URL, TLS certificate/key paths, admin
    toggle, and OAuth issuer/audience/JWKS URI (the three OAuth variables require
    `--features oauth`).
  - `ObservabilityConfig`: log format, metrics-enabled flag, and metrics bind address.
  - `RbacConfig`: redaction salt (direct string or `_FILE` path indirection).

  All variables follow the `RMCP_SERVER_KIT__<SECTION>__<FIELD>` naming convention with `__`
  as the nesting delimiter.

- **`_FILE` secret indirection for `rbac.redaction_salt`.** Set
  `RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE` to a file path to read the salt from a
  Kubernetes Secret volume mount. Setting both the direct variable and the `_FILE` variable
  simultaneously is a hard startup error. Exactly one terminal line ending is stripped from
  the file contents (`\r\n`, `\n`, or `\r`); all other whitespace is preserved, so the same
  logical secret produces the same salt whether supplied inline or via a file.

- **Fail-closed parsing.** An env var set to an unparseable value causes `apply_env_overrides`
  to return `McpxError::Config` naming the exact variable and the expected type. There is no
  warn-and-ignore path.

No default values changed. `serve()`, `validate()`, and every constructor read no process
environment automatically. Existing code that does not call `apply_env_overrides` is
unaffected.

## [3.4.0] - 2026-08-24 (tagged, never published)

### Added

- **TOML controls for `server.max_request_body` and `server.expose_build_metadata`.**
  Both `ServerConfig` fields were already defined but had no route into `serve()`.
  They are now bridged via `ServerConfig::apply_to_mcp_config`. `max_request_body`
  caps the request body in bytes (default 1 MiB; must be greater than zero, validated
  by `McpServerConfig::validate()`). `expose_build_metadata` controls whether
  `build_git_sha`, `build_timestamp`, and `rust_version` appear in the unauthenticated
  `/version` response (default `false`, unchanged from 3.1.0).

- **`[server.security_headers]` TOML table - all twelve OWASP headers configurable
  from TOML.** `SecurityHeadersConfig` now derives `serde::Deserialize` with
  `#[serde(default)]`. Every one of the twelve fields is configurable under
  `[server.security_headers]` without any change to built-in defaults. The
  existing three-state semantic is preserved: an absent key keeps the built-in
  default; `""` omits the header from every response; a non-empty string is used
  verbatim and validated at startup. At startup the server emits a `warn`-level
  log entry for every header that is overridden or omitted, so a weakened policy is
  visible in logs rather than only in a config diff. HSTS `preload` (any case)
  remains rejected by the validator. Unknown keys under `[server.security_headers]`
  are silently ignored (no `deny_unknown_fields`).

- **`ServerConfig::apply_to_mcp_config`** - the bridge between the TOML schema and
  `serve()`. Accepts a `base: McpServerConfig` and returns `McpServerConfig` with
  every bridgeable transport field replaced by the TOML-sourced value. Uses
  replacement semantics throughout: `None` and `false` in TOML clear the
  corresponding field on `base`, so there is no partial-merge ambiguity. The
  runtime-only fields preserved from `base` unchanged are `name`, `version`, `rbac`,
  `readiness_check`, `extra_router`, `on_reload_ready`, `metrics_enabled`, and
  `metrics_bind`. Returns `McpxError::Config` when any duration string cannot be
  parsed by `humantime`. The method is side-effect free and never reads process
  environment variables.

- **New clearing-capable `McpServerConfig` builder setters** added to support the
  replacement-semantics bridge: `with_bind_addr`, `with_tls_paths`,
  `with_optional_auth`, `with_max_request_body`, `with_expose_build_metadata`,
  `with_session_idle_timeout`, `with_sse_keep_alive`, `with_tls_handshake_timeout`,
  `with_max_concurrent_tls_handshakes`, `with_allowed_origins`,
  `with_extra_route_rate_limit_exempt_paths`, `with_trusted_proxies`,
  `with_optional_tool_rate_limit`, `with_optional_tool_rate_limit_burst`,
  `with_optional_extra_route_rate_limit`, `with_optional_extra_route_rate_limit_burst`,
  `with_optional_forwarded_header`, `with_optional_public_url`,
  `with_compression_enabled`, `with_compression_min_size`,
  `with_optional_max_concurrent_requests`, `with_admin_enabled`, and `with_admin_role`.
  All are additive; no existing builder method is changed or removed.

No default values changed. This release is purely additive - existing configurations
that do not opt in to `[server.security_headers]`, `max_request_body`, or
`expose_build_metadata` parse and behave identically to 3.3.x.

## [3.3.1] - 2026-08-22

### Changed

- **`cargo vet` no longer audits this crate against itself.**
  `supply-chain/config.toml` set `audit-as-crates-io = true` for
  `rmcp-server-kit`, asking cargo-vet to audit our own package as though it
  were a third-party crates.io dependency. That requirement was satisfied with
  a self-exemption - an explicit trust-without-audit marker - so it asserted no
  security property, while pinning an exact version meant every release
  orphaned it. The `cargo vet` CI job consequently sat red from the 3.2.0
  release until the 3.3.0 cycle without anyone noticing, because it was
  `continue-on-error: true`.

  The policy is now `audit-as-crates-io = false`, which is the correct setting
  for a first-party crate you author and publish. The self-exemption and the
  `unpublished` marker are removed, the per-release refresh step in
  `docs/RELEASING.md` is gone, and the CI job is now **blocking** rather than
  advisory. Third-party audit coverage is unchanged (385 exemptions, was 386 -
  the difference is the self-entry).

### Testing

- **Regression test for the graceful-shutdown grace window.** The 3.3.0 fix
  that stopped in-flight MCP sessions being cancelled when the drain window
  opens shipped without a timing test. Added one that performs a real MCP
  `initialize` handshake, blocks inside `call_tool` until the test releases it,
  triggers shutdown while the call is in flight, and asserts the tool response
  still arrives.

  It is event-gated rather than duration-gated, so it does not depend on
  elapsed-time thresholds. It exercises `/mcp` specifically: routes registered
  through `with_extra_router` never reach `StreamableHttpService`, the sole
  consumer of the shutdown session token, so a test built that way would pass
  against the bug. Verified by reverting the wiring to `ct.child_token()`, at
  which point the assertion fails with an empty SSE frame.

- **Regression test for the force-exit shutdown arm.** The session token is
  cancelled in both arms of the shutdown `select!` - after axum drains, and
  when the force-exit timer wins - but only the first was covered.

  The assertion is on the client-side response body, not on the server task
  completing: when force-exit wins, the `axum::serve` future is dropped and
  `serve_with_listener` returns whether or not the token was cancelled, so a
  join-handle assertion could never fail. What the cancellation changes is that
  rmcp ends the SSE stream, so the body reaches EOF instead of hanging.
  Awaiting only `send()` would be equally useless, since the response headers
  are established before the tool result exists.

  Verified by removing the force-exit `session_ct.cancel()` while leaving the
  graceful-drain one intact: the body then never terminates and the test fails
  on its timeout.

## [3.3.0] - 2026-08-21

### Added

- **`ArgumentAllowlist::required`** (default `false`) - opt into requiring that
  a constrained argument actually be supplied. An allowlist has always
  constrained the value *only when the argument is present*, so a caller could
  skip it entirely by omitting the key. That is safe when the tool's input
  schema marks the argument required, but fails open when the handler
  substitutes a default for a missing value. With `required = true` the
  argument must be present **and** string-valued; a missing key, a non-string
  value, or an absent/non-object `arguments` object are all rejected with 403.
  Combining `required = true` with an empty `allowed` list means "must be
  supplied as a string, any value accepted". Set it via
  `ArgumentAllowlist::with_required(true)` or `required = true` in TOML.

  This is purely additive: the field is `#[serde(default)]` on an
  already-`#[non_exhaustive]` struct and `ArgumentAllowlist::new` is unchanged,
  so existing configurations parse identically and behave exactly as before
  unless they opt in.

### Security

- **mTLS: a CRL whose first fetch failed is no longer suppressed for the
  process lifetime.** A discovered CRL Distribution Point URL was committed to
  the permanent dedup set as soon as it was queued. If that first fetch then
  failed, no code path could undo it - `seen_urls` is pruned only for URLs
  whose CRL was successfully cached and later went stale - so the URL was never
  re-enqueued and revocation for that CDP was silently disabled under the
  default `crl_deny_on_unavailable = false` (or the handshake failed forever
  with it set to `true`). CDP URLs come from the client-presented certificate,
  so a holder of a revoked-but-chain-valid cert could trigger this deliberately
  by making the first fetch fail from a host they control. Queued-but-unfetched
  URLs now occupy a separate in-flight state and are promoted to the permanent
  set **only once the CRL is confirmed present in the cache** - a fetch error or
  a `crl_max_cache_entries` rejection clears the marker so a later handshake
  retries. Retry volume stays bounded by the existing
  `crl_discovery_rate_per_min` limiter, global fetch concurrency, and per-host
  semaphores. Cache removal now clears both states.

- **OAuth proxy: caller-supplied client-authentication parameters can no longer
  be smuggled upstream.** `client_id` was stripped by splitting the raw form on
  `&` and dropping segments literally starting with `client_id=`. Percent-encoded
  keys (`%63lient_id=`, `client%5Fid=`) survived that filter but decode upstream
  to `client_id`, and `client_secret` was never filtered at all - so a caller
  could ship duplicate client credentials to the IdP alongside the proxy's own
  injected values, and a first-wins upstream parser would honour the caller's.
  The query/body is now parsed and re-serialized as
  `application/x-www-form-urlencoded`, dropping every decoded `client_id`,
  `client_secret`, `client_assertion`, and `client_assertion_type` before
  proxy-owned values are injected, across all three proxy call sites. Decoded
  values and the relative order of non-client parameters (including repeated
  `scope` / `resource`) are preserved; raw byte encoding is normalized, which is
  semantically equivalent for form data.

- **RBAC: a non-string `arguments.host` no longer downgrades the host check.**
  The host was read with `as_str()`, so an array, object, number, bool, or null
  yielded `None` and routed to `check_operation`, skipping `RoleConfig.hosts`
  glob evaluation entirely - letting a caller opt out of host restrictions by
  changing the argument's shape. A present-but-non-string `host` is now denied,
  matching the existing fail-closed behaviour for non-string allowlisted
  arguments. An **absent** `host` still routes to `check_operation`, so
  genuinely hostless tools (`ping`, `list_hosts`) are unaffected.

- **Metrics: HTTP label cardinality is now bounded.** `metrics_middleware` runs
  outside the auth layer and labelled series with the raw request path and raw
  HTTP method, so unauthenticated requests to random paths minted a permanent
  Prometheus time series each, growing in-process state until exhaustion. Labels
  now come from a closed set: the matched route template where available, `/mcp`
  for the nested MCP service, `<unmatched>` otherwise, and a fixed method list
  with `OTHER` for extension verbs. The raw path is never used. Label **names**
  are unchanged, so existing dashboards keep working; label **values** for
  previously-unmatched requests change.

### Fixed

- **`Forwarded` header parsing now requires balanced quotes (RFC 7239 §4).**
  `for=` values were previously unquoted with `trim_matches('"')`, which strips
  leading and trailing quotes *independently*. Malformed values such as
  `for="203.0.113.9` (lone leading quote), `for=203.0.113.9"` (lone trailing),
  and `for="""203.0.113.9"""` were silently normalized into a valid address
  instead of being rejected. A value is now parsed strictly as RFC 7239
  `token / quoted-string`: a bare token must contain no `"` at all, and a
  quoted-string must be balanced; anything else yields
  `FallbackReason::MalformedEntry` and resolution falls back to the direct
  peer. This removes a parser differential between `rmcp-server-kit` and an
  upstream proxy, which matters because the resolved client IP feeds per-IP
  rate limiting and operator allowlists. Well-formed quoted values -
  including `for="[2001:db8::1]:443"` and quoted `unknown` / `_obfuscated`
  identifiers - are unaffected.

- **Graceful shutdown no longer cuts MCP sessions at the start of the grace
  window.** The MCP service received a child of the same cancellation token the
  shutdown path cancels immediately when the trigger fires, so in-flight
  sessions and SSE streams could terminate before `shutdown_timeout` elapsed
  during a normal SIGTERM rollout. The MCP service now holds a dedicated
  session token, cancelled only after axum finishes draining - or when the
  force-exit timer wins, so a stuck stream still cannot hang shutdown. The
  lifecycle token continues to drive the metrics listener, the CRL refresher,
  and external shutdown wiring unchanged.

### Changed

- **Metrics middleware no longer allocates a `String` per request** for the
  HTTP status label. It now renders the status code into a stack
  `core::fmt::NumBuffer` via `u16::format_into` (both stable in Rust 1.98,
  already the crate MSRV). Behavior and emitted label values are unchanged;
  only the `metrics` feature path is affected.
- **The screened-redirect JWKS/discovery `reqwest::Client` is no longer built
  in production OAuth builds.** `OauthHttpClient::inner` is read only by the
  `cfg`-gated redirect-policy regression helpers (`__test_get`,
  `__test_inner_client`), so the private field and its construction now share
  the same `cfg(any(test, feature = "test-helpers"))` gate, replacing an
  `#[allow(dead_code)]`. A minimal `oauth` build allocates one fewer HTTP
  client and connection pool. **No observable behavior change:** every
  misconfiguration error (SSRF allowlist compile, `ca_cert_path` read,
  `ca_cert_path` parse) is raised by the shared pre-work and `make_base()`,
  which the credential client still executes. The two
  `ClientBuilder::build()` failure labels were unified to
  `"oauth http client init: {e}"` so the startup error text is identical
  whether or not `inner` is compiled in; `"oauth credential client init"`
  is retired and was never independently reachable, since both clients
  consume the same base configuration.

### Fixed (tooling)

- **`cargo clippy --features oauth` (without `test-helpers`) now passes.**
  It previously failed with four `-D warnings` errors - two
  `clippy::clone_on_copy` and two `clippy::unit_arg` - because
  `TestLoopbackBypass` aliases to `()` outside test builds and the existing
  `#[allow]` covered only `clippy::clone_on_ref_ptr`. CI did not catch this:
  the `clippy` job runs `--all-features` only, and the `features-matrix` job
  runs `cargo build`/`cargo test` but not `cargo clippy`.
- **CI now lints every shipped feature combination.** A
  `cargo clippy --all-targets $FEATURES -- -D warnings` step was added to the
  feature matrix in both `.github/workflows/ci.yml` and `.gitlab-ci.yml`
  (as `feature-matrix:clippy`), covering default, `--no-default-features`,
  `--features oauth`, `--features metrics`, and `--all-features`. This closes
  the blind spot that allowed the lint failure above to go unnoticed.

### Documentation

- `RUST_GUIDELINES.md` now tracks stable Rust through **1.98**, and
  `AGENTS.md` documents the test-tier taxonomy (unit / property /
  integration-pure / integration-mocked / e2e / perf) together with the
  per-file `#![cfg(...)]` feature gates.
- The constant-time documentation on `verify_bearer_token` no longer claims
  full branchlessness. The guarantee the implementation actually provides is
  exactly one Argon2id verification per configured key regardless of which slot
  matches or whether one is expired; selecting the verification target and
  recording the matched index remain ordinary data-dependent branches on
  locals, dwarfed by the Argon2id cost. No behaviour change.
- Stale `src/` line-number citations in `AGENTS.md`, `docs/ARCHITECTURE.md`,
  and `docs/MINDMAP.md` repointed to their current locations.

## [3.2.0] - 2026-08-21

### Changed

- **MSRV raised to Rust 1.98.0** (from 1.95.0). The `rust-version` manifest
  field, the GitHub Actions MSRV gate, and the GitLab CI image now target
  1.98.0, matching the stable toolchain CI already runs. Per the project's
  SemVer policy an MSRV bump is a minor-version change. No public API change.

## [3.1.3] - 2026-08-19

### Changed

- **`OAuthConfig::{issuer, audience, jwks_uri}` are now `#[serde(default)]`.**
  A partially-specified `[server.auth.oauth]` table - e.g. one carrying only
  `role_claim`/`role_mappings`, with the URL and audience fields supplied by a
  downstream env-override layer applied after TOML parsing - now deserializes
  instead of failing at parse time on a serde "missing field" error. The three
  fields are enforced at [`OAuthConfig::validate`] time instead
  (parse-don't-validate): empty `issuer` and `jwks_uri` were already rejected by
  the HTTPS URL check, and `validate()` now **also rejects an empty `audience`**
  (`oauth.audience must not be empty`). Previously `audience` was not validated,
  so an empty value passed config validation and then failed closed silently at
  runtime under the default `AudienceValidationMode::Strict` (nothing matches an
  empty audience). Purely additive: configs that specify all three fields parse
  and validate exactly as before; the only observable change is that omitting one
  from a present `[oauth]` table now surfaces as a clear `validate()` error
  rather than a serde parse error.

## [3.1.2] - 2026-08-19

### Changed

- Maintenance release: **no library code or public-API changes since 3.1.1**
  (`cargo-semver-checks` reports no break). Refreshes the dependency lockfile
  to the latest semver-compatible versions - notably `ref-cast` 1.0.27 - and
  re-validates the crate against the current dependency set. Because
  `Cargo.lock` is not shipped for a library, consumers resolve their own
  dependency versions, so this release is behaviorally identical to 3.1.1.

## [3.1.1] - 2026-08-01

### Fixed

- **`cargo build --features oauth` no longer fails under `-D warnings`.** In a
  minimal `oauth` build (without `oauth-mtls-client` or `test-helpers`) the
  internal screened-redirect HTTP client was constructed but never read,
  tripping the `dead_code` lint under `RUSTFLAGS="-D warnings"`. The full
  feature matrix now builds cleanly with warnings denied.

  *Note:* the 3.1.0 tag was never published to crates.io - its release build
  failed on the issue above - so this is the first published release of the
  3.1.x line and includes all of the 3.1.0 changes below.

## [3.1.0] - 2026-08-01

> **⚠️ Behavioral changes - please read before upgrading.** This release hardens
> several security-relevant runtime *defaults*. Because the crate's major version
> tracks the `rmcp` SDK, these ship in a minor release; **no public API is removed
> or retyped** (`cargo-semver-checks` reports no break), but the defaults below
> change. Each has a config opt-out to restore the prior behavior.
>
> **1. OAuth JWT audience validation now defaults to `strict`.** A token whose
> configured audience appears only in the `azp` claim (not `aud`) is now
> **rejected**; previously the default (`warn`) accepted it with a one-shot
> warning. If your IdP issues `azp`-only tokens and cannot yet be reconfigured,
> restore the pre-3.1 behavior with `audience_validation_mode`:
>
> ```toml
> [server.auth.oauth]
> issuer   = "https://idp.example.com/"
> audience = "https://mcp.example.com/mcp"
> jwks_uri = "https://idp.example.com/.well-known/jwks.json"
>
> # Restore pre-3.1 acceptance of azp-only tokens (pick one):
> audience_validation_mode = "warn"        # accept azp-only, one-shot WARN per process
> # audience_validation_mode = "permissive" # accept azp-only silently
> ```
>
> The deprecated `strict_audience_validation = false` also still maps to `"warn"`.
>
> **2. `/version` no longer exposes build metadata to anonymous callers.**
> `build_git_sha`, `build_timestamp`, and `rust_version` are suppressed by
> default. Re-enable them with `[server]``expose_build_metadata = true`.
>
> **3. `trusted_proxies` rejects a `/0` CIDR.** A `0.0.0.0/0` or `::/0` entry now
> fails validation at startup - narrow it to your real proxy ranges, e.g.
> `[server]``trusted_proxies = ["10.0.0.0/8"]`.
>
> **4. OAuth `/introspect` and `/revoke` require the admin role.** When
> `[server.auth.oauth.proxy]``require_auth_on_admin_endpoints = true`, an
> authenticated non-admin caller now receives `403`. Grant the caller the
> configured `[server]``admin_role` (default `"admin"`).
>
> **5. JWKS key selection is fail-closed on `kid`.** A `kid`-bearing JWT must
> match a *named* JWKS key; it no longer falls back to an unnamed key of the same
> algorithm. Affects only non-standard IdPs that mint `kid`-bearing tokens against
> a `kid`-less JWKS.
>
> For mTLS deployments, the CRL cache/verifier fix and the `crl_stale_grace`> `crl_retry_retention` rename (the old key still works as an alias) affect only
> the opt-in `crl_deny_on_unavailable = true` path.

### Security

- **OAuth audience validation now defaults to `strict`.** A token whose
  configured audience appears only in the `azp` claim (not `aud`) is now
  **rejected by default**; previously the default (`warn`) accepted it with a
  one-shot warning. **Behavioral change.** To keep the previous behavior, set
  `audience_validation_mode = "warn"` (accept `azp`-only with a one-shot
  warning) or `"permissive"` (accept silently). The deprecated
  `strict_audience_validation` flag is now `Option<bool>`: `Some(false)` maps to
  `warn`, `Some(true)`/unset map to `strict`. (rust-review HIGH / H1.)
- **OAuth JWKS cache now fails closed when a refresh cannot succeed.** After a
  failed or cooldown-suppressed JWKS refresh, an expired cache no longer serves
  a stale (possibly rotated-out) signing key: the post-refresh key lookup now
  re-applies the same freshness gate as the initial lookup, so a token whose
  `kid` exists only in the expired cache is rejected rather than accepted.
  (rust-review HIGH / H2.)
- **mTLS CRL fail-closed precheck now matches the live verifier cache.** CRL
  cache updates now rebuild the rustls verifier from a candidate cache before
  publishing either the cache or `cached_urls`, preventing the fail-closed
  precheck from advertising CRL URLs the live verifier cannot enforce. The
  precheck is now any-of-N: a certificate with multiple relevant CDPs fails
  fast only when none are cached, while webpki remains the authoritative
  per-certificate revocation decision. `crl_end_entity_only` is respected by
  the precheck, and `crl_retry_retention` is documented as the preferred TOML
  key for retry-retention semantics while `crl_stale_grace` remains a
  backward-compatible alias. (rust-review HIGH / H3.)
- **SSRF: cloud-metadata is now unbypassable through IPv6 transition/compatibility
  forms.** `ip_block_reason` re-labels NAT64 (`64:ff9b::/96`) and 6to4
  (`2002::/16`) addresses embedding a cloud-metadata IPv4 (e.g.
  `64:ff9b::169.254.169.254`) as `cloud_metadata` rather than
  `nat64_embedded`/`6to4_embedded`, so an operator SSRF allowlist covering a
  transition prefix can no longer re-allow the metadata endpoint.
  (rust-review MEDIUM / M2.)
- **SSRF: deprecated IPv4-compatible IPv6 (`::a.b.c.d`, `::/96`) is now blocked.**
  These addresses previously fell through the IPv6 classifier as public; they
  now inherit the embedded IPv4 rule (`::127.0.0.1` → loopback,
  `::169.254.169.254` → cloud_metadata) with the rest of the deprecated prefix
  blocked as defence in depth, matching the guarantee already documented in
  `SECURITY.md`. (rust-review MEDIUM / M3.)
- **`trusted_proxies` now rejects a `/0` prefix.** `0.0.0.0/0` and `::/0` were
  accepted by both the TOML and builder validators; a `/0` trusted proxy marks
  every peer trusted and lets any client spoof the resolved client IP
  (rate-limit bypass, audit poisoning) via forwarding headers. Both validators
  now reject prefix-0 CIDRs via a shared helper, mirroring the SSRF allowlist
  parser. **Behavioral change** - a configuration that relied on a `/0` trusted
  proxy must narrow it to the real proxy CIDRs.
  (rust-review MEDIUM / M1.)
- **OAuth credential POSTs no longer follow redirects.** `/token`, `/introspect`,
  `/revoke`, and the RFC 8693 token-exchange requests now use a dedicated
  `redirect::Policy::none()` client, so a 307/308 from a compromised or
  open-redirecting endpoint can no longer re-send the `client_secret`-bearing
  body to another host. (rust-review MEDIUM / M7.)
- **OAuth JWKS key selection is fail-closed on `kid`.** A JWT carrying a `kid`
  must now match a *named* JWKS key exactly; it no longer falls back to an
  unnamed key of the same algorithm, closing an unknown-`kid` key-selection
  vector. **Behavior change** for non-standard IdPs that mint `kid`-bearing
  tokens against a `kid`-less JWKS. (rust-review LOW / L4.)
- **OAuth targets: internal hostname suffixes rejected pre-DNS.** OAuth/JWKS
  targets whose host ends in `.localhost`, `.local`, or `.internal` are rejected
  before resolution (an exact-host allowlist entry still overrides; a trailing
  FQDN dot is canonicalized). Exact `localhost` is unaffected - already covered
  by the post-DNS IP screen. (rust-review LOW / L3.)
- **Security response headers now cover early and fallback responses.** The
  OWASP header layer (`X-Content-Type-Options`, `X-Frame-Options`,
  `Content-Security-Policy`, and - under TLS - `Strict-Transport-Security`) was
  previously an inner layer, so origin-rejection 403s, CORS-preflight replies,
  overload 503s, and the 404 fallback were emitted without it. It is now the
  outermost response layer and decorates every response. (rust-review MEDIUM /
  M5.)
- **OAuth `/introspect` and `/revoke` now require the admin role.** When
  `require_auth_on_admin_endpoints` is set, these proxy endpoints are gated by
  the configured `admin_role` (default `admin`) in addition to authentication;
  an authenticated non-admin caller now receives `403` instead of reaching the
  upstream. **Behavioral change** for authenticated non-admin callers who could
  previously introspect/revoke tokens. (rust-review MEDIUM / M6.)

### Changed

- **Default `Content-Security-Policy` hardened** to `default-src 'none';
  form-action 'self'; object-src 'none'; frame-ancestors 'none';
  upgrade-insecure-requests` (was `default-src 'none'; frame-ancestors 'none'`).
  Operators that set a custom `content_security_policy` are unaffected.
  (rust-review LOW / L1.)
- **`X-Forwarded-For` / `Forwarded` node ports are validated.** A forwarding
  entry with an empty or non-numeric port (`203.0.113.7:`,
  `[2001:db8::1]:notaport`) is now treated as malformed, so resolution falls
  back to the direct peer instead of trusting an ambiguous identifier.
  (rust-review LOW / L2.)
- **Lint posture:** added `large_stack_arrays`, `large_stack_frames`,
  `ptr_as_ptr`, and `cast_lossless` (warn) per RUST_GUIDELINES.md §9, and
  documented why `RUSTFLAGS="-D warnings"` (not Cargo `build.warnings`, which is
  Rust 1.97+) is the correct deny-warnings mechanism at MSRV 1.95.
  (rust-review tooling / T1, T2.)

### Added

- **`OAuthConfig::require_subject`** (opt-in, default `false`). When enabled, a
  JWT without a `sub` claim is rejected. Leave `false` for OAuth
  client-credentials / machine-to-machine tokens, which legitimately carry no
  subject. (rust-review LOW / L4.)
- **`McpServerConfig::expose_build_metadata`** (opt-in, default `false`). The
  unauthenticated `/version` endpoint now serves only `name`, `version`, and
  `mcpx_version` by default; `build_git_sha`, `build_timestamp`, and
  `rust_version` are included only when this is enabled, so build fingerprints
  are not leaked to anonymous callers. **Behavior change** to the default
  `/version` response shape. (rust-review LOW / L5.)

### Removed

- **`OauthHttpClient::__test_get` is no longer present in default builds.** This
  hidden, test-only accessor was `#[doc(hidden)] pub` but ungated; because a
  literal-IP target performs no DNS lookup, the `SsrfScreeningResolver` never
  fired, making it a reachable SSRF vector in production builds. It is now gated
  behind `#[cfg(any(test, feature = "test-helpers"))]` like its sibling
  helpers, so it is absent from the default public API and remains available
  only under the `test-helpers` feature for downstream integration tests.
  (rust-review MEDIUM / M4.)

## [3.0.0] - 2026-07-30

### Changed

- **BREAKING: migrated to `rmcp` 3.0** (was 2.x). Consumers depend on
  `rmcp` directly, so bump your own `rmcp = "2"` to `rmcp = "3"` in
  lockstep. Handlers that override `call_tool` / `get_prompt` /
  `read_resource` must switch their return types to the new MRTR-aware
  enums (`CallToolResponse` / `GetPromptResponse` / `ReadResourceResponse`)
  - wrap an existing result with `.into()`; handlers using the default
  `ServerHandler` need only the version bump. Internally `HookedHandler`
  now returns `CallToolResponse` and passes MRTR `InputRequired`/`Task`
  responses through untouched (the result-size cap still applies to
  completed results). rmcp 3.0's MSRV is 1.88 (our 1.95 target is
  unaffected). See [`docs/MIGRATION.md`]docs/MIGRATION.md.

## [2.1.1] - 2026-07-29

### Changed

- **Dependency major-version upgrades.**
  - `tower-http` 0.6 → 0.7. The only call-site change is
    `compression::predicate::SizeAbove::new`, which now takes `u64`
    (previously `u16`).
  - `jsonwebtoken` 10 → 11 (behind the `oauth` feature). `jwk::KeyAlgorithm`
    is now `#[non_exhaustive]`; already covered by the existing wildcard arm
    in `jwk_algorithm`.
  - `base64` 0.22 → 0.23, pinned to `default-features = false,
    features = ["std"]` so the new default-on `simd-unsafe` engine is not
    compiled in, preserving the prior std-only, `unsafe`-free behavior.

## [2.1.0] - 2026-07-05

### Security

- **OAuth proxy endpoints now honor the configured `max_request_body`.**
  The `/token`, `/register`, `/introspect`, and `/revoke` proxy routes
  previously fell back to axum's 2 MB `DefaultBodyLimit` because the
  `RequestBodyLimitLayer` was scoped to `/mcp` only. They are now built on a
  dedicated sub-router that applies the operator-configured limit (via
  `RequestBodyLimitLayer`), so a single `max_request_body` value governs all
  inbound bodies. Oversized requests to these routes now return `413`.
  (rust-review MEDIUM.)
- **Upstream OAuth proxy responses are now size-capped.** `handle_token`,
  the introspect/revoke admin proxy, and the RFC 8693 token-exchange path
  now read upstream responses through a bounded, fail-closed streaming
  helper (`OAUTH_PROXY_MAX_RESPONSE_BYTES`, 1 MiB) instead of an unbounded
  `resp.bytes()`. An oversized or unreadable upstream response yields a
  generic `502` and is never forwarded to the client - symmetric with the
  already-bounded JWKS fetch. (rust-review LOW.)

### Added

- **`McpxError::client_message()`** - returns the exact client-facing body
  for any variant (verbatim message for `Auth`/`Rbac`/`RateLimited`/
  `RateLimitedFor`; a generic `"internal server error"` for all internal
  variants). Additive; documents and exposes the client-safe-message
  invariant that `IntoResponse` already upholds. (rust-review LOW hardening.)

### Changed

- **JSON logs no longer emit escaped `Debug` wrappers for claim/identity
  fields.** Structured fields that were logged with `?` on quote-bearing
  types (`Option<String>`, `serde_json::Value`, audience lists) now render
  as clean native strings - e.g. `"sub":"alice"` instead of
  `"sub":"Some(String(\"alice\"))"`. Added internal helpers
  (`fmt_json_aud`, `fmt_json_str`, `OneOrMany::log_display`,
  `AudienceValidationMode::as_str`); the `aud` claim is formatted so
  string-or-array audiences are preserved without loss. Log levels, fields,
  and the (stderr) output stream are unchanged. The deliberate `Debug`
  rendering of unparseable attacker-supplied CDP URLs (log-injection
  defense) is intentionally retained.

### Documentation

- Annotated the request-path async fns (`authenticate_bearer_identity`,
  `validate_token_with_reason`, `decode_claims`, `find_key`,
  `refresh_with_cooldown`, `refresh_inner`) with mandated cancel-safety
  comments (RUST_GUIDELINES §5). `JwksCache::refresh_with_cooldown` is
  documented as **NOT cancel-safe by design**: it commits the refresh
  cooldown before fetching to throttle JWKS-endpoint abuse, accepting a
  ≤10 s post-cancellation false-negative window during key rotation rather
  than reopening the invalid-JWT → JWKS-refresh DoS vector. (rust-review
  LOW; behavior unchanged.)

## [2.0.0] - 2026-07-04

### Changed

- **BREAKING: Bumped the `rmcp` SDK from 1.8 to 2.1** - a major
  ([rmcp-v1.8.0...rmcp-v2.1.0]https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.8.0...rmcp-v2.1.0).
  rmcp 2.0 realigns the Rust API with the MCP 2025-11-25 spec. The **JSON
  wire format is unchanged** (only additive `_meta` / optional fields);
  all breakage is at the Rust API level. In-crate this touched exactly the
  unified content model: `rmcp::model::Content` / `RawContent` are replaced
  by the flat `ContentBlock` (the `Annotated<T>` wrapper and the `.raw`
  accessor are gone). We updated `Content::text(...)``ContentBlock::text(...)`
  and the `matches!(&result.content[N].raw, RawContent::Text(_))` test
  assertions → `matches!(&result.content[N], ContentBlock::Text(_))` in
  `src/tool_hooks.rs`, `tests/e2e.rs`, and `benches/hook_latency.rs`.
  `ServerHandler`, `ServerInfo` / `ServerCapabilities` (already built via
  the builder), `StreamableHttpService`, `StreamableHttpServerConfig`,
  `ServiceExt`, `transport::io::stdio()`, and the requested feature set
  (`server`, `transport-streamable-http-server`, `transport-io`, `macros`)
  are all source-compatible and unchanged. rmcp 2.1 (over 2.0) is a
  drop-in with fixes only (SEP-414 trace-context accessors, SEP-2575 meta
  helpers, cancel-safe `AsyncRwTransport::receive`, OAuth refresh-token
  preservation) - none of which this crate consumes directly. No MSRV
  change (rmcp pins nothing above our Rust 1.95 floor).

  **Why this is a major for `rmcp-server-kit`:** rmcp types appear in this
  crate's public API (`HookedHandler<H: ServerHandler>` and its
  `ServerHandler` impl, `HookOutcome::Replace(Box<CallToolResult>)`, and
  other hook signatures returning/consuming `CallToolResult`). Downstream
  code that pattern-matches on the re-exported content model must migrate
  `RawContent`/`.raw``ContentBlock`. (`cargo semver-checks` reports no
  change to *our* signature names, but it cannot see the semantic break in
  the re-exported rmcp types, so the next release is bumped to **2.0.0**
  deliberately.)

- **`tower-http` intentionally stays on 0.6** (currently 0.6.11, the latest
  0.6). 0.7.0 is available but `reqwest` (which we depend on directly) and
  `axum 0.8` both hard-pin `tower-http = "^0.6.8"`, so upgrading our direct
  dependency to 0.7 would only fork a second `tower-http` major into the
  tree with no functional benefit - we use no 0.7-only feature, and the one
  0.7 API delta that would touch us (`SizeAbove` threshold `u16``u64`)
  is a cost, not a gain. This will be revisited once `reqwest`/`axum`
  admit `tower-http 0.7`. Other dependencies were refreshed to their latest
  semver-compatible patches (`humantime` 2.3→2.4, `rand` 0.10.1→0.10.2,
  `rustls-pki-types` 1.14→1.15, `time` 0.3.51→0.3.53, `num-bigint`
  0.4.6→0.4.7).

## [1.15.0] - 2026-06-29

### Changed

- **Bumped the `rmcp` SDK from 1.7 to 1.8.** Non-breaking for this crate
  ([rmcp-v1.7.0...rmcp-v1.8.0]https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0):
  `ServerHandler`, `StreamableHttpService`, `call_tool` / `CallToolRequestParams`,
  and the `handler::server` module are all source-compatible. rmcp 1.8 adds
  transparent server-side hardening that flows through `serve()` unchanged -
  `MCP-Protocol-Version` header vs initialize-body validation, stricter tool
  input/output-schema stripping, and SEP-2164 resource-not-found code
  selection (peers on `2026-07-28`+ get `INVALID_PARAMS`, older peers keep
  `RESOURCE_NOT_FOUND`). SEP-2577 deprecates Roots/Sampling/Logging in the SDK;
  this crate does not use those APIs, so no warnings surface. `tower-http`
  stays on 0.6 (reqwest 0.13.4 still pins `^0.6.8`; 0.7 would only fork the
  tree without benefit). Other dependencies refreshed to latest semver-compatible
  patches (anyhow, rustls, bytes, zeroize, time, uuid, …).

## [1.14.0] - 2026-06-11

### Added

- **Exempt paths for the extra-route rate limiter**
  ([#11]https://github.com/andrico21/rmcp-server-kit/issues/11):
  `McpServerConfig::with_extra_route_rate_limit_exempt_paths(paths)` +
  TOML `server.extra_route_rate_limit_exempt_paths`. Entries are matched
  by **raw exact string comparison** against the request path - no
  globs, no normalization - and the check is fail-closed (anything not
  listed stays limited) and runs before key extraction, so exempt
  requests (e.g. the RFC 8414 metadata document MCP clients fetch on
  every connect) consume no limiter budget and never appear in deny
  telemetry. Requires `extra_route_rate_limit`; entries must be
  non-empty and start with `/` (validated at startup).
- **Prometheus deny counters for all four built-in rate limiters**
  ([#11]https://github.com/andrico21/rmcp-server-kit/issues/11,
  feature `metrics`): new `McpMetrics.rate_limited_total`
  (`rmcp_server_kit_rate_limited_total`, label `limiter` in `tool` /
  `auth_pre` / `auth_post` / `extra_route`), incremented at each deny
  site alongside the existing warn-level log. The shared `McpMetrics`
  handle now travels to inner middleware via a request extension
  inserted by the metrics layer; with metrics disabled the deny sites
  are unchanged. `McpMetrics` is `#[non_exhaustive]`, so the field
  addition is semver-minor.

### Security

- **CRL URL gate now rejects embedded credentials (userinfo), and
  rejection sites no longer echo what they reject.** The shared
  scheme guard used by CDP extraction and the CRL fetcher refuses
  `https://user:pass@host/...` URLs (`userinfo_forbidden`) - making the
  userinfo rejection that `SECURITY.md` already documented actually
  enforced - and both rejection sites log only a sanitized
  scheme+host+port rendering, so credentials from a CA chain's or client
  certificate's CDP extension can never reach warn logs or error
  strings. Found by the 1.13.0 guidelines review; fix design approved by
  Oracle and Momus review gates.
- **OAuth redirect-rejection warns no longer echo the rejected target
  URL.** Both redirect-policy closures (`OauthHttpClient`, `JwksCache`)
  log the sanitized scheme+host+port instead of the full target, so a
  malicious IdP redirecting to a userinfo-bearing URL cannot plant
  credentials in the logs.

### Fixed

- **Unparsed CDP URIs are debug-logged with `Debug` formatting** (`?raw`
  instead of `%raw`), escaping control characters an attacker could
  embed in a client certificate's CDP extension to forge log lines or
  emit terminal escapes (the string only reaches this branch when
  `Url::parse` fails, which strips none of the original bytes).

### Documentation

- All non-test `tokio::select!` sites now carry explicit
  `// cancel-safe:` annotations (transport shutdown races, CRL
  bootstrap/refresher loops), completing the crate's cancel-safety
  documentation mandate.
- New `SECURITY.md` subsection "CRL discovery under adversarial load":
  documents the pre-validation CDP discovery invariant, the bounded
  griefing residual and why per-source-IP budgeting is impossible at the
  `ClientCertVerifier` layer, plus operator guidance (alert on
  `discovery_rate_limited`, size `crl_max_seen_urls` /
  `crl_max_cache_entries` to the CA estate, rely on bootstrap
  pre-seeding). The drop-newest-at-cap cache policy rationale (LRU would
  let an attacker evict the legitimate warm set) is now documented at
  the policy site and in `SECURITY.md`.

### Tooling

- **`clippy::nursery` enabled crate-wide at `warn`** (promoted to deny in
  CI via `-D warnings`), with three documented allows:
  `missing_const_for_fn` (const-ness on pub fns is a one-way semver
  promise), `redundant_pub_crate` (antagonistic to the enabled
  `unreachable_pub`), and `option_if_let_else` (readability regressions
  on the HMAC-fallback / poisoned-lock idioms). ~90 nursery findings
  fixed across the crate, including explicit early guard drops on the
  JWKS/CRL cache write paths and doc-link cleanups.
- **`str_to_string` flipped from `allow` to `warn`** and remaining
  violations fixed; `string_to_string` documented as removed from clippy
  (covered by the already-enabled `implicit_clone`).
- **`await_holding_refcell_ref` and `mem_forget` denied** (declared for
  future-proofing; the crate has no current usage of either).
- **`cargo vet` exemption baseline committed** under `supply-chain/`
  (400 exemptions); the CI vet job now runs without `|| true` and can be
  promoted to required after a green run.
- **New `taplo` CI job** (`taplo fmt --check`); all tracked TOML files
  reformatted to the repo's `.taplo.toml` policy, which now excludes the
  cargo-vet-owned `supply-chain/` directory.

## [1.13.0] - 2026-06-11

### Added

- **Trusted-forwarder mode: proxy-aware client IPs for all rate
  limiters** (completes the final deferred item from
  [#10]https://github.com/andrico21/rmcp-server-kit/issues/10):
  `McpServerConfig::with_trusted_proxies(cidrs)` +
  `with_forwarded_header(mode)` (TOML `server.trusted_proxies`,
  `server.forwarded_header` = `"x-forwarded-for"` default /
  `"forwarded"` for RFC 7239). When the **direct peer** is inside the
  trusted CIDRs, the client IP is resolved via the rightmost-untrusted
  walk over the last forwarding-header instance (16-entry scan cap;
  malformed/obfuscated/all-trusted chains fall back to the direct peer;
  only reason codes are logged, never header contents). The result is
  the new public `transport::ClientIp` request extension, and **all
  four per-IP rate limiters now key by it** - behind a proxy, clients
  get individual buckets instead of sharing the proxy's. With the
  feature off (default), `ClientIp` equals the direct peer and behavior
  is unchanged. `PeerAddr` keeps its direct-socket-peer contract.
  Headers from untrusted peers are ignored entirely (leftmost-trust is
  never used). New dependency: `ipnet` (MIT OR Apache-2.0).

## [1.12.0] - 2026-06-11

### Added

- **`Retry-After` on every rate-limit response.** All four built-in
  limiters (auth pre-auth gate, post-failure auth limiter, `tools/call`
  limiter, extra-route limiter) now deny with a `Retry-After: n` header
  (RFC 9110 delta-seconds: best-effort wait rounded **up** to whole
  seconds, never `0`) alongside the unchanged 429 status and plain-text
  body. Backed by the new `BoundedKeyedLimiter::check_key_wait` method
  (returns the wait `Duration` on deny; `check_key` now delegates to it)
  and the new `McpxError::RateLimitedFor { message, retry_after }`
  variant. The legacy `McpxError::RateLimited(String)` variant is
  retained and remains headerless. Completes the first deferred item
  from [#10]https://github.com/andrico21/rmcp-server-kit/issues/10.
- **Optional burst knobs on every rate limiter.** Burst sets the bucket
  capacity (maximum requests admitted back-to-back); the sustained
  per-minute rate is unchanged, and burst may be smaller or larger than
  the rate. New surface: `McpServerConfig::with_tool_rate_limit_burst` /
  `with_extra_route_rate_limit_burst` (+ TOML `tool_rate_limit_burst`,
  `extra_route_rate_limit_burst`) and
  `RateLimitConfig::{with_burst, with_pre_auth_burst}` (+ TOML
  `auth.rate_limit.{burst, pre_auth_burst}`). Bursts must be greater
  than zero; the tool/extra-route bursts require their base knob, while
  `pre_auth_burst` is valid without an explicit pre-auth rate (the
  gate's base always resolves to `max_attempts_per_minute × 10`).
  Unset = today's behavior (burst = rate). Completes the second
  deferred item from
  [#10]https://github.com/andrico21/rmcp-server-kit/issues/10.

## [1.11.0] - 2026-06-10

### Added

- **Opt-in per-IP rate limiting for `with_extra_router` routes** (closes
  [#10]https://github.com/andrico21/rmcp-server-kit/issues/10):
  `McpServerConfig::with_extra_route_rate_limit(per_minute)` and the
  matching TOML field `server.extra_route_rate_limit`. When set, the
  application's extra router is wrapped - pre-merge, so the limiter can
  never leak onto `/mcp`, health, admin, or OAuth endpoints - in a
  per-source-IP limiter backed by the same memory-bounded machinery as
  the tool limiter (10,000 tracked keys, 15-minute idle eviction). On
  limit: `429` with a plain-text body, matching the tool/auth limiters
  (no `Retry-After`; adding it uniformly across all limiters is tracked
  separately). Keyed by the direct socket peer (no `X-Forwarded-For`
  interpretation); fails open when no peer address is present; the
  value must be greater than zero (validated at startup); startup-only
  (not hot-reloadable).

## [1.10.0] - 2026-06-10

### Added

- **Uniform client peer-address exposure for application routes**
  (requested by a downstream consumer running chained-OAuth endpoints on
  `with_extra_router` under direct TLS):
  - New public `transport::PeerAddr` request extension (`#[non_exhaustive]`,
    `Copy`/`Eq`/`Hash`) carrying the direct socket peer address, inserted
    on **both** the plain and the TLS listener and extractable via its
    `FromRequestParts` impl or `Extension<PeerAddr>` - including from
    `with_extra_router` routes, which bypass auth/RBAC. Direct peer only
    (no `X-Forwarded-For` interpretation); absent under `serve_stdio`;
    never logged by the framework.
  - The TLS listener now also mirrors the peer address into the standard
    `axum::extract::ConnectInfo<SocketAddr>` extension (insert-only-when-
    absent), so stock per-IP middleware (e.g. `tower_governor`'s
    `PeerIpKeyExtractor`) works unmodified on direct-TLS deployments
    instead of failing every request. `TlsConnInfo` (and the mTLS
    identity it carries) remains private and connection-bound.

## [1.9.0] - 2026-06-10

### Added

- **TLS accept-path tuning knobs** (closes
  [#9]https://github.com/andrico21/rmcp-server-kit/issues/9):
  `McpServerConfig::with_tls_handshake_timeout(Duration)` and
  `McpServerConfig::with_max_concurrent_tls_handshakes(usize)`, plus the
  matching TOML fields `server.tls_handshake_timeout` (humantime string)
  and `server.max_concurrent_tls_handshakes`. Defaults are unchanged
  (10 s / 256). Both values must be greater than zero (validated in
  `McpServerConfig::validate` and `validate_server_config`) and are
  **startup-only** - they bind at listener construction and do not
  participate in `ReloadHandle` hot reload. The completed-handshake
  channel capacity remains internal.

### Fixed

- **Removed markdown backticks from the azp-only audience deprecation
  warning.** The one-shot `tracing::warn!` emitted in
  `AudienceValidationMode::Warn` carried rustdoc-style backticks into
  terminal/JSON log output; the message is now plain text, matching the
  crate's logging style. No behavior change.

## [1.8.2] - 2026-06-10

### Security

- **The SSRF IP range guard now classifies IPv6 transition prefixes.**
  NAT64 (`64:ff9b::/96`, RFC 6052) and 6to4 (`2002::/16`, RFC 3056)
  addresses are blocked when the IPv4 address they embed is itself
  blocked (closing e.g. `64:ff9b::10.0.0.1` reaching internal RFC 1918
  space through a NAT64 gateway) while remaining permitted for embedded
  public addresses, so DNS64/NAT64-only egress networks keep working.
  Teredo (`2001::/32`, RFC 4380) is blocked outright. Applies to both the
  CRL and OAuth/JWKS fetch paths; see SECURITY.md "IPv6 transition
  prefixes".

### Changed

- **`JwksCache::new` returns an error instead of panicking when
  `jwks_cache_ttl` is not a valid humantime duration.** The documented
  panic existed only for unvalidated configs (the `OAuthConfig::validate`
  pipeline rejects invalid TTLs up front); the function signature already
  returned `Result`, so the failure now surfaces through it.
- **Deduplicated OAuth SSRF target screening (internal).** The screening
  logic previously existed twice: a test-instrumented copy and a
  byte-identical production copy compiled only under
  `cfg(not(any(test, feature = "test-helpers")))` - meaning the test suite
  never compiled the production branch and a future edit could silently
  diverge the two. Both paths now delegate to one shared core
  (`screen_oauth_target_core`) compiled identically under all cfgs, with
  the loopback bypass plumbed as a parameter that production hardcodes to
  `false`. No behavior change; error messages are byte-identical.
- **Lint hardening (internal):** enabled `clippy::string_slice` (warn,
  escalated to deny in CI) and pinned `clippy::await_holding_lock` to
  deny. Manual `&str[range]` slicing in the RBAC glob matcher and the
  origin auto-derivation was rewritten with checked `get(..)` accessors -
  behavior is unchanged under the existing char-boundary invariants, and
  a future invariant violation now degrades to a non-match instead of a
  panic.
- **Corrected the `log_format` field documentation** to list all three
  accepted values (`json`, `pretty`, `text`) and the actual default
  (`pretty`); the validator already accepted all three.

### Fixed

- **TLS accept loop no longer serializes handshakes (idle-connection
  denial of service).** `TlsListener::accept` previously performed each
  TLS handshake inline before accepting the next connection, so a single
  idle TCP connection (e.g. `nc host 8443` sending no bytes) stalled ALL
  new connections indefinitely. TCP accepts and TLS handshakes now run on
  a dedicated background task that spawns each handshake onto its own
  worker, bounded by a 256-handshake in-flight cap (with kernel-backlog
  backpressure at saturation) and a 10-second per-handshake timeout. The
  handshake-time mTLS identity extraction and its binding to the
  connection stream are unchanged.
- **CRL timestamps outside the platform-representable `SystemTime` range no
  longer panic the CRL refresher.** `thisUpdate`/`nextUpdate` values are
  parsed from raw fetched CRL bytes before signature validation, so they are
  attacker-controlled; a pre-1601 timestamp (unrepresentable by Windows
  `SystemTime`) previously panicked the spawned refresher task, silently
  halting CRL discovery and refresh for the process lifetime. Conversion now
  uses checked arithmetic and clamps unrepresentable or absurd values toward
  `UNIX_EPOCH` - the safe direction (a clamped timestamp can only make a CRL
  look older, forcing an eager refresh, never fresher).
- **The per-host CRL fetch semaphore cap no longer permanently locks out new
  CRL hosts.** Previously, once `crl_max_host_semaphores` (default 1024)
  distinct CRL hosts had ever been seen, fetches for any NEW host failed
  with `crl_host_semaphore_cap_exceeded` until process restart - an
  attacker presenting client certificates with unique CDP hostnames could
  poison the map permanently. At the cap, idle entries (no in-flight fetch)
  are now evicted on demand; the cap error remains only for genuinely
  concurrent fetch floods across `crl_max_host_semaphores` distinct hosts.


## [1.8.1] - 2026-06-05

### Changed

- **Renamed build-time environment variables to match the crate name.**
  The `/version` endpoint now reads `RMCP_SERVER_KIT_BUILD_SHA`,
  `RMCP_SERVER_KIT_BUILD_TIME`, and `RMCP_SERVER_KIT_RUSTC_VERSION` (via
  `option_env!`) instead of the legacy `MCPX_BUILD_SHA`,
  `MCPX_BUILD_TIME`, and `MCPX_RUSTC_VERSION` names. Build pipelines that
  populate these variables at compile time must update their CI / build
  scripts; otherwise the affected `/version` fields silently fall back to
  `"unknown"`. The runtime JSON shape (`build_git_sha`, `build_timestamp`,
  `rust_version`, `mcpx_version`) and all public API surface are
  unchanged.

## [1.8.0] - 2026-06-04

### Changed

- **Raised the minimum supported `rmcp` version from `1.5` to `1.7`.** The
  crate is built and tested exclusively against `rmcp 1.7.x` in CI, so the
  declared floor now matches the version actually exercised rather than
  claiming support for a range that CI never verifies. The public API is
  unchanged and the code still compiles and passes the full test suite
  against `rmcp 1.5.0`; this bump tightens the dependency requirement only.
  Downstream consumers pinned below `rmcp 1.7` must update their own `rmcp`
  requirement accordingly.

## [1.7.7] - 2026-06-04

### Dependencies

- **Bumped the `shlex` constraint `1.3``2`.** The RBAC argument-allowlist
  splitter consumes only `shlex::split`, whose behaviour is identical across
  the two lines; shlex 2.0 merely *removed* the deprecated `quote`/`join`
  APIs (subject of RUSTSEC-2024-0006) and an unsound `DerefMut` impl, none of
  which this crate uses. The bump collapses the duplicate `shlex` copy that
  was otherwise pulled in transitively (via `cc`), so the resolved graph now
  carries a single `shlex 2.0.1`. The `rbac` tokenization regression suite
  (`src/rbac.rs`) passes unchanged, confirming behaviour parity.
- **Validated the crate against the latest semver-compatible dependency
  versions** (minor/patch only; `Cargo.lock` remains intentionally untracked
  for this library crate). Confirmed clean against notable upstream releases
  including the `rmcp` MCP SDK and `rmcp-macros` `1.5.0 → 1.7.0`, `rustls`
  `0.23.38 → 0.23.40`, `tower-http` `0.6.8 → 0.6.11`, `jsonwebtoken`
  `10.3.0 → 10.4.0`, `reqwest` `0.13.3 → 0.13.4`, `hyper` `1.9 → 1.10.1`, and
  `tokio` `1.52.1 → 1.52.3`. Full build, Clippy (`-D warnings`), and the
  complete test suite (`--all-features`) all pass unchanged.

## [1.7.6] - 2026-05-20

### Security / Hardening

- **`SeenIdentitySet` is now memory-bounded** (M2). The internal
  first-seen-identity log-dedup table in `src/auth.rs` previously used
  an unbounded `Mutex<HashSet<String>>`, which grew with attacker-
  influenced identity churn (mTLS SAN/CN or OAuth `sub`) until process
  exit. Replaced with a bounded FIFO set capped at 4096 entries
  (~256 KiB at 64-byte names). Poison-tolerant `Mutex` with explicit
  `SAFETY:` rationale. Honest clients never trigger eviction; hostile
  churn is bounded. Internal type, no public API change.

### Quality / lint hygiene

- **Spelled out test fixtures** (M1). Replaced 9 `..Default::default()`
  shorthand uses across `src/auth.rs` and `src/transport.rs` test modules
  with explicit per-field initialisation, making the assertions readable
  without cross-referencing the type's `Default` impl.
- **Demoted speculative `TODO(refactor):` markers to `NOTE:`** (L1) at
  `src/rbac.rs:647` and `src/transport.rs:850` - these are documented
  design trade-offs, not pending work.
- **Added `reason = "..."` justifications** to remaining `#[allow]` /
  `#[expect]` attributes (L2 / L3 / Q5): `src/auth.rs:1031`,
  `src/transport.rs:2124`, `src/oauth.rs:2376`, plus the test-module
  inner attributes in `src/config.rs`, `src/metrics.rs`,
  `src/observability.rs`, `src/cancel.rs`, and the crate-level
  `#![cfg_attr(test, allow(...))]` in `src/lib.rs`.
- **Added `clippy::panic_in_result_fn` to the crate-level test-only
  allow list** (Q8) in `src/lib.rs` as cheap future-proofing for
  `Result`-returning `#[tokio::test]` bodies.
- **`SAFETY:` comment** (M3) added to the `Mutex` poison-recovery path
  in `SeenIdentitySet::insert_is_first` explaining why continuing past
  poison preserves correctness.
- **Removed unused `use std::sync::Mutex`** in `src/admin.rs` (bonus
  cleanup surfaced during M2).

### Docs

- **Clarified `SeenIdentitySet` as FIFO, not LRU** (Q3). The type's
  rustdoc and the call-site comment in `AuthState::log_auth` now
  consistently say "bounded FIFO set" instead of the previously vague
  "LRU-style". Added a unit test
  (`seen_identity_set_fifo_does_not_refresh_on_repeat_hit`) that locks
  in the FIFO contract by asserting repeat hits do **not** bump an
  entry's eviction position.
- **Clarified the global CRL discovery limiter** (Q13) at
  `src/auth.rs:467-477` and `src/mtls_revocation.rs:117-125`. The
  comments now explicitly note that this limiter is **distinct** from
  the bearer pre-auth limiter (which is already keyed per-IP via a
  bounded keyed governor in the ordinary request middleware path).
- **Scoped the typed pre-tokenized argument matcher** (Q18) as a
  `NOTE(future-pr):` design block above `ArgumentAllowlist` in
  `src/rbac.rs`. Captures Oracle-approved scope: keep public
  `ArgumentAllowlist` shape stable, add a private compiled IR owned by
  `RbacPolicy::new`, with a required equivalence test matrix.
- **Marked the deferred `#[must_use]` on `with_hooks`** (Q15) with a
  `NOTE(next-minor):` comment in `src/tool_hooks.rs:239` so the next
  minor-bump owner finds the deferred semver-minor change.

## [1.7.5] - 2026-05-20

### Changed

- **Lints: tightened `clippy::expect_used` from `allow` to `deny`** at the
  crate level. The five legitimate production `.expect()` sites
  (`auth.rs` `DUMMY_PHC_HASH` PHC string construction, fixed-salt Argon2
  hash; `oauth.rs` re-parsing the already-validated `jwks_cache_ttl`;
  `rbac.rs` HMAC key construction from a 32-byte SHA-256 digest) now
  carry per-site `#[allow(clippy::expect_used, reason = "...")]`
  attributes that pin the safety argument next to the call. Closes the
  asymmetry where `unwrap_used = "deny"` was bypassable via `.expect()`
  with no machine-checked justification. Existing test files already
  carry the `expect_used` allow at file scope; one (`oauth_url_validation.rs`)
  was updated to match the convention.

- **API: removed `impl Deref<Target = T> for Validated<T>`** in
  `transport.rs`. `Validated<T>` is a typestate proof-of-validation
  newtype; exposing `Deref` made the validation marker easy to lose at
  call sites via implicit auto-deref. Use [`Validated::as_inner`] for
  read-only borrowing or [`Validated::into_inner`] to recover the raw
  value. The two `serve()` variants already called `into_inner()`
  immediately, so the change is observable only through the test
  helper and any downstream caller that wrote `*validated` or
  `validated.<field>` instead of `validated.as_inner().<field>`.

  **Migration**: replace `*validated` / `&*validated` with
  `validated.as_inner()`, and `validated.<field>` with
  `validated.as_inner().<field>`. The doc-comment on `Validated`
  reflects the new access pattern.

- **Lint attributes: upgraded four `#[allow(clippy::...)]` allows to
  `reason = "..."` form** in `rbac.rs` (`rbac_middleware`),
  `transport.rs` (`build_app_router`, `serve_stdio`), and `oauth.rs`
  (`select_jwks_key`). The justifications previously lived in adjacent
  comments only; they are now attached to the attribute itself so they
  travel with the suppression in lint reports.

- **CI: re-enabled the `cargo-semver-checks` job on pull requests.** Disabled
  for the 1.6.0 H3 break (`Option<String>` -> `Option<RfcTimestamp>` on
  `ApiKeyEntry::expires_at`); the intentional break shipped, became the
  published baseline on crates.io, and was followed by purely additive
  releases (1.7.4 added the `cancel` module and `McpxError::RetryableTimeout`).
  Locally verified clean against the published baseline (222 checks pass,
  no semver update required).

## [1.7.4] - 2026-05-19

### Added

- **`cancel` module: `run_with_cancel_and_timeout` for cancel-safe
  tool handlers.** Solves the "drop mid-`.await`" hazard when a
  `tokio::select!` arm racing `CancellationToken::cancelled()` or
  `tokio::time::sleep(timeout)` wins against a long-running future
  that owns a remote-side resource (SSH channel, in-flight HTTP
  body, DB transaction). Spawning the future onto `tokio::spawn`
  first and racing the `JoinHandle` (without `.abort()`) lets the
  inner future complete its own cleanup path while the caller
  returns cancel/timeout to the client immediately. `DetachOutcome`
  is `#[non_exhaustive]` and `#[must_use]`. The originating
  tracing span is preserved via `.instrument(Span::current())`.
  Task-local RBAC scope is intentionally NOT propagated into the
  detached task -- detached work should finish or close
  already-authorized resources rather than initiate fresh
  RBAC-gated operations; the module-level `# Caveats` rustdoc
  shows how to capture and rebind RBAC context for callers that
  genuinely need it. Originally implemented in the downstream
  `podmcp` crate to close that crate's M-6 deferred-audit finding.

## [1.7.3] - 2026-05-15

### Changed

- **Deps: routine dependency refresh.** Bumped runtime crates `rmcp`
  `1.6 -> 1.7` (via `cargo update`, semver-compatible),
  `hmac` `0.12 -> 0.13`, `sha2` `0.10 -> 0.11`. The `hmac` 0.13 release
  no longer re-exports `KeyInit` through the `Mac` trait, so
  `src/rbac.rs` was updated to import `hmac::KeyInit` explicitly at the
  single call-site that constructs `Hmac<Sha256>::new_from_slice`
  (HMAC seed for the redaction token derivation). No behavioural
  change, no public API change. Bumped dev/bench-only
  `criterion` `0.5 -> 0.8`; the bench harness uses only stable
  `criterion_group!` / `criterion_main!` / `Criterion::bench_function`
  / `black_box` APIs, so no source changes were required in
  `benches/`. Lockfile also picks up transitive `winnow` `1.0.2 ->
  1.0.3` patch. After this update every direct dependency in the
  manifest is at its latest crates.io stable; remaining lockfile
  duplications (`hmac 0.12+0.13`, `sha2 0.10+0.11`, `thiserror 1+2`,
  `rand 0.8+0.9+0.10`) are transitive-only and pinned by upstream
  leaf crates (`argon2`, `jsonwebtoken`, `rcgen`, `rsa`, `wiremock`,
  `prometheus`). The two `cargo update --verbose --dry-run`
  hold-backs (`crypto-common 0.1.6 -> 0.1.7`,
  `matchit 0.8.4 -> 0.8.6`) are unfixable from this repo:
  `matchit` is exact-version pinned (`=0.8.4`) by `axum 0.8.9` and
  `crypto-common` is held by transitive pins inside the RustCrypto
  v0.10 / `digest 0.10` ecosystem that `jsonwebtoken 10.4.0` and
  `argon2 0.5.3` still target. All 321 unit tests + 29 E2E tests
  pass on Rust 1.95.0 under `--all-features`; clippy clean with
  `-D warnings`; both benches execute end-to-end.

## [1.7.2] - 2026-05-15

### Fixed

- **Test: consolidate the M-H2 env-proxy matrix into a single
  sequential test to eliminate a Windows CI race**
  (`tests/ssrf_resolver.rs`). The six per-variant tests
  (`no_proxy_defeats_*`) each invoked `temp_env::with_var` to mutate
  process-wide environment variables (`HTTP_PROXY` / `HTTPS_PROXY` /
  `ALL_PROXY` upper- and lower-case) before constructing an
  `OauthHttpClient`. Rust's default test runner runs `#[test]` cases
  in parallel threads; the env-var mutations could leak across threads
  and into other concurrently-running tests on Windows runners
  (`Test (windows-latest)` failed on tag `1.7.1`). The matrix now
  runs as one sequential `#[test]` so all six variants are exercised
  without racing parallel tests. Coverage is preserved (still
  asserting `ssrf:` diagnostic for every variant).

## [1.7.1] - 2026-05-15

### Fixed

- **Build: replace runtime-RNG salt for the constant-time Argon2
  placeholder with a fixed salt** (`src/auth.rs`). The
  `DUMMY_PHC_HASH` was previously generated with
  `SaltString::generate(&mut argon2::password_hash::rand_core::OsRng)`,
  which depends on `rand_core 0.6`'s `getrandom` cargo feature being
  activated transitively. That feature is not turned on in any
  configuration of this crate (default, `--features metrics`,
  `--no-default-features`), so the build broke as soon as `argon2`'s
  re-exported `rand_core` was reached by name resolution. Switch to a
  fixed 16-byte salt (`SaltString::from_b64("AAAA...")`); the dummy
  hash never matches real input and is only used as a same-cost
  Argon2 verification target to flatten timing across slots, so salt
  randomness is irrelevant. Closes the post-release CI failure on
  `1.7.0` tag.

## [1.7.0] - 2026-05-15

### Security

- **M-H2: Outbound HTTP clients now close the TOCTOU window between
  pre-flight SSRF screening and connect-time DNS resolution**
  (`src/ssrf_resolver.rs`, `src/ssrf.rs`, `src/oauth.rs`,
  `src/mtls_revocation.rs`). Previously `screen_oauth_target` and
  `CrlSet::new` performed an `IpAddr` lookup, validated it against the
  cloud-metadata blocklist and operator allowlist, and then handed the
  request to `reqwest`, which independently re-resolved the hostname
  inside its own connector. A controlled-DNS attacker could pass the
  pre-flight check with a public IP and have the connector see a
  loopback / private / metadata answer microseconds later. Every
  outbound `reqwest::Client` now installs a custom
  `SsrfScreeningResolver` (`ClientBuilder::dns_resolver(...)`) that
  re-applies the same `ip_block_reason` + `CompiledSsrfAllowlist`
  policy on the addresses actually returned to the connector.
  Cloud-metadata short-circuits before the allowlist is consulted and
  remains unbypassable in every code path. The resolver fails closed
  with a `"ssrf:"`-prefixed error on policy denial so operators can
  distinguish deliberate denials from generic DNS failures. Defence in
  depth: every `ClientBuilder` also calls `.no_proxy()` to disable
  reqwest's auto-proxy detection, since `HTTP_PROXY` /
  `HTTPS_PROXY` / `ALL_PROXY` env vars would otherwise route DNS
  through the proxy and bypass the resolver entirely. Wired at all six
  outbound construction sites: `OauthHttpClient::build`,
  `build_mtls_clients`, `JwksCache::with_config`, the OAuth wiremock
  test harness, `CrlSet::new`, and `bootstrap_fetch`. Closes the last
  open finding from the 2026-05-13 deep code review.

### Added

- **`oauth-mtls-client` cargo feature** enabling RFC 8705 §2 mTLS
  client authentication for the OAuth token-exchange endpoint.
  Disabled by default; opt in via
  `rmcp-server-kit = { version = "1", features = ["oauth-mtls-client"] }`.
  See M-H4 entry under `### Security` for the full security rationale.
- **`ClientCertConfig::new(cert_path, key_path)`** constructor for the
  `#[non_exhaustive]` `ClientCertConfig` so downstream crates can build
  one without struct-literal syntax.

### Fixed

- **M4: `oauth.role_claim` now resolves first-class `Claims` fields**
  (`src/oauth.rs`). `resolve_role` previously only walked the `extra`
  map, so `role_claim = "sub"` (or `azp` / `client_id` / `aud` / `scope`)
  was silently treated as missing even when the JWT contained those
  standard fields. A new `first_class_claim_values` helper layers the
  RFC 7519 / RFC 8693 standard claims into the lookup, with `scope`
  whitespace-split per RFC 8693 §4.2 and `aud` returning every audience.
- **M7: Prometheus `/metrics` listener now participates in graceful
  shutdown** (`src/metrics.rs`, `src/transport.rs`). `serve_metrics`
  gained a `shutdown: CancellationToken` parameter and wires it into
  `axum::serve(...).with_graceful_shutdown(...)`, so cancelling the
  parent server's shutdown token now releases the metrics port instead
  of leaking it until process exit.

### Fixed

- **M5: `oauth.jwks_cache_ttl` is now validated up-front** (`src/oauth.rs`).
  Previously, a malformed `jwks_cache_ttl` (e.g. `"ten minutes"`) was
  silently swallowed by `unwrap_or(Duration::from_mins(10))` inside
  `JwksCache::new`, so the operator-configured TTL was ignored without
  any warning. `OAuthConfig::validate` now parses the string and rejects
  startup with a clear `McpxError::Config` on failure; `JwksCache::new`
  therefore relies on a typed invariant instead of a silent fallback.
- **M6: `max_concurrent_requests = Some(0)` is now rejected** at
  `McpServerConfig::validate` time (`src/transport.rs`). A zero cap would
  deadlock the global concurrency limiter and reject every request.
  Mirrors the equivalent TOML-side check already present in
  `src/config.rs`.
- **M8: `auth.rate_limit.max_tracked_keys = 0` is now rejected** at
  `McpServerConfig::validate` time (`src/transport.rs`). A zero cap would
  force the bounded keyed limiter to evict on every insert and
  effectively disable rate limiting. `BoundedKeyedLimiter::new` now also
  carries a `debug_assert!(max_tracked_keys > 0)` as defense-in-depth.

### Documentation

- **M9: `docs/GUIDE.md` configuration tables now match the actual `config.rs`
  schema**. Added previously-missing `ServerConfig` rows
  (`session_idle_timeout`, `sse_keep_alive`, `public_url`,
  `compression_enabled`, `compression_min_size`, `max_concurrent_requests`,
  `admin_enabled`, `admin_role`, `auth`), the `ObservabilityConfig`
  `log_request_headers` row, and the `OAuthConfig`
  `audience_validation_mode` row. The `stdio_enabled` row now warns that
  stdio bypasses auth/RBAC/TLS/Origin checks. The
  `strict_audience_validation` row is marked **Deprecated since 1.7.0**
  with the resolution semantics documented; the "new deployments"
  recommendation snippet now uses `audience_validation_mode = "strict"`.
- **M10: crate-level rustdoc on `src/lib.rs` expanded** with a runnable
  `no_run` quick-start example, a feature-flag overview (`oauth`,
  `metrics`, `test-helpers`), and a prominent security warning for
  `transport::serve_stdio` (which bypasses auth, RBAC, TLS, Origin
  validation, and rate limiting).

## [1.6.0] - 2026-05-13

### Security

- **Fail-closed RFC 3339 validation for API key `expires_at`** (`src/auth.rs`).
  Previously, a malformed `expires_at` string in the API key TOML file was
  silently treated as "never expires" because `chrono::DateTime::parse_from_rfc3339`
  errors inside `verify_bearer_token` were discarded. An operator who
  mistyped (e.g. `"2026-01-01"` instead of `"2026-01-01T00:00:00Z"`) would
  unknowingly ship a non-expiring key. Expiry strings are now parsed and
  validated **at TOML deserialization time** via a new `RfcTimestamp`
  newtype: any malformed value rejects server startup (or hot-reload) with
  a clear error pointing at the offending key. `verify_bearer_token` no
  longer needs to parse strings on the hot path.

### Changed (BREAKING - source compatibility)

> Shipped as **1.6.0** by maintainer policy: the only known downstream
> consumer (`atlassian-mcp-rs`, same maintainer) does not touch the
> affected API surface. `cargo-semver-checks` is temporarily disabled in
> CI with a `FIXME(H3-fix, 2026-05-13)` marker; re-enable on the next
> release with no public-API breaks.

- `ApiKeyEntry::expires_at` is now `Option<RfcTimestamp>` (was
  `Option<String>`).
- `ApiKeySummary::expires_at` is now `Option<RfcTimestamp>` (was
  `Option<String>`).
- `ApiKeyEntry::with_expiry` now takes `RfcTimestamp` (was
  `impl Into<String>`). For string input use the new
  `ApiKeyEntry::try_with_expiry(impl AsRef<str>) -> Result<Self, chrono::ParseError>`.
- `RfcTimestamp` (`Copy`) is now part of the public API in `src/auth.rs`;
  its on-the-wire form is `chrono`'s canonical RFC 3339 with `+00:00`
  (not `Z`) for UTC.

### Added

- **`RfcTimestamp` newtype** in `src/auth.rs` wrapping
  `chrono::DateTime<chrono::FixedOffset>` with a fail-closed `Deserialize`,
  `Display`/`Debug` via `to_rfc3339`, `parse`, `as_datetime`, and
  `into_inner`.
- **Mutation-coverage tests** for `glob_match` / `match_middle` boundary
  cases in `src/rbac.rs` and for `RbacPolicy::argument_allowed` glob-tool
  matching, killing five surviving mutants surfaced by the nightly
  `cargo mutants` job. Each test is annotated with the specific mutation
  it kills so the intent survives future refactors.
- **Exact-string contract tests** for `AuthFailureClass::as_str`,
  `response_body`, and `bearer_error` in `src/auth.rs`. These literals
  are part of the observable wire/log surface (metric labels, audit-log
  fields, OAuth `WWW-Authenticate` reasons); the tests pin them so a
  silent change becomes a test failure.
- **Boolean-flag contract tests** for `AuthConfig::summary` in
  `src/auth.rs`, asserting `bearer` is `true` iff `api_keys` is
  non-empty (kills the surviving `!`-deletion mutant at line 615) and
  pinning `enabled` / `mtls` / `oauth` propagation.
- **`RfcTimestamp` regression suite** (`src/auth.rs`) - eight tests covering
  malformed/valid parse, TOML deserialization fail-closed behavior,
  `try_with_expiry`, and `ApiKeySummary` JSON serialization wire format.

## [1.5.0] - 2026-04-29

### Added

- **Configurable security headers** (`src/transport.rs`) -- new
  `SecurityHeadersConfig` struct and `McpServerConfig::with_security_headers`
  builder method allow operators to override or omit any of the twelve
  OWASP security headers emitted by `security_headers_middleware`. Each
  field is `Option<String>` with a three-state semantic: `None` keeps the
  default, `Some("")` omits the header entirely, and `Some(value)` overrides.
  Non-empty values are validated via `HeaderValue::from_str` inside
  `McpServerConfig::validate()`; invalid values fail server startup. The
  `Strict-Transport-Security` field additionally rejects any value containing
  `preload` (case-insensitive) -- HSTS preload-list opt-in must be made via
  a dedicated future builder, not smuggled through this knob. Existing
  defaults are unchanged; this is a purely additive API surface change.

### Fixed

- **OAuth proxy** (`src/transport.rs`) -- `/token`, `/register`, `/introspect`,
  and `/revoke` responses now include `Pragma: no-cache` and
  `Vary: Authorization`, completing RFC 6749 §5.1 / RFC 6750 §5.4 compliance
  for OAuth proxy deployments. `Cache-Control: no-store` was already set
  globally by `security_headers_middleware`; this patch fills the remaining
  legacy-cache and `Vary` gaps. The new `oauth_token_cache_headers_middleware`
  is feature-gated (`oauth`) and only active when `OAuthConfig.proxy` is
  configured -- resource-server-only deployments are unaffected. `Vary` is
  appended (not replaced), preserving any pre-existing `Vary` value (e.g.
  `Accept-Encoding` from the compression layer).

## [1.4.1] - 2026-04-24

Patch release fixing a tokenization bug in `RbacPolicy::argument_allowed`
that prevented allowlist entries containing spaces from ever matching,
and tightening fail-closed handling of malformed shell input.

### Security

- **`Cargo.lock`** -- bump transitive `rustls-webpki` `0.103.12 -> 0.103.13`
  to pick up the fix for [RUSTSEC-2026-0104]https://rustsec.org/advisories/RUSTSEC-2026-0104.
  The advisory describes a reachable panic in
  `BorrowedCertRevocationList::from_der` /
  `OwnedCertRevocationList::from_der` when parsing a syntactically valid
  empty `BIT STRING` in the `onlySomeReasons` element of an
  `IssuingDistributionPoint` CRL extension. The panic is reachable
  before the CRL signature is verified, so any consumer that fetches
  CRLs via `mtls_revocation` would be exposed; consumers that do not
  use CRLs are unaffected. No code or API changes in this crate -- the
  fix is entirely a transitive dependency bump.

### Fixed

- **`src/rbac.rs`** -- `RbacPolicy::argument_allowed` now tokenizes
  argument values with POSIX-shell-like lexical rules (`shlex::split`)
  instead of `str::split_whitespace`. Allowlist entries containing
  spaces (e.g. `/usr/bin/my tool`) now match correctly when the value
  quotes the path per shell rules; previously they were unmatchable.
  Malformed shell syntax (unbalanced quotes, dangling escapes), empty
  `value`, and well-formed but empty first argv elements (e.g.
  `value = r#""""#`) now fail closed.

### Behavior change matrix

POSIX-shell-like tokenization is now the contract. The new behavior
diverges from `str::split_whitespace` in the cases below. We ship as a
patch because (a) the function signature is unchanged, (b) the
"now-allow" change unbreaks legitimately-quoted spaced paths, and
(c) every "now-deny" change is either malformed input or a
configuration that worked only by accident under whitespace splitting
and almost certainly diverged from the consumer's actual exec
tokenization downstream.

| Input class | 1.4.0 | 1.4.1 | Direction |
|---|---|---|---|
| Plain unquoted token (`ls`) | allow if listed | allow if listed | identical |
| Quoted path with embedded space (e.g. `"/usr/bin/my tool" --x`) | deny (broken) | allow if listed | stricter-correct |
| Unbalanced quote / dangling escape | accepts truncation | **deny** | stricter (security-positive) |
| Empty input string `""` | accepts `""` if listed | **deny** | stricter |
| Quoted empty token `r#""""#` | accepts `""` if listed | **deny** | stricter |
| Tab/newline separator | works incidentally | works per POSIX | identical in practice |
| Quoted-literal allowlist entry (e.g. `["'bash'"]` matching `'bash' -c true`) | allow | **deny** (shlex strips the surrounding quotes -> first token `bash`, not `'bash'`) | observable regression -- see operator notes |
| Backslash-literal allowlist entry (e.g. `[r"foo\bar"]`) | allow | **deny** (POSIX shlex treats `\` as escape -> first token becomes `foobar`) | observable regression -- see operator notes |
| Windows-style path allowlist entry (e.g. `[r"C:\Windows\System32\cmd.exe"]`) | allow | **deny** (POSIX shlex eats backslashes) | observable regression -- see operator notes |

### Notes for operators

- **POSIX-shell-like semantics only.** The matcher now models POSIX
  word-splitting + quote removal as performed by `shlex::split`. It
  does **not** model real shell *execution* (`FOO=1 cmd`, expansions,
  command substitution, redirections, operators) or Windows
  command-line tokenization (`CommandLineToArgvW`, `cmd.exe`,
  PowerShell). Consumers in those regimes still need their own
  validation at the boundary.
- **Backslash is an escape character** under POSIX rules. Allowlist
  entries that embed `\` (e.g. Windows-style paths) must be quoted at
  the policy boundary, expressed with forward slashes, or migrated to
  a typed pre-tokenized argument matcher in a future release.
- **Quoted literals in the allowlist** (e.g. `"'bash'"`) no longer
  match. These configurations were never sound -- they only worked
  because the old `split_whitespace` first token also retained the
  quote characters as literals, which any execve-aware consumer would
  immediately strip. Update such entries to the bare command name
  (`"bash"`) or its full path.
- **Performance:** `shlex::split` allocates a `Vec<String>` for the
  full input on every matched allowlist entry, where the previous
  implementation only walked to the first whitespace. Acceptable under
  existing request-body caps; observable on adversarial input.

### API surface

API surface unchanged: signature of `RbacPolicy::argument_allowed`
(`fn(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool`)
is preserved. `cargo semver-checks` confirms patch-level compatibility.

### Dependencies

- Added `shlex = "1.3"` (MIT/Apache-2.0, zero transitive deps). Pinned
  to `>=1.3` to stay on the post-RUSTSEC-2024-0006 line; that advisory
  affects `shlex::quote` / `shlex::join` (CVE-2024-58266), neither of
  which is consumed here.

## [1.4.0] - 2026-04-24

Minor release adding an opt-in operator allowlist for the OAuth/JWKS
post-DNS SSRF guard, so in-cluster IdPs (e.g. Keycloak resolving to
RFC1918 addresses) can be reached without disabling SSRF protection.
Defaults are unchanged (fail-closed), and cloud-metadata addresses
remain blocked regardless of allowlist contents.

### Added

- **`src/oauth.rs`** - New `OAuthSsrfAllowlist { hosts, cidrs }` type and
  `OAuthConfigBuilder::ssrf_allowlist(...)` setter. Lets operators name
  the hostnames or CIDR blocks (IPv4 and IPv6) whose otherwise-blocked
  addresses (private/loopback/link-local/CGNAT/unique-local) the
  OAuth/JWKS fetcher is allowed to reach. Hosts are case-insensitive
  exact match; CIDRs are family-strict (no IPv4-mapped-IPv6, no `/0`,
  no zone IDs, host bits must be zero). Misconfiguration is rejected at
  `OAuthConfig::validate()` and `JwksCache::new()` so deploy-time
  feedback is immediate. When non-empty, validation logs a
  `tracing::warn!` naming the host and CIDR counts.
- **`src/ssrf.rs`** - New `CompiledSsrfAllowlist` + `CidrEntry` types
  (crate-private) and `redirect_target_reason_with_allowlist` that
  consults the allowlist on per-redirect-hop literal-IP screening while
  keeping cloud-metadata unbypassable.
- **`src/ssrf.rs`** - Cloud-metadata classifier now also covers AWS
  IPv6 (`fd00:ec2::254`), GCP IPv6 (`fd20:ce::254`), and the
  Alibaba/Tencent IPv4 metadata address (`100.100.100.200`). These
  addresses are classified as `cloud_metadata` *before* the generic
  `unique_local` / `cgnat` buckets so an operator allowlist for
  `fd00::/8` or `100.64.0.0/10` cannot silently re-allow them.

### Security

- **`src/oauth.rs`** - Cloud-metadata IPv4 (`169.254.169.254`,
  `100.100.100.200`) and IPv6 (`fd00:ec2::254`, `fd20:ce::254`) are
  now explicitly carved out of the operator allowlist path: even when
  an operator allowlists a containing CIDR, addresses classified as
  `cloud_metadata` continue to use the strict legacy error message and
  are never permitted. New unit tests pin this invariant
  (`redirect_with_fd00_8_allowlist_still_blocks_aws_v6_metadata`,
  `redirect_with_cgnat_allowlist_still_blocks_alibaba_metadata`).
- **`src/oauth.rs`** - Empty (default) allowlist preserves the
  pre-1.4.0 error message verbatim so existing operator runbooks and
  alerting on "OAuth target resolved to blocked IP" keep working.
  Configured allowlists that still block emit a more verbose error
  naming the hostname, the resolved IP, the block reason, and the two
  config fields the operator can edit.

### Changed

- **`src/oauth.rs`** - `evaluate_oauth_redirect`,
  `screen_oauth_target`, and `screen_oauth_target_with_test_override`
  now take a `&CompiledSsrfAllowlist` parameter. These are private
  helpers; no downstream impact.

### Documentation

- **`docs/GUIDE.md`** - New "Allowing in-cluster IdPs" subsection in the
  OAuth chapter showing the recommended TOML and builder snippets.
- **`SECURITY.md`** - New "Operator allowlist" subsection under OAuth
  SSRF hardening documenting the trust model, the cloud-metadata
  carve-out, and the auditing expectations.

## [1.3.2] - 2026-04-21

Security and quality patch release rolling up the post-1.3.1 multi-agent
review findings. No breaking changes; drop-in replacement for `1.3.1`.

### Security

- **`src/auth.rs`** - Bearer-scheme parsing in the auth middleware is now case-insensitive per RFC 7235 §2.1 (e.g. `bearer …` and `BEARER …` are accepted alongside `Bearer …`). Previously these were silently rejected as `invalid_credential` and counted toward the auth-failure rate limit, which could cause spurious lockouts for spec-conformant clients.
- **`src/auth.rs`** - `AuthIdentity` and `ApiKeyEntry` now have manual `Debug` implementations that redact the raw bearer token, the JWT `sub` claim, and the Argon2id hash. This prevents secret material from leaking via `format!("{:?}", …)` or `tracing::debug!(?identity, …)` calls, and is enforced by new unit tests.
- **`src/oauth.rs`** - Added post-DNS SSRF screening for the initial OAuth/JWKS request target so hostnames resolving to blocked IP ranges are rejected before connect, mirroring CRL fetch hardening.
- **`src/oauth.rs`** - Added opt-in `strict_audience_validation` so operators can disable the legacy `azp` fallback and enforce `aud`-only audience checks for new deployments.
- **`src/transport.rs` / `src/oauth.rs`** - Added opt-in `require_auth_on_admin_endpoints` so OAuth `/introspect` and `/revoke` can be mounted behind the normal auth middleware while preserving legacy behavior by default.
- **`src/rbac.rs`** - RBAC and tool rate limiting now inspect JSON-RPC batch arrays and reject the full batch if any `tools/call` entry is denied.
- **`src/oauth.rs`** - Added `jwks_max_response_bytes` (default 1 MiB) and streaming JWKS reads so oversized responses are refused without unbounded allocation.

### Changed

- **`src/metrics.rs`** - `http_request_duration_seconds` now uses an explicit, latency-tuned bucket set (`[1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s]`) instead of the Prometheus default buckets, which were skewed toward web-page rather than RPC latency. The histogram name and labels are unchanged; existing dashboards keep working but will gain finer sub-100 ms resolution.
- **`src/tool_hooks.rs`** - `with_hooks` now documents that dropping the returned wrapper silently loses the configured hooks. The natural `#[must_use]` enforcement is deferred to the next minor-version bump (adding `#[must_use]` to a public function is a SemVer-minor change per cargo-semver-checks).
- **`README.md`** - Quick-start dependency line dropped the gratuitous `features = ["oauth"]` so a copy-paste install no longer pulls in OAuth, `jsonwebtoken`, and `reqwest` for users who only need the default transport. Optional features are now described in a separate note pointing at the Cargo features table.

### Documentation

- **`docs/ARCHITECTURE.md` / `docs/MINDMAP.md`** - Refreshed mTLS sections to match the current per-connection `TlsConnInfo` design (the previous text described the long-removed `RwLock<HashMap<SocketAddr, AuthIdentity>>` map).
- **`docs/ARCHITECTURE.md`** - Metrics section now lists only the metrics actually exported by `src/metrics.rs` (`http_requests_total`, `http_request_duration_seconds`) and points operators at `McpMetrics::registry` for custom collectors. The previous list named gauges and counters that were never implemented.

## [1.3.1] - 2026-04-21

First usable release of `rmcp-server-kit`. A reusable, production-grade
framework for building [Model Context Protocol](https://modelcontextprotocol.io/)
servers in Rust on top of the official `rmcp` SDK.

Consumers supply an `rmcp::handler::server::ServerHandler` implementation;
this crate provides Streamable HTTP transport, TLS / mTLS, structured
authentication (API key, mTLS, OAuth 2.1 JWT), RBAC with per-tool
argument allowlists, per-IP rate limiting, OWASP security headers,
structured observability, optional Prometheus metrics, admin
diagnostics, and graceful shutdown.

### Highlights

- **Transport** - Streamable HTTP (`/mcp`), `/healthz`, `/readyz`,
  `/version`, admin diagnostics, graceful shutdown, configurable TLS
  and mTLS. Optional `serve_stdio()` for local subprocess MCP.
- **Authentication** - API-key (Argon2id-hashed, constant-time verify),
  mTLS client certificates with subject→role mapping, OAuth 2.1 JWT
  validation against JWKS (feature `oauth`). Pre-auth rate limiting
  defends Argon2id against CPU-spray attacks.
- **mTLS revocation** - CDP-driven CRL fetching with bounded memory,
  bounded concurrency, and bounded discovery rate. Auto-discovers CRL
  URLs from the CA chain at startup and from connecting client certs
  during handshakes. Hot-reloadable via `ReloadHandle::refresh_crls()`.
- **RBAC** - `RbacPolicy` with default-deny, per-role allow/deny tool
  lists (glob-supported), per-tool argument allowlists, HMAC-SHA256
  argument-value redaction in deny logs, task-local accessors
  (`current_role`, `current_identity`, `current_token`, `current_sub`).
- **OAuth 2.1** - JWKS cache with refresh cooldown, configurable allowed
  algorithms (RS256/ES256 default; symmetric keys rejected), HTTPS-only
  redirect policy, custom CA support, optional OAuth proxy endpoints
  (`/authorize`, `/token`, `/register`, `/introspect`, `/revoke`).
- **SSRF hardening** - Validate-time literal-IP / userinfo rejection on
  every operator-supplied URL plus a runtime per-hop IP-range guard on
  every redirect closure (CRL, JWKS, OAuth admin traffic). Blocks
  private, loopback, link-local, multicast, broadcast, and cloud-
  metadata ranges.
- **Hardening defaults** - Per-IP token-bucket rate limiting (governor)
  with memory-bounded LRU eviction, request-body cap (default 1 MiB),
  request-timeout cap, OWASP security headers (HSTS, CSP, X-Frame-
  Options, etc.), configurable CORS and Host allow-lists, JWKS key cap
  (default 256), CRL response-body cap (default 5 MiB).
- **Hot reload** - Lock-free `arc-swap`-backed reload of API keys,
  RBAC policy, and CRL set without dropping in-flight requests.
- **Tool hooks** - Opt-in `HookedHandler` wrapping `ServerHandler` with
  async `before_call` / `after_call` hooks. After-hooks run on a
  spawned task with the parent span and RBAC task-locals re-installed.
  Configurable `max_result_bytes` cap.
- **Observability** - `tracing-subscriber` initialization with
  `EnvFilter`, JSON or pretty console output, optional audit-file
  sink. Sensitive values wrapped in `secrecy::SecretString` end-to-end.
- **Metrics** (feature `metrics`) - Prometheus registry served on a
  separate listener (request count, duration histogram, in-flight
  gauge, auth failures, RBAC denies).
- **Configuration** - Programmatic builder API on `McpServerConfig`
  with compile-time `Validated<T>` typestate, plus matching TOML
  schema in `src/config.rs`.

### Cargo features

- `oauth` (default off) - OAuth 2.1 JWT validation via JWKS plus
  optional OAuth proxy endpoints.
- `metrics` (default off) - Prometheus registry and `/metrics` endpoint.
- `test-helpers` (default off) - opt-in test-only constructors used by
  downstream integration suites; not part of the stable API surface.

### Minimum supported Rust

`rmcp-server-kit` targets stable Rust **1.95** or newer (`edition = "2024"`).

### Documentation

- [`README.md`]README.md - quick start.
- [`docs/GUIDE.md`]docs/GUIDE.md - end-to-end consumer guide and TOML schema.
- [`docs/ARCHITECTURE.md`]docs/ARCHITECTURE.md - file-cited deep architecture map.
- [`docs/MINDMAP.md`]docs/MINDMAP.md - visual project mindmap.
- [`AGENTS.md`]AGENTS.md - repository navigation hub for AI agents.
- [`SECURITY.md`]SECURITY.md - coordinated disclosure policy and
  hardening posture.