imcp2 0.2.0

Minimal MCP server that bridges an LLM to the Internet Computer, speaking textual Candid.
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
//! The MCP tools: an [`rmcp`] server handler exposing Internet Computer tools
//! over streamable HTTP.
//!
//! The LLM only ever deals with textual Candid; encoding/decoding happens in
//! `calls`. Anonymous canister calls use the shared agent injected by the
//! embedding application (see [`crate::McpServer`]); per-app identities are
//! minted on demand from the connection's registered session key (see
//! `identities`).

use candid::Principal;
use ic_agent::{Agent, Identity};
use rmcp::{
    handler::server::wrapper::Parameters,
    model::*,
    service::RequestContext,
    tool, tool_handler, tool_router,
    schemars, ErrorData as McpError, RoleServer, ServerHandler,
};

use crate::{auth, calls, discover, identities, identities::Identities, management, skills};

/// Cap on the per-canister Candid probes open_app / discover_app_canisters run to
/// fill in OQL / api-doc capability flags (#3). Discovery output is already bounded,
/// but a large declared manifest could still list many ids — probe only the most
/// authoritative handful so the extra fan-out stays small.
const MAX_CAPABILITY_PROBES: usize = 8;

/// Candid references exposed as MCP resources so the client writes correct
/// textual Candid. The textual-syntax cheat sheet is emphasised because every
/// tool here speaks textual Candid; the full type reference backs it up.
const CANDID_TEXTUAL_URI: &str = "candid://textual-syntax";
const CANDID_REFERENCE_URI: &str = "candid://reference";
/// URI scheme for IC skills exposed as MCP resources (`skill://<name>`).
const SKILL_URI_PREFIX: &str = "skill://";
const CANDID_TEXTUAL_MD: &str = include_str!("../static/candid-textual-syntax.md");
const CANDID_REFERENCE_MD: &str = include_str!("../static/candid-reference.md");

/// The OQL query-surface usage guide. Served on demand (via the `icp_oql_guide`
/// tool and the `oql://usage` resource) rather than inlined into every
/// `get_canister_candid` reply: `get_canister_candid` only signals `oql: true` plus a one-line
/// pointer here, so the guidance is delivered once, when the model chooses to
/// query, without bloating every interface read.
const OQL_USAGE_URI: &str = "oql://usage";
const OQL_PRIMER_MD: &str = include_str!("../static/oql-primer.md");

// Per-tool argument and output types live in the module that implements the
// tool: `calls` (get_canister_candid, canister_query, canister_update_call),
// `discover`, `identities`, `skills`, `management`. main.rs only wires the tools
// together.

#[derive(Clone)]
pub(crate) struct IcTools {
    agent: Agent,
    identities: Identities,
    skills: skills::SkillsCatalog,
}

#[tool_router]
impl IcTools {
    pub(crate) fn new(agent: Agent, identities: Identities, skills: skills::SkillsCatalog) -> Self {
        Self {
            agent,
            identities,
            skills,
        }
    }

