emdash 0.7.0 → 0.9.0

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 (354) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-DoNJiveC.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-BzltprvY.mjs} +90 -139
  4. package/dist/apply-BzltprvY.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +194 -17
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +34 -57
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +17 -12
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +9 -6
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +301 -165
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +34 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
  26. package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
  27. package/dist/{byline-C4OVd8b3.mjs → byline-BSaNL1w7.mjs} +5 -5
  28. package/dist/byline-BSaNL1w7.mjs.map +1 -0
  29. package/dist/bylines-CvJ3PYz2.mjs +113 -0
  30. package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
  31. package/dist/cache-C6N_hhN7.mjs +65 -0
  32. package/dist/cache-C6N_hhN7.mjs.map +1 -0
  33. package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
  34. package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
  35. package/dist/cli/index.mjs +229 -31
  36. package/dist/cli/index.mjs.map +1 -1
  37. package/dist/client/cf-access.d.mts +1 -1
  38. package/dist/client/index.d.mts +1 -1
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/client/index.mjs.map +1 -1
  41. package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
  42. package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
  43. package/dist/{content-D7J5y73J.mjs → content-8lOYF0pr.mjs} +43 -28
  44. package/dist/content-8lOYF0pr.mjs.map +1 -0
  45. package/dist/db/index.d.mts +3 -3
  46. package/dist/db/index.mjs +2 -2
  47. package/dist/db/libsql.d.mts +1 -1
  48. package/dist/db/libsql.d.mts.map +1 -1
  49. package/dist/db/libsql.mjs +7 -2
  50. package/dist/db/libsql.mjs.map +1 -1
  51. package/dist/db/postgres.d.mts +1 -1
  52. package/dist/db/sqlite.d.mts +1 -1
  53. package/dist/db/sqlite.d.mts.map +1 -1
  54. package/dist/db/sqlite.mjs +8 -3
  55. package/dist/db/sqlite.mjs.map +1 -1
  56. package/dist/{db-errors-D0UT85nC.mjs → db-errors-WRezodiz.mjs} +1 -1
  57. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
  58. package/dist/{default-CME5YdZ3.mjs → default-D8ksjWhO.mjs} +1 -1
  59. package/dist/{default-CME5YdZ3.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
  60. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  61. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  62. package/dist/{error-CiYn9yDu.mjs → error-D_-tqP-I.mjs} +1 -1
  63. package/dist/error-D_-tqP-I.mjs.map +1 -0
  64. package/dist/{index-De6_Xv3v.d.mts → index-BFRaVcD6.d.mts} +243 -40
  65. package/dist/index-BFRaVcD6.d.mts.map +1 -0
  66. package/dist/index.d.mts +11 -11
  67. package/dist/index.mjs +29 -25
  68. package/dist/{load-CBcmDIot.mjs → load-DDqMMvZL.mjs} +2 -2
  69. package/dist/{load-CBcmDIot.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
  70. package/dist/{loader-DeiBJEMe.mjs → loader-CKLbBnhK.mjs} +32 -10
  71. package/dist/loader-CKLbBnhK.mjs.map +1 -0
  72. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DqWNC3lM.mjs} +45 -3
  73. package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
  74. package/dist/media/index.d.mts +1 -1
  75. package/dist/media/index.mjs +1 -1
  76. package/dist/media/local-runtime.d.mts +7 -7
  77. package/dist/media/local-runtime.mjs +3 -3
  78. package/dist/{media-DqHVh136.mjs → media-BW32b4gi.mjs} +4 -7
  79. package/dist/media-BW32b4gi.mjs.map +1 -0
  80. package/dist/{mode-CpNnGkPz.mjs → mode-ier8jbBk.mjs} +1 -1
  81. package/dist/mode-ier8jbBk.mjs.map +1 -0
  82. package/dist/options-BVp3UsTS.mjs +117 -0
  83. package/dist/options-BVp3UsTS.mjs.map +1 -0
  84. package/dist/page/index.d.mts +2 -2
  85. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
  86. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
  87. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
  88. package/dist/placeholder-CIJejMlK.mjs.map +1 -0
  89. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  90. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  91. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  92. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  93. package/dist/public-url-DByxYjUw.mjs +51 -0
  94. package/dist/public-url-DByxYjUw.mjs.map +1 -0
  95. package/dist/{query-g4Ug-9j9.mjs → query-Cg9ZKRQ0.mjs} +114 -16
  96. package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
  97. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-BhUBKRc1.mjs} +13 -8
  98. package/dist/redirect-BhUBKRc1.mjs.map +1 -0
  99. package/dist/{registry-Ci3WxVAr.mjs → registry-Dw70ChxB.mjs} +69 -11
  100. package/dist/registry-Dw70ChxB.mjs.map +1 -0
  101. package/dist/{request-cache-DiR961CV.mjs → request-cache-B-bmkipQ.mjs} +1 -1
  102. package/dist/request-cache-B-bmkipQ.mjs.map +1 -0
  103. package/dist/runner-Bnoj7vjK.d.mts +44 -0
  104. package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
  105. package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
  106. package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
  107. package/dist/runtime.d.mts +6 -6
  108. package/dist/runtime.mjs +4 -4
  109. package/dist/{search-B0effn3j.mjs → search-dOGEccMa.mjs} +341 -152
  110. package/dist/search-dOGEccMa.mjs.map +1 -0
  111. package/dist/secrets-CW3reAnU.mjs +314 -0
  112. package/dist/secrets-CW3reAnU.mjs.map +1 -0
  113. package/dist/seed/index.d.mts +2 -2
  114. package/dist/seed/index.mjs +15 -14
  115. package/dist/seo/index.d.mts +1 -1
  116. package/dist/storage/local.d.mts +1 -1
  117. package/dist/storage/local.mjs +1 -1
  118. package/dist/storage/s3.d.mts +1 -1
  119. package/dist/storage/s3.d.mts.map +1 -1
  120. package/dist/storage/s3.mjs +4 -4
  121. package/dist/storage/s3.mjs.map +1 -1
  122. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
  123. package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
  124. package/dist/{tokens-BFPFx3CA.mjs → tokens-D7zMmWi2.mjs} +2 -2
  125. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
  126. package/dist/{transport-BykRfpyy.mjs → transport-BeMCmin1.mjs} +6 -5
  127. package/dist/{transport-BykRfpyy.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
  128. package/dist/{transport-H4Iwx7tC.d.mts → transport-DNEfeMaU.d.mts} +1 -1
  129. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
  130. package/dist/types-4fVtCIm0.mjs +68 -0
  131. package/dist/types-4fVtCIm0.mjs.map +1 -0
  132. package/dist/{types-CnZYHyLW.d.mts → types-BSyXeCFW.d.mts} +24 -2
  133. package/dist/{types-CnZYHyLW.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
  134. package/dist/{types-DgrIP0tF.d.mts → types-BuBIptGk.d.mts} +80 -106
  135. package/dist/types-BuBIptGk.d.mts.map +1 -0
  136. package/dist/{types-BH2L167P.mjs → types-CDbKp7ND.mjs} +1 -1
  137. package/dist/{types-BH2L167P.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
  138. package/dist/{types-DDS4MxsT.mjs → types-CIOg5AR8.mjs} +1 -1
  139. package/dist/{types-DDS4MxsT.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
  140. package/dist/{types-6CUZRrZP.d.mts → types-CJsYGpco.d.mts} +24 -2
  141. package/dist/{types-6CUZRrZP.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
  142. package/dist/types-CRxNbK-Z.mjs +68 -0
  143. package/dist/types-CRxNbK-Z.mjs.map +1 -0
  144. package/dist/{types-C2v0c34j.d.mts → types-CrtWgIvl.d.mts} +1 -1
  145. package/dist/{types-C2v0c34j.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
  146. package/dist/{types-CFWjXmus.d.mts → types-M78DQ1lx.d.mts} +1 -1
  147. package/dist/{types-CFWjXmus.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
  148. package/dist/{validate-CqsNItbt.mjs → validate-Baqf0slj.mjs} +3 -3
  149. package/dist/{validate-CqsNItbt.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
  150. package/dist/{validate-kM8Pjuf7.d.mts → validate-BfQh_C_y.d.mts} +4 -4
  151. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
  152. package/dist/validation-BfEI7tNe.mjs +144 -0
  153. package/dist/validation-BfEI7tNe.mjs.map +1 -0
  154. package/dist/version-DoxrVdYf.mjs +7 -0
  155. package/dist/{version-BnTKdfam.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
  156. package/dist/zod-generator-CC0xNe_K.mjs +132 -0
  157. package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
  158. package/locals.d.ts +1 -6
  159. package/package.json +21 -7
  160. package/src/api/auth-storage.ts +37 -0
  161. package/src/api/error.ts +6 -0
  162. package/src/api/errors.ts +8 -0
  163. package/src/api/handlers/comments.ts +19 -4
  164. package/src/api/handlers/content.ts +151 -4
  165. package/src/api/handlers/device-flow.ts +5 -0
  166. package/src/api/handlers/index.ts +2 -0
  167. package/src/api/handlers/marketplace.ts +11 -4
  168. package/src/api/handlers/media.ts +8 -1
  169. package/src/api/handlers/menus.ts +160 -21
  170. package/src/api/handlers/oauth-authorization.ts +72 -33
  171. package/src/api/handlers/redirects.ts +16 -3
  172. package/src/api/handlers/revision.ts +23 -14
  173. package/src/api/handlers/sections.ts +8 -1
  174. package/src/api/handlers/taxonomies.ts +131 -22
  175. package/src/api/handlers/validation.ts +212 -0
  176. package/src/api/openapi/document.ts +4 -1
  177. package/src/api/public-url.ts +54 -5
  178. package/src/api/route-utils.ts +14 -0
  179. package/src/api/schemas/comments.ts +2 -2
  180. package/src/api/schemas/common.ts +1 -1
  181. package/src/api/schemas/content.ts +17 -0
  182. package/src/api/schemas/sections.ts +3 -3
  183. package/src/api/schemas/setup.ts +8 -0
  184. package/src/api/schemas/users.ts +1 -1
  185. package/src/api/schemas/widgets.ts +12 -10
  186. package/src/api/setup-complete.ts +40 -0
  187. package/src/api/types.ts +5 -1
  188. package/src/astro/integration/index.ts +30 -2
  189. package/src/astro/integration/routes.ts +28 -0
  190. package/src/astro/integration/runtime.ts +49 -1
  191. package/src/astro/integration/virtual-modules.ts +73 -2
  192. package/src/astro/integration/vite-config.ts +49 -13
  193. package/src/astro/middleware/auth.ts +34 -6
  194. package/src/astro/middleware/redirect.ts +29 -16
  195. package/src/astro/middleware/request-context.ts +15 -5
  196. package/src/astro/middleware.ts +41 -10
  197. package/src/astro/routes/PluginRegistry.tsx +10 -1
  198. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  199. package/src/astro/routes/api/auth/mode.ts +57 -0
  200. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  201. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  202. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  203. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  204. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  205. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  206. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  207. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  208. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  209. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  210. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  211. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  212. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  213. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  214. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  215. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  216. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  217. package/src/astro/routes/api/import/wordpress/prepare.ts +9 -0
  218. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  219. package/src/astro/routes/api/manifest.ts +62 -45
  220. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  221. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  222. package/src/astro/routes/api/openapi.json.ts +27 -10
  223. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  224. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  225. package/src/astro/routes/api/redirects/[id].ts +10 -4
  226. package/src/astro/routes/api/redirects/index.ts +7 -3
  227. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  228. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  229. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  230. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  231. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  232. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  233. package/src/astro/routes/api/search/index.ts +10 -2
  234. package/src/astro/routes/api/sections/[slug].ts +10 -4
  235. package/src/astro/routes/api/sections/index.ts +7 -3
  236. package/src/astro/routes/api/settings/email.ts +4 -9
  237. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  238. package/src/astro/routes/api/setup/admin.ts +8 -2
  239. package/src/astro/routes/api/setup/index.ts +2 -2
  240. package/src/astro/routes/api/setup/status.ts +3 -1
  241. package/src/astro/routes/api/snapshot.ts +44 -18
  242. package/src/astro/routes/api/taxonomies/index.ts +0 -1
  243. package/src/astro/routes/api/themes/preview.ts +11 -5
  244. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  245. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  246. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  247. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  248. package/src/astro/types.ts +32 -3
  249. package/src/auth/allowed-origins.ts +168 -0
  250. package/src/auth/mode.ts +15 -3
  251. package/src/auth/passkey-config.ts +35 -13
  252. package/src/auth/providers/github-admin.tsx +29 -0
  253. package/src/auth/providers/github.ts +31 -0
  254. package/src/auth/providers/google-admin.tsx +44 -0
  255. package/src/auth/providers/google.ts +31 -0
  256. package/src/auth/types.ts +114 -4
  257. package/src/bylines/index.ts +37 -88
  258. package/src/cli/commands/auth.ts +28 -6
  259. package/src/cli/commands/bundle-utils.ts +11 -2
  260. package/src/cli/commands/bundle.ts +31 -9
  261. package/src/cli/commands/content.ts +13 -0
  262. package/src/cli/commands/login.ts +8 -1
  263. package/src/cli/commands/publish.ts +24 -0
  264. package/src/cli/commands/secrets.ts +183 -0
  265. package/src/cli/credentials.ts +1 -1
  266. package/src/cli/index.ts +5 -1
  267. package/src/client/index.ts +4 -4
  268. package/src/client/transport.ts +17 -7
  269. package/src/components/Break.astro +2 -2
  270. package/src/components/EmDashHead.astro +18 -13
  271. package/src/components/EmDashImage.astro +7 -6
  272. package/src/components/Embed.astro +1 -1
  273. package/src/components/Gallery.astro +6 -4
  274. package/src/components/Image.astro +9 -4
  275. package/src/components/InlinePortableTextEditor.tsx +106 -19
  276. package/src/components/LiveSearch.astro +5 -14
  277. package/src/config/secrets.ts +528 -0
  278. package/src/database/dialect-helpers.ts +50 -0
  279. package/src/database/migrations/034_published_at_index.ts +1 -1
  280. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  281. package/src/database/migrations/runner.ts +156 -23
  282. package/src/database/repositories/audit.ts +6 -8
  283. package/src/database/repositories/byline.ts +6 -8
  284. package/src/database/repositories/comment.ts +12 -16
  285. package/src/database/repositories/content.ts +76 -52
  286. package/src/database/repositories/index.ts +1 -1
  287. package/src/database/repositories/media.ts +10 -13
  288. package/src/database/repositories/plugin-storage.ts +4 -6
  289. package/src/database/repositories/redirect.ts +26 -19
  290. package/src/database/repositories/taxonomy.ts +40 -3
  291. package/src/database/repositories/types.ts +57 -8
  292. package/src/database/repositories/user.ts +6 -8
  293. package/src/db/libsql.ts +1 -3
  294. package/src/db/sqlite.ts +2 -5
  295. package/src/emdash-runtime.ts +388 -247
  296. package/src/index.ts +14 -1
  297. package/src/loader.ts +30 -6
  298. package/src/mcp/server.ts +781 -141
  299. package/src/media/normalize.ts +1 -1
  300. package/src/media/url.ts +78 -0
  301. package/src/page/site-identity.ts +58 -0
  302. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  303. package/src/plugins/context.ts +13 -10
  304. package/src/plugins/define-plugin.ts +40 -12
  305. package/src/plugins/email-console.ts +10 -3
  306. package/src/plugins/hooks.ts +34 -19
  307. package/src/plugins/index.ts +9 -0
  308. package/src/plugins/manifest-schema.ts +49 -2
  309. package/src/plugins/types.ts +174 -13
  310. package/src/preview/urls.ts +23 -3
  311. package/src/query.ts +149 -6
  312. package/src/redirects/cache.ts +38 -18
  313. package/src/request-cache.ts +3 -0
  314. package/src/schema/registry.ts +97 -5
  315. package/src/schema/zod-generator.ts +27 -5
  316. package/src/search/fts-manager.ts +0 -2
  317. package/src/search/query.ts +111 -26
  318. package/src/search/types.ts +8 -1
  319. package/src/sections/index.ts +7 -9
  320. package/src/seed/apply.ts +2 -0
  321. package/src/settings/index.ts +80 -6
  322. package/src/settings/types.ts +23 -1
  323. package/src/storage/s3.ts +12 -6
  324. package/src/taxonomies/index.ts +11 -1
  325. package/src/virtual-modules.d.ts +21 -1
  326. package/src/widgets/index.ts +1 -1
  327. package/dist/apply-5uslYdUu.mjs.map +0 -1
  328. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  329. package/dist/bylines-hPTW79hw.mjs +0 -157
  330. package/dist/bylines-hPTW79hw.mjs.map +0 -1
  331. package/dist/cache-BkKBuIvS.mjs +0 -56
  332. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  333. package/dist/chunk-ClPoSABd.mjs +0 -21
  334. package/dist/content-D7J5y73J.mjs.map +0 -1
  335. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  336. package/dist/error-CiYn9yDu.mjs.map +0 -1
  337. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  338. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  339. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  340. package/dist/media-DqHVh136.mjs.map +0 -1
  341. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  342. package/dist/placeholder-C-fk5hYI.mjs.map +0 -1
  343. package/dist/query-g4Ug-9j9.mjs.map +0 -1
  344. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  345. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  346. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  347. package/dist/runner-BR2xKwhn.d.mts +0 -34
  348. package/dist/runner-BR2xKwhn.d.mts.map +0 -1
  349. package/dist/search-B0effn3j.mjs.map +0 -1
  350. package/dist/taxonomies-K2z0Uhnj.mjs.map +0 -1
  351. package/dist/types-CMMN0pNg.mjs +0 -31
  352. package/dist/types-CMMN0pNg.mjs.map +0 -1
  353. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  354. package/dist/version-BnTKdfam.mjs +0 -7
package/src/mcp/server.ts CHANGED
@@ -12,61 +12,224 @@
12
12
  import type { Permission, RoleLevel } from "@emdash-cms/auth";
13
13
  import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth";
14
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
- import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
16
15
  import { z } from "zod";
17
16
 
17
+ import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js";
18
+
18
19
  import type { EmDashHandlers } from "../astro/types.js";
19
20
  import { hasScope } from "../auth/api-tokens.js";
20
21
 
21
22
  const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/;
23
+ /** http(s) scheme matcher used by `settings_update` URL validation. */
24
+ const HTTP_SCHEME_PATTERN = /^https?:\/\//i;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Shared schemas — kept in sync with `api/schemas/settings.ts` (which the
28
+ // REST handler validates against). Defined inline to match the rest of the
29
+ // MCP tool registrations rather than reaching across into the REST layer.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const settingsMediaReferenceSchema = z.object({
33
+ mediaId: z.string().describe("Media item ID (use media_create or media_list)"),
34
+ alt: z.string().optional().describe("Alt text for the media reference"),
35
+ });
36
+
37
+ const settingsSocialSchema = z.object({
38
+ twitter: z.string().optional(),
39
+ github: z.string().optional(),
40
+ facebook: z.string().optional(),
41
+ instagram: z.string().optional(),
42
+ linkedin: z.string().optional(),
43
+ youtube: z.string().optional(),
44
+ });
45
+
46
+ const settingsSeoSchema = z.object({
47
+ titleSeparator: z
48
+ .string()
49
+ .max(10)
50
+ .optional()
51
+ .describe("Separator between page title and site title (e.g. ' | ')"),
52
+ defaultOgImage: settingsMediaReferenceSchema
53
+ .optional()
54
+ .describe("Default Open Graph image when content has none"),
55
+ robotsTxt: z
56
+ .string()
57
+ .max(5000)
58
+ .optional()
59
+ .describe("Custom robots.txt body. Leave unset for the EmDash default."),
60
+ googleVerification: z
61
+ .string()
62
+ .max(100)
63
+ .optional()
64
+ .describe("Google Search Console verification token"),
65
+ bingVerification: z
66
+ .string()
67
+ .max(100)
68
+ .optional()
69
+ .describe("Bing Webmaster Tools verification token"),
70
+ });
22
71
 
23
72
  // ---------------------------------------------------------------------------
24
73
  // Helpers
25
74
  // ---------------------------------------------------------------------------
26
75
 
27
- type HandlerResult = { success: boolean; data?: unknown; error?: unknown };
76
+ type HandlerResult = {
77
+ success: boolean;
78
+ data?: unknown;
79
+ error?: unknown;
80
+ };
81
+
82
+ type SuccessEnvelope = {
83
+ content: Array<{ type: "text"; text: string }>;
84
+ _meta?: Record<string, unknown>;
85
+ };
86
+
87
+ type ErrorEnvelope = {
88
+ content: Array<{ type: "text"; text: string }>;
89
+ isError: true;
90
+ _meta: { code: string; details?: Record<string, unknown> };
91
+ };
28
92
 
29
93
  /**
30
- * Unwrap an ApiResult<T> into MCP tool result format.
31
- * On success, returns the data as pretty-printed JSON text content.
32
- * On failure, returns the error message with isError flag.
94
+ * Return a successful tool response with the data as pretty-printed JSON.
33
95
  */
34
- function unwrap(result: HandlerResult): {
35
- content: Array<{ type: "text"; text: string }>;
36
- isError?: true;
37
- } {
38
- if (result.success && result.data !== undefined) {
39
- return {
40
- content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
41
- };
42
- }
43
- const errMsg =
44
- result.error && typeof result.error === "object" && "message" in result.error
45
- ? String((result.error as Record<string, unknown>).message)
46
- : "Unknown error";
47
- return { content: [{ type: "text", text: errMsg }], isError: true };
96
+ function respondData(data: unknown): SuccessEnvelope {
97
+ return {
98
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
99
+ };
48
100
  }
49
101
 
50
102
  /**
51
- * Return a JSON text block.
103
+ * Return a structured error tool response.
104
+ *
105
+ * The error code is emitted both in the human-readable message (as a stable
106
+ * `[CODE]` prefix that callers can match on) and in `_meta.code` so MCP-aware
107
+ * clients can read it programmatically once the SDK supports forwarding meta.
52
108
  */
53
- function jsonResult(data: unknown): {
54
- content: Array<{ type: "text"; text: string }>;
55
- } {
109
+ function respondError(
110
+ code: string,
111
+ message: string,
112
+ details?: Record<string, unknown>,
113
+ ): ErrorEnvelope {
114
+ const text = `[${code}] ${message}`;
115
+ const meta: { code: string; details?: Record<string, unknown> } = { code };
116
+ if (details !== undefined) meta.details = details;
56
117
  return {
57
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
118
+ content: [{ type: "text", text }],
119
+ isError: true,
120
+ _meta: meta,
58
121
  };
59
122
  }
60
123
 
61
124
  /**
62
- * Return an error text block.
125
+ * Auth/permission errors thrown from `requireScope` / `requireRole` /
126
+ * `requireOwnership` / `requireDraftAccess`. Carries a stable string `code`
127
+ * field so `respondHandlerError` can surface it through `_meta.code` and
128
+ * the message prefix.
129
+ *
130
+ * Distinct from `McpError` (which the SDK catches at JSON-RPC level — the
131
+ * code there is numeric, not a stable EmDash error code).
63
132
  */
64
- function errorResult(error: unknown): {
65
- content: Array<{ type: "text"; text: string }>;
66
- isError: true;
67
- } {
68
- const msg = error instanceof Error ? error.message : String(error);
69
- return { content: [{ type: "text", text: msg }], isError: true };
133
+ class EmDashAuthError extends Error {
134
+ override readonly name = "EmDashAuthError";
135
+ constructor(
136
+ message: string,
137
+ readonly code: string,
138
+ ) {
139
+ super(message);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Map an unknown thrown error to a structured error envelope.
145
+ *
146
+ * Recognises (in priority order):
147
+ * - `EmDashAuthError` — `code` is a stable EmDash auth code
148
+ * (`UNAUTHORIZED`, `INSUFFICIENT_SCOPE`, `INSUFFICIENT_PERMISSIONS`).
149
+ * - `Error` objects with an `apiError: { code, details? }` annotation
150
+ * (handlers throw these for NOT_FOUND / CONFLICT inside transactions;
151
+ * see `api/handlers/content.ts:538`).
152
+ * - `SchemaError` (and any error with a string `code` field) — the code
153
+ * is forwarded verbatim. `details` is forwarded too if present.
154
+ * - Plain `Error` instances — message preserved, code falls back to
155
+ * `fallbackCode` (or `INTERNAL_ERROR`).
156
+ * - Strings — used directly as the message.
157
+ * - Anything else — coerced via `String()`.
158
+ *
159
+ * The original message is always preserved so tests and humans can see the
160
+ * specific failure cause. Numeric `code` values (e.g. on `McpError`) are
161
+ * ignored — the field is reserved for stable string codes.
162
+ */
163
+ function respondHandlerError(error: unknown, fallbackCode = "INTERNAL_ERROR"): ErrorEnvelope {
164
+ let code = fallbackCode;
165
+ let message: string;
166
+ let details: Record<string, unknown> | undefined;
167
+
168
+ if (error instanceof EmDashAuthError) {
169
+ message = error.message || fallbackCode;
170
+ code = error.code;
171
+ } else if (error instanceof Error) {
172
+ message = error.message || fallbackCode;
173
+ const apiError = (error as { apiError?: { code?: string; details?: unknown } }).apiError;
174
+ if (apiError && typeof apiError.code === "string" && apiError.code) {
175
+ code = apiError.code;
176
+ if (apiError.details && typeof apiError.details === "object") {
177
+ details = apiError.details as Record<string, unknown>;
178
+ }
179
+ } else {
180
+ // Errors that carry their own `code` (SchemaError, custom errors).
181
+ // Skip numeric codes (McpError, Node fs errors) — `_meta.code` is
182
+ // reserved for stable string codes.
183
+ const rawCode = (error as { code?: unknown }).code;
184
+ if (typeof rawCode === "string" && rawCode) {
185
+ code = rawCode;
186
+ }
187
+ const rawDetails = (error as { details?: unknown }).details;
188
+ if (rawDetails && typeof rawDetails === "object") {
189
+ details = rawDetails as Record<string, unknown>;
190
+ }
191
+ }
192
+ } else if (typeof error === "string") {
193
+ message = error;
194
+ } else {
195
+ message = String(error);
196
+ }
197
+
198
+ return respondError(code, message, details);
199
+ }
200
+
201
+ /**
202
+ * Unwrap an ApiResult<T> into MCP tool result format.
203
+ *
204
+ * On success returns the data as JSON. On failure propagates the structured
205
+ * `{ code, message, details }` from the handler so the caller sees both a
206
+ * machine-readable code (in `_meta.code` and as a `[CODE]` message prefix)
207
+ * and the original human-readable message.
208
+ */
209
+ function unwrap(result: HandlerResult): SuccessEnvelope | ErrorEnvelope {
210
+ if (result.success && result.data !== undefined) {
211
+ return respondData(result.data);
212
+ }
213
+ const err =
214
+ result.error && typeof result.error === "object"
215
+ ? (result.error as { code?: unknown; message?: unknown; details?: unknown })
216
+ : undefined;
217
+ if (!err) return respondError("INTERNAL_ERROR", "Unknown error");
218
+ const code = typeof err.code === "string" && err.code ? err.code : "INTERNAL_ERROR";
219
+ const message = typeof err.message === "string" && err.message ? err.message : "Unknown error";
220
+ const details =
221
+ err.details && typeof err.details === "object"
222
+ ? (err.details as Record<string, unknown>)
223
+ : undefined;
224
+ return respondError(code, message, details);
225
+ }
226
+
227
+ /**
228
+ * Return a JSON text block (success path for tools that don't go through
229
+ * the ApiResult-returning handler layer, e.g. schema/menu/taxonomy).
230
+ */
231
+ function jsonResult(data: unknown): SuccessEnvelope {
232
+ return respondData(data);
70
233
  }
71
234
 
72
235
  // ---------------------------------------------------------------------------
@@ -118,7 +281,7 @@ function requireScope(
118
281
  ): void {
119
282
  const payload = getExtra(extra);
120
283
  if (payload.tokenScopes && !hasScope(payload.tokenScopes, scope)) {
121
- throw new McpError(ErrorCode.InvalidRequest, `Insufficient scope: requires ${scope}`);
284
+ throw new EmDashAuthError(`Insufficient scope: requires ${scope}`, "INSUFFICIENT_SCOPE");
122
285
  }
123
286
  }
124
287
 
@@ -135,7 +298,10 @@ function requireRole(
135
298
  ): void {
136
299
  const payload = getExtra(extra);
137
300
  if (payload.userRole < minRole) {
138
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
301
+ throw new EmDashAuthError(
302
+ "Insufficient permissions for this operation",
303
+ "INSUFFICIENT_PERMISSIONS",
304
+ );
139
305
  }
140
306
  }
141
307
 
@@ -155,7 +321,10 @@ function canReadDrafts(extra: { authInfo?: { extra?: Record<string, unknown> } }
155
321
  */
156
322
  function requireDraftAccess(extra: { authInfo?: { extra?: Record<string, unknown> } }): void {
157
323
  if (!canReadDrafts(extra)) {
158
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
324
+ throw new EmDashAuthError(
325
+ "Insufficient permissions for this operation",
326
+ "INSUFFICIENT_PERMISSIONS",
327
+ );
159
328
  }
160
329
  }
161
330
 
@@ -175,7 +344,10 @@ function requireOwnership(
175
344
  const payload = getExtra(extra);
176
345
  const user = { id: payload.userId, role: payload.userRole };
177
346
  if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
178
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
347
+ throw new EmDashAuthError(
348
+ "Insufficient permissions for this operation",
349
+ "INSUFFICIENT_PERMISSIONS",
350
+ );
179
351
  }
180
352
  }
181
353
 
@@ -183,26 +355,18 @@ function requireOwnership(
183
355
  * Extract the author ID from a content handler response.
184
356
  *
185
357
  * Content handlers return `{ item: { id, authorId, ... }, _rev? }`.
186
- * This helper navigates that shape safely.
358
+ * This helper navigates that shape safely. Returns "" when authorId is
359
+ * missing or non-string (e.g. seed-imported content with no author);
360
+ * `canActOnOwn` then decides based on the caller's permissions —
361
+ * an actor with `*:edit_any` succeeds, an actor with only `*:edit_own`
362
+ * is denied with a clean permission error.
187
363
  */
188
364
  function extractContentAuthorId(data: unknown): string {
189
- if (!data || typeof data !== "object") {
190
- throw new McpError(
191
- ErrorCode.InternalError,
192
- "Cannot determine content ownership: no data returned",
193
- );
194
- }
365
+ if (!data || typeof data !== "object") return "";
195
366
  const obj = data as Record<string, unknown>;
196
367
  const item =
197
368
  obj.item && typeof obj.item === "object" ? (obj.item as Record<string, unknown>) : obj;
198
- const authorId = typeof item?.authorId === "string" ? item.authorId : "";
199
- if (!authorId) {
200
- throw new McpError(
201
- ErrorCode.InternalError,
202
- "Cannot determine content ownership: content has no authorId",
203
- );
204
- }
205
- return authorId;
369
+ return typeof item?.authorId === "string" ? item.authorId : "";
206
370
  }
207
371
 
208
372
  /**
@@ -227,6 +391,34 @@ export function createMcpServer(): McpServer {
227
391
  { capabilities: { logging: {} } },
228
392
  );
229
393
 
394
+ // Wrap every tool registration's callback so EmDashAuthError throws
395
+ // (from requireScope / requireRole / requireOwnership / requireDraftAccess)
396
+ // surface as structured `_meta.code`-bearing tool error envelopes
397
+ // instead of the SDK's text-only fallback in createToolError().
398
+ //
399
+ // Type-erased on purpose — the SDK's overloads are too narrow for a
400
+ // generic wrapper, but the runtime contract (callback returns the tool
401
+ // result envelope) holds for every registered tool.
402
+ const originalRegisterTool = server.registerTool.bind(server);
403
+ (server as { registerTool: typeof server.registerTool }).registerTool = ((
404
+ name: string,
405
+ config: unknown,
406
+ callback: (...callbackArgs: unknown[]) => Promise<SuccessEnvelope | ErrorEnvelope>,
407
+ ) => {
408
+ const wrapped = async (
409
+ ...callbackArgs: unknown[]
410
+ ): Promise<SuccessEnvelope | ErrorEnvelope> => {
411
+ try {
412
+ return await callback(...callbackArgs);
413
+ } catch (error) {
414
+ return respondHandlerError(error, "INTERNAL_ERROR");
415
+ }
416
+ };
417
+ return (
418
+ originalRegisterTool as unknown as (n: string, c: unknown, cb: typeof wrapped) => unknown
419
+ )(name, config, wrapped);
420
+ }) as typeof server.registerTool;
421
+
230
422
  // =====================================================================
231
423
  // Content tools
232
424
  // =====================================================================
@@ -253,7 +445,12 @@ export function createMcpServer(): McpServer {
253
445
  .max(100)
254
446
  .optional()
255
447
  .describe("Max items to return (default 50, max 100)"),
256
- cursor: z.string().optional().describe("Pagination cursor from a previous response"),
448
+ cursor: z
449
+ .string()
450
+ .min(1)
451
+ .max(2048)
452
+ .optional()
453
+ .describe("Pagination cursor from a previous response"),
257
454
  orderBy: z
258
455
  .string()
259
456
  .optional()
@@ -388,9 +585,9 @@ export function createMcpServer(): McpServer {
388
585
  if (args.status === "published") {
389
586
  const user = { id: userId, role: getExtra(extra).userRole };
390
587
  if (!hasPermission(user, "content:publish_own" as Permission)) {
391
- throw new McpError(
392
- ErrorCode.InvalidRequest,
588
+ throw new EmDashAuthError(
393
589
  "Insufficient permissions: publishing requires content:publish_own",
590
+ "INSUFFICIENT_PERMISSIONS",
394
591
  );
395
592
  }
396
593
  const result = await emdash.handleContentCreate(args.collection, {
@@ -428,7 +625,10 @@ export function createMcpServer(): McpServer {
428
625
  "Update an existing content item. Only include fields you want to change " +
429
626
  "in the 'data' object — unspecified fields are left unchanged. Pass the " +
430
627
  "_rev token from content_get to enable optimistic concurrency checking " +
431
- "(the update fails if the item was modified since you read it).",
628
+ "(the update fails if the item was modified since you read it). " +
629
+ "`seo` and `bylines` are persisted alongside the field updates in a " +
630
+ "single transaction. `publishedAt` requires the content:publish_any " +
631
+ "permission and is useful for migrations or correcting historical dates.",
432
632
  inputSchema: z.object({
433
633
  collection: z.string().describe("Collection slug"),
434
634
  id: z.string().describe("Content item ID or slug"),
@@ -443,6 +643,28 @@ export function createMcpServer(): McpServer {
443
643
  .describe(
444
644
  "New status. Setting to 'published' requires publish permission. Setting to 'draft' unpublishes the item and also requires publish permission.",
445
645
  ),
646
+ // Reuse the REST schema rather than redefining inline. The REST schema's
647
+ // `canonical` field is gated through `httpUrl` (validates the URL parses
648
+ // AND has an http(s) scheme) which rejects javascript:/data: URIs that
649
+ // would otherwise become stored XSS in the rendered <link rel="canonical">.
650
+ // Inlining a looser shape here would let MCP callers bypass that.
651
+ seo: contentSeoInput
652
+ .optional()
653
+ .describe(
654
+ "Per-content SEO metadata. Only valid for collections with SEO enabled (see schema_get_collection.hasSeo). Fields not included are left unchanged; pass null to clear.",
655
+ ),
656
+ bylines: z
657
+ .array(contentBylineInputSchema)
658
+ .optional()
659
+ .describe(
660
+ "Replace the byline list for this item. The first entry becomes the primary byline. Pass an empty array to clear all bylines.",
661
+ ),
662
+ publishedAt: z.iso
663
+ .datetime({ offset: true, message: "must be an ISO 8601 datetime" })
664
+ .nullish()
665
+ .describe(
666
+ "Override the publication timestamp (ISO 8601). Requires content:publish_any permission. Pass null to clear. Useful for content migrations.",
667
+ ),
446
668
  _rev: z
447
669
  .string()
448
670
  .optional()
@@ -452,35 +674,48 @@ export function createMcpServer(): McpServer {
452
674
  async (args, extra) => {
453
675
  requireScope(extra, "content:write");
454
676
  requireRole(extra, Role.AUTHOR);
455
- const { emdash, userId } = getExtra(extra);
677
+ const { emdash, userId, userRole } = getExtra(extra);
456
678
 
457
679
  // Fetch item to check ownership
458
680
  const existing = await emdash.handleContentGet(args.collection, args.id);
459
681
  if (!existing.success) {
460
682
  return unwrap(existing);
461
683
  }
462
- requireOwnership(
463
- extra,
464
- extractContentAuthorId(existing.data),
465
- "content:edit_own",
466
- "content:edit_any",
467
- );
684
+ const ownerId = extractContentAuthorId(existing.data);
685
+ requireOwnership(extra, ownerId, "content:edit_own", "content:edit_any");
686
+
687
+ // Writing publishedAt directly (incl. clearing to null) overwrites
688
+ // historical record — gate behind publish_any, mirroring the REST PUT
689
+ // route. Status-driven publishes are gated separately below.
690
+ if (args.publishedAt !== undefined) {
691
+ const user = { id: userId, role: userRole };
692
+ if (!hasPermission(user, "content:publish_any" as Permission)) {
693
+ throw new EmDashAuthError(
694
+ "Setting publishedAt requires content:publish_any permission",
695
+ "INSUFFICIENT_PERMISSIONS",
696
+ );
697
+ }
698
+ }
468
699
 
469
700
  const resolvedId = extractContentId(existing.data) ?? args.id;
470
701
 
471
702
  // Status transitions route through dedicated handlers for proper revision management
472
703
  if (args.status === "published") {
473
- requireOwnership(
474
- extra,
475
- extractContentAuthorId(existing.data),
476
- "content:publish_own",
477
- "content:publish_any",
478
- );
479
- if (args.data || args.slug) {
704
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
705
+ if (
706
+ args.data ||
707
+ args.slug ||
708
+ args.seo !== undefined ||
709
+ args.bylines !== undefined ||
710
+ args.publishedAt !== undefined
711
+ ) {
480
712
  const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
481
713
  data: args.data,
482
714
  slug: args.slug,
483
715
  authorId: userId,
716
+ seo: args.seo,
717
+ bylines: args.bylines,
718
+ publishedAt: args.publishedAt,
484
719
  _rev: args._rev,
485
720
  });
486
721
  if (!updateResult.success) return unwrap(updateResult);
@@ -489,17 +724,21 @@ export function createMcpServer(): McpServer {
489
724
  }
490
725
 
491
726
  if (args.status === "draft") {
492
- requireOwnership(
493
- extra,
494
- extractContentAuthorId(existing.data),
495
- "content:publish_own",
496
- "content:publish_any",
497
- );
498
- if (args.data || args.slug) {
727
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
728
+ if (
729
+ args.data ||
730
+ args.slug ||
731
+ args.seo !== undefined ||
732
+ args.bylines !== undefined ||
733
+ args.publishedAt !== undefined
734
+ ) {
499
735
  const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
500
736
  data: args.data,
501
737
  slug: args.slug,
502
738
  authorId: userId,
739
+ seo: args.seo,
740
+ bylines: args.bylines,
741
+ publishedAt: args.publishedAt,
503
742
  _rev: args._rev,
504
743
  });
505
744
  if (!updateResult.success) return unwrap(updateResult);
@@ -512,6 +751,9 @@ export function createMcpServer(): McpServer {
512
751
  data: args.data,
513
752
  slug: args.slug,
514
753
  authorId: userId,
754
+ seo: args.seo,
755
+ bylines: args.bylines,
756
+ publishedAt: args.publishedAt,
515
757
  _rev: args._rev,
516
758
  }),
517
759
  );
@@ -614,31 +856,53 @@ export function createMcpServer(): McpServer {
614
856
  description:
615
857
  "Publish a content item, making it live on the site. Creates a published " +
616
858
  "revision from the current draft. Further edits create a new draft without " +
617
- "affecting the live version until re-published.",
859
+ "affecting the live version until re-published. Pass `publishedAt` to " +
860
+ "backdate (e.g. when migrating content from another CMS) — this requires " +
861
+ "the content:publish_any permission. Without `publishedAt`, the existing " +
862
+ "`published_at` is preserved on re-publish (idempotent) and falls back to " +
863
+ "the current time on first publish.",
618
864
  inputSchema: z.object({
619
865
  collection: z.string().describe("Collection slug"),
620
866
  id: z.string().describe("Content item ID or slug"),
867
+ publishedAt: z.iso
868
+ .datetime({ offset: true, message: "must be an ISO 8601 datetime" })
869
+ .optional()
870
+ .describe(
871
+ "Override publication timestamp (ISO 8601). Requires content:publish_any permission. Useful when importing content with original publish dates.",
872
+ ),
621
873
  }),
622
874
  },
623
875
  async (args, extra) => {
624
876
  requireScope(extra, "content:write");
625
877
  requireRole(extra, Role.AUTHOR);
626
- const ec = getEmDash(extra);
878
+ const { emdash, userId, userRole } = getExtra(extra);
627
879
 
628
880
  // Fetch item to check ownership
629
- const existing = await ec.handleContentGet(args.collection, args.id);
881
+ const existing = await emdash.handleContentGet(args.collection, args.id);
630
882
  if (!existing.success) {
631
883
  return unwrap(existing);
632
884
  }
633
- requireOwnership(
634
- extra,
635
- extractContentAuthorId(existing.data),
636
- "content:publish_own",
637
- "content:publish_any",
638
- );
885
+ const ownerId = extractContentAuthorId(existing.data);
886
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
887
+
888
+ // Backdating overwrites historical record — gate behind publish_any
889
+ // regardless of ownership (mirrors the REST PUT route's publishedAt gate).
890
+ if (args.publishedAt !== undefined) {
891
+ const user = { id: userId, role: userRole };
892
+ if (!hasPermission(user, "content:publish_any" as Permission)) {
893
+ throw new EmDashAuthError(
894
+ "Setting publishedAt requires content:publish_any permission",
895
+ "INSUFFICIENT_PERMISSIONS",
896
+ );
897
+ }
898
+ }
639
899
 
640
900
  const resolvedId = extractContentId(existing.data) ?? args.id;
641
- return unwrap(await ec.handleContentPublish(args.collection, resolvedId));
901
+ return unwrap(
902
+ await emdash.handleContentPublish(args.collection, resolvedId, {
903
+ publishedAt: args.publishedAt,
904
+ }),
905
+ );
642
906
  },
643
907
  );
644
908
 
@@ -714,6 +978,40 @@ export function createMcpServer(): McpServer {
714
978
  },
715
979
  );
716
980
 
981
+ server.registerTool(
982
+ "content_unschedule",
983
+ {
984
+ title: "Cancel Scheduled Publication",
985
+ description:
986
+ "Cancel a previously scheduled publication. The item remains in its current " +
987
+ "status (typically 'draft' or 'scheduled'); only the scheduledAt timestamp is " +
988
+ "cleared. Idempotent — calling on an item that isn't scheduled is a no-op.",
989
+ inputSchema: z.object({
990
+ collection: z.string().describe("Collection slug"),
991
+ id: z.string().describe("Content item ID or slug"),
992
+ }),
993
+ },
994
+ async (args, extra) => {
995
+ requireScope(extra, "content:write");
996
+ requireRole(extra, Role.AUTHOR);
997
+ const ec = getEmDash(extra);
998
+
999
+ const existing = await ec.handleContentGet(args.collection, args.id);
1000
+ if (!existing.success) {
1001
+ return unwrap(existing);
1002
+ }
1003
+ requireOwnership(
1004
+ extra,
1005
+ extractContentAuthorId(existing.data),
1006
+ "content:publish_own",
1007
+ "content:publish_any",
1008
+ );
1009
+
1010
+ const resolvedId = extractContentId(existing.data) ?? args.id;
1011
+ return unwrap(await ec.handleContentUnschedule(args.collection, resolvedId));
1012
+ },
1013
+ );
1014
+
717
1015
  server.registerTool(
718
1016
  "content_compare",
719
1017
  {
@@ -782,7 +1080,7 @@ export function createMcpServer(): McpServer {
782
1080
  inputSchema: z.object({
783
1081
  collection: z.string().describe("Collection slug"),
784
1082
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
785
- cursor: z.string().optional().describe("Pagination cursor"),
1083
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
786
1084
  }),
787
1085
  annotations: { readOnlyHint: true },
788
1086
  },
@@ -882,7 +1180,7 @@ export function createMcpServer(): McpServer {
882
1180
  const items = await registry.listCollections();
883
1181
  return jsonResult({ items });
884
1182
  } catch (error) {
885
- return errorResult(error);
1183
+ return respondHandlerError(error, "SCHEMA_LIST_ERROR");
886
1184
  }
887
1185
  },
888
1186
  );
@@ -915,11 +1213,11 @@ export function createMcpServer(): McpServer {
915
1213
  const registry = new SchemaRegistry(ec.db);
916
1214
  const collection = await registry.getCollectionWithFields(args.slug);
917
1215
  if (!collection) {
918
- return errorResult(`Collection '${args.slug}' not found`);
1216
+ return respondError("NOT_FOUND", `Collection '${args.slug}' not found`);
919
1217
  }
920
1218
  return jsonResult(collection);
921
1219
  } catch (error) {
922
- return errorResult(error);
1220
+ return respondHandlerError(error, "SCHEMA_GET_ERROR");
923
1221
  }
924
1222
  },
925
1223
  );
@@ -962,12 +1260,14 @@ export function createMcpServer(): McpServer {
962
1260
  labelSingular: args.labelSingular,
963
1261
  description: args.description,
964
1262
  icon: args.icon,
1263
+ // SchemaRegistry.createCollection now defaults `supports` to
1264
+ // ['drafts', 'revisions'] when undefined; pass through verbatim.
965
1265
  supports: args.supports,
966
1266
  });
967
- ec.invalidateManifest();
1267
+ ec.invalidateUrlPatternCache();
968
1268
  return jsonResult(collection);
969
1269
  } catch (error) {
970
- return errorResult(error);
1270
+ return respondHandlerError(error, "SCHEMA_CREATE_ERROR");
971
1271
  }
972
1272
  },
973
1273
  );
@@ -996,10 +1296,10 @@ export function createMcpServer(): McpServer {
996
1296
  const { SchemaRegistry } = await import("../schema/index.js");
997
1297
  const registry = new SchemaRegistry(ec.db);
998
1298
  await registry.deleteCollection(args.slug, { force: args.force });
999
- ec.invalidateManifest();
1299
+ ec.invalidateUrlPatternCache();
1000
1300
  return jsonResult({ deleted: args.slug });
1001
1301
  } catch (error) {
1002
- return errorResult(error);
1302
+ return respondHandlerError(error, "SCHEMA_DELETE_ERROR");
1003
1303
  }
1004
1304
  },
1005
1305
  );
@@ -1100,10 +1400,9 @@ export function createMcpServer(): McpServer {
1100
1400
  searchable: args.searchable,
1101
1401
  translatable: args.translatable,
1102
1402
  });
1103
- ec.invalidateManifest();
1104
1403
  return jsonResult(field);
1105
1404
  } catch (error) {
1106
- return errorResult(error);
1405
+ return respondHandlerError(error, "FIELD_CREATE_ERROR");
1107
1406
  }
1108
1407
  },
1109
1408
  );
@@ -1129,10 +1428,9 @@ export function createMcpServer(): McpServer {
1129
1428
  const { SchemaRegistry } = await import("../schema/index.js");
1130
1429
  const registry = new SchemaRegistry(ec.db);
1131
1430
  await registry.deleteField(args.collection, args.fieldSlug);
1132
- ec.invalidateManifest();
1133
1431
  return jsonResult({ deleted: args.fieldSlug, collection: args.collection });
1134
1432
  } catch (error) {
1135
- return errorResult(error);
1433
+ return respondHandlerError(error, "FIELD_DELETE_ERROR");
1136
1434
  }
1137
1435
  },
1138
1436
  );
@@ -1155,7 +1453,7 @@ export function createMcpServer(): McpServer {
1155
1453
  .optional()
1156
1454
  .describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"),
1157
1455
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1158
- cursor: z.string().optional().describe("Pagination cursor"),
1456
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1159
1457
  }),
1160
1458
  annotations: { readOnlyHint: true },
1161
1459
  },
@@ -1172,6 +1470,54 @@ export function createMcpServer(): McpServer {
1172
1470
  },
1173
1471
  );
1174
1472
 
1473
+ server.registerTool(
1474
+ "media_create",
1475
+ {
1476
+ title: "Register Uploaded Media",
1477
+ description:
1478
+ "Register a media file that has already been uploaded to storage. The " +
1479
+ "caller is responsible for placing the file at `storageKey` (typically " +
1480
+ "using a signed upload URL obtained from the admin UI or a separate API). " +
1481
+ "This tool persists the metadata record so the file is discoverable via " +
1482
+ "media_list / media_get and can be referenced by content. For binary " +
1483
+ "uploads the MCP transport is not appropriate — use the signed-upload " +
1484
+ "flow instead.",
1485
+ inputSchema: z.object({
1486
+ filename: z.string().describe("Original filename (e.g. 'logo.png')"),
1487
+ mimeType: z.string().describe("MIME type (e.g. 'image/png')"),
1488
+ storageKey: z.string().describe("Storage path/key the file was uploaded to"),
1489
+ size: z.number().int().nonnegative().optional().describe("File size in bytes"),
1490
+ width: z.number().int().positive().optional().describe("Image width in pixels"),
1491
+ height: z.number().int().positive().optional().describe("Image height in pixels"),
1492
+ contentHash: z.string().optional().describe("Hash of the file contents (for dedupe)"),
1493
+ blurhash: z.string().optional().describe("Blurhash for image placeholders"),
1494
+ dominantColor: z
1495
+ .string()
1496
+ .optional()
1497
+ .describe("Hex color string for the image's dominant color"),
1498
+ }),
1499
+ },
1500
+ async (args, extra) => {
1501
+ requireScope(extra, "media:write");
1502
+ requireRole(extra, Role.AUTHOR);
1503
+ const { emdash, userId } = getExtra(extra);
1504
+ return unwrap(
1505
+ await emdash.handleMediaCreate({
1506
+ filename: args.filename,
1507
+ mimeType: args.mimeType,
1508
+ storageKey: args.storageKey,
1509
+ size: args.size,
1510
+ width: args.width,
1511
+ height: args.height,
1512
+ contentHash: args.contentHash,
1513
+ blurhash: args.blurhash,
1514
+ dominantColor: args.dominantColor,
1515
+ authorId: userId,
1516
+ }),
1517
+ );
1518
+ },
1519
+ );
1520
+
1175
1521
  server.registerTool(
1176
1522
  "media_get",
1177
1523
  {
@@ -1305,7 +1651,7 @@ export function createMcpServer(): McpServer {
1305
1651
  });
1306
1652
  return jsonResult(results);
1307
1653
  } catch (error) {
1308
- return errorResult(error);
1654
+ return respondHandlerError(error, "SEARCH_ERROR");
1309
1655
  }
1310
1656
  },
1311
1657
  );
@@ -1332,7 +1678,7 @@ export function createMcpServer(): McpServer {
1332
1678
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1333
1679
  return unwrap(await handleTaxonomyList(ec.db));
1334
1680
  } catch (error) {
1335
- return errorResult(error);
1681
+ return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
1336
1682
  }
1337
1683
  },
1338
1684
  );
@@ -1348,7 +1694,7 @@ export function createMcpServer(): McpServer {
1348
1694
  inputSchema: z.object({
1349
1695
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1350
1696
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1351
- cursor: z.string().optional().describe("Pagination cursor"),
1697
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1352
1698
  }),
1353
1699
  annotations: { readOnlyHint: true },
1354
1700
  },
@@ -1364,24 +1710,47 @@ export function createMcpServer(): McpServer {
1364
1710
  const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
1365
1711
  .taxonomies;
1366
1712
  const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1367
- if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1713
+ if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
1368
1714
 
1369
1715
  // Paginated term query via repository (avoids N+1 of handleTermList)
1370
1716
  const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1717
+ const { decodeCursor, encodeCursor, InvalidCursorError } =
1718
+ await import("../database/repositories/types.js");
1371
1719
  const repo = new TaxonomyRepository(ec.db);
1372
1720
  const limit = Math.min(args.limit ?? 50, 100);
1373
1721
  const terms = await repo.findByName(args.taxonomy);
1374
1722
 
1375
- // Manual cursor pagination over the sorted results
1723
+ // Manual keyset pagination over the sorted-by-label results.
1724
+ // Using a base64-encoded `(label, id)` cursor matches the
1725
+ // scheme other list endpoints use and tolerates concurrent
1726
+ // deletion of the cursor-term — the cursor is a position,
1727
+ // not a row reference, so a missing row just means we skip
1728
+ // past it rather than erroring.
1376
1729
  let startIdx = 0;
1377
1730
  if (args.cursor) {
1378
- const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
1379
- if (cursorIdx >= 0) startIdx = cursorIdx + 1;
1731
+ let decoded: { orderValue: string; id: string };
1732
+ try {
1733
+ decoded = decodeCursor(args.cursor);
1734
+ } catch (error) {
1735
+ if (error instanceof InvalidCursorError) {
1736
+ return respondError("INVALID_CURSOR", error.message);
1737
+ }
1738
+ throw error;
1739
+ }
1740
+ // Find the first term that sorts strictly after the cursor
1741
+ // position. Stable order is `(label asc, id asc)` so a
1742
+ // `(label, id)` tuple comparison is the keyset.
1743
+ startIdx = terms.findIndex(
1744
+ (t) =>
1745
+ t.label > decoded.orderValue || (t.label === decoded.orderValue && t.id > decoded.id),
1746
+ );
1747
+ if (startIdx < 0) startIdx = terms.length;
1380
1748
  }
1381
1749
 
1382
1750
  const page = terms.slice(startIdx, startIdx + limit);
1383
1751
  const hasMore = startIdx + limit < terms.length;
1384
- const nextCursor = hasMore ? page.at(-1)?.id : undefined;
1752
+ const last = page.at(-1);
1753
+ const nextCursor = hasMore && last ? encodeCursor(last.label, last.id) : undefined;
1385
1754
 
1386
1755
  return jsonResult({
1387
1756
  items: page.map((t) => ({
@@ -1395,7 +1764,7 @@ export function createMcpServer(): McpServer {
1395
1764
  nextCursor,
1396
1765
  });
1397
1766
  } catch (error) {
1398
- return errorResult(error);
1767
+ return respondHandlerError(error, "TAXONOMY_LIST_TERMS_ERROR");
1399
1768
  }
1400
1769
  },
1401
1770
  );
@@ -1406,7 +1775,10 @@ export function createMcpServer(): McpServer {
1406
1775
  title: "Create Taxonomy Term",
1407
1776
  description:
1408
1777
  "Create a new term in a taxonomy. For hierarchical taxonomies like " +
1409
- "categories, you can specify a parentId to create a child term.",
1778
+ "categories, you can specify a parentId to create a child term. The " +
1779
+ "parent must exist and belong to the same taxonomy. The parent's " +
1780
+ "ancestor chain must not exceed 100 levels — attempts to attach a " +
1781
+ "new term beneath a chain of 100+ existing ancestors are rejected.",
1410
1782
  inputSchema: z.object({
1411
1783
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1412
1784
  slug: z.string().describe("URL-safe identifier for the term"),
@@ -1416,7 +1788,7 @@ export function createMcpServer(): McpServer {
1416
1788
  }),
1417
1789
  },
1418
1790
  async (args, extra) => {
1419
- requireScope(extra, "content:write");
1791
+ requireScope(extra, "taxonomies:manage");
1420
1792
  requireRole(extra, Role.EDITOR);
1421
1793
  const ec = getEmDash(extra);
1422
1794
  try {
@@ -1430,7 +1802,75 @@ export function createMcpServer(): McpServer {
1430
1802
  }),
1431
1803
  );
1432
1804
  } catch (error) {
1433
- return errorResult(error);
1805
+ return respondHandlerError(error, "TAXONOMY_TERM_CREATE_ERROR");
1806
+ }
1807
+ },
1808
+ );
1809
+
1810
+ server.registerTool(
1811
+ "taxonomy_update_term",
1812
+ {
1813
+ title: "Update Taxonomy Term",
1814
+ description:
1815
+ "Update an existing term in a taxonomy. Any field can be omitted to leave " +
1816
+ "it unchanged. Renaming a term's slug must not collide with another term in " +
1817
+ "the same taxonomy. Set parentId to null to detach from a parent. The new " +
1818
+ "parent must exist, belong to the same taxonomy, and not introduce a cycle " +
1819
+ "(a term cannot be its own ancestor). The new parent's ancestor chain must " +
1820
+ "not exceed 100 levels — reparenting under a chain of 100+ ancestors is " +
1821
+ "rejected.",
1822
+ inputSchema: z.object({
1823
+ taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1824
+ termSlug: z.string().describe("Current slug of the term to update"),
1825
+ slug: z.string().optional().describe("New slug (must be unique in the taxonomy)"),
1826
+ label: z.string().optional().describe("New display name"),
1827
+ parentId: z.string().nullable().optional().describe("New parent term ID; null to detach"),
1828
+ description: z.string().optional().describe("New description"),
1829
+ }),
1830
+ },
1831
+ async (args, extra) => {
1832
+ requireScope(extra, "taxonomies:manage");
1833
+ requireRole(extra, Role.EDITOR);
1834
+ const ec = getEmDash(extra);
1835
+ try {
1836
+ const { handleTermUpdate } = await import("../api/handlers/taxonomies.js");
1837
+ return unwrap(
1838
+ await handleTermUpdate(ec.db, args.taxonomy, args.termSlug, {
1839
+ slug: args.slug,
1840
+ label: args.label,
1841
+ parentId: args.parentId,
1842
+ description: args.description,
1843
+ }),
1844
+ );
1845
+ } catch (error) {
1846
+ return respondHandlerError(error, "TAXONOMY_TERM_UPDATE_ERROR");
1847
+ }
1848
+ },
1849
+ );
1850
+
1851
+ server.registerTool(
1852
+ "taxonomy_delete_term",
1853
+ {
1854
+ title: "Delete Taxonomy Term",
1855
+ description:
1856
+ "Permanently delete a term from a taxonomy. Any content tagged with this " +
1857
+ "term loses the association. Cannot delete a term that has children — " +
1858
+ "delete children first.",
1859
+ inputSchema: z.object({
1860
+ taxonomy: z.string().describe("Taxonomy name"),
1861
+ termSlug: z.string().describe("Slug of the term to delete"),
1862
+ }),
1863
+ annotations: { destructiveHint: true },
1864
+ },
1865
+ async (args, extra) => {
1866
+ requireScope(extra, "taxonomies:manage");
1867
+ requireRole(extra, Role.EDITOR);
1868
+ const ec = getEmDash(extra);
1869
+ try {
1870
+ const { handleTermDelete } = await import("../api/handlers/taxonomies.js");
1871
+ return unwrap(await handleTermDelete(ec.db, args.taxonomy, args.termSlug));
1872
+ } catch (error) {
1873
+ return respondHandlerError(error, "TAXONOMY_TERM_DELETE_ERROR");
1434
1874
  }
1435
1875
  },
1436
1876
  );
@@ -1454,20 +1894,10 @@ export function createMcpServer(): McpServer {
1454
1894
  requireScope(extra, "content:read");
1455
1895
  const ec = getEmDash(extra);
1456
1896
  try {
1457
- const menus = await ec.db
1458
- .selectFrom("_emdash_menus" as never)
1459
- .select([
1460
- "id" as never,
1461
- "name" as never,
1462
- "label" as never,
1463
- "created_at" as never,
1464
- "updated_at" as never,
1465
- ])
1466
- .orderBy("name" as never, "asc")
1467
- .execute();
1468
- return jsonResult(menus);
1897
+ const { handleMenuList } = await import("../api/handlers/menus.js");
1898
+ return unwrap(await handleMenuList(ec.db));
1469
1899
  } catch (error) {
1470
- return errorResult(error);
1900
+ return respondHandlerError(error, "MENU_LIST_ERROR");
1471
1901
  }
1472
1902
  },
1473
1903
  );
@@ -1489,24 +1919,146 @@ export function createMcpServer(): McpServer {
1489
1919
  requireScope(extra, "content:read");
1490
1920
  const ec = getEmDash(extra);
1491
1921
  try {
1492
- const menu = (await ec.db
1493
- .selectFrom("_emdash_menus" as never)
1494
- .selectAll()
1495
- .where("name" as never, "=", args.name as never)
1496
- .executeTakeFirst()) as { id: string } | undefined;
1497
-
1498
- if (!menu) return errorResult(`Menu '${args.name}' not found`);
1499
-
1500
- const items = await ec.db
1501
- .selectFrom("_emdash_menu_items" as never)
1502
- .selectAll()
1503
- .where("menu_id" as never, "=", menu.id as never)
1504
- .orderBy("sort_order" as never, "asc")
1505
- .execute();
1506
-
1507
- return jsonResult({ ...menu, items });
1922
+ const { handleMenuGet } = await import("../api/handlers/menus.js");
1923
+ return unwrap(await handleMenuGet(ec.db, args.name));
1924
+ } catch (error) {
1925
+ return respondHandlerError(error, "MENU_GET_ERROR");
1926
+ }
1927
+ },
1928
+ );
1929
+
1930
+ server.registerTool(
1931
+ "menu_create",
1932
+ {
1933
+ title: "Create Menu",
1934
+ description:
1935
+ "Create a new navigation menu. The `name` is the stable identifier used " +
1936
+ "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
1937
+ "name shown in the admin. Add items afterwards with menu_set_items.",
1938
+ inputSchema: z.object({
1939
+ name: z
1940
+ .string()
1941
+ .regex(COLLECTION_SLUG_PATTERN)
1942
+ .describe("Stable identifier (lowercase letters, numbers, underscores)"),
1943
+ label: z.string().describe("Display name for the admin"),
1944
+ }),
1945
+ },
1946
+ async (args, extra) => {
1947
+ requireScope(extra, "menus:manage");
1948
+ requireRole(extra, Role.EDITOR);
1949
+ const ec = getEmDash(extra);
1950
+ try {
1951
+ const { handleMenuCreate } = await import("../api/handlers/menus.js");
1952
+ return unwrap(await handleMenuCreate(ec.db, { name: args.name, label: args.label }));
1508
1953
  } catch (error) {
1509
- return errorResult(error);
1954
+ return respondHandlerError(error, "MENU_CREATE_ERROR");
1955
+ }
1956
+ },
1957
+ );
1958
+
1959
+ server.registerTool(
1960
+ "menu_update",
1961
+ {
1962
+ title: "Update Menu",
1963
+ description: "Update a menu's label. The `name` (stable identifier) cannot be changed.",
1964
+ inputSchema: z.object({
1965
+ name: z.string().describe("Menu name to update"),
1966
+ label: z.string().describe("New display label"),
1967
+ }),
1968
+ },
1969
+ async (args, extra) => {
1970
+ requireScope(extra, "menus:manage");
1971
+ requireRole(extra, Role.EDITOR);
1972
+ const ec = getEmDash(extra);
1973
+ try {
1974
+ const { handleMenuUpdate } = await import("../api/handlers/menus.js");
1975
+ return unwrap(await handleMenuUpdate(ec.db, args.name, { label: args.label }));
1976
+ } catch (error) {
1977
+ return respondHandlerError(error, "MENU_UPDATE_ERROR");
1978
+ }
1979
+ },
1980
+ );
1981
+
1982
+ server.registerTool(
1983
+ "menu_delete",
1984
+ {
1985
+ title: "Delete Menu",
1986
+ description: "Delete a menu. Items are also removed. Cannot be undone.",
1987
+ inputSchema: z.object({
1988
+ name: z.string().describe("Menu name to delete"),
1989
+ }),
1990
+ annotations: { destructiveHint: true },
1991
+ },
1992
+ async (args, extra) => {
1993
+ requireScope(extra, "menus:manage");
1994
+ requireRole(extra, Role.EDITOR);
1995
+ const ec = getEmDash(extra);
1996
+ try {
1997
+ const { handleMenuDelete } = await import("../api/handlers/menus.js");
1998
+ return unwrap(await handleMenuDelete(ec.db, args.name));
1999
+ } catch (error) {
2000
+ return respondHandlerError(error, "MENU_DELETE_ERROR");
2001
+ }
2002
+ },
2003
+ );
2004
+
2005
+ server.registerTool(
2006
+ "menu_set_items",
2007
+ {
2008
+ title: "Set Menu Items",
2009
+ description:
2010
+ "Replace the entire item list of a menu in one call. This is atomic: the " +
2011
+ "existing items are deleted and the new list is inserted in the order " +
2012
+ "provided. Use this rather than per-item add/remove tools so the resulting " +
2013
+ "order and parent links are unambiguous.",
2014
+ inputSchema: z.object({
2015
+ name: z.string().describe("Menu name to update"),
2016
+ items: z
2017
+ .array(
2018
+ z.object({
2019
+ label: z.string().describe("Item display text"),
2020
+ type: z
2021
+ .enum(["custom", "page", "post", "taxonomy", "collection"])
2022
+ .describe("Item kind"),
2023
+ customUrl: z
2024
+ .string()
2025
+ .optional()
2026
+ .describe("URL for type='custom' items (ignored otherwise)"),
2027
+ referenceCollection: z
2028
+ .string()
2029
+ .optional()
2030
+ .describe("Target collection slug for content references"),
2031
+ referenceId: z.string().optional().describe("Target content/term ID for references"),
2032
+ titleAttr: z.string().optional().describe("HTML title attribute"),
2033
+ target: z.string().optional().describe("HTML target attribute, e.g. '_blank'"),
2034
+ cssClasses: z.string().optional().describe("Space-separated CSS classes"),
2035
+ /**
2036
+ * Items are positioned by array index, but parents may be referenced
2037
+ * by their array index — items with `parentIndex` set are nested under
2038
+ * the item at that position. Items without `parentIndex` are top-level.
2039
+ */
2040
+ parentIndex: z
2041
+ .number()
2042
+ .int()
2043
+ .nonnegative()
2044
+ .optional()
2045
+ .describe(
2046
+ "Array index of the parent item (must be earlier in the list). Omit for top-level items.",
2047
+ ),
2048
+ }),
2049
+ )
2050
+ .describe("Ordered list of menu items"),
2051
+ }),
2052
+ },
2053
+ async (args, extra) => {
2054
+ requireScope(extra, "menus:manage");
2055
+ requireRole(extra, Role.EDITOR);
2056
+ const ec = getEmDash(extra);
2057
+ try {
2058
+ const { handleMenuSetItems } = await import("../api/handlers/menus.js");
2059
+ return unwrap(await handleMenuSetItems(ec.db, args.name, args.items));
2060
+ } catch (error) {
2061
+ return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
1510
2062
  }
1511
2063
  },
1512
2064
  );
@@ -1566,7 +2118,10 @@ export function createMcpServer(): McpServer {
1566
2118
  }
1567
2119
  const revItem = revision.data?.item;
1568
2120
  if (!revItem?.collection || !revItem?.entryId) {
1569
- return errorResult("Revision is missing collection or entry reference");
2121
+ return respondError(
2122
+ "VALIDATION_ERROR",
2123
+ "Revision is missing collection or entry reference",
2124
+ );
1570
2125
  }
1571
2126
 
1572
2127
  // Fetch the content entry to check ownership
@@ -1585,5 +2140,90 @@ export function createMcpServer(): McpServer {
1585
2140
  },
1586
2141
  );
1587
2142
 
2143
+ // =====================================================================
2144
+ // Settings tools
2145
+ // =====================================================================
2146
+
2147
+ server.registerTool(
2148
+ "settings_get",
2149
+ {
2150
+ title: "Get Site Settings",
2151
+ description:
2152
+ "Get all site-wide settings (title, tagline, logo, favicon, URL, " +
2153
+ "date/time formatting, social links, SEO defaults). Media references " +
2154
+ "(logo, favicon, defaultOgImage) include resolved URLs. Unset values " +
2155
+ "are omitted from the response.",
2156
+ inputSchema: z.object({}),
2157
+ annotations: { readOnlyHint: true },
2158
+ },
2159
+ async (_args, extra) => {
2160
+ requireScope(extra, "settings:read");
2161
+ requireRole(extra, Role.EDITOR);
2162
+ const ec = getEmDash(extra);
2163
+ try {
2164
+ const { handleSettingsGet } = await import("../api/handlers/settings.js");
2165
+ return unwrap(await handleSettingsGet(ec.db, ec.storage));
2166
+ } catch (error) {
2167
+ return respondHandlerError(error, "SETTINGS_READ_ERROR");
2168
+ }
2169
+ },
2170
+ );
2171
+
2172
+ server.registerTool(
2173
+ "settings_update",
2174
+ {
2175
+ title: "Update Site Settings",
2176
+ description:
2177
+ "Update one or more site-wide settings. This is a partial update: only " +
2178
+ "the fields provided are changed; omitted fields are left as-is. Returns " +
2179
+ "the full settings object after the update. To set a media reference " +
2180
+ "(logo, favicon, seo.defaultOgImage), pass an object with `mediaId` " +
2181
+ "(and optional `alt`) — the media item must already exist (use " +
2182
+ "media_create first).",
2183
+ inputSchema: z.object({
2184
+ title: z.string().optional().describe("Site title"),
2185
+ tagline: z.string().optional().describe("Site tagline / short description"),
2186
+ logo: settingsMediaReferenceSchema
2187
+ .optional()
2188
+ .describe("Logo media reference ({ mediaId, alt? })"),
2189
+ favicon: settingsMediaReferenceSchema
2190
+ .optional()
2191
+ .describe("Favicon media reference ({ mediaId, alt? })"),
2192
+ url: z
2193
+ .union([
2194
+ z
2195
+ .string()
2196
+ .url()
2197
+ .refine((u) => HTTP_SCHEME_PATTERN.test(u), "URL must use http or https"),
2198
+ z.literal(""),
2199
+ ])
2200
+ .optional()
2201
+ .describe("Canonical site URL (http or https). Empty string clears it."),
2202
+ postsPerPage: z
2203
+ .number()
2204
+ .int()
2205
+ .min(1)
2206
+ .max(100)
2207
+ .optional()
2208
+ .describe("Default page size for content listings"),
2209
+ dateFormat: z.string().optional().describe("Date format token string"),
2210
+ timezone: z.string().optional().describe("IANA timezone identifier"),
2211
+ social: settingsSocialSchema.optional().describe("Social handles / URLs"),
2212
+ seo: settingsSeoSchema.optional().describe("Site-wide SEO defaults"),
2213
+ }),
2214
+ },
2215
+ async (args, extra) => {
2216
+ requireScope(extra, "settings:manage");
2217
+ requireRole(extra, Role.ADMIN);
2218
+ const ec = getEmDash(extra);
2219
+ try {
2220
+ const { handleSettingsUpdate } = await import("../api/handlers/settings.js");
2221
+ return unwrap(await handleSettingsUpdate(ec.db, ec.storage, args));
2222
+ } catch (error) {
2223
+ return respondHandlerError(error, "SETTINGS_UPDATE_ERROR");
2224
+ }
2225
+ },
2226
+ );
2227
+
1588
2228
  return server;
1589
2229
  }