emdash 0.7.0 → 1.0.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 (225) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
  4. package/dist/apply-x0eMK1lX.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 +86 -15
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +22 -2
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.mjs +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +259 -71
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +16 -8
  20. package/dist/astro/types.d.mts.map +1 -1
  21. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  22. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  23. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  24. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  25. package/dist/cli/index.mjs +16 -12
  26. package/dist/cli/index.mjs.map +1 -1
  27. package/dist/client/cf-access.d.mts +1 -1
  28. package/dist/client/index.d.mts +1 -1
  29. package/dist/client/index.mjs +1 -1
  30. package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
  31. package/dist/content-BcQPYxdV.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/libsql.d.mts +1 -1
  34. package/dist/db/postgres.d.mts +1 -1
  35. package/dist/db/sqlite.d.mts +1 -1
  36. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  37. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  38. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  39. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  40. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  41. package/dist/error-zG5T1UGA.mjs.map +1 -0
  42. package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
  43. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +22 -20
  46. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  47. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  48. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  49. package/dist/loader-CndGj8kM.mjs.map +1 -0
  50. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  51. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/local-runtime.d.mts +7 -7
  54. package/dist/media/local-runtime.mjs +2 -2
  55. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  56. package/dist/media-D8FbNsl0.mjs.map +1 -0
  57. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  58. package/dist/mode-BnAOqItE.mjs.map +1 -0
  59. package/dist/page/index.d.mts +2 -2
  60. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  61. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  62. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  63. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  64. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  65. package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
  66. package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  67. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
  68. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  69. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  70. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  71. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  72. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  73. package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
  74. package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  75. package/dist/runtime.d.mts +6 -6
  76. package/dist/runtime.mjs +2 -2
  77. package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
  78. package/dist/search-BoZYFuUk.mjs.map +1 -0
  79. package/dist/seed/index.d.mts +2 -2
  80. package/dist/seed/index.mjs +12 -12
  81. package/dist/seo/index.d.mts +1 -1
  82. package/dist/storage/local.d.mts +1 -1
  83. package/dist/storage/local.mjs +1 -1
  84. package/dist/storage/s3.d.mts +1 -1
  85. package/dist/storage/s3.d.mts.map +1 -1
  86. package/dist/storage/s3.mjs +4 -4
  87. package/dist/storage/s3.mjs.map +1 -1
  88. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  89. package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  90. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  91. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  92. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  93. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  94. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  95. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  96. package/dist/types-BIgulNsW.mjs +68 -0
  97. package/dist/types-BIgulNsW.mjs.map +1 -0
  98. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  99. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  100. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  101. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  102. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  103. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  104. package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
  105. package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  106. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  107. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  108. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  109. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  110. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  111. package/dist/types-i36XcA_X.d.mts.map +1 -0
  112. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  113. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  114. package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  115. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  116. package/dist/validation-C-ZpN2GI.mjs +144 -0
  117. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  118. package/dist/version-DJrV1K0M.mjs +7 -0
  119. package/dist/{version-BnTKdfam.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
  120. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  121. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  122. package/package.json +19 -6
  123. package/src/api/auth-storage.ts +37 -0
  124. package/src/api/error.ts +6 -0
  125. package/src/api/errors.ts +8 -0
  126. package/src/api/handlers/comments.ts +13 -0
  127. package/src/api/handlers/content.ts +122 -3
  128. package/src/api/handlers/index.ts +2 -0
  129. package/src/api/handlers/media.ts +8 -1
  130. package/src/api/handlers/menus.ts +160 -21
  131. package/src/api/handlers/redirects.ts +16 -3
  132. package/src/api/handlers/sections.ts +8 -1
  133. package/src/api/handlers/taxonomies.ts +128 -16
  134. package/src/api/handlers/validation.ts +212 -0
  135. package/src/api/openapi/document.ts +4 -1
  136. package/src/api/public-url.ts +6 -3
  137. package/src/api/route-utils.ts +14 -0
  138. package/src/api/schemas/common.ts +1 -1
  139. package/src/api/schemas/setup.ts +8 -0
  140. package/src/api/schemas/widgets.ts +12 -10
  141. package/src/api/setup-complete.ts +40 -0
  142. package/src/astro/integration/index.ts +13 -2
  143. package/src/astro/integration/routes.ts +28 -0
  144. package/src/astro/integration/runtime.ts +19 -1
  145. package/src/astro/integration/virtual-modules.ts +41 -0
  146. package/src/astro/integration/vite-config.ts +43 -12
  147. package/src/astro/middleware/auth.ts +21 -0
  148. package/src/astro/middleware.ts +18 -1
  149. package/src/astro/routes/PluginRegistry.tsx +10 -1
  150. package/src/astro/routes/api/auth/mode.ts +57 -0
  151. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  152. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  153. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  154. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  155. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  156. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  157. package/src/astro/routes/api/settings/email.ts +4 -9
  158. package/src/astro/routes/api/setup/admin.ts +8 -2
  159. package/src/astro/routes/api/setup/index.ts +2 -2
  160. package/src/astro/routes/api/setup/status.ts +3 -1
  161. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  162. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  163. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  164. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  165. package/src/astro/types.ts +9 -0
  166. package/src/auth/mode.ts +15 -3
  167. package/src/auth/providers/github-admin.tsx +29 -0
  168. package/src/auth/providers/github.ts +31 -0
  169. package/src/auth/providers/google-admin.tsx +44 -0
  170. package/src/auth/providers/google.ts +31 -0
  171. package/src/auth/types.ts +114 -4
  172. package/src/cli/commands/bundle.ts +3 -1
  173. package/src/components/EmDashImage.astro +7 -6
  174. package/src/components/Gallery.astro +5 -3
  175. package/src/components/Image.astro +8 -3
  176. package/src/components/InlinePortableTextEditor.tsx +2 -1
  177. package/src/components/LiveSearch.astro +5 -14
  178. package/src/database/repositories/audit.ts +6 -8
  179. package/src/database/repositories/byline.ts +6 -8
  180. package/src/database/repositories/comment.ts +12 -16
  181. package/src/database/repositories/content.ts +40 -40
  182. package/src/database/repositories/index.ts +1 -1
  183. package/src/database/repositories/media.ts +10 -13
  184. package/src/database/repositories/plugin-storage.ts +4 -6
  185. package/src/database/repositories/redirect.ts +12 -16
  186. package/src/database/repositories/taxonomy.ts +14 -3
  187. package/src/database/repositories/types.ts +57 -8
  188. package/src/database/repositories/user.ts +6 -8
  189. package/src/emdash-runtime.ts +306 -90
  190. package/src/index.ts +5 -1
  191. package/src/loader.ts +6 -5
  192. package/src/mcp/server.ts +678 -105
  193. package/src/media/normalize.ts +1 -1
  194. package/src/media/url.ts +78 -0
  195. package/src/plugins/email-console.ts +10 -3
  196. package/src/plugins/hooks.ts +11 -0
  197. package/src/plugins/manifest-schema.ts +12 -0
  198. package/src/plugins/types.ts +23 -2
  199. package/src/query.ts +1 -1
  200. package/src/request-cache.ts +3 -0
  201. package/src/schema/registry.ts +41 -5
  202. package/src/search/fts-manager.ts +0 -2
  203. package/src/search/query.ts +111 -26
  204. package/src/search/types.ts +8 -1
  205. package/src/sections/index.ts +7 -9
  206. package/src/storage/s3.ts +12 -6
  207. package/src/virtual-modules.d.ts +21 -1
  208. package/src/widgets/index.ts +1 -1
  209. package/dist/apply-5uslYdUu.mjs.map +0 -1
  210. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  211. package/dist/content-D7J5y73J.mjs.map +0 -1
  212. package/dist/error-CiYn9yDu.mjs.map +0 -1
  213. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  214. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  215. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  216. package/dist/media-DqHVh136.mjs.map +0 -1
  217. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  218. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  219. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  220. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  221. package/dist/search-B0effn3j.mjs.map +0 -1
  222. package/dist/types-CMMN0pNg.mjs +0 -31
  223. package/dist/types-CMMN0pNg.mjs.map +0 -1
  224. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  225. package/dist/version-BnTKdfam.mjs +0 -7
package/src/mcp/server.ts CHANGED
@@ -12,61 +12,222 @@
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
 
18
17
  import type { EmDashHandlers } from "../astro/types.js";
19
18
  import { hasScope } from "../auth/api-tokens.js";
20
19
 
21
20
  const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/;
21
+ /** http(s) scheme matcher used by `settings_update` URL validation. */
22
+ const HTTP_SCHEME_PATTERN = /^https?:\/\//i;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared schemas — kept in sync with `api/schemas/settings.ts` (which the
26
+ // REST handler validates against). Defined inline to match the rest of the
27
+ // MCP tool registrations rather than reaching across into the REST layer.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const settingsMediaReferenceSchema = z.object({
31
+ mediaId: z.string().describe("Media item ID (use media_create or media_list)"),
32
+ alt: z.string().optional().describe("Alt text for the media reference"),
33
+ });
34
+
35
+ const settingsSocialSchema = z.object({
36
+ twitter: z.string().optional(),
37
+ github: z.string().optional(),
38
+ facebook: z.string().optional(),
39
+ instagram: z.string().optional(),
40
+ linkedin: z.string().optional(),
41
+ youtube: z.string().optional(),
42
+ });
43
+
44
+ const settingsSeoSchema = z.object({
45
+ titleSeparator: z
46
+ .string()
47
+ .max(10)
48
+ .optional()
49
+ .describe("Separator between page title and site title (e.g. ' | ')"),
50
+ defaultOgImage: settingsMediaReferenceSchema
51
+ .optional()
52
+ .describe("Default Open Graph image when content has none"),
53
+ robotsTxt: z
54
+ .string()
55
+ .max(5000)
56
+ .optional()
57
+ .describe("Custom robots.txt body. Leave unset for the EmDash default."),
58
+ googleVerification: z
59
+ .string()
60
+ .max(100)
61
+ .optional()
62
+ .describe("Google Search Console verification token"),
63
+ bingVerification: z
64
+ .string()
65
+ .max(100)
66
+ .optional()
67
+ .describe("Bing Webmaster Tools verification token"),
68
+ });
22
69
 