    #[tool(
        description = "Fetch the Candid (.did) interface definition of an Internet Computer canister, read from its public `candid:service` metadata. Also reports two capability flags: `oql` (the canister exposes the OQL query surface — READ it via icp_oql_guide → get_canister_oql_schema → canister_query with the `oql` argument, since a Candid `method` query is then rejected) and `api_doc_available` (a `getApiDoc`/`get_api_doc` method exists — call get_canister_api_doc for a prose behavior guide; skip that call when this is false).",
        annotations(title = "Get Candid interface", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<calls::GetCandidOutput>(),
    )]
    async fn get_canister_candid(
        &self,
        Parameters(calls::GetCandidArgs { canister_id }): Parameters<calls::GetCandidArgs>,
    ) -> Result<CallToolResult, McpError> {
        let principal = match Principal::from_text(&canister_id) {
            Ok(p) => p,
            Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
        };
        match self
            .agent
            .read_state_canister_metadata(principal, "candid:service")
            .await
        {
            Ok(bytes) => match String::from_utf8(bytes) {
                Ok(did) => {
                    // Signal an OQL query surface structurally (`oql: true`). When
                    // set, the guidance pointer is emitted as a SEPARATE content
                    // block, so the first block stays the raw `.did` (still valid,
                    // copy-pastable Candid) — the full primer is served on demand
                    // (see icp_oql_guide / OQL_USAGE_URI), never inlined.
                    let oql = calls::has_oql(&did);
                    // Same predicate get_canister_api_doc uses, so the flag gates that
                    // call: true means a `getApiDoc`/`get_api_doc` method exists.
                    let api_doc_available = calls::api_doc_method(&did).is_some();
                    let output = calls::GetCandidOutput {
                        canister_id,
                        candid: did.clone(),
                        oql,
                        api_doc_available,
                    };
                    // Assemble any capability notes as separate blocks so the first
                    // block stays the raw, paste-able `.did`.
                    let mut notes = Vec::new();
                    if oql {
                        notes.push(format!(
                            "This canister exposes an OQL query surface (a JSON query language \
                             over its data), so READ it through the OQL tools — a Candid `method` \
                             query via canister_query is rejected here. Order: icp_oql_guide \
                             (dialect, once) → get_canister_oql_schema (entities/fields) → \
                             canister_query with the `oql` argument (run a JSON query, get a table). \
                             Those wrap the `schema`/`execute` methods (no Candid escaping). Per-app \
                             data is caller-gated, so the OQL read path REQUIRES the app's \
                             derivation_origin — an anonymous OQL read is rejected (for now), not \
                             silently empty; pass the derivation_origin from open_app / resolve_app. \
                             See icp_oql_guide (or the `{OQL_USAGE_URI}` resource) for the dialect. \
                             canister_update_call then handles UPDATE calls only."
                        ));
                    }
                    notes.push(if api_doc_available {
                        "This canister exposes an API-doc method (api_doc_available=true): call \
                         get_canister_api_doc for a prose \"how this app behaves\" guide (units, \
                         auth, lifecycle, gotchas)."
                            .to_string()
                    } else {
                        "This canister declares no API-doc method (api_doc_available=false) — don't \
                         call get_canister_api_doc; the Candid types above are the interface."
                            .to_string()
                    });
                    let mut blocks = vec![did];
                    blocks.extend(notes);
                    Ok(ok_structured_blocks(blocks, &output))
                }
                Err(e) => Ok(err(format!("metadata is not valid UTF-8: {e}"))),
            },
            Err(e) => Ok(err(format!(
                "could not read candid:service metadata: {e}"
            ))),
        }
    }

    #[tool(
        description = "Load the OQL query-surface guide: the JSON query dialect for canisters that expose OQL (get_canister_candid reports `oql: true`) — entities/fields/edges via `schema`, and the `execute` query object (filters, aggregation, ordering, edge traversal, paging). This is step ONE of the fixed sequence guide→schema→query: read this once, then `get_canister_oql_schema` for the exact entity/field names (they are the schema's own — often PLURAL and unlike the Candid types/methods, e.g. `bookings` not `Booking`/`getBookings`), then `canister_query` with the `oql` argument. Never guess bespoke per-question methods. The schema read and the query REQUIRE the app's `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are disabled for now and are rejected with guidance. Both wrap the `schema`/`execute` methods, so you write plain JSON — no Candid escaping.",
        annotations(title = "Get the OQL query guide", read_only_hint = true, destructive_hint = false, open_world_hint = false),
        output_schema = schema_for_output::<calls::OqlGuideOutput>(),
    )]
    async fn icp_oql_guide(
        &self,
        Parameters(_args): Parameters<management::NoArgs>,
    ) -> Result<CallToolResult, McpError> {
        let output = calls::OqlGuideOutput { content: OQL_PRIMER_MD.to_string() };
        Ok(ok_structured(output.content.clone(), &output))
    }

    #[tool(
        description = "Fetch the OQL schema catalogue of a canister that exposes the OQL surface (get_canister_candid reports `oql: true`): its entities, their primary keys, fields, and edges, as JSON. The MIDDLE step of guide→schema→query: read `icp_oql_guide` once, then call THIS before canister_query so you use the exact entity/field names instead of guessing. Entity names are the schema's own — often PLURAL and different from the Candid types/methods (e.g. `bookings`, not `Booking`/`getBookings`). Returns the schema plus a ready-to-run `canister_query` example per entity (each preserving this call's identity). AUTH: `derivation_origin` is REQUIRED — the schema itself is gated by the caller's principal, so a read with no origin is REJECTED (anonymous per-app reads are disabled for now) with guidance to pass it, rather than returning an empty entity list you'd misread as \"the app has no data model\". Pass the app's canonical `derivation_origin` (from open_app / resolve_app) to read the entities visible to the USER; the reply echoes `derived_for_origin` / `acted_as_principal`.",
        annotations(title = "Get the OQL schema", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<calls::OqlSchemaOutput>(),
    )]
    async fn get_canister_oql_schema(
        &self,
        Parameters(calls::OqlSchemaArgs { canister_id, derivation_origin, account }): Parameters<calls::OqlSchemaArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let principal = match Principal::from_text(&canister_id) {
            Ok(p) => p,
            Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
        };
        let arg_bytes = match calls::encode_unit_arg() {
            Ok(b) => b,
            Err(e) => return Ok(err(e)),
        };
        // Anonymous per-app reads are disabled for now (the schema is itself gated by
        // the caller's principal): reject with guidance instead of returning an empty
        // entity list an agent would misread as "the app has no data model".
        let target = match resolve_identity_target(derivation_origin) {
            Ok(t) => t,
            Err(e) => return Ok(err(e)),
        };
        let Some(target) = target else {
            return Ok(err(oql_needs_origin_error("Reading the OQL schema")));
        };
        let requested = Some(target.requested.clone());
        let origin = Some(target.origin.clone());
        let (agent, acted_as) = match self
            .resolve_agent(&ctx, Some(target.origin.as_str()), account.as_deref(), "reading the schema")
            .await
        {
            Ok(a) => a,
            Err(e) => return Ok(err(e)),
        };
        let reply = match calls::raw_call(&agent, principal, "schema", arg_bytes, true).await {
            Ok(b) => b,
            Err(e) => return Ok(err(format!("OQL schema call failed: {e}"))),
        };
        let schema = calls::decode_schema_reply(&reply);
        // An origin is required (rejected above if absent), so this is never anonymous.
        let is_anonymous = false;
        // #8: a COMPLETE canister_query per entity, carrying the SAME identity this
        // schema was read under (so copying one doesn't drop to anon).
        let example_queries =
            calls::oql_query_examples(&canister_id, &schema, Some(target.origin.as_str()), account.as_deref());
        // #1: an EMPTY schema (no visible entities) for this authenticated account is
        // "nothing visible here", not "the app has no data model".
        let empty_note = if calls::oql_schema_is_empty(&schema) {
            Some(
                "This account sees no OQL entities on this canister — confirm the \
                 derivation_origin/account are the ones the user uses in their browser."
                    .to_string(),
            )
        } else {
            None
        };
        // Keep the primary block as the raw schema JSON (paste-able); surface the
        // empty-note, the ready-to-run examples, and the identity note as SEPARATE
        // blocks so none of them break the JSON for a copy-paste consumer.
        let mut blocks = vec![schema.clone()];
        if let Some(note) = &empty_note {
            blocks.push(note.clone());
        }
        if !example_queries.is_empty() {
            blocks.push(format!(
                "Ready-to-run queries (read-only; each preserves this identity):\n{}",
                example_queries.join("\n")
            ));
        }
        blocks.push(format!("[{}]", identity_annotation(&target, acted_as.as_deref())));
        let output = calls::OqlSchemaOutput {
            canister_id,
            schema,
            acted_as_principal: acted_as,
            derived_for_origin: origin,
            requested,
            is_anonymous,
            note: empty_note,
            example_queries,
        };
        Ok(ok_structured_blocks(blocks, &output))
    }

    #[tool(
        description = "Read a canister's own API documentation — a prose \"how this app behaves\" guide covering units, auth, lifecycle, non-obvious semantics, mutation safety, polling rules, and gotchas — if it exposes a `getApiDoc`/`get_api_doc` method. Call this ONLY when get_canister_candid (or open_app) reports `api_doc_available: true`; most canisters have no such doc, and then the Candid types ARE the interface. Returns a STRUCTURED result in every case (never a bare error): on success `available: true` + the doc markdown; otherwise `available: false` with `expected` (true = interface read fine, no such method — don't retry) and `retry` (true = a transient/unreachable failure — retry) plus a `next` hint, so you can tell \"no doc here\" from \"couldn't reach it\".",
        annotations(title = "Get a canister's API documentation", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<calls::ApiDocOutput>(),
    )]
    async fn get_canister_api_doc(
        &self,
        Parameters(calls::ApiDocArgs { canister_id }): Parameters<calls::ApiDocArgs>,
    ) -> Result<CallToolResult, McpError> {
        let principal = match Principal::from_text(&canister_id) {
            Ok(p) => p,
            Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
        };
        // Read the interface to learn which naming the canister uses
        // (getApiDoc vs get_api_doc); the doc is public, so call anonymously.
        let did = calls::candid_service(&self.agent, principal).await;
        // A structured "not available" (#6) so the agent can tell an EXPECTED
        // absence (interface read fine, no api-doc method — don't retry) from an
        // UNREACHABLE canister (couldn't read the interface / the call trapped —
        // retry) instead of getting one opaque error for both.
        let unavailable = |expected: bool, retry: bool, next: &str| {
            let output = calls::ApiDocOutput {
                canister_id: canister_id.clone(),
                available: false,
                method: None,
                doc: None,
                expected,
                retry,
                next: Some(next.to_string()),
            };
            ok_structured(next.to_string(), &output)
        };
        let Some(did) = did.as_deref() else {
            // Interface unreadable: can't tell whether an api-doc method exists, and
            // this is often transient (or access-restricted) — flag it retryable.
            return Ok(unavailable(
                false,
                true,
                "Couldn't read this canister's Candid interface, so its API-doc method (if any) is \
                 unknown — this may be transient. Retry, or read the interface with \
                 get_canister_candid.",
            ));
        };
        let method = match calls::api_doc_method(did) {
            Some(m) => m,
            None => {
                // Interface read fine; the canister simply declares no api-doc method
                // — expected for most canisters, and retrying won't change it.
                return Ok(unavailable(
                    true,
                    false,
                    "This canister declares no `getApiDoc`/`get_api_doc` method — most canisters \
                     don't. Use get_canister_candid for the interface; its api_doc_available flag \
                     mirrors this.",
                ));
            }
        };
        let arg_bytes = match calls::encode_unit_arg() {
            Ok(b) => b,
            Err(e) => return Ok(err(e)),
        };
        let reply = match calls::raw_call(&self.agent, principal, method, arg_bytes, true).await {
            Ok(b) => b,
            Err(e) => {
                // The method exists but the call failed — a transient/network issue,
                // so retryable (distinct from the "no such method" case above).
                return Ok(unavailable(
                    false,
                    true,
                    &format!("The {method} call failed ({e}); this is likely transient — retry."),
                ));
            }
        };
        let doc = calls::decode_text_reply(&reply);
        let output = calls::ApiDocOutput {
            canister_id,
            available: true,
            method: Some(method.to_string()),
            doc: Some(doc.clone()),
            expected: false,
            retry: false,
            next: None,
        };
        Ok(ok_structured(doc, &output))
    }

    /// The agent to sign calls with for a request, and the principal it signs as:
    /// the shared anonymous agent (principal `None`) when `origin` is `None`, else
    /// one backed by a short-lived account delegation for that Internet Identity
    /// derivation `origin`, derived on demand from this connection's standing
    /// credential. `origin` must be a VALIDATED derivation origin: the canonical one
    /// from [`resolve_identity_target`], which every caller here now passes
    /// (canister_update_call, both of canister_query's paths, and
    /// get_canister_oql_schema). (get_app_principal and
    /// list_app_accounts don't use this helper — they call
    /// `Identities::delegated_identity_for` / `list_accounts` directly with a
    /// `resolve_identity_target` origin.) `delegated_identity_for` re-canonicalizes
    /// internally (idempotent), so an already-canonical origin is fine. `what`
    /// names the action for the no-session error.
    async fn resolve_agent(
        &self,
        ctx: &RequestContext<RoleServer>,
        origin: Option<&str>,
        account: Option<&str>,
        what: &str,
    ) -> Result<(Agent, Option<String>), String> {
        match origin {
            None => Ok((self.agent.clone(), None)),
            Some(origin) => {
                let session_id = authed_session(ctx)
                    .ok_or_else(|| format!("{what} as an app needs an authenticated session"))?
                    .session_id;
                let delegated = self
                    .identities
                    .delegated_identity_for(&session_id, origin, account)
                    .await?;
                let principal = delegated.sender().ok().map(|p| p.to_text());
                // Clone the injected agent and swap in the delegated identity:
                // authenticated calls ride the host's boundary-node routing,
                // never a second hard-coded endpoint.
                let mut agent = self.agent.clone();
                agent.set_identity(delegated);
                Ok((agent, principal))
            }
        }
    }

    /// Probe the app's OWN data canisters once (#3): fetch each candidate's Candid
    /// anonymously (the interface is public) and record its OQL / api-doc capability
    /// flags on the `DiscoveredCanister`. Only app-owned data candidates are probed
    /// — never the frontend or a shared system canister (ledger/II/NNS) — so the
    /// caller-gated data-access handle the flags feed stays correctly scoped. Probes
    /// run CONCURRENTLY and fail soft: an unreadable interface just leaves the flags
    /// null. Bounded by `MAX_CAPABILITY_PROBES` so a large manifest can't fan out
    /// unboundedly.
    async fn enrich_capabilities(&self, canisters: &mut [discover::DiscoveredCanister]) {
        let targets: Vec<(usize, Principal)> = canisters
            .iter()
            .enumerate()
            .filter(|(_, c)| discover::is_app_data_candidate(c))
            .filter_map(|(i, c)| Principal::from_text(&c.canister_id).ok().map(|p| (i, p)))
            .take(MAX_CAPABILITY_PROBES)
            .collect();
        if targets.is_empty() {
            return;
        }
        let mut set = tokio::task::JoinSet::new();
        for (i, principal) in targets {
            let agent = self.agent.clone();
            set.spawn(async move {
                let did = calls::candid_service(&agent, principal).await;
                // Only conclude flags when the interface was actually read; a failed
                // read leaves them null (unknown), not a misleading `false`.
                let flags = did
                    .as_deref()
                    .map(|d| (calls::has_oql(d), calls::api_doc_method(d).is_some()));
                (i, flags)
            });
        }
        while let Some(res) = set.join_next().await {
            if let Ok((i, Some((oql, api_doc)))) = res {
                canisters[i].oql = Some(oql);
                canisters[i].api_doc_available = Some(api_doc);
            }
        }
    }

    #[tool(
        description = "Make an UPDATE call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Args are encoded against the method's declared Candid types (so plain literals like 42 coerce correctly — no `: type` annotations needed). Omit `derivation_origin` to call anonymously, or pass it to call AS your account at that app — a short-lived account delegation is derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's EXACT canonical II derivation origin (not necessarily its visible URL; don't infer it from alternativeOrigins). Get it once from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate) and reuse it here — this tool does NOT accept a raw website URL. By default this uses the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result echoes `derived_for_origin` + `requested` + `acted_as_principal` so you can catch an origin mismatch. For READ-only calls (Candid query methods or OQL queries) use canister_query instead. If get_canister_candid couldn't fetch the interface, pass the `.did` text as `candid` so args/replies are still typed.",
        annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<calls::CanisterUpdateCallOutput>(),
    )]
    async fn canister_update_call(
        &self,
        Parameters(calls::CanisterUpdateCallArgs {
            canister_id,
            method,
            args,
            derivation_origin,
            account,
            candid,
        }): Parameters<calls::CanisterUpdateCallArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let principal = match Principal::from_text(&canister_id) {
            Ok(p) => p,
            Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
        };
        // The interface to encode/decode against: the canister's own
        // candid:service if exposed, else the caller-supplied `candid`. Update calls
        // are never redirected (OQL is read-only), so no oql_query_redirect here.
        let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await;
        let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) {
            Ok(b) => b,
            Err(e) => return Ok(err(e)),
        };
        // Resolve which principal to act as: none = anonymous; else the app's
        // effective (canonical) II derivation origin, from the caller's explicit
        // `derivation_origin` (obtained once via open_app / resolve_app).
        let target = match resolve_identity_target(derivation_origin) {
            Ok(t) => t,
            Err(e) => return Ok(err(e)),
        };
        let origin = target.as_ref().map(|t| t.origin.as_str());
        let (agent, acted_as_principal) = match self
            .resolve_agent(&ctx, origin, account.as_deref(), "calling")
            .await
        {
            Ok(a) => a,
            Err(e) => return Ok(err(e)),
        };
        let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, false).await {
            Ok(b) => b,
            Err(e) => return Ok(err(format!("call failed: {e}"))),
        };
        // Decode against the Candid interface so field names are recovered.
        let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes);
        let (derived_for_origin, requested, derivation_origin_source) = match &target {
            Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())),
            None => (None, None, None),
        };
        let is_anonymous = target.is_none();
        // Keep the primary text block pure textual Candid (paste-able); surface the
        // identity note (so a wrong-principal is visible to text-only clients) as a
        // SEPARATE block rather than contaminating the reply.
        let mut blocks = vec![reply.clone()];
        if let Some(t) = &target {
            let acted = acted_as_principal.as_deref().unwrap_or("<unknown>");
            blocks.push(format!("[{}]", identity_annotation(t, Some(acted))));
        }
        let output = calls::CanisterUpdateCallOutput {
            canister_id, method, reply,
            acted_as_principal, derived_for_origin, requested, derivation_origin_source,
            is_anonymous,
        };
        Ok(ok_structured_blocks(blocks, &output))
    }

    #[tool(
        description = "If the request is about a specific app or the user's own data in it (a balance, holdings, a profile, \"what can it do\"), start with open_app first to resolve the app and discover its canisters, then compose the answer from what it returns, since there is rarely a dedicated per-feature tool. READ from an Internet Computer canister — provide EITHER a Candid `query` method OR an OQL query (exactly one). `method` is a query function from the canister's Candid interface, invoked with textual-Candid `args`; `oql` is an OQL query as a JSON object string, run against the canister's `execute` method (no Candid escaping — write plain JSON). Use `oql` when get_canister_candid reports `oql: true` (a Candid `method` query is then REJECTED — read via OQL); use `method` for a plain query canister such as a ledger. The `oql` path REQUIRES `derivation_origin` (per-app data is caller-gated; an anonymous OQL read is rejected for now) and returns `columns` + `rows` (a markdown table) with `has_more` for paging; on an empty result it validates the query's `start` against the schema and returns valid_entities + a did_you_mean repair. The `method` path may be anonymous, or pass `derivation_origin` + `account` to read AS your account; it returns the decoded reply in textual Candid. Get `derivation_origin` from open_app / resolve_app (not a raw URL), and the OQL entity/field names from get_canister_oql_schema. For state changes use canister_update_call.",
        annotations(title = "Query a canister (Candid method or OQL)", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<calls::CanisterQueryOutput>(),
    )]
    async fn canister_query(
        &self,
        Parameters(calls::CanisterQueryArgs {
            canister_id,
            method,
            args,
            oql,
            derivation_origin,
            account,
            candid,
        }): Parameters<calls::CanisterQueryArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let principal = match Principal::from_text(&canister_id) {
            Ok(p) => p,
            Err(e) => return Ok(err(format!("invalid canister id: {e}"))),
        };
        // Normalize: an empty/whitespace-only `method`/`oql` counts as absent, so a
        // blank string doesn't masquerade as "both provided".
        let method = method.map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
        let oql = oql.filter(|o| !o.trim().is_empty());
        match (method, oql) {
            (Some(_), Some(_)) => Ok(err(
                "provide EITHER `method` (a Candid query) OR `oql` (an OQL query) — not both."
                    .into(),
            )),
            (None, None) => Ok(err(
                "canister_query needs a read to run: pass `method` (a Candid query function) or \
                 `oql` (an OQL query object). For a canister that exposes an OQL surface \
                 (get_canister_candid reports oql: true) use `oql`; otherwise use `method`."
                    .into(),
            )),
            (Some(method), None) => {
                self.canister_candid_query(
                    &ctx, principal, canister_id, method, args, derivation_origin, account, candid,
                )
                .await
            }
            (None, Some(oql)) => {
                self.canister_oql_query(&ctx, principal, canister_id, oql, derivation_origin, account)
                    .await
            }
        }
    }

    /// `canister_query`'s Candid-`method` path: a read-only query call, encoded and
    /// decoded against the canister's Candid interface. Rejected on an OQL canister
    /// (data reads go through the `oql` path); may be anonymous or as an app account.
    #[allow(clippy::too_many_arguments)]
    async fn canister_candid_query(
        &self,
        ctx: &RequestContext<RoleServer>,
        principal: Principal,
        canister_id: String,
        method: String,
        args: String,
        derivation_origin: Option<String>,
        account: Option<String>,
        candid: Option<String>,
    ) -> Result<CallToolResult, McpError> {
        // The interface to encode/decode against: the canister's own candid:service
        // if exposed, else the caller-supplied `candid`.
        let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await;
        // Prefer OQL: a canister that exposes an OQL query surface must be READ with
        // an OQL query, so reject a raw Candid `method` query here. Fail fast, before
        // encoding args.
        if let Some(msg) = calls::oql_query_redirect(did.as_deref()) {
            return Ok(err(msg));
        }
        // Read/write split: a query call to an UPDATE method is rejected by the
        // replica at runtime, so when the interface is readable and `method` is
        // declared as an update (not a query), fail fast with a clear pointer to
        // canister_update_call instead of an opaque failure. Fail OPEN when the
        // interface can't be read or the method isn't declared (is_query_method →
        // None): the IC then decides, matching the old permissive behavior. (The
        // reverse — a query method called as an update — is valid on the IC, so
        // canister_update_call stays permissive.)
        if let Some(did_text) = did.as_deref() {
            if calls::is_query_method(did_text, &method) == Some(false) {
                return Ok(err(format!(
                    "`{method}` is not a `query` method on this canister — its Candid signature is \
                     an update method, which the replica refuses to run as a query. Call it with \
                     canister_update_call instead (a state-changing update call)."
                )));
            }
        }
        let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) {
            Ok(b) => b,
            Err(e) => return Ok(err(e)),
        };
        let target = match resolve_identity_target(derivation_origin) {
            Ok(t) => t,
            Err(e) => return Ok(err(e)),
        };
        let origin = target.as_ref().map(|t| t.origin.as_str());
        let (agent, acted_as_principal) = match self
            .resolve_agent(ctx, origin, account.as_deref(), "querying")
            .await
        {
            Ok(a) => a,
            Err(e) => return Ok(err(e)),
        };
        let reply_bytes = match calls::raw_call(&agent, principal, &method, arg_bytes, true).await {
            Ok(b) => b,
            Err(e) => return Ok(err(format!("query failed: {e}"))),
        };
        let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes);
        let (derived_for_origin, requested, derivation_origin_source) = match &target {
            Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())),
            None => (None, None, None),
        };
        let is_anonymous = target.is_none();
        // #1: an anonymous query whose reply LOOKS empty is most likely an auth
        // artifact (per-app data is caller-gated), not "no data". Computed only from
        // local facts (anonymous + empty-looking reply); conservative, so a reply
        // with real content never trips it.
        let note = (is_anonymous && calls::candid_reply_is_empty(&reply)).then(|| {
            calls::anonymous_empty_note(
                "this query call",
                "the app's canonical `derivation_origin` (from open_app / resolve_app)",
            )
        });
        let mut blocks = vec![reply.clone()];
        if let Some(n) = &note {
            blocks.push(n.clone());
        }
        if let Some(t) = &target {
            let acted = acted_as_principal.as_deref().unwrap_or("<unknown>");
            blocks.push(format!("[{}]", identity_annotation(t, Some(acted))));
        }
        let output = calls::CanisterQueryOutput {
            canister_id,
            mode: "candid".to_string(),
            method: Some(method),
            reply: Some(reply),
            columns: Vec::new(),
            rows: Vec::new(),
            has_more: false,
            acted_as_principal,
            derived_for_origin,
            requested,
            derivation_origin_source,
            is_anonymous,
            note,
            valid_entities: None,
            did_you_mean: None,
        };
        Ok(ok_structured_blocks(blocks, &output))
    }

    /// `canister_query`'s `oql` path (the former run_canister_oql_query tool): wrap
    /// the JSON query as `execute`'s single text arg, run it, and decode the tabular
    /// reply. Anonymous per-app OQL reads are disabled, so a `derivation_origin` is
    /// REQUIRED; an empty/failed result is diagnosed against the schema for the SAME
    /// principal (#1/#7).
    async fn canister_oql_query(
        &self,
        ctx: &RequestContext<RoleServer>,
        principal: Principal,
        canister_id: String,
        oql: String,
        derivation_origin: Option<String>,
        account: Option<String>,
    ) -> Result<CallToolResult, McpError> {
        // Validate the query is a JSON object and wrap it as `execute`'s single text
        // arg — the model writes plain JSON, we do the Candid encoding.
        let query_json = match calls::normalize_oql_query(&oql) {
            Ok(s) => s,
            Err(e) => return Ok(err(e)),
        };
        let arg_bytes = match calls::encode_text_arg(&query_json) {
            Ok(b) => b,
            Err(e) => return Ok(err(e)),
        };
        // Anonymous per-app reads are disabled for now (this canister gates data by
        // the caller's principal), so an OQL query REQUIRES a derivation origin.
        let target = match resolve_identity_target(derivation_origin) {
            Ok(t) => t,
            Err(e) => return Ok(err(e)),
        };
        let Some(target) = target else {
            return Ok(err(oql_needs_origin_error("Running an OQL query")));
        };
        let requested = Some(target.requested.clone());
        let origin = Some(target.origin.clone());
        let derivation_origin_source = Some(target.source.clone());
        let (agent, acted_as) = match self
            .resolve_agent(ctx, Some(target.origin.as_str()), account.as_deref(), "querying")
            .await
        {
            Ok(a) => a,
            Err(e) => return Ok(err(e)),
        };
        let identity_note = format!("[{}]", identity_annotation(&target, acted_as.as_deref()));
        // An origin is required (rejected above if absent), so this is never anonymous.
        let is_anonymous = false;
        let reply = match calls::raw_call(&agent, principal, "execute", arg_bytes, true).await {
            Ok(b) => b,
            Err(e) => {
                // A trap is the OTHER way an invalid `start` shows up (#7): validate
                // it against the schema for THIS principal and fold the repair into
                // the error. Failed context: only the unknown-`start` repair is
                // appended, never a "came back empty" note.
                let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await;
                let mut msg = format!("OQL execute failed: {e}");
                if let Some(note) = d.note {
                    msg.push_str(&format!("\n\n{note}"));
                }
                return Ok(err(msg));
            }
        };
        // Decode the reply against the canister's interface so cell/field names are
        // recovered (the wire format hashes them).
        let did = calls::resolve_did(&agent, principal, None).await;
        match calls::parse_execute_reply(did.as_deref(), &reply) {
            calls::OqlResult::Table { columns, rows, has_more } => {
                // Validate-on-empty (#1/#7): a 0-row result is diagnosed with facts
                // from the SAME principal — unknown-`start` repair, or a benign
                // "0 rows for this account" — never by probing others.
                let mut diag = if rows.is_empty() {
                    diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::EmptyResult).await
                } else {
                    OqlEmptyDiagnosis::none()
                };
                if rows.is_empty() && diag.note.is_none() {
                    diag.note = Some(format!(
                        "Query matched 0 rows for this account{}.",
                        calls::oql_query_start(&query_json)
                            .map(|s| format!(" (start: \"{s}\")"))
                            .unwrap_or_default()
                    ));
                }
                let mut blocks = vec![calls::render_table(&columns, &rows, has_more)];
                if let Some(note) = &diag.note {
                    blocks.push(note.clone());
                }
                blocks.push(identity_note);
                let output = calls::CanisterQueryOutput {
                    canister_id,
                    mode: "oql".to_string(),
                    method: None,
                    reply: None,
                    columns,
                    rows,
                    has_more,
                    acted_as_principal: acted_as,
                    derived_for_origin: origin,
                    requested,
                    derivation_origin_source,
                    is_anonymous,
                    note: diag.note,
                    valid_entities: diag.valid_entities,
                    did_you_mean: diag.did_you_mean,
                };
                Ok(ok_structured_blocks(blocks, &output))
            }
            calls::OqlResult::QueryError(msg) => {
                // The canister returned its error arm. An invalid `start` can land
                // here too, so enrich with the schema-based repair (#7).
                let d = diagnose_empty_oql(&agent, principal, &query_json, is_anonymous, EmptyContext::Failed).await;
                let mut text = format!("the canister returned an OQL error: {msg}");
                if let Some(note) = d.note {
                    text.push_str(&format!("\n\n{note}"));
                }
                Ok(err(text))
            }
            calls::OqlResult::TooManyColumns { column_count } => {
                // The reply's first row was wider than the tool will densify. This
                // is NOT pageable (paging raises `offset`, which returns more rows,
                // not fewer columns), so give the actionable fix — narrow `select`
                // — rather than a `has_more` that would loop the agent forever.
                Ok(err(format!(
                    "This OQL result has {column_count} columns, more than the \
                     {max} this tool returns. Narrow the query's `select` to the \
                     columns you need and re-query. (Paging with a higher `offset` \
                     won't help — `offset` returns more rows, not fewer columns.)",
                    max = calls::MAX_OQL_COLUMNS,
                )))
            }
            calls::OqlResult::Unrecognized(raw) => {
                // Not a recognizable OQL result — hand back the raw decoded reply so
                // the model still has the data. Carry a `note` (rather than null) so a
                // structured-only consumer doesn't misread empty columns/rows as
                // "no data" when the reply actually carried content (in the text block).
                let unparsed_note =
                    "This reply couldn't be parsed as an OQL table; the raw decoded reply is in the \
                     text block. This is NOT an empty result — read the raw reply for the data."
                        .to_string();
                let blocks = vec![
                    format!("(Could not parse this as an OQL table; raw reply below.)\n\n{raw}"),
                    identity_note,
                ];
                let output = calls::CanisterQueryOutput {
                    canister_id,
                    mode: "oql".to_string(),
                    method: None,
                    reply: None,
                    columns: Vec::new(),
                    rows: Vec::new(),
                    has_more: false,
                    acted_as_principal: acted_as,
                    derived_for_origin: origin,
                    requested,
                    derivation_origin_source,
                    is_anonymous,
                    note: Some(unparsed_note),
                    valid_entities: None,
                    did_you_mean: None,
                };
                Ok(ok_structured_blocks(blocks, &output))
            }
        }
    }

    #[tool(
        description = "Get the Internet Computer principal you act as at an app, without making a canister call. Identify the app by `derivation_origin` — its EXACT canonical Internet Identity derivation origin (NOT necessarily the visible website URL, and never inferred from an alternativeOrigins list). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. The account delegation is derived on demand from this connection's standing Internet Identity credential. By default this resolves the app's default account; pass `account` (a name from list_app_accounts) for a specific one. The result returns the `principal` plus `derived_for_origin` and `requested` — compare them to catch a canonicalization surprise. If the principal looks wrong, the derivation origin is wrong: re-resolve the app with open_app / resolve_app rather than guessing an origin.",
        annotations(title = "Get your principal at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<identities::PrincipalOutput>(),
    )]
    async fn get_app_principal(
        &self,
        Parameters(identities::GetPrincipalArgs { derivation_origin, account }): Parameters<identities::GetPrincipalArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let session_id = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("getting an app principal needs an authenticated session".into())),
        };
        // `derivation_origin` is a required String here, so this always yields a
        // target (never the anonymous None) unless it fails validation.
        let target = match resolve_identity_target(Some(derivation_origin)) {
            Ok(Some(t)) => t,
            Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())),
            Err(e) => return Ok(err(e)),
        };
        let delegated = match self
            .identities
            .delegated_identity_for(&session_id, &target.origin, account.as_deref())
            .await
        {
            Ok(d) => d,
            Err(e) => return Ok(err(e)),
        };
        let principal = match delegated.sender() {
            Ok(p) => p.to_text(),
            Err(e) => return Ok(err(format!("could not derive principal for {}: {e}", target.origin))),
        };
        // Surface a query-only session (H2) so the LLM won't attempt (and have the
        // IC reject at ingress) canister-management updates.
        let read_only = self.identities.is_read_only(&session_id).await == Some(true);
        let mut text = format!("{principal}\n\n[{}]", identity_annotation(&target, None));
        if read_only {
            text.push_str(
                "\n\n(This Internet Identity session was authorized for \"Questions only\": reads work, \
                 but canister management — create/install/start/stop/delete, and icp_canister_status — \
                 needs update access. Ask the user to reconnect and choose \"Actions & questions\" on \
                 Internet Identity's consent screen.)",
            );
        }
        let output = identities::PrincipalOutput {
            derived_for_origin: target.origin,
            requested: target.requested,
            derivation_origin_source: target.source,
            account,
            principal,
            read_only,
        };
        Ok(ok_structured(text, &output))
    }

    #[tool(
        description = "List the user's Internet Identity accounts at an app. Identify the app by `derivation_origin` — its EXACT canonical II derivation origin (not necessarily the visible URL). Get it from open_app / resolve_app (which turn an app name or URL into the derivation origin under the guessed-domain gate); this tool does NOT accept a raw website URL. Internet Identity gives the user a distinct principal per derivation origin, and within it they may hold several accounts: a default account everyone gets automatically (the anchor's current, user-controllable default there), plus any named accounts they created. Use this before acting on the user's behalf: if there's only the default account, just proceed (canister_query / canister_update_call / get_app_principal with no `account`); if there are several, pick one with the user by passing its name as `account`. Returns each account's name (the default has none), number, and last-used time, plus `derived_for_origin`/`requested` — if these accounts don't match what the user sees in their browser, the derivation origin is wrong (re-resolve the app rather than guessing). Requires an authenticated session.",
        annotations(title = "List your accounts at an app", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<identities::AccountsOutput>(),
    )]
    async fn list_app_accounts(
        &self,
        Parameters(identities::ListAccountsArgs { derivation_origin }): Parameters<identities::ListAccountsArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let session_id = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("listing your accounts needs an authenticated session".into())),
        };
        // `derivation_origin` is a required String here, so this always yields a
        // target (never the anonymous None) unless it fails validation.
        let target = match resolve_identity_target(Some(derivation_origin)) {
            Ok(Some(t)) => t,
            Ok(None) => return Ok(err("`derivation_origin` must not be empty".into())),
            Err(e) => return Ok(err(e)),
        };
        match self.identities.list_accounts(&session_id, &target.origin).await {
            Ok(accounts) => {
                let text = format_accounts(&target, &accounts);
                let output = identities::AccountsOutput {
                    derived_for_origin: target.origin,
                    requested: target.requested,
                    derivation_origin_source: target.source,
                    accounts: accounts.iter().map(identities::AccountEntry::from).collect(),
                };
                Ok(ok_structured(text, &output))
            }
            Err(e) => Ok(err(e)),
        }
    }

    #[tool(
        description = "ROUTING. Run this chain before answering anything that names, or implies, a specific app or the user's own data in one app (a balance, holdings, bookings, positions, profile, or \"what can it do\"): (1) Is the request about a specific APP's functionality or the user's data in it, rather than the IC protocol itself? (2) Is that app on the Internet Computer? If unsure, THIS tool (or icp_find_app_by_name) is how you find out: a known app or an IC-verifiable URL resolves, while an unrecognized app name, or a URL with no Internet Computer evidence, is refused rather than guessed. (3) If so, START HERE: one open_app call resolves the app AND discovers its service. An app feature is almost never a single named tool (there is no \"get_balance\"); you COMPOSE it from what open_app returns, reading the discovered canisters and their `oql`/`api_doc_available` flags to see how to READ (canister_query, including OQL) or ACT (canister_update_call). So never conclude \"there's no tool for that\" from the flat tool list without opening the app first. Open an Internet Computer app in ONE call, given its NAME or its URL — the recommended entry point when a user names an app. It resolves the app's Internet Identity derivation origin (like resolve_app) AND discovers the canisters behind it (like discover_app_canisters) together, so you don't chain those yourself. Pass a NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a URL (e.g. \"https://oisy.com\"): a name or bare host is matched to the built-in known-app registry FIRST (so even a wrong-TLD guess like \"multidex.com\" repairs to the canonical URL), and an explicit https:// URL is resolved as given. NEVER fabricate a domain from a name — an unknown bare name is refused with instructions to find the real URL (web search / ask the user), and a URL with no Internet Computer evidence is refused, both instead of guessing a wrong identity. Returns `app_url` (the one used), `derivation_origin` (+ its source) to act with, `alternative_origins`, and the discovered `canisters` (with provenance/labels AND per-canister `oql`/`api_doc_available` capability flags, from a one-shot Candid probe of the app's own canisters). A canister flagged `oql` holds the app's data, GATED BY THE CALLER's principal: to read the USER's own data (\"my …\", \"our …\") pass the returned `derivation_origin` to get_canister_oql_schema (for the entity/field names) and to canister_query (with the `oql` argument, to run the query). Those OQL reads REQUIRE `derivation_origin` — not `app_url` — and reject an anonymous read for now. No authenticated session required for open_app itself (no principal is derived here). Narrower tools remain for single steps: icp_find_app_by_name (name→URL only), resolve_app (origin only), discover_app_canisters (canisters only).",
        annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<discover::OpenAppOutput>(),
    )]
    async fn open_app(
        &self,
        Parameters(discover::OpenAppArgs { app }): Parameters<discover::OpenAppArgs>,
    ) -> Result<CallToolResult, McpError> {
        // Clean the query like every other identity input: trim, reject empty, and
        // reject control chars — otherwise they'd be echoed into the UnknownName
        // error via find_app_by_name's note (the rest of the surface avoids this).
        let query = match clean_identity_arg("app", &app) {
            Ok(q) => q,
            Err(e) => return Ok(err(e)),
        };
        // Disambiguate name vs URL, repairing a known app's name/wrong-TLD to its
        // canonical URL and refusing a bare unknown name rather than guessing.
        let (app_url, app_url_source) = match discover::classify_app_query(&query) {
            discover::AppQuery::Known(m) => (m.app_url, "known_app_registry"),
            discover::AppQuery::Url(u) => (u, "as_provided"),
            discover::AppQuery::UnknownName => {
                // Echo find_app_by_name's web-search/ask guidance (it builds the
                // "not a known app; don't guess a domain" note from the registry).
                return Ok(err(discover::find_app_by_name(&query).note));
            }
        };
        let app_url = match clean_app_url(&app_url) {
            Ok(u) => u,
            Err(e) => return Ok(err(e)),
        };
        // Resolve (the gating step) and discover (canisters) run CONCURRENTLY so the
        // happy path doesn't pay for discovery sequentially. Discovery is spawned so it
        // can be ABORTED the moment resolution fails or the IC-evidence gate refuses:
        // that makes a refused/guessed origin return PROMPTLY (no waiting out
        // discovery's timeout) and cancels the REST of the crawl. It does not prevent
        // discovery from starting — because it runs concurrently with the gate, a few
        // requests may already be in flight before abort() — but those are bounded by
        // discover's own size/count caps, and the common (accepted) case keeps the
        // full concurrency win. Gating before starting discovery would eliminate that
        // residual I/O only by serializing the two, which the happy path pays for.
        let discovery = tokio::spawn({
            let url = app_url.clone();
            async move { discover::discover(&url).await }
        });
        let resolved = match discover::resolve_app_identity(&app_url, true).await {
            Ok(r) => r,
            Err(e) => {
                discovery.abort();
                return Ok(err(app_url_error_with_guidance(&app_url, e)));
            }
        };
        // The same guessed-domain gate as resolve_app / the identity routes: an
        // assumed origin with no IC evidence is refused, not resolved.
        if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault
            && resolved.application_is_ic == Some(false)
        {
            discovery.abort();
            return Ok(err(unverified_app_url_error(&resolved.application_origin)));
        }
        let effective = identities::target_origin(&resolved.derivation_origin);
        let note = resolution_note(&resolved, &effective);
        // Origin resolution succeeded and passed the IC gate, so the derivation
        // context stands regardless of discovery. Collect the concurrent discovery
        // now, keeping a FAILURE distinct from "found nothing" (a JoinError — i.e. a
        // panic — is treated as a discovery failure, never a hard error).
        let (canisters, omitted, discovery_error) = match discovery.await {
            Ok(Ok(d)) => (d.canisters, d.omitted, None),
            Ok(Err(e)) => (Vec::new(), 0, Some(e)),
            Err(join_err) => (Vec::new(), 0, Some(format!("discovery task error: {join_err}"))),
        };
        // Enrich the app's OWN data canisters with OQL / api-doc capability flags
        // (#3), so open_app hands back a ready-to-use handle: which canister holds
        // the (caller-gated) data, and the origin to read it as the user.
        let mut discovered: Vec<discover::DiscoveredCanister> =
            canisters.iter().map(discover::DiscoveredCanister::from).collect();
        self.enrich_capabilities(&mut discovered).await;
        // The ready-to-use data-access handle: the resolved origin, scoped (by
        // is_app_data_candidate) to the app's own OQL canisters — never a guessed
        // origin, never II/NNS/ledger/frontend. `derivation_origin` only: the
        // data-access note names the OQL read tools (get_canister_oql_schema,
        // canister_query), which take `derivation_origin`, not a website URL, so
        // offering app_url here would send the agent to a param those tools don't accept.
        let handle = format!("derivation_origin=\"{effective}\"");

        let mut text = format!(
            "app_url: {app_url} ({app_url_source})\nderivation_origin: {effective} ({})\n",
            resolved.derivation_origin_source.as_str()
        );
        if !resolved.alternative_origins.is_empty() {
            text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", ")));
        }
        if let Some(e) = &discovery_error {
            text.push_str(&format!(
                "\nCanister discovery FAILED ({e}) — this is NOT \"the app has no canisters\"; the \
                 derivation origin above is still valid. Retry discover_app_canisters, or proceed \
                 with a canister id you already have.\n"
            ));
        } else if discovered.is_empty() {
            text.push_str("\nNo canisters discovered — the app appears to declare none.\n");
        } else {
            text.push_str("\nCanisters:\n");
            for c in &discovered {
                text.push_str(&render_canister_line(c));
            }
            if omitted > 0 {
                text.push_str(&format!(
                    "(+{omitted} more findings dropped by the output cap; the list is \
                     authority-ordered, so the least authoritative entries were cut first)\n"
                ));
            }
        }
        if let Some(n) = &note {
            text.push_str(&format!("\nNOTE: {n}"));
        }
        if let Some(access) = data_access_note(&discovered, Some(&handle)) {
            text.push_str(&format!("\n\n{access}"));
        }
        text.push_str(
            "\n\nNext: inspect a canister with get_canister_candid — its oql / api_doc_available \
             flags say whether to read via OQL and whether get_canister_api_doc has a doc \
             (only call it when api_doc_available). To act as the user, pass the derivation_origin \
             above to canister_query (read) and canister_update_call (write); for an OQL canister, \
             call get_canister_oql_schema for the entity/field names, then canister_query with \
             the `oql` argument — plus an optional account from list_app_accounts. A \"my/our…\" \
             question is an AUTHENTICATED read: pass the origin.",
        );
        let output = discover::OpenAppOutput {
            app_url,
            app_url_source: app_url_source.to_string(),
            application_origin: resolved.application_origin,
            derivation_origin: effective,
            derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(),
            alternative_origins: resolved.alternative_origins,
            application_is_ic: resolved.application_is_ic,
            canisters: discovered,
            omitted,
            discovery_error,
            note,
        };
        Ok(ok_structured(text, &output))
    }

    #[tool(
        description = "Resolve an application URL to its Internet Identity derivation context, so you don't have to figure out the derivation origin yourself. `app_url` must be a URL you actually HAVE — from the user, from icp_find_app_by_name, or from a web search of the app's official site. NEVER guess or fabricate a domain from an app's name (when you only know a NAME, call icp_find_app_by_name first): a lookalike domain is an unrelated or squatted site, and this tool REFUSES to resolve an origin that shows no evidence of being an Internet Computer app rather than hand back a wrong identity. Returns the `application_origin`, the `derivation_origin` to pass to the identity tools, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of well-known custom-derivation-origin apps (e.g. NNS, Oisy, MULTI/DEX), used only when the app declares none; or \"app_url_default\" — the origin IS IC-served but declares nothing, so it was assumed to be its own derivation origin, correct only if the app has no custom one), and the app's `alternative_origins` (informational — the INVERSE relation, never use it to infer the derivation origin). This does NOT return a principal — it resolves the origin only, since you haven't picked an account; to get the principal you act as, pass the returned `derivation_origin` to get_app_principal (choosing an `account`) or list_app_accounts. Use this first when you only know an app's URL; no authenticated session is required.",
        annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<identities::ResolveAppOutput>(),
    )]
    async fn resolve_app(
        &self,
        Parameters(identities::ResolveAppArgs { app_url }): Parameters<identities::ResolveAppArgs>,
    ) -> Result<CallToolResult, McpError> {
        let app_url = match clean_app_url(&app_url) {
            Ok(u) => u,
            Err(e) => return Ok(err(e)),
        };
        // This tool surfaces `alternative_origins`, so fetch them (unlike the
        // identity hot path in resolve_identity_target). It resolves the derivation
        // origin ONLY — no principal is derived here, because the caller hasn't
        // chosen an account; that's get_app_principal / list_app_accounts' job. So
        // this needs no authenticated session.
        let resolved = match discover::resolve_app_identity(&app_url, true).await {
            Ok(r) => r,
            Err(e) => return Ok(err(app_url_error_with_guidance(&app_url, e))),
        };
        // A guessed/lookalike domain fails HARD instead of resolving to a
        // plausible-but-wrong identity: no declared origin, not a known app, and
        // no sign the origin is even served from the IC. The error carries the
        // "did you mean" repair and the legitimate ways to obtain the URL.
        if resolved.derivation_origin_source == discover::DerivationSource::AppUrlDefault
            && resolved.application_is_ic == Some(false)
        {
            return Ok(err(unverified_app_url_error(&resolved.application_origin)));
        }
        let effective = identities::target_origin(&resolved.derivation_origin);
        let note = resolution_note(&resolved, &effective);
        let mut text = format!(
            "application_origin: {}\nderivation_origin: {} ({})\n",
            resolved.application_origin, effective, resolved.derivation_origin_source.as_str()
        );
        if !resolved.alternative_origins.is_empty() {
            text.push_str(&format!("alternative_origins: {}\n", resolved.alternative_origins.join(", ")));
        }
        if let Some(n) = &note {
            text.push_str(&format!("\nNOTE: {n}"));
        }
        let output = identities::ResolveAppOutput {
            application_origin: resolved.application_origin,
            derivation_origin: effective,
            derivation_origin_source: resolved.derivation_origin_source.as_str().to_string(),
            alternative_origins: resolved.alternative_origins,
            application_is_ic: resolved.application_is_ic,
            note,
        };
        Ok(ok_structured(text, &output))
    }

    #[tool(
        description = "Discover the Internet Computer canisters behind a web domain (e.g. \"oisy.com\"). The domain must be one you actually have (from the user, icp_find_app_by_name, or a web search) — NEVER a domain guessed from an app's name; when you only know a NAME, call icp_find_app_by_name first. Returns every canister id found, with provenance, most authoritative first: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's MAIN backend) and the app's own /.well-known/ic-app.json manifest (ALL its canisters, with roles) — then the `x-ic-canister-id` header (the frontend/asset canister), a `/env.json` runtime config (e.g. backend_canister_id), and labelled/bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json/bundle entries are mined candidates: pick by label (prefer production/IC ids) and confirm with get_canister_candid before calling.",
        annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<discover::DiscoverOutput>(),
    )]
    async fn discover_app_canisters(
        &self,
        Parameters(discover::DiscoverCanistersArgs { domain }): Parameters<discover::DiscoverCanistersArgs>,
    ) -> Result<CallToolResult, McpError> {
        match discover::discover(&domain).await {
            Ok(d) if !d.canisters.is_empty() => {
                // Probe the app's own data canisters for OQL / api-doc capabilities
                // (#3), mirroring open_app. discover_app_canisters resolves no origin,
                // so the data-access note points at resolve_app / open_app for it.
                let mut discovered: Vec<discover::DiscoveredCanister> =
                    d.canisters.iter().map(discover::DiscoveredCanister::from).collect();
                self.enrich_capabilities(&mut discovered).await;
                let mut out = format!("Canisters discovered for {domain}:\n");
                for c in &discovered {
                    out.push_str(&render_canister_line(c));
                }
                if d.omitted > 0 {
                    out.push_str(&format!(
                        "(+{} more findings dropped by the output cap; the list is \
                         authority-ordered, so the least authoritative entries were cut first)\n",
                        d.omitted
                    ));
                }
                out.push_str(
                    "\n`ai-connect.html` and `ic-app.json` entries are DECLARED by the app itself \
                     (its main backend, and its own canister manifest with roles) — treat them as \
                     the app's claim about its composition. The `header` (x-ic-canister-id) entry \
                     is the frontend/asset canister. Others come from env.json or the JS bundle \
                     and may include multiple environments (prefer the production/IC ids). A \
                     «name» (type) is the IC dashboard's label for that id. `[oql]`/`[api-doc]` \
                     flags are from a Candid probe of the app's own canisters. Confirm an interface \
                     with get_canister_candid before calling.",
                );
                if let Some(access) = data_access_note(&discovered, None) {
                    out.push_str(&format!("\n\n{access}"));
                }
                let output = discover::DiscoverOutput {
                    domain,
                    canisters: discovered,
                    omitted: d.omitted,
                };
                Ok(ok_structured(out, &output))
            }
            Ok(d) => {
                let mut text =
                    format!("No IC canisters found for {domain} — is it served from the Internet Computer?");
                if let Some(m) = discover::similar_known_app(&domain) {
                    text.push_str(&format!(
                        " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).",
                        m.name, m.app_url, m.derivation_origin
                    ));
                }
                text.push_str(
                    " If you GUESSED this domain from an app name, don't guess again: call \
                     icp_find_app_by_name with the name, or WEB SEARCH the app's official URL, \
                     or ask the user for it.",
                );
                let output = discover::DiscoverOutput::from((domain, d));
                Ok(ok_structured(text, &output))
            }
            // A fetch failure on a guessed domain (DNS-dead lookalikes like
            // "multi.dex") carries the same repair as the identity routes, so it
            // reads as "wrong URL — here's the real one", not a transient error.
            Err(e) => Ok(err(app_url_error_with_guidance(&domain, e))),
        }
    }

    #[tool(
        description = "Find Internet Computer canisters by NAME. Searches the IC dashboard's service registries — the ICRC token ledgers (e.g. ckBTC, ckETH, ckUSDC, SNS tokens) by symbol/name, and the SNS project catalog by name — and returns matching canister ids. Use this when the user names a token, project, or service (e.g. \"ckUSDC\") rather than a canister id; then confirm with get_canister_candid, read with canister_query, and write with canister_update_call. (No public name-search exists over arbitrary canisters; this covers the IC's labelled services.)",
        annotations(title = "Find canisters by name", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<discover::FindCanisterOutput>(),
    )]
    async fn icp_find_canister_by_name(
        &self,
        Parameters(discover::FindCanisterArgs { query }): Parameters<discover::FindCanisterArgs>,
    ) -> Result<CallToolResult, McpError> {
        match discover::search_by_name(&query).await {
            Ok(matches) if !matches.is_empty() => {
                let mut out = format!("Canisters matching \"{query}\":\n");
                for m in &matches {
                    out.push_str(&format!(
                        "- {} — {} [{}]{}\n",
                        m.canister_id,
                        m.name,
                        m.kind,
                        m.note.as_deref().map(|n| format!("  — {n}")).unwrap_or_default(),
                    ));
                }
                out.push_str(
                    "\nConfirm an interface with get_canister_candid, then read with canister_query and \
                     write with canister_update_call. \
                     For an SNS match the id is the project root — icp_lookup_canister_info_by_id it to learn more.",
                );
                let output = discover::FindCanisterOutput::from((query, matches));
                Ok(ok_structured(out, &output))
            }
            Ok(_) => {
                let text = format!(
                    "No named canisters found matching \"{query}\". This searches known tokens (ICRC \
                     ledgers) and SNS projects, so an arbitrary canister won't appear unless it's a \
                     labelled service. If you have a website, try discover_app_canisters; if you already \
                     have a canister id, try icp_lookup_canister_info_by_id or get_canister_candid."
                );
                let output = discover::FindCanisterOutput::from((query, Vec::new()));
                Ok(ok_structured(text, &output))
            }
            Err(e) => Ok(err(e)),
        }
    }

    #[tool(
        description = "Find a well-known Internet Computer APP by NAME and get its front-end URL and Internet Identity derivation origin — the missing name→app step (discovery otherwise needs a URL). ALWAYS call this FIRST when the user names an app and you don't have its URL — NEVER guess a domain from the name (lookalike domains like <name>.com/.app are unrelated or squatted sites, and the URL-taking tools refuse origins with no IC evidence). Offline and instant. Covers only a small built-in set (NNS, Oisy, MULTI/DEX, ICPSwap). For ANY other app there is NO on-chain name→URL directory, so the result's `note` directs you to WEB SEARCH the app's official URL (or ask the user) and then use resolve_app / discover_app_canisters. Returns `matches` (each with `app_url` + `derivation_origin`) — usually one, or none. This is name→app; for a token/service→canister-id use icp_find_canister_by_name, and for a URL→canisters use discover_app_canisters.",
        annotations(title = "Find a well-known app by name", read_only_hint = true, destructive_hint = false, open_world_hint = false),
        output_schema = schema_for_output::<discover::FindAppOutput>(),
    )]
    async fn icp_find_app_by_name(
        &self,
        Parameters(discover::FindAppArgs { name }): Parameters<discover::FindAppArgs>,
    ) -> Result<CallToolResult, McpError> {
        let output = discover::find_app_by_name(&name);
        let mut text = String::new();
        for m in &output.matches {
            text.push_str(&format!(
                "{} — {} (derivation_origin: {})\n",
                m.name, m.app_url, m.derivation_origin
            ));
        }
        text.push_str(&output.note);
        Ok(ok_structured(text, &output))
    }

    #[tool(
        description = "Identify what an Internet Computer canister IS, from the IC dashboard: its label/name (e.g. \"ICP Ledger\"), type (e.g. \"ledger\"), controllers, hosting subnet, module hash, language, and latest upgrade proposal. Use this to make sense of a bare canister id — e.g. one returned by discover_app_canisters.",
        annotations(title = "Identify a canister", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<discover::CanisterIdentityOutput>(),
    )]
    async fn icp_lookup_canister_info_by_id(
        &self,
        Parameters(discover::LookupCanisterArgs { canister_id }): Parameters<discover::LookupCanisterArgs>,
    ) -> Result<CallToolResult, McpError> {
        let client = match discover::http_client() {
            Ok(c) => c,
            Err(e) => return Ok(err(e)),
        };
        match discover::lookup_canister(&client, &canister_id).await {
            Ok(info) => {
                let text = format_canister_info(&info);
                let output = discover::CanisterIdentityOutput::from(info);
                Ok(ok_structured(text, &output))
            }
            Err(e) => Ok(err(e)),
        }
    }

    // ---- ICP skills awareness ----------------------------------------------

    #[tool(
        description = "List the official Internet Computer skills — authoritative how-to guides for authoring and shipping IC apps (Motoko language, mops/icp CLIs, cycles management, stable memory & upgrades, security, DeFi, auth, …). Returns each skill's name and a one-line description. Load a skill's full instructions with icp_get_skill(name). Consult these BEFORE writing Motoko/Rust canister code, building, or deploying.",
        annotations(title = "List Internet Computer skills", read_only_hint = true, destructive_hint = false, open_world_hint = false),
        output_schema = schema_for_output::<skills::SkillsOutput>(),
    )]
    async fn icp_list_skills(
        &self,
        Parameters(_args): Parameters<management::NoArgs>,
    ) -> Result<CallToolResult, McpError> {
        match self.skills.list().await {
            Ok(s) => {
                let text = skills::SkillsCatalog::format_list(&s);
                let output = skills::SkillsOutput::from(s);
                Ok(ok_structured(text, &output))
            }
            Err(e) => Ok(err(e)),
        }
    }

    #[tool(
        description = "Fetch the full instructions (SKILL.md) of one Internet Computer skill by name (e.g. \"writing-motoko\", \"icp-cli\", \"mops-cli\", \"cycles-management\", \"stable-memory\", \"canister-security\"). Call icp_list_skills first to see the available names. Use this to learn the exact, current way to do an IC task before doing it.",
        annotations(title = "Get an Internet Computer skill", read_only_hint = true, destructive_hint = false, open_world_hint = false),
        output_schema = schema_for_output::<skills::SkillOutput>(),
    )]
    async fn icp_get_skill(
        &self,
        Parameters(skills::GetSkillArgs { name }): Parameters<skills::GetSkillArgs>,
    ) -> Result<CallToolResult, McpError> {
        match self.skills.get(&name).await {
            Ok(md) => {
                let output = skills::SkillOutput { name, content: md };
                Ok(ok_structured(output.content.clone(), &output))
            }
            Err(e) => Ok(err(e)),
        }
    }

    // ---- Canister creation & management (as your standing II principal) -----

    #[tool(
        description = "Your cycles-ledger balance — the cycles that icp_create_canister and icp_top_up_canister spend. Acts as your Internet Identity principal (also printed). If it's empty, fund it first (e.g. via the icp CLI / cycles-management skill). Requires an authenticated session.",
        annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CyclesBalance>(),
    )]
    async fn icp_cycles_balance(
        &self,
        Parameters(_args): Parameters<management::NoArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("checking your cycles balance needs an authenticated session".into())),
        };
        match management::cycles_balance(&self.identities, &sid).await {
            Ok(b) => Ok(ok_structured(b.human(), &b)),
            Err(e) => Ok(err(e)),
        }
    }

    #[tool(
        description = "Create and fund a NEW Internet Computer canister (as your Internet Identity). SPENDS FUNDS: this draws cycles or ICP from your accounts and cannot be automatically reversed, so confirm with the user before calling it. Fund it EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string like \"0.5\", transferred from your ICP-ledger account and converted to cycles via the CMC). BOTH accounts belong to your management principal — the same principal icp_cycles_balance reports (its default subaccount); check/fund it before calling (cycles-ledger balance via icp_cycles_balance, or hold ICP in that principal's ICP-ledger account). The ICP path is best-effort with no retries: if the ICP transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Controllers default to your own principal. Returns the new canister id — then build your Wasm (see the writing-motoko/icp-cli skills) and install it with icp_install_code. Requires an authenticated session.",
        // destructive_hint = true: it adds a canister, but it does so by spending the
        // user's cycles or ICP irreversibly, and it is not idempotent (a retry spends
        // again). See the annotation test for why "additive" is the wrong read here.
        annotations(title = "Create a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CreatedCanister>(),
    )]
    async fn icp_create_canister(
        &self,
        Parameters(args): Parameters<management::CreateCanisterArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("creating a canister needs an authenticated session".into())),
        };
        match management::create_canister(&self.identities, &sid, args).await {
            Ok(c) => Ok(ok_structured(c.human(), &c)),
            Err(e) => Ok(err(e)),
        }
    }

    #[tool(
        description = "Add cycles to an existing canister (as your Internet Identity). SPENDS FUNDS: this draws cycles or ICP from your accounts and cannot be automatically reversed, so confirm with the user before calling it. Fund EITHER with `cycles` (exact, drawn from your cycles-ledger balance) OR with `icp` (a decimal-ICP string, transferred from your ICP-ledger account and converted via the CMC straight into the target canister). Both accounts belong to your management principal — the one icp_cycles_balance reports (default subaccount). The ICP path is best-effort with no retries: if the transfer lands but the mint fails, the error carries the block index to recover with — do not blindly re-run. `cycles` wins if both are given. Requires an authenticated session.",
        // destructive_hint = true: same reasoning as icp_create_canister — the cycles
        // land in the target canister, but the funds leave the user's accounts for
        // good, and a retry spends again.
        annotations(title = "Top up a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_top_up_canister(
        &self,
        Parameters(args): Parameters<management::TopUpArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("topping up a canister needs an authenticated session".into())),
        };
        let canister_id = args.canister_id.clone();
        Ok(ok_canister_action(
            canister_id,
            management::top_up_canister(&self.identities, &sid, args).await,
        ))
    }

    #[tool(
        description = "Install a compiled Wasm module on a canister you control (as your Internet Identity). Provide the module as `wasm_base64` (or `wasm_hex`); large modules are uploaded via the chunk store automatically. `mode` is \"install\" (default, empty canister), \"reinstall\" (wipe state), or \"upgrade\" (preserve stable memory). `arg` is the init/upgrade argument in textual Candid, e.g. \"()\". Build the Wasm in your own environment first (see the writing-motoko / icp-cli / mops-cli skills). Requires an authenticated session.",
        annotations(title = "Install code on a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_install_code(
        &self,
        Parameters(args): Parameters<management::InstallCodeArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("installing code needs an authenticated session".into())),
        };
        let canister_id = args.canister_id.clone();
        Ok(ok_canister_action(
            canister_id,
            management::install_code(&self.identities, &sid, args).await,
        ))
    }

    #[tool(
        description = "Report a canister's status: run state, cycle balance, module hash, memory size, controllers, and allocations. Controller-only (acts as your Internet Identity). This only READS status (it changes nothing), but on the IC it is an update call, so it needs an Internet Identity session authorized for \"Actions & questions\" rather than \"Questions only\". Requires an authenticated session.",
        annotations(title = "Get canister status", read_only_hint = true, destructive_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_canister_status(
        &self,
        Parameters(args): Parameters<management::CanisterRefArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("reading canister status needs an authenticated session".into())),
        };
        let canister_id = args.canister_id.clone();
        Ok(ok_canister_action(
            canister_id,
            management::canister_status(&self.identities, &sid, args).await,
        ))
    }

    #[tool(
        description = "Update a canister's settings: controllers, compute/memory allocation, freezing threshold, reserved-cycles limit, wasm memory limit, or log visibility (\"controllers\"|\"public\"). Only the fields you pass are changed. WARNING: passing `controllers` REPLACES the whole set — include your own principal to remain a controller. Requires an authenticated session.",
        annotations(title = "Update canister settings", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_update_canister_settings(
        &self,
        Parameters(args): Parameters<management::UpdateSettingsArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("updating settings needs an authenticated session".into())),
        };
        let canister_id = args.canister_id.clone();
        Ok(ok_canister_action(
            canister_id,
            management::update_canister_settings(&self.identities, &sid, args).await,
        ))
    }

    #[tool(
        description = "Start a stopped canister you control. Requires an authenticated session.",
        annotations(title = "Start a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_start_canister(
        &self,
        Parameters(management::CanisterRefArgs { canister_id }): Parameters<management::CanisterRefArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("starting a canister needs an authenticated session".into())),
        };
        let result = management::start_canister(&self.identities, &sid, &canister_id).await;
        Ok(ok_canister_action(canister_id, result))
    }

    #[tool(
        description = "Stop a running canister you control (required before deleting it). Requires an authenticated session.",
        annotations(title = "Stop a canister", read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_stop_canister(
        &self,
        Parameters(management::CanisterRefArgs { canister_id }): Parameters<management::CanisterRefArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("stopping a canister needs an authenticated session".into())),
        };
        let result = management::stop_canister(&self.identities, &sid, &canister_id).await;
        Ok(ok_canister_action(canister_id, result))
    }

    #[tool(
        description = "Remove a canister's code and state, leaving it empty (it keeps its id and cycles). Acts as your Internet Identity. Requires an authenticated session.",
        annotations(title = "Uninstall code from a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_uninstall_code(
        &self,
        Parameters(management::CanisterRefArgs { canister_id }): Parameters<management::CanisterRefArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("uninstalling code needs an authenticated session".into())),
        };
        let result = management::uninstall_code(&self.identities, &sid, &canister_id).await;
        Ok(ok_canister_action(canister_id, result))
    }

    #[tool(
        description = "Delete a canister permanently (irreversible — stop it first; remaining cycles are burned). Acts as your Internet Identity. Requires an authenticated session.",
        annotations(title = "Delete a canister", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
        output_schema = schema_for_output::<management::CanisterActionOutput>(),
    )]
    async fn icp_delete_canister(
        &self,
        Parameters(management::CanisterRefArgs { canister_id }): Parameters<management::CanisterRefArgs>,
        ctx: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let sid = match authed_session(&ctx) {
            Some(s) => s.session_id,
            None => return Ok(err("deleting a canister needs an authenticated session".into())),
        };
        let result = management::delete_canister(&self.identities, &sid, &canister_id).await;
        Ok(ok_canister_action(canister_id, result))
    }
}

