pmcp 2.18.0

High-quality Rust SDK for Model Context Protocol (MCP) with full TypeScript SDK compatibility
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
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
//! OAuth authentication support for CLI tools.
//!
//! This module implements multiple OAuth 2.0 flows for CLI authentication:
//! - Authorization Code Flow with PKCE (RFC 7636) - browser-based, most compatible
//! - Device Code Flow (RFC 8628) - fallback for servers that support it
//!
//! Supports automatic OAuth discovery via:
//! - OpenID Connect Discovery (/.well-known/openid-configuration)
//! - OAuth 2.0 Server Metadata (/.well-known/oauth-authorization-server)
//!
//! # Feature Gate
//!
//! This module is only available when the `oauth` feature is enabled:
//!
//! ```toml
//! pmcp = { version = "1.11", features = ["oauth"] }
//! ```

use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::{Arc, Once, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;
use tokio::time::sleep;
use url::Url;

use crate::client::auth::{AuthorizationServerExtras, OidcDiscoveryClient, TokenExchangeClient};
use crate::client::http_middleware::HttpMiddlewareChain;
use crate::client::oauth_middleware::{BearerToken, OAuthClientMiddleware};
use crate::error::{Error, Result};
use crate::server::auth::oauth2::OidcDiscoveryMetadata;
use crate::shared::credential_file::FileCredentialStore;
use crate::shared::credential_store::{
    normalize_server_key, CredentialKey, CredentialStore, StoredCredentials,
};
use crate::shared::http_body_cap::{
    collect_reqwest_body_within_cap, is_body_over_cap, DEFAULT_AUTH_RESPONSE_BYTES,
};
use crate::shared::oauth_validation::{
    derive_application_type, iss_presence_from, parse_iss_env_value,
    validate_authorization_response, AuthorizationRequestRecord, IssPresence,
};
use crate::shared::pkce::{code_challenge_s256, generate_code_verifier, generate_state};

/// The environment variable an operator sets to override RFC 9207 `iss`
/// strictness without a redeploy or a code change.
///
/// Accepted values are `strict` and `lenient`, parsed case-insensitively after
/// trimming by
/// [`parse_iss_env_value`](crate::shared::oauth_validation::parse_iss_env_value).
/// A value the SDK does not recognise is **announced** with a `tracing::warn!`
/// naming this variable and its two accepted values, then ignored — it never
/// silently leaves validation lenient.
const ISS_VALIDATION_ENV_VAR: &str = "PMCP_OAUTH_ISS_VALIDATION";

/// The largest HTTP request line the loopback callback listener will read.
///
/// Any process on the local machine can connect to the callback port and send
/// an unbounded request line. The authorization response is a query string —
/// a `code`, a `state` and possibly an `iss`, each well under 100 bytes — never
/// a payload, so refusing at 16 `KiB` costs nothing legitimate. The read is
/// bounded at the socket, so an oversized line is refused without the whole
/// line ever being allocated.
///
/// This is the transport-level twin of
/// [`MAX_CALLBACK_QUERY_BYTES`](crate::shared::oauth_validation::MAX_CALLBACK_QUERY_BYTES),
/// which bounds the query the pure validator will parse.
pub const MAX_CALLBACK_REQUEST_LINE_BYTES: usize = 16_384;

/// The response served when the callback validated. Byte-identical to the page
/// this module has always served on success.
const CALLBACK_SUCCESS_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
     <html><body style='font-family: sans-serif; text-align: center; padding: 50px;'>\
     <h1 style='color: green;'>Authentication Successful!</h1>\
     <p>You can close this window and return to the terminal.</p>\
     </body></html>";

/// The response served when the callback did NOT validate. Byte-identical to
/// the page this module has always served on failure, and deliberately so: it
/// carries no authorization-server-supplied text. RFC 9207 forbids acting on or
/// displaying an `error_description` that arrived behind a failing `iss`, so the
/// failure page must stay fixed bytes.
const CALLBACK_FAILURE_RESPONSE: &str =
    "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
     <html><body style='font-family: sans-serif; text-align: center; padding: 50px;'>\
     <h1 style='color: red;'>Authentication Failed</h1>\
     <p>No authorization code received. Please try again.</p>\
     </body></html>";

/// The scope an OAuth client asks for when it wants a refresh token (SEP-2207).
///
/// It appears at exactly two protocol stages and means a DIFFERENT thing at
/// each, which is why this module writes it in two places and deliberately not
/// in a third:
///
/// | Stage | What writing it there means | Written here? |
/// |---|---|---|
/// | the Dynamic Client Registration request's `scope` | CLIENT METADATA: what this client is permitted to ask for | yes, when advertised |
/// | the authorization request's `scope` | the ASK itself — the only stage at which requesting it does anything | yes, when advertised |
/// | a refresh request's `scope` | a scope CHANGE on an existing grant | **no, never** |
///
/// The third row is the one that matters. RFC 6749 §6 permits a refresh request
/// to narrow the granted scope and never to widen it, so introducing a scope at
/// refresh that was never granted can have the authorization server refuse a
/// refresh that would otherwise have succeeded. Refresh sends only what was
/// GRANTED, or no `scope` at all.
///
/// Both writes are conditioned on the authorization server ADVERTISING the scope
/// in its `scopes_supported`, because SEP-2207 states the client MAY request it
/// when the server advertises support — the condition is the whole rule.
const OFFLINE_ACCESS_SCOPE: &str = "offline_access";

/// The largest Dynamic Client Registration response body this client will read,
/// on the success path and on the rejection path alike.
///
/// Defined as the shared authorization-surface cap rather than as a second
/// literal, so a hostile registration endpoint faces ONE number and the refusal
/// message that names it cannot drift from the number actually enforced.
const MAX_DCR_RESPONSE_BYTES: usize = DEFAULT_AUTH_RESPONSE_BYTES;

/// The file name the issuer-keyed credential store uses inside whichever
/// directory holds it.
///
/// This is `default_credential_path`'s own file name, and
/// `the_credential_store_file_name_matches_default_credential_path` asserts the
/// two cannot drift. They must agree: a caller who sets
/// [`OAuthConfig::cache_file`] and a caller who does not would otherwise end up
/// with two different credential stores inside one directory, and a login
/// through one would be invisible to the other.
const CREDENTIAL_STORE_FILE_NAME: &str = "oauth-cache.json";
/// A stable, non-reversible identifier for a token, safe to put in a log line.
///
/// A PREFIX of a live access token is still token material: 20 characters is
/// enough to correlate it against a leak elsewhere, and on some authorization
/// servers it is enough to identify the issuing key or the tenant. A prefix of
/// the SHA-256 digest identifies the same token across log lines without being
/// any part of it.
///
/// Twelve hex characters is 48 bits — far beyond collision range for the
/// handful of tokens one process holds, and far too short to brute-force back
/// to a token carrying at least 128 bits of entropy.
///
/// The result is deliberately prefixed with `sha256:` so a reader cannot
/// mistake it for the token itself, which is the failure mode a bare hex string
/// invites.
const FINGERPRINT_HEX_CHARS: usize = 12;

/// Lowercase hex digits for [`token_fingerprint`].
///
/// A table and a loop rather than a `format!` per byte: `clippy::format_collect`
/// rejects the latter, and this shape allocates exactly once. Declared at module
/// level because `clippy::items_after_statements` rejects a `const` inside the
/// function body.
const HEX_DIGITS: [char; 16] = [
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
];

fn token_fingerprint(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    let digest = hasher.finalize();
    let mut hex = String::with_capacity(FINGERPRINT_HEX_CHARS);
    for byte in digest.iter().take(FINGERPRINT_HEX_CHARS / 2) {
        hex.push(HEX_DIGITS[usize::from(byte >> 4)]);
        hex.push(HEX_DIGITS[usize::from(byte & 0x0f)]);
    }
    format!("sha256:{hex}")
}

/// Absolute Unix seconds, with a pre-epoch clock reported as `0` rather than
/// panicking.
///
/// One function for the whole module so an expiry computed on the
/// authorization-code path and one computed on the device-code path cannot
/// disagree about what "now" means. The previous `cache_token` form
/// (`.duration_since(UNIX_EPOCH).unwrap()`) panicked in library code on a clock
/// set before 1970, which is a denial of service a caller cannot catch.
fn unix_now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_secs())
}

/// Compose a `scope` value from the configured scopes plus
/// [`OFFLINE_ACCESS_SCOPE`], and only when the authorization server advertises
/// that scope in its `scopes_supported`.
///
/// The result is **order-stable** (configured scopes keep their order,
/// `offline_access` is appended last) and **deduplicated** (a caller who already
/// listed `offline_access` gets one entry, not two — a duplicated scope token is
/// legal but is the kind of thing a strict authorization server rejects).
///
/// It takes a slice and returns a fresh `Vec`, so `OAuthConfig::scopes` is never
/// mutated. That is not a stylistic preference: `scopes` is a public field on a
/// public struct, and a caller who reuses one [`OAuthConfig`] across two flows
/// against an advertising server must not watch it grow an entry per flow.
fn compose_scopes_with_offline_access(
    configured: &[String],
    scopes_supported: &[String],
) -> Vec<String> {
    let mut composed: Vec<String> = Vec::with_capacity(configured.len() + 1);
    for scope in configured {
        if !composed.iter().any(|held| held == scope) {
            composed.push(scope.clone());
        }
    }

    let advertised = scopes_supported
        .iter()
        .any(|scope| scope == OFFLINE_ACCESS_SCOPE);
    if advertised && !composed.iter().any(|held| held == OFFLINE_ACCESS_SCOPE) {
        composed.push(OFFLINE_ACCESS_SCOPE.to_string());
    }

    composed
}

/// Write SEP-837's `application_type` onto a registration request, DERIVING it
/// from the `redirect_uris` the request is actually registering.
///
/// Returns the value that will go on the wire, so the caller can compare it
/// against whatever the authorization server echoes back.
///
/// # Why derive from `redirect_uris` rather than from `config.redirect_port`
///
/// The redirect URIs are the values the authorization server will enforce its
/// per-type constraints against. Deriving from anything else lets the declared
/// type and the registered URIs drift apart the moment the URI shape changes,
/// which is precisely the mismatch SEP-837 warns registration will be rejected
/// for.
///
/// # The override path
///
/// An `application_type` that is ALREADY set on the request is kept, never
/// overwritten. `DcrRequest::set_application_type` performs no validation
/// exactly so that it can serve as the authoritative override for this
/// derivation (116-03 / D-09), and silently clobbering it here would delete that
/// path.
///
/// # Errors
///
/// Propagates [`derive_application_type`]'s refusal for an empty, unparseable,
/// cleartext-remote or MIXED `redirect_uris` vector. A mixed vector is an error
/// and never a pick: choosing one classification for a vector containing both
/// registers a client whose declared type contradicts some of its own redirect
/// URIs, which is an open-redirect primitive (D-10).
fn apply_application_type(request: &mut DcrRequest) -> Result<String> {
    if let Some(explicit) = request.application_type() {
        return Ok(explicit.to_string());
    }

    let derived = derive_application_type(&request.redirect_uris)?;
    request.set_application_type(derived.as_str());
    Ok(derived.as_str().to_string())
}

/// Whether the authorization server registered this client under a DIFFERENT
/// `application_type` than the one the registration request asked for.
///
/// Returns `Some((sent, echoed))` only for a genuine divergence. Both an EQUAL
/// echo and an ABSENT echo return `None`, and the second of those is the rule
/// worth stating: RFC 7591 § 3.2.1 does not require the server to echo the
/// metadata it accepted, so an omission means "no answer", never "a different
/// answer". Treating an omission as divergence would make every RFC-conformant
/// terse registration server produce a warning about a disagreement that never
/// happened.
///
/// A divergence is deliberately NOT an error anywhere it is used. The same
/// RFC § 3.2.1 permits the server to modify any requested client metadata, so
/// failing here would turn a legal server behaviour into an outage (T-116-36,
/// disposition `accept`). The value of detecting it is diagnostic: the client
/// is now registered under constraints it did not choose.
///
/// This is a pure function over two borrowed strings precisely so the RULE is
/// testable without a network, without a log subscriber, and without adding a
/// field to any public constructible type.
fn application_type_divergence(sent: &str, echoed: Option<&str>) -> Option<(String, String)> {
    match echoed {
        Some(registered) if registered != sent => Some((sent.to_string(), registered.to_string())),
        _ => None,
    }
}

/// The most characters of an authorization-server-supplied `error` or
/// `error_description` that a registration-rejection message will reproduce.
///
/// The body itself is already bounded by [`MAX_DCR_RESPONSE_BYTES`], but a
/// 1 `MiB` `error_description` is still an echo channel wearing a
/// specification-approved hat (T-116-37). Reproducing a short prefix keeps the
/// message actionable while capping what a hostile registration endpoint can
/// push into a developer's terminal and log aggregator.
const MAX_DCR_ERROR_FIELD_CHARS: usize = 200;

/// The RFC 7591 § 3.2.2 error fields of a rejected registration, projected out
/// of an entirely authorization-server-controlled body.
///
/// Every field is `Option` because none of them is guaranteed: the body may be
/// a conformant error object, an unrelated JSON document, an HTML error page or
/// nothing at all. The projection never fails and never panics — an
/// unrecognisable body simply yields two `None`s, and the rejection message
/// then names the HTTP status and what the client sent, which is still
/// actionable.
#[derive(Debug, Default)]
struct DcrRejectionFields {
    /// RFC 7591 § 3.2.2 `error` — a single ASCII error code such as
    /// `invalid_redirect_uri`.
    error: Option<String>,
    /// RFC 7591 § 3.2.2 `error_description` — human-readable detail.
    error_description: Option<String>,
}

/// Project a rejected registration's body onto its RFC 7591 § 3.2.2 error
/// fields, taking only string values and only a bounded prefix of each.
///
/// # Why `as_str` and not a stringification
///
/// The body is attacker-influenced input. A non-string `error` is `None`
/// rather than a coerced `"42"`, exactly as `DcrResponse::application_type`
/// treats the same class of value (116-03). This is the only place the
/// rejection path reads server-supplied text at all, so it is the only place
/// that rule has to hold.
fn dcr_rejection_fields(body: &[u8]) -> DcrRejectionFields {
    let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) else {
        return DcrRejectionFields::default();
    };

    DcrRejectionFields {
        error: bounded_error_field(parsed.get("error")),
        error_description: bounded_error_field(parsed.get("error_description")),
    }
}

/// Take a JSON value's string content, truncated to
/// [`MAX_DCR_ERROR_FIELD_CHARS`] on a character boundary.
///
/// The truncation marker names how many characters were dropped and reproduces
/// none of them, so the fact of truncation is visible without the truncated
/// content leaking through the notice itself.
fn bounded_error_field(value: Option<&serde_json::Value>) -> Option<String> {
    let text = value.and_then(serde_json::Value::as_str)?;
    let total = text.chars().count();
    if total <= MAX_DCR_ERROR_FIELD_CHARS {
        return Some(text.to_string());
    }

    let kept: String = text.chars().take(MAX_DCR_ERROR_FIELD_CHARS).collect();
    Some(format!(
        "{kept}… [truncated: {} of {total} characters withheld]",
        total - MAX_DCR_ERROR_FIELD_CHARS
    ))
}

/// Compose the error for a registration the authorization server REJECTED.
///
/// SEP-837 ¶3 obliges an MCP client to surface a MEANINGFUL error when
/// registration is refused, because the most common cause is a redirect-URI
/// constraint tied to the very `application_type` this module now sends. A bare
/// status code does not tell a developer which of their two knobs to turn, so
/// the message names FOUR things:
///
/// | Named | Why |
/// |---|---|
/// | the HTTP status | distinguishes a refusal from a transport failure |
/// | the server's `error` / `error_description` | the server's own reason, when it gave one |
/// | the `application_type` that was sent | half of the rejected pair |
/// | the `redirect_uris` that were sent | the other half |
///
/// # What is deliberately absent
///
/// No raw body content beyond the two parsed, length-bounded fields above. The
/// rejection path must not become the echo channel the bounded read exists to
/// close (T-116-37).
///
/// SEP-837's OPTIONAL retry with an adjusted `application_type` is also
/// deliberately not implemented. The specification says clients MAY retry; an
/// automatic retry would silently register the client under a type its operator
/// did not choose, which is the opposite of surfacing a meaningful error.
fn registration_rejected(
    status: reqwest::StatusCode,
    fields: &DcrRejectionFields,
    sent_application_type: &str,
    sent_redirect_uris: &[String],
) -> Error {
    let server_reason = match (&fields.error, &fields.error_description) {
        (Some(code), Some(description)) => {
            format!("error={code}; error_description={description}")
        },
        (Some(code), None) => format!("error={code}"),
        (None, Some(description)) => format!("error_description={description}"),
        (None, None) => {
            "the response body carried no RFC 7591 section 3.2.2 `error` field".to_string()
        },
    };

    Error::internal(format!(
        "DCR failed ({status}): the authorization server rejected this dynamic client \
         registration. Server reason: {server_reason}\n\
         \n\
         The registration that was rejected declared application_type=\"{sent_application_type}\" \
         with redirect_uris={sent_redirect_uris:?}. Those two are the pair an OIDC authorization \
         server enforces its redirect-URI constraints over, so they are what to change.\n\
         \n\
         No other part of the response body is reproduced here. Pass a pre-registered client_id \
         to skip DCR."
    ))
}

