emdash 0.6.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.
- package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
- package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
- package/dist/{apply-B4MsLM-w.mjs → apply-x0eMK1lX.mjs} +186 -28
- package/dist/apply-x0eMK1lX.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +92 -17
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +22 -2
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.mjs +7 -2
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +263 -74
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +25 -8
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
- package/dist/byline-Chbr2GoP.mjs.map +1 -0
- package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
- package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
- package/dist/cli/index.mjs +17 -13
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{content-BsBoyj8G.mjs → content-BcQPYxdV.mjs} +39 -15
- package/dist/content-BcQPYxdV.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
- package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
- package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
- package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
- package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
- package/dist/error-zG5T1UGA.mjs.map +1 -0
- package/dist/{index-BYv0mB9g.d.mts → index-DIb-CzNx.d.mts} +232 -15
- package/dist/index-DIb-CzNx.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +23 -21
- package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
- package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
- package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
- package/dist/loader-CndGj8kM.mjs.map +1 -0
- package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
- package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/media/local-runtime.mjs +2 -2
- package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
- package/dist/media-D8FbNsl0.mjs.map +1 -0
- package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
- package/dist/mode-BnAOqItE.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-Bk_3vKvU.mjs → query-fqEdLFms.mjs} +9 -9
- package/dist/{query-Bk_3vKvU.mjs.map → query-fqEdLFms.mjs.map} +1 -1
- package/dist/{redirect-7lGhLBNZ.mjs → redirect-D_pshWdf.mjs} +69 -13
- package/dist/redirect-D_pshWdf.mjs.map +1 -0
- package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
- package/dist/registry-C3Mr0ODu.mjs.map +1 -0
- package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
- package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
- package/dist/{runner-Fl2NcUUz.d.mts → runner-OURCaApa.d.mts} +2 -2
- package/dist/{runner-Fl2NcUUz.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
- package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
- package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +2 -2
- package/dist/{search-DI4bM2w9.mjs → search-BoZYFuUk.mjs} +339 -102
- package/dist/search-BoZYFuUk.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +12 -12
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +4 -4
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-B4IAshV8.mjs} +5 -5
- package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
- package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
- package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
- package/dist/types-BIgulNsW.mjs +68 -0
- package/dist/types-BIgulNsW.mjs.map +1 -0
- package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
- package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
- package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
- package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
- package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
- package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
- package/dist/{types-8xrvl_68.d.mts → types-CS8FIX7L.d.mts} +10 -1
- package/dist/{types-8xrvl_68.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
- package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
- package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
- package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
- package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
- package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
- package/dist/types-i36XcA_X.d.mts.map +1 -0
- package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
- package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
- package/dist/{validate-CaLH1Ia2.d.mts → validate-DHxmpFJt.d.mts} +4 -4
- package/dist/{validate-CaLH1Ia2.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
- package/dist/validation-C-ZpN2GI.mjs +144 -0
- package/dist/validation-C-ZpN2GI.mjs.map +1 -0
- package/dist/version-DJrV1K0M.mjs +7 -0
- package/dist/{version-Uaf2ynPX.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
- package/dist/zod-generator-CpwccCIv.mjs +132 -0
- package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
- package/package.json +19 -6
- package/src/api/auth-storage.ts +37 -0
- package/src/api/error.ts +6 -0
- package/src/api/errors.ts +8 -0
- package/src/api/handlers/comments.ts +13 -0
- package/src/api/handlers/content.ts +124 -3
- package/src/api/handlers/index.ts +2 -0
- package/src/api/handlers/media.ts +8 -1
- package/src/api/handlers/menus.ts +160 -21
- package/src/api/handlers/redirects.ts +16 -3
- package/src/api/handlers/sections.ts +8 -1
- package/src/api/handlers/taxonomies.ts +128 -16
- package/src/api/handlers/validation.ts +212 -0
- package/src/api/openapi/document.ts +4 -1
- package/src/api/public-url.ts +6 -3
- package/src/api/route-utils.ts +14 -0
- package/src/api/schemas/common.ts +1 -1
- package/src/api/schemas/content.ts +8 -0
- package/src/api/schemas/setup.ts +8 -0
- package/src/api/schemas/widgets.ts +12 -10
- package/src/api/setup-complete.ts +40 -0
- package/src/astro/integration/font-provider.ts +3 -1
- package/src/astro/integration/index.ts +15 -2
- package/src/astro/integration/routes.ts +28 -0
- package/src/astro/integration/runtime.ts +74 -2
- package/src/astro/integration/virtual-modules.ts +41 -0
- package/src/astro/integration/vite-config.ts +43 -12
- package/src/astro/middleware/auth.ts +21 -0
- package/src/astro/middleware.ts +18 -1
- package/src/astro/routes/PluginRegistry.tsx +10 -1
- package/src/astro/routes/admin.astro +14 -7
- package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
- package/src/astro/routes/api/auth/mode.ts +57 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
- package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
- package/src/astro/routes/api/auth/passkey/options.ts +2 -1
- package/src/astro/routes/api/auth/signup/request.ts +26 -8
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
- package/src/astro/routes/api/content/[collection]/index.ts +20 -10
- package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
- package/src/astro/routes/api/import/wordpress/media.ts +2 -7
- package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
- package/src/astro/routes/api/manifest.ts +7 -0
- package/src/astro/routes/api/oauth/device/code.ts +2 -1
- package/src/astro/routes/api/oauth/device/token.ts +2 -1
- package/src/astro/routes/api/settings/email.ts +4 -9
- package/src/astro/routes/api/setup/admin-verify.ts +30 -5
- package/src/astro/routes/api/setup/admin.ts +38 -8
- package/src/astro/routes/api/setup/index.ts +7 -4
- package/src/astro/routes/api/setup/status.ts +3 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
- package/src/astro/routes/api/widget-areas/[name].ts +4 -1
- package/src/astro/routes/api/widget-areas/index.ts +4 -1
- package/src/astro/types.ts +18 -0
- package/src/auth/mode.ts +15 -3
- package/src/auth/providers/github-admin.tsx +29 -0
- package/src/auth/providers/github.ts +31 -0
- package/src/auth/providers/google-admin.tsx +44 -0
- package/src/auth/providers/google.ts +31 -0
- package/src/auth/rate-limit.ts +50 -22
- package/src/auth/setup-nonce.ts +22 -0
- package/src/auth/trusted-proxy.ts +92 -0
- package/src/auth/types.ts +114 -4
- package/src/cli/commands/bundle.ts +3 -1
- package/src/components/EmDashImage.astro +7 -6
- package/src/components/Gallery.astro +5 -3
- package/src/components/Image.astro +8 -3
- package/src/components/InlinePortableTextEditor.tsx +2 -1
- package/src/components/LiveSearch.astro +5 -14
- package/src/database/migrations/035_bounded_404_log.ts +112 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/audit.ts +6 -8
- package/src/database/repositories/byline.ts +6 -8
- package/src/database/repositories/comment.ts +12 -16
- package/src/database/repositories/content.ts +79 -40
- package/src/database/repositories/index.ts +1 -1
- package/src/database/repositories/media.ts +10 -13
- package/src/database/repositories/options.ts +25 -0
- package/src/database/repositories/plugin-storage.ts +4 -6
- package/src/database/repositories/redirect.ts +123 -24
- package/src/database/repositories/taxonomy.ts +14 -3
- package/src/database/repositories/types.ts +57 -8
- package/src/database/repositories/user.ts +6 -8
- package/src/database/types.ts +9 -0
- package/src/emdash-runtime.ts +309 -91
- package/src/import/registry.ts +4 -3
- package/src/import/ssrf.ts +253 -12
- package/src/index.ts +5 -1
- package/src/loader.ts +6 -5
- package/src/mcp/server.ts +753 -107
- package/src/media/normalize.ts +1 -1
- package/src/media/url.ts +78 -0
- package/src/plugins/context.ts +15 -3
- package/src/plugins/email-console.ts +10 -3
- package/src/plugins/hooks.ts +11 -0
- package/src/plugins/manager.ts +6 -0
- package/src/plugins/manifest-schema.ts +12 -0
- package/src/plugins/request-meta.ts +66 -15
- package/src/plugins/routes.ts +3 -1
- package/src/plugins/types.ts +23 -2
- package/src/query.ts +1 -1
- package/src/request-cache.ts +3 -0
- package/src/schema/registry.ts +41 -5
- package/src/search/fts-manager.ts +0 -2
- package/src/search/query.ts +111 -26
- package/src/search/types.ts +8 -1
- package/src/sections/index.ts +7 -9
- package/src/seed/apply.ts +26 -0
- package/src/storage/s3.ts +12 -6
- package/src/virtual-modules.d.ts +21 -1
- package/src/visual-editing/toolbar.ts +6 -1
- package/src/widgets/index.ts +1 -1
- package/dist/apply-B4MsLM-w.mjs.map +0 -1
- package/dist/byline-C4OVd8b3.mjs.map +0 -1
- package/dist/content-BsBoyj8G.mjs.map +0 -1
- package/dist/error-CiYn9yDu.mjs.map +0 -1
- package/dist/index-BYv0mB9g.d.mts.map +0 -1
- package/dist/loader-DeiBJEMe.mjs.map +0 -1
- package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
- package/dist/media-DqHVh136.mjs.map +0 -1
- package/dist/mode-CpNnGkPz.mjs.map +0 -1
- package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
- package/dist/registry-Ci3WxVAr.mjs.map +0 -1
- package/dist/request-cache-DiR961CV.mjs.map +0 -1
- package/dist/runner-Cd-_WyDo.mjs.map +0 -1
- package/dist/search-DI4bM2w9.mjs.map +0 -1
- package/dist/types-CMMN0pNg.mjs +0 -31
- package/dist/types-CMMN0pNg.mjs.map +0 -1
- package/dist/types-DgrIP0tF.d.mts.map +0 -1
- package/dist/version-Uaf2ynPX.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 = {
|
|
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
|
-
*
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
54
|
-
|
|
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
|
|
116
|
+
content: [{ type: "text", text }],
|
|
117
|
+
isError: true,
|
|
118
|
+
_meta: meta,
|
|
58
119
|
};
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
/**
|
|
62
|
-
*
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -84,6 +245,15 @@ interface EmDashExtra {
|
|
|
84
245
|
tokenScopes?: string[];
|
|
85
246
|
}
|
|
86
247
|
|
|
248
|
+
function isPublished(t: unknown): boolean {
|
|
249
|
+
return (
|
|
250
|
+
typeof t === "object" &&
|
|
251
|
+
t !== null &&
|
|
252
|
+
"status" in t &&
|
|
253
|
+
(t as Record<string, unknown>).status === "published"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
87
257
|
function getExtra(extra: { authInfo?: { extra?: Record<string, unknown> } }): EmDashExtra {
|
|
88
258
|
const payload = extra.authInfo?.extra as EmDashExtra | undefined;
|
|
89
259
|
if (!payload?.emdash) {
|
|
@@ -109,7 +279,7 @@ function requireScope(
|
|
|
109
279
|
): void {
|
|
110
280
|
const payload = getExtra(extra);
|
|
111
281
|
if (payload.tokenScopes && !hasScope(payload.tokenScopes, scope)) {
|
|
112
|
-
throw new
|
|
282
|
+
throw new EmDashAuthError(`Insufficient scope: requires ${scope}`, "INSUFFICIENT_SCOPE");
|
|
113
283
|
}
|
|
114
284
|
}
|
|
115
285
|
|
|
@@ -126,7 +296,33 @@ function requireRole(
|
|
|
126
296
|
): void {
|
|
127
297
|
const payload = getExtra(extra);
|
|
128
298
|
if (payload.userRole < minRole) {
|
|
129
|
-
throw new
|
|
299
|
+
throw new EmDashAuthError(
|
|
300
|
+
"Insufficient permissions for this operation",
|
|
301
|
+
"INSUFFICIENT_PERMISSIONS",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether the current user may read non-published content (drafts, scheduled,
|
|
308
|
+
* trashed, revisions, compare). SUBSCRIBER may hold content:read for
|
|
309
|
+
* member-only published content but must not see drafts.
|
|
310
|
+
*/
|
|
311
|
+
function canReadDrafts(extra: { authInfo?: { extra?: Record<string, unknown> } }): boolean {
|
|
312
|
+
const payload = getExtra(extra);
|
|
313
|
+
return hasPermission({ role: payload.userRole }, "content:read_drafts");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Throw if the current user cannot read non-published content. Used by
|
|
318
|
+
* editor-only views (revisions, compare, trash, preview-url).
|
|
319
|
+
*/
|
|
320
|
+
function requireDraftAccess(extra: { authInfo?: { extra?: Record<string, unknown> } }): void {
|
|
321
|
+
if (!canReadDrafts(extra)) {
|
|
322
|
+
throw new EmDashAuthError(
|
|
323
|
+
"Insufficient permissions for this operation",
|
|
324
|
+
"INSUFFICIENT_PERMISSIONS",
|
|
325
|
+
);
|
|
130
326
|
}
|
|
131
327
|
}
|
|
132
328
|
|
|
@@ -146,7 +342,10 @@ function requireOwnership(
|
|
|
146
342
|
const payload = getExtra(extra);
|
|
147
343
|
const user = { id: payload.userId, role: payload.userRole };
|
|
148
344
|
if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
|
|
149
|
-
throw new
|
|
345
|
+
throw new EmDashAuthError(
|
|
346
|
+
"Insufficient permissions for this operation",
|
|
347
|
+
"INSUFFICIENT_PERMISSIONS",
|
|
348
|
+
);
|
|
150
349
|
}
|
|
151
350
|
}
|
|
152
351
|
|
|
@@ -154,26 +353,18 @@ function requireOwnership(
|
|
|
154
353
|
* Extract the author ID from a content handler response.
|
|
155
354
|
*
|
|
156
355
|
* Content handlers return `{ item: { id, authorId, ... }, _rev? }`.
|
|
157
|
-
* 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.
|
|
158
361
|
*/
|
|
159
362
|
function extractContentAuthorId(data: unknown): string {
|
|
160
|
-
if (!data || typeof data !== "object")
|
|
161
|
-
throw new McpError(
|
|
162
|
-
ErrorCode.InternalError,
|
|
163
|
-
"Cannot determine content ownership: no data returned",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
363
|
+
if (!data || typeof data !== "object") return "";
|
|
166
364
|
const obj = data as Record<string, unknown>;
|
|
167
365
|
const item =
|
|
168
366
|
obj.item && typeof obj.item === "object" ? (obj.item as Record<string, unknown>) : obj;
|
|
169
|
-
|
|
170
|
-
if (!authorId) {
|
|
171
|
-
throw new McpError(
|
|
172
|
-
ErrorCode.InternalError,
|
|
173
|
-
"Cannot determine content ownership: content has no authorId",
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
return authorId;
|
|
367
|
+
return typeof item?.authorId === "string" ? item.authorId : "";
|
|
177
368
|
}
|
|
178
369
|
|
|
179
370
|
/**
|
|
@@ -198,6 +389,34 @@ export function createMcpServer(): McpServer {
|
|
|
198
389
|
{ capabilities: { logging: {} } },
|
|
199
390
|
);
|
|
200
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
|
+
|
|
201
420
|
// =====================================================================
|
|
202
421
|
// Content tools
|
|
203
422
|
// =====================================================================
|
|
@@ -224,7 +443,12 @@ export function createMcpServer(): McpServer {
|
|
|
224
443
|
.max(100)
|
|
225
444
|
.optional()
|
|
226
445
|
.describe("Max items to return (default 50, max 100)"),
|
|
227
|
-
cursor: z
|
|
446
|
+
cursor: z
|
|
447
|
+
.string()
|
|
448
|
+
.min(1)
|
|
449
|
+
.max(2048)
|
|
450
|
+
.optional()
|
|
451
|
+
.describe("Pagination cursor from a previous response"),
|
|
228
452
|
orderBy: z
|
|
229
453
|
.string()
|
|
230
454
|
.optional()
|
|
@@ -240,9 +464,12 @@ export function createMcpServer(): McpServer {
|
|
|
240
464
|
async (args, extra) => {
|
|
241
465
|
requireScope(extra, "content:read");
|
|
242
466
|
const ec = getEmDash(extra);
|
|
467
|
+
// Subscribers must only see published content; force the status
|
|
468
|
+
// filter regardless of caller-supplied value.
|
|
469
|
+
const status = canReadDrafts(extra) ? args.status : "published";
|
|
243
470
|
return unwrap(
|
|
244
471
|
await ec.handleContentList(args.collection, {
|
|
245
|
-
status
|
|
472
|
+
status,
|
|
246
473
|
limit: args.limit,
|
|
247
474
|
cursor: args.cursor,
|
|
248
475
|
orderBy: args.orderBy,
|
|
@@ -276,7 +503,29 @@ export function createMcpServer(): McpServer {
|
|
|
276
503
|
async (args, extra) => {
|
|
277
504
|
requireScope(extra, "content:read");
|
|
278
505
|
const ec = getEmDash(extra);
|
|
279
|
-
|
|
506
|
+
const result = await ec.handleContentGet(args.collection, args.id, args.locale);
|
|
507
|
+
// Hide non-published items from users without draft access. Return a
|
|
508
|
+
// not-found error so subscribers can't enumerate draft IDs by status.
|
|
509
|
+
if (result.success && !canReadDrafts(extra)) {
|
|
510
|
+
const data =
|
|
511
|
+
result.data && typeof result.data === "object"
|
|
512
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
|
|
513
|
+
(result.data as Record<string, unknown>)
|
|
514
|
+
: undefined;
|
|
515
|
+
const item =
|
|
516
|
+
data?.item && typeof data.item === "object"
|
|
517
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof check
|
|
518
|
+
(data.item as Record<string, unknown>)
|
|
519
|
+
: undefined;
|
|
520
|
+
const status = typeof item?.status === "string" ? item.status : null;
|
|
521
|
+
if (status !== "published") {
|
|
522
|
+
return unwrap({
|
|
523
|
+
success: false,
|
|
524
|
+
error: { code: "NOT_FOUND", message: `Content item not found: ${args.id}` },
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return unwrap(result);
|
|
280
529
|
},
|
|
281
530
|
);
|
|
282
531
|
|
|
@@ -334,9 +583,9 @@ export function createMcpServer(): McpServer {
|
|
|
334
583
|
if (args.status === "published") {
|
|
335
584
|
const user = { id: userId, role: getExtra(extra).userRole };
|
|
336
585
|
if (!hasPermission(user, "content:publish_own" as Permission)) {
|
|
337
|
-
throw new
|
|
338
|
-
ErrorCode.InvalidRequest,
|
|
586
|
+
throw new EmDashAuthError(
|
|
339
587
|
"Insufficient permissions: publishing requires content:publish_own",
|
|
588
|
+
"INSUFFICIENT_PERMISSIONS",
|
|
340
589
|
);
|
|
341
590
|
}
|
|
342
591
|
const result = await emdash.handleContentCreate(args.collection, {
|
|
@@ -660,6 +909,40 @@ export function createMcpServer(): McpServer {
|
|
|
660
909
|
},
|
|
661
910
|
);
|
|
662
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
|
+
|
|
663
946
|
server.registerTool(
|
|
664
947
|
"content_compare",
|
|
665
948
|
{
|
|
@@ -676,6 +959,7 @@ export function createMcpServer(): McpServer {
|
|
|
676
959
|
},
|
|
677
960
|
async (args, extra) => {
|
|
678
961
|
requireScope(extra, "content:read");
|
|
962
|
+
requireDraftAccess(extra);
|
|
679
963
|
const ec = getEmDash(extra);
|
|
680
964
|
return unwrap(await ec.handleContentCompare(args.collection, args.id));
|
|
681
965
|
},
|
|
@@ -727,12 +1011,13 @@ export function createMcpServer(): McpServer {
|
|
|
727
1011
|
inputSchema: z.object({
|
|
728
1012
|
collection: z.string().describe("Collection slug"),
|
|
729
1013
|
limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
|
|
730
|
-
cursor: z.string().optional().describe("Pagination cursor"),
|
|
1014
|
+
cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
|
|
731
1015
|
}),
|
|
732
1016
|
annotations: { readOnlyHint: true },
|
|
733
1017
|
},
|
|
734
1018
|
async (args, extra) => {
|
|
735
1019
|
requireScope(extra, "content:read");
|
|
1020
|
+
requireDraftAccess(extra);
|
|
736
1021
|
const ec = getEmDash(extra);
|
|
737
1022
|
return unwrap(
|
|
738
1023
|
await ec.handleContentListTrashed(args.collection, {
|
|
@@ -780,7 +1065,23 @@ export function createMcpServer(): McpServer {
|
|
|
780
1065
|
async (args, extra) => {
|
|
781
1066
|
requireScope(extra, "content:read");
|
|
782
1067
|
const ec = getEmDash(extra);
|
|
783
|
-
|
|
1068
|
+
const result = await ec.handleContentTranslations(args.collection, args.id);
|
|
1069
|
+
// Filter out non-published translations for users without draft
|
|
1070
|
+
// access so a subscriber can't enumerate locales that aren't yet live.
|
|
1071
|
+
if (result.success && !canReadDrafts(extra)) {
|
|
1072
|
+
const data =
|
|
1073
|
+
result.data && typeof result.data === "object"
|
|
1074
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
|
|
1075
|
+
(result.data as Record<string, unknown>)
|
|
1076
|
+
: undefined;
|
|
1077
|
+
const translations = Array.isArray(data?.translations) ? data.translations : [];
|
|
1078
|
+
const filtered = translations.filter(isPublished);
|
|
1079
|
+
return unwrap({
|
|
1080
|
+
success: true,
|
|
1081
|
+
data: { ...data, translations: filtered },
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
return unwrap(result);
|
|
784
1085
|
},
|
|
785
1086
|
);
|
|
786
1087
|
|
|
@@ -810,7 +1111,7 @@ export function createMcpServer(): McpServer {
|
|
|
810
1111
|
const items = await registry.listCollections();
|
|
811
1112
|
return jsonResult({ items });
|
|
812
1113
|
} catch (error) {
|
|
813
|
-
return
|
|
1114
|
+
return respondHandlerError(error, "SCHEMA_LIST_ERROR");
|
|
814
1115
|
}
|
|
815
1116
|
},
|
|
816
1117
|
);
|
|
@@ -843,11 +1144,11 @@ export function createMcpServer(): McpServer {
|
|
|
843
1144
|
const registry = new SchemaRegistry(ec.db);
|
|
844
1145
|
const collection = await registry.getCollectionWithFields(args.slug);
|
|
845
1146
|
if (!collection) {
|
|
846
|
-
return
|
|
1147
|
+
return respondError("NOT_FOUND", `Collection '${args.slug}' not found`);
|
|
847
1148
|
}
|
|
848
1149
|
return jsonResult(collection);
|
|
849
1150
|
} catch (error) {
|
|
850
|
-
return
|
|
1151
|
+
return respondHandlerError(error, "SCHEMA_GET_ERROR");
|
|
851
1152
|
}
|
|
852
1153
|
},
|
|
853
1154
|
);
|
|
@@ -890,12 +1191,14 @@ export function createMcpServer(): McpServer {
|
|
|
890
1191
|
labelSingular: args.labelSingular,
|
|
891
1192
|
description: args.description,
|
|
892
1193
|
icon: args.icon,
|
|
1194
|
+
// SchemaRegistry.createCollection now defaults `supports` to
|
|
1195
|
+
// ['drafts', 'revisions'] when undefined; pass through verbatim.
|
|
893
1196
|
supports: args.supports,
|
|
894
1197
|
});
|
|
895
1198
|
ec.invalidateManifest();
|
|
896
1199
|
return jsonResult(collection);
|
|
897
1200
|
} catch (error) {
|
|
898
|
-
return
|
|
1201
|
+
return respondHandlerError(error, "SCHEMA_CREATE_ERROR");
|
|
899
1202
|
}
|
|
900
1203
|
},
|
|
901
1204
|
);
|
|
@@ -927,7 +1230,7 @@ export function createMcpServer(): McpServer {
|
|
|
927
1230
|
ec.invalidateManifest();
|
|
928
1231
|
return jsonResult({ deleted: args.slug });
|
|
929
1232
|
} catch (error) {
|
|
930
|
-
return
|
|
1233
|
+
return respondHandlerError(error, "SCHEMA_DELETE_ERROR");
|
|
931
1234
|
}
|
|
932
1235
|
},
|
|
933
1236
|
);
|
|
@@ -1031,7 +1334,7 @@ export function createMcpServer(): McpServer {
|
|
|
1031
1334
|
ec.invalidateManifest();
|
|
1032
1335
|
return jsonResult(field);
|
|
1033
1336
|
} catch (error) {
|
|
1034
|
-
return
|
|
1337
|
+
return respondHandlerError(error, "FIELD_CREATE_ERROR");
|
|
1035
1338
|
}
|
|
1036
1339
|
},
|
|
1037
1340
|
);
|
|
@@ -1060,7 +1363,7 @@ export function createMcpServer(): McpServer {
|
|
|
1060
1363
|
ec.invalidateManifest();
|
|
1061
1364
|
return jsonResult({ deleted: args.fieldSlug, collection: args.collection });
|
|
1062
1365
|
} catch (error) {
|
|
1063
|
-
return
|
|
1366
|
+
return respondHandlerError(error, "FIELD_DELETE_ERROR");
|
|
1064
1367
|
}
|
|
1065
1368
|
},
|
|
1066
1369
|
);
|
|
@@ -1083,7 +1386,7 @@ export function createMcpServer(): McpServer {
|
|
|
1083
1386
|
.optional()
|
|
1084
1387
|
.describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"),
|
|
1085
1388
|
limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
|
|
1086
|
-
cursor: z.string().optional().describe("Pagination cursor"),
|
|
1389
|
+
cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
|
|
1087
1390
|
}),
|
|
1088
1391
|
annotations: { readOnlyHint: true },
|
|
1089
1392
|
},
|
|
@@ -1100,6 +1403,54 @@ export function createMcpServer(): McpServer {
|
|
|
1100
1403
|
},
|
|
1101
1404
|
);
|
|
1102
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
|
+
|
|
1103
1454
|
server.registerTool(
|
|
1104
1455
|
"media_get",
|
|
1105
1456
|
{
|
|
@@ -1233,7 +1584,7 @@ export function createMcpServer(): McpServer {
|
|
|
1233
1584
|
});
|
|
1234
1585
|
return jsonResult(results);
|
|
1235
1586
|
} catch (error) {
|
|
1236
|
-
return
|
|
1587
|
+
return respondHandlerError(error, "SEARCH_ERROR");
|
|
1237
1588
|
}
|
|
1238
1589
|
},
|
|
1239
1590
|
);
|
|
@@ -1260,7 +1611,7 @@ export function createMcpServer(): McpServer {
|
|
|
1260
1611
|
const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
|
|
1261
1612
|
return unwrap(await handleTaxonomyList(ec.db));
|
|
1262
1613
|
} catch (error) {
|
|
1263
|
-
return
|
|
1614
|
+
return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
|
|
1264
1615
|
}
|
|
1265
1616
|
},
|
|
1266
1617
|
);
|
|
@@ -1276,7 +1627,7 @@ export function createMcpServer(): McpServer {
|
|
|
1276
1627
|
inputSchema: z.object({
|
|
1277
1628
|
taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
|
|
1278
1629
|
limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
|
|
1279
|
-
cursor: z.string().optional().describe("Pagination cursor"),
|
|
1630
|
+
cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
|
|
1280
1631
|
}),
|
|
1281
1632
|
annotations: { readOnlyHint: true },
|
|
1282
1633
|
},
|
|
@@ -1292,24 +1643,47 @@ export function createMcpServer(): McpServer {
|
|
|
1292
1643
|
const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
|
|
1293
1644
|
.taxonomies;
|
|
1294
1645
|
const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
|
|
1295
|
-
if (!taxonomy) return
|
|
1646
|
+
if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
|
|
1296
1647
|
|
|
1297
1648
|
// Paginated term query via repository (avoids N+1 of handleTermList)
|
|
1298
1649
|
const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
|
|
1650
|
+
const { decodeCursor, encodeCursor, InvalidCursorError } =
|
|
1651
|
+
await import("../database/repositories/types.js");
|
|
1299
1652
|
const repo = new TaxonomyRepository(ec.db);
|
|
1300
1653
|
const limit = Math.min(args.limit ?? 50, 100);
|
|
1301
1654
|
const terms = await repo.findByName(args.taxonomy);
|
|
1302
1655
|
|
|
1303
|
-
// Manual
|
|
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.
|
|
1304
1662
|
let startIdx = 0;
|
|
1305
1663
|
if (args.cursor) {
|
|
1306
|
-
|
|
1307
|
-
|
|
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;
|
|
1308
1681
|
}
|
|
1309
1682
|
|
|
1310
1683
|
const page = terms.slice(startIdx, startIdx + limit);
|
|
1311
1684
|
const hasMore = startIdx + limit < terms.length;
|
|
1312
|
-
const
|
|
1685
|
+
const last = page.at(-1);
|
|
1686
|
+
const nextCursor = hasMore && last ? encodeCursor(last.label, last.id) : undefined;
|
|
1313
1687
|
|
|
1314
1688
|
return jsonResult({
|
|
1315
1689
|
items: page.map((t) => ({
|
|
@@ -1323,7 +1697,7 @@ export function createMcpServer(): McpServer {
|
|
|
1323
1697
|
nextCursor,
|
|
1324
1698
|
});
|
|
1325
1699
|
} catch (error) {
|
|
1326
|
-
return
|
|
1700
|
+
return respondHandlerError(error, "TAXONOMY_LIST_TERMS_ERROR");
|
|
1327
1701
|
}
|
|
1328
1702
|
},
|
|
1329
1703
|
);
|
|
@@ -1334,7 +1708,10 @@ export function createMcpServer(): McpServer {
|
|
|
1334
1708
|
title: "Create Taxonomy Term",
|
|
1335
1709
|
description:
|
|
1336
1710
|
"Create a new term in a taxonomy. For hierarchical taxonomies like " +
|
|
1337
|
-
"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.",
|
|
1338
1715
|
inputSchema: z.object({
|
|
1339
1716
|
taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
|
|
1340
1717
|
slug: z.string().describe("URL-safe identifier for the term"),
|
|
@@ -1344,7 +1721,7 @@ export function createMcpServer(): McpServer {
|
|
|
1344
1721
|
}),
|
|
1345
1722
|
},
|
|
1346
1723
|
async (args, extra) => {
|
|
1347
|
-
requireScope(extra, "
|
|
1724
|
+
requireScope(extra, "taxonomies:manage");
|
|
1348
1725
|
requireRole(extra, Role.EDITOR);
|
|
1349
1726
|
const ec = getEmDash(extra);
|
|
1350
1727
|
try {
|
|
@@ -1358,7 +1735,75 @@ export function createMcpServer(): McpServer {
|
|
|
1358
1735
|
}),
|
|
1359
1736
|
);
|
|
1360
1737
|
} catch (error) {
|
|
1361
|
-
return
|
|
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");
|
|
1362
1807
|
}
|
|
1363
1808
|
},
|
|
1364
1809
|
);
|
|
@@ -1382,20 +1827,10 @@ export function createMcpServer(): McpServer {
|
|
|
1382
1827
|
requireScope(extra, "content:read");
|
|
1383
1828
|
const ec = getEmDash(extra);
|
|
1384
1829
|
try {
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
.select([
|
|
1388
|
-
"id" as never,
|
|
1389
|
-
"name" as never,
|
|
1390
|
-
"label" as never,
|
|
1391
|
-
"created_at" as never,
|
|
1392
|
-
"updated_at" as never,
|
|
1393
|
-
])
|
|
1394
|
-
.orderBy("name" as never, "asc")
|
|
1395
|
-
.execute();
|
|
1396
|
-
return jsonResult(menus);
|
|
1830
|
+
const { handleMenuList } = await import("../api/handlers/menus.js");
|
|
1831
|
+
return unwrap(await handleMenuList(ec.db));
|
|
1397
1832
|
} catch (error) {
|
|
1398
|
-
return
|
|
1833
|
+
return respondHandlerError(error, "MENU_LIST_ERROR");
|
|
1399
1834
|
}
|
|
1400
1835
|
},
|
|
1401
1836
|
);
|
|
@@ -1417,24 +1852,146 @@ export function createMcpServer(): McpServer {
|
|
|
1417
1852
|
requireScope(extra, "content:read");
|
|
1418
1853
|
const ec = getEmDash(extra);
|
|
1419
1854
|
try {
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1855
|
+
const { handleMenuGet } = await import("../api/handlers/menus.js");
|
|
1856
|
+
return unwrap(await handleMenuGet(ec.db, args.name));
|
|
1857
|
+
} catch (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));
|
|
1436
1932
|
} catch (error) {
|
|
1437
|
-
return
|
|
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");
|
|
1438
1995
|
}
|
|
1439
1996
|
},
|
|
1440
1997
|
);
|
|
@@ -1460,6 +2017,7 @@ export function createMcpServer(): McpServer {
|
|
|
1460
2017
|
},
|
|
1461
2018
|
async (args, extra) => {
|
|
1462
2019
|
requireScope(extra, "content:read");
|
|
2020
|
+
requireDraftAccess(extra);
|
|
1463
2021
|
const ec = getEmDash(extra);
|
|
1464
2022
|
return unwrap(
|
|
1465
2023
|
await ec.handleRevisionList(args.collection, args.id, {
|
|
@@ -1493,7 +2051,10 @@ export function createMcpServer(): McpServer {
|
|
|
1493
2051
|
}
|
|
1494
2052
|
const revItem = revision.data?.item;
|
|
1495
2053
|
if (!revItem?.collection || !revItem?.entryId) {
|
|
1496
|
-
return
|
|
2054
|
+
return respondError(
|
|
2055
|
+
"VALIDATION_ERROR",
|
|
2056
|
+
"Revision is missing collection or entry reference",
|
|
2057
|
+
);
|
|
1497
2058
|
}
|
|
1498
2059
|
|
|
1499
2060
|
// Fetch the content entry to check ownership
|
|
@@ -1512,5 +2073,90 @@ export function createMcpServer(): McpServer {
|
|
|
1512
2073
|
},
|
|
1513
2074
|
);
|
|
1514
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
|
+
|
|
1515
2161
|
return server;
|
|
1516
2162
|
}
|