/// The authenticated MCP session of the calling request, if it carried a valid
/// bearer token (injected by [`auth::require_token`]).
fn authed_session(ctx: &RequestContext<RoleServer>) -> Option<auth::AuthedSession> {
    ctx.extensions
        .get::<axum::http::request::Parts>()
        .and_then(|parts| parts.extensions.get::<auth::AuthedSession>())
        .cloned()
}

/// The rejection for an OQL read (`get_canister_oql_schema`, or `canister_query`'s
/// `oql` path) attempted with NO derivation origin. OQL reads per-app data gated by
/// the caller's principal, so an anonymous read is signed as 2vxsx-fae and returns
/// nothing useful — so, for the time being, we reject it outright rather than let it
/// silently come back empty. `what` names the action. Points at open_app /
/// resolve_app so the agent recovers the origin in one step.
fn oql_needs_origin_error(what: &str) -> String {
    format!(
        "{what} reads per-app data that this canister gates by the CALLER's principal, so it \
         cannot be done anonymously — pass `derivation_origin`, the app's canonical Internet \
         Identity origin, to read as your account. If you don't have it, resolve it from the \
         app's URL or name with open_app / resolve_app, then retry. (Anonymous per-app reads are \
         disabled for now; public metadata such as get_canister_candid still works without an origin.)"
    )
}