/// What one completed Dynamic Client Registration produced.
///
/// Exists so `registered_application_type` can leave
/// `OAuthHelper::do_dynamic_client_registration` at all. The obvious
/// alternative — a new field on [`AuthorizationResult`] — was considered and
/// REJECTED: that struct is public, all-`pub`-field and not
/// `#[non_exhaustive]`, so adding a field is `cargo-semver-checks`'
/// `constructible_struct_adds_field`, a MAJOR break, and avoiding exactly that
/// class of break is what this whole phase is built around.
/// `StoredCredentials` (116-05) has PRIVATE fields and an inherent
/// `with_registered_application_type` builder for the same value, which is why
/// the persistence hop 116-11 adds costs no semver event either.
#[derive(Debug)]
struct DcrOutcome {
    /// The parsed RFC 7591 registration response.
    response: crate::server::auth::provider::DcrResponse,
    /// The `application_type` this client ended up registered under — the
    /// server's echoed value when it echoed one, otherwise the value that was
    /// sent.
    registered_application_type: String,
}

/// How the interactive authorization URL reaches a human.
///
/// **This is a platform seam, not test scaffolding.** The interactive CLI flow
/// is one caller and not the only caller: a headless CI runner, a container
/// without a display, an SSH or remote-desktop session, or a hosting platform
/// that relays the URL through its own UI all want to PRINT or forward the URL
/// rather than open a window on whatever machine the process happens to be on.
/// Implement this trait to do that. It must not be hidden behind
/// `#[doc(hidden)]`.
///
/// A useful consequence is that the flow becomes testable end to end: a test
/// launcher can read the `state` and `code_challenge` out of the URL it
/// receives and deliver a callback, with no browser window and no human.
///
/// # Errors
///
/// [`BrowserLauncher::open`] returns `Err` only when the URL could not be
/// delivered to a human **at all**. The flow then aborts rather than waiting
/// five minutes for a callback nobody will deliver. A launcher that has a
/// fallback — as [`SystemBrowserLauncher`] does, because the flow already
/// logged the URL for manual entry — should return `Ok(())`.
pub trait BrowserLauncher: Send + Sync + std::fmt::Debug {
    /// Deliver the authorization URL to the human completing the flow.
    fn open(&self, url: &str) -> Result<()>;
}

/// The default [`BrowserLauncher`]: opens the platform's browser.
///
/// Returns `Ok(())` even when the platform browser cannot be opened, matching
/// this module's long-standing behaviour — the flow has already logged the URL
/// with "If the browser doesn't open, visit: …", so a human can still complete
/// the flow by pasting it. Aborting there would remove a working manual path.
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemBrowserLauncher;

impl BrowserLauncher for SystemBrowserLauncher {
    fn open(&self, url: &str) -> Result<()> {
        if let Err(e) = webbrowser::open(url) {
            tracing::warn!(
                "Failed to open browser: {}. Please open the URL manually.",
                e
            );
        }
        Ok(())
    }
}

// Re-export RFC 7591 DCR types from the authoritative server-side definitions
// so library users can construct DCR requests via `pmcp::client::oauth::DcrRequest`.
// Source of truth: src/server/auth/provider.rs:302-382.
pub use crate::server::auth::provider::{DcrRequest, DcrResponse};

/// Whether an [`OAuthHelper`] may fall back to an INTERACTIVE browser login.
///
/// Select it with [`OAuthHelper::with_interactivity`]. [`Self::Interactive`] is
/// the default, so a helper that never calls that method behaves exactly as it
/// always has.
///
/// # What [`Self::RefreshOnly`] is for, and what it costs
///
/// With the default mode, a headless runtime that cannot complete a browser
/// login pays for the attempt anyway: [`OAuthHelper::get_access_token`] binds a
/// loopback listener nothing can reach, hands an authorization URL to a browser
/// nobody can see, and then waits **five minutes** for a callback that will
/// never arrive — per attempt. In a Lambda or a Worker that is five minutes of
/// billed wall clock ending in a timeout that does not say what is actually
/// wrong.
///
/// [`Self::RefreshOnly`] turns that into an immediate
/// [`Error::reauth_required`], which a caller can convert into whatever its own
/// runtime calls a consent-required condition — an operator notification, a
/// queued re-login, a `401` to the user. The cost is that a `RefreshOnly`
/// helper can never obtain credentials it does not already have: the mode
/// narrows the FALL-BACK, not the cache and not the refresh.
///
/// # Why this is a mode and not environment sniffing
///
/// Sniffing (`DISPLAY`, `SSH_TTY`, `AWS_LAMBDA_FUNCTION_NAME`, …) guesses, and
/// the two ways of guessing wrong are not symmetric: guessing "interactive"
/// when it is not costs five minutes and then fails anyway, while guessing
/// "headless" when it is not breaks a CLI login that would have worked. A
/// caller always knows which one it is; the SDK does not.
///
/// # The guarantee
///
/// Under [`Self::RefreshOnly`] the interactive path is unreachable BY
/// CONSTRUCTION rather than skipped by a branch: the arm that handles it calls
/// an associated function that has no `self`, and therefore no access to the
/// configured [`BrowserLauncher`] and no route to the loopback listener. Both
/// public entry points check.
///
/// # Examples
///
/// ```rust,no_run
/// use pmcp::client::oauth::{Interactivity, OAuthConfig, OAuthHelper};
///
/// # async fn example() -> pmcp::Result<()> {
/// let helper = OAuthHelper::new(OAuthConfig::default())?
///     .with_interactivity(Interactivity::RefreshOnly);
///
/// match helper.get_access_token().await {
///     Ok(token) => println!("bearer {token}"),
///     Err(e) if e.is_reauth_required() => {
///         // Actionable in milliseconds instead of a five-minute timeout.
///         eprintln!("a human must log in again at {:?}", e.reauth_issuer());
///     },
///     Err(e) => return Err(e),
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Interactivity {
    /// Today's behaviour, unchanged: when no stored credential can serve the
    /// request, run the authorization-code flow — bind a loopback listener,
    /// open a browser and wait for the callback.
    #[default]
    Interactive,
    /// Serve the request from stored credentials or a refresh, or fail with
    /// [`Error::reauth_required`]. No browser is opened and no listener is
    /// bound.
    RefreshOnly,
}

/// OAuth configuration for CLI authentication flows.
///
/// # Migration note (pmcp 2.5.0)
///
/// `client_id` changed from `String` to `Option<String>` to support RFC 7591
/// Dynamic Client Registration. Existing callers that passed a pre-registered
/// id must now wrap it in `Some(...)`:
///
/// ```rust,ignore
/// // Before (pmcp < 2.5.0):
/// OAuthConfig { client_id: "my-client".to_string(), /* ... */ }
/// // After (pmcp 2.5.0+):
/// OAuthConfig {
///     client_id: Some("my-client".to_string()),
///     client_name: None,
///     dcr_enabled: false,
///     /* ... */
/// }
/// ```
#[derive(Debug, Clone)]
pub struct OAuthConfig {
    /// OAuth issuer URL (e.g., `https://auth.example.com`).
    /// If `None`, will auto-discover from MCP server URL.
    pub issuer: Option<String>,
    /// MCP server URL for auto-discovery (required if issuer is `None`).
    pub mcp_server_url: Option<String>,
    /// OAuth client ID. When `None` and `dcr_enabled` is `true` and the
    /// discovery metadata advertises a `registration_endpoint`, the SDK
    /// auto-performs RFC 7591 Dynamic Client Registration to obtain one.
    pub client_id: Option<String>,
    /// Client name advertised to the authorization server during DCR
    /// (RFC 7591 §2). Only consulted when DCR fires. Falls back to the
    /// literal `"pmcp-sdk"` if `None` at DCR time.
    pub client_name: Option<String>,
    /// Enable RFC 7591 Dynamic Client Registration when `client_id` is
    /// `None` and the server advertises a `registration_endpoint`.
    /// Defaults to `true` via `Default::default()`; set to `false` to
    /// opt out and require an explicit `client_id`.
    pub dcr_enabled: bool,
    /// OAuth scopes to request.
    pub scopes: Vec<String>,
    /// Cache file path for storing tokens.
    pub cache_file: Option<PathBuf>,
    /// Redirect port for localhost callback (default: 8080).
    pub redirect_port: u16,
}

impl Default for OAuthConfig {
    fn default() -> Self {
        Self {
            issuer: None,
            mcp_server_url: None,
            client_id: None,
            client_name: None,
            dcr_enabled: true,
            scopes: Vec::new(),
            cache_file: None,
            redirect_port: 8080,
        }
    }
}

/// Result of a successful OAuth authorization flow, carrying the full set of
/// artifacts a cache consumer needs to persist and later refresh.
///
/// # Field semantics
///
/// - `access_token`: The bearer token. Put in `Authorization: Bearer <...>` headers.
/// - `refresh_token`: Present when the IdP returned one (Okta, Auth0, Keycloak
///   with offline_access). `None` when the IdP does not issue refresh tokens.
///
///   **Device-code flow (RFC 8628):** When `OAuthHelper` falls back from
///   authorization-code to device-code (e.g., the IdP does not support
///   localhost callbacks), `refresh_token` may be `None` because RFC 8628 §3.5
///   does NOT require the token response to include a `refresh_token`. Users
///   will need to re-run `cargo pmcp auth login` when the access_token
///   expires on such IdPs. `issuer` is still populated from discovery;
///   `client_id` is whatever was passed in `OAuthConfig` (or DCR-issued if
///   DCR fired); `expires_at` captures whatever `expires_in` the token
///   response provided (or `None` if absent).
/// - `expires_at`: Absolute unix seconds (not `expires_in` relative). `None`
///   when the IdP omitted `expires_in` from the token response.
/// - `scopes`: The scopes the IdP actually granted. May differ from
///   `config.scopes` (the requested scopes) when the server downgrades or
///   expands them.
/// - `issuer`: The effective issuer — caller-provided if present, else the
///   value discovered from `.well-known/openid-configuration`. Always `Some`
///   for a successful flow.
/// - `client_id`: The effective client_id — the DCR-issued id when DCR fired,
///   or the caller-provided value otherwise. Always populated.
#[derive(Debug, Clone)]
pub struct AuthorizationResult {
    /// Bearer access token.
    pub access_token: String,
    /// Refresh token, if the IdP issued one.
    pub refresh_token: Option<String>,
    /// Absolute expiration time (unix seconds).
    pub expires_at: Option<u64>,
    /// Granted scopes.
    pub scopes: Vec<String>,
    /// Effective issuer (caller-provided or discovered).
    pub issuer: Option<String>,
    /// Effective client_id (DCR-issued or caller-provided).
    pub client_id: String,
}

/// The client identity one flow resolved, plus what dynamic registration
/// registered it as.
///
/// Exists because `client_id` alone is not enough to persist a credential
/// record: the `application_type` the authorization server registered (116-10)
/// has to travel from `do_dynamic_client_registration` to
/// [`StoredCredentials::with_registered_application_type`], and a new field on
/// the public, all-`pub`-field [`AuthorizationResult`] would be
/// `constructible_struct_adds_field` — a MAJOR semver break.
#[derive(Debug, Clone)]
struct ResolvedClientIdentity {
    /// The effective client id: DCR-issued when DCR fired, config-supplied
    /// otherwise.
    client_id: String,
    /// `Some` only when dynamic registration ran in THIS flow. A
    /// config-supplied client id was registered out of band, so this client has
    /// no observation to record about it — and recording a guess would be worse
    /// than recording nothing.
    registered_application_type: Option<String>,
}

/// What the credential store could contribute to one `get_access_token` call.
///
/// A plain `Option<String>` was enough while a miss always meant "run the
/// browser flow". [`Interactivity::RefreshOnly`] has to TELL a caller why there
/// is no token, so the miss now carries its reason.
#[derive(Debug)]
enum StoreOutcome {
    /// An access token that can be handed to the caller verbatim.
    Token(String),
    /// The store could not serve this request; the variant says why.
    Miss(StoreMiss),
}

/// Why the credential store could not serve a request on its own.
///
/// The three variants are kept apart rather than collapsed into one message
/// because each has a DIFFERENT operator fix, and a headless caller acting on
/// the refusal is exactly the audience that cannot go and look.
#[derive(Debug)]
enum StoreMiss {
    /// No store is configured, or it holds no entry for this
    /// `(issuer, account, server)`.
    NoCredentials,
    /// An entry exists and has expired, but carries no refresh token — RFC 6749
    /// §6 does not require one ever to have been issued.
    NoRefreshToken,
    /// A refresh was attempted and refused.
    RefreshFailed(Error),
}

/// Device code authorization response.
#[derive(Debug, Deserialize)]
struct DeviceAuthResponse {
    device_code: String,
    user_code: String,
    verification_uri: String,
    #[serde(default)]
    verification_uri_complete: Option<String>,
    expires_in: u64,
    interval: Option<u64>,
}

/// Token response from the OAuth token endpoint.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenResponse {
    access_token: String,
    #[serde(default)]
    refresh_token: Option<String>,
    #[serde(default)]
    expires_in: Option<u64>,
    token_type: String,
}

/// OAuth helper for CLI authentication flows.
///
/// Supports both Authorization Code Flow with PKCE and Device Code Flow,
/// with automatic discovery of OAuth endpoints from the MCP server URL.
///
/// # RFC 9207 `iss` validation and the `PMCP_OAUTH_ISS_VALIDATION` override
///
/// Every authorization response is validated before its `code` can be
/// exchanged: the CSRF `state` must match the value this flow generated, and an
/// `iss` that is PRESENT is always compared — byte for byte, with no
/// normalisation — against the issuer the authorization server published in its
/// own discovery document. That floor is unconditional. The only configurable
/// question is whether an ABSENT `iss` is fatal, and it is resolved from three
/// tiers, highest first:
///
/// | Tier | Source | Wins over |
/// |---|---|---|
/// | 1 | the `PMCP_OAUTH_ISS_VALIDATION` environment variable | everything below |
/// | 2 | [`OAuthHelper::with_iss_validation`] | the discovery flag |
/// | 3 | the discovery document's `authorization_response_iss_parameter_supported` | — |
///
/// `PMCP_OAUTH_ISS_VALIDATION` accepts exactly two values, compared
/// case-insensitively after trimming:
///
/// - `strict` — an authorization response with no `iss` is REJECTED.
/// - `lenient` — an absent `iss` proceeds (a present one is still compared).
///
/// **An unrecognised value is announced, not swallowed.** `true`, `1` and `yes`
/// are all plausible things to type and none of them is accepted; setting one
/// emits a `tracing::warn!` naming the variable, the value observed and the two
/// accepted values, and the next tier decides. **With the variable unset and no
/// builder call the behaviour is exactly the discovery flag's**, so an existing
/// deployment sees no change.
///
/// The variable is read at the point the flow needs it, never in a constructor,
/// so a hosting platform can supply the policy as a parameter through
/// [`OAuthHelper::with_iss_validation`] instead of through a process
/// environment.
#[derive(Debug)]
pub struct OAuthHelper {
    config: OAuthConfig,
    client: reqwest::Client,
    /// Tier 2 of the `iss` precedence chain. `None` means "not set by the
    /// builder", which is distinct from `Some(IssPresence::Optional)`.
    ///
    /// A PRIVATE field rather than an [`OAuthConfig`] field on purpose:
    /// `OAuthConfig` is public, all-pub-field and not `#[non_exhaustive]`, so a
    /// new field there is `constructible_struct_adds_field` — a MAJOR break that
    /// would invalidate every struct literal, including three in this
    /// repository. `OAuthHelper`'s fields are all private, so adding one here is
    /// semver-free.
    iss_validation: Option<IssPresence>,
    /// How the interactive authorization URL reaches a human. Defaults to
    /// [`SystemBrowserLauncher`], i.e. unchanged behaviour.
    browser_launcher: Arc<dyn BrowserLauncher>,
    /// The SEP-2352 credential store, resolved LAZILY.
    ///
    /// Empty until either [`OAuthHelper::with_credential_store`] fills it or the
    /// first store operation resolves the default [`FileCredentialStore`]. It is
    /// a `OnceLock` rather than an `Option` precisely so the resolution can
    /// happen behind `&self`: `OAuthHelper::new` must perform no filesystem and
    /// no environment access, because a hosting platform that will inject its
    /// own store a line later has no home directory for the default to find.
    credential_store: OnceLock<Arc<dyn CredentialStore>>,
    /// The account component of the [`CredentialKey`]. Empty by default — the
    /// single-user CLI case — and set by
    /// [`OAuthHelper::with_account_scope`] for a multi-tenant caller.
    account_scope: String,
    /// Fires the D-17 legacy-cache warning at most once per instance, rather
    /// than once per call.
    legacy_cache_warned: Once,
    /// Whether this helper may fall back to an interactive browser login.
    ///
    /// A PRIVATE field rather than an [`OAuthConfig`] field for the reason
    /// `iss_validation` above records: `OAuthConfig` is public, all-pub-field
    /// and not `#[non_exhaustive]`, so a new field there is a MAJOR break.
    /// [`Interactivity::Interactive`] is the default, so no existing caller
    /// changes behaviour.
    interactivity: Interactivity,
}