23
70
  // ---------------------------------------------------------------------------
24
71
  // Helpers
25
72
  // ---------------------------------------------------------------------------
26
73
 
27
- type HandlerResult = { success: boolean; data?: unknown; error?: unknown };
74
+ type HandlerResult = {
75
+ success: boolean;
76
+ data?: unknown;
77
+ error?: unknown;
78
+ };
79
+
80
+ type SuccessEnvelope = {
81
+ content: Array<{ type: "text"; text: string }>;
82
+ _meta?: Record<string, unknown>;
83
+ };
84
+
85
+ type ErrorEnvelope = {
86
+ content: Array<{ type: "text"; text: string }>;
87
+ isError: true;
88
+ _meta: { code: string; details?: Record<string, unknown> };
89
+ };
28
90
 
29
91
  /**
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.
92
+ * Return a successful tool response with the data as pretty-printed JSON.
33
93
  */
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 };
94
+ function respondData(data: unknown): SuccessEnvelope {
95
+ return {
96
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
97
+ };
48
98
  }
49
99
 
50
100
  /**
51
- * Return a JSON text block.
101
+ * Return a structured error tool response.
102
+ *
103
+ * The error code is emitted both in the human-readable message (as a stable
104
+ * `[CODE]` prefix that callers can match on) and in `_meta.code` so MCP-aware
105
+ * clients can read it programmatically once the SDK supports forwarding meta.
52
106
  */