/// The diagnosis attached to an EMPTY / trapped OQL query result (#1 + #7).
struct OqlEmptyDiagnosis {
    /// The human-readable explanation of the empty result, if one applies.
    note: Option<String>,
    /// The entities visible to this caller, when an unknown-`start` diagnosis fired.
    valid_entities: Option<Vec<String>>,
    /// The nearest valid entity to an unknown `start` (`booking` → `bookings`).
    did_you_mean: Option<String>,
}

impl OqlEmptyDiagnosis {
    fn none() -> Self {
        Self { note: None, valid_entities: None, did_you_mean: None }
    }
}

/// Why `diagnose_empty_oql` was called — a genuinely EMPTY result (0-row table)
/// vs. a FAILED one (trap / OQL error arm). The `start`-entity repair applies to
/// both, but the "came back empty ⇒ likely not authenticated" / "no entities here"
/// notes describe an EMPTY result and would misdescribe a failure that already
/// carries its own error message, so they are emitted for `EmptyResult` only.
#[derive(Clone, Copy, PartialEq)]
enum EmptyContext {
    EmptyResult,
    Failed,
}

/// Diagnose why an OQL read came back EMPTY (0 rows) or FAILED (trap / error arm),
/// using ONLY facts gathered from the SAME agent/principal that ran the query —
/// never probing whether some OTHER principal would see data (no leak). This is
/// validate-on-empty, not an unconditional preflight: it runs a single extra
/// `schema` read for this principal and hard-validates the query's `start` entity
/// (only `start` — field and dotted-edge paths are treated advisorily by the caller).
///
///  - `start` isn't one of the visible entities → the #7 unknown-entity repair
///    (valid entities + a "did you mean?" near match). Emitted in BOTH contexts —
///    an unknown `start` is the common reason a query traps.
///  - The notes below describe an EMPTY RESULT and are emitted for `EmptyResult`
///    only (a `Failed` result keeps its own error message; "came back empty" would
///    misdescribe it):
///     - Anonymous + empty → the #1 auth remediation (empty is almost certainly
///       "not authenticated as your account", not "no data").
///     - Authenticated + the schema read SUCCEEDED and shows no entities → this
///       account sees no entities here. (A schema read that FAILED is left
///       undiagnosed — the caller falls back to a benign "0 rows" note — rather
///       than mislabeled "no entities".)
///     - `start` valid but 0 rows → authenticated gets no note here (the caller
///       adds the benign "0 rows"); anonymous still gets the auth hint.
async fn diagnose_empty_oql(
    agent: &Agent,
    principal: Principal,
    query_json: &str,
    is_anonymous: bool,
    ctx: EmptyContext,
) -> OqlEmptyDiagnosis {
    // The derivation_origin the caller would ADD to authenticate — a placeholder we
    // never fill with a guessed origin (#1).
    const ADD_HINT: &str = "the app's `derivation_origin` (its canonical Internet Identity origin)";
    // Re-read the schema for THIS principal (same agent). `None` = the read FAILED
    // (unknown, not "empty"); `Some(vec)` = it succeeded (possibly with no entities).
    // Keeping the two apart stops a transient schema failure from being mislabeled
    // "this account sees no entities".
    let entities: Option<Vec<String>> = match calls::encode_unit_arg() {
        Ok(arg) => match calls::raw_call(agent, principal, "schema", arg, true).await {
            Ok(reply) => Some(calls::oql_entity_names(&calls::decode_schema_reply(&reply))),
            Err(_) => None,
        },
        Err(_) => None,
    };
    let start = calls::oql_query_start(query_json);
    let mut d = OqlEmptyDiagnosis::none();

    // #7 unknown-`start` repair — needs a successful, non-empty schema. Applies in
    // BOTH contexts (an unknown entity is exactly what makes a query trap).
    if let Some(ents) = entities.as_ref().filter(|e| !e.is_empty()) {
        if let Some(s) = start.as_deref().filter(|s| !ents.iter().any(|e| e == s)) {
            d.did_you_mean = calls::closest_entity(s, ents);
            let dym = d
                .did_you_mean
                .as_deref()
                .map(|m| format!(" Did you mean \"{m}\"?"))
                .unwrap_or_default();
            d.note = Some(format!(
                "`start`: \"{s}\" is not a queryable entity on this canister. Valid entities \
                 (visible to this caller): {}.{dym}",
                ents.join(", ")
            ));
            d.valid_entities = Some(ents.clone());
            return d;
        }
    }

    // The remaining notes describe an EMPTY result — skip them on a failure, whose
    // own error message stands, and appending "came back empty" would contradict it.
    if ctx == EmptyContext::Failed {
        return d;
    }

    if is_anonymous {
        // Anonymous + empty (schema empty, unreadable, or `start` valid) → auth hint.
        d.note = Some(calls::anonymous_empty_note("this query", ADD_HINT));
    } else if matches!(entities.as_deref(), Some([])) {
        // Authenticated AND the schema read SUCCEEDED with no entities — a genuine
        // "nothing visible here" (not a failed re-read mislabeled as empty).
        d.note = Some(
            "This account sees no OQL entities on this canister, so there is nothing to query as \
             it — confirm the derivation_origin/account are the ones the user uses in their browser."
                .to_string(),
        );
    }
    // Otherwise (authenticated: `start` valid, OR the schema read failed): no
    // actionable diagnosis — leave `note` None so the caller adds the benign
    // "0 rows for this account" on an empty table.
    d
}

