excalidrop 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +19 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +62 -6
  4. package/dist/cli.js.map +1 -1
  5. package/dist/frontend/assets/{advancedFormat-13WcrIKs.js → advancedFormat-DE1yOycD.js} +1 -1
  6. package/dist/frontend/assets/{ar-SA-G6X2FPQ2-D8qDPMhv.js → ar-SA-G6X2FPQ2-QnaMc7JM.js} +1 -1
  7. package/dist/frontend/assets/{arc-B_uYwE6m.js → arc-Cg8U0Eq5.js} +1 -1
  8. package/dist/frontend/assets/{az-AZ-76LH7QW2-BVH7xgNf.js → az-AZ-76LH7QW2-BO9Hr9vy.js} +1 -1
  9. package/dist/frontend/assets/{bg-BG-XCXSNQG7-ByPUKhGL.js → bg-BG-XCXSNQG7-DsQdG8GQ.js} +1 -1
  10. package/dist/frontend/assets/{blockDiagram-38ab4fdb-CQvePe8Z.js → blockDiagram-38ab4fdb-Cfq7gTl5.js} +1 -1
  11. package/dist/frontend/assets/{blockDiagram-68f4deed-CwvcKSDG.js → blockDiagram-68f4deed-DKXvoBM4.js} +1 -1
  12. package/dist/frontend/assets/{bn-BD-2XOGV67Q-D4KqOeKh.js → bn-BD-2XOGV67Q-0GTwXdnx.js} +1 -1
  13. package/dist/frontend/assets/{c4Diagram-15b5d702-COmliiHE.js → c4Diagram-15b5d702-CzLwW3PM.js} +1 -1
  14. package/dist/frontend/assets/{c4Diagram-3d4e48cf-D7OkyBCG.js → c4Diagram-3d4e48cf-BtA5sIH_.js} +1 -1
  15. package/dist/frontend/assets/{ca-ES-6MX7JW3Y-CVSEc9Gr.js → ca-ES-6MX7JW3Y-D792TpuE.js} +1 -1
  16. package/dist/frontend/assets/channel-BiBGrGc2.js +1 -0
  17. package/dist/frontend/assets/{classDiagram-70f12bd4-CiR1mAxL.js → classDiagram-70f12bd4-CYlskbl7.js} +1 -1
  18. package/dist/frontend/assets/{classDiagram-d40c83e7-BOwtqvA-.js → classDiagram-d40c83e7-TFFAmI3n.js} +1 -1
  19. package/dist/frontend/assets/{classDiagram-v2-d5a6b087-CLfovOEZ.js → classDiagram-v2-d5a6b087-CuEs-vQM.js} +1 -1
  20. package/dist/frontend/assets/{classDiagram-v2-f2320105-BbgjahgF.js → classDiagram-v2-f2320105-CP2QUPIw.js} +1 -1
  21. package/dist/frontend/assets/clone-DP5GGGqv.js +1 -0
  22. package/dist/frontend/assets/{createText-2e5e7dd3-hFM4xCnY.js → createText-2e5e7dd3-B9buhJhz.js} +1 -1
  23. package/dist/frontend/assets/{createText-d213de94-BcC2siap.js → createText-d213de94-Dl7cGRq1.js} +1 -1
  24. package/dist/frontend/assets/{cs-CZ-2BRQDIVT-GLI3AmJq.js → cs-CZ-2BRQDIVT-BX_XTb-l.js} +1 -1
  25. package/dist/frontend/assets/{cytoscape-cose-bilkent-DIrtWvIC.js → cytoscape-cose-bilkent-bdLom2jo.js} +1 -1
  26. package/dist/frontend/assets/{da-DK-5WZEPLOC-BirYbTgL.js → da-DK-5WZEPLOC-Bfj3iC9L.js} +1 -1
  27. package/dist/frontend/assets/{de-DE-XR44H4JA-CpII26VX.js → de-DE-XR44H4JA-dlBwXmiG.js} +1 -1
  28. package/dist/frontend/assets/{edges-332bd1c7-CV1_ozEk.js → edges-332bd1c7-B1wvBqMI.js} +1 -1
  29. package/dist/frontend/assets/{edges-e0da2a9e-pqeZxZR9.js → edges-e0da2a9e-CdBHUaUf.js} +1 -1
  30. package/dist/frontend/assets/{el-GR-BZB4AONW-DFs7KhuG.js → el-GR-BZB4AONW-_x5lVXg8.js} +1 -1
  31. package/dist/frontend/assets/{elk.bundled-CK-SRvXT.js → elk.bundled-DtUPYeYN.js} +1 -1
  32. package/dist/frontend/assets/{erDiagram-880f2ed8-D8z0YP6a.js → erDiagram-880f2ed8-BsPsKkXE.js} +1 -1
  33. package/dist/frontend/assets/{erDiagram-9861fffd-5ZZvug0m.js → erDiagram-9861fffd-Bgpjn4d-.js} +1 -1
  34. package/dist/frontend/assets/{es-ES-U4NZUMDT-DKbN2UTU.js → es-ES-U4NZUMDT-CgmLkh9-.js} +1 -1
  35. package/dist/frontend/assets/{eu-ES-A7QVB2H4-7_Gn1vLd.js → eu-ES-A7QVB2H4-xmyjxW0n.js} +1 -1
  36. package/dist/frontend/assets/{fa-IR-HGAKTJCU-GYGVj_td.js → fa-IR-HGAKTJCU-BN1XqkA9.js} +1 -1
  37. package/dist/frontend/assets/{fi-FI-Z5N7JZ37-CSqOf1gy.js → fi-FI-Z5N7JZ37-DPGmsozE.js} +1 -1
  38. package/dist/frontend/assets/{flowDb-7c981674-hAdbykWR.js → flowDb-7c981674-D-Lz-ucy.js} +1 -1
  39. package/dist/frontend/assets/{flowDb-956e92f1-BT3sVrS_.js → flowDb-956e92f1-CGqQ4oJb.js} +1 -1
  40. package/dist/frontend/assets/{flowDiagram-66a62f08-BFbBUcUi.js → flowDiagram-66a62f08-yAsoSO4M.js} +1 -1
  41. package/dist/frontend/assets/{flowDiagram-cbd28bf7-D-6XLmjT.js → flowDiagram-cbd28bf7-Cv24oAFw.js} +1 -1
  42. package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-DL-IsgmS.js +1 -0
  43. package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-cm0Es-T3.js +1 -0
  44. package/dist/frontend/assets/{flowchart-elk-definition-36e2d292-9Nr5xJ91.js → flowchart-elk-definition-36e2d292-8RiRHcVG.js} +1 -1
  45. package/dist/frontend/assets/{flowchart-elk-definition-4a651766-Dh7uuQPf.js → flowchart-elk-definition-4a651766-Z37NlWB1.js} +1 -1
  46. package/dist/frontend/assets/{fr-FR-RHASNOE6-C_Z36lVW.js → fr-FR-RHASNOE6-daCFKfHs.js} +1 -1
  47. package/dist/frontend/assets/{ganttDiagram-04f9e578-DJ_dJIYA.js → ganttDiagram-04f9e578-BMq_3g8O.js} +1 -1
  48. package/dist/frontend/assets/{ganttDiagram-c361ad54-CQOWRFrH.js → ganttDiagram-c361ad54-D09Dl_OA.js} +1 -1
  49. package/dist/frontend/assets/{gitGraphDiagram-21fc4d3e-7pQNN07o.js → gitGraphDiagram-21fc4d3e-ffU3Oe9e.js} +1 -1
  50. package/dist/frontend/assets/{gitGraphDiagram-72cf32ee-BVi0ODi6.js → gitGraphDiagram-72cf32ee-B1qFZhLG.js} +1 -1
  51. package/dist/frontend/assets/{gl-ES-HMX3MZ6V-DV1iEV4R.js → gl-ES-HMX3MZ6V-Bi_ejBVO.js} +1 -1
  52. package/dist/frontend/assets/{graph-DPklf_4J.js → graph-Cld5o79d.js} +1 -1
  53. package/dist/frontend/assets/{graph-DZoF-TB0.js → graph-D0R7rXy3.js} +1 -1
  54. package/dist/frontend/assets/{he-IL-6SHJWFNN-qSfOYyrG.js → he-IL-6SHJWFNN-BxMAYMHO.js} +1 -1
  55. package/dist/frontend/assets/{hi-IN-IWLTKZ5I-CUUpX9FX.js → hi-IN-IWLTKZ5I-CEvdaxPD.js} +1 -1
  56. package/dist/frontend/assets/{hu-HU-A5ZG7DT2-jc9NuvMZ.js → hu-HU-A5ZG7DT2-DD25K3R6.js} +1 -1
  57. package/dist/frontend/assets/{id-ID-SAP4L64H-gAQX_ycs.js → id-ID-SAP4L64H-BbNSAn5U.js} +1 -1
  58. package/dist/frontend/assets/{index-DMNFnJz0.js → index-1WYcrLVJ.js} +4 -4
  59. package/dist/frontend/assets/{index-3862675e-DkeMPq9X.js → index-3862675e-B37xIMNY.js} +1 -1
  60. package/dist/frontend/assets/{index-6079d271-GJeDwDgK.js → index-6079d271-MI3jF-v5.js} +1 -1
  61. package/dist/frontend/assets/index-BQuFwudZ.js +349 -0
  62. package/dist/frontend/assets/{infoDiagram-4a4f5b27-D4VmHTt0.js → infoDiagram-4a4f5b27-Ct-SquLn.js} +1 -1
  63. package/dist/frontend/assets/{infoDiagram-f8f76790-CKGoMOTF.js → infoDiagram-f8f76790-DhHJqTvC.js} +1 -1
  64. package/dist/frontend/assets/{it-IT-JPQ66NNP-D8O8hsKi.js → it-IT-JPQ66NNP-DGCSNBVd.js} +1 -1
  65. package/dist/frontend/assets/{ja-JP-DBVTYXUO-CD5HWhjP.js → ja-JP-DBVTYXUO-BCiZKQS1.js} +1 -1
  66. package/dist/frontend/assets/{journeyDiagram-29694f62-Cj-S78w5.js → journeyDiagram-29694f62-JHRAdUlO.js} +1 -1
  67. package/dist/frontend/assets/{journeyDiagram-49397b02-Cs4WN5FP.js → journeyDiagram-49397b02-Cz3Xcr6V.js} +1 -1
  68. package/dist/frontend/assets/{kaa-6HZHGXH3-xebLrnfE.js → kaa-6HZHGXH3-BVSalYYt.js} +1 -1
  69. package/dist/frontend/assets/{kab-KAB-ZGHBKWFO-DvfjxFnt.js → kab-KAB-ZGHBKWFO-C9onfV-Y.js} +1 -1
  70. package/dist/frontend/assets/{kk-KZ-P5N5QNE5-BKr_G3W_.js → kk-KZ-P5N5QNE5-D7HmWmH6.js} +1 -1
  71. package/dist/frontend/assets/{km-KH-HSX4SM5Z-BC3jqWvk.js → km-KH-HSX4SM5Z--gSIK5hM.js} +1 -1
  72. package/dist/frontend/assets/{ko-KR-MTYHY66A-Dueq3BFJ.js → ko-KR-MTYHY66A-DX-ewFiI.js} +1 -1
  73. package/dist/frontend/assets/{ku-TR-6OUDTVRD-DOUVUhi3.js → ku-TR-6OUDTVRD-BzkN5jDn.js} +1 -1
  74. package/dist/frontend/assets/{layout-DNILJXpj.js → layout-Bg902Usv.js} +1 -1
  75. package/dist/frontend/assets/{layout-BC6d-Wwh.js → layout-DTqY_XPF.js} +1 -1
  76. package/dist/frontend/assets/{line-DHiJikCQ.js → line-CyQqGgtO.js} +1 -1
  77. package/dist/frontend/assets/{linear-CEqgKjcC.js → linear-Bs8n8jyN.js} +1 -1
  78. package/dist/frontend/assets/{lt-LT-XHIRWOB4-b-v2D9bi.js → lt-LT-XHIRWOB4-DYMQN62-.js} +1 -1
  79. package/dist/frontend/assets/{lv-LV-5QDEKY6T-DeSmLJYT.js → lv-LV-5QDEKY6T-Ba6KAHxv.js} +1 -1
  80. package/dist/frontend/assets/{mindmap-definition-ac74a2e8-CU4tLzl8.js → mindmap-definition-ac74a2e8-CFIgjLLb.js} +1 -1
  81. package/dist/frontend/assets/{mindmap-definition-fc14e90a-cr8cL-H3.js → mindmap-definition-fc14e90a-BrE0Pj3C.js} +1 -1
  82. package/dist/frontend/assets/{mr-IN-CRQNXWMA-CEN0CMR0.js → mr-IN-CRQNXWMA-CopS0bND.js} +1 -1
  83. package/dist/frontend/assets/{my-MM-5M5IBNSE-Bri-YGoK.js → my-MM-5M5IBNSE-CBjLWYW5.js} +1 -1
  84. package/dist/frontend/assets/{nb-NO-T6EIAALU-47lldcE5.js → nb-NO-T6EIAALU-CfqYpnNU.js} +1 -1
  85. package/dist/frontend/assets/{nl-NL-IS3SIHDZ-C4cm6lD2.js → nl-NL-IS3SIHDZ-CQao565c.js} +1 -1
  86. package/dist/frontend/assets/{nn-NO-6E72VCQL-pb1Jxyuz.js → nn-NO-6E72VCQL-BfcDB6Nm.js} +1 -1
  87. package/dist/frontend/assets/{oc-FR-POXYY2M6-tuCngTrJ.js → oc-FR-POXYY2M6-CBEuTLTI.js} +1 -1
  88. package/dist/frontend/assets/{pa-IN-N4M65BXN-Cosawm6d.js → pa-IN-N4M65BXN-CIRvxv_4.js} +1 -1
  89. package/dist/frontend/assets/{pica-Cqow9aeR.js → pica-n9a5bdBf.js} +1 -1
  90. package/dist/frontend/assets/{pie-C78Yrqjh.js → pie-Bw6jHLyi.js} +1 -1
  91. package/dist/frontend/assets/{pieDiagram-421022e6-AfZ8Wmiu.js → pieDiagram-421022e6-Bq7587Ql.js} +1 -1
  92. package/dist/frontend/assets/{pieDiagram-8a3498a8-Sb4RWl24.js → pieDiagram-8a3498a8-Cs30mQ4p.js} +1 -1
  93. package/dist/frontend/assets/{pl-PL-T2D74RX3-Chumgtd8.js → pl-PL-T2D74RX3-DtyedWZX.js} +1 -1
  94. package/dist/frontend/assets/{pt-BR-5N22H2LF-Y6yMB05f.js → pt-BR-5N22H2LF-DN1KIZAy.js} +1 -1
  95. package/dist/frontend/assets/{pt-PT-UZXXM6DQ-2XNNwJUO.js → pt-PT-UZXXM6DQ-BabgX2Go.js} +1 -1
  96. package/dist/frontend/assets/{quadrantDiagram-0957ecba-aT6ajsQz.js → quadrantDiagram-0957ecba-r08S29Gl.js} +1 -1
  97. package/dist/frontend/assets/{quadrantDiagram-120e2f19-DA8xtpNX.js → quadrantDiagram-120e2f19-Cr2SCg_P.js} +1 -1
  98. package/dist/frontend/assets/{relay-CO0fAEJb.js → relay-BgjIEMqw.js} +2 -2
  99. package/dist/frontend/assets/{requirementDiagram-23d650b8-BZtxzO7K.js → requirementDiagram-23d650b8-DWSUlPnt.js} +1 -1
  100. package/dist/frontend/assets/{requirementDiagram-deff3bca-CQYENSbT.js → requirementDiagram-deff3bca-D-4X-NA5.js} +1 -1
  101. package/dist/frontend/assets/{ro-RO-JPDTUUEW-BfolbfPU.js → ro-RO-JPDTUUEW-BTtFaTbo.js} +1 -1
  102. package/dist/frontend/assets/{ru-RU-B4JR7IUQ-BNWrm2pe.js → ru-RU-B4JR7IUQ-Cx7p9fCi.js} +1 -1
  103. package/dist/frontend/assets/{sankeyDiagram-04a897e0-D0OJOdMM.js → sankeyDiagram-04a897e0-b2SeMYG6.js} +1 -1
  104. package/dist/frontend/assets/{sankeyDiagram-23345273-DJYXL1_D.js → sankeyDiagram-23345273-DLciAh1g.js} +1 -1
  105. package/dist/frontend/assets/{selectAll-DOPd5EPW.js → selectAll-3cbo0gSx.js} +1 -1
  106. package/dist/frontend/assets/{sequenceDiagram-17ac3bff-DHJQ2Zdp.js → sequenceDiagram-17ac3bff-D4C97IMR.js} +1 -1
  107. package/dist/frontend/assets/{sequenceDiagram-704730f1-B013vXOZ.js → sequenceDiagram-704730f1-CsIwqj61.js} +1 -1
  108. package/dist/frontend/assets/{si-LK-N5RQ5JYF-D32y43Nh.js → si-LK-N5RQ5JYF-DRbkL9Bw.js} +1 -1
  109. package/dist/frontend/assets/{sk-SK-C5VTKIMK-ORKN2f5z.js → sk-SK-C5VTKIMK-b8KPqW9j.js} +1 -1
  110. package/dist/frontend/assets/{sl-SI-NN7IZMDC-orl2CefP.js → sl-SI-NN7IZMDC-DAgg3HEv.js} +1 -1
  111. package/dist/frontend/assets/{stateDiagram-587899a1-Bq1RJXU-.js → stateDiagram-587899a1-BrL0AJ2m.js} +1 -1
  112. package/dist/frontend/assets/{stateDiagram-9c5f0230-fS9zifGF.js → stateDiagram-9c5f0230-znRpiySU.js} +1 -1
  113. package/dist/frontend/assets/{stateDiagram-v2-51a3dcff-CBvwJ0uj.js → stateDiagram-v2-51a3dcff-Cm0gu2aT.js} +1 -1
  114. package/dist/frontend/assets/{stateDiagram-v2-d93cdb3a-BtvnfP6y.js → stateDiagram-v2-d93cdb3a-D1v21-Eu.js} +1 -1
  115. package/dist/frontend/assets/{styles-2ab5d517-CD2K6uvC.js → styles-2ab5d517-DegF9FpK.js} +1 -1
  116. package/dist/frontend/assets/{styles-5f03d8d2-BGwVkv5B.js → styles-5f03d8d2-rJg_uZ1y.js} +1 -1
  117. package/dist/frontend/assets/{styles-6aaf32cf-Cp8Xm4ia.js → styles-6aaf32cf-DZ1tE453.js} +1 -1
  118. package/dist/frontend/assets/{styles-9a916d00-BFniIAZ5.js → styles-9a916d00-8MQRBV5u.js} +1 -1
  119. package/dist/frontend/assets/{styles-c10674c1-CsoDZQ7K.js → styles-c10674c1-CoYU8Hxx.js} +1 -1
  120. package/dist/frontend/assets/{styles-edf9a4b0-CiM579LN.js → styles-edf9a4b0--FFAlqXa.js} +1 -1
  121. package/dist/frontend/assets/subset-shared.chunk.js +1 -1
  122. package/dist/frontend/assets/subset-worker.chunk.js +1 -1
  123. package/dist/frontend/assets/{sv-SE-XGPEYMSR-DHbvQRGF.js → sv-SE-XGPEYMSR-BupEJvWR.js} +1 -1
  124. package/dist/frontend/assets/{svgDrawCommon-08f97a94-BjVpQLSR.js → svgDrawCommon-08f97a94-CTj31sRY.js} +1 -1
  125. package/dist/frontend/assets/{svgDrawCommon-3ba9043b-DjE4ZK0L.js → svgDrawCommon-3ba9043b-BZHRpxTv.js} +1 -1
  126. package/dist/frontend/assets/{ta-IN-2NMHFXQM-Ca1t_Ko2.js → ta-IN-2NMHFXQM-DTr9GIu-.js} +1 -1
  127. package/dist/frontend/assets/{th-TH-HPSO5L25-2L4nZMR3.js → th-TH-HPSO5L25-BDPsaOgQ.js} +1 -1
  128. package/dist/frontend/assets/{timeline-definition-7e6b55e7-BFW1xuIt.js → timeline-definition-7e6b55e7-DYpxvJMC.js} +1 -1
  129. package/dist/frontend/assets/{timeline-definition-85554ec2-Bjtl-I1R.js → timeline-definition-85554ec2-qV8Ig3TX.js} +1 -1
  130. package/dist/frontend/assets/{tr-TR-DEFEU3FU-DTNddWux.js → tr-TR-DEFEU3FU-Cgh12nEY.js} +1 -1
  131. package/dist/frontend/assets/{uk-UA-QMV73CPH-L17E1Z9m.js → uk-UA-QMV73CPH-DsPGU0Dc.js} +1 -1
  132. package/dist/frontend/assets/{union-BOPJv_NW.js → union-BNBs5eWq.js} +1 -1
  133. package/dist/frontend/assets/{vi-VN-M7AON7JQ-B2rkMBha.js → vi-VN-M7AON7JQ-DCP17qnJ.js} +1 -1
  134. package/dist/frontend/assets/{xychartDiagram-b6496bcd-zkCAiikr.js → xychartDiagram-b6496bcd-BlZqZpsh.js} +1 -1
  135. package/dist/frontend/assets/{xychartDiagram-e933f94c-DgmjX1Cq.js → xychartDiagram-e933f94c-j14yGvbF.js} +1 -1
  136. package/dist/frontend/assets/{zh-CN-LNUGB5OW-C3MR_Iw2.js → zh-CN-LNUGB5OW-DtwvmVxH.js} +1 -1
  137. package/dist/frontend/assets/{zh-HK-E62DVLB3-Bp3C_l-n.js → zh-HK-E62DVLB3-ChF_vupN.js} +1 -1
  138. package/dist/frontend/assets/{zh-TW-RAJ6MFWO-DiA5M5RC.js → zh-TW-RAJ6MFWO-CU8L3VT-.js} +1 -1
  139. package/dist/frontend/assets/{zipObject-BXMcibol.js → zipObject-BENW7yI5.js} +1 -1
  140. package/dist/frontend/index.html +1 -1
  141. package/dist/mcp.d.ts.map +1 -1
  142. package/dist/mcp.js +30 -4
  143. package/dist/mcp.js.map +1 -1
  144. package/package.json +2 -1
  145. package/worker/README.md +69 -0
  146. package/worker/src/index.js +170 -0
  147. package/worker/src/mcp.js +607 -0
  148. package/worker/src/relay.js +130 -0
  149. package/worker/wrangler.toml +20 -0
  150. package/dist/frontend/assets/channel-FdQr2Y2u.js +0 -1
  151. package/dist/frontend/assets/clone-Bww_2hMC.js +0 -1
  152. package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-BrPX1C0T.js +0 -1
  153. package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-DcmJmCpH.js +0 -1
  154. package/dist/frontend/assets/index-DPE4WCka.js +0 -349