impl OAuthHelper {
    /// Create a new OAuth helper with the given configuration.
    ///
    /// Performs **no** filesystem and no environment access: the credential
    /// store, and therefore any home-directory resolution, is deferred to the
    /// first operation that needs it.
    pub fn new(config: OAuthConfig) -> Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(30))
            .build()
            .map_err(|e| Error::internal(format!("Failed to create HTTP client: {e}")))?;

        Ok(Self {
            config,
            client,
            iss_validation: None,
            browser_launcher: Arc::new(SystemBrowserLauncher),
            credential_store: OnceLock::new(),
            account_scope: String::new(),
            legacy_cache_warned: Once::new(),
            interactivity: Interactivity::Interactive,
        })
    }

    /// Choose whether this helper may fall back to an INTERACTIVE browser
    /// login.
    ///
    /// See [`Interactivity`] for what the two modes mean, what the default one
    /// costs a headless runtime, and why this is an explicit mode rather than
    /// environment sniffing. With no call to this method the helper behaves
    /// exactly as it always has.
    ///
    /// This is an inherent builder method rather than an [`OAuthConfig`] field
    /// because that struct is exhaustively constructible downstream and gaining
    /// a field would be a MAJOR semver break.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use pmcp::client::oauth::{Interactivity, OAuthConfig, OAuthHelper};
    ///
    /// # fn example() -> pmcp::Result<()> {
    /// let helper = OAuthHelper::new(OAuthConfig::default())?
    ///     .with_interactivity(Interactivity::RefreshOnly);
    /// # let _ = helper;
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_interactivity(mut self, mode: Interactivity) -> Self {
        self.interactivity = mode;
        self
    }

    /// Persist credentials through `store` instead of through the default
    /// on-disk [`FileCredentialStore`].
    ///
    /// This is the platform seam. A helper built this way touches no home
    /// directory at all — not in the constructor, and not on any later call —
    /// which is what makes it usable from a Lambda, a container or any runtime
    /// that keeps credentials in a KV store or a secrets manager.
    ///
    /// This is an inherent builder method rather than an [`OAuthConfig`] field
    /// because that struct is exhaustively constructible downstream and gaining
    /// a field would be a MAJOR semver break.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use std::sync::Arc;
    /// use pmcp::client::oauth::{OAuthConfig, OAuthHelper};
    /// use pmcp::{CredentialStore, InMemoryCredentialStore};
    ///
    /// # fn example() -> pmcp::Result<()> {
    /// let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
    /// let helper = OAuthHelper::new(OAuthConfig::default())?
    ///     .with_credential_store(store);
    /// # let _ = helper;
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_credential_store(mut self, store: Arc<dyn CredentialStore>) -> Self {
        self.credential_store = OnceLock::from(store);
        self
    }

    /// Key this helper's credentials under `account`.
    ///
    /// The account is the second component of the [`CredentialKey`], so two
    /// helpers with different account scopes never see one another's
    /// credentials even against the same authorization server and the same MCP
    /// server. The default is the empty string, which is the single-user CLI
    /// case.
    ///
    /// The value is stored verbatim — the SDK does not parse, normalise or
    /// interpret it. A platform passes whatever identifies the principal to it,
    /// such as a Cognito `sub`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use pmcp::client::oauth::{OAuthConfig, OAuthHelper};
    ///
    /// # fn example() -> pmcp::Result<()> {
    /// let helper = OAuthHelper::new(OAuthConfig::default())?
    ///     .with_account_scope("cognito-sub-123");
    /// # let _ = helper;
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_account_scope(mut self, account: impl Into<String>) -> Self {
        self.account_scope = account.into();
        self
    }

    /// Set tier 2 of the RFC 9207 `iss` precedence chain.
    ///
    /// See the [type-level documentation](Self) for the full chain. In short:
    /// `PMCP_OAUTH_ISS_VALIDATION` still wins over whatever is set here, and
    /// whatever is set here wins over the authorization server's own
    /// `authorization_response_iss_parameter_supported` flag. Passing
    /// [`IssPresence::Required`] makes an authorization response with no `iss`
    /// fatal even against a server that advertises nothing.
    ///
    /// This is an inherent builder method rather than an [`OAuthConfig`] field
    /// because that struct is exhaustively constructible downstream and gaining
    /// a field would be a MAJOR semver break.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use pmcp::client::oauth::{OAuthConfig, OAuthHelper};
    /// use pmcp::IssPresence;
    ///
    /// # fn example() -> pmcp::Result<()> {
    /// let helper = OAuthHelper::new(OAuthConfig::default())?
    ///     .with_iss_validation(IssPresence::Required);
    /// # let _ = helper;
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_iss_validation(mut self, presence: IssPresence) -> Self {
        self.iss_validation = Some(presence);
        self
    }

    /// Route the interactive authorization URL through a custom
    /// [`BrowserLauncher`] instead of the platform browser.
    ///
    /// Use this on a headless runner, in a container without a display, or from
    /// a hosting platform that relays the URL through its own UI. With no call
    /// to this method the flow uses [`SystemBrowserLauncher`], i.e. today's
    /// behaviour, unchanged.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use std::sync::Arc;
    /// use pmcp::client::oauth::{BrowserLauncher, OAuthConfig, OAuthHelper};
    ///
    /// #[derive(Debug)]
    /// struct PrintTheUrl;
    /// impl BrowserLauncher for PrintTheUrl {
    ///     fn open(&self, url: &str) -> pmcp::Result<()> {
    ///         println!("Open this URL to continue: {url}");
    ///         Ok(())
    ///     }
    /// }
    ///
    /// # fn example() -> pmcp::Result<()> {
    /// let helper = OAuthHelper::new(OAuthConfig::default())?
    ///     .with_browser_launcher(Arc::new(PrintTheUrl));
    /// # let _ = helper;
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn with_browser_launcher(mut self, launcher: Arc<dyn BrowserLauncher>) -> Self {
        self.browser_launcher = launcher;
        self
    }

    /// Resolve the effective [`IssPresence`] for one flow, reading the
    /// `PMCP_OAUTH_ISS_VALIDATION` override at the CALL SITE.
    ///
    /// The read lives here rather than in [`OAuthHelper::new`] so construction
    /// stays I/O-free and a platform can supply the policy as a parameter. An
    /// unrecognised value warns and falls through; the precedence arithmetic
    /// itself is [`iss_presence_from`]'s, never re-implemented here.
    fn resolve_iss_presence(&self, discovery_flag: Option<bool>) -> IssPresence {
        let env_override = match std::env::var(ISS_VALIDATION_ENV_VAR) {
            Ok(raw) => {
                let parsed = parse_iss_env_value(&raw);
                if parsed.is_none() {
                    tracing::warn!(
                        "{} is set to `{}`, which is not one of its two accepted values \
                         `strict` or `lenient`. The variable is IGNORED; the builder setting \
                         or the authorization server's discovery flag decides instead.",
                        ISS_VALIDATION_ENV_VAR,
                        raw
                    );
                }
                parsed
            },
            Err(std::env::VarError::NotUnicode(_)) => {
                tracing::warn!(
                    "{} is set to a value that is not valid Unicode, so it cannot be one of \
                     the two accepted values `strict` or `lenient`. The variable is IGNORED.",
                    ISS_VALIDATION_ENV_VAR
                );
                None
            },
            Err(std::env::VarError::NotPresent) => None,
        };

        iss_presence_from(env_override, self.iss_validation, discovery_flag)
    }

    /// The credential store this helper persists through, or `None` when the
    /// caller asked for no persistence at all.
    ///
    /// Resolution order, evaluated on FIRST USE and never in the constructor:
    ///
    /// 1. a store injected through [`OAuthHelper::with_credential_store`];
    /// 2. otherwise, when [`OAuthConfig::cache_file`] is set, a
    ///    [`FileCredentialStore`] over `<that file's directory>/oauth-cache.json`;
    /// 3. otherwise **no store** — the caller opted out of caching.
    ///
    /// # Why `cache_file` names the DIRECTORY and not the store itself
    ///
    /// `cache_file` points at the flat, issuer-less `TokenCache` document this
    /// module used to write. Every such file on disk today is in that format, so
    /// pointing the issuer-keyed store at it would both READ it — which D-17
    /// forbids, because an issuer-less token cannot be attributed to an
    /// authorization server without guessing — and OVERWRITE it, when the whole
    /// point is to leave it for the user to delete. The directory is honoured;
    /// the file is not.
    ///
    /// # Why an absent `cache_file` means no persistence
    ///
    /// That is what it has always meant here: every previous cache read and
    /// write in this module was guarded by `if let Some(ref cache_file) =
    /// self.config.cache_file`. `cargo pmcp auth login --no-cache` sets the
    /// field to `None` for exactly that reason, so resolving a default store in
    /// that case would silently defeat the flag.
    ///
    /// A caller that wants a store at a specific path builds one:
    /// `with_credential_store(Arc::new(FileCredentialStore::new(path)))`.
    fn credential_store(&self) -> Option<&Arc<dyn CredentialStore>> {
        if let Some(injected) = self.credential_store.get() {
            return Some(injected);
        }

        let legacy = self.config.cache_file.as_ref()?;
        let resolved: Arc<dyn CredentialStore> = Arc::new(FileCredentialStore::new(
            legacy.with_file_name(CREDENTIAL_STORE_FILE_NAME),
        ));
        Some(self.credential_store.get_or_init(|| resolved))
    }

    /// The normalized MCP server key — the THIRD component of the
    /// [`CredentialKey`], and the one SEP-2352 does not name.
    ///
    /// Two MCP servers can share one authorization server and one account while
    /// holding different dynamic registrations, different client IDs and
    /// different granted scopes. Without this component they share one entry, so
    /// whichever authenticated last overwrites the other and a logout on one
    /// deletes the other's credentials (D-116-R1, AUTH-03 as amended in
    /// `0aebf7f6`). RFC 8707's `resource` parameter would have bound the
    /// audience instead; it is deferred by owner decision, so the key carries
    /// the binding.
    ///
    /// It is computed with [`normalize_server_key`] so that trailing-slash,
    /// path and host-case variants of one MCP server URL do not become two
    /// logins.
    ///
    /// # Errors
    ///
    /// When neither `mcp_server_url` nor `issuer` is configured there is nothing
    /// to key against, and the same configuration cannot discover metadata
    /// either. The issuer is the fallback because a helper configured with only
    /// an issuer is talking to that authorization server directly, so its URL is
    /// the only stable server identity available.
    fn server_key(&self) -> Result<String> {
        if let Some(ref url) = self.config.mcp_server_url {
            return normalize_server_key(url);
        }
        if let Some(ref issuer) = self.config.issuer {
            return normalize_server_key(issuer);
        }
        Err(Error::internal(
            "cannot address stored credentials: neither mcp_server_url nor issuer is configured"
                .to_string(),
        ))
    }

    /// The `(issuer, account, server)` address of this helper's credentials.
    ///
    /// `issuer` must be the issuer the authorization server published in its OWN
    /// discovery document — the same value AUTH-01 uses as its RFC 9207 anchor —
    /// and never `config.issuer`, which is a user-typed discovery seed.
    fn credential_key(&self, issuer: &str, server_key: &str) -> CredentialKey {
        CredentialKey::new(issuer, self.account_scope.as_str(), server_key)
    }

    /// Announce an authorization-server SUBSTITUTION for this MCP server, and
    /// refuse the flow exactly where the specification asks for a refusal
    /// (D-18, refined by RESEARCH A4).
    ///
    /// Issuer-keyed storage makes a substitution SAFE — the old credentials
    /// simply become unreachable — but it also makes it INVISIBLE: the user is
    /// walked through a fresh login at an identity provider they did not expect,
    /// and nothing says so. This is the "says so".
    ///
    /// # The mechanism the specification names is NOT the one used here
    ///
    /// The specification describes an authorization-server change as one
    /// "detected via updated protected resource metadata" — RFC 9728 Protected
    /// Resource Metadata. `pmcp` does not implement RFC 9728: it derives the
    /// authorization server from the MCP base URL directly (see
    /// [`Self::discover_metadata_with_extras`]), and RFC 9728 discovery is
    /// **DEFERRED by owner decision (2026-08-02)**, recorded with a named owner
    /// in this phase's deferred-items file.
    ///
    /// So detection here uses the provenance signal that exists today: the
    /// issuer discovery actually RESOLVED for this MCP server URL, compared
    /// against the one last recorded for it. That is a narrower signal than the
    /// specification's, and it is stated plainly rather than presented as
    /// parity. Practically it detects a server that starts pointing at a
    /// different authorization server; it cannot detect a change announced only
    /// through protected resource metadata, because nothing reads that yet.
    ///
    /// # Why the remedy depends on credential PROVENANCE
    ///
    /// The specification's two adjacent sentences prescribe different remedies,
    /// and collapsing them into one would be wrong in both directions:
    ///
    /// - **DCR-issued** (`config.client_id` is `None`) — warn and PROCEED. The
    ///   requirement is "MUST NOT reuse client credentials from a different
    ///   authorization server and MUST re-register with the new authorization
    ///   server", which issuer-keyed storage already accomplishes by missing the
    ///   cache. Hard-failing here would convert a legitimate operational event —
    ///   a tenant move, a provider migration — into an outage, which D-18
    ///   explicitly rejects.
    /// - **Pre-registered** (`config.client_id` is `Some`) — return
    ///   [`Error::reauth_required`]. A pre-registered id is provisioned for one
    ///   authorization server and is meaningless at another; silently re-running
    ///   a browser login against an unexpected identity provider with it is
    ///   precisely the case the specification warns about. The browser flow is
    ///   not started.
    ///
    /// # Errors
    ///
    /// The ONLY error this returns is the pre-registered refusal above. Every
    /// store failure — an unreadable issuer record, an unaddressable server, a
    /// failed write — warns and proceeds, because a store that cannot be read
    /// must not be able to brick authentication and because the resulting
    /// behaviour is exactly today's: no detection. That is also what the
    /// [`CredentialStore::last_issuer`] default (`Ok(None)`) promises an
    /// implementor who declines the tracking.
    async fn announce_authorization_server_change(
        &self,
        metadata: &OidcDiscoveryMetadata,
    ) -> Result<()> {
        let Some(store) = self.credential_store() else {
            return Ok(());
        };
        let Ok(server_key) = self.server_key() else {
            return Ok(());
        };
        let discovered = metadata.issuer.as_str();

        let previous = match store.last_issuer(&server_key).await {
            Ok(previous) => previous,
            Err(e) => {
                tracing::warn!(
                    "could not read the last-seen authorization server for {server_key} ({e}); \
                     proceeding without substitution detection"
                );
                return Ok(());
            },
        };

        let Some(previous) = previous else {
            // First connection for this MCP server. Recording HERE, rather than
            // only on a successful authorization, means a login that never
            // completes still establishes the anchor a second connection needs.
            Self::record_issuer_best_effort(store, &server_key, discovered).await;
            return Ok(());
        };

        if previous == discovered {
            return Ok(());
        }

        if self.config.client_id.is_some() {
            return Err(Error::reauth_required(
                discovered,
                &format!(
                    "the authorization server for MCP server {server_key} changed from \
                     {previous} to {discovered}. This client is configured with a PRE-REGISTERED \
                     client_id, which is specific to one authorization server, so it is neither \
                     reused nor exchanged at the new one and no browser flow is started. If the \
                     change is expected, register this client with {discovered} and update \
                     OAuthConfig::client_id; if it is not, treat the MCP server as compromised."
                ),
            ));
        }

        tracing::warn!(
            "the authorization server for MCP server {} changed from {} to {}. This client's \
             credentials were issued by dynamic registration, so the previous ones are neither \
             reused nor sent anywhere — they are simply unreachable under the new issuer — and \
             this client is re-registering with {} and asking you to log in there. If you did not \
             expect that identity provider, stop and treat the MCP server as compromised.",
            server_key,
            previous,
            discovered,
            discovered
        );
        Self::record_issuer_best_effort(store, &server_key, discovered).await;
        Ok(())
    }

    /// Record a server's last-seen issuer, warning rather than failing when the
    /// store refuses.
    ///
    /// Detection is diagnostic, so a write failure here must not abort a flow.
    /// The AUTHORITATIVE record is the one `save_with_issuer` writes on success,
    /// and that failure IS propagated.
    async fn record_issuer_best_effort(
        store: &Arc<dyn CredentialStore>,
        server_key: &str,
        issuer: &str,
    ) {
        if let Err(e) = store.record_issuer(server_key, issuer).await {
            tracing::warn!(
                "could not record the authorization server for {server_key} ({e}); a later \
                 substitution may go undetected until the next successful login"
            );
        }
    }

    /// Announce, once per helper, that the legacy flat token cache is being
    /// discarded (D-17).
    ///
    /// The old `~/.pmcp/oauth-tokens.json` holds a single token and records NO
    /// issuer. It cannot be re-keyed for [`CredentialKey`] without GUESSING
    /// which authorization server issued it, and guessing is precisely what
    /// SEP-2352 forbids — so it is never opened for reading and never migrated.
    /// It is also never deleted or renamed: removing a user's file to fix a
    /// format problem is not this SDK's call.
    ///
    /// `cargo-pmcp`'s own multi-server cache is a different file that DOES
    /// record an issuer per entry, and it gets a real migration inside
    /// [`FileCredentialStore`].
    fn discard_legacy_token_cache(&self) {
        self.legacy_cache_warned.call_once(|| {
            let legacy = self
                .config
                .cache_file
                .clone()
                .unwrap_or_else(default_cache_path);
            if !legacy.exists() {
                return;
            }
            tracing::warn!(
                "the legacy OAuth token cache at {} is DISCARDED, not migrated: it records no \
                 issuer, so which authorization server issued its token cannot be determined \
                 without guessing — and guessing is what SEP-2352 forbids. One re-login is \
                 required. The file is left in place; delete it when you are ready.",
                legacy.display()
            );
        });
    }

    /// Read the credentials stored for `issuer` under this helper's account and
    /// server, treating every failure as a cache MISS.
    ///
    /// A store that cannot be read must never be able to prevent a fresh login:
    /// a corrupt document, a stale lock or a path that now holds the legacy flat
    /// format would otherwise brick authentication entirely. The failure is
    /// warned — naming the path or the reason the store gave, never any
    /// credential content — and the flow continues as if nothing were cached,
    /// which costs exactly one interactive login.
    async fn load_stored_credentials(&self, issuer: &str) -> Option<StoredCredentials> {
        let store = self.credential_store()?;
        let server_key = match self.server_key() {
            Ok(key) => key,
            Err(e) => {
                tracing::warn!("cannot address stored credentials ({e}); treating as a cache miss");
                return None;
            },
        };

        match store.load(&self.credential_key(issuer, &server_key)).await {
            Ok(found) => found,
            Err(e) => {
                tracing::warn!(
                    "the credential store could not be read ({e}); treating as a cache miss, \
                     which costs one re-login"
                );
                None
            },
        }
    }

    /// Persist one successful authorization under `(issuer, account, server)`,
    /// together with the server's last-seen issuer.
    ///
    /// `issuer` is the discovery document's issuer, never `config.issuer`.
    ///
    /// The record carries the effective `client_id`, the GRANTED scopes and the
    /// registered `application_type`. The client id is not a convenience: it is
    /// what makes SEP-2352's "MUST re-register with the new authorization
    /// server" automatic, because a DCR-issued id lives under the key of the
    /// authorization server that issued it and is unreachable from any other.
    ///
    /// The write goes through [`CredentialStore::save_with_issuer`] rather than
    /// `save` followed by `record_issuer`: two separate writes leave a window in
    /// which the store names one issuer while holding another's credentials, and
    /// [`FileCredentialStore`] implements the combined method as ONE atomic
    /// read-modify-write precisely so this call site is correct for free.
    async fn persist_credentials(
        &self,
        issuer: &str,
        result: &AuthorizationResult,
        registered_application_type: Option<&str>,
    ) -> Result<()> {
        let Some(store) = self.credential_store() else {
            return Ok(());
        };

        let server_key = self.server_key()?;
        let mut credentials = StoredCredentials::new(&result.access_token, &result.client_id)
            .with_granted_scopes(result.scopes.clone());
        if let Some(refresh_token) = result.refresh_token.as_deref() {
            credentials = credentials.with_refresh_token(refresh_token);
        }
        if let Some(expires_at) = result.expires_at {
            credentials = credentials.with_expires_at(expires_at);
        }
        if let Some(application_type) = registered_application_type {
            credentials = credentials.with_registered_application_type(application_type);
        }

        store
            .save_with_issuer(
                &self.credential_key(issuer, &server_key),
                &credentials,
                &server_key,
                issuer,
            )
            .await
    }

    /// The issuer REPORTED to a caller for its own bookkeeping: the
    /// caller-provided `config.issuer` when there is one, else the discovered
    /// value.
    ///
    /// Deliberately distinct from the value used to KEY credentials and to
    /// anchor RFC 9207 validation, both of which are `metadata.issuer` alone.
    fn effective_issuer(&self, metadata: &OidcDiscoveryMetadata) -> Option<String> {
        self.config
            .issuer
            .clone()
            .or_else(|| Some(metadata.issuer.clone()))
    }

    /// Perform RFC 7591 Dynamic Client Registration against `registration_endpoint`.
    ///
    /// Body is a public PKCE shape (`token_endpoint_auth_method: "none"`, no secret
    /// requested). `client_name` falls back to `"pmcp-sdk"` when the config value is
    /// `None`. Non-`https://` endpoints are rejected except for localhost loopback
    /// variants, guarding against discovery-spoofing. Response body size is capped
    /// at [`MAX_DCR_RESPONSE_BYTES`] on both the success and the rejection path.
    ///
    /// # The two SEPs this body carries
    ///
    /// - **SEP-837** — an `application_type` derived from the `redirect_uris`
    ///   being registered, sent UNCONDITIONALLY. Omitting it defaults to `web`
    ///   under OIDC, which contradicts the loopback redirect this very request
    ///   registers; a non-OIDC authorization server safely ignores it.
    /// - **SEP-2207** — `refresh_token` declared in `grant_types`, plus
    ///   `offline_access` in the registered `scope` when the authorization server
    ///   advertises it. See [`OFFLINE_ACCESS_SCOPE`] for why this is client
    ///   METADATA and not the request itself.
    ///
    /// `metadata` is taken as a parameter (rather than re-discovered) so
    /// `scopes_supported` is read from the same document the rest of the flow
    /// used. This is a private method, so widening its signature costs nothing.
    async fn do_dynamic_client_registration(
        &self,
        registration_endpoint: &str,
        metadata: &OidcDiscoveryMetadata,
    ) -> Result<DcrOutcome> {
        let parsed = Url::parse(registration_endpoint)
            .map_err(|e| Error::internal(format!("Invalid registration_endpoint URL: {e}")))?;
        // `url::Url::host_str()` returns IPv6 literals WITH brackets (e.g.
        // `http://[::1]/register` -> `Some("[::1]")`); match both forms.
        let scheme_ok = parsed.scheme() == "https"
            || (parsed.scheme() == "http"
                && matches!(
                    parsed.host_str(),
                    Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]")
                ));
        if !scheme_ok {
            return Err(Error::internal(format!(
                "registration_endpoint must be https:// (or http://localhost, \
                 http://127.0.0.1, http://[::1]) — got {}",
                registration_endpoint
            )));
        }

        let client_name = self
            .config
            .client_name
            .clone()
            .unwrap_or_else(|| "pmcp-sdk".to_string());
        // Literal `127.0.0.1` rather than `localhost` — per RFC 8252 §7.3, avoids
        // browsers resolving `localhost` to `::1` when the listener binds IPv4-only.
        let redirect_uri = format!("http://127.0.0.1:{}/callback", self.config.redirect_port);

        // SEP-2207 client metadata: declaring `offline_access` here says what this
        // client is PERMITTED to ask for. The ask itself happens at the
        // authorization request — see `build_authorization_url`.
        let registered_scopes =
            compose_scopes_with_offline_access(&self.config.scopes, &metadata.scopes_supported);

        let mut request = crate::server::auth::provider::DcrRequest {
            redirect_uris: vec![redirect_uri],
            client_name: Some(client_name),
            client_uri: None,
            logo_uri: None,
            contacts: vec![],
            token_endpoint_auth_method: Some("none".to_string()),
            // SEP-2207: an authorization server that was never told this client
            // wants a refresh grant has every reason not to issue a refresh
            // token. This is the missing prerequisite for refresh working at all.
            grant_types: vec![
                "authorization_code".to_string(),
                "refresh_token".to_string(),
            ],
            // `DcrRequest` has `#[serde(skip_serializing_if = "Vec::is_empty")]`;
            // RFC 7591 §3.1 requires `response_types` in the body, so it must be
            // non-empty. `"code"` is the authorization-code public-PKCE flow.
            response_types: vec!["code".to_string()],
            // `scope` is `skip_serializing_if = "Option::is_none"`, so an empty
            // composition leaves the key off the wire entirely rather than
            // registering an empty string.
            scope: (!registered_scopes.is_empty()).then(|| registered_scopes.join(" ")),
            software_id: None,
            software_version: None,
            extra: Default::default(),
        };

        // SEP-837, sent on every era and under every configuration: there is no
        // gate, no feature flag and no era check. `application_type` has been a
        // standard OIDC Dynamic Registration parameter since 2014, a non-OIDC
        // server ignores it, and era-gating would require plumbing a protocol
        // era into DCR that does not exist before an MCP connection is
        // established.
        let sent_application_type = apply_application_type(&mut request)?;

        let response = self
            .client
            .post(registration_endpoint)
            .json(&request)
            .send()
            .await
            .map_err(|e| Error::internal(format!("DCR request failed: {e}")))?;

        if !response.status().is_success() {
            let status = response.status();
            // The rejection body is as authorization-server-controlled as the
            // success body, so it is read through the SAME bounded reader. Its
            // over-cap refusal names the cap and reproduces no body content, and
            // it propagates verbatim: a registration endpoint that answers a
            // refusal with a gigabyte must not be able to spend a gigabyte of
            // this client's memory on the way to being refused.
            let body = collect_reqwest_body_within_cap(response, MAX_DCR_RESPONSE_BYTES).await?;
            let fields = dcr_rejection_fields(&body);
            return Err(registration_rejected(
                status,
                &fields,
                &sent_application_type,
                &request.redirect_uris,
            ));
        }

        // The cap is enforced DURING the read, not after it. The previous shape
        // read the whole body with `.bytes()` and then measured it, which bounds
        // what is ACCEPTED and not what is ALLOCATED — a registration endpoint
        // answering with a gigabyte still spent a gigabyte of this client's
        // memory on the way to being refused (D-113-V). The cap VALUE is
        // unchanged at 1 MiB: this is a change of mechanism, not of policy.
        let bytes = collect_reqwest_body_within_cap(response, MAX_DCR_RESPONSE_BYTES)
            .await
            .map_err(|e| {
                if is_body_over_cap(&e) {
                    // Re-framed as a DCR refusal so the message names the
                    // endpoint's own limit; the shared refusal's rule is kept —
                    // it names the cap and the observed size and reproduces no
                    // byte of the refused body.
                    Error::internal(format!(
                        "DCR response exceeds the {MAX_DCR_RESPONSE_BYTES} byte cap — refusing \
                         to parse it. {e}"
                    ))
                } else {
                    e
                }
            })?;
        let registration = serde_json::from_slice::<crate::server::auth::provider::DcrResponse>(
            &bytes,
        )
        .map_err(|e| {
            Error::internal(format!(
                "Failed to parse DCR response ({:?} error at line {}, column {}). The parser's \
                 own message is not reproduced here because a data error echoes the offending \
                 input, and a registration response body carries a client identity",
                e.classify(),
                e.line(),
                e.column()
            ))
        })?;

        // D-11 — echo divergence WARNS and never fails. See
        // `application_type_divergence` for why an omitted echo is not a
        // divergence, and why a real one is not an error.
        let echoed = registration.application_type();
        if let Some((requested, registered)) =
            application_type_divergence(&sent_application_type, echoed)
        {
            tracing::warn!(
                "the registration endpoint at {} registered this client with \
                 application_type=\"{}\" although \"{}\" was requested. RFC 7591 section 3.2.1 \
                 permits an authorization server to modify requested client metadata, so the \
                 registration STANDS and this is not an error — but this client is now registered \
                 under redirect-URI constraints it did not choose.",
                registration_endpoint,
                registered,
                requested
            );
        }
        // The type this client is actually registered under: the server's answer
        // when it gave one, otherwise the value that was sent, because RFC 7591
        // does not require an echo and an un-echoed request is registered as
        // requested.
        let registered_application_type =
            echoed.unwrap_or(sent_application_type.as_str()).to_string();

        Ok(DcrOutcome {
            response: registration,
            registered_application_type,
        })
    }

    /// Resolve the `client_id` for the current OAuth flow, performing DCR
    /// lazily when all three conditions hold:
    ///   1. `self.config.dcr_enabled == true`
    ///   2. `self.config.client_id.is_none()`
    ///   3. `metadata.registration_endpoint.is_some()`
    ///
    /// Returns `Err` with an actionable message when DCR is needed but the
    /// server does not advertise a `registration_endpoint`.
    ///
    /// The returned [`ResolvedClientIdentity`] carries the registered
    /// `application_type` alongside the id, because that value has to reach
    /// [`StoredCredentials::with_registered_application_type`] and there is no
    /// other hop out of the registration call that costs no semver event.
    async fn resolve_client_identity_for_flow(
        &self,
        metadata: &OidcDiscoveryMetadata,
    ) -> Result<ResolvedClientIdentity> {
        // Caller-provided client_id skips DCR entirely.
        if let Some(ref id) = self.config.client_id {
            return Ok(ResolvedClientIdentity {
                client_id: id.clone(),
                registered_application_type: None,
            });
        }

        if !self.config.dcr_enabled {
            return Err(Error::internal(
                "no client_id configured and dcr_enabled is false — \
                 provide OAuthConfig::client_id or enable dcr_enabled"
                    .to_string(),
            ));
        }

        match metadata.registration_endpoint.as_ref() {
            Some(endpoint) => {
                tracing::info!("Performing Dynamic Client Registration at {}", endpoint);
                let outcome = self
                    .do_dynamic_client_registration(endpoint, metadata)
                    .await?;
                // The registered `application_type` is both LOGGED and CARRIED:
                // the log serves a developer diagnosing a redirect-URI refusal,
                // and the carried value reaches
                // `StoredCredentials::with_registered_application_type` so the
                // same diagnosis is possible from the stored record later.
                tracing::info!(
                    "DCR succeeded — issued client_id, registered with application_type=\"{}\"",
                    outcome.registered_application_type
                );
                Ok(ResolvedClientIdentity {
                    client_id: outcome.response.client_id,
                    registered_application_type: Some(outcome.registered_application_type),
                })
            },
            None => Err(Error::internal(
                "server does not support DCR — pass a pre-registered client_id".to_string(),
            )),
        }
    }

    /// Test-only hook: drive the discovery + DCR resolver path without invoking
    /// the browser PKCE flow. Used by `tests/oauth_dcr_integration.rs`.
    ///
    /// Integration tests under `tests/` compile as a separate crate, so the
    /// library's `#[cfg(test)]` does not apply. The `oauth` feature gate plus
    /// `#[doc(hidden)]` and the `test_` prefix discourage external use.
    #[doc(hidden)]
    #[cfg(any(test, feature = "oauth"))]
    pub async fn test_resolve_client_id_from_discovery(&self) -> Result<String> {
        let metadata = self.get_metadata().await?;
        self.resolve_client_identity_for_flow(&metadata)
            .await
            .map(|identity| identity.client_id)
    }

    /// Whether an authorization-code-flow failure is a validation REFUSAL that
    /// must be surfaced verbatim rather than downgraded.
    ///
    /// Both callers of the authorization-code flow fall back to device code (or
    /// to a generic "no supported OAuth flow available" message) when it fails.
    /// That is right for "this flow was not available" and WRONG for "this flow
    /// detected an attack", for two reasons:
    ///
    /// - It destroys the stable programmatic identity. A caller that branches on
    ///   `err.is_iss_mismatch()` — the whole reason those markers exist — would
    ///   see a generic internal error instead, and message-substring matching is
    ///   exactly what the markers replaced.
    /// - It re-attempts authentication against an authorization server whose
    ///   response just failed a mix-up or CSRF check, handing the same attacker
    ///   a second attempt through a different grant.
    ///
    /// Every other failure keeps its existing fallback behaviour untouched.
    fn is_terminal_authorization_refusal(error: &Error) -> bool {
        error.is_iss_mismatch() || error.is_state_mismatch()
    }

    /// Extract base URL from MCP server URL.
    ///
    /// For example, `https://api.example.com/mcp` becomes `https://api.example.com`.
    fn extract_base_url(mcp_url: &str) -> Result<String> {
        let parsed = Url::parse(mcp_url)
            .map_err(|e| Error::internal(format!("Invalid MCP server URL: {e}")))?;

        // Build base URL with scheme, host, and port
        let mut base = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
        if let Some(port) = parsed.port() {
            // Only add port if it's not the default for the scheme
            let is_default_port = (parsed.scheme() == "https" && port == 443)
                || (parsed.scheme() == "http" && port == 80);
            if !is_default_port {
                base.push_str(&format!(":{}", port));
            }
        }

        Ok(base)
    }

    /// Discover OAuth metadata from MCP server URL using OIDC discovery.
    async fn discover_metadata_with_extras(
        &self,
        mcp_url: &str,
    ) -> Result<(OidcDiscoveryMetadata, AuthorizationServerExtras)> {
        let base_url = Self::extract_base_url(mcp_url)?;

        tracing::info!("Discovering OAuth configuration from {}...", base_url);

        let discovery_client = OidcDiscoveryClient::new();

        match discovery_client.discover_with_extras(&base_url).await {
            Ok((metadata, extras)) => {
                tracing::info!("OAuth discovery successful");
                tracing::debug!("Issuer: {}", metadata.issuer);
                if let Some(ref device_endpoint) = metadata.device_authorization_endpoint {
                    tracing::debug!("Device endpoint: {}", device_endpoint);
                }
                Ok((metadata, extras))
            },
            Err(e) => Err(Error::internal(format!(
                "Failed to discover OAuth configuration at {}: {}\n\
                 \n\
                 Please provide --oauth-issuer explicitly, or ensure the server\n\
                 exposes OAuth metadata at {}/.well-known/openid-configuration",
                base_url, e, base_url
            ))),
        }
    }

    /// Get OAuth metadata (either by discovering or constructing from issuer).
    ///
    /// Delegates to [`Self::get_metadata_with_extras`] and discards the RFC 9207
    /// flag, so callers that do not resolve `iss` policy are untouched.
    async fn get_metadata(&self) -> Result<OidcDiscoveryMetadata> {
        self.get_metadata_with_extras()
            .await
            .map(|(metadata, _)| metadata)
    }

    /// Get OAuth metadata together with the discovery-only values that do not
    /// fit on [`OidcDiscoveryMetadata`] — in particular RFC 9207's
    /// `authorization_response_iss_parameter_supported`, which is tier 3 of the
    /// `iss` precedence chain.
    async fn get_metadata_with_extras(
        &self,
    ) -> Result<(OidcDiscoveryMetadata, AuthorizationServerExtras)> {
        if let Some(ref mcp_url) = self.config.mcp_server_url {
            // Discover from MCP server URL
            self.discover_metadata_with_extras(mcp_url).await
        } else if let Some(ref issuer) = self.config.issuer {
            // Manually provided issuer - try to discover from it
            tracing::info!("Discovering OAuth configuration from {}...", issuer);

            let discovery_client = OidcDiscoveryClient::new();
            match discovery_client.discover_with_extras(issuer).await {
                Ok(found) => {
                    tracing::info!("OAuth discovery successful");
                    Ok(found)
                },
                Err(e) => Err(Error::internal(format!(
                    "Failed to discover OAuth configuration from issuer {}: {}\n\
                     \n\
                     Please ensure the issuer URL exposes OAuth metadata at\n\
                     {}/.well-known/openid-configuration",
                    issuer, e, issuer
                ))),
            }
        } else {
            Err(Error::internal(
                "Either oauth_issuer or mcp_server_url must be provided for OAuth authentication"
                    .to_string(),
            ))
        }
    }

    /// Get or refresh access token, performing OAuth flow if needed.
    ///
    /// For callers that only need a bearer-header value. Cache consumers that
    /// need to persist `refresh_token` / `expires_at` / `issuer` across runs
    /// should use [`authorize_with_details`](Self::authorize_with_details) instead.
    ///
    /// # Discovery now precedes the cache read, and it has to
    ///
    /// Credentials are addressed by the authorization server that ISSUED them
    /// (SEP-2352), so the issuer has to be known before the store can be asked
    /// anything. Discovery therefore runs first even on a cache hit. What a hit
    /// still avoids is the part that costs a human: no browser is opened and no
    /// authorization request is made.
    ///
    /// # Interactivity
    ///
    /// Under [`Interactivity::RefreshOnly`] this method never reaches the
    /// interactive tail at all: a store miss becomes
    /// [`Error::reauth_required`] immediately. See [`Interactivity`].
    pub async fn get_access_token(&self) -> Result<String> {
        self.discard_legacy_token_cache();

        // Get metadata to see what flows are supported. The extras carry the
        // RFC 9207 flag, which is tier 3 of the `iss` precedence chain.
        let (metadata, extras) = self.get_metadata_with_extras().await?;
        let iss_presence = self.resolve_iss_presence(extras.iss_parameter_supported());

        // D-18, BEFORE the cache is consulted and before anything interactive:
        // a pre-registered client must not be walked through a login at an
        // authorization server it was not provisioned for.
        self.announce_authorization_server_change(&metadata).await?;

        let miss = match self.token_from_store(&metadata).await? {
            StoreOutcome::Token(access_token) => return Ok(access_token),
            StoreOutcome::Miss(miss) => miss,
        };

        match self.interactivity {
            // NOTHING reachable from this arm can bind a socket or open a
            // browser: `refresh_only_refusal` is an associated function with no
            // `self`, so it holds no `BrowserLauncher`, no `redirect_port` and
            // no route to `authorization_code_flow_inner`. That is what makes
            // the interactive path unreachable BY CONSTRUCTION here rather than
            // merely skipped by this `match`.
            Interactivity::RefreshOnly => Err(Self::refresh_only_refusal(&metadata, &miss)),
            Interactivity::Interactive => self.interactive_token(&metadata, iss_presence).await,
        }
    }

    /// The interactive tail: bind a loopback listener, hand a URL to a browser,
    /// wait up to five minutes for the callback.
    ///
    /// Split out of [`Self::get_access_token`] so that the one call site is
    /// visibly the [`Interactivity::Interactive`] arm of a two-arm `match`. A
    /// reviewer checking D-08's guarantee has to read exactly one `match` and
    /// one call site, instead of tracing a fall-through through the rest of the
    /// function.
    async fn interactive_token(
        &self,
        metadata: &OidcDiscoveryMetadata,
        iss_presence: IssPresence,
    ) -> Result<String> {
        tracing::info!("No cached token found, starting OAuth flow...");
        self.authorize_with_fallback(metadata, iss_presence)
            .await
            .map(|result| result.access_token)
    }

    /// The typed refusal an [`Interactivity::RefreshOnly`] caller receives
    /// instead of a browser it cannot see and a five-minute wait.
    ///
    /// An associated function taking no `self` **on purpose** — see the comment
    /// at its call site. It also names WHICH of the three conditions occurred,
    /// because the operator fix differs: a rejected refresh token means
    /// re-authorize, an absent one means re-authorize asking for
    /// `offline_access`, and no credentials at all means the store was never
    /// seeded for this `(issuer, account, server)`.
    fn refresh_only_refusal(metadata: &OidcDiscoveryMetadata, miss: &StoreMiss) -> Error {
        let reason = match miss {
            StoreMiss::NoCredentials => {
                "no credentials are stored for this authorization server, account and MCP server"
                    .to_string()
            },
            StoreMiss::NoRefreshToken => {
                "the stored credentials have expired and carry no refresh token".to_string()
            },
            StoreMiss::RefreshFailed(e) => format!("the stored refresh token was refused: {e}"),
        };
        Error::reauth_required(
            &metadata.issuer,
            &format!(
                "{reason}. This helper is in Interactivity::RefreshOnly, so no browser was \
                 opened and no loopback listener was bound. An interactive authorization is \
                 required; perform one and store the result, then retry."
            ),
        )
    }

    /// Serve this request from the credential store when it can be: a live
    /// token verbatim, or a refreshed one.
    ///
    /// A [`StoreOutcome::Miss`] carries WHY, because
    /// [`Interactivity::RefreshOnly`] has to report a reason to a caller that
    /// cannot see a browser, and "nothing was stored" and "the refresh was
    /// refused" have different fixes.
    async fn token_from_store(&self, metadata: &OidcDiscoveryMetadata) -> Result<StoreOutcome> {
        let Some(cached) = self.load_stored_credentials(&metadata.issuer).await else {
            return Ok(StoreOutcome::Miss(StoreMiss::NoCredentials));
        };

        if cached.expires_at().is_some_and(|at| unix_now_secs() < at) {
            tracing::info!("Using cached OAuth token");
            return Ok(StoreOutcome::Token(cached.access_token().to_string()));
        }

        let Some(refresh_token) = cached.refresh_token() else {
            return Ok(StoreOutcome::Miss(StoreMiss::NoRefreshToken));
        };
        tracing::warn!("OAuth token expired, refreshing...");
        let refreshed = match self
            .refresh_token(
                refresh_token,
                Some(cached.client_id()),
                cached.granted_scopes(),
            )
            .await
        {
            Ok(refreshed) => refreshed,
            Err(e) => {
                // Previously SILENT. A refresh failure is the single most useful
                // line in an unattended log, because everything downstream of it
                // needs a human: the flow is about to fall back to a browser
                // nobody may be watching. The message carries the authorization
                // server's own reason and no credential content.
                tracing::warn!("OAuth token refresh failed: {e}");
                return Ok(StoreOutcome::Miss(StoreMiss::RefreshFailed(e)));
            },
        };

        // The refreshed record inherits everything the token response does not
        // restate. RFC 6749 section 6 permits an authorization server to omit a
        // new refresh token, in which case the existing one stays valid, and it
        // never restates the client id or the registered application type.
        //
        // D-14 defect 1 lives on the next three lines. `TokenResponse`'s
        // `refresh_token` is `#[serde(default)]`, so an OMITTED field arrives as
        // `None` — and `None` looks exactly like data here. Writing it over a
        // good token limits an unattended agent to exactly ONE refresh cycle
        // before it demands a human, so the rule is: replace only when the
        // response actually SUPPLIES one.
        let result = AuthorizationResult {
            access_token: refreshed.access_token,
            refresh_token: refreshed
                .refresh_token
                .or_else(|| Some(refresh_token.to_string())),
            // `saturating_add`, because `expires_in` is peer-supplied: a
            // hostile `u64::MAX` would otherwise panic in a debug build.
            expires_at: refreshed
                .expires_in
                .map(|ttl| unix_now_secs().saturating_add(ttl)),
            scopes: cached.granted_scopes().to_vec(),
            issuer: self.effective_issuer(metadata),
            client_id: cached.client_id().to_string(),
        };
        self.persist_credentials(
            &metadata.issuer,
            &result,
            cached.registered_application_type(),
        )
        .await?;
        Ok(StoreOutcome::Token(result.access_token))
    }

    /// Like `get_access_token` but returns the full authorization result for
    /// cache persistence.
    ///
    /// Cache callers (e.g., `cargo pmcp auth login`) should prefer this method;
    /// simple callers that just need a bearer header can keep using
    /// `get_access_token`.
    ///
    /// Drives DCR lazily when eligible; runs PKCE via the authorization_code
    /// flow; captures `refresh_token`, `expires_at`, `scopes`, and the
    /// effective issuer + client_id.
    ///
    /// # Device-code fallback
    ///
    /// If the authorization-code flow fails and the server advertises a
    /// `device_authorization_endpoint`, this method falls back to device code
    /// flow (RFC 8628). In that case, `refresh_token` may be `None` since
    /// RFC 8628 §3.5 does not require it, and `scopes` falls back to the
    /// requested scopes when the token response does not echo them.
    ///
    /// # Interactivity
    ///
    /// This method IS the interactive authorization, so under
    /// [`Interactivity::RefreshOnly`] it refuses with
    /// [`Error::reauth_required`] before performing any I/O at all. The mode's
    /// guarantee has to hold at BOTH public entry points, or a caller escapes
    /// it by picking the other one.
    pub async fn authorize_with_details(&self) -> Result<AuthorizationResult> {
        if self.interactivity == Interactivity::RefreshOnly {
            return Err(Error::reauth_required(
                self.config
                    .issuer
                    .as_deref()
                    .unwrap_or("the authorization server"),
                "authorize_with_details performs an interactive authorization, and this helper \
                 is in Interactivity::RefreshOnly. No browser was opened and no loopback \
                 listener was bound. Use get_access_token to serve the request from stored \
                 credentials, or build a helper without RefreshOnly to log in.",
            ));
        }

        self.discard_legacy_token_cache();

        let (metadata, extras) = self.get_metadata_with_extras().await?;
        let iss_presence = self.resolve_iss_presence(extras.iss_parameter_supported());

        // D-18, before anything interactive. Both public entry points check, so
        // a substitution cannot be reached by picking the other one.
        self.announce_authorization_server_change(&metadata).await?;

        // Deliberately does NOT consult the store: this is the "log me in" entry
        // point, and `cargo pmcp auth login` means a fresh authorization.
        // `get_access_token` is the one that reads the cache.
        self.authorize_with_fallback(&metadata, iss_presence).await
    }

    /// Run the authorization-code flow, PERSIST what it produced, and fall back
    /// to the device-code grant on a non-terminal failure.
    ///
    /// One implementation for both public entry points, so a credential that
    /// reaches `get_access_token`'s caller and one that reaches
    /// `authorize_with_details`' caller cannot be stored differently.
    async fn authorize_with_fallback(
        &self,
        metadata: &OidcDiscoveryMetadata,
        iss_presence: IssPresence,
    ) -> Result<AuthorizationResult> {
        match self
            .authorization_code_flow_inner(metadata, iss_presence)
            .await
        {
            Ok((token_response, identity)) => {
                // The scope this flow ACTUALLY requested at the authorization
                // request — `config.scopes` plus `offline_access` when the
                // server advertises it. Composed by the same function
                // `build_authorization_url` uses, so the value recorded as
                // "requested" cannot drift from the value sent. RFC 6749 §5.1
                // makes this the granted scope when the token response omits
                // `scope`, so using `config.scopes` alone would silently narrow
                // every subsequent refresh.
                let requested_scopes = compose_scopes_with_offline_access(
                    &self.config.scopes,
                    &metadata.scopes_supported,
                );
                let result = Self::build_auth_result(
                    token_response,
                    identity.client_id,
                    self.effective_issuer(metadata),
                    &requested_scopes,
                );
                self.persist_credentials(
                    &metadata.issuer,
                    &result,
                    identity.registered_application_type.as_deref(),
                )
                .await?;
                Ok(result)
            },
            Err(e) if Self::is_terminal_authorization_refusal(&e) => Err(e),
            Err(e) => {
                tracing::warn!("Authorization code flow failed: {}", e);

                if metadata.device_authorization_endpoint.is_some() {
                    tracing::info!(
                        "Trying device code flow (refresh_token may be None per RFC 8628)..."
                    );
                    return self.device_code_flow_with_metadata(metadata).await;
                }
                Err(Error::internal(
                    "No supported OAuth flow available.\n\
                     \n\
                     The server must support either:\n\
                     - Authorization code flow (authorization_endpoint), or\n\
                     - Device code flow (device_authorization_endpoint)"
                        .to_string(),
                ))
            },
        }
    }

    /// Helper to construct an `AuthorizationResult` from a `TokenResponse`.
    ///
    /// `requested_scopes` must be the scope the flow ACTUALLY sent at the
    /// authorization request — including `offline_access` when it was added —
    /// because RFC 6749 §5.1 makes it the granted scope whenever the token
    /// response omits `scope`.
    fn build_auth_result(
        token_response: crate::client::auth::TokenResponse,
        client_id: String,
        effective_issuer: Option<String>,
        requested_scopes: &[String],
    ) -> AuthorizationResult {
        // Convert `expires_in` (relative seconds) to `expires_at` (absolute unix seconds).
        let expires_at = token_response
            .expires_in
            .map(|ttl| unix_now_secs().saturating_add(ttl));

        // The GRANTED scope, per RFC 6749 §5.1. The two branches look
        // interchangeable and are NOT, which is why each names its own rule:
        //
        // - `scope` PRESENT: that string IS the granted scope. It is recorded
        //   verbatim even when it is NARROWER than what was asked for — the
        //   authorization server is entitled to downgrade, and assuming the
        //   request was honoured is exactly the tampering assumption T-116-38b
        //   names.
        // - `scope` ABSENT: RFC 6749 §5.1 says the parameter is OPTIONAL "if
        //   identical to the scope requested by the client", so an omission
        //   means the request was granted in full and the REQUESTED scope is
        //   what was granted.
        //
        // 116-12 refreshes with this value and nothing else, so a wrong branch
        // here silently re-widens or narrows every subsequent refresh.
        let granted_scopes = match token_response.scope.as_deref() {
            Some(granted) => granted
                .split_whitespace()
                .map(String::from)
                .collect::<Vec<_>>(),
            None => requested_scopes.to_vec(),
        };

        AuthorizationResult {
            access_token: token_response.access_token,
            refresh_token: token_response.refresh_token,
            expires_at,
            scopes: granted_scopes,
            issuer: effective_issuer,
            client_id,
        }
    }

    /// Bind the loopback callback listener and return it with the redirect URI
    /// that must be advertised to the authorization server.
    ///
    /// The advertised URL and the bind address MUST be the literal `127.0.0.1`
    /// (not `localhost`), otherwise browsers can resolve `localhost` to `::1`
    /// (IPv6) and hit `ERR_CONNECTION_REFUSED` on our IPv4-only listener.
    async fn bind_callback_listener(redirect_port: u16) -> Result<(TcpListener, String)> {
        let redirect_uri = format!("http://127.0.0.1:{}/callback", redirect_port);

        let listener = TcpListener::bind(format!("127.0.0.1:{}", redirect_port))
            .await
            .map_err(|e| {
                Error::internal(format!(
                    "Failed to bind to 127.0.0.1:{}.\n\
                     \n\
                     This port may already be in use. Try a different port with:\n\
                     --oauth-redirect-port PORT\n\
                     \n\
                     Error: {e}",
                    redirect_port
                ))
            })?;

        tracing::debug!("Local callback server listening on port {}", redirect_port);
        tracing::warn!(
            "Ensure the redirect URI is registered in your OAuth provider: {}",
            redirect_uri
        );

        Ok((listener, redirect_uri))
    }

    /// Build the authorization URL from the per-request record.
    ///
    /// `state` and `code_challenge` both come from the record, so the value in
    /// the URL and the value that will be compared on the callback cannot
    /// diverge.
    fn build_authorization_url(
        &self,
        metadata: &OidcDiscoveryMetadata,
        client_id: &str,
        redirect_uri: &str,
        record: &AuthorizationRequestRecord,
    ) -> Result<Url> {
        let mut auth_url = Url::parse(&metadata.authorization_endpoint)
            .map_err(|e| Error::internal(format!("Invalid authorization endpoint: {e}")))?;

        // SEP-2207: THIS is the stage at which requesting `offline_access` means
        // anything. Declaring it in the registration says what the client may
        // ask for; this is the ask. Composed from `config.scopes` — never by
        // mutating it, because a caller who reuses one `OAuthConfig` across two
        // flows must not watch its public `scopes` field grow.
        let requested_scopes =
            compose_scopes_with_offline_access(&self.config.scopes, &metadata.scopes_supported);

        auth_url
            .query_pairs_mut()
            .append_pair("client_id", client_id)
            .append_pair("response_type", "code")
            .append_pair("redirect_uri", redirect_uri)
            .append_pair("scope", &requested_scopes.join(" "))
            .append_pair(
                "code_challenge",
                &code_challenge_s256(record.code_verifier()),
            )
            .append_pair("code_challenge_method", "S256")
            // The CSRF `state` is the record's — a BOUND value, comparable on
            // the callback. It is produced by `generate_state()`, never by the
            // PKCE verifier generator: RFC 7636's verifier and RFC 6749 §10.12's
            // `state` are distinct roles and conflating their generators hides
            // that one of them is never checked.
            .append_pair("state", record.state());

        Ok(auth_url)
    }

    /// Read one HTTP request line, refusing anything over
    /// [`MAX_CALLBACK_REQUEST_LINE_BYTES`].
    ///
    /// The cap is applied at the socket by a limited reader, so an oversized
    /// line is refused without ever being allocated in full. There is
    /// deliberately no unbounded read here: any local process can connect to
    /// this port.
    async fn read_request_line_within_cap(stream: &mut TcpStream) -> Result<String> {
        let mut limited = BufReader::new(stream).take(MAX_CALLBACK_REQUEST_LINE_BYTES as u64 + 1);
        let mut raw = Vec::with_capacity(256);

        limited
            .read_until(b'\n', &mut raw)
            .await
            .map_err(|e| Error::internal(format!("Failed to read OAuth callback request: {e}")))?;

        if raw.len() > MAX_CALLBACK_REQUEST_LINE_BYTES {
            return Err(Error::internal(format!(
                "OAuth callback request line exceeds the \
                 MAX_CALLBACK_REQUEST_LINE_BYTES limit of {MAX_CALLBACK_REQUEST_LINE_BYTES} \
                 bytes; refused at the socket, and none of it is reproduced here"
            )));
        }

        String::from_utf8(raw)
            .map_err(|_| Error::internal("OAuth callback request line is not UTF-8".to_string()))
    }

    /// Isolate the query component of a callback request line.
    ///
    /// The request line is `GET /callback?<query> HTTP/1.1`; parsing it against
    /// a dummy origin is a convenient way to split the path from the query. The
    /// raw query is handed on undecoded — the percent-decode belongs to the
    /// validator, which performs the `application/x-www-form-urlencoded` decode
    /// RFC 9207 §2.4 requires. Never hand-roll it here.
    fn callback_query_from_request_line(request_line: &str) -> Result<String> {
        let path = request_line.split_whitespace().nth(1).ok_or_else(|| {
            Error::internal("OAuth callback request line has no request target".to_string())
        })?;

        let callback_url = Url::parse(&format!("http://localhost{}", path)).map_err(|e| {
            Error::internal(format!("OAuth callback request target is unparseable: {e}"))
        })?;

        Ok(callback_url.query().unwrap_or_default().to_string())
    }

    /// Accept ONE loopback callback, validate it, and only then write a
    /// response.
    ///
    /// The ordering is the security property, not a style choice. Reading,
    /// isolating the query and validating all happen BEFORE any response byte is
    /// committed, so the page the human sees and the code the caller receives are
    /// consequences of the SAME `Result`. Serving a success page first and
    /// validating afterwards would teach a user to trust a login that was about
    /// to be rejected, and would make the failure page unselectable — the task
    /// cannot know the outcome at the moment it must choose which page to write.
    async fn serve_one_callback(
        listener: TcpListener,
        record: &AuthorizationRequestRecord,
    ) -> Result<String> {
        let (mut stream, _) = listener
            .accept()
            .await
            .map_err(|e| Error::internal(format!("Failed to accept OAuth callback: {e}")))?;

        // Steps 1-3: bounded read, isolate the query, validate through the pure
        // tier. Not one byte has been written to the socket yet.
        let outcome = match Self::read_request_line_within_cap(&mut stream).await {
            Ok(request_line) => Self::callback_query_from_request_line(&request_line)
                .and_then(|raw_query| validate_authorization_response(&raw_query, record)),
            Err(e) => Err(e),
        };

        // Step 4: the page is chosen by the outcome that already exists. Neither
        // page carries authorization-server-supplied text.
        let response = if outcome.is_ok() {
            CALLBACK_SUCCESS_RESPONSE
        } else {
            CALLBACK_FAILURE_RESPONSE
        };
        let _ = stream.write_all(response.as_bytes()).await;
        let _ = stream.flush().await;

        outcome
    }

    /// Wait for a validated authorization code, or the typed refusal that says
    /// why there will not be one.
    ///
    /// The channel carries a `Result`, so the caller's only route to the token
    /// exchange is the `Ok` branch. The record is CLONED into the listener task
    /// rather than shared behind an `Arc`: it is a small owned struct of two
    /// short strings, an issuer and a copy enum, and cloning keeps the task
    /// `'static` without adding a second ownership story.
    async fn await_validated_authorization_code(
        listener: TcpListener,
        record: AuthorizationRequestRecord,
    ) -> Result<String> {
        let (tx, rx) = oneshot::channel::<Result<String>>();
        let callback_task = tokio::spawn(async move {
            let _ = tx.send(Self::serve_one_callback(listener, &record).await);
        });

        tracing::info!("Waiting for authorization...");

        let received = tokio::time::timeout(Duration::from_mins(5), rx)
            .await
            .map_err(|_| {
                Error::internal("Timeout waiting for OAuth callback (5 minutes)".to_string())
            })?
            .map_err(|e| Error::internal(format!("OAuth callback channel error: {e}")))?;

        callback_task.abort();

        received
    }

    /// Inner PKCE authorization code flow returning the full token response.
    ///
    /// Returns (`TokenResponse`, [`ResolvedClientIdentity`]) so
    /// [`Self::authorize_with_fallback`] can populate `AuthorizationResult`
    /// including `refresh_token`, `expires_at`, `scopes` and the effective
    /// `client_id` — and can persist the registered `application_type`
    /// alongside them.
    ///
    /// Persistence deliberately lives in the CALLER: a flow that exchanged a
    /// code has not yet decided what the granted scope was (RFC 6749 §5.1's
    /// omission rule needs the composed request), and storing twice from two
    /// levels is how a record and its issuer come to disagree.
    ///
    /// `iss_presence` is resolved by the caller through
    /// [`Self::resolve_iss_presence`] and passed in, so the environment is read
    /// once per flow rather than once per layer.
    async fn authorization_code_flow_inner(
        &self,
        metadata: &OidcDiscoveryMetadata,
        iss_presence: IssPresence,
    ) -> Result<(crate::client::auth::TokenResponse, ResolvedClientIdentity)> {
        tracing::info!("Starting OAuth authorization code flow...");

        let identity = self.resolve_client_identity_for_flow(metadata).await?;
        let resolved_client_id = identity.client_id.clone();

        // The specification's mandated per-request record: the expected issuer,
        // the PKCE verifier, the CSRF `state` and the `iss` policy, bound into
        // ONE value. Three separate locals is exactly how `state` came to be an
        // unnamed temporary that nothing could compare.
        //
        // The anchor is `metadata.issuer` — the value the authorization server
        // published in its OWN discovery document, validated against the issuer
        // used to build the discovery URL at fetch time. It is deliberately NOT
        // `config.issuer` (a user-typed discovery seed) and not the effective
        // issuer reported to cache consumers: the attack being defended against
        // is "this response came from a different authorization server than the
        // one whose metadata I fetched".
        let record = AuthorizationRequestRecord::new(
            metadata.issuer.clone(),
            generate_code_verifier()?,
            generate_state()?,
            iss_presence,
        );

        let (listener, redirect_uri) =
            Self::bind_callback_listener(self.config.redirect_port).await?;

        let auth_url =
            self.build_authorization_url(metadata, &resolved_client_id, &redirect_uri, &record)?;

        tracing::info!("OAuth Authentication Required");
        tracing::info!("Opening browser for authentication...");
        tracing::info!("If the browser doesn't open, visit: {}", auth_url.as_str());

        // A launcher that cannot deliver the URL to a human at all aborts here,
        // rather than leaving the flow waiting five minutes for a callback
        // nobody will deliver.
        self.browser_launcher.open(auth_url.as_str())?;

        // Validation happens INSIDE the listener, before any response byte is
        // written. An `Err` here is unreachable-from-redemption by construction:
        // the token exchange below is only reachable on the `Ok` branch.
        let authorization_code =
            Self::await_validated_authorization_code(listener, record.clone()).await?;

        tracing::info!("Authorization code received");

        // Exchange authorization code for access token
        tracing::debug!("Exchanging authorization code for access token...");

        let token_exchange = TokenExchangeClient::new();
        let token_response = token_exchange
            .exchange_code(
                &metadata.token_endpoint,
                &authorization_code,
                &resolved_client_id,
                None, // No client secret for public clients
                &redirect_uri,
                Some(record.code_verifier()), // PKCE verifier, from the record
            )
            .await
            .map_err(|e| {
                Error::internal(format!(
                    "Failed to exchange authorization code for token: {e}"
                ))
            })?;

        tracing::info!("Authentication successful");

        Ok((token_response, identity))
    }

    /// Perform OAuth device code flow (with pre-fetched metadata).
    async fn device_code_flow_with_metadata(
        &self,
        metadata: &OidcDiscoveryMetadata,
    ) -> Result<AuthorizationResult> {
        tracing::info!("Starting OAuth device code flow...");

        // Check if device flow is supported
        let device_auth_endpoint =
            metadata
                .device_authorization_endpoint
                .as_ref()
                .ok_or_else(|| {
                    Error::internal(
                        "Device authorization endpoint not found in OAuth metadata.\n\
                         \n\
                         The OAuth server does not support device code flow (RFC 8628)."
                            .to_string(),
                    )
                })?;

        // Rest of device code flow implementation...
        self.device_code_flow_internal(metadata, device_auth_endpoint)
            .await
    }

    /// Internal implementation of device code flow.
    async fn device_code_flow_internal(
        &self,
        metadata: &OidcDiscoveryMetadata,
        device_auth_endpoint: &str,
    ) -> Result<AuthorizationResult> {
        let identity = self.resolve_client_identity_for_flow(metadata).await?;
        let resolved_client_id = identity.client_id.clone();

        // Step 1: Request device code. `offline_access` is deliberately NOT
        // composed in here: the device grant never builds an authorization URL,
        // and SEP-2207's request stage is that URL.
        let scope = self.config.scopes.join(" ");

        let response = self
            .client
            .post(device_auth_endpoint)
            .form(&[
                ("client_id", resolved_client_id.as_str()),
                ("scope", &scope),
            ])
            .send()
            .await
            .map_err(|e| Error::internal(format!("Failed to request device code: {e}")))?;

        if !response.status().is_success() {
            let status = response.status();
            let body =
                collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
            return Err(Error::internal(format!(
                "Device authorization failed ({status}): {}",
                String::from_utf8_lossy(&body)
            )));
        }

        let body = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
        let device_auth: DeviceAuthResponse = serde_json::from_slice(&body).map_err(|e| {
            Error::internal(format!(
                "Failed to parse device authorization response ({:?} error at line {}, \
                 column {}). The parser's own message is not reproduced here because a data \
                 error echoes the offending input",
                e.classify(),
                e.line(),
                e.column()
            ))
        })?;

        // Step 2: Display user code and verification URL
        tracing::info!("OAuth device code flow");
        tracing::info!("1. Visit: {}", device_auth.verification_uri);
        tracing::info!("2. Enter code: {}", device_auth.user_code);

        if let Some(complete_uri) = &device_auth.verification_uri_complete {
            tracing::info!("Or visit directly: {}", complete_uri);
        }

        // Step 3: Poll for token
        let poll_interval = Duration::from_secs(device_auth.interval.unwrap_or(5));
        let token_endpoint = &metadata.token_endpoint;
        let expires_at = SystemTime::now() + Duration::from_secs(device_auth.expires_in);

        loop {
            if SystemTime::now() > expires_at {
                return Err(Error::internal(
                    "Device code expired. Please try again.".to_string(),
                ));
            }

            sleep(poll_interval).await;
            tracing::debug!("Polling for authorization...");

            let response = self
                .client
                .post(token_endpoint)
                .form(&[
                    ("client_id", resolved_client_id.as_str()),
                    ("device_code", &device_auth.device_code),
                    ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
                ])
                .send()
                .await
                .map_err(|e| Error::internal(format!("Failed to poll for token: {e}")))?;

            let status = response.status();
            // Polled in a loop, so an unbounded read here is an unbounded read
            // per poll for as long as the device code lives.
            let raw =
                collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;

            if status.is_success() {
                let token_response: TokenResponse = serde_json::from_slice(&raw).map_err(|e| {
                    Error::internal(format!(
                        "Failed to parse token response ({:?} error at line {}, column {}). \
                             The parser's own message is not reproduced here because a data \
                             error echoes the offending input, and a token response body carries \
                             credentials",
                        e.classify(),
                        e.line(),
                        e.column()
                    ))
                })?;

                tracing::info!("Authentication successful");

                // `scopes` stays `config.scopes`: the device grant never builds
                // an authorization URL, so SEP-2207's `offline_access` was never
                // requested on this path and recording it would be a lie. RFC
                // 8628 §3.5 does not require a `refresh_token` either, so this
                // record may legitimately carry none.
                let result = AuthorizationResult {
                    access_token: token_response.access_token,
                    refresh_token: token_response.refresh_token,
                    expires_at: token_response
                        .expires_in
                        .map(|ttl| unix_now_secs().saturating_add(ttl)),
                    scopes: self.config.scopes.clone(),
                    issuer: self.effective_issuer(metadata),
                    client_id: resolved_client_id,
                };
                self.persist_credentials(
                    &metadata.issuer,
                    &result,
                    identity.registered_application_type.as_deref(),
                )
                .await?;

                return Ok(result);
            }

            // Check error response
            if let Ok(error) = serde_json::from_slice::<serde_json::Value>(&raw) {
                if let Some(error_code) = error.get("error").and_then(|e| e.as_str()) {
                    match error_code {
                        "authorization_pending" => continue,
                        "slow_down" => {
                            sleep(poll_interval).await;
                            continue;
                        },
                        "access_denied" => {
                            return Err(Error::internal("User denied authorization".to_string()));
                        },
                        "expired_token" => {
                            return Err(Error::internal("Device code expired".to_string()));
                        },
                        _ => {
                            return Err(Error::internal(format!("OAuth error: {}", error_code)));
                        },
                    }
                }
            }
        }
    }

    /// Refresh an existing access token (RFC 6749 §6).
    ///
    /// `stored_client_id` and `granted_scopes` come from the credential record
    /// the caller has ALREADY loaded under this helper's
    /// `(issuer, account, server)` key. They are parameters rather than a second
    /// `store.load` for a correctness reason and not a performance one: a
    /// refresh token and the `client_id` it was issued to are ONE pairing, and
    /// re-reading the store here could pair this refresh token with a
    /// `client_id` another process wrote in between.
    ///
    /// # Errors
    ///
    /// When no `client_id` is available from either place, when the request
    /// fails, when the authorization server refuses, or when the response body
    /// exceeds [`DEFAULT_AUTH_RESPONSE_BYTES`].
    async fn refresh_token(
        &self,
        refresh_token: &str,
        stored_client_id: Option<&str>,
        granted_scopes: &[String],
    ) -> Result<TokenResponse> {
        let metadata = self.get_metadata().await?;
        let token_endpoint = &metadata.token_endpoint;

        // D-14 defect 2. Under dynamic registration the `client_id` is ISSUED,
        // so it lives in the credential record and never in `OAuthConfig` —
        // reading config alone made a DCR-registered client unable to refresh
        // even ONCE, sending it back through a full browser login on every
        // expiry. The stored id wins because it is the one this refresh token
        // was issued to; the configured one is the fallback for a pre-registered
        // client. Only when BOTH are absent is it an error, and the message
        // names both places.
        let client_id = stored_client_id
            .filter(|id| !id.is_empty())
            .or(self.config.client_id.as_deref())
            .ok_or_else(|| {
                Error::internal(
                    "cannot refresh: no client_id in the stored credential record for this \
                     (issuer, account, server), and none in OAuthConfig::client_id. Both places \
                     were checked. Run an interactive authorization to register (or re-register) \
                     this client."
                        .to_string(),
                )
            })?;

        // D-14 defect 3, and stage 4 of SEP-2207's `offline_access` lifecycle:
        //
        //   1. `offline_access` is DECLARED in the DCR client metadata when the
        //      authorization server advertises it (116-10).
        //   2. It is REQUESTED in the authorization request when advertised
        //      (116-10).
        //   3. What the server actually GRANTED is recorded from the token
        //      response's `scope` or, when that field is absent, taken as the
        //      requested scope per RFC 6749 §5.1 (116-10, persisted by 116-11).
        //   4. A refresh sends EXACTLY that recorded set, or omits `scope`
        //      entirely when it is empty.
        //
        // Stage 4 never introduces a scope, never consults `scopes_supported`
        // and never falls back to `config.scopes`. `config.scopes` is what was
        // ASKED for; RFC 6749 §6 says a refresh request's scope "MUST NOT
        // include any scope not originally granted by the resource owner", so
        // re-widening here is a specification violation that a conforming
        // authorization server answers with `invalid_scope` — breaking refresh
        // entirely, which is the opposite of what this fix is for. Reaching for
        // `config.scopes` looks obviously right at this call site and is
        // obviously wrong two protocol stages away.
        //
        // Built as a `Vec` so an empty grant leaves the key OFF the wire rather
        // than sending `scope=`, which a conforming server may read as a
        // request for no scopes at all. This is the shape the sibling
        // `TokenExchangeClient::refresh_token` already uses.
        let scope = granted_scopes.join(" ");
        let mut form: Vec<(&str, &str)> = vec![
            ("client_id", client_id),
            ("refresh_token", refresh_token),
            ("grant_type", "refresh_token"),
        ];
        if !scope.is_empty() {
            form.push(("scope", scope.as_str()));
        }

        let response = self
            .client
            .post(token_endpoint)
            .form(&form)
            .send()
            .await
            .map_err(|e| Error::internal(format!("Failed to refresh token: {e}")))?;

        if !response.status().is_success() {
            let status = response.status();
            // A hostile authorization server controls its ERROR bodies too, and
            // an error path is where it has the most freedom. The over-cap
            // refusal propagates verbatim: it names the cap and the observed
            // size and reproduces no byte of what it dropped.
            let body =
                collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
            return Err(Error::internal(format!(
                "Token refresh failed ({status}): {}",
                String::from_utf8_lossy(&body)
            )));
        }

        let body = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
        serde_json::from_slice::<TokenResponse>(&body).map_err(|e| {
            Error::internal(format!(
                "Failed to parse refresh response ({:?} error at line {}, column {}). The \
                 parser's own message is not reproduced here because a data error echoes the \
                 offending input, and a token response body carries credentials",
                e.classify(),
                e.line(),
                e.column()
            ))
        })
    }

    /// Create HTTP middleware chain with OAuth bearer token.
    ///
    /// Obtains an access token (from cache, refresh, or interactive flow)
    /// and wraps it in a middleware chain suitable for HTTP transports.
    pub async fn create_middleware_chain(&self) -> Result<Arc<HttpMiddlewareChain>> {
        let access_token = self.get_access_token().await?;

        // NEVER a prefix of the token itself. See `token_fingerprint`.
        tracing::debug!(
            "Creating OAuth middleware with token {}",
            token_fingerprint(&access_token)
        );

        let bearer_token = BearerToken::new(access_token);
        let oauth_middleware = OAuthClientMiddleware::new(bearer_token);

        let mut chain = HttpMiddlewareChain::new();
        chain.add(Arc::new(oauth_middleware));

        tracing::info!("OAuth middleware added to chain");

        Ok(Arc::new(chain))
    }
}