/// Which app principal an identity-bearing tool should act as, resolved from the
/// caller's `derivation_origin`.
#[derive(Debug)]
struct IdentityTarget {
    /// The EFFECTIVE (canonical) Internet Identity derivation origin to feed the
    /// delegation layer — echoed to the caller as `derived_for_origin`.
    origin: String,
    /// Exactly what the caller supplied as `derivation_origin`.
    requested: String,
    /// How `origin` was determined. Always "explicit" here: the identity-bearing
    /// tools take the canonical derivation origin DIRECTLY (they no longer accept an
    /// `app_url` to resolve). The "declared"/"known"/"app_url_default" sources come
    /// from the resolver tools (`open_app` / `resolve_app`), where a URL is turned
    /// into a derivation origin under the guessed-domain gate; feed their result
    /// here. Kept for parity with those tools' echoed `derivation_origin_source`.
    source: String,
}

/// Resolve the caller's `derivation_origin` into an [`IdentityTarget`] (or `None`
/// when it's absent — an anonymous call). The origin is canonicalized and used
/// VERBATIM (source `explicit`): these tools trust the caller to supply the exact
/// canonical origin Internet Identity derives against. There is deliberately no
/// `app_url` here — a derivation origin is a stable per-app value, so it's resolved
/// ONCE via `open_app` / `resolve_app` (which run the guessed-domain gate and return
/// it) and then reused across calls, rather than re-resolved on every invocation
/// against this stateless server.
fn resolve_identity_target(
    derivation_origin: Option<String>,
) -> Result<Option<IdentityTarget>, String> {
    match derivation_origin {
        None => Ok(None),
        Some(d) => {
            let d = clean_identity_arg("derivation_origin", &d)?;
            let origin = canonicalize_derivation_origin(&d)?;
            Ok(Some(IdentityTarget {
                origin,
                requested: d,
                source: "explicit".to_string(),
            }))
        }
    }
}

/// Append guess-repair guidance to an `app_url` resolution failure (DNS, TLS,
/// timeout, SSRF refusal): a fetch error on a domain FABRICATED from an app name
/// (e.g. "multi.dex") must redirect the caller to the name→URL tools — the same
/// way the no-IC-evidence refusal does — rather than read as a transient error
/// inviting the next guess.
fn app_url_error_with_guidance(app_url: &str, e: String) -> String {
    let mut msg = e;
    if let Some(m) = discover::similar_known_app(app_url) {
        msg.push_str(&format!(
            " DID YOU MEAN {}? Its real URL is {} (derivation origin {}).",
            m.name, m.app_url, m.derivation_origin
        ));
    }
    msg.push_str(
        " If you arrived at this URL by guessing from an app name, don't guess again — call \
         icp_find_app_by_name with the name, WEB SEARCH the app's official URL, or ask the user.",
    );
    msg
}

/// The refusal text for an `app_url` that resolved to `app_url_default` with NO
/// evidence of being served from the Internet Computer — almost always a domain
/// GUESSED from an app name (e.g. "multidex.com" for MULTI/DEX, whose real URL is
/// multidex.ai). Rather than resolving the guess to a plausible-but-wrong identity,
/// tools return this error, which (a) names the failure, (b) suggests the
/// well-known app the host resembles when there is one ("did you mean …"), and
/// (c) spells out the legitimate ways to obtain the URL — so the caller converges
/// on the real app instead of guessing again.
fn unverified_app_url_error(application_origin: &str) -> String {
    let mut msg = format!(
        "{application_origin} is reachable but shows NO evidence of being an Internet Computer \
         app — no valid `x-ic-canister-id` gateway header (the IC HTTP gateway sets one on every \
         response) — and its /.well-known/ic-app.json couldn't be fetched or declares no Internet \
         Identity derivation origin. Refusing to treat it as an app. "
    );
    if let Some(m) = discover::similar_known_app(application_origin) {
        msg.push_str(&format!(
            "DID YOU MEAN {}? Its real URL is {} (derivation origin {}) — use that instead. ",
            m.name, m.app_url, m.derivation_origin
        ));
    }
    msg.push_str(
        "If you arrived at this URL by GUESSING from an app name, stop guessing — a lookalike \
         domain is an unrelated or squatted site, and an identity derived there is wrong: \
         (1) call icp_find_app_by_name with the app's name (well-known apps resolve offline); \
         (2) if it's not known, WEB SEARCH the app's official URL; (3) or ask the user for it. \
         If this origin genuinely IS the app's canonical Internet Identity origin (e.g. a \
         non-IC-hosted app the user pointed you at), pass it explicitly as `derivation_origin` \
         to proceed deliberately.",
    );
    msg
}