53
- function jsonResult(data: unknown): {
54
- content: Array<{ type: "text"; text: string }>;
55
- } {
107
+ function respondError(
108
+ code: string,
109
+ message: string,
110
+ details?: Record<string, unknown>,
111
+ ): ErrorEnvelope {
112
+ const text = `[${code}] ${message}`;
113
+ const meta: { code: string; details?: Record<string, unknown> } = { code };
114
+ if (details !== undefined) meta.details = details;
56
115
  return {
57
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
116
+ content: [{ type: "text", text }],
117
+ isError: true,
118
+ _meta: meta,
58
119
  };
59
120
  }
60
121
 
61
122
  /**
62
- * Return an error text block.
123
+ * Auth/permission errors thrown from `requireScope` / `requireRole` /
124
+ * `requireOwnership` / `requireDraftAccess`. Carries a stable string `code`
125
+ * field so `respondHandlerError` can surface it through `_meta.code` and
126
+ * the message prefix.
127
+ *
128
+ * Distinct from `McpError` (which the SDK catches at JSON-RPC level — the
129
+ * code there is numeric, not a stable EmDash error code).
63
130
  */
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 };
131
+ class EmDashAuthError extends Error {
132
+ override readonly name = "EmDashAuthError";
133
+ constructor(
134
+ message: string,
135
+ readonly code: string,
136
+ ) {
137
+ super(message);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Map an unknown thrown error to a structured error envelope.
143
+ *
144
+ * Recognises (in priority order):
145
+ * - `EmDashAuthError` — `code` is a stable EmDash auth code
146
+ * (`UNAUTHORIZED`, `INSUFFICIENT_SCOPE`, `INSUFFICIENT_PERMISSIONS`).
147
+ * - `Error` objects with an `apiError: { code, details? }` annotation
148
+ * (handlers throw these for NOT_FOUND / CONFLICT inside transactions;
149
+ * see `api/handlers/content.ts:538`).
150
+ * - `SchemaError` (and any error with a string `code` field) — the code
151
+ * is forwarded verbatim. `details` is forwarded too if present.
152
+ * - Plain `Error` instances — message preserved, code falls back to
153
+ * `fallbackCode` (or `INTERNAL_ERROR`).
154
+ * - Strings — used directly as the message.
155
+ * - Anything else — coerced via `String()`.
156
+ *
157
+ * The original message is always preserved so tests and humans can see the
158
+ * specific failure cause. Numeric `code` values (e.g. on `McpError`) are
159
+ * ignored — the field is reserved for stable string codes.
160
+ */
161
+ function respondHandlerError(error: unknown, fallbackCode = "INTERNAL_ERROR"): ErrorEnvelope {
162
+ let code = fallbackCode;
163
+ let message: string;
164
+ let details: Record<string, unknown> | undefined;
165
+
166
+ if (error instanceof EmDashAuthError) {
167
+ message = error.message || fallbackCode;
168
+ code = error.code;
169
+ } else if (error instanceof Error) {
170
+ message = error.message || fallbackCode;
171
+ const apiError = (error as { apiError?: { code?: string; details?: unknown } }).apiError;
172
+ if (apiError && typeof apiError.code === "string" && apiError.code) {
173
+ code = apiError.code;
174
+ if (apiError.details && typeof apiError.details === "object") {
175
+ details = apiError.details as Record<string, unknown>;
176
+ }
177
+ } else {
178
+ // Errors that carry their own `code` (SchemaError, custom errors).
179
+ // Skip numeric codes (McpError, Node fs errors) — `_meta.code` is
180
+ // reserved for stable string codes.
181
+ const rawCode = (error as { code?: unknown }).code;
182
+ if (typeof rawCode === "string" && rawCode) {
183
+ code = rawCode;
184
+ }
185
+ const rawDetails = (error as { details?: unknown }).details;
186
+ if (rawDetails && typeof rawDetails === "object") {
187
+ details = rawDetails as Record<string, unknown>;
188
+ }
189
+ }
190
+ } else if (typeof error === "string") {
191
+ message = error;
192
+ } else {
193
+ message = String(error);
194
+ }
195
+
196
+ return respondError(code, message, details);
197
+ }
198
+
199
+ /**
200
+ * Unwrap an ApiResult<T> into MCP tool result format.
201
+ *
202
+ * On success returns the data as JSON. On failure propagates the structured
203
+ * `{ code, message, details }` from the handler so the caller sees both a
204
+ * machine-readable code (in `_meta.code` and as a `[CODE]` message prefix)
205
+ * and the original human-readable message.
206
+ */
207
+ function unwrap(result: HandlerResult): SuccessEnvelope | ErrorEnvelope {
208
+ if (result.success && result.data !== undefined) {
209
+ return respondData(result.data);
210
+ }
211
+ const err =
212
+ result.error && typeof result.error === "object"
213
+ ? (result.error as { code?: unknown; message?: unknown; details?: unknown })
214
+ : undefined;
215
+ if (!err) return respondError("INTERNAL_ERROR", "Unknown error");
216
+ const code = typeof err.code === "string" && err.code ? err.code : "INTERNAL_ERROR";
217
+ const message = typeof err.message === "string" && err.message ? err.message : "Unknown error";
218
+ const details =
219
+ err.details && typeof err.details === "object"
220
+ ? (err.details as Record<string, unknown>)
221
+ : undefined;
222
+ return respondError(code, message, details);
223
+ }
224
+
225
+ /**
226
+ * Return a JSON text block (success path for tools that don't go through
227
+ * the ApiResult-returning handler layer, e.g. schema/menu/taxonomy).
228
+ */
229
+ function jsonResult(data: unknown): SuccessEnvelope {
230
+ return respondData(data);
70
231
  }
71
232
 
72
233
  // ---------------------------------------------------------------------------
@@ -118,7 +279,7 @@ function requireScope(
118
279
  ): void {
119
280
  const payload = getExtra(extra);
120
281
  if (payload.tokenScopes && !hasScope(payload.tokenScopes, scope)) {
121
- throw new McpError(ErrorCode.InvalidRequest, `Insufficient scope: requires ${scope}`);
282
+ throw new EmDashAuthError(`Insufficient scope: requires ${scope}`, "INSUFFICIENT_SCOPE");
122
283
  }
123
284
  }
124
285
 
@@ -135,7 +296,10 @@ function requireRole(
135
296
  ): void {
136
297
  const payload = getExtra(extra);
137
298
  if (payload.userRole < minRole) {
138
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
299
+ throw new EmDashAuthError(
300
+ "Insufficient permissions for this operation",
301
+ "INSUFFICIENT_PERMISSIONS",
302
+ );
139
303
  }
140
304
  }
141
305
 
@@ -155,7 +319,10 @@ function canReadDrafts(extra: { authInfo?: { extra?: Record<string, unknown> } }
155
319
  */
156
320
  function requireDraftAccess(extra: { authInfo?: { extra?: Record<string, unknown> } }): void {
157
321
  if (!canReadDrafts(extra)) {
158
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
322
+ throw new EmDashAuthError(
323
+ "Insufficient permissions for this operation",
324
+ "INSUFFICIENT_PERMISSIONS",
325
+ );
159
326
  }
160
327
  }
161
328
 
@@ -175,7 +342,10 @@ function requireOwnership(
175
342
  const payload = getExtra(extra);
176
343
  const user = { id: payload.userId, role: payload.userRole };
177
344
  if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
178
- throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
345
+ throw new EmDashAuthError(
346
+ "Insufficient permissions for this operation",
347
+ "INSUFFICIENT_PERMISSIONS",
348
+ );
179
349
  }
180
350
  }
181
351
 
@@ -183,26 +353,18 @@ function requireOwnership(
183
353
  * Extract the author ID from a content handler response.
184
354
  *
185
355
  * Content handlers return `{ item: { id, authorId, ... }, _rev? }`.
186
- * This helper navigates that shape safely.
356
+ * This helper navigates that shape safely. Returns "" when authorId is
357
+ * missing or non-string (e.g. seed-imported content with no author);
358
+ * `canActOnOwn` then decides based on the caller's permissions —
359
+ * an actor with `*:edit_any` succeeds, an actor with only `*:edit_own`
360
+ * is denied with a clean permission error.
187
361
  */
188
362
  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
- }
363
+ if (!data || typeof data !== "object") return "";
195
364
  const obj = data as Record<string, unknown>;
196
365
  const item =
197
366
  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;
367
+ return typeof item?.authorId === "string" ? item.authorId : "";
206
368
  }
207
369
 
208
370
  /**
@@ -227,6 +389,34 @@ export function createMcpServer(): McpServer {
227
389
  { capabilities: { logging: {} } },
228
390
  );
229
391
 
392
+ // Wrap every tool registration's callback so EmDashAuthError throws
393
+ // (from requireScope / requireRole / requireOwnership / requireDraftAccess)
394
+ // surface as structured `_meta.code`-bearing tool error envelopes
395
+ // instead of the SDK's text-only fallback in createToolError().
396
+ //
397
+ // Type-erased on purpose — the SDK's overloads are too narrow for a
398
+ // generic wrapper, but the runtime contract (callback returns the tool
399
+ // result envelope) holds for every registered tool.
400
+ const originalRegisterTool = server.registerTool.bind(server);
401
+ (server as { registerTool: typeof server.registerTool }).registerTool = ((
402
+ name: string,
403
+ config: unknown,
404
+ callback: (...callbackArgs: unknown[]) => Promise<SuccessEnvelope | ErrorEnvelope>,
405
+ ) => {
406
+ const wrapped = async (
407
+ ...callbackArgs: unknown[]
408
+ ): Promise<SuccessEnvelope | ErrorEnvelope> => {
409
+ try {
410
+ return await callback(...callbackArgs);
411
+ } catch (error) {
412
+ return respondHandlerError(error, "INTERNAL_ERROR");
413
+ }
414
+ };
415
+ return (
416
+ originalRegisterTool as unknown as (n: string, c: unknown, cb: typeof wrapped) => unknown
417
+ )(name, config, wrapped);
418
+ }) as typeof server.registerTool;
419
+
230
420
  // =====================================================================
231
421
  // Content tools
232
422
  // =====================================================================
@@ -253,7 +443,12 @@ export function createMcpServer(): McpServer {
253
443
  .max(100)
254
444
  .optional()
255
445
  .describe("Max items to return (default 50, max 100)"),
256
- cursor: z.string().optional().describe("Pagination cursor from a previous response"),
446
+ cursor: z
447
+ .string()
448
+ .min(1)
449
+ .max(2048)
450
+ .optional()
451
+ .describe("Pagination cursor from a previous response"),
257
452
  orderBy: z
258
453
  .string()
259
454
  .optional()
@@ -388,9 +583,9 @@ export function createMcpServer(): McpServer {
388
583
  if (args.status === "published") {
389
584
  const user = { id: userId, role: getExtra(extra).userRole };
390
585
  if (!hasPermission(user, "content:publish_own" as Permission)) {
391
- throw new McpError(
392
- ErrorCode.InvalidRequest,
586
+ throw new EmDashAuthError(
393
587
  "Insufficient permissions: publishing requires content:publish_own",
588
+ "INSUFFICIENT_PERMISSIONS",
394
589
  );
395
590
  }
396
591
  const result = await emdash.handleContentCreate(args.collection, {
@@ -714,6 +909,40 @@ export function createMcpServer(): McpServer {
714
909
  },
715
910
  );
716
911
 
912
+ server.registerTool(
913
+ "content_unschedule",
914
+ {
915
+ title: "Cancel Scheduled Publication",
916
+ description:
917
+ "Cancel a previously scheduled publication. The item remains in its current " +
918
+ "status (typically 'draft' or 'scheduled'); only the scheduledAt timestamp is " +
919
+ "cleared. Idempotent — calling on an item that isn't scheduled is a no-op.",
920
+ inputSchema: z.object({
921
+ collection: z.string().describe("Collection slug"),
922
+ id: z.string().describe("Content item ID or slug"),
923
+ }),
924
+ },
925
+ async (args, extra) => {
926
+ requireScope(extra, "content:write");
927
+ requireRole(extra, Role.AUTHOR);
928
+ const ec = getEmDash(extra);
929
+
930
+ const existing = await ec.handleContentGet(args.collection, args.id);
931
+ if (!existing.success) {
932
+ return unwrap(existing);
933
+ }
934
+ requireOwnership(
935
+ extra,
936
+ extractContentAuthorId(existing.data),
937
+ "content:publish_own",
938
+ "content:publish_any",
939
+ );
940
+
941
+ const resolvedId = extractContentId(existing.data) ?? args.id;
942
+ return unwrap(await ec.handleContentUnschedule(args.collection, resolvedId));
943
+ },
944
+ );
945
+
717
946
  server.registerTool(
718
947
  "content_compare",
719
948
  {
@@ -782,7 +1011,7 @@ export function createMcpServer(): McpServer {
782
1011
  inputSchema: z.object({
783
1012
  collection: z.string().describe("Collection slug"),
784
1013
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
785
- cursor: z.string().optional().describe("Pagination cursor"),
1014
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
786
1015
  }),
787
1016
  annotations: { readOnlyHint: true },
788
1017
  },
@@ -882,7 +1111,7 @@ export function createMcpServer(): McpServer {
882
1111
  const items = await registry.listCollections();
883
1112
  return jsonResult({ items });
884
1113
  } catch (error) {
885
- return errorResult(error);
1114
+ return respondHandlerError(error, "SCHEMA_LIST_ERROR");
886
1115
  }
887
1116
  },
888
1117
  );
@@ -915,11 +1144,11 @@ export function createMcpServer(): McpServer {
915
1144
  const registry = new SchemaRegistry(ec.db);
916
1145
  const collection = await registry.getCollectionWithFields(args.slug);
917
1146
  if (!collection) {
918
- return errorResult(`Collection '${args.slug}' not found`);
1147
+ return respondError("NOT_FOUND", `Collection '${args.slug}' not found`);
919
1148
  }
920
1149
  return jsonResult(collection);
921
1150
  } catch (error) {
922
- return errorResult(error);
1151
+ return respondHandlerError(error, "SCHEMA_GET_ERROR");
923
1152
  }
924
1153
  },
925
1154
  );
@@ -962,12 +1191,14 @@ export function createMcpServer(): McpServer {
962
1191
  labelSingular: args.labelSingular,
963
1192
  description: args.description,
964
1193
  icon: args.icon,
1194
+ // SchemaRegistry.createCollection now defaults `supports` to
1195
+ // ['drafts', 'revisions'] when undefined; pass through verbatim.
965
1196
  supports: args.supports,
966
1197
  });
967
1198
  ec.invalidateManifest();
968
1199
  return jsonResult(collection);
969
1200
  } catch (error) {
970
- return errorResult(error);
1201
+ return respondHandlerError(error, "SCHEMA_CREATE_ERROR");
971
1202
  }
972
1203
  },
973
1204
  );
@@ -999,7 +1230,7 @@ export function createMcpServer(): McpServer {
999
1230
  ec.invalidateManifest();
1000
1231
  return jsonResult({ deleted: args.slug });
1001
1232
  } catch (error) {
1002
- return errorResult(error);
1233
+ return respondHandlerError(error, "SCHEMA_DELETE_ERROR");
1003
1234
  }
1004
1235
  },
1005
1236
  );
@@ -1103,7 +1334,7 @@ export function createMcpServer(): McpServer {
1103
1334
  ec.invalidateManifest();
1104
1335
  return jsonResult(field);
1105
1336
  } catch (error) {
1106
- return errorResult(error);
1337
+ return respondHandlerError(error, "FIELD_CREATE_ERROR");
1107
1338
  }
1108
1339
  },
1109
1340
  );
@@ -1132,7 +1363,7 @@ export function createMcpServer(): McpServer {
1132
1363
  ec.invalidateManifest();
1133
1364
  return jsonResult({ deleted: args.fieldSlug, collection: args.collection });
1134
1365
  } catch (error) {
1135
- return errorResult(error);
1366
+ return respondHandlerError(error, "FIELD_DELETE_ERROR");
1136
1367
  }
1137
1368
  },
1138
1369
  );
@@ -1155,7 +1386,7 @@ export function createMcpServer(): McpServer {
1155
1386
  .optional()
1156
1387
  .describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"),
1157
1388
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1158
- cursor: z.string().optional().describe("Pagination cursor"),
1389
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1159
1390
  }),
1160
1391
  annotations: { readOnlyHint: true },
1161
1392
  },
@@ -1172,6 +1403,54 @@ export function createMcpServer(): McpServer {
1172
1403
  },
1173
1404
  );
1174
1405
 
1406
+ server.registerTool(
1407
+ "media_create",
1408
+ {
1409
+ title: "Register Uploaded Media",
1410
+ description:
1411
+ "Register a media file that has already been uploaded to storage. The " +
1412
+ "caller is responsible for placing the file at `storageKey` (typically " +
1413
+ "using a signed upload URL obtained from the admin UI or a separate API). " +
1414
+ "This tool persists the metadata record so the file is discoverable via " +
1415
+ "media_list / media_get and can be referenced by content. For binary " +
1416
+ "uploads the MCP transport is not appropriate — use the signed-upload " +
1417
+ "flow instead.",
1418
+ inputSchema: z.object({
1419
+ filename: z.string().describe("Original filename (e.g. 'logo.png')"),
1420
+ mimeType: z.string().describe("MIME type (e.g. 'image/png')"),
1421
+ storageKey: z.string().describe("Storage path/key the file was uploaded to"),
1422
+ size: z.number().int().nonnegative().optional().describe("File size in bytes"),
1423
+ width: z.number().int().positive().optional().describe("Image width in pixels"),
1424
+ height: z.number().int().positive().optional().describe("Image height in pixels"),
1425
+ contentHash: z.string().optional().describe("Hash of the file contents (for dedupe)"),
1426
+ blurhash: z.string().optional().describe("Blurhash for image placeholders"),
1427
+ dominantColor: z
1428
+ .string()
1429
+ .optional()
1430
+ .describe("Hex color string for the image's dominant color"),
1431
+ }),
1432
+ },
1433
+ async (args, extra) => {
1434
+ requireScope(extra, "media:write");
1435
+ requireRole(extra, Role.AUTHOR);
1436
+ const { emdash, userId } = getExtra(extra);
1437
+ return unwrap(
1438
+ await emdash.handleMediaCreate({
1439
+ filename: args.filename,
1440
+ mimeType: args.mimeType,
1441
+ storageKey: args.storageKey,
1442
+ size: args.size,
1443
+ width: args.width,
1444
+ height: args.height,
1445
+ contentHash: args.contentHash,
1446
+ blurhash: args.blurhash,
1447
+ dominantColor: args.dominantColor,
1448
+ authorId: userId,
1449
+ }),
1450
+ );
1451
+ },
1452
+ );
1453
+
1175
1454
  server.registerTool(
1176
1455
  "media_get",
1177
1456
  {
@@ -1305,7 +1584,7 @@ export function createMcpServer(): McpServer {
1305
1584
  });
1306
1585
  return jsonResult(results);
1307
1586
  } catch (error) {
1308
- return errorResult(error);
1587
+ return respondHandlerError(error, "SEARCH_ERROR");
1309
1588
  }
1310
1589
  },
1311
1590
  );
@@ -1332,7 +1611,7 @@ export function createMcpServer(): McpServer {
1332
1611
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1333
1612
  return unwrap(await handleTaxonomyList(ec.db));
1334
1613
  } catch (error) {
1335
- return errorResult(error);
1614
+ return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
1336
1615
  }
1337
1616
  },
1338
1617
  );
@@ -1348,7 +1627,7 @@ export function createMcpServer(): McpServer {
1348
1627
  inputSchema: z.object({
1349
1628
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1350
1629
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1351
- cursor: z.string().optional().describe("Pagination cursor"),
1630
+ cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1352
1631
  }),
1353
1632
  annotations: { readOnlyHint: true },
1354
1633
  },
@@ -1364,24 +1643,47 @@ export function createMcpServer(): McpServer {
1364
1643
  const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
1365
1644
  .taxonomies;
1366
1645
  const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1367
- if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1646
+ if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
1368
1647
 
1369
1648
  // Paginated term query via repository (avoids N+1 of handleTermList)
1370
1649
  const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1650
+ const { decodeCursor, encodeCursor, InvalidCursorError } =
1651
+ await import("../database/repositories/types.js");
1371
1652
  const repo = new TaxonomyRepository(ec.db);
1372
1653
  const limit = Math.min(args.limit ?? 50, 100);
1373
1654
  const terms = await repo.findByName(args.taxonomy);
1374
1655
 
1375
- // Manual cursor pagination over the sorted results
1656
+ // Manual keyset pagination over the sorted-by-label results.
1657
+ // Using a base64-encoded `(label, id)` cursor matches the
1658
+ // scheme other list endpoints use and tolerates concurrent
1659
+ // deletion of the cursor-term — the cursor is a position,
1660
+ // not a row reference, so a missing row just means we skip
1661
+ // past it rather than erroring.
1376
1662
  let startIdx = 0;
1377
1663
  if (args.cursor) {
1378
- const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
1379
- if (cursorIdx >= 0) startIdx = cursorIdx + 1;
1664
+ let decoded: { orderValue: string; id: string };
1665
+ try {
1666
+ decoded = decodeCursor(args.cursor);
1667
+ } catch (error) {
1668
+ if (error instanceof InvalidCursorError) {
1669
+ return respondError("INVALID_CURSOR", error.message);
1670
+ }
1671
+ throw error;
1672
+ }
1673
+ // Find the first term that sorts strictly after the cursor
1674
+ // position. Stable order is `(label asc, id asc)` so a
1675
+ // `(label, id)` tuple comparison is the keyset.
1676
+ startIdx = terms.findIndex(
1677
+ (t) =>
1678
+ t.label > decoded.orderValue || (t.label === decoded.orderValue && t.id > decoded.id),
1679
+ );
1680
+ if (startIdx < 0) startIdx = terms.length;
1380
1681
  }
1381
1682
 
1382
1683
  const page = terms.slice(startIdx, startIdx + limit);
1383
1684
  const hasMore = startIdx + limit < terms.length;
1384
- const nextCursor = hasMore ? page.at(-1)?.id : undefined;
1685
+ const last = page.at(-1);
1686
+ const nextCursor = hasMore && last ? encodeCursor(last.label, last.id) : undefined;
1385
1687
 
1386
1688
  return jsonResult({
1387
1689
  items: page.map((t) => ({
@@ -1395,7 +1697,7 @@ export function createMcpServer(): McpServer {
1395
1697
  nextCursor,
1396
1698
  });
1397
1699
  } catch (error) {
1398
- return errorResult(error);
1700
+ return respondHandlerError(error, "TAXONOMY_LIST_TERMS_ERROR");
1399
1701
  }
1400
1702
  },
1401
1703
  );
@@ -1406,7 +1708,10 @@ export function createMcpServer(): McpServer {
1406
1708
  title: "Create Taxonomy Term",
1407
1709
  description:
1408
1710
  "Create a new term in a taxonomy. For hierarchical taxonomies like " +
1409
- "categories, you can specify a parentId to create a child term.",
1711
+ "categories, you can specify a parentId to create a child term. The " +
1712
+ "parent must exist and belong to the same taxonomy. The parent's " +
1713
+ "ancestor chain must not exceed 100 levels — attempts to attach a " +
1714
+ "new term beneath a chain of 100+ existing ancestors are rejected.",
1410
1715
  inputSchema: z.object({
1411
1716
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1412
1717
  slug: z.string().describe("URL-safe identifier for the term"),
@@ -1416,7 +1721,7 @@ export function createMcpServer(): McpServer {
1416
1721
  }),
1417
1722
  },
1418
1723
  async (args, extra) => {
1419
- requireScope(extra, "content:write");
1724
+ requireScope(extra, "taxonomies:manage");
1420
1725
  requireRole(extra, Role.EDITOR);
1421
1726
  const ec = getEmDash(extra);
1422
1727
  try {
@@ -1430,7 +1735,75 @@ export function createMcpServer(): McpServer {
1430
1735
  }),
1431
1736
  );
1432
1737
  } catch (error) {
1433
- return errorResult(error);
1738
+ return respondHandlerError(error, "TAXONOMY_TERM_CREATE_ERROR");
1739
+ }
1740
+ },
1741
+ );
1742
+
1743
+ server.registerTool(
1744
+ "taxonomy_update_term",
1745
+ {
1746
+ title: "Update Taxonomy Term",
1747
+ description:
1748
+ "Update an existing term in a taxonomy. Any field can be omitted to leave " +
1749
+ "it unchanged. Renaming a term's slug must not collide with another term in " +
1750
+ "the same taxonomy. Set parentId to null to detach from a parent. The new " +
1751
+ "parent must exist, belong to the same taxonomy, and not introduce a cycle " +
1752
+ "(a term cannot be its own ancestor). The new parent's ancestor chain must " +
1753
+ "not exceed 100 levels — reparenting under a chain of 100+ ancestors is " +
1754
+ "rejected.",
1755
+ inputSchema: z.object({
1756
+ taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1757
+ termSlug: z.string().describe("Current slug of the term to update"),
1758
+ slug: z.string().optional().describe("New slug (must be unique in the taxonomy)"),
1759
+ label: z.string().optional().describe("New display name"),
1760
+ parentId: z.string().nullable().optional().describe("New parent term ID; null to detach"),
1761
+ description: z.string().optional().describe("New description"),
1762
+ }),
1763
+ },
1764
+ async (args, extra) => {
1765
+ requireScope(extra, "taxonomies:manage");
1766
+ requireRole(extra, Role.EDITOR);
1767
+ const ec = getEmDash(extra);
1768
+ try {
1769
+ const { handleTermUpdate } = await import("../api/handlers/taxonomies.js");
1770
+ return unwrap(
1771
+ await handleTermUpdate(ec.db, args.taxonomy, args.termSlug, {
1772
+ slug: args.slug,
1773
+ label: args.label,
1774
+ parentId: args.parentId,
1775
+ description: args.description,
1776
+ }),
1777
+ );
1778
+ } catch (error) {
1779
+ return respondHandlerError(error, "TAXONOMY_TERM_UPDATE_ERROR");
1780
+ }
1781
+ },
1782
+ );
1783
+
1784
+ server.registerTool(
1785
+ "taxonomy_delete_term",
1786
+ {
1787
+ title: "Delete Taxonomy Term",
1788
+ description:
1789
+ "Permanently delete a term from a taxonomy. Any content tagged with this " +
1790
+ "term loses the association. Cannot delete a term that has children — " +
1791
+ "delete children first.",
1792
+ inputSchema: z.object({
1793
+ taxonomy: z.string().describe("Taxonomy name"),
1794
+ termSlug: z.string().describe("Slug of the term to delete"),
1795
+ }),
1796
+ annotations: { destructiveHint: true },
1797
+ },
1798
+ async (args, extra) => {
1799
+ requireScope(extra, "taxonomies:manage");
1800
+ requireRole(extra, Role.EDITOR);
1801
+ const ec = getEmDash(extra);
1802
+ try {
1803
+ const { handleTermDelete } = await import("../api/handlers/taxonomies.js");
1804
+ return unwrap(await handleTermDelete(ec.db, args.taxonomy, args.termSlug));
1805
+ } catch (error) {
1806
+ return respondHandlerError(error, "TAXONOMY_TERM_DELETE_ERROR");
1434
1807
  }
1435
1808
  },
1436
1809
  );
@@ -1454,20 +1827,10 @@ export function createMcpServer(): McpServer {
1454
1827
  requireScope(extra, "content:read");
1455
1828
  const ec = getEmDash(extra);
1456
1829
  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);
1830
+ const { handleMenuList } = await import("../api/handlers/menus.js");
1831
+ return unwrap(await handleMenuList(ec.db));
1469
1832
  } catch (error) {
1470
- return errorResult(error);
1833
+ return respondHandlerError(error, "MENU_LIST_ERROR");
1471
1834
  }
1472
1835
  },
1473
1836
  );
@@ -1489,24 +1852,146 @@ export function createMcpServer(): McpServer {
1489
1852
  requireScope(extra, "content:read");
1490
1853
  const ec = getEmDash(extra);
1491
1854
  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 });
1855
+ const { handleMenuGet } = await import("../api/handlers/menus.js");
1856
+ return unwrap(await handleMenuGet(ec.db, args.name));
1508
1857
  } catch (error) {
1509
- return errorResult(error);
1858
+ return respondHandlerError(error, "MENU_GET_ERROR");
1859
+ }
1860
+ },
1861
+ );
1862
+
1863
+ server.registerTool(
1864
+ "menu_create",
1865
+ {
1866
+ title: "Create Menu",
1867
+ description:
1868
+ "Create a new navigation menu. The `name` is the stable identifier used " +
1869
+ "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
1870
+ "name shown in the admin. Add items afterwards with menu_set_items.",
1871
+ inputSchema: z.object({
1872
+ name: z
1873
+ .string()
1874
+ .regex(COLLECTION_SLUG_PATTERN)
1875
+ .describe("Stable identifier (lowercase letters, numbers, underscores)"),
1876
+ label: z.string().describe("Display name for the admin"),
1877
+ }),
1878
+ },
1879
+ async (args, extra) => {
1880
+ requireScope(extra, "menus:manage");
1881
+ requireRole(extra, Role.EDITOR);
1882
+ const ec = getEmDash(extra);
1883
+ try {
1884
+ const { handleMenuCreate } = await import("../api/handlers/menus.js");
1885
+ return unwrap(await handleMenuCreate(ec.db, { name: args.name, label: args.label }));
1886
+ } catch (error) {
1887
+ return respondHandlerError(error, "MENU_CREATE_ERROR");
1888
+ }
1889
+ },
1890
+ );
1891
+
1892
+ server.registerTool(
1893
+ "menu_update",
1894
+ {
1895
+ title: "Update Menu",
1896
+ description: "Update a menu's label. The `name` (stable identifier) cannot be changed.",
1897
+ inputSchema: z.object({
1898
+ name: z.string().describe("Menu name to update"),
1899
+ label: z.string().describe("New display label"),
1900
+ }),
1901
+ },
1902
+ async (args, extra) => {
1903
+ requireScope(extra, "menus:manage");
1904
+ requireRole(extra, Role.EDITOR);
1905
+ const ec = getEmDash(extra);
1906
+ try {
1907
+ const { handleMenuUpdate } = await import("../api/handlers/menus.js");
1908
+ return unwrap(await handleMenuUpdate(ec.db, args.name, { label: args.label }));
1909
+ } catch (error) {
1910
+ return respondHandlerError(error, "MENU_UPDATE_ERROR");
1911
+ }
1912
+ },
1913
+ );
1914
+
1915
+ server.registerTool(
1916
+ "menu_delete",
1917
+ {
1918
+ title: "Delete Menu",
1919
+ description: "Delete a menu. Items are also removed. Cannot be undone.",
1920
+ inputSchema: z.object({
1921
+ name: z.string().describe("Menu name to delete"),
1922
+ }),
1923
+ annotations: { destructiveHint: true },
1924
+ },
1925
+ async (args, extra) => {
1926
+ requireScope(extra, "menus:manage");
1927
+ requireRole(extra, Role.EDITOR);
1928
+ const ec = getEmDash(extra);
1929
+ try {
1930
+ const { handleMenuDelete } = await import("../api/handlers/menus.js");
1931
+ return unwrap(await handleMenuDelete(ec.db, args.name));
1932
+ } catch (error) {
1933
+ return respondHandlerError(error, "MENU_DELETE_ERROR");
1934
+ }
1935
+ },
1936
+ );
1937
+
1938
+ server.registerTool(
1939
+ "menu_set_items",
1940
+ {
1941
+ title: "Set Menu Items",
1942
+ description:
1943
+ "Replace the entire item list of a menu in one call. This is atomic: the " +
1944
+ "existing items are deleted and the new list is inserted in the order " +
1945
+ "provided. Use this rather than per-item add/remove tools so the resulting " +
1946
+ "order and parent links are unambiguous.",
1947
+ inputSchema: z.object({
1948
+ name: z.string().describe("Menu name to update"),
1949
+ items: z
1950
+ .array(
1951
+ z.object({
1952
+ label: z.string().describe("Item display text"),
1953
+ type: z
1954
+ .enum(["custom", "page", "post", "taxonomy", "collection"])
1955
+ .describe("Item kind"),
1956
+ customUrl: z
1957
+ .string()
1958
+ .optional()
1959
+ .describe("URL for type='custom' items (ignored otherwise)"),
1960
+ referenceCollection: z
1961
+ .string()
1962
+ .optional()
1963
+ .describe("Target collection slug for content references"),
1964
+ referenceId: z.string().optional().describe("Target content/term ID for references"),
1965
+ titleAttr: z.string().optional().describe("HTML title attribute"),
1966
+ target: z.string().optional().describe("HTML target attribute, e.g. '_blank'"),
1967
+ cssClasses: z.string().optional().describe("Space-separated CSS classes"),
1968
+ /**
1969
+ * Items are positioned by array index, but parents may be referenced
1970
+ * by their array index — items with `parentIndex` set are nested under
1971
+ * the item at that position. Items without `parentIndex` are top-level.
1972
+ */
1973
+ parentIndex: z
1974
+ .number()
1975
+ .int()
1976
+ .nonnegative()
1977
+ .optional()
1978
+ .describe(
1979
+ "Array index of the parent item (must be earlier in the list). Omit for top-level items.",
1980
+ ),
1981
+ }),
1982
+ )
1983
+ .describe("Ordered list of menu items"),
1984
+ }),
1985
+ },
1986
+ async (args, extra) => {
1987
+ requireScope(extra, "menus:manage");
1988
+ requireRole(extra, Role.EDITOR);
1989
+ const ec = getEmDash(extra);
1990
+ try {
1991
+ const { handleMenuSetItems } = await import("../api/handlers/menus.js");
1992
+ return unwrap(await handleMenuSetItems(ec.db, args.name, args.items));
1993
+ } catch (error) {
1994
+ return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
1510
1995
  }
1511
1996
  },