@@ -0,0 +1,607 @@
1
+ // Stateless MCP route (spec 2026-07-28) — single universal endpoint.
2
+ // POST /mcp with JSON-RPC body. No initialize handshake, no session ids.
3
+ // Every canvas tool takes an explicit `repo` arg (owner/name); the caller's
4
+ // GitHub token arrives per request as `Authorization: Bearer ...`.
5
+ // Pure-GitHub tools run here directly; viewer-live tools (screenshot,
6
+ // viewport, export, mermaid) fan out to the per-repo RelayRoom DO.
7
+
8
+ const MCP_VERSION = '2026-07-28';
9
+ const SERVER_NAME = 'excalidrop';
10
+ const SERVER_VERSION = '2.0.0';
11
+ const SCENE_PATH = 'canvas.excalidraw';
12
+ const CANVAS_BRANCH = 'excalidrop';
13
+ const GH_API = 'https://api.github.com';
14
+ const TOOL_LIST_TTL_MS = 3600000;
15
+
16
+ // --- base64 helpers (Workers have no Node Buffer)
17
+ function b64encode(str) {
18
+ const bytes = new TextEncoder().encode(str);
19
+ let bin = '';
20
+ const CH = 0x8000;
21
+ for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode(...bytes.subarray(i, i + CH));
22
+ return btoa(bin);
23
+ }
24
+ function b64decode(b64) {
25
+ const bin = atob(String(b64).replace(/\n/g, ''));
26
+ return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
27
+ }
28
+
29
+ // --- best-effort quotas (per isolate; resets on evict — honest 429 either way)
30
+ const DAY = () => new Date().toISOString().slice(0, 10);
31
+ let dailyCount = 0;
32
+ let dailyDay = DAY();
33
+ const DAILY_BUDGET = 90000; // below the 100k plan cap so headroom remains for /relay + /exchange
34
+ const rate = new Map(); // key -> {count, reset}
35
+ function rateOk(key, limit, windowMs) {
36
+ const now = Date.now();
37
+ const cur = rate.get(key);
38
+ if (!cur || now > cur.reset) { rate.set(key, { count: 1, reset: now + windowMs }); return true; }
39
+ cur.count += 1;
40
+ return cur.count <= limit;
41
+ }
42
+ function quotaError() {
43
+ return {
44
+ error: 'shared relay daily budget exhausted (resets 00:00 UTC). ' +
45
+ 'Re-run `npx excalidrop setup --relay self` to deploy a relay into your own Cloudflare account (free 100k/day) and use its /mcp URL instead.',
46
+ quota: 'daily', reset: '00:00 UTC',
47
+ };
48
+ }
49
+
50
+ // --- per-token default repo (switch_remote). In-memory per isolate, 24h TTL.
51
+ const defaults = new Map(); // tokenHash -> {repo, at}
52
+ function hashToken(t) {
53
+ let h = 0x811c9dc5;
54
+ for (let i = 0; i < t.length; i++) { h ^= t.charCodeAt(i); h = Math.imul(h, 0x01000193); }
55
+ return (h >>> 0).toString(36);
56
+ }
57
+ function defaultRepo(token) {
58
+ const d = defaults.get(hashToken(token));
59
+ if (!d || Date.now() - d.at > 86400000) return null;
60
+ return d.repo;
61
+ }
62
+
63
+ // --- auth cache: pull-only 10min, push 60s (mirrors relay.js)
64
+ const authCache = new Map();
65
+ async function verifyAccess(token, repo, needPush) {
66
+ const key = `${hashToken(token)}:${repo.toLowerCase()}`;
67
+ const cached = authCache.get(key);
68
+ if (cached) {
69
+ const ttl = cached.push ? 60000 : 600000;
70
+ if (Date.now() - cached.at < ttl) {
71
+ if (needPush && !cached.push) return { ok: false, status: 403 };
72
+ if (!cached.pull) return { ok: false, status: 403 };
73
+ return { ok: true, login: cached.login };
74
+ }
75
+ }
76
+ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'excalidrop-mcp' };
77
+ const r = await fetch(`${GH_API}/repos/${repo}`, { headers }).catch(() => null);
78
+ if (!r || r.status === 404) return { ok: false, status: 404 };
79
+ if (r.status === 401) return { ok: false, status: 403 };
80
+ if (!r.ok) return { ok: false, status: 403 };
81
+ const j = await r.json().catch(() => ({}));
82
+ const pull = !!(j.permissions?.pull ?? true);
83
+ const push = !!j.permissions?.push;
84
+ if (!pull || (needPush && !push)) return { ok: false, status: 403 };
85
+ const me = await fetch(`${GH_API}/user`, { headers }).then((x) => x.json()).catch(() => ({}));
86
+ authCache.set(key, { at: Date.now(), login: me.login || '?', pull, push });
87
+ return { ok: true, login: me.login || '?' };
88
+ }
89
+
90
+ // --- GitHub scene IO (stateless: read-modify-write per call, sha-guarded)
91
+ function ghHeaders(token) {
92
+ return { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'excalidrop-mcp', 'Content-Type': 'application/json' };
93
+ }
94
+ async function getScene(repo, token) {
95
+ const r = await fetch(`${GH_API}/repos/${repo}/contents/${SCENE_PATH}?ref=${CANVAS_BRANCH}`, { headers: ghHeaders(token) });
96
+ if (r.status === 404) return { doc: null, sha: null };
97
+ if (!r.ok) throw new Error(`scene read ${r.status}: ${(await r.text()).slice(0, 150)}`);
98
+ const j = await r.json();
99
+ let b64 = typeof j.content === 'string' && j.content.length ? j.content : null;
100
+ if (!b64) {
101
+ const blob = await fetch(`${GH_API}/repos/${repo}/git/blobs/${j.sha}`, { headers: ghHeaders(token) });
102
+ if (!blob.ok) throw new Error(`scene blob ${blob.status}`);
103
+ b64 = (await blob.json()).content || null;
104
+ }
105
+ if (!b64) throw new Error('scene returned no content');
106
+ return { doc: JSON.parse(b64decode(b64)), sha: j.sha };
107
+ }
108
+ async function putScene(repo, token, doc, message, sha) {
109
+ const body = { message, content: b64encode(JSON.stringify(doc, null, 2)), branch: CANVAS_BRANCH };
110
+ if (sha) body.sha = sha;
111
+ const r = await fetch(`${GH_API}/repos/${repo}/contents/${SCENE_PATH}`, { method: 'PUT', headers: ghHeaders(token), body: JSON.stringify(body) });
112
+ if (r.status === 404) throw new Error(`branch ${CANVAS_BRANCH} missing — open the canvas viewer once to initialize it`);
113
+ if (r.status === 409 || r.status === 422) return { conflict: true, status: r.status };
114
+ if (!r.ok) throw new Error(`scene write ${r.status}: ${(await r.text()).slice(0, 150)}`);
115
+ return { sha: (await r.json()).content.sha };
116
+ }
117
+ function sceneDoc(elements, files) {
118
+ return {
119
+ type: 'excalidraw', version: 2, source: 'excalidrop',
120
+ elements: elements || [],
121
+ ...(files?.length ? { files } : {}),
122
+ };
123
+ }
124
+ // Read → mutate → write with one conflict retry (union-merge fresh elements).
125
+ async function mutateScene(repo, token, message, fn) {
126
+ for (let attempt = 0; attempt < 2; attempt++) {
127
+ const { doc, sha } = await getScene(repo, token);
128
+ const elements = [...(doc?.elements || [])];
129
+ const filesMap = new Map();
130
+ const rawFiles = doc?.files;
131
+ for (const f of (Array.isArray(rawFiles) ? rawFiles : (rawFiles ? [rawFiles] : []))) if (f?.id) filesMap.set(f.id, f);
132
+ const out = fn(elements, filesMap) || {};
133
+ const res = await putScene(repo, token, sceneDoc(elements, [...filesMap.values()]), message || out.message || `excalidrop: update ${elements.length} elements`, sha || undefined);
134
+ if (!res.conflict) return { elements, count: elements.length, sha: res.sha, extra: out };
135
+ // conflict: merge upstream newcomers, retry once
136
+ const fresh = await getScene(repo, token).catch(() => null);
137
+ if (fresh?.doc?.elements) {
138
+ const ids = new Set(elements.map((e) => e.id));
139
+ for (const el of fresh.doc.elements) if (el?.id && !ids.has(el.id)) elements.push(el);
140
+ }
141
+ }
142
+ throw new Error('scene conflict: retry the call');
143
+ }
144
+
145
+ // --- element helpers (mirrors mcp.ts buildElement)
146
+ function generateId() {
147
+ return Date.now().toString(36) + Math.random().toString(36).substring(2);
148
+ }
149
+ function normalizeFontFamily(ff) {
150
+ if (ff === undefined) return undefined;
151
+ if (typeof ff === 'number') return ff;
152
+ const map = {
153
+ virgil: 1, hand: 1, handwritten: 1, helvetica: 2, sans: 2, 'sans-serif': 2,
154
+ cascadia: 3, mono: 3, monospace: 3, excalifont: 5, nunito: 6,
155
+ lilita: 7, 'lilita one': 7, 'comic shanns': 8, comic: 8,
156
+ 1: 1, 2: 2, 3: 3, 5: 5, 6: 6, 7: 7, 8: 8,
157
+ };
158
+ return map[String(ff).toLowerCase()];
159
+ }
160
+ function toLabel(el) {
161
+ const { text, ...rest } = el;
162
+ if (text && el.type !== 'text') return { ...rest, label: { text } };
163
+ return el;
164
+ }
165
+ function buildElement(d) {
166
+ const { startElementId, endElementId, id: customId, points, ...rest } = d;
167
+ const normPoints = points ? points.map((p) => (Array.isArray(p) ? p : [p.x, p.y])) : undefined;
168
+ const now = new Date().toISOString();
169
+ const el = {
170
+ id: customId || generateId(), ...rest,
171
+ ...(normPoints ? { points: normPoints } : {}),
172
+ ...(startElementId ? { start: { id: startElementId } } : {}),
173
+ ...(endElementId ? { end: { id: endElementId } } : {}),
174
+ createdAt: now, updatedAt: now, version: 1,
175
+ };
176
+ if (el.fontFamily !== undefined) el.fontFamily = normalizeFontFamily(el.fontFamily);
177
+ if ((startElementId || endElementId) && !normPoints) el.points = [[0, 0], [100, 0]];
178
+ return toLabel(el);
179
+ }
180
+ function parseRepo(input) {
181
+ const s = String(input || '').trim().replace(/\/$/, '');
182
+ const m = s.match(/^https?:\/\/([a-z0-9-]+)\.github\.io\/([a-z0-9_.-]+)/i);
183
+ if (m) return `${m[1]}/${m[2]}`.toLowerCase();
184
+ if (/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(s)) return s.toLowerCase();
185
+ throw new Error(`Cannot parse repo from "${input}" — use owner/repo`);
186
+ }
187
+ function needRepo(args, token) {
188
+ const raw = args?.repo || defaultRepo(token);
189
+ if (!raw) throw new Error('no repo: pass { repo: "owner/name" } or call switch_remote first');
190
+ return parseRepo(raw);
191
+ }
192
+
193
+ // --- viewer-live fan-out via the per-repo DO (same room viewers dial)
194
+ async function viaDo(env, repo, token, method, args) {
195
+ if (!env.RELAY) throw new Error('relay not configured on this deployment');
196
+ const id = env.RELAY.idFromName(`repo:${repo.toLowerCase()}`);
197
+ const stub = env.RELAY.get(id);
198
+ const r = await stub.fetch(new Request(`https://internal/rpc?repo=${encodeURIComponent(repo.toLowerCase())}`, {
199
+ method: 'POST',
200
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ method, args: args || {} }),
202
+ }));
203
+ const j = await r.json().catch(() => null);
204
+ if (r.status === 503) throw new Error(`No viewer connected for ${repo} — open the canvas URL in a browser first.`);
205
+ if (!r.ok) throw new Error(j?.error || `viewer rpc ${r.status}`);
206
+ return j;
207
+ }
208
+
209
+ // --- tool catalog (schemas mirror mcp.ts stdio tools + required repo)
210
+ const TYPES = ['rectangle', 'ellipse', 'diamond', 'arrow', 'text', 'line', 'freedraw', 'image'];
211
+ const EL_PROPS = {
212
+ id: { type: 'string' }, type: { type: 'string', enum: TYPES },
213
+ x: { type: 'number' }, y: { type: 'number' },
214
+ width: { type: 'number' }, height: { type: 'number' },
215
+ backgroundColor: { type: 'string' }, strokeColor: { type: 'string' },
216
+ strokeWidth: { type: 'number' }, strokeStyle: { type: 'string' },
217
+ roughness: { type: 'number' }, opacity: { type: 'number' },
218
+ text: { type: 'string' }, fontSize: { type: 'number' },
219
+ fontFamily: { type: ['string', 'number'] },
220
+ startElementId: { type: 'string' }, endElementId: { type: 'string' },
221
+ endArrowhead: { type: 'string' }, startArrowhead: { type: 'string' },
222
+ };
223
+ const REPO_PROP = { repo: { type: 'string', description: 'Canvas repo as owner/name. Omit only after switch_remote.' } };
224
+ const TOOLS = [
225
+ { name: 'create_element', description: 'Create a new element on the canvas. For arrows, use startElementId/endElementId to bind to shapes.', inputSchema: { type: 'object', properties: { ...REPO_PROP, ...EL_PROPS }, required: ['type', 'x', 'y'] } },
226
+ { name: 'batch_create_elements', description: 'Create multiple elements at once. Assign custom id to shapes so arrows can reference them via startElementId/endElementId.', inputSchema: { type: 'object', properties: { ...REPO_PROP, elements: { type: 'array', items: { type: 'object', properties: EL_PROPS, required: ['type', 'x', 'y'] } } }, required: ['elements'] } },
227
+ { name: 'update_element', description: 'Update an existing element', inputSchema: { type: 'object', properties: { ...REPO_PROP, ...EL_PROPS }, required: ['id'] } },
228
+ { name: 'delete_element', description: 'Delete an element', inputSchema: { type: 'object', properties: { ...REPO_PROP, id: { type: 'string' } }, required: ['id'] } },
229
+ { name: 'get_element', description: 'Get a single element by ID', inputSchema: { type: 'object', properties: { ...REPO_PROP, id: { type: 'string' } }, required: ['id'] } },
230
+ { name: 'query_elements', description: 'Query elements with optional filters', inputSchema: { type: 'object', properties: { ...REPO_PROP, type: { type: 'string', enum: TYPES }, filter: { type: 'object', additionalProperties: true }, bbox: { type: 'object', properties: { x_min: { type: 'number' }, x_max: { type: 'number' }, y_min: { type: 'number' }, y_max: { type: 'number' } } } } } },
231
+ { name: 'describe_scene', description: 'AI-readable description of the canvas: types, positions, connections, layout, bounding box.', inputSchema: { type: 'object', properties: { ...REPO_PROP } } },
232
+ { name: 'export_scene', description: 'Export the canvas to .excalidraw JSON.', inputSchema: { type: 'object', properties: { ...REPO_PROP } } },
233
+ { name: 'import_scene', description: 'Import elements from raw .excalidraw JSON data (replace or merge).', inputSchema: { type: 'object', properties: { ...REPO_PROP, data: { type: 'string' }, mode: { type: 'string', enum: ['replace', 'merge'] } }, required: ['data', 'mode'] } },
234
+ { name: 'clear_canvas', description: 'Clear all elements (commits to GitHub).', inputSchema: { type: 'object', properties: { ...REPO_PROP } } },
235
+ { name: 'group_elements', description: 'Group multiple elements together', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } } }, required: ['elementIds'] } },
236
+ { name: 'ungroup_elements', description: 'Ungroup a group of elements', inputSchema: { type: 'object', properties: { ...REPO_PROP, groupId: { type: 'string' } }, required: ['groupId'] } },
237
+ { name: 'align_elements', description: 'Align elements to a specific position', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } }, alignment: { type: 'string', enum: ['left', 'center', 'right', 'top', 'middle', 'bottom'] } }, required: ['elementIds', 'alignment'] } },
238
+ { name: 'distribute_elements', description: 'Distribute elements evenly', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } }, direction: { type: 'string', enum: ['horizontal', 'vertical'] } }, required: ['elementIds', 'direction'] } },
239
+ { name: 'lock_elements', description: 'Lock elements to prevent modification', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } } }, required: ['elementIds'] } },
240
+ { name: 'unlock_elements', description: 'Unlock elements to allow modification', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } } }, required: ['elementIds'] } },
241
+ { name: 'duplicate_elements', description: 'Duplicate elements with a configurable offset', inputSchema: { type: 'object', properties: { ...REPO_PROP, elementIds: { type: 'array', items: { type: 'string' } }, offsetX: { type: 'number' }, offsetY: { type: 'number' } }, required: ['elementIds'] } },
242
+ { name: 'add_image', description: 'Add an image from a dataURL (data:...;base64,...).', inputSchema: { type: 'object', properties: { ...REPO_PROP, dataURL: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' } }, required: ['dataURL'] } },
243
+ { name: 'snapshot_scene', description: 'Save a named snapshot (stored on the excalidrop branch under snapshots/)', inputSchema: { type: 'object', properties: { ...REPO_PROP, name: { type: 'string' } }, required: ['name'] } },
244
+ { name: 'restore_snapshot', description: 'Restore the canvas from a named snapshot', inputSchema: { type: 'object', properties: { ...REPO_PROP, name: { type: 'string' } }, required: ['name'] } },
245
+ { name: 'commit_scene', description: 'Remote commits every call immediately; this returns the current element count (no-op for compatibility).', inputSchema: { type: 'object', properties: { ...REPO_PROP } } },
246
+ { name: 'switch_remote', description: 'Set the default repo for later calls without an explicit repo arg (per-token, 24h).', inputSchema: { type: 'object', properties: { target: { type: 'string' } } } },
247
+ { name: 'current_canvas', description: 'Show the default repo for this token, if set.', inputSchema: { type: 'object', properties: {} } },
248
+ { name: 'list_canvases', description: 'List repos you can access (candidates for canvases).', inputSchema: { type: 'object', properties: {} } },
249
+ { name: 'read_diagram_guide', description: 'Design guide for beautiful diagrams: colors, sizing, layout, arrows, anti-patterns.', inputSchema: { type: 'object', properties: {} } },
250
+ { name: 'get_canvas_screenshot', description: 'Screenshot via a connected viewer (viewer tab must be open).', inputSchema: { type: 'object', properties: { ...REPO_PROP, background: { type: 'boolean' } } } },
251
+ { name: 'export_to_image', description: 'Export to PNG/SVG via a connected viewer (viewer tab must be open).', inputSchema: { type: 'object', properties: { ...REPO_PROP, format: { type: 'string', enum: ['png', 'svg'] }, background: { type: 'boolean' } }, required: ['format'] } },
252
+ { name: 'set_viewport', description: 'Control the viewer viewport via a connected viewer.', inputSchema: { type: 'object', properties: { ...REPO_PROP, scrollToContent: { type: 'boolean' }, scrollToElementId: { type: 'string' }, zoom: { type: 'number' }, offsetX: { type: 'number' }, offsetY: { type: 'number' } } } },
253
+ { name: 'create_from_mermaid', description: 'Convert a Mermaid diagram to elements via a connected viewer, then commit them.', inputSchema: { type: 'object', properties: { ...REPO_PROP, mermaidDiagram: { type: 'string' } }, required: ['mermaidDiagram'] } },
254
+ { name: 'export_to_excalidraw_url', description: 'Export to a shareable excalidraw.com URL (not supported on remote — use export_scene).', inputSchema: { type: 'object', properties: { ...REPO_PROP } } },
255
+ { name: 'github_login', description: 'Remote uses per-request Bearer tokens (OAuth via your chat client). No device flow here — connect with a GitHub token.', inputSchema: { type: 'object', properties: {} } },
256
+ ];
257
+
258
+ const DIAGRAM_GUIDE = `# Excalidraw Diagram Design Guide
259
+ ## Stroke Colors — Black #1e1e1e (default), Red #e03131 (errors), Green #2f9e44 (success), Blue #1971c2 (primary), Purple #9c36b5 (services), Orange #e8590c (async), Cyan #0c8599 (data), Gray #868e96 (annotations)
260
+ ## Fills (backgroundColor) — #ffc9c9/#b2f2bb/#a5d8ff/#eebefa/#ffd8a8/#99e9f2/#e9ecef/#ffffff paired with matching strokes
261
+ ## Rules — shapes >= 120x60, fonts >= 16 (titles >= 20), 40-80px gaps, 20px grid, bind arrows via startElementId/endElementId, dashed = async, max 3-4 fill colors, every shape labeled.
262
+ `;
263
+
264
+ function textResult(text) {
265
+ return { content: [{ type: 'text', text }] };
266
+ }
267
+ function byId(elements, id) {
268
+ return elements.find((e) => e.id === id) || null;
269
+ }
270
+
271
+ async function callTool(env, name, args, token) {
272
+ const a = args || {};
273
+ switch (name) {
274
+ case 'read_diagram_guide': return textResult(DIAGRAM_GUIDE);
275
+ case 'github_login':
276
+ return textResult('Remote MCP authenticates per request: connect this endpoint in your chat client with GitHub OAuth (or send Authorization: Bearer <token> with repo scope). No device flow here.');
277
+ case 'export_to_excalidraw_url':
278
+ throw new Error('not supported on remote — use export_scene and share the JSON');
279
+ case 'switch_remote': {
280
+ if (!a.target) throw new Error('pass { target: "owner/repo" }');
281
+ const repo = parseRepo(a.target);
282
+ const v = await verifyAccess(token, repo, false);
283
+ if (!v.ok) throw new Error(v.status === 404 ? `repo ${repo} not found` : `no access to ${repo} with this token`);
284
+ defaults.set(hashToken(token), { repo, at: Date.now() });
285
+ return textResult(`Default canvas set to ${repo}. Later calls may omit repo.`);
286
+ }
287
+ case 'current_canvas': {
288
+ const d = defaultRepo(token);
289
+ return textResult(d ? `remote:${d}` : 'no default canvas — pass { repo: "owner/repo" } or call switch_remote');
290
+ }
291
+ case 'list_canvases': {
292
+ const r = await fetch(`${GH_API}/user/repos?per_page=100&affiliation=owner,collaborator&sort=pushed`, { headers: ghHeaders(token) });
293
+ if (!r.ok) throw new Error(`github ${r.status}: cannot list repos with this token`);
294
+ const list = await r.json();
295
+ const names = list.map((x) => `${x.full_name}${x.private ? ' (private)' : ''}`);
296
+ return textResult(`Repos you can access (any may hold a canvas on branch ${CANVAS_BRANCH}):\n${names.join('\n') || '(none)'}`);
297
+ }
298
+ }
299
+
300
+ // --- all remaining tools need a repo + read access; mutations need push
301
+ const MUTATING = new Set(['create_element', 'batch_create_elements', 'update_element', 'delete_element', 'clear_canvas', 'import_scene', 'group_elements', 'ungroup_elements', 'align_elements', 'distribute_elements', 'lock_elements', 'unlock_elements', 'duplicate_elements', 'add_image', 'restore_snapshot', 'snapshot_scene', 'create_from_mermaid']);
302
+ const repo = needRepo(a, token);
303
+ const v = await verifyAccess(token, repo, MUTATING.has(name));
304
+ if (!v.ok) throw new Error(v.status === 404 ? `repo ${repo} not found` : `no access to ${repo} with this token`);
305
+
306
+ switch (name) {
307
+ case 'describe_scene':
308
+ case 'query_elements':
309
+ case 'get_element':
310
+ case 'export_scene':
311
+ case 'commit_scene': {
312
+ const { doc } = await getScene(repo, token);
313
+ const elements = doc?.elements || [];
314
+ if (name === 'commit_scene') return textResult(`Committed (remote auto-commits every call): ${elements.length} elements on ${repo}.`);
315
+ if (name === 'get_element') {
316
+ if (!a.id) throw new Error('id is required');
317
+ const el = byId(elements, a.id);
318
+ if (!el) throw new Error(`Element ${a.id} not found`);
319
+ return textResult(JSON.stringify(el, null, 2));
320
+ }
321
+ if (name === 'export_scene') {
322
+ return textResult(JSON.stringify({ type: 'excalidraw', version: 2, source: 'excalidrop', elements, appState: { viewBackgroundColor: '#ffffff', gridSize: null } }, null, 2));
323
+ }
324
+ if (name === 'query_elements') {
325
+ let out = elements;
326
+ if (a.type) out = out.filter((e) => e.type === a.type);
327
+ if (a.bbox) {
328
+ const b = a.bbox;
329
+ out = out.filter((el) =>
330
+ (b.x_min === undefined || el.x >= b.x_min) && (b.x_max === undefined || el.x <= b.x_max) &&
331
+ (b.y_min === undefined || el.y >= b.y_min) && (b.y_max === undefined || el.y <= b.y_max));
332
+ }
333
+ if (a.filter) out = out.filter((el) => Object.entries(a.filter).every(([k, val]) => el[k] === val));
334
+ return textResult(JSON.stringify(out, null, 2));
335
+ }
336
+ // describe_scene
337
+ const byType = {};
338
+ for (const e of elements) byType[e.type] = (byType[e.type] || 0) + 1;
339
+ const conns = elements.filter((e) => e.type === 'arrow' && (e.start || e.end)).length;
340
+ const xs = elements.map((e) => e.x), ys = elements.map((e) => e.y);
341
+ return textResult(`Canvas ${repo}: ${elements.length} elements (${Object.entries(byType).map(([t, n]) => `${n}x ${t}`).join(', ') || 'empty'}), ${conns} bound arrows.` +
342
+ (elements.length ? ` bbox x[${Math.min(...xs)}..${Math.max(...xs)}] y[${Math.min(...ys)}..${Math.max(...ys)}].` : '') +
343
+ `\n${JSON.stringify(elements.slice(0, 50), null, 2)}${elements.length > 50 ? `\n… +${elements.length - 50} more (use query_elements)` : ''}`);
344
+ }
345
+
346
+ case 'create_element': {
347
+ if (!a.type || a.x === undefined || a.y === undefined) throw new Error('type, x, y are required');
348
+ const el = buildElement(a);
349
+ const { sha } = await mutateScene(repo, token, `excalidrop: add ${el.type} ${el.id}`, (elements) => { elements.push(el); });
350
+ return textResult(`Element created on ${repo} (${sha.slice(0, 7)}):\n${JSON.stringify(el, null, 2)}`);
351
+ }
352
+ case 'batch_create_elements': {
353
+ if (!Array.isArray(a.elements) || !a.elements.length) throw new Error('elements[] is required');
354
+ const els = a.elements.map(buildElement);
355
+ const { sha } = await mutateScene(repo, token, `excalidrop: batch add ${els.length} elements`, (elements) => { for (const e of els) elements.push(e); });
356
+ return textResult(`${els.length} elements created on ${repo} (${sha.slice(0, 7)}):\n${JSON.stringify(els, null, 2)}`);
357
+ }
358
+ case 'update_element': {
359
+ if (!a.id) throw new Error('id is required');
360
+ const { id, repo: _r, points, ...updates } = a;
361
+ let updated = null;
362
+ await mutateScene(repo, token, `excalidrop: update ${id}`, (elements) => {
363
+ const i = elements.findIndex((e) => e.id === id);
364
+ if (i === -1) throw new Error(`Element ${id} not found`);
365
+ updated = toLabel({ ...elements[i], ...updates, ...(points ? { points: points.map((p) => (Array.isArray(p) ? p : [p.x, p.y])) } : {}), updatedAt: new Date().toISOString() });
366
+ if (updated.fontFamily !== undefined) updated.fontFamily = normalizeFontFamily(updated.fontFamily);
367
+ elements[i] = updated;
368
+ });
369
+ return textResult(`Element updated on ${repo}:\n${JSON.stringify(updated, null, 2)}`);
370
+ }
371
+ case 'delete_element': {
372
+ if (!a.id) throw new Error('id is required');
373
+ await mutateScene(repo, token, `excalidrop: delete ${a.id}`, (elements) => {
374
+ const i = elements.findIndex((e) => e.id === a.id);
375
+ if (i === -1) throw new Error(`Element ${a.id} not found`);
376
+ elements.splice(i, 1);
377
+ });
378
+ return textResult(`Element ${a.id} deleted from ${repo}.`);
379
+ }
380
+ case 'clear_canvas': {
381
+ const { extra } = await mutateScene(repo, token, 'excalidrop: clear canvas', (elements) => { const n = elements.length; elements.length = 0; return { message: `excalidrop: clear ${n} elements`, n }; });
382
+ return textResult(`Canvas cleared (${extra.n} elements removed from ${repo}).`);
383
+ }
384
+ case 'import_scene': {
385
+ if (!a.data) throw new Error('data (raw .excalidraw JSON) is required');
386
+ const incomingDoc = JSON.parse(a.data);
387
+ const incoming = (Array.isArray(incomingDoc) ? incomingDoc : incomingDoc.elements || []).map((el) => ({ ...el, id: el.id || generateId() }));
388
+ if (!incoming.length) throw new Error('No elements found in the import data');
389
+ await mutateScene(repo, token, `excalidrop: import ${incoming.length} (${a.mode})`, (elements, files) => {
390
+ if (a.mode === 'replace') elements.length = 0;
391
+ if (incomingDoc.files && typeof incomingDoc.files === 'object') {
392
+ for (const f of Object.values(incomingDoc.files)) if (f?.id) files.set(f.id, f);
393
+ }
394
+ for (const e of incoming) elements.push(e);
395
+ });
396
+ return textResult(`Imported ${incoming.length} elements into ${repo} (mode: ${a.mode}).`);
397
+ }
398
+ case 'add_image': {
399
+ if (!a.dataURL || !a.dataURL.startsWith('data:')) throw new Error('add_image requires a dataURL (data:...;base64,...)');
400
+ const mm = /^data:([^;]+);base64,(.+)$/s.exec(a.dataURL);
401
+ if (!mm) throw new Error('Invalid image data');
402
+ if (mm[2].length > 14 * 1024 * 1024) throw new Error('Image too large (max ~10MB)');
403
+ const fileId = generateId();
404
+ const elId = generateId();
405
+ const now = new Date().toISOString();
406
+ await mutateScene(repo, token, `excalidrop: add image ${fileId}`, (elements, files) => {
407
+ files.set(fileId, { id: fileId, dataURL: a.dataURL, mimeType: mm[1], created: Date.now() });
408
+ elements.push({ id: elId, type: 'image', x: a.x ?? 0, y: a.y ?? 0, width: a.width ?? 400, height: a.height ?? 300, fileId, status: 'saved', scale: [1, 1], createdAt: now, updatedAt: now, version: 1 });
409
+ });
410
+ return textResult(`Image added to ${repo} (fileId ${fileId}, element ${elId}).`);
411
+ }
412
+ case 'group_elements': {
413
+ if (!a.elementIds?.length) throw new Error('elementIds[] is required');
414
+ const groupId = generateId();
415
+ let n = 0;
416
+ await mutateScene(repo, token, `excalidrop: group ${a.elementIds.length}`, (elements) => {
417
+ for (const id of a.elementIds) { const el = byId(elements, id); if (el) { el.groupIds = [...(el.groupIds || []), groupId]; n++; } }
418
+ if (!n) throw new Error('No elements grouped (ids not found)');
419
+ });
420
+ return textResult(JSON.stringify({ groupId, elementIds: a.elementIds, successCount: n }, null, 2));
421
+ }
422
+ case 'ungroup_elements': {
423
+ if (!a.groupId) throw new Error('groupId is required');
424
+ let n = 0;
425
+ await mutateScene(repo, token, `excalidrop: ungroup ${a.groupId}`, (elements) => {
426
+ for (const el of elements.filter((e) => (e.groupIds || []).includes(a.groupId))) { el.groupIds = el.groupIds.filter((g) => g !== a.groupId); n++; }
427
+ if (!n) throw new Error(`Group ${a.groupId} not found`);
428
+ });
429
+ return textResult(JSON.stringify({ groupId: a.groupId, ungrouped: true, successCount: n }, null, 2));
430
+ }
431
+ case 'align_elements': {
432
+ const { elementIds, alignment } = a;
433
+ if (!elementIds || elementIds.length < 2) throw new Error('Need at least 2 elements to align');
434
+ await mutateScene(repo, token, `excalidrop: align ${alignment}`, (elements) => {
435
+ const els = elementIds.map((id) => byId(elements, id)).filter(Boolean);
436
+ if (els.length < 2) throw new Error('Need at least 2 elements to align (ids not found)');
437
+ let fn;
438
+ if (alignment === 'left') { const v = Math.min(...els.map((e) => e.x)); fn = () => ({ x: v }); }
439
+ else if (alignment === 'right') { const v = Math.max(...els.map((e) => e.x + (e.width || 0))); fn = (e) => ({ x: v - (e.width || 0) }); }
440
+ else if (alignment === 'center') { const m = els.map((e) => e.x + (e.width || 0) / 2).reduce((x, y) => x + y, 0) / els.length; fn = (e) => ({ x: m - (e.width || 0) / 2 }); }
441
+ else if (alignment === 'top') { const v = Math.min(...els.map((e) => e.y)); fn = () => ({ y: v }); }
442
+ else if (alignment === 'bottom') { const v = Math.max(...els.map((e) => e.y + (e.height || 0))); fn = (e) => ({ y: v - (e.height || 0) }); }
443
+ else if (alignment === 'middle') { const m = els.map((e) => e.y + (e.height || 0) / 2).reduce((x, y) => x + y, 0) / els.length; fn = (e) => ({ y: m - (e.height || 0) / 2 }); }
444
+ else throw new Error(`unknown alignment ${alignment}`);
445
+ for (const el of els) Object.assign(el, fn(el));
446
+ });
447
+ return textResult(JSON.stringify({ aligned: true, elementIds, alignment }, null, 2));
448
+ }
449
+ case 'distribute_elements': {
450
+ const { elementIds, direction } = a;
451
+ if (!elementIds || elementIds.length < 3) throw new Error('Need at least 3 elements to distribute');
452
+ await mutateScene(repo, token, `excalidrop: distribute ${direction}`, (elements) => {
453
+ const els = elementIds.map((id) => byId(elements, id)).filter(Boolean);
454
+ if (els.length < 3) throw new Error('Need at least 3 elements to distribute (ids not found)');
455
+ if (direction === 'horizontal') {
456
+ els.sort((x, y) => x.x - y.x);
457
+ const gap = (els[els.length - 1].x + (els[els.length - 1].width || 0) - els[0].x - els.reduce((s, e) => s + (e.width || 0), 0)) / (els.length - 1);
458
+ let x = els[0].x;
459
+ for (const el of els) { el.x = x; x += (el.width || 0) + gap; }
460
+ } else if (direction === 'vertical') {
461
+ els.sort((x, y) => x.y - y.y);
462
+ const gap = (els[els.length - 1].y + (els[els.length - 1].height || 0) - els[0].y - els.reduce((s, e) => s + (e.height || 0), 0)) / (els.length - 1);
463
+ let y = els[0].y;
464
+ for (const el of els) { el.y = y; y += (el.height || 0) + gap; }
465
+ } else throw new Error(`unknown direction ${direction}`);
466
+ });
467
+ return textResult(JSON.stringify({ distributed: true, elementIds, direction }, null, 2));
468
+ }
469
+ case 'lock_elements':
470
+ case 'unlock_elements': {
471
+ if (!a.elementIds?.length) throw new Error('elementIds[] is required');
472
+ const locked = name === 'lock_elements';
473
+ await mutateScene(repo, token, `excalidrop: ${locked ? 'lock' : 'unlock'} ${a.elementIds.length}`, (elements) => {
474
+ for (const id of a.elementIds) { const el = byId(elements, id); if (el) el.locked = locked; }
475
+ });
476
+ return textResult(JSON.stringify({ [locked ? 'locked' : 'unlocked']: true, elementIds: a.elementIds }, null, 2));
477
+ }
478
+ case 'duplicate_elements': {
479
+ if (!a.elementIds?.length) throw new Error('elementIds[] is required');
480
+ const dx = a.offsetX ?? 20, dy = a.offsetY ?? 20;
481
+ let ids = [];
482
+ await mutateScene(repo, token, `excalidrop: duplicate ${a.elementIds.length}`, (elements) => {
483
+ for (const id of a.elementIds) {
484
+ const el = byId(elements, id);
485
+ if (!el) continue;
486
+ const copy = { ...JSON.parse(JSON.stringify(el)), id: generateId(), x: el.x + dx, y: el.y + dy, groupIds: [], updatedAt: new Date().toISOString() };
487
+ elements.push(copy);
488
+ ids.push(copy.id);
489
+ }
490
+ });
491
+ return textResult(JSON.stringify({ duplicated: ids.length, newIds: ids }, null, 2));
492
+ }
493
+ case 'snapshot_scene': {
494
+ if (!a.name) throw new Error('name is required');
495
+ const { doc } = await getScene(repo, token);
496
+ const elements = doc?.elements || [];
497
+ const r = await fetch(`${GH_API}/repos/${repo}/contents/snapshots/${a.name}.json`, {
498
+ method: 'PUT', headers: ghHeaders(token),
499
+ body: JSON.stringify({ message: `excalidrop: snapshot ${a.name} (${elements.length} elements)`, content: b64encode(JSON.stringify({ name: a.name, elements, createdAt: new Date().toISOString() }, null, 2)), branch: CANVAS_BRANCH }),
500
+ });
501
+ if (!r.ok) throw new Error(`snapshot write ${r.status}: ${(await r.text()).slice(0, 150)}`);
502
+ return textResult(`Snapshot "${a.name}" saved (${elements.length} elements) on ${repo}.`);
503
+ }
504
+ case 'restore_snapshot': {
505
+ if (!a.name) throw new Error('name is required');
506
+ const r = await fetch(`${GH_API}/repos/${repo}/contents/snapshots/${a.name}.json?ref=${CANVAS_BRANCH}`, { headers: ghHeaders(token) });
507
+ if (r.status === 404) throw new Error(`Snapshot "${a.name}" not found`);
508
+ if (!r.ok) throw new Error(`snapshot read ${r.status}`);
509
+ const j = await r.json();
510
+ const snap = JSON.parse(b64decode(j.content));
511
+ const els = (snap.elements || []).map((e) => ({ ...e, id: e.id || generateId() }));
512
+ await mutateScene(repo, token, `excalidrop: restore ${a.name}`, (elements) => { elements.length = 0; for (const e of els) elements.push(e); });
513
+ return textResult(`Snapshot "${a.name}" restored (${els.length} elements) on ${repo}.`);
514
+ }
515
+
516
+ // --- viewer-live tools: fan out to the DO room for this repo
517
+ case 'get_canvas_screenshot': {
518
+ const out = await viaDo(env, repo, token, 'screenshot', { background: a.background ?? true, format: 'png' });
519
+ return { content: [{ type: 'text', text: `Screenshot of ${repo} (${out.format || 'png'}):` }, { type: 'image', data: out.data, mimeType: out.format === 'svg' ? 'image/svg+xml' : 'image/png' }] };
520
+ }
521
+ case 'export_to_image': {
522
+ if (!a.format) throw new Error('format is required');
523
+ const out = await viaDo(env, repo, token, 'export', { background: a.background ?? true, format: a.format });
524
+ return { content: [{ type: 'text', text: `Exported ${repo} as ${a.format}.` }, { type: 'image', data: out.data, mimeType: a.format === 'svg' ? 'image/svg+xml' : 'image/png' }] };
525
+ }
526
+ case 'set_viewport': {
527
+ const out = await viaDo(env, repo, token, 'viewport', a);
528
+ return textResult(`Viewport updated on ${repo}.\n${JSON.stringify(out, null, 2)}`);
529
+ }
530
+ case 'create_from_mermaid': {
531
+ if (!a.mermaidDiagram) throw new Error('mermaidDiagram is required');
532
+ const out = await viaDo(env, repo, token, 'mermaid', { mermaidDiagram: a.mermaidDiagram, config: a.config || {} });
533
+ const els = (out.elements || []).map((e) => ({ ...e, id: e.id || generateId() }));
534
+ if (!els.length) throw new Error('Viewer returned no elements for this diagram');
535
+ await mutateScene(repo, token, `excalidrop: mermaid import ${els.length}`, (elements) => { for (const e of els) elements.push(e); });
536
+ return textResult(`${els.length} elements created on ${repo} from Mermaid diagram.`);
537
+ }
538
+ default:
539
+ throw new Error(`unknown tool ${name}`);
540
+ }
541
+ }
542
+
543
+ function rpcError(id, code, message) {
544
+ return { jsonrpc: '2.0', id: id ?? null, error: { code, message: String(message).slice(0, 500) } };
545
+ }
546
+
547
+ export async function handleMcp(req, env, cors) {
548
+ const headers = { 'Content-Type': 'application/json', ...cors };
549
+ if (req.method === 'GET') {
550
+ return new Response(JSON.stringify({ name: SERVER_NAME, mcp: `POST this URL with JSON-RPC (spec ${MCP_VERSION}). Register https://<this-host>/mcp once in your chat client, then call tools with { repo: "owner/name" }.` }), { headers });
551
+ }
552
+ if (req.method !== 'POST') return new Response(JSON.stringify({ error: 'method not allowed (use POST)' }), { status: 405, headers });
553
+
554
+ // daily budget (best-effort per isolate)
555
+ const day = DAY();
556
+ if (day !== dailyDay) { dailyDay = day; dailyCount = 0; }
557
+ dailyCount += 1;
558
+ if (dailyCount > DAILY_BUDGET) {
559
+ return new Response(JSON.stringify(quotaError()), { status: 429, headers });
560
+ }
561
+
562
+ const ip = req.headers.get('cf-connecting-ip') || 'unknown';
563
+ const token = (req.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '');
564
+ if (!token) return new Response(JSON.stringify(rpcError(null, -32001, 'missing Bearer token — connect with GitHub OAuth (repo scope)')), { status: 401, headers });
565
+ if (!rateOk(`mcp:${hashToken(token)}`, 60, 60000) || !rateOk(`mcpip:${ip}`, 120, 60000)) {
566
+ return new Response(JSON.stringify(rpcError(null, -32002, 'rate limited (60/min per token). Retry shortly.')), { status: 429, headers });
567
+ }
568
+
569
+ let body;
570
+ try { body = await req.json(); } catch { return new Response(JSON.stringify(rpcError(null, -32700, 'bad json')), { status: 400, headers }); }
571
+ // Header-based routing per spec; fall back to the body for lenient clients.
572
+ const method = req.headers.get('Mcp-Method') || body.method;
573
+ const name = req.headers.get('Mcp-Name') || body.params?.name;
574
+ const id = body.id ?? null;
575
+
576
+ try {
577
+ if (method === 'server/discover' || (method === 'initialize')) {
578
+ return new Response(JSON.stringify({
579
+ jsonrpc: '2.0', id,
580
+ result: {
581
+ name: SERVER_NAME, version: SERVER_VERSION, protocolVersion: MCP_VERSION,
582
+ description: 'GitHub-backed Excalidraw canvas. One endpoint for every canvas: pass { repo: "owner/name" } per tool call.',
583
+ capabilities: { tools: {} },
584
+ },
585
+ }), { headers });
586
+ }
587
+ if (method === 'tools/list') {
588
+ return new Response(JSON.stringify({
589
+ jsonrpc: '2.0', id,
590
+ result: { tools: TOOLS, ttlMs: TOOL_LIST_TTL_MS, cacheScope: 'catalog' },
591
+ }), { headers });
592
+ }
593
+ if (method === 'tools/call') {
594
+ if (!name) return new Response(JSON.stringify(rpcError(id, -32602, 'tool name is required (params.name)')), { status: 400, headers });
595
+ if (!TOOLS.some((t) => t.name === name)) return new Response(JSON.stringify(rpcError(id, -32601, `unknown tool ${name}`)), { status: 404, headers });
596
+ const result = await callTool(env, name, body.params?.arguments || {}, token);
597
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { headers });
598
+ }
599
+ return new Response(JSON.stringify(rpcError(id, -32601, `unsupported method ${method} (use server/discover, tools/list, tools/call)`)), { status: 400, headers });
600
+ } catch (e) {
601
+ const msg = (e && e.message) || 'tool failed';
602
+ const noAccess = /no access|not found|missing Bearer|rate limited|budget exhausted/i.test(msg);
603
+ const noViewer = /no viewer connected|viewer timed out/i.test(msg);
604
+ const status = noAccess ? 403 : noViewer ? 503 : 500;
605
+ return new Response(JSON.stringify(rpcError(id, noAccess ? -32003 : -32603, msg)), { status, headers });
606
+ }
607
+ }