/// The path of the LEGACY, issuer-less flat token cache
/// (`~/.pmcp/oauth-tokens.json`).
///
/// Uses the user's home directory, falling back to the current directory when
/// the home directory cannot be determined.
///
/// # This file is no longer read (D-17 / SEP-2352)
///
/// It held ONE token and recorded NO issuer, so which authorization server
/// issued it cannot be determined without guessing — and guessing is what
/// SEP-2352 forbids. `OAuthHelper` never opens it, never migrates it and never
/// deletes it; it warns once that it is being discarded and leaves it for the
/// user to remove. One re-login is required.
///
/// The value is still useful as a LOCATION: setting
/// [`OAuthConfig::cache_file`] to it opts a caller into the issuer-keyed store,
/// which lives beside it as `oauth-cache.json` in the same directory. Setting
/// the field to `None` continues to mean "do not cache".
pub fn default_cache_path() -> PathBuf {
    let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    path.push(".pmcp");
    path.push("oauth-tokens.json");
    path
}

/// Create an OAuth middleware chain from configuration.
///
/// This is a one-liner convenience for tools that just need a middleware chain:
/// ```no_run
/// # use pmcp::client::oauth::{OAuthConfig, create_oauth_middleware};
/// # async fn example() -> pmcp::Result<()> {
/// let config = OAuthConfig {
///     issuer: Some("https://auth.example.com".to_string()),
///     mcp_server_url: None,
///     client_id: Some("my-client".to_string()),
///     client_name: None,
///     dcr_enabled: false,
///     scopes: vec!["openid".to_string()],
///     cache_file: None,
///     redirect_port: 8080,
/// };
/// let chain = create_oauth_middleware(config).await?;
/// // Pass chain to HttpClient or transport
/// # Ok(())
/// # }
/// ```
pub async fn create_oauth_middleware(config: OAuthConfig) -> Result<Arc<HttpMiddlewareChain>> {
    let helper = OAuthHelper::new(config)?;
    helper.create_middleware_chain().await
}