/// The human `note` explaining how a resolved derivation origin was determined,
/// shared by `resolve_app` and `open_app` so the two stay in lock-step. `None` for
/// a Declared origin (self-evident); a Known origin explains the built-in registry;
/// an AppUrlDefault origin flags the assumption and, when the host resembles a
/// well-known app, appends a lookalike CAUTION (an IC-hosted squat can pass the
/// evidence gate yet still not be the app it mimics). `effective` is the
/// canonicalized derivation origin (`target_origin`).
fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option<String> {
    let lookalike = discover::similar_known_app(&resolved.application_origin).map(|m| {
        format!(
            " CAUTION: this host RESEMBLES the well-known app {} but is not one of its real \
             origins — {}'s URL is {} (derivation origin {}). If you meant {}, use that URL; \
             if you guessed this URL from the app's name, use icp_find_app_by_name instead of \
             guessing.",
            m.name, m.name, m.app_url, m.derivation_origin, m.name
        )
    });
    match resolved.derivation_origin_source {
        discover::DerivationSource::Declared => None,
        discover::DerivationSource::Known => Some(format!(
            "This app didn't declare a derivation origin in /.well-known/ic-app.json, but it's \
             a known app that pins a custom one, so this used the built-in value {effective}. \
             The app's own declaration, if it ships one, would override this."
        )),
        discover::DerivationSource::AppUrlDefault => Some(format!(
            "This origin showed evidence of being served from the Internet Computer (its \
             responses carry the gateway's `x-ic-canister-id` header), but its \
             /.well-known/ic-app.json couldn't be fetched or declares no `derivation_origin`, \
             and it isn't in the built-in known-app registry — so this ASSUMED the application \
             origin, canonicalized to {effective} (what II derives against). That is correct \
             for apps without a custom derivation origin; if this app pins a custom one, the \
             assumption yields a WRONG principal — supply the canonical origin explicitly.{}",
            lookalike.as_deref().unwrap_or_default()
        )),
    }
}

/// Validate a user-supplied identity argument (`derivation_origin` / `app_url`):
/// trim surrounding whitespace, reject an empty/whitespace-only value, and reject
/// ASCII/Unicode control characters. Both fields are echoed back verbatim (as
/// `requested` / `derived_for_origin`) and `derivation_origin` feeds the delegation
/// origin, so a stray control char could corrupt a log line or the origin string —
/// fail closed rather than pass it through.
fn clean_identity_arg(field: &str, raw: &str) -> Result<String, String> {
    let v = raw.trim();
    if v.is_empty() {
        return Err(format!("`{field}` must not be empty"));
    }
    if v.chars().any(char::is_control) {
        return Err(format!("`{field}` must not contain control characters"));
    }
    Ok(v.to_string())
}

/// Canonicalize a cleaned `derivation_origin` to the effective origin the delegation
/// path derives against (`target_origin`), rejecting one that carries no host (e.g.
/// "https://" → `https://`) — which would otherwise derive against, and echo back,
/// an empty origin.
fn canonicalize_derivation_origin(cleaned: &str) -> Result<String, String> {
    let invalid = || {
        "`derivation_origin` must be an https origin or a bare host, with no user-info \
         (e.g. https://app.example.com or app.example.com)"
            .to_string()
    };
    // Reject any explicit scheme other than https (a bare host with no scheme is
    // fine — target_origin prepends https). Without this, target_origin would
    // rewrite a non-https scheme into a different origin than requested — `ftp://x`
    // mangled into a bogus `https://…`, or `http://x` silently upgraded to
    // `https://x` — contradicting the https-only contract and confusing debugging.
    // A bare host with no scheme is fine — target_origin prepends https. An explicit
    // scheme must be https, and we lowercase it before handing off: target_origin strips
    // only a lowercase `https://`, so an uppercase `HTTPS://` would otherwise survive
    // into the host and mangle the origin (e.g. `https://HTTPS:`), turning a valid input
    // into a rejection or a bogus origin. Rejecting a non-https scheme here also stops
    // target_origin from silently rewriting it (`ftp://x` → bogus `https://…`, `http://x`
    // → `https://x`), contradicting the https-only contract.
    let normalized = match cleaned.split_once("://") {
        Some((scheme, rest)) => {
            if !scheme.eq_ignore_ascii_case("https") {
                return Err(invalid());
            }
            format!("https://{rest}")
        }
        None => cleaned.to_string(),
    };
    let origin = identities::target_origin(&normalized);
    // Require the canonical origin to parse as an https URL with a real host and NO
    // user-info. `target_origin` keeps any `user@` prefix, but a browser origin never
    // has one, so `https://user@host` would derive a different principal than
    // `https://host` while `requested == derived_for_origin` hides the mismatch. This
    // also rejects a host-less input ("https://") and embedded spaces/invalid chars.
    let parsed = url::Url::parse(&origin).ok().filter(|u| {
        u.scheme() == "https"
            && u.host_str().map_or(false, |h| !h.is_empty())
            && u.username().is_empty()
            && u.password().is_none()
    });
    match parsed {
        // Reserialize to a canonical ASCII origin (lowercased host, no path/user-info),
        // exactly like the `app_url` path (`Url::origin().ascii_serialization()`), so a
        // mixed-case host echoes identically and hits the same delegation-cache key
        // instead of forking `https://Example.com` vs `https://example.com`.
        Some(u) if u.origin().is_tuple() => Ok(u.origin().ascii_serialization()),
        _ => Err(invalid()),
    }
}

/// Clean a user-supplied `app_url`: the shared identity-arg checks, plus reject an
/// explicit non-https scheme up front. The connector only ever fetches https
/// discovery targets (the SSRF guard refuses anything else), so an `http://` URL
/// would otherwise fail with a late, indirect error — reject it here with a clear
/// message. A bare host (no scheme) is fine; `resolve_app_identity` prepends https.
fn clean_app_url(raw: &str) -> Result<String, String> {
    let u = clean_identity_arg("app_url", raw)?;
    if let Some((scheme, _)) = u.split_once("://") {
        if !scheme.eq_ignore_ascii_case("https") {
            return Err(
                "`app_url` must be an https URL or a bare host (the connector only fetches https origins)"
                    .to_string(),
            );
        }
    }
    // Require a real host and NO user-info, matching `canonicalize_derivation_origin`.
    // `resolve_app_identity` prepends https to a bare host and derives the application
    // origin via `Url::origin()`, which silently DROPS any `user:pass@` — so
    // `https://user@host` would resolve to `https://host` (differing from what the
    // caller supplied) and `https://` would yield no host. Reject both up front with a
    // clear message instead of a late, indirect downstream failure.
    let candidate = if u.contains("://") { u.clone() } else { format!("https://{u}") };
    let valid = url::Url::parse(&candidate).ok().map_or(false, |parsed| {
        parsed.scheme() == "https"
            && parsed.host_str().map_or(false, |h| !h.is_empty())
            && parsed.username().is_empty()
            && parsed.password().is_none()
    });
    if !valid {
        return Err(
            "`app_url` must be an https URL or bare host with a real host and no user-info \
             (e.g. https://app.example.com or app.example.com)"
                .to_string(),
        );
    }
    Ok(u)
}

/// A one-line identity annotation for the human-readable `text` (which text-only
/// clients see instead of the structured output): the origin II derived against,
/// how it was determined (`source`, always "explicit" for the identity-bearing
/// tools now that they take the canonical origin directly), and — whenever it
/// differs from the derived origin (canonicalization, http→https, a stripped path)
/// — the caller's `requested` value, so a requested≠derived mismatch stays visible
/// in every client. `acted_as` prefixes the signed-as principal when known.
fn identity_annotation(target: &IdentityTarget, acted_as: Option<&str>) -> String {
    let mut s = String::new();
    if let Some(p) = acted_as {
        s.push_str(&format!("signed as {p} — "));
    }
    s.push_str(&format!("derived for {} (source: {})", target.origin, target.source));
    if target.requested != target.origin {
        s.push_str(&format!("; requested {}", target.requested));
    }
    s
}

#[tool_handler]
impl ServerHandler for IcTools {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(
            ServerCapabilities::builder().enable_tools().enable_resources().build(),
        )
        .with_server_info(Implementation::from_build_env())
        .with_instructions(
            "Internet Computer tools. Every tool speaks TEXTUAL Candid — the `(...)` value \
             syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`, never \
             the binary form. Tool names signal SCOPE: an `icp_` prefix marks IC protocol / \
             meta-level tools (dashboard name/id lookups, the official IC skills, the OQL dialect \
             guide, and canister creation/management); `…_app…` names \
             (`open_app`, `discover_app_canisters`, `get_app_principal`, `list_app_accounts`, `resolve_app`) \
             act on a whole APP, keyed by its Internet Identity derivation origin or app URL; and \
             `…canister…` names (`get_canister_candid`, `get_canister_api_doc`, \
             `get_canister_oql_schema`, `canister_query`, `canister_update_call`) act on ONE \
             specific canister. Before writing Candid args, consult the `candid://textual-syntax` \
             resource (the value syntax these tools use); `candid://reference` has the full type \
             reference.\n\n\
             START WITH open_app WHEN THE USER NAMES OR LINKS AN APP. `open_app(name-or-URL)` is the \
             one-call entry point: it takes an app NAME (e.g. \"MULTI/DEX\", \"Oisy\", \"NNS\") or a \
             URL, resolves the Internet Identity derivation origin AND discovers the canisters \
             together, probes the app's own canisters for their `oql`/`api_doc_available` \
             capabilities, and repairs a wrong-TLD guess to the canonical known-app URL — so you do \
             NOT chain resolve_app + discover_app_canisters yourself, and you do NOT start from \
             discover_app_canisters. (open_app bundles `resolve_app` + `discover_app_canisters`; use \
             those directly only for a single step.) RULE — names are not URLs: NEVER guess or \
             fabricate a domain from an app's name (e.g. <name>.com/.app); pass the NAME to open_app \
             (or `icp_find_app_by_name`) and let the connector resolve it, WEB SEARCH the official \
             URL, or ask the user. Lookalike domains are unrelated or squatted sites, and open_app / \
             every URL-taking tool REFUSES an origin that shows no evidence of being an Internet \
             Computer app instead of resolving it to a wrong identity. When the user names a TOKEN, \
             PROJECT or SERVICE (e.g. \"ckUSDC\"), use `icp_find_canister_by_name` for its canister \
             id; `icp_lookup_canister_info_by_id(id)` tells you what a bare canister id IS (dashboard \
             label, type, controllers, subnet).\n\n\
             \"MY / OUR …\" IS AN AUTHENTICATED READ. A question about the USER's OWN data in an app \
             (\"who am I meeting with…\", \"my bookings\", \"our balance\") reads data the app gates \
             by the CALLER's principal. An OQL read (get_canister_oql_schema, and canister_query \
             with the `oql` argument) REQUIRES the app's \
             `derivation_origin` (from open_app / resolve_app) — anonymous per-app reads are \
             disabled for now, so a call with no origin is REJECTED with guidance to pass it, rather \
             than silently returning empty. Authenticating never hurts a public read either — the \
             canister serves the request regardless of principal — so always pass the origin for app \
             data. (canister_query can still run a Candid `method` query anonymously for genuinely \
             public canisters like ledgers.)\n\n\
             INSPECTING A CANISTER. `get_canister_candid` fetches the interface and reports two \
             capability flags: `oql` and `api_doc_available` (open_app reports the same per \
             canister). If `oql: true`, READ the canister via OQL, in order: `icp_oql_guide` (the \
             JSON dialect, once) → `get_canister_oql_schema` (the entities and fields) → \
             `canister_query` with the `oql` argument \
             (run a JSON query, get a table). These wrap the canister's `schema`/`execute` methods, \
             so you never hand-encode Candid for OQL — and on an OQL canister a Candid `method` query \
             through canister_query is REJECTED (use `oql`; canister_update_call handles UPDATES). \
             Call `get_canister_api_doc` ONLY when `api_doc_available` is true: then it returns a \
             prose \"how this app behaves\" guide (units, auth, lifecycle, mutation safety, polling, \
             gotchas) the Candid types don't convey; when the flag is false the canister has no such \
             doc and the Candid types ARE the interface — don't call it.\n\n\
             PRESENT VALUES IN THE USER'S LOCAL FORMAT. Canister data is stored in canonical, \
             locale-neutral forms, so CONVERT it for the user rather than echoing the raw value. \
             Timestamps are almost always nanoseconds since the Unix epoch in UTC (IC time; divide \
             by 1e9 for seconds) — render them in the USER's time zone and date/number \
             conventions, not raw UTC nanoseconds. Physical quantities are usually SI/metric or an \
             app-defined unit — check `get_canister_api_doc` for the exact unit, then convert to the \
             user's locale for the measures that split US-customary vs metric: temperature (°C↔°F), \
             mass/weight (g,kg↔oz,lb), length/height/distance (cm,m,km↔in,ft,mi), and volume \
             (mL,L↔fl oz,US gal). Infer the user's locale and time zone from the conversation (their \
             language, where they are, the app) or ask when it matters; keep the raw value alongside \
             the converted one when precision matters (money, exact timestamps) or the source unit \
             is uncertain. Don't convert blindly — first establish the SOURCE unit (from \
             `get_canister_api_doc`, the field/entity name, or the schema), then convert.\n\n\
             `canister_query` (reads) and `canister_update_call` (writes) call a method with \
             textual Candid in/out: omit the identity args to call anonymously, or act AS your \
             account at an app. To act as an app account, identify the app by its \
             `derivation_origin` — the EXACT canonical origin Internet Identity derives its \
             principal from, which is NOT necessarily the visible website URL and must NEVER be \
             inferred from an ii-alternative-origins list. The identity-bearing tools \
             (canister_query, canister_update_call, get_app_principal, list_app_accounts, \
             get_canister_oql_schema) \
             take ONLY `derivation_origin`, NOT a website URL: a derivation origin is a stable \
             per-app value, so RESOLVE IT ONCE with `open_app` (or `resolve_app`) — which turn an \
             app name/URL into it under the guessed-domain gate — and reuse it across calls, rather \
             than re-resolving a URL every time on this stateless server. A short-lived (<=5 min) account \
             delegation is minted ON DEMAND from this connection's standing credential, no extra \
             sign-in. `get_app_principal` returns the principal without a call; `list_app_accounts` lists \
             the user's accounts (a default one plus any named ones), and canister_query / \
             canister_update_call / get_app_principal take an optional `account` (a name from that \
             list) — omit it for the default. Every identity result echoes `derived_for_origin` (the origin actually used) and \
             `requested` (what you passed), so a canonicalization mismatch is visible. If a principal, \
             account, or balance doesn't match what the user sees in their browser, the derivation \
             origin is wrong: re-resolve the app with `open_app`/`resolve_app` (don't guess an origin). The standing \
             credential is obtained when you connect \
             (authenticate via Internet Identity) and lasts for the session duration you choose when \
             connecting (up to 30 days); reconnect when it expires. \
             Internet Identity's consent screen asks the user to choose an access level, \
             \"Questions only\" or \"Actions & questions\". On a Questions-only session reads work, but \
             the canister-management tools below make update calls the network rejects — if one fails \
             that way, ask the user to reconnect and choose \"Actions & questions\".\n\n\
             Typical flow (acting FOR THE USER at an app): (0-2) `open_app(name-or-URL)` in ONE \
             call gives the `derivation_origin` AND the app's canisters (with `oql`/`api_doc_available` \
             flags) — pass the NAME the user said (well-known apps NNS/Oisy/MULTI/DEX/ICPSwap resolve \
             offline) or a URL you have, NEVER a domain guessed from the name (there is no on-chain \
             name→URL directory; `icp_find_canister_by_name` finds token/SNS canister ids, not \
             front-ends). If you want just one part, `icp_find_app_by_name` does name→URL, \
             `resolve_app(url)` the origin, `discover_app_canisters(url)` the canisters; (3) \
             `list_app_accounts` — if there is more than one account, ask which to use and remember \
             it; (4) `get_app_principal` ONLY when you need the principal value itself (`canister_query` / \
             `canister_update_call` act as the account without pre-fetching it); (5) inspect the \
             canister with `get_canister_candid` — its `oql` flag says whether to read via OQL, \
             its `api_doc_available` flag whether `get_canister_api_doc` has a doc; (6) READ as the \
             user with `canister_query`, passing the `derivation_origin` (REQUIRED for OQL): use the \
             `oql` argument when `oql: true` (get the entity/field names from get_canister_oql_schema; \
             an anonymous OQL read is rejected for now, and a Candid `method` \
             query is REJECTED on an OQL canister), else a Candid `method` query; (7) ACT with \
             `canister_update_call`, passing `derivation_origin` + `account` to act as the \
             user. Public metadata (get_canister_candid, discover_app_canisters) and public \
             canister_query Candid `method` queries need no origin; OQL reads always require one. The \
             per-canister inspection (5) is independent of the identity steps (1/3/4), so they can \
             run in parallel. Managing your OWN canisters \
             (the `icp_` create/install/status/… tools) acts as your standing MANAGEMENT principal at \
             this server's origin — a DIFFERENT identity than the per-app principals above.\n\n\
             To AUTHOR, BUILD and DEPLOY IC code, first consult the official IC skills: \
             `icp_list_skills` lists them and `icp_get_skill(name)` loads one. Especially \
             `writing-motoko` (language), `mops-cli` (deps/build), `icp-cli` (build & deploy), \
             `cycles-management` \
             (ICP↔cycles & funding), `stable-memory` (upgrades) and `canister-security`. Compiling \
             Motoko/Rust to Wasm happens in YOUR environment (guided by these skills); these tools \
             then put it on chain. To CREATE and MANAGE canisters as your Internet Identity, use: \
             `icp_cycles_balance` (your cycles-ledger balance), `icp_create_canister` (create + fund from \
             that balance — amount in `cycles` or `icp`), `icp_install_code` (install your compiled \
             Wasm — base64 — single-shot or chunked), `icp_canister_status`, `icp_update_canister_settings`, \
             `icp_start_canister`/`icp_stop_canister`/`icp_uninstall_code`/`icp_delete_canister`, and \
             `icp_top_up_canister`. These act as your standing II principal, which must hold cycles in \
             the cycles ledger first (fund it via the icp CLI / cycles-management skill). So to \
             \"build X and deploy a canister with Y ICP worth of cycles\": read the relevant skills, \
             write & build the Wasm locally, `icp_create_canister(icp=Y)`, then `icp_install_code`."
                .to_string(),
        )
    }

    async fn list_resources(
        &self,
        _request: Option<PaginatedRequestParams>,
        _ctx: RequestContext<RoleServer>,
    ) -> Result<ListResourcesResult, McpError> {
        let mut resources = vec![
            RawResource::new(CANDID_TEXTUAL_URI, "Candid textual syntax (used by these tools)")
                .no_annotation(),
            RawResource::new(CANDID_REFERENCE_URI, "Candid type reference (full spec)")
                .no_annotation(),
            RawResource::new(OQL_USAGE_URI, "OQL query surface usage guide")
                .no_annotation(),
        ];
        // Surface the IC skills as resources too (best-effort: if the registry is
        // unreachable, the candid resources above still list). Each `skill://<name>`
        // is read on demand in read_resource.
        if let Ok(skills) = self.skills.list().await {
            for s in skills {
                let title = if s.title.is_empty() {
                    format!("IC skill: {}", s.name)
                } else {
                    format!("IC skill: {}", s.title)
                };
                resources.push(
                    RawResource::new(format!("{SKILL_URI_PREFIX}{}", s.name), title).no_annotation(),
                );
            }
        }
        Ok(ListResourcesResult {
            resources,
            next_cursor: None,
            meta: None,
        })
    }

    async fn read_resource(
        &self,
        request: ReadResourceRequestParams,
        _ctx: RequestContext<RoleServer>,
    ) -> Result<ReadResourceResult, McpError> {
        // Skills are fetched live by name; the candid references are static.
        if let Some(name) = request.uri.strip_prefix(SKILL_URI_PREFIX) {
            return match self.skills.get(name).await {
                Ok(md) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
                    md,
                    request.uri,
                )])),
                Err(e) => Err(McpError::resource_not_found(
                    "resource_not_found",
                    Some(serde_json::json!({ "uri": request.uri, "error": e })),
                )),
            };
        }
        let body = match request.uri.as_str() {
            CANDID_TEXTUAL_URI => CANDID_TEXTUAL_MD,
            CANDID_REFERENCE_URI => CANDID_REFERENCE_MD,
            OQL_USAGE_URI => OQL_PRIMER_MD,
            other => {
                return Err(McpError::resource_not_found(
                    "resource_not_found",
                    Some(serde_json::json!({ "uri": other })),
                ))
            }
        };
        Ok(ReadResourceResult::new(vec![ResourceContents::text(
            body,
            request.uri,
        )]))
    }
}