1512
1997
  );
@@ -1566,7 +2051,10 @@ export function createMcpServer(): McpServer {
1566
2051
  }
1567
2052
  const revItem = revision.data?.item;
1568
2053
  if (!revItem?.collection || !revItem?.entryId) {
1569
- return errorResult("Revision is missing collection or entry reference");
2054
+ return respondError(
2055
+ "VALIDATION_ERROR",
2056
+ "Revision is missing collection or entry reference",
2057
+ );
1570
2058
  }
1571
2059
 
1572
2060
  // Fetch the content entry to check ownership
@@ -1585,5 +2073,90 @@ export function createMcpServer(): McpServer {
1585
2073
  },
1586
2074
  );
1587
2075
 
2076
+ // =====================================================================
2077
+ // Settings tools
2078
+ // =====================================================================
2079
+
2080
+ server.registerTool(
2081
+ "settings_get",
2082
+ {
2083
+ title: "Get Site Settings",
2084
+ description:
2085
+ "Get all site-wide settings (title, tagline, logo, favicon, URL, " +
2086
+ "date/time formatting, social links, SEO defaults). Media references " +
2087
+ "(logo, favicon, defaultOgImage) include resolved URLs. Unset values " +
2088
+ "are omitted from the response.",
2089
+ inputSchema: z.object({}),
2090
+ annotations: { readOnlyHint: true },
2091
+ },
2092
+ async (_args, extra) => {
2093
+ requireScope(extra, "settings:read");
2094
+ requireRole(extra, Role.EDITOR);
2095
+ const ec = getEmDash(extra);
2096
+ try {
2097
+ const { handleSettingsGet } = await import("../api/handlers/settings.js");
2098
+ return unwrap(await handleSettingsGet(ec.db, ec.storage));
2099
+ } catch (error) {
2100
+ return respondHandlerError(error, "SETTINGS_READ_ERROR");
2101
+ }
2102
+ },
2103
+ );
2104
+
2105
+ server.registerTool(
2106
+ "settings_update",
2107
+ {
2108
+ title: "Update Site Settings",
2109
+ description:
2110
+ "Update one or more site-wide settings. This is a partial update: only " +
2111
+ "the fields provided are changed; omitted fields are left as-is. Returns " +
2112
+ "the full settings object after the update. To set a media reference " +
2113
+ "(logo, favicon, seo.defaultOgImage), pass an object with `mediaId` " +
2114
+ "(and optional `alt`) — the media item must already exist (use " +
2115
+ "media_create first).",
2116
+ inputSchema: z.object({
2117
+ title: z.string().optional().describe("Site title"),
2118
+ tagline: z.string().optional().describe("Site tagline / short description"),
2119
+ logo: settingsMediaReferenceSchema
2120
+ .optional()
2121
+ .describe("Logo media reference ({ mediaId, alt? })"),
2122
+ favicon: settingsMediaReferenceSchema
2123
+ .optional()
2124
+ .describe("Favicon media reference ({ mediaId, alt? })"),
2125
+ url: z
2126
+ .union([
2127
+ z
2128
+ .string()
2129
+ .url()
2130
+ .refine((u) => HTTP_SCHEME_PATTERN.test(u), "URL must use http or https"),
2131
+ z.literal(""),
2132
+ ])
2133
+ .optional()
2134
+ .describe("Canonical site URL (http or https). Empty string clears it."),
2135
+ postsPerPage: z
2136
+ .number()
2137
+ .int()
2138
+ .min(1)
2139
+ .max(100)
2140
+ .optional()
2141
+ .describe("Default page size for content listings"),
2142
+ dateFormat: z.string().optional().describe("Date format token string"),
2143
+ timezone: z.string().optional().describe("IANA timezone identifier"),
2144
+ social: settingsSocialSchema.optional().describe("Social handles / URLs"),
2145
+ seo: settingsSeoSchema.optional().describe("Site-wide SEO defaults"),
2146
+ }),
2147
+ },
2148
+ async (args, extra) => {
2149
+ requireScope(extra, "settings:manage");
2150
+ requireRole(extra, Role.ADMIN);
2151
+ const ec = getEmDash(extra);
2152
+ try {
2153
+ const { handleSettingsUpdate } = await import("../api/handlers/settings.js");
2154
+ return unwrap(await handleSettingsUpdate(ec.db, ec.storage, args));
2155
+ } catch (error) {
2156
+ return respondHandlerError(error, "SETTINGS_UPDATE_ERROR");
2157
+ }
2158
+ },
2159
+ );
2160
+
1588
2161
  return server;
1589
2162
  }