/// [`token_fingerprint`] is the only thing in this module allowed to put a
/// token-derived string into a log line, so the rules it has to satisfy are
/// pinned here rather than left to review.
#[cfg(test)]
mod token_fingerprint_tests {
    use super::*;

    /// A token used across the rows below, long enough that a "first N
    /// characters" regression would visibly leak.
    const TOKEN: &str = "ya29.A0ARrdaM-THIS-IS-A-LIVE-LOOKING-ACCESS-TOKEN-abcdef0123456789";

    /// **The row that matters: the fingerprint reproduces NO part of the
    /// token.**
    ///
    /// A presence assertion ("it contains `sha256:`") is not a detector for a
    /// leak channel — 116-10's finding — so absence is asserted directly, over
    /// every prefix long enough to be identifying, in BOTH directions.
    fn assert_no_token_material(fingerprint: &str, token: &str) {
        assert!(
            !fingerprint.contains(token),
            "the whole token appeared in {fingerprint}"
        );
        for len in 4..=token.len() {
            let prefix = &token[..len];
            assert!(
                !fingerprint.contains(prefix),
                "a {len}-character prefix of the token appeared in {fingerprint}"
            );
        }
    }

    #[test]
    fn a_fingerprint_reproduces_no_part_of_the_token() {
        assert_no_token_material(&token_fingerprint(TOKEN), TOKEN);
    }