/// One canister line for the open_app / discover_app_canisters text output: id,
/// discovery label, dashboard «name» (type), sources, and any capability flags
/// (`[oql]` / `[api-doc]`) filled in by [`IcTools::enrich_capabilities`]. Shared by
/// both tools so their listings stay identical.
fn render_canister_line(c: &discover::DiscoveredCanister) -> String {
    let identity = match (&c.name, &c.kind) {
        (Some(n), Some(k)) => format!("  «{n}» ({k})"),
        (Some(n), None) => format!("  «{n}»"),
        _ => String::new(),
    };
    let mut caps = String::new();
    if c.oql == Some(true) {
        caps.push_str(" [oql]");
    }
    if c.api_doc_available == Some(true) {
        caps.push_str(" [api-doc]");
    }
    format!(
        "- {}{}{} [{}]{}\n",
        c.canister_id,
        c.label.as_deref().map(|l| format!("  — {l}")).unwrap_or_default(),
        identity,
        c.sources.join(", "),
        caps,
    )
}

/// The caller-gated data-access note (#3): when discovery surfaced OQL data
/// canister(s), spell out that their data is gated by the CALLER's principal (an OQL
/// read requires the origin — an anonymous read is rejected for now) and how to read
/// as the user. `handle` is the ready-to-use origin clause when the origin is
/// already resolved (open_app), or `None` when it isn't (discover_app_canisters), in
/// which case the note points at resolve_app / open_app to obtain it.
fn data_access_note(canisters: &[discover::DiscoveredCanister], handle: Option<&str>) -> Option<String> {
    if !canisters.iter().any(|c| c.oql == Some(true)) {
        return None;
    }
    let how = match handle {
        Some(clause) => format!(
            "To read it as the user, pass {clause} to get_canister_oql_schema (for the entity/field \
             names) and canister_query (with the `oql` argument), plus an optional account from list_app_accounts."
        ),
        None => "Resolve the app's derivation_origin (resolve_app / open_app) and pass it to \
                 get_canister_oql_schema (for the entity/field names) and canister_query (with the \
                 `oql` argument) to read as the user."
            .to_string(),
    };
    Some(format!(
        "Data access: the canister(s) flagged [oql] hold this app's data, gated by the CALLER's \
         principal — an OQL read REQUIRES the origin (an anonymous read is rejected for now). {how}"
    ))
}

/// Render an IC dashboard canister identity as readable text for icp_lookup_canister_info_by_id.
fn format_canister_info(info: &discover::CanisterInfo) -> String {
    let mut s = format!("Canister {}\n", info.canister_id);
    s.push_str(&format!(
        "- name: {}\n",
        info.name.as_deref().unwrap_or("(unlabelled — not a known/named canister)")
    ));
    if let Some(t) = &info.canister_type {
        s.push_str(&format!("- type: {t}\n"));
    }
    if let Some(sub) = &info.subnet_id {
        s.push_str(&format!("- subnet: {sub}\n"));
    }
    if !info.controllers.is_empty() {
        s.push_str(&format!("- controllers: {}\n", info.controllers.join(", ")));
    }
    if let Some(lang) = &info.language {
        s.push_str(&format!("- language: {lang}\n"));
    }
    if let Some(mh) = &info.module_hash {
        s.push_str(&format!("- module hash: {mh}\n"));
    }
    if let Some(p) = info.latest_upgrade_proposal {
        s.push_str(&format!("- latest upgrade: NNS proposal {p}\n"));
    }
    s.push_str("\nFetch its interface with get_canister_candid, then read with canister_query and write with canister_update_call.");
    s
}

/// Render the user's accounts at an app (from `Identities::list_accounts`) as
/// readable text for the `list_app_accounts` tool.
fn format_accounts(target: &IdentityTarget, accounts: &[identities::AccountInfo]) -> String {
    // A one-line derivation-origin header so a wrong origin (or requested≠derived
    // mismatch) is visible even to text-only clients.
    let header = format!("Accounts {}", identity_annotation(target, None));
    if accounts.is_empty() {
        return format!("{header}\n\nNo Internet Identity accounts found there.");
    }
    let mut out = format!("{header}\n\nYour accounts:\n");
    for a in accounts {
        // The default account (anchor's current default) has no name/number.
        let label = match &a.name {
            Some(name) => format!("\"{name}\""),
            None => "(default account — no name)".to_string(),
        };
        let number = match a.account_number {
            Some(n) => format!("account #{n}"),
            None => "default".to_string(),
        };
        let last_used = a
            .last_used
            .map(|ns| format!(", last used {ns} ns since epoch"))
            .unwrap_or_default();
        out.push_str(&format!("- {label} [{number}{last_used}]\n"));
    }
    if accounts.len() == 1 {
        out.push_str(
            "\nOnly the default account exists here — act on the user's behalf directly: \
             canister_query / canister_update_call / get_app_principal with no `account`.",
        );
    } else {
        out.push_str(
            "\nThere are multiple accounts here. Confirm which one the user means (or act on each), \
             then pass its name as `account` to canister_query / canister_update_call / \
             get_app_principal. Omit `account` for the default one.",
        );
    }
    out
}

/// The MCP `outputSchema` for `T`, for use in a `#[tool(output_schema = …)]`
/// attribute. Thin wrapper over rmcp's generator that unwraps the object-root
/// validation: a non-object schema is a programming error (every tool output
/// type is a struct), so it panics at router-construction time rather than
/// forcing an `.expect(…)` at each of the ~19 call sites.
fn schema_for_output<T: schemars::JsonSchema + std::any::Any>() -> std::sync::Arc<rmcp::model::JsonObject> {
    rmcp::handler::server::tool::schema_for_output::<T>().unwrap_or_else(|e| {
        panic!(
            "output schema for `{}` must be object-rooted: {e}",
            std::any::type_name::<T>()
        )
    })
}

/// A success result carrying both the human-readable `text` and a machine-
/// readable `value` that conforms to the tool's declared `outputSchema`.
/// Clients that consume structured tool output get the typed form; the rest
/// still get the text.
///
/// MCP requires `outputSchema` (and therefore `structuredContent`) to be
/// object-rooted, so we only attach `value` when it serializes to a JSON
/// object; anything else (a bare array/string/number, or a serialization
/// failure) falls back to text-only rather than emitting structured content
/// that couldn't match the declared schema.
fn ok_structured<T: serde::Serialize>(text: String, value: &T) -> CallToolResult {
    ok_structured_blocks(vec![text], value)
}

/// Like [`ok_structured`], but emits several text content blocks instead of one.
/// The first block is the primary payload (e.g. a canister's raw `.did`); any
/// further blocks are separate notes. Keeping them distinct means a consumer
/// that copies the first block gets clean data (the `.did` stays valid,
/// paste-able Candid), while the model still sees the trailing note(s) — used by
/// `get_canister_candid` to attach the OQL pointer without contaminating the interface
/// text. The structured `value` is attached under the same object-rooted rule as
/// [`ok_structured`].
fn ok_structured_blocks<T: serde::Serialize>(texts: Vec<String>, value: &T) -> CallToolResult {
    let mut result = CallToolResult::success(texts.into_iter().map(Content::text).collect());
    result.structured_content = match serde_json::to_value(value) {
        Ok(v @ serde_json::Value::Object(_)) => Some(v),
        _ => None,
    };
    result
}

/// Map a management/lifecycle tool's `Result<String, String>` (the human
/// confirmation message) to a `CallToolResult`, attaching a
/// `management::CanisterActionOutput { canister_id, message }` as structured content on
/// success so the reply conforms to the tool's declared `outputSchema`.
///
/// The `canister_id` is normalized to its canonical principal text: the
/// management layer accepts ids with surrounding whitespace (it parses via
/// `Principal::from_text(s.trim())`) and renders the canonical form in its
/// messages, so we canonicalize here too to keep the structured field
/// consistent with the text. On the success path the id always parses (the
/// operation just used it); the trimmed input is only a defensive fallback.
fn ok_canister_action(canister_id: String, r: Result<String, String>) -> CallToolResult {
    match r {
        Ok(message) => {
            let canister_id = Principal::from_text(canister_id.trim())
                .map(|p| p.to_text())
                .unwrap_or_else(|_| canister_id.trim().to_string());
            let output = management::CanisterActionOutput { canister_id, message };
            ok_structured(output.message.clone(), &output)
        }
        Err(text) => err(text),
    }
}

fn err(text: String) -> CallToolResult {
    CallToolResult::error(vec![Content::text(text)])
}


#[cfg(test)]
mod tests {
    use super::calls::decode_bytes_with_did;
    use candid::types::value::IDLArgs;
    use candid_parser::parse_idl_args;

    // Anonymous per-app reads are disabled for now: the OQL read tools reject a
    // call with no origin, and the rejection must name what to pass and how to get
    // it (so the agent recovers in one step) — not just say "no".
    #[test]
    fn oql_needs_origin_error_is_actionable() {
        let msg = super::oql_needs_origin_error("Running an OQL query");
        assert!(msg.contains("Running an OQL query"), "echoes the action: {msg}");
        assert!(msg.contains("derivation_origin"), "names the arg to pass: {msg}");
        assert!(
            msg.contains("open_app") && msg.contains("resolve_app"),
            "names how to get the origin: {msg}"
        );
        assert!(
            msg.to_lowercase().contains("anonymous"),
            "explains anonymous is disabled: {msg}"
        );
    }

    // Field names are hashed on the Candid wire; decoding against the method's
    // declared return type must recover them (type-less decoding shows hashes).
    #[test]
    fn typed_decode_recovers_field_names() {
        let did = "service : { stats : () -> (record { name : text; url : text }) query }";
        // Encode a record reply (names get hashed in the wire format).
        let bytes = parse_idl_args("(record { name = \"ICP\"; url = \"https://internetcomputer.org\" })")
            .unwrap()
            .to_bytes()
            .unwrap();

        // Type-less decode -> hashed field ids.
        let typeless = IDLArgs::from_bytes(&bytes).unwrap().to_string();
        assert!(!typeless.contains("name ="), "type-less should NOT have names: {typeless}");

        // Typed decode against the .did -> real field names.
        let typed = decode_bytes_with_did(did, "stats", &bytes).expect("typed decode");
        assert!(typed.contains("name ="), "typed should have `name`: {typed}");
        assert!(typed.contains("url ="), "typed should have `url`: {typed}");
    }