    /// The 20-character prefix the previous implementation logged is exactly
    /// what this row forbids, so a revert would fail here rather than in review.
    #[test]
    fn the_previous_twenty_character_prefix_is_absent() {
        let fingerprint = token_fingerprint(TOKEN);
        assert!(
            !fingerprint.contains(&TOKEN[..20]),
            "the old plaintext prefix is back: {fingerprint}"
        );
    }

    /// Stable across calls, so one token can be correlated across log lines —
    /// which is the entire reason for logging anything at all here.
    #[test]
    fn a_fingerprint_is_stable_for_one_token() {
        assert_eq!(token_fingerprint(TOKEN), token_fingerprint(TOKEN));
    }

    /// Distinct for distinct tokens, including two that share a long prefix:
    /// an implementation that hashed only the first few bytes would collide
    /// here and be useless for correlation.
    #[test]
    fn two_tokens_sharing_a_long_prefix_fingerprint_differently() {
        let sibling = format!("{TOKEN}-second");
        assert_ne!(token_fingerprint(TOKEN), token_fingerprint(&sibling));
    }

    /// Shape: the `sha256:` marker plus exactly
    /// [`FINGERPRINT_HEX_CHARS`] lowercase hex digits, so a reader cannot
    /// mistake it for a credential.
    #[test]
    fn a_fingerprint_is_the_marker_plus_twelve_hex_digits() {
        let fingerprint = token_fingerprint(TOKEN);
        let hex = fingerprint
            .strip_prefix("sha256:")
            .expect("the marker prefix");
        assert_eq!(hex.len(), FINGERPRINT_HEX_CHARS);
        assert!(
            hex.chars()
                .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
            "expected lowercase hex, got {hex}"
        );
    }

    /// An empty token is not a panic. It cannot occur through the live flow,
    /// but the helper is a logging primitive and a logging primitive that can
    /// panic is worse than the leak it replaced.
    #[test]
    fn an_empty_token_is_fingerprinted_without_panicking() {
        assert_eq!(
            token_fingerprint("").len(),
            "sha256:".len() + FINGERPRINT_HEX_CHARS
        );
    }
}

/// The credential-store WIRING rules that are decidable without a network.
///
/// The end-to-end evidence lives in `tests/oauth_store_wiring.rs`; these pin the
/// two rules that would otherwise only be visible as a downstream symptom.
#[cfg(test)]
mod credential_store_wiring_tests {
    use super::*;

    /// [`CREDENTIAL_STORE_FILE_NAME`] and `default_credential_path` must name
    /// the same file, or a caller who sets `cache_file` and a caller who does
    /// not would build two different stores inside one directory and a login
    /// through one would be invisible to the other.
    #[test]
    fn the_credential_store_file_name_matches_default_credential_path() {
        let default = crate::shared::credential_file::default_credential_path()
            .expect("a resolvable default credential path");
        assert_eq!(
            default.file_name().and_then(std::ffi::OsStr::to_str),
            Some(CREDENTIAL_STORE_FILE_NAME),
            "the store file name drifted from default_credential_path"
        );
    }

    /// The legacy flat cache and the issuer-keyed store are DIFFERENT files, so
    /// resolving the store never opens or overwrites the legacy document.
    #[test]
    fn the_legacy_flat_cache_and_the_credential_store_are_different_files() {
        let legacy = default_cache_path();
        assert_eq!(
            legacy.file_name().and_then(std::ffi::OsStr::to_str),
            Some("oauth-tokens.json")
        );
        assert_ne!(
            legacy.file_name().and_then(std::ffi::OsStr::to_str),
            Some(CREDENTIAL_STORE_FILE_NAME)
        );
        assert_eq!(
            legacy
                .with_file_name(CREDENTIAL_STORE_FILE_NAME)
                .file_name()
                .and_then(std::ffi::OsStr::to_str),
            Some(CREDENTIAL_STORE_FILE_NAME),
            "the store lives beside the legacy file, not on top of it"
        );
    }

    /// With no `cache_file` and no injected store there is no persistence at
    /// all, which is what `--no-cache` has always meant. Resolution is also
    /// I/O-free in that case: there is nothing to resolve.
    #[test]
    fn no_cache_file_and_no_injected_store_resolves_to_no_store() {
        let helper = OAuthHelper::new(OAuthConfig {
            mcp_server_url: Some("https://mcp.example".to_string()),
            cache_file: None,
            ..OAuthConfig::default()
        })
        .expect("helper");
        assert!(helper.credential_store().is_none());
    }

    /// A configured `cache_file` opts into a store BESIDE it, never onto it.
    #[test]
    fn a_configured_cache_file_resolves_a_store_beside_it() {
        let helper = OAuthHelper::new(OAuthConfig {
            mcp_server_url: Some("https://mcp.example".to_string()),
            cache_file: Some(PathBuf::from("/nonexistent-116-11/oauth-tokens.json")),
            ..OAuthConfig::default()
        })
        .expect("helper");
        let store = helper.credential_store().expect("a resolved store");
        assert!(
            format!("{store:?}").contains("oauth-cache.json"),
            "expected a store beside the legacy file, got {store:?}"
        );
        assert!(
            !std::path::Path::new("/nonexistent-116-11").exists(),
            "resolving a store must not create anything"
        );
    }

    /// The key carries all THREE components, with the server normalized.
    #[test]
    fn the_credential_key_carries_issuer_account_and_normalized_server() {
        let helper = OAuthHelper::new(OAuthConfig {
            mcp_server_url: Some("https://MCP.Example:443/api/".to_string()),
            ..OAuthConfig::default()
        })
        .expect("helper")
        .with_account_scope("cognito-sub-123");

        let server_key = helper.server_key().expect("a normalizable server URL");
        assert_eq!(server_key, "https://mcp.example");

        let key = helper.credential_key("https://as.example", &server_key);
        assert_eq!(key.issuer(), "https://as.example");
        assert_eq!(key.account(), "cognito-sub-123");
        assert_eq!(key.server(), "https://mcp.example");
    }

    /// The default account scope is the empty string — the single-user CLI
    /// case — and the issuer is the fallback server identity.
    #[test]
    fn the_default_account_scope_is_empty_and_the_issuer_is_the_server_fallback() {
        let helper = OAuthHelper::new(OAuthConfig {
            mcp_server_url: None,
            issuer: Some("https://as.example/tenant".to_string()),
            ..OAuthConfig::default()
        })
        .expect("helper");
        let server_key = helper.server_key().expect("a normalizable issuer");
        assert_eq!(server_key, "https://as.example");
        assert_eq!(
            helper
                .credential_key("https://as.example", &server_key)
                .account(),
            ""
        );

        let unaddressable = OAuthHelper::new(OAuthConfig::default()).expect("helper");
        assert!(unaddressable.server_key().is_err());
    }
}

#[cfg(test)]
mod oauth_config_tests {
    use super::*;

    #[test]
    fn oauth_config_default_has_dcr_enabled_and_none_client_id() {
        let c = OAuthConfig::default();
        assert!(
            c.client_id.is_none(),
            "default client_id must be None for DCR auto-fire"
        );
        assert!(c.dcr_enabled, "default dcr_enabled must be true");
        assert!(c.client_name.is_none(), "default client_name is None");
    }

    #[test]
    fn oauth_config_struct_literal_with_some_client_id_compiles() {
        let _c = OAuthConfig {
            issuer: None,
            mcp_server_url: Some("https://x.example".into()),
            client_id: Some("my-client".into()),
            client_name: None,
            dcr_enabled: false,
            scopes: vec![],
            cache_file: None,
            redirect_port: 8080,
        };
    }

    #[test]
    fn dcr_types_are_reexported() {
        // Compile-only: verifies pub use lands `DcrRequest` / `DcrResponse`
        // at `pmcp::client::oauth::*`.
        let _r: super::DcrRequest = super::DcrRequest {
            redirect_uris: vec!["http://localhost:8080/callback".into()],
            client_name: Some("test".into()),
            client_uri: None,
            logo_uri: None,
            contacts: vec![],
            token_endpoint_auth_method: Some("none".into()),
            grant_types: vec!["authorization_code".into()],
            response_types: vec![],
            scope: None,
            software_id: None,
            software_version: None,
            extra: Default::default(),
        };
        let _rsp = super::DcrResponse {
            client_id: "x".into(),
            client_secret: None,
            client_secret_expires_at: None,
            registration_access_token: None,
            registration_client_uri: None,
            token_endpoint_auth_method: None,
            extra: Default::default(),
        };
    }
}

#[cfg(test)]
mod dcr_tests {
    use super::*;
    use crate::server::auth::oauth2::OidcDiscoveryMetadata;

    /// Construct an OidcDiscoveryMetadata with only the fields we care about
    /// for DCR tests. OidcDiscoveryMetadata does NOT implement Default, so we
    /// provide all required fields explicitly.
    fn metadata(reg: Option<&str>) -> OidcDiscoveryMetadata {
        OidcDiscoveryMetadata {
            issuer: "https://issuer.example".into(),
            authorization_endpoint: "https://issuer.example/auth".into(),
            token_endpoint: "https://issuer.example/token".into(),
            jwks_uri: None,
            userinfo_endpoint: None,
            registration_endpoint: reg.map(String::from),
            revocation_endpoint: None,
            introspection_endpoint: None,
            device_authorization_endpoint: None,
            response_types_supported: vec![],
            grant_types_supported: vec![],
            scopes_supported: vec![],
            token_endpoint_auth_methods_supported: vec![],
            code_challenge_methods_supported: vec![],
        }
    }