    // Every tool must carry MCP annotations, else clients fall back to the unsafe
    // defaults (readOnly=false, destructive=true) and mislabel reads like
    // get_canister_candid as destructive. Assert the read/write classification serializes.
    #[test]
    fn every_tool_has_correct_read_write_annotations() {
        let tools = super::IcTools::tool_router().list_all();
        assert_eq!(tools.len(), 26, "expected 26 tools, got {}", tools.len());
        assert!(
            tools.iter().all(|t| t.annotations.is_some()),
            "every tool must carry annotations (else clients assume write/destructive)"
        );
        let ann = |name: &str| {
            tools
                .iter()
                .find(|t| &*t.name == name)
                .unwrap_or_else(|| panic!("tool {name} not found"))
                .annotations
                .clone()
                .unwrap_or_else(|| panic!("tool {name} has no annotations"))
        };
        // Pure reads (and status, which reads but is an update call) are read-only,
        // AND set destructive_hint=false explicitly so a naive client that doesn't
        // gate destructive on read_only can't mislabel them.
        for name in [
            "get_canister_candid", "canister_query", "get_canister_oql_schema", "discover_app_canisters", "icp_find_canister_by_name", "icp_find_app_by_name", "icp_lookup_canister_info_by_id",
            "icp_list_skills", "icp_get_skill", "icp_oql_guide",
            "get_canister_api_doc", "open_app", "resolve_app", "list_app_accounts", "icp_cycles_balance", "get_app_principal", "icp_canister_status",
        ] {
            let a = ann(name);
            assert_eq!(a.read_only_hint, Some(true), "{name} should be read-only");
            assert_eq!(a.destructive_hint, Some(false), "{name} should set destructive=false explicitly");
        }
        // Destructive writes: not read-only, destructive. Two kinds live here.
        // Overwriting/removing state: delete, uninstall, install (reinstall and
        // upgrade replace the running module), settings (can hand control away).
        // And SPENDING THE USER'S FUNDS: create and top-up add a canister or its
        // cycles, so "additive" is tempting — but the cycles or ICP leave the user's
        // ledger accounts irreversibly, no tool here can claw them back, and neither
        // call is idempotent, so a client retry spends again. `destructiveHint` is
        // what a client gates its confirmation prompt on and its spec default is
        // `true`, so declaring `false` would be an affirmative (and wrong) promise
        // that a stray call costs the user nothing. Keep them here.
        for name in [
            "icp_delete_canister", "icp_uninstall_code", "icp_install_code", "icp_update_canister_settings",
            "icp_create_canister", "icp_top_up_canister",
        ] {
            let a = ann(name);
            assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only");
            assert_eq!(a.destructive_hint, Some(true), "{name} should be destructive");
        }
        // Additive/reversible writes: not read-only, not destructive. These change
        // run state only, cost nothing, and each is undone by its counterpart.
        for name in ["icp_start_canister", "icp_stop_canister"] {
            let a = ann(name);
            assert_eq!(a.read_only_hint, Some(false), "{name} should not be read-only");
            assert_eq!(a.destructive_hint, Some(false), "{name} should not be destructive");
        }
        // The update-call tool is conservatively write + destructive.
        let cc = ann("canister_update_call");
        assert_eq!(cc.read_only_hint, Some(false));
        assert_eq!(cc.destructive_hint, Some(true));
    }

    // EVERY tool must declare an outputSchema so a model knows the shape of its
    // reply — and MCP requires that schema to be object-rooted. This guards the
    // whole surface: a new tool added without an output schema fails here.
    #[test]
    fn every_tool_declares_an_object_output_schema() {
        let tools = super::IcTools::tool_router().list_all();
        for t in &tools {
            let schema = t
                .output_schema
                .as_ref()
                .unwrap_or_else(|| panic!("tool {} must declare an output schema", t.name));
            assert_eq!(
                schema.get("type"),
                Some(&serde_json::json!("object")),
                "tool {}'s outputSchema must be object-rooted per the MCP spec",
                t.name
            );
        }
    }

    // Spot-check icp_find_canister_by_name's schema lists the expected properties.
    #[test]
    fn find_canister_declares_output_schema() {
        let tools = super::IcTools::tool_router().list_all();
        let tool = tools
            .iter()
            .find(|t| &*t.name == "icp_find_canister_by_name")
            .expect("icp_find_canister_by_name tool not found");
        let schema = tool
            .output_schema
            .as_ref()
            .expect("icp_find_canister_by_name must declare an output schema");
        let props = schema
            .get("properties")
            .and_then(|v| v.as_object())
            .expect("outputSchema must have properties");
        assert!(props.contains_key("query"), "outputSchema should describe `query`");
        assert!(props.contains_key("matches"), "outputSchema should describe `matches`");
    }

    // The object-root guard in ok_structured must drop non-object payloads
    // (a bare array/string can't match an MCP object outputSchema) while
    // still attaching genuine objects.
    #[test]
    fn ok_structured_only_attaches_objects() {
        let obj = super::ok_structured("t".to_string(), &serde_json::json!({"a": 1}));
        assert!(obj.structured_content.is_some(), "an object payload must attach");

        let arr = super::ok_structured("t".to_string(), &serde_json::json!([1, 2, 3]));
        assert!(arr.structured_content.is_none(), "a bare array must be dropped");

        let s = super::ok_structured("t".to_string(), &"hello");
        assert!(s.structured_content.is_none(), "a bare string must be dropped");
    }

    // ok_canister_action normalizes the canister id to its canonical principal
    // text, so the structured field matches the id the management layer acted
    // on (it trims/parses the input) rather than echoing raw whitespace.
    #[test]
    fn ok_canister_action_canonicalizes_canister_id() {
        let result = super::ok_canister_action(
            "  ryjl3-tyaaa-aaaaa-aaaba-cai  ".to_string(),
            Ok("Started ryjl3-tyaaa-aaaaa-aaaba-cai.".to_string()),
        );
        let value = result.structured_content.expect("structured content must be attached");
        assert_eq!(
            value.get("canister_id"),
            Some(&serde_json::json!("ryjl3-tyaaa-aaaaa-aaaba-cai")),
            "structured canister_id must be the canonical, trimmed principal text"
        );
    }

    // A structured icp_find_canister_by_name reply must round-trip through the declared
    // schema shape: text for humans, plus a machine-readable object with a
    // `matches` array carrying each match's fields.
    #[test]
    fn find_canister_output_serializes_to_declared_shape() {
        let output = super::discover::FindCanisterOutput {
            query: "ckUSDC".to_string(),
            matches: vec![super::discover::FoundCanister {
                canister_id: "xevnm-gaaaa-aaaar-qafnq-cai".to_string(),
                name: "ckUSDC".to_string(),
                kind: "token".to_string(),
                note: None,
            }],
        };
        let result = super::ok_structured("human text".to_string(), &output);
        let value = result
            .structured_content
            .expect("structured content must be attached");
        assert_eq!(value.get("query"), Some(&serde_json::json!("ckUSDC")));
        let matches = value.get("matches").and_then(|v| v.as_array()).expect("matches array");
        assert_eq!(matches.len(), 1);
        assert_eq!(
            matches[0].get("canister_id"),
            Some(&serde_json::json!("xevnm-gaaaa-aaaar-qafnq-cai"))
        );
        assert_eq!(matches[0].get("kind"), Some(&serde_json::json!("token")));
    }

    // resolve_identity_target's validation rules are part of the tool contract. It
    // takes ONLY `derivation_origin` now (the identity-bearing tools no longer
    // accept an app_url to resolve — see clean_app_url tests for URL validation).

    // A whitespace-only `derivation_origin` is empty after trimming and must be
    // rejected rather than canonicalized into a bogus origin.
    #[test]
    fn resolve_identity_target_rejects_blank_derivation_origin() {
        let err = super::resolve_identity_target(Some("   ".to_string()))
            .expect_err("blank derivation_origin must be an error");
        assert!(err.contains("must not be empty"), "unexpected message: {err}");
    }

    // `derivation_origin` is trimmed before canonicalization, so surrounding
    // whitespace never leaks into either the echoed `requested` or the effective
    // `origin` fed to the delegation path.
    #[test]
    fn resolve_identity_target_trims_derivation_origin() {
        let target = super::resolve_identity_target(Some("  https://example.com  ".to_string()))
            .expect("valid derivation_origin resolves")
            .expect("an explicit derivation_origin yields a target");
        assert_eq!(target.requested, "https://example.com", "requested must be trimmed");
        assert_eq!(target.origin, "https://example.com", "origin must be the canonical trimmed form");
        assert_eq!(target.source, "explicit");
    }

    // No `derivation_origin` is the anonymous path: no target, no error.
    #[test]
    fn resolve_identity_target_none_is_anonymous() {
        let target = super::resolve_identity_target(None).expect("None is anonymous");
        assert!(target.is_none(), "None must yield no target");
    }

    // A control character is rejected up front (it would otherwise be echoed back
    // verbatim / corrupt the delegation origin).
    #[test]
    fn resolve_identity_target_rejects_control_chars() {
        let err = super::resolve_identity_target(Some("https://ex\u{7}ample.com".to_string()))
            .expect_err("control chars must be rejected");
        assert!(err.contains("control characters"), "unexpected message: {err}");
    }

    // An uppercase scheme must canonicalize the same as lowercase — `target_origin`
    // strips only a lowercase `https://`, so without normalization `HTTPS://` would
    // survive into the host and mangle the origin instead of yielding `https://host`.
    #[test]
    fn resolve_identity_target_normalizes_uppercase_scheme() {
        let t = super::resolve_identity_target(Some("HTTPS://example.com".to_string()))
            .expect("uppercase https scheme must be accepted")
            .expect("a derivation_origin yields a target");
        assert_eq!(t.origin, "https://example.com", "unexpected origin: {}", t.origin);
    }

    // A mixed-case HOST is canonicalized to lowercase, so the echoed origin and the
    // delegation-cache key match the resolver path (which serializes via Url::origin())
    // instead of forking `https://Example.COM` from `https://example.com`.
    #[test]
    fn resolve_identity_target_lowercases_host() {
        let t = super::resolve_identity_target(Some("https://Example.COM".to_string()))
            .expect("mixed-case host must be accepted")
            .expect("a derivation_origin yields a target");
        assert_eq!(t.origin, "https://example.com", "host must be lowercased: {}", t.origin);
    }

    // A `derivation_origin` with no host (e.g. "https://") reduces to an empty
    // origin and must be rejected rather than derived against.
    #[test]
    fn resolve_identity_target_rejects_hostless_derivation_origin() {
        let err = super::resolve_identity_target(Some("https://".to_string()))
            .expect_err("host-less derivation_origin must be rejected");
        assert!(err.contains("host"), "unexpected message: {err}");
    }

    // A non-http(s) scheme must be rejected, not mangled into a bogus https origin.
    #[test]
    fn resolve_identity_target_rejects_non_http_scheme() {
        let err = super::resolve_identity_target(Some("ftp://example.com".to_string()))
            .expect_err("ftp:// must be rejected");
        assert!(err.contains("https origin"), "unexpected message: {err}");
    }

    // `clean_app_url` (used by the resolver tools open_app / resolve_app) fails
    // closed on bad URLs up front, so a caller gets a clear error instead of a late
    // SSRF-guard/URL-parse failure. (The identity-bearing tools no longer take an
    // app_url; the resolvers do.)
    #[test]
    fn clean_app_url_rejects_bad_urls() {
        assert!(super::clean_app_url("   ").expect_err("blank").contains("must not be empty"));
        assert!(super::clean_app_url("http://example.com").expect_err("http").contains("https"));
        assert!(
            super::clean_app_url("https://user:pass@example.com")
                .expect_err("user-info")
                .contains("user-info")
        );
        assert!(super::clean_app_url("https://").expect_err("host-less").contains("real host"));
        // A good bare host / https URL passes through.
        assert_eq!(super::clean_app_url("oisy.com").unwrap(), "oisy.com");
        assert_eq!(super::clean_app_url("https://oisy.com").unwrap(), "https://oisy.com");
    }

    // The guessed-domain refusal (offline): the message must name the failure,
    // redirect to the legitimate name→URL steps, carry the "did you mean" repair
    // when the host resembles a well-known app, and keep the deliberate
    // `derivation_origin` escape hatch — that combination is what turns a guessing
    // loop into a one-step correction.
    #[test]
    fn unverified_app_url_error_redirects_and_repairs() {
        // The exact wild-observed guess: multidex.com for MULTI/DEX (multidex.ai).
        let msg = super::unverified_app_url_error("https://multidex.com");
        assert!(msg.contains("NO evidence"), "{msg}");
        assert!(msg.contains("icp_find_app_by_name"), "{msg}");
        assert!(msg.contains("WEB SEARCH"), "{msg}");
        assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}");
        assert!(msg.contains("https://multidex.ai"), "{msg}");
        assert!(msg.contains("`derivation_origin`"), "escape hatch missing: {msg}");
        // A host resembling no known app still gets the redirect, just no repair.
        let plain = super::unverified_app_url_error("https://example.com");
        assert!(plain.contains("icp_find_app_by_name"), "{plain}");
        assert!(!plain.contains("DID YOU MEAN"), "{plain}");
    }

    // A fetch failure on a guessed domain (offline: the DNS-dead "multi.dex" guess)
    // carries the same repair, so it reads as "wrong URL — here's the real one"
    // rather than a transient error inviting the next guess.
    #[test]
    fn app_url_fetch_errors_carry_guess_guidance() {
        let msg = super::app_url_error_with_guidance(
            "multi.dex",
            "could not resolve multi.dex: no such host".to_string(),
        );
        assert!(msg.contains("could not resolve"), "{msg}");
        assert!(msg.contains("DID YOU MEAN MULTI/DEX"), "{msg}");
        assert!(msg.contains("https://multidex.ai"), "{msg}");
        assert!(msg.contains("icp_find_app_by_name"), "{msg}");
        // Unrelated hosts: guidance without a bogus suggestion.
        let plain = super::app_url_error_with_guidance("example.com", "timeout".to_string());
        assert!(!plain.contains("DID YOU MEAN"), "{plain}");
        assert!(plain.contains("icp_find_app_by_name"), "{plain}");
    }

    // The human-readable identity annotation must surface a requested≠derived
    // mismatch (and the source) in ALL clients.
    #[test]
    fn identity_annotation_surfaces_mismatch_and_source() {
        // requested == origin: origin + source, but no redundant `requested` echo.
        let t = super::IdentityTarget {
            origin: "https://nns.ic0.app".to_string(),
            requested: "https://nns.ic0.app".to_string(),
            source: "explicit".to_string(),
        };
        let a = super::identity_annotation(&t, None);
        assert!(a.contains("derived for https://nns.ic0.app"), "{a}");
        assert!(a.contains("source: explicit"), "{a}");
        assert!(!a.contains("requested"), "no mismatch must not echo requested: {a}");

        // requested != origin (e.g. a stripped path): the mismatch is surfaced.
        let t2 = super::IdentityTarget {
            origin: "https://app.example.com".to_string(),
            requested: "https://app.example.com/some/path".to_string(),
            source: "explicit".to_string(),
        };
        let a2 = super::identity_annotation(&t2, Some("aaaaa-aa"));
        assert!(a2.contains("signed as aaaaa-aa"), "{a2}");
        assert!(a2.contains("requested https://app.example.com/some/path"), "{a2}");
    }

    // resolve_identity_target backs every identity-bearing path now — including
    // canister_query's OQL mode and get_canister_candid's schema read — so it must
    // fail closed on bad derivation origins: control chars, host-less, a non-https
    // scheme, an embedded space, and user-info are all rejected; a valid one trims +
    // canonicalizes. (The None/blank/uppercase/lowercase-host cases have their own
    // tests above.)
    #[test]
    fn resolve_identity_target_canonicalizes_and_rejects_bad_origins() {
        use super::resolve_identity_target as r;
        assert!(r(Some("https://a\u{7}b.com".to_string())).is_err(), "control chars rejected");
        assert!(r(Some("https://".to_string())).is_err(), "host-less rejected");
        assert!(r(Some("ftp://example.com".to_string())).is_err(), "non-http(s) scheme rejected");
        assert!(r(Some("https://ex ample.com".to_string())).is_err(), "embedded space rejected");
        assert!(
            r(Some("https://user@example.com".to_string())).is_err(),
            "user-info rejected (would derive a different principal than the bare origin)"
        );
        assert!(
            r(Some("http://example.com".to_string())).is_err(),
            "explicit http:// rejected (https-only contract; target_origin would silently upgrade it)"
        );
        let t = r(Some("  https://example.com  ".to_string()))
            .expect("valid origin resolves")
            .expect("an explicit derivation_origin yields a target");
        assert_eq!(t.origin, "https://example.com", "valid input trims + canonicalizes");
    }
}