    #[tokio::test]
    async fn dcr_skipped_when_client_id_provided() {
        let cfg = OAuthConfig {
            client_id: Some("preset".into()),
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let resolved = helper
            .resolve_client_identity_for_flow(&metadata(Some("https://x/register")))
            .await
            .unwrap();
        assert_eq!(resolved.client_id, "preset");
    }

    #[tokio::test]
    async fn dcr_skipped_when_dcr_disabled_with_client_id() {
        let cfg = OAuthConfig {
            client_id: Some("preset".into()),
            dcr_enabled: false,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let resolved = helper
            .resolve_client_identity_for_flow(&metadata(None))
            .await
            .unwrap();
        assert_eq!(resolved.client_id, "preset");
    }

    #[tokio::test]
    async fn dcr_needed_but_unsupported_errors_with_actionable_message() {
        let cfg = OAuthConfig {
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .resolve_client_identity_for_flow(&metadata(None))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("server does not support DCR"),
            "expected actionable DCR-missing message, got: {msg}"
        );
    }

    #[tokio::test]
    async fn dcr_needed_but_disabled_errors_when_client_id_none() {
        let cfg = OAuthConfig {
            client_id: None,
            dcr_enabled: false,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .resolve_client_identity_for_flow(&metadata(Some("https://x/register")))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("dcr_enabled is false"),
            "expected dcr_enabled=false error, got: {msg}"
        );
    }

    #[tokio::test]
    async fn dcr_rejects_http_non_localhost_endpoint() {
        let cfg = OAuthConfig {
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .do_dynamic_client_registration("http://attacker.example/register", &metadata(None))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("must be https"), "got: {msg}");
    }

    #[test]
    fn dcr_request_body_matches_rfc7591_public_pkce_shape() {
        let req = crate::server::auth::provider::DcrRequest {
            redirect_uris: vec!["http://localhost:8080/callback".into()],
            client_name: Some("pmcp-sdk".into()),
            client_uri: None,
            logo_uri: None,
            contacts: vec![],
            token_endpoint_auth_method: Some("none".into()),
            grant_types: vec!["authorization_code".into()],
            response_types: vec![],
            scope: None,
            software_id: None,
            software_version: None,
            extra: Default::default(),
        };
        let v: serde_json::Value = serde_json::to_value(&req).unwrap();
        assert_eq!(v["client_name"], "pmcp-sdk");
        assert_eq!(
            v["redirect_uris"],
            serde_json::json!(["http://localhost:8080/callback"])
        );
        assert_eq!(v["grant_types"], serde_json::json!(["authorization_code"]));
        assert_eq!(v["token_endpoint_auth_method"], "none");
    }

    #[test]
    fn dcr_request_body_contains_response_types_code() {
        // Serde-level guard: `DcrRequest` has
        // `#[serde(skip_serializing_if = "Vec::is_empty")]` on `response_types`,
        // so an accidental empty-Vec default would silently drop the field.
        let req = crate::server::auth::provider::DcrRequest {
            redirect_uris: vec!["http://localhost:8080/callback".into()],
            client_name: Some("pmcp-sdk".into()),
            client_uri: None,
            logo_uri: None,
            contacts: vec![],
            token_endpoint_auth_method: Some("none".into()),
            grant_types: vec!["authorization_code".into()],
            response_types: vec!["code".into()],
            scope: None,
            software_id: None,
            software_version: None,
            extra: Default::default(),
        };
        let s = serde_json::to_string(&req).unwrap();
        assert!(
            s.contains(r#""response_types":["code"]"#),
            "RFC 7591 §3.1 response_types missing from wire body: {s}"
        );
    }

    #[tokio::test]
    async fn dcr_advertises_127_0_0_1_redirect_not_localhost() {
        // Regression guard: advertising `http://localhost:<port>` causes browsers
        // to resolve to `::1` (IPv6) and miss the IPv4-only callback listener.
        // The mock only matches when the wire body pins `127.0.0.1`; a regression
        // back to `localhost` makes this mock return 501 and the DCR call errors.
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/register")
            .match_body(mockito::Matcher::PartialJsonString(
                serde_json::json!({
                    "redirect_uris": ["http://127.0.0.1:8080/callback"]
                })
                .to_string(),
            ))
            .with_status(201)
            .with_body(r#"{"client_id":"ok"}"#)
            .create_async()
            .await;
        let helper = OAuthHelper::new(OAuthConfig {
            dcr_enabled: true,
            redirect_port: 8080,
            ..OAuthConfig::default()
        })
        .unwrap();
        let result = helper
            .do_dynamic_client_registration(&format!("{}/register", server.url()), &metadata(None))
            .await;
        assert!(
            result.is_ok(),
            "DCR body did not pin 127.0.0.1 redirect_uri"
        );
        mock.assert_async().await;
    }

    #[tokio::test]
    async fn dcr_accepts_ipv6_loopback_registration_endpoint() {
        // The guard must accept `[::1]` alongside `localhost` and `127.0.0.1`.
        // It rejects BEFORE the HTTP call, so a connection failure on port 9
        // is the expected non-scheme-guard error here.
        let cfg = OAuthConfig {
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .do_dynamic_client_registration("http://[::1]:9/register", &metadata(None))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        // Must NOT be the scheme-guard error — the guard passed, we only
        // failed on the downstream HTTP call (port 9 is unreachable).
        assert!(
            !msg.contains("must be https"),
            "scheme guard should accept http://[::1] but rejected: {msg}"
        );
    }

    #[tokio::test]
    async fn dcr_accepts_http_localhost_registration_endpoint() {
        let cfg = OAuthConfig {
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .do_dynamic_client_registration("http://localhost:9/register", &metadata(None))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        assert!(
            !msg.contains("must be https"),
            "scheme guard should accept http://localhost but rejected: {msg}"
        );
    }

    #[tokio::test]
    async fn dcr_accepts_http_ipv4_loopback_registration_endpoint() {
        let cfg = OAuthConfig {
            dcr_enabled: true,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper
            .do_dynamic_client_registration("http://127.0.0.1:9/register", &metadata(None))
            .await
            .unwrap_err();
        let msg = format!("{err}");
        assert!(
            !msg.contains("must be https"),
            "scheme guard should accept http://127.0.0.1 but rejected: {msg}"
        );
    }

    #[tokio::test]
    async fn authorize_with_details_fails_cleanly_without_server() {
        // Unit-test scope: verify the method signature compiles and returns
        // an error when no real server is reachable (not a behavior test —
        // full behavior is in the mockito integration test, Task 1.3).
        let cfg = OAuthConfig {
            mcp_server_url: Some("http://localhost:1/nonexistent".into()),
            client_id: Some("x".into()),
            dcr_enabled: false,
            ..OAuthConfig::default()
        };
        let helper = OAuthHelper::new(cfg).unwrap();
        let err = helper.authorize_with_details().await.unwrap_err();
        // Any error path is acceptable here — the test ensures no panic.
        let _ = format!("{err}");
    }

    #[test]
    fn authorization_result_struct_has_expected_fields() {
        // Compile-time check: every required field is present and public.
        let _r = AuthorizationResult {
            access_token: "a".into(),
            refresh_token: Some("r".into()),
            expires_at: Some(1),
            scopes: vec!["openid".into()],
            issuer: Some("https://i.example".into()),
            client_id: "c".into(),
        };
    }

    #[test]
    fn build_auth_result_converts_expires_in_to_expires_at() {
        let token = crate::client::auth::TokenResponse {
            access_token: "a".into(),
            token_type: "Bearer".into(),
            expires_in: Some(3600),
            refresh_token: Some("r".into()),
            scope: Some("openid profile".into()),
        };
        let now = unix_now_secs();
        let r = OAuthHelper::build_auth_result(
            token,
            "c1".into(),
            Some("https://i.example".into()),
            &["openid".into()],
        );
        assert_eq!(r.client_id, "c1");
        assert_eq!(r.refresh_token.as_deref(), Some("r"));
        assert_eq!(r.issuer.as_deref(), Some("https://i.example"));
        assert_eq!(r.scopes, vec!["openid".to_string(), "profile".into()]);
        let expires_at = r.expires_at.expect("expires_at populated");
        assert!(
            expires_at >= now + 3599 && expires_at <= now + 3601,
            "expires_at ({}) should be approximately now+3600 ({})",
            expires_at,
            now + 3600
        );
    }

    #[test]
    fn build_auth_result_falls_back_to_requested_scopes_when_no_grant() {
        let token = crate::client::auth::TokenResponse {
            access_token: "a".into(),
            token_type: "Bearer".into(),
            expires_in: None,
            refresh_token: None,
            scope: None,
        };
        let requested = vec!["openid".to_string(), "email".to_string()];
        let r = OAuthHelper::build_auth_result(token, "c".into(), None, &requested);
        assert_eq!(r.scopes, requested);
        assert!(r.expires_at.is_none());
        assert!(r.refresh_token.is_none());
    }
}

/// SEP-837 and SEP-2207 composition rules, pinned at the level they are
/// DECIDED at rather than at the level they are observed at.
///
/// Each of these three helpers is a private pure function precisely so its rule
/// can be asserted without a network, a log subscriber or a new public field.
/// The wire-level consequences are asserted separately in
/// `tests/oauth_dcr_integration.rs`; a rule that only had wire coverage would be
/// the shape 116-09 measured and named — a suite that is green over a defect it
/// structurally cannot see.
#[cfg(test)]
mod sep837_sep2207_composition_tests {
    use super::*;

    fn dcr_request_registering(redirect_uris: &[&str]) -> DcrRequest {
        DcrRequest {
            redirect_uris: redirect_uris.iter().map(|u| (*u).to_string()).collect(),
            client_name: Some("pmcp-sdk".to_string()),
            client_uri: None,
            logo_uri: None,
            contacts: vec![],
            token_endpoint_auth_method: Some("none".to_string()),
            grant_types: vec![
                "authorization_code".to_string(),
                "refresh_token".to_string(),
            ],
            response_types: vec!["code".to_string()],
            scope: None,
            software_id: None,
            software_version: None,
            extra: Default::default(),
        }
    }

    fn owned(values: &[&str]) -> Vec<String> {
        values.iter().map(|v| (*v).to_string()).collect()
    }

    // -----------------------------------------------------------------------
    // apply_application_type — derivation, and the override that outranks it
    // -----------------------------------------------------------------------

    #[test]
    fn a_loopback_redirect_derives_native() {
        let mut request = dcr_request_registering(&["http://127.0.0.1:8080/callback"]);
        let sent = apply_application_type(&mut request).expect("loopback derives");
        assert_eq!(sent, "native");
        assert_eq!(request.application_type(), Some("native"));
    }

    #[test]
    fn an_https_non_loopback_redirect_derives_web() {
        // pmcp's own flow only ever registers a loopback URI, so this row is
        // unreachable end to end and is exercised directly — a platform
        // `oauth-proxy` registering an https redirect is the real caller.
        let mut request = dcr_request_registering(&["https://proxy.example.com/callback"]);
        let sent = apply_application_type(&mut request).expect("https non-loopback derives");
        assert_eq!(sent, "web");
        assert_eq!(request.application_type(), Some("web"));
    }

    #[test]
    fn an_explicit_application_type_is_never_clobbered_by_the_derivation() {
        // D-09's documented override path. The redirect URI here would derive
        // `native`, so a clobbering implementation produces a DIFFERENT value
        // and this test is a real detector rather than a tautology.
        let mut request = dcr_request_registering(&["http://127.0.0.1:8080/callback"]);
        request.set_application_type("web");
        let sent = apply_application_type(&mut request).expect("an override is not re-derived");
        assert_eq!(sent, "web");
        assert_eq!(request.application_type(), Some("web"));
    }

    #[test]
    fn a_mixed_redirect_vector_is_an_error_and_never_a_pick() {
        let mut request = dcr_request_registering(&[
            "http://127.0.0.1:8080/callback",
            "https://proxy.example.com/callback",
        ]);
        let err = apply_application_type(&mut request)
            .expect_err("D-10: a mixed vector is an ERROR, never a silent choice");
        let message = err.to_string();
        assert!(
            message.contains("127.0.0.1"),
            "names the native URI: {message}"
        );
        assert!(
            message.contains("proxy.example.com"),
            "names the web URI: {message}"
        );
        assert_eq!(
            request.application_type(),
            None,
            "a refused derivation must not leave a half-written value on the request"
        );
    }

    // -----------------------------------------------------------------------
    // compose_scopes_with_offline_access — SEP-2207's advertise-conditioned add
    // -----------------------------------------------------------------------

    #[test]
    fn offline_access_is_added_only_when_the_server_advertises_it() {
        let configured = owned(&["openid", "profile"]);
        assert_eq!(
            compose_scopes_with_offline_access(&configured, &owned(&["openid", "offline_access"])),
            owned(&["openid", "profile", "offline_access"]),
            "advertised: appended last, configured order preserved"
        );
        assert_eq!(
            compose_scopes_with_offline_access(&configured, &owned(&["openid", "profile"])),
            owned(&["openid", "profile"]),
            "NOT advertised: absent, because SEP-2207 conditions the request on support"
        );
        assert_eq!(
            compose_scopes_with_offline_access(&configured, &[]),
            owned(&["openid", "profile"]),
            "an empty scopes_supported advertises nothing"
        );
    }

    #[test]
    fn an_already_configured_offline_access_is_not_duplicated() {
        let configured = owned(&["openid", "offline_access"]);
        assert_eq!(
            compose_scopes_with_offline_access(&configured, &owned(&["offline_access"])),
            owned(&["openid", "offline_access"]),
            "a duplicated scope token is legal but is what a strict server rejects"
        );
    }

    #[test]
    fn the_configured_scopes_are_never_mutated_and_never_accumulate() {
        let configured = owned(&["openid"]);
        let advertised = owned(&["offline_access"]);

        let first = compose_scopes_with_offline_access(&configured, &advertised);
        let second = compose_scopes_with_offline_access(&configured, &advertised);

        assert_eq!(
            configured,
            owned(&["openid"]),
            "`OAuthConfig::scopes` is a public field; a caller reusing one config \
             across two flows must not watch it grow"
        );
        assert_eq!(
            first, second,
            "two flows compose the same value, not a longer one"
        );
        assert_eq!(first, owned(&["openid", "offline_access"]));
    }

    #[test]
    fn duplicate_configured_scopes_collapse_to_one_entry() {
        let configured = owned(&["openid", "openid", "profile"]);
        assert_eq!(
            compose_scopes_with_offline_access(&configured, &[]),
            owned(&["openid", "profile"])
        );
    }
}

/// D-11's divergence RULE, pinned where it is DECIDED.
///
/// The module name carries `application_type_divergence` so a
/// `nextest -E 'test(application_type_divergence)'` selector reaches every row
/// here — 116-01 measured that a selector which matches nothing reports success
/// rather than failing, so the name is load-bearing rather than descriptive.
///
/// There is deliberately no log-capture assertion anywhere in this module.
/// `tracing-subscriber` is an OPTIONAL dependency behind the `logging` feature
/// in this repo and not a dev-dependency, so a test built on capturing the
/// `tracing::warn!` would be unrunnable in the default configuration — and
/// would be pinning the REPORTING rather than the rule. The four rows below
/// pin the rule; `tests/oauth_dcr_integration.rs` pins the consequence that
/// actually matters end to end, which is that registration SUCCEEDS anyway.
#[cfg(test)]
mod application_type_divergence_tests {
    use super::*;
    use crate::server::auth::provider::DcrResponse;
    use serde_json::json;

    /// Parse a `DcrResponse` from a body shaped exactly as an authorization
    /// server would send it, so the `extra` carrier is exercised rather than
    /// bypassed.
    fn response_echoing(application_type: serde_json::Value) -> DcrResponse {
        serde_json::from_value(json!({
            "client_id": "issued-id",
            "application_type": application_type,
        }))
        .expect("a DcrResponse parses from a client_id plus an extra key")
    }

    #[test]
    fn an_equal_echo_is_not_a_divergence() {
        let response = response_echoing(json!("native"));
        assert_eq!(response.application_type(), Some("native"));
        assert_eq!(
            application_type_divergence("native", response.application_type()),
            None,
            "the server agreed; there is nothing to warn about"
        );
    }

    #[test]
    fn a_different_echo_is_a_divergence_naming_both_values() {
        let response = response_echoing(json!("web"));
        assert_eq!(
            application_type_divergence("native", response.application_type()),
            Some(("native".to_string(), "web".to_string())),
            "the tuple is (sent, registered) in that order — a warning that named \
             them the other way round would send a developer to change the wrong knob"
        );
    }

    #[test]
    fn an_absent_echo_is_not_a_divergence() {
        // RFC 7591 § 3.2.1 does not require the server to echo accepted
        // metadata, so silence is "no answer" and never "a different answer".
        let response: DcrResponse =
            serde_json::from_value(json!({ "client_id": "issued-id" })).expect("parses");
        assert_eq!(response.application_type(), None);
        assert_eq!(
            application_type_divergence("native", response.application_type()),
            None
        );
    }

    #[test]
    fn a_non_string_echo_reaches_application_type_divergence_as_an_absence() {
        // The accessor projects `Value::as_str` only (116-03), so a hostile
        // non-string value is `None` rather than a coerced `"42"` — and this
        // row proves the two halves compose: a non-string echo must not be
        // reported as a divergence against the string `"native"`.
        for hostile in [json!(42), json!(null), json!(true), json!(["native"])] {
            let response = response_echoing(hostile.clone());
            assert_eq!(
                response.application_type(),
                None,
                "a non-string echo must project to None: {hostile}"
            );
            assert_eq!(
                application_type_divergence("native", response.application_type()),
                None,
                "and must therefore not be reported as a divergence: {hostile}"
            );
        }
    }

    #[test]
    fn an_oversized_error_field_is_truncated_without_reproducing_what_it_dropped() {
        let long = "Z".repeat(MAX_DCR_ERROR_FIELD_CHARS + 500);
        let body =
            json!({ "error": "invalid_redirect_uri", "error_description": long }).to_string();
        let fields = dcr_rejection_fields(body.as_bytes());

        assert_eq!(fields.error.as_deref(), Some("invalid_redirect_uri"));
        let description = fields.error_description.expect("a description");
        assert!(
            description.contains("500 of 700 characters withheld"),
            "the notice must say how much was dropped: {description}"
        );
        assert!(
            description.chars().count() < MAX_DCR_ERROR_FIELD_CHARS + 100,
            "the bounded field must be far shorter than the input"
        );
    }

    #[test]
    fn a_non_string_or_unparseable_rejection_body_yields_no_fields() {
        let coerced = json!({ "error": 42, "error_description": ["nope"] }).to_string();
        let fields = dcr_rejection_fields(coerced.as_bytes());
        assert_eq!(fields.error, None, "a non-string error is never coerced");
        assert_eq!(fields.error_description, None);

        let html = dcr_rejection_fields(b"<html><body>502 Bad Gateway</body></html>");
        assert_eq!(html.error, None);
        assert_eq!(html.error_description, None);
    }
}

#[cfg(test)]
mod dcr_proptest {
    use super::*;
    use proptest::prelude::*;

    fn arb_dcr_request() -> impl Strategy<Value = crate::server::auth::provider::DcrRequest> {
        (
            prop::collection::vec("[a-z][a-z0-9-]{2,30}", 1..3),
            prop::option::of("[a-zA-Z][a-zA-Z0-9 _-]{1,40}"),
            prop::option::of(
                prop::string::string_regex("(none|client_secret_basic|client_secret_post)")
                    .unwrap(),
            ),
        )
            .prop_map(|(uris, name, auth_method)| {
                let redirect_uris = uris
                    .into_iter()
                    .map(|u| format!("http://localhost:8080/{u}"))
                    .collect();
                crate::server::auth::provider::DcrRequest {
                    redirect_uris,
                    client_name: name,
                    client_uri: None,
                    logo_uri: None,
                    contacts: vec![],
                    token_endpoint_auth_method: auth_method,
                    grant_types: vec!["authorization_code".into()],
                    response_types: vec!["code".into()],
                    scope: None,
                    software_id: None,
                    software_version: None,
                    extra: Default::default(),
                }
            })
    }

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(64))]
        #[test]
        fn dcr_request_serde_roundtrip(req in arb_dcr_request()) {
            let v = serde_json::to_value(&req).unwrap();
            let back: crate::server::auth::provider::DcrRequest =
                serde_json::from_value(v).unwrap();
            prop_assert_eq!(req.redirect_uris, back.redirect_uris);
            prop_assert_eq!(req.client_name, back.client_name);
            prop_assert_eq!(req.token_endpoint_auth_method, back.token_endpoint_auth_method);
        }

        #[test]
        fn oauth_config_builder_allows_all_combinations(
            has_id in any::<bool>(),
            has_name in any::<bool>(),
            dcr in any::<bool>(),
        ) {
            let cfg = OAuthConfig {
                client_id: has_id.then(|| "id".into()),
                client_name: has_name.then(|| "name".into()),
                dcr_enabled: dcr,
                mcp_server_url: Some("https://x.example".into()),
                ..OAuthConfig::default()
            };
            OAuthHelper::new(cfg).unwrap();
        }
    }
}

// In-tree proptest smoke check for DCR response parsing. The authoritative
// robustness gate is the cargo-fuzz target at `fuzz/fuzz_targets/dcr_response_parser.rs`
// (CLAUDE.md ALWAYS / FUZZ Testing). This module exists for fast per-PR
// regression coverage that runs as part of `cargo test`.
#[cfg(test)]
mod dcr_parser_fuzz {
    use proptest::prelude::*;

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(200))]
        #[test]
        fn parser_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..4096)) {
            // Must return Result, never panic. Error paths are acceptable.
            let _ = serde_json::from_slice::<
                crate::server::auth::provider::DcrResponse
            >(&bytes);
        }

        #[test]
        fn parser_accepts_minimal_valid_response(
            id in "[a-zA-Z0-9-]{8,40}",
            has_secret in any::<bool>(),
        ) {
            let mut v = serde_json::json!({"client_id": id});
            if has_secret {
                v["client_secret"] = serde_json::json!("s3cret");
            }
            let parsed: crate::server::auth::provider::DcrResponse =
                serde_json::from_value(v).unwrap();
            prop_assert_eq!(parsed.client_id, id);
            prop_assert_eq!(parsed.client_secret.is_some(), has_secret);
        }
